Zum Inhalt springen

Building Your Own MCP Server

Knowledge

In this section, you'll build your own MCP server step by step. By the end, you'll have a working server that you can integrate into Claude Desktop or Cursor.

We'll build a Notes MCP Server that can:

  • Create notes (Tool)
  • Read notes (Resource)
  • Provide a summarization template (Prompt)

Understand

Anatomy of an MCP Server

An MCP server consists of four parts:

  1. Server instance -- Name and version
  2. Tool definitions -- Functions with Zod validation
  3. Resource definitions -- Data endpoints with URI
  4. Transport binding -- stdio for local servers

With stdio transport, an important detail to note: stdout is used for JSON-RPC communication. Log messages must therefore go through stderr (console.error).

Apply

Step 1: Set Up the Project

Create a new directory and initialize the project:

mkdir mcp-notes-server
cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

Step 2: Server Skeleton

Create src/index.ts -- the heart of your server:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// In-memory storage for notes
const notes: Map<string, { title: string; content: string; createdAt: string }> = new Map();

// Create server
const server = new McpServer({
  name: "notes-server",
  version: "1.0.0",
});

This is the skeleton: you import the SDK, create a data store, and initialize the server with a name and version.

Step 3: Define a Tool -- Create Note

Now let's add the first tool:

// Tool: Create note
server.tool(
  "create_note",
  "Creates a new note with a title and content",
  {
    title: z.string().describe("Title of the note"),
    content: z.string().describe("Content of the note"),
  },
  async ({ title, content }) => {
    const id = `note_${Date.now()}`;
    notes.set(id, {
      title,
      content,
      createdAt: new Date().toISOString(),
    });

    return {
      content: [
        {
          type: "text",
          text: `Note "${title}" created (ID: ${id})`,
        },
      ],
    };
  }
);

Note the structure: server.tool() takes four parameters:

  1. Name -- Unique identifier
  2. Description -- What the tool does (for the LLM)
  3. Schema -- Parameters with Zod validation
  4. Handler -- The actual logic

Step 4: Define a Resource -- Read Notes

// Resource: List all notes
server.resource(
  "notes-list",
  "notes://list",
  async (uri) => {
    const allNotes = Array.from(notes.entries()).map(
      ([id, note]) => `- [${id}] ${note.title} (${note.createdAt})`
    );

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: allNotes.length > 0
            ? allNotes.join("\n")
            : "No notes available.",
        },
      ],
    };
  }
);

*Resources vs. Tools

The Resource only reads data -- it doesn't change anything. The LLM can use this Resource to enrich the context of its responses with current notes.

Step 5: Define a Prompt -- Summarization Template

// Prompt: Summarize notes
server.prompt(
  "summarize_notes",
  "Creates a summary of all notes",
  async () => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Here are my current notes:\n\n${
            Array.from(notes.entries())
              .map(([id, n]) => `## ${n.title}\n${n.content}`)
              .join("\n\n")
          }\n\nPlease create a structured summary with the key points.`,
        },
      },
    ],
  })
);

Step 6: Start the Server

Finally, connect the server to the stdio transport:

// Start server with stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Notes MCP server running...");
}

main().catch(console.error);

iconsole.error Instead of console.log

With stdio transport, stdout is used for JSON-RPC communication. That's why log messages must be sent via stderr (console.error) so they don't interfere with the protocol.

Step 7: Build and Test

# Compile TypeScript
npx tsc

# Test the server with the MCP Inspector
npx @modelcontextprotocol/inspector dist/index.js

The MCP Inspector is an interactive debugging tool that lets you test your server without needing to integrate it into an AI application.

Step 8: Integrate with Claude Desktop

Add your server to the Claude Desktop configuration (claude_desktop_config.json):

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"]
    }
  }
}

After restarting Claude Desktop, your Notes server will appear in the tool list. You can now say:

"Create a note titled 'Meeting Outcome' with the content 'Budget was approved'."

And Claude will call your create_note tool.

The Complete Code

Here's the full src/index.ts again:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const notes: Map<string, { title: string; content: string; createdAt: string }> = new Map();

const server = new McpServer({
  name: "notes-server",
  version: "1.0.0",
});

server.tool(
  "create_note",
  "Creates a new note with a title and content",
  {
    title: z.string().describe("Title of the note"),
    content: z.string().describe("Content of the note"),
  },
  async ({ title, content }) => {
    const id = `note_${Date.now()}`;
    notes.set(id, {
      title,
      content,
      createdAt: new Date().toISOString(),
    });
    return {
      content: [{ type: "text", text: `Note "${title}" created (ID: ${id})` }],
    };
  }
);

server.resource(
  "notes-list",
  "notes://list",
  async (uri) => {
    const allNotes = Array.from(notes.entries()).map(
      ([id, note]) => `- [${id}] ${note.title} (${note.createdAt})`
    );
    return {
      contents: [{
        uri: uri.href,
        mimeType: "text/plain",
        text: allNotes.length > 0 ? allNotes.join("\n") : "No notes available.",
      }],
    };
  }
);

server.prompt(
  "summarize_notes",
  "Creates a summary of all notes",
  async () => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `Here are my current notes:\n\n${
          Array.from(notes.entries())
            .map(([id, n]) => `## ${n.title}\n${n.content}`)
            .join("\n\n")
        }\n\nPlease create a structured summary.`,
      },
    }],
  })
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Notes MCP server running...");
}

main().catch(console.error);

Why does an MCP server with stdio transport use console.error instead of console.log for log messages?

Reflect

Building your own MCP server gives you full control over integrating your data and services. The key point: stdout is reserved for JSON-RPC -- logs must go through stderr. In the next section, we will look at MCP in the enterprise context.