Unmarkdown
Developers

Build Your First MCP Server in 30 Minutes (Document Publishing Example)

Updated Feb 25, 2026 · 18 min read

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:

  1. convert_markdown: Takes markdown text and returns formatted HTML
  2. 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:

  1. Name ("convert_markdown"): The identifier Claude uses to call this tool. Keep it descriptive and snake_case.
  2. 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.
  3. 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.
  4. Handler: An async function that receives the validated parameters and returns a result. The result must be an object with a content array 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:

  1. StdioServerTransport is 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.
  2. console.error for logging. Since stdout is used for MCP protocol messages, all logging must go to stderr. Using console.log in 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:

  1. Your server name ("my-document-server") in the connection panel
  2. Two tools listed: convert_markdown and save_document
  3. A parameter form for each tool where you can enter test values

Test convert_markdown:

  • Set markdown to: # Hello World\n\nThis is a **test** with a [link](https://example.com).\n\n- Item one\n- Item two
  • Set title to: 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 title to: My First MCP Document
  • Set markdown to: # 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:

  1. Add a shebang to your entry file: #!/usr/bin/env node
  2. Set the bin field in package.json (already done in our setup)
  3. 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:

LanguagePackageBest For
TypeScript/JS@modelcontextprotocol/sdkWeb services, npm distribution
Pythonmcp (includes FastMCP)Data science, ML pipelines
C#/.NETModelContextProtocolEnterprise, Windows integration
Javaio.modelcontextprotocol:sdkEnterprise backends
Kotlinio.modelcontextprotocol:kotlin-sdkAndroid, JVM services
Gogithub.com/mark3labs/mcp-goCLI tools, system utilities
Rustmcp-serverPerformance-critical servers
SwiftMCPServermacOS/iOS native apps
Rubymcp-rbRails applications
PHPphp-mcp/serverWordPress, 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 TypeTime EstimateExample
Hello world (this tutorial)15 to 30 minutesConvert markdown, save files
API wrapper (single service)1 to 3 daysWrap a REST API with 5 to 10 tools
Database connector2 to 5 daysQuery, insert, update with schema awareness
Complex server with auth1 to 2 weeksOAuth, rate limiting, multi-user, remote transport
Production platform integration2 to 4 weeksFull 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.

Your markdown deserves a beautiful home.

Start publishing for free. Upgrade when you need more.

View pricing