AI/TLDR

How to Build an MCP Server

Walk through every layer of an MCP server — primitives, SDK setup, tool and resource definitions, transport choices, and how to wire it into Claude.

INTERMEDIATE12 MIN READUPDATED 2026-06-12

In plain English

An MCP server is a small process you write once that wraps any capability — a database, an API, the local file system — and hands it to any MCP-compatible AI host (Claude Desktop, Cursor, your own agent) through a standard protocol. Once the server is running, the host can discover your tools automatically, and the model can invoke them without you writing any host-specific glue.

This article is about the building side. You will learn the three server primitives (tools, resources, prompts), how to define them with the official Python and TypeScript SDKs, how to choose a transport, and how to test locally before moving to a hosted deployment. If you are still fuzzy on what MCP is, read What Is MCP? first and come back — this article assumes you understand the protocol basics.

Why building your own server matters

Hundreds of community MCP servers already exist — for GitHub, Slack, Postgres, Google Drive, and more. But any internal system, proprietary API, or domain-specific tool you control needs a server you write. Once you write it, every MCP host your team uses gets access to that tool for free. You do not rewrite the integration for each new AI app.

Building a server is also the fastest way to make an AI agent truly useful in your own context. Agents are only as capable as the tools they can reach. A well-designed MCP server with clear descriptions and tight input schemas will let a model choose the right tool with much higher reliability than a vague, catch-all function.

  • Internal APIs that third-party servers do not cover — HR systems, billing data, internal knowledge bases
  • Local tools like shell commands, file watchers, or dev-environment introspection that must run on the developer's machine
  • Composite servers that wrap several downstream services behind a single MCP facade to reduce context clutter
  • Regulated or air-gapped environments where data cannot leave your infrastructure and a hosted server is not an option

Anatomy of an MCP server

Every MCP server has the same four-layer structure regardless of language: an SDK instance that manages the lifecycle, one or more primitives (tools, resources, prompts) that you register, a transport that carries JSON-RPC messages to and from the host, and your business logic — the actual code that does the work.

The three primitives at a glance

MCP hosts discover what your server offers by calling tools/list, resources/list, and prompts/list. You register primitives once at startup; the SDK handles the list responses automatically.

  • Tools — functions the model can call to take an action or fetch data. Each tool has a name, a human-readable description, and a JSON Schema inputSchema. Tools are the most-used primitive in practice.
  • Resources — read-only data the host can load as context. Think of a resource like a file: the host reads it, not the model directly. Good for things like a DB schema, a config file, or a dashboard snapshot the model needs to understand before acting.
  • Prompts — reusable prompt templates the server offers. The host can fill in parameters and inject the result into the conversation. Useful for standardizing how a team asks about a codebase or a dataset.

How a tool call flows

Writing your first server

Below are minimal working servers in both Python and TypeScript. Both expose one tool (get_word_count) that counts words in a string. This tiny example shows all the boilerplate you need — scale it up by adding more @mcp.tool() decorators or server.registerTool() calls.

Python (FastMCP)

bashbash
# install the SDK (Python 3.10+)
uv add "mcp[cli]"
# or: pip install "mcp[cli]"
pythonpython
# server.py
from mcp.server.fastmcp import FastMCP

# 1. Create the server instance (name shown in host UI)
mcp = FastMCP("text-utils")

# 2. Register a tool using the decorator
@mcp.tool()
def get_word_count(text: str) -> int:
    """Count the number of words in a block of text.

    Args:
        text: The text to count words in.
    """
    # Type hints → JSON Schema input schema (automatic)
    # Docstring → tool description the model reads (automatic)
    return len(text.split())

# 3. Run over stdio so a local host can launch this as a subprocess
if __name__ == "__main__":
    mcp.run(transport="stdio")

FastMCP reads your Python type hints to build the JSON Schema inputSchema automatically. The docstring becomes the tool description. You rarely write raw JSON Schema by hand when using FastMCP.

TypeScript (McpServer)

bashbash
# install the SDK (Node 16+)
npm install @modelcontextprotocol/sdk zod@3
npm install -D typescript @types/node
typescripttypescript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 1. Create the server instance
const server = new McpServer({
  name: "text-utils",
  version: "1.0.0",
});

// 2. Register a tool — inputSchema uses Zod, which the SDK converts to JSON Schema
server.registerTool(
  "get_word_count",
  {
    description: "Count the number of words in a block of text.",
    inputSchema: {
      text: z.string().describe("The text to count words in."),
    },
  },
  async ({ text }) => ({
    content: [{ type: "text", text: String(text.split(/\s+/).length) }],
  })
);

// 3. Connect stdio transport and start
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // server is now running; do NOT use console.log() — it corrupts stdio
  console.error("text-utils MCP server running on stdio");
}

main();

Exposing a resource

Resources let a host load data as context without making a tool call. You register them with a URI scheme of your choosing. Here is a Python example that exposes a static config file:

pythonpython
@mcp.resource("config://app/settings")
def get_settings() -> str:
    """Current application settings (read-only)."""
    return open("settings.json").read()

Exposing a prompt template

pythonpython
from mcp.server.fastmcp import FastMCP
from mcp.types import PromptMessage, TextContent

@mcp.prompt()
def summarize_code(language: str, code: str) -> list[PromptMessage]:
    """Summarize a code snippet in plain English.

    Args:
        language: Programming language of the snippet.
        code: The source code to summarize.
    """
    return [
        PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=f"Summarize the following {language} code in one paragraph:\n\n{code}",
            ),
        )
    ]

Choosing a transport: local vs hosted

The same server code can run over two transports. The choice determines who can connect and where the server lives.

Running locally over stdio

A stdio server is launched on demand by the host each time it starts. You register it with a JSON config that tells the host which command to run. For Claude Desktop, that config lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS or %APPDATA%\Claude\claude_desktop_config.json on Windows:

jsonjson
{
  "mcpServers": {
    "text-utils": {
      "command": "uv",
      "args": [
        "--directory", "/absolute/path/to/project",
        "run", "server.py"
      ]
    }
  }
}

Running remotely over HTTP

For a hosted server, swap the transport in FastMCP from stdio to streamable-http. The SDK starts an HTTP server you can deploy to any cloud runtime:

pythonpython
# Remote HTTP server (FastMCP)
if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",
        port=8000,
        path="/mcp",  # endpoint the host connects to
    )

The host config for a remote server uses a URL instead of a command:

jsonjson
{
  "mcpServers": {
    "text-utils-remote": {
      "url": "https://your-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer <your-token>"
      }
    }
  }
}

Testing before you connect a host

Before wiring your server into Claude or another host, test it with the MCP Inspector — a browser-based tool that connects to your server, lists every primitive, and lets you call tools by hand:

bashbash
# run from your project directory
npx @modelcontextprotocol/inspector uv run server.py

# or for Node/TypeScript
npx @modelcontextprotocol/inspector node build/index.js

The Inspector opens a UI at localhost:5173 (by default) where you can see the tools list, fill in arguments, and inspect the raw JSON-RPC messages. Fix broken schemas here rather than inside a full agent loop.

Going deeper

Writing great tool descriptions

Tool quality is mostly description quality. A model chooses which tool to call based on names and descriptions alone — it cannot inspect your code. Write descriptions as if briefing a new team member: what does the tool do, what are the gotchas, when should the model prefer this tool over a similar one. If a tool has side effects (writes, deletes, sends emails) say so explicitly — models are more cautious about side-effecting tools when they know.

Returning rich content

A tool handler returns a content array. Each item has a type of text, image, or resource. The text type is most common; image lets you return base64-encoded image data for vision models; resource embeds a resource URI the host can dereference. Returning an isError: true flag alongside a text description is the correct way to report tool failures — do not throw unhandled exceptions.

pythonpython
# Returning a structured error from a tool (Python)
@mcp.tool()
def divide(a: float, b: float) -> dict:
    """Divide a by b."""
    if b == 0:
        return {"isError": True, "content": [{"type": "text", "text": "Cannot divide by zero"}]}
    return {"content": [{"type": "text", "text": str(a / b)}]}

Dynamic tool registration

Both SDKs support adding or removing tools after the server has started. When you add a tool at runtime, notify the host by sending a notifications/tools/list_changed message — the SDK does this automatically when you register via the server object. This powers patterns like enabling premium tools only for authenticated users, or loading available tools from a database at startup.

Security checklist

  • Validate all inputs — JSON Schema catches missing fields and wrong types, but business-logic validation (e.g. path traversal, SQL injection) is your responsibility.
  • Scope credentials tightly — the server process should hold only the API keys it actually needs. Use environment variables, never hardcode.
  • Watch what you return — a tool result lands verbatim in the model's context. Avoid returning raw secrets, PII, or large blobs that inflate the context window.
  • Use auth on remote servers — an open HTTP endpoint is a public RCE risk. Always require a bearer token or OAuth flow before accepting tool calls.
  • Audit tool permissions — destructive tools (delete, send, deploy) should require explicit confirmation via elicitation or be disabled by default.

Once your server is solid, the next horizon is composing multiple servers in a multi-agent system or pairing it with a framework like the Claude Agent SDK for more complex orchestration. The MCP server you build today works in that future context without any changes — the protocol is the durable part.

FAQ

Do I need to know JSON Schema to build an MCP server?

Not with the high-level SDKs. FastMCP (Python) reads your type hints and docstrings to generate the JSON Schema automatically. The TypeScript SDK accepts Zod schemas and converts them. You only write raw JSON Schema if you use the lower-level SDK primitives or need schema features the high-level wrappers do not expose.

Can I build an MCP server without TypeScript or Python?

Yes. The official MCP GitHub org ships SDKs for TypeScript, Python, Java, Kotlin, C#, Go, PHP, Ruby, Rust, and Swift. Any language that can speak JSON-RPC over stdio or HTTP can implement the protocol even without an official SDK, but the SDKs save significant boilerplate.

How do I connect my MCP server to Claude Desktop?

Open Claude Desktop's config file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows). Add an entry under mcpServers with the command and absolute path to launch your server, then restart Claude Desktop. The hammer icon in the chat UI confirms tools are detected.

What is the difference between a tool and a resource in MCP?

A tool is a function the model can call — it takes arguments, runs code, and returns a result. A resource is read-only data the host loads as context, similar to attaching a file. Use tools when the model needs to take an action or fetch dynamic data; use resources for stable background information (schemas, configs, docs) that the host can inject into the conversation.

Should I use stdio or HTTP for my MCP server?

Use stdio if the server runs on the same machine as the host (local dev tools, file system access, personal scripts) — it is simpler, faster, and needs no auth. Use Streamable HTTP when you need the server accessible over a network, shared across a team, or deployed as a cloud service. The protocol and your tool code are identical either way; only the transport line changes.

How do I debug an MCP server that is not connecting?

Start with the MCP Inspector (npx @modelcontextprotocol/inspector), which isolates the server from the host entirely. If the Inspector can connect and list tools, the problem is in the host config (wrong path, wrong command, missing env vars). If the Inspector also fails, check that your server starts without errors, that a stdio server writes nothing to stdout before the SDK takes over, and that all required dependencies are installed in the environment the host launches.

Further reading