What if you could give Claude the ability to do something new, something specific to your workflow, in 30 minutes? Not by writing a prompt. By building a tool that Claude can call directly.
That is what building an MCP server gives you. You define a set of tools, each with a name, parameters, and a handler function. Claude discovers those tools automatically, decides when to use them based on the user's request, and calls them with the right arguments. You write the logic. Claude handles the orchestration.
This tutorial walks you through building a working MCP server from scratch. By the end, you will have two tools: one that converts markdown to HTML and one that saves documents to the filesystem. You will test them with the MCP Inspector and connect them to Claude Desktop.
What you will build: a document publishing MCP server
A local MCP server with two tools:
- convert_markdown: Takes markdown text and returns formatted HTML
- save_document: Takes a title and markdown content, saves it as an HTML file, and returns the file path
These tools are intentionally simple. The goal is to understand the MCP server architecture, not to build a production service. Once you understand the pattern, you can extend it to wrap any API, database, or service you work with.
Prerequisites
- Node.js 18 or later
- npm
- A text editor
- Claude Desktop (for the final integration step)
Verify your Node.js version:
node --version
# Should be v18.0.0 or later
How MCP servers work
Before writing code, it helps to understand the architecture.
An MCP server is a process that communicates with an AI client (Claude Desktop, claude.ai, Claude Code) using a structured protocol. The server declares what tools it offers. The client presents those tools to the AI model. When the model decides to use a tool, the client sends a request to the server, the server executes the handler, and the result flows back to the model.
The communication happens over a transport layer. For local servers (the most common case), the transport is stdio: the client launches the server as a child process and communicates via standard input and output. For remote servers, the transport is Streamable HTTP, which works over the network.
The official TypeScript SDK handles all the protocol details. You define tools and their handlers. The SDK manages the message framing, request routing, and response formatting.
If you want a conceptual overview of what MCP is and why it matters, read that first. This tutorial assumes you understand the basic concept and want to build something.
Step 1: Project setup
Create a new directory and initialize the project:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
Install the MCP SDK and a markdown parser:
npm install @modelcontextprotocol/sdk zod marked
npm install -D typescript @types/node
Three dependencies:
- @modelcontextprotocol/sdk: The official MCP server SDK. Handles protocol negotiation, tool registration, and transport.
- zod: Schema validation library. The SDK uses Zod to define tool parameter schemas.
- marked: A fast markdown-to-HTML parser. This is the "business logic" our tools will use.
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
Update package.json to add the build script and set the module type:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"my-mcp-server": "dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Create the source directory:
mkdir src
Step 2: Define your first tool
Create src/index.ts. This is the entire server.
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { marked } from "marked";
// Create the server instance
const server = new McpServer({
name: "my-document-server",
version: "1.0.0",
});
That is the foundation. An MCP server with a name and version, ready to register tools.
Now add the first tool:
// Tool 1: Convert markdown to HTML
server.tool(
"convert_markdown",
"Converts markdown text to formatted HTML. Supports headings, lists, tables, code blocks, and all standard markdown syntax.",
{
markdown: z.string().describe("The markdown text to convert"),
title: z
.string()
.optional()
.describe("Optional title for the HTML document"),
},
async ({ markdown, title }) => {
const html = await marked.parse(markdown);
const fullHtml = title
? `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #1a1a1a; }
h1, h2, h3 { margin-top: 1.5em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; font-weight: 600; }
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
</style>
</head>
<body>
${html}
</body>
</html>`
: html;
return {
content: [
{
type: "text" as const,
text: fullHtml,
},
],
};
}
);
Let's break down the server.tool() call. It takes four arguments:
- Name (
"convert_markdown"): The identifier Claude uses to call this tool. Keep it descriptive and snake_case. - Description: A plain-English explanation of what the tool does. Claude reads this to decide when to use the tool. Be specific. "Converts markdown" is vague. "Converts markdown text to formatted HTML with support for headings, lists, tables, and code blocks" tells Claude exactly what this tool can handle.
- Parameters: A Zod schema defining the input. Each field can have a
.describe()that helps Claude understand what to pass. The SDK automatically converts this to JSON Schema for the MCP protocol. - Handler: An async function that receives the validated parameters and returns a result. The result must be an object with a
contentarray containing text or image blocks.
Step 3: Add a second tool
Now add a tool that saves documents to the filesystem:
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
// Tool 2: Save a document as an HTML file
server.tool(
"save_document",
"Saves markdown content as a styled HTML file to the local filesystem. Returns the absolute file path. The file can be opened in any browser.",
{
title: z.string().describe("The document title (used as filename and page title)"),
markdown: z.string().describe("The markdown content to save"),
folder: z
.string()
.optional()
.describe("Subfolder name within the documents directory. Created if it does not exist."),
},
async ({ title, markdown, folder }) => {
// Create the output directory
const baseDir = join(homedir(), "mcp-documents");
const outputDir = folder ? join(baseDir, folder) : baseDir;
await mkdir(outputDir, { recursive: true });
// Convert markdown to HTML
const html = await marked.parse(markdown);
// Build the full HTML document
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #1a1a1a; }
h1 { border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { margin-top: 1.5em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; font-weight: 600; }
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
.metadata { color: #666; font-size: 0.9em; margin-bottom: 2em; }
</style>
</head>
<body>
<h1>${title}</h1>
<div class="metadata">Created: ${new Date().toISOString().split("T")[0]}</div>
${html}
</body>
</html>`;
// Generate a safe filename
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const filename = `${safeTitle}.html`;
const filePath = join(outputDir, filename);
// Write the file
await writeFile(filePath, fullHtml, "utf-8");
return {
content: [
{
type: "text" as const,
text: `Document saved successfully.\n\nFile: ${filePath}\nTitle: ${title}\nSize: ${fullHtml.length} bytes\n\nOpen in browser: file://${filePath}`,
},
],
};
}
);
This tool demonstrates a common MCP pattern: wrapping a local operation (filesystem write) behind a tool interface so the AI can perform it on the user's behalf. The same pattern works for database queries, API calls, email sending, or any other operation your workflow needs.
Step 4: Wire up the transport
Add the transport layer at the bottom of src/index.ts:
// Start the server with stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Two important details:
StdioServerTransportis the standard transport for local MCP servers. The client (Claude Desktop) launches your server as a child process and communicates over stdin/stdout. You do not need to manage ports, WebSockets, or HTTP.console.errorfor logging. Since stdout is used for MCP protocol messages, all logging must go to stderr. Usingconsole.login an MCP server will corrupt the protocol stream and cause connection failures.
Step 5: Complete source code
Here is the full src/index.ts for reference:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { marked } from "marked";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
const server = new McpServer({
name: "my-document-server",
version: "1.0.0",
});
// Tool 1: Convert markdown to HTML
server.tool(
"convert_markdown",
"Converts markdown text to formatted HTML. Supports headings, lists, tables, code blocks, and all standard markdown syntax.",
{
markdown: z.string().describe("The markdown text to convert"),
title: z
.string()
.optional()
.describe("Optional title for the HTML document"),
},
async ({ markdown, title }) => {
const html = await marked.parse(markdown);
const fullHtml = title
? `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #1a1a1a; }
h1, h2, h3 { margin-top: 1.5em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; font-weight: 600; }
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
</style>
</head>
<body>
${html}
</body>
</html>`
: html;
return {
content: [{ type: "text" as const, text: fullHtml }],
};
}
);
// Tool 2: Save a document as an HTML file
server.tool(
"save_document",
"Saves markdown content as a styled HTML file to the local filesystem. Returns the absolute file path. The file can be opened in any browser.",
{
title: z.string().describe("The document title (used as filename and page title)"),
markdown: z.string().describe("The markdown content to save"),
folder: z
.string()
.optional()
.describe("Subfolder name within the documents directory. Created if it does not exist."),
},
async ({ title, markdown, folder }) => {
const baseDir = join(homedir(), "mcp-documents");
const outputDir = folder ? join(baseDir, folder) : baseDir;
await mkdir(outputDir, { recursive: true });
const html = await marked.parse(markdown);
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #1a1a1a; }
h1 { border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { margin-top: 1.5em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; font-weight: 600; }
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
.metadata { color: #666; font-size: 0.9em; margin-bottom: 2em; }
</style>
</head>
<body>
<h1>${title}</h1>
<div class="metadata">Created: ${new Date().toISOString().split("T")[0]}</div>
${html}
</body>
</html>`;
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const filename = `${safeTitle}.html`;
const filePath = join(outputDir, filename);
await writeFile(filePath, fullHtml, "utf-8");
return {
content: [
{
type: "text" as const,
text: `Document saved successfully.\n\nFile: ${filePath}\nTitle: ${title}\nSize: ${fullHtml.length} bytes\n\nOpen in browser: file://${filePath}`,
},
],
};
}
);
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Build the project:
npm run build
If the build succeeds, you will have a dist/index.js file ready to run.
Step 6: Test with MCP Inspector
The MCP Inspector is a browser-based debugging tool that lets you test your server without connecting to Claude. It visualizes the tools your server exposes and lets you call them interactively.
npx @modelcontextprotocol/inspector node dist/index.js
This launches the Inspector at http://127.0.0.1:6274. Open it in your browser.
You should see:
- Your server name ("my-document-server") in the connection panel
- Two tools listed:
convert_markdownandsave_document - A parameter form for each tool where you can enter test values
Test convert_markdown:
- Set
markdownto:# Hello World\n\nThis is a **test** with a [link](https://example.com).\n\n- Item one\n- Item two - Set
titleto:Test Document - Click "Call Tool"
- The result should contain a complete HTML document with styled headings, bold text, a link, and a list
Test save_document:
- Set
titleto:My First MCP Document - Set
markdownto:# Meeting Notes\n\n## Decisions\n\n- Approved the Q2 budget\n- Delayed the API migration to March\n\n## Action Items\n\n| Owner | Task | Due |\n|-------|------|-----|\n| Alice | Update roadmap | Feb 28 |\n| Bob | Draft migration plan | Mar 5 | - Click "Call Tool"
- The result should show the file path. Open that file in your browser to verify the output.
If both tools work in the Inspector, your server is ready for Claude.
Step 7: Connect to Claude Desktop
Open your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server to the mcpServers section:
{
"mcpServers": {
"my-document-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Replace /absolute/path/to/my-mcp-server/dist/index.js with the actual absolute path to your compiled server file.
Restart Claude Desktop. In a new conversation, you should see a hammer icon indicating that MCP tools are available. Click it to verify your two tools are listed.
Now try these prompts:
"Convert this to HTML: # Project Update\n\n## What shipped\n- User authentication\n- API rate limiting\n\n## What's next\n- Dashboard redesign"
Claude will call convert_markdown and return the formatted HTML.
"Save a document called 'Weekly Standup' with sections for what I did, what I'm doing, and blockers. Fill in some example content."
Claude will generate the content, call save_document, and return the file path. You can open the file in your browser to see the styled document.
"Save a meeting notes document in a 'team' folder with today's decisions about the pricing change."
Claude will call save_document with the folder parameter set to "team", creating the subdirectory automatically.
Understanding the SDK patterns
Now that you have a working server, here are the key patterns to understand for building more sophisticated tools.
Tool parameter schemas
The Zod schemas you pass to server.tool() define what parameters Claude can provide. The SDK supports all Zod types:
{
// Required string
name: z.string().describe("The user's full name"),
// Optional number with default
limit: z.number().default(10).describe("Maximum results to return"),
// Enum (Claude sees the allowed values)
format: z.enum(["html", "pdf", "markdown"]).describe("Output format"),
// Boolean
includeMetadata: z.boolean().optional().describe("Whether to include creation date"),
// Array of strings
tags: z.array(z.string()).optional().describe("Tags to apply to the document"),
}
The .describe() calls are important. Claude reads these descriptions to understand what each parameter expects. A well-described schema reduces the chance of Claude passing incorrect values.
Error handling
Tools should return errors gracefully rather than throwing exceptions:
server.tool(
"read_document",
"Reads a document by filename",
{ filename: z.string() },
async ({ filename }) => {
try {
const content = await readFile(
join(homedir(), "mcp-documents", filename),
"utf-8"
);
return {
content: [{ type: "text" as const, text: content }],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Error reading document: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
}
);
The isError: true flag tells the AI that the operation failed. Claude will typically explain the error to the user and suggest alternatives.
Multiple content blocks
Tool responses can return multiple content blocks, mixing text and images:
return {
content: [
{ type: "text" as const, text: "Document converted successfully." },
{ type: "text" as const, text: `HTML output:\n\n${html}` },
{ type: "text" as const, text: `Word count: ${wordCount}` },
],
};
Resources (read-only data)
MCP servers can also expose resources, which are read-only data that the AI can access without a tool call. Resources are useful for configuration files, reference documents, or any static data the AI should know about:
server.resource(
"config",
"server://config",
async (uri) => ({
contents: [
{
uri: uri.href,
text: JSON.stringify({
outputDir: join(homedir(), "mcp-documents"),
maxFileSize: "5MB",
supportedFormats: ["markdown", "html"],
}),
mimeType: "application/json",
},
],
})
);
Extending your MCP server
Adding authentication
For servers that wrap authenticated APIs, pass credentials via environment variables:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["dist/index.js"],
"env": {
"API_KEY": "your_api_key_here"
}
}
}
}
Read them in your server:
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.error("API_KEY environment variable is required");
process.exit(1);
}
Remote transport (Streamable HTTP)
For servers that need to run on a remote host (not the user's local machine), the MCP spec defines Streamable HTTP transport. This is more complex than stdio and requires handling HTTP sessions, but it enables cloud-hosted MCP servers that multiple users can connect to.
The Unmarkdown™ MCP server at https://unmarkdown.com/api/mcp uses Streamable HTTP with OAuth authentication, serving tools to claude.ai users directly without any local installation.
Publishing to npm
If you want others to use your server, publish it as an npm package:
- Add a shebang to your entry file:
#!/usr/bin/env node - Set the
binfield inpackage.json(already done in our setup) - Run
npm publish
Users can then reference your server with npx:
{
"mcpServers": {
"your-server": {
"command": "npx",
"args": ["-y", "your-package-name"]
}
}
}
This is how the Unmarkdown™ MCP server is distributed: npx -y @unmarkdown/mcp-server installs and runs it in one step.
The MCP SDK ecosystem
The TypeScript SDK is the most widely used, but official SDKs exist for 10 languages:
| Language | Package | Best For |
|---|---|---|
| TypeScript/JS | @modelcontextprotocol/sdk | Web services, npm distribution |
| Python | mcp (includes FastMCP) | Data science, ML pipelines |
| C#/.NET | ModelContextProtocol | Enterprise, Windows integration |
| Java | io.modelcontextprotocol:sdk | Enterprise backends |
| Kotlin | io.modelcontextprotocol:kotlin-sdk | Android, JVM services |
| Go | github.com/mark3labs/mcp-go | CLI tools, system utilities |
| Rust | mcp-server | Performance-critical servers |
| Swift | MCPServer | macOS/iOS native apps |
| Ruby | mcp-rb | Rails applications |
| PHP | php-mcp/server | WordPress, Laravel |
Python's FastMCP deserves a special mention. It provides a decorator-based API that auto-generates parameter schemas from type hints. A minimal Python MCP server is about 10 lines:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("My Server")
@mcp.tool()
def convert_markdown(markdown: str, title: str = "Untitled") -> str:
"""Converts markdown to HTML."""
import markdown as md
return md.markdown(markdown)
mcp.run()
If Python is your primary language, FastMCP is the fastest path to a working server.
How Unmarkdown™'s MCP server is built
For a production reference, the Unmarkdown™ MCP server is open source (MIT license) and demonstrates several patterns beyond what this tutorial covers:
- 7 tools spanning document CRUD, publishing, format conversion, and usage tracking
- Dual transport: stdio for local (Claude Desktop, Claude Code) and Streamable HTTP for remote (claude.ai)
- OAuth 2.1 authentication for the remote endpoint, with API key fallback
- Direct service function calls instead of HTTP intermediaries, for lower latency and simpler error handling
- Quota enforcement via a wrapper that checks usage limits before tool execution
The tools work with the same context engineering principles described elsewhere on this blog: persistent documents that Claude can read and update across conversations, turning ephemeral AI output into durable knowledge.
Time estimates for real projects
Now that you understand the fundamentals, here is what to expect for different types of MCP servers:
| Project Type | Time Estimate | Example |
|---|---|---|
| Hello world (this tutorial) | 15 to 30 minutes | Convert markdown, save files |
| API wrapper (single service) | 1 to 3 days | Wrap a REST API with 5 to 10 tools |
| Database connector | 2 to 5 days | Query, insert, update with schema awareness |
| Complex server with auth | 1 to 2 weeks | OAuth, rate limiting, multi-user, remote transport |
| Production platform integration | 2 to 4 weeks | Full CRUD, webhooks, real-time updates, monitoring |
The pattern is always the same: define tools, implement handlers, wire up transport. The complexity lives in the business logic, not in the MCP infrastructure. The SDK handles the protocol. You handle the "what should this tool actually do?" question.
Start with the tutorial server. Get it working with Claude Desktop. Then replace the markdown/filesystem logic with whatever API or service you actually want Claude to interact with. The MCP patterns you learned here transfer directly.
