MCP Server Builder skill for Claude Code

Connect Claude to your systems with tools it picks right. Builds Model Context Protocol servers that Claude can actually use well. Instead of mirroring every REST endpoint, Claude designs a few task-level tools with bounded schemas, compact outputs and fixable error messages, wires stdio or Streamable HTTP with the official SDKs, tests each tool in the Inspector and a scripted client, and registers the server for you or your team without leaking secrets.

  • namemcp-server-builder
  • size235 lines · ≈2,600 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds Model Context Protocol servers with the TypeScript or Python SDK, designing task-level tools with tight input schemas, resources, and prompts, choosing stdio or Streamable HTTP transport, testing with the MCP Inspector and an SDK client, and registering the server with claude mcp add or a shared .mcp.json. Use when the user wants to connect Claude to an internal API, database, or service, write an MCP server or tool, or debug a server that fails to connect or returns errors.

Try asking

  • Build an MCP server so Claude can search and file issues in our tracker
  • Wrap our internal Postgres reporting API as MCP tools with a Python server
  • My MCP server shows 'failed to connect' in /mcp — debug it

Use it when

  • Claude needs live access to an internal API, database or service
  • Turning a set of scripts into tools Claude can call
  • Sharing a tool server with the team through .mcp.json
  • An MCP server fails to connect or Claude misuses its tools

Skip it when

  • You are building the agent itself rather than a tool server — use agent-sdk-builder instead
  • A plain CLI plus a skill that documents it would do — use skill-authoring instead

How to check the result

Every tool lists and runs correctly in the MCP Inspector, the scripted client smoke test passes, and real prompts in Claude Code reach the intended tool on the first call.

The common mistake it prevents

Writing logs to stdout in a stdio server, which corrupts the JSON-RPC stream and makes the connection drop without a clear error.

~/.claude/skills/mcp-server-builder/SKILL.md

--- name: mcp-server-builder description: Builds Model Context Protocol servers with the TypeScript or Python SDK, designing task-level tools with tight input schemas, resources,… license: MIT ---

An MCP server is done when Claude picks the right tool from its description alone, calls it with valid arguments on the first try, gets back a concise result or an actionable error, and the server passes an Inspector session plus a scripted client smoke test. A server that mirrors every REST endpoint one-to-one usually fails the first criterion.

Decide first

QuestionDefaultEscape hatch
LanguageWhatever the team maintains; TypeScript SDK below, Python in the reference—
Transportstdio for a local, single-user toolStreamable HTTP for shared/remote servers or many clients
Tool granularityOne tool per user task (find_customer_by_email)Thin API wrappers only for truly generic APIs
Write operationsSeparate tools from reads, clearly named (create_, delete_)—
Large dataResource or paginated tool with a limit—

Already exists? Check the official and community server lists before building; many common services already have maintained servers.

Workflow

- [ ] 1. List the 3-8 tasks Claude should accomplish, phrased as user requests
- [ ] 2. Design tools: names, descriptions, input schemas, output shapes, errors
- [ ] 3. Scaffold the server and implement handlers
- [ ] 4. Test with the Inspector, then a scripted client smoke test
- [ ] 5. Register with Claude Code and test with real prompts
- [ ] 6. Harden: auth, input validation, output caps, logging to stderr

2. Tool design rules

  • Name: snake_case verb_noun, unique across servers you expect to coexist (search_issues, not search). Claude Code exposes it as mcp__<server>__<tool>.
  • Description: what it does, when to use it, what it returns, and when not to use it. This is the routing text; write it like a skill description.
  • Inputs: every parameter gets a type, a .describe(), and bounds (min, max, enum). Prefer enums over free strings. Provide defaults.
  • Outputs: return only fields Claude needs, in compact JSON or short text. Include IDs needed for follow-up calls. Paginate with limit + cursor; say in the result when more exists.
  • Errors: return a result with isError: true and a message that says how to fix the call (“status must be one of open, closed”). Throwing produces a protocol error Claude cannot reason about as well.
  • Mark intent with annotations where the SDK supports them (readOnlyHint, destructiveHint, idempotentHint) so clients can gate dangerous tools.

3. TypeScript server (stdio)

mkdir issues-mcp && cd issues-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node && npx tsc --init --module nodenext --target es2022 --outDir build
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const API = process.env.ISSUES_API_URL ?? "http://localhost:8080";
const TOKEN = process.env.ISSUES_TOKEN; // never hard-code secrets

export function buildServer() {
  const server = new McpServer({ name: "issues", version: "0.1.0" });

  server.registerTool(
    "search_issues",
    {
      title: "Search issues",
      description:
        "Search the issue tracker by keyword and optional status. Use to find existing bugs " +
        "before filing new ones. Returns id, title, status, updated_at; max 50 per call.",
      inputSchema: {
        query: z.string().min(2).describe("Keywords matched against title and body"),
        status: z.enum(["open", "closed", "any"]).default("open"),
        limit: z.number().int().min(1).max(50).default(10),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ query, status, limit }) => {
      const url = `${API}/issues?q=${encodeURIComponent(query)}&status=${status}&limit=${limit}`;
      const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
      if (!res.ok) {
        return { isError: true, content: [{ type: "text", text: `Tracker returned ${res.status}. Check ISSUES_TOKEN or narrow the query.` }] };
      }
      const items = (await res.json()) as Array<{ id: string; title: string; status: string; updated_at: string }>;
      const slim = items.map(({ id, title, status, updated_at }) => ({ id, title, status, updated_at }));
      return { content: [{ type: "text", text: JSON.stringify({ count: slim.length, items: slim }) }] };
    },
  );

  server.registerResource(
    "labels",
    "issues://labels",
    { title: "Issue labels", description: "All labels and their meaning", mimeType: "application/json" },
    async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await (await fetch(`${API}/labels`)).json()) }] }),
  );

  server.registerPrompt(
    "triage_issue",
    { title: "Triage an issue", description: "Classify severity and owner", argsSchema: { id: z.string() } },
    ({ id }) => ({ messages: [{ role: "user", content: { type: "text", text: `Fetch issue ${id}, classify severity (S1-S4) and suggest an owning team.` } }] }),
  );
  return server;
}

const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("issues MCP server running on stdio"); // stderr only: stdout is the protocol channel

Build with npx tsc, run with node build/index.js. For Python (FastMCP, uv, Pydantic-typed tools), read references/python-fastmcp.md.

Streamable HTTP (shared or remote)

import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {          // stateless: new server + transport per request
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => { transport.close(); server.close(); });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
app.listen(3333, "127.0.0.1");

For HTTP: require auth (bearer token or OAuth) before handleRequest, validate the Origin header, bind to 127.0.0.1 unless it must be reachable, and terminate TLS in front of it. SSE transport is legacy; do not start new servers on it. When splitting buildServer() into its own module for HTTP, keep the stdio startup lines in a separate entry file.

4. Test

npx @modelcontextprotocol/inspector node build/index.js       # interactive UI: list and call tools

In the Inspector, check: every tool lists with its schema; a valid call returns the expected shape; an invalid argument is rejected by schema; a backend failure returns isError with a useful message.

Scripted smoke test for CI:

// test/smoke.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client({ name: "smoke", version: "0.0.0" });
await client.connect(new StdioClientTransport({ command: "node", args: ["build/index.js"] }));
const { tools } = await client.listTools();
if (!tools.some((t) => t.name === "search_issues")) throw new Error("search_issues missing");
const r = await client.callTool({ name: "search_issues", arguments: { query: "login" } });
console.log(JSON.stringify(r).slice(0, 300));
await client.close();

Unit-test handler logic as plain functions; keep the MCP layer thin.

5. Register with Claude Code

# stdio, local scope (default: you, this project)
claude mcp add --transport stdio issues --env ISSUES_TOKEN=xxx -- node /abs/path/issues-mcp/build/index.js
# HTTP
claude mcp add --transport http issues http://127.0.0.1:3333/mcp --header "Authorization: Bearer xxx"
# share with the team: writes .mcp.json at the repo root
claude mcp add --scope project --transport stdio issues -- node ./tools/issues-mcp/build/index.js
claude mcp list && claude mcp get issues   # inspect; `claude mcp remove issues` to undo

Committed .mcp.json must not contain secrets; use env expansion:

{ "mcpServers": { "issues": { "command": "node", "args": ["./tools/issues-mcp/build/index.js"],
  "env": { "ISSUES_TOKEN": "${ISSUES_TOKEN}" } } } }

Inside a session, /mcp shows connection status, tool lists, and handles OAuth login. Project-scoped servers ask each user for approval on first use.

Then test with real prompts from step 1, without naming the tool. If Claude picks the wrong tool or skips it, fix the description, not the prompt.

Gotchas

  • console.log (or print) in a stdio server corrupts the JSON-RPC stream and the connection drops. Log to stderr.
  • Relative paths in claude mcp add resolve against wherever Claude Code starts. Use absolute paths for local scope.
  • Large results: Claude Code warns on big MCP outputs and truncates past a limit (configurable with MAX_MCP_OUTPUT_TOKENS). Design for small pages instead of raising the limit.
  • Tool descriptions and results are prompt-injection surfaces. Treat fetched content as data, and never let a tool’s result authorize a destructive follow-up on its own.
  • Too many tools (dozens per server) degrade selection and cost context in every session. Consolidate.
  • A schema that accepts anything (z.any(), untyped dicts) moves validation errors from the schema into your backend.

Verification

  • Inspector: all tools listed, schemas correct, happy path and error path both checked.
  • Smoke script exits 0 in CI.
  • claude mcp list shows the server connected; /mcp lists the tools.
  • Real-prompt test: each step-1 request is solved with the intended tool on the first call, and an intentionally bad request yields an error Claude recovers from.

Output

**Server:** issues-mcp (TypeScript, stdio) at tools/issues-mcp
**Tools:** search_issues (read), get_issue (read), create_issue (write, destructiveHint=false)
**Resources:** issues://labels   **Prompts:** triage_issue
**Registered:** project scope via .mcp.json (token from $ISSUES_TOKEN)
**Tests:** Inspector pass; test/smoke.ts in CI; 5/5 real prompts used the right tool

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/python-fastmcp.md 126 lines · 4.6 KB
# Python MCP servers with FastMCP

Read this when the server is written in Python. It uses the official `mcp` package and
its bundled `FastMCP` class. (A separate, independently maintained `fastmcp` package
exists with a similar API and extra features; pick one and do not mix imports.)

## Setup

```bash
uv init issues-mcp && cd issues-mcp
uv add "mcp[cli]" httpx
```

## Server

```python
# server.py
import json
import os
import sys
from typing import Annotated, Literal

import httpx
from pydantic import Field
from mcp.server.fastmcp import FastMCP

API = os.environ.get("ISSUES_API_URL", "http://localhost:8080")
TOKEN = os.environ.get("ISSUES_TOKEN", "")

mcp = FastMCP("issues")


@mcp.tool()
async def search_issues(
    query: Annotated[str, Field(min_length=2, description="Keywords matched against title and body")],
    status: Literal["open", "closed", "any"] = "open",
    limit: Annotated[int, Field(ge=1, le=50)] = 10,
) -> str:
    """Search the issue tracker by keyword. Use to find existing bugs before filing new ones.
    Returns id, title, status, updated_at as JSON; max 50 per call."""
    async with httpx.AsyncClient(timeout=15) as client:
        r = await client.get(f"{API}/issues", params={"q": query, "status": status, "limit": limit},
                             headers={"Authorization": f"Bearer {TOKEN}"})
    if r.status_code != 200:
        # Raising inside a tool returns an error result (isError) to the client with this message.
        raise ValueError(f"Tracker returned {r.status_code}. Check ISSUES_TOKEN or narrow the query.")
    items = [{k: i[k] for k in ("id", "title", "status", "updated_at")} for i in r.json()]
    return json.dumps({"count": len(items), "items": items})


@mcp.resource("issues://labels")
async def labels() -> str:
    """All labels and their meaning."""
    async with httpx.AsyncClient(timeout=15) as client:
        return (await client.get(f"{API}/labels")).text


@mcp.prompt()
def triage_issue(id: str) -> str:
    """Classify severity and owner for an issue."""
    return f"Fetch issue {id}, classify severity (S1-S4) and suggest an owning team."


if __name__ == "__main__":
    print("issues MCP server starting", file=sys.stderr)  # never print to stdout on stdio
    mcp.run()  # stdio by default; mcp.run(transport="streamable-http") for HTTP
```

Notes:

- The docstring becomes the tool description; type hints and `Field` constraints become
  the input schema. Write the docstring as routing text.
- Return `str` (JSON text) or typed objects (dataclasses / Pydantic models / dicts); typed
  returns can also produce structured output in recent SDK versions.
- Add a `ctx: Context` parameter (from `mcp.server.fastmcp import Context`) to send log
  messages or progress to the client during long operations.
- Use async HTTP clients; a blocking call in an async tool stalls the whole server.

## Run and test

```bash
uv run mcp dev server.py                  # launches the MCP Inspector against the server
npx @modelcontextprotocol/inspector uv run server.py   # equivalent, explicit
```

Scripted smoke test with the Python client:

```python
# smoke.py
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(command="uv", args=["run", "server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert any(t.name == "search_issues" for t in tools.tools), "search_issues missing"
            result = await session.call_tool("search_issues", {"query": "login"})
            print(str(result)[:300])

asyncio.run(main())
```

## Register with Claude Code

```bash
claude mcp add --transport stdio issues --env ISSUES_TOKEN=xxx -- \
  uv --directory /abs/path/issues-mcp run server.py
```

`uv --directory` makes the command independent of where Claude Code was started. For
HTTP, run `mcp.run(transport="streamable-http")` (serves `/mcp`, port 8000 by default)
and register with `claude mcp add --transport http issues http://127.0.0.1:8000/mcp`.

## Python-specific gotchas

- `print()` without `file=sys.stderr` breaks stdio framing. The `logging` module writes
  to stderr by default, which is safe.
- Unhandled exceptions in module import (missing env var at top level) make the server
  exit before the handshake; Claude Code shows only "failed to connect". Run
  `uv run server.py` directly first to see the traceback.
- Virtualenv mismatch: registering `python server.py` uses whatever `python` is on PATH
  when Claude Code starts. Use `uv run` or an absolute interpreter path.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc