# Building Servers with SDKs

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Server Development
Source: https://www.devtutorials.ai/learn/mcp/server-development/building-servers-with-sdks

> Building a complete MCP server: McpServer setup, registering tools, resources and prompts, stdio and HTTP transports, the same server in Python, and testing with handler tests, the Inspector and CI.

Learning to cook from a recipe card is one thing. Cooking a full meal for guests is another: you plan, prepare each dish, taste as you go, and check everything before it leaves the kitchen.

In this lesson, you put the whole chapter together. You build a complete MCP server with the official SDK: set it up, register tools, resources, and prompts, run it over stdio or HTTP, and test it properly.

The main idea is straightforward: create an `McpServer`, register each tool, resource, and prompt with a schema and a handler, choose a transport, and test at three levels: handlers directly, with the MCP Inspector, and with a real host.

## A Real-World Example

Ravi builds the first version of Brightgrid's returns server in an afternoon:

1. He starts from a minimal template: one tool, one resource, one prompt.
2. He replaces them with `lookup_order`, `book_return`, `kb://returns-policy`, and a `reply_to_customer` prompt.
3. He writes small tests that call each handler directly.
4. He runs the server over stdio and checks it with the Inspector CLI.
5. He adds it to VS Code and tries it in chat.
6. For the company's shared deployment, he runs the same code over HTTP.

In simple words: build small, test each layer, then connect.

## What You Will Learn

In this lesson, you will learn:

1. How to set up a server with the TypeScript SDK.
2. How to register tools, resources, and prompts.
3. How to run it over stdio and over HTTP.
4. The same server in Python.
5. How to test handlers, use the Inspector, and smoke-test in CI.

The **MCP Lab**'s Server code tab runs real SDK code. Press **Start from a minimal server** to load a small starting server, then change it and press **Restart** or **Update live**. Everything you build there uses the same calls as the files below.

## Server Setup

### The Pieces

An MCP server combines protocol communication with your application's functionality. The SDK handles messages to and from the host, while your code defines what the server can do. To expose that functionality, you create a server object, register its tools, resources, and prompts, and connect a transport so a host can reach it.

A TypeScript server has three parts:

1. **Create** an `McpServer` with a name and version.
2. **Register** tools, resources, and prompts.
3. **Connect** a transport: stdio, or HTTP through `createMcpHandler`.

The SDK takes care of the JSON-RPC protocol details, while your code handles the actual business logic and data access. In other words, the server object is the coordinator; your handlers do the work.

Under the hood, `registerTool` stores the definition and the handler, and the SDK answers `tools/list` and `tools/call` for you:

```js runnable title="A stand-in for registerTool"
// What registerTool builds: a tiny stand-in for the SDK, to see the moving parts.
function createServer(name) {
  const tools = new Map();
  return {
    registerTool(toolName, def, handler) {
      tools.set(toolName, { def, handler });
    },
    listTools() {
      return [...tools].map(([n, t]) => ({
        name: n,
        description: t.def.description,
        inputSchema: t.def.inputSchema,
      }));
    },
    async callTool(n, args) {
      const t = tools.get(n);
      if (!t) {
        return { error: { code: -32602, message: `Tool ${n} not found` } };
      }
      return { result: await t.handler(args) };
    },
  };
}

const server = createServer("demo");
server.registerTool(
  "add_numbers",
  {
    description: "Add two numbers.",
    inputSchema: {
      type: "object",
      properties: { a: { type: "number" }, b: { type: "number" } },
    },
  },
  async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),
);

console.log(JSON.stringify(server.listTools()));
server
  .callTool("add_numbers", { a: 2, b: 3 })
  .then((r) => console.log(JSON.stringify(r)));
```

Expected output:

```text
[{"name":"add_numbers","description":"Add two numbers.","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}}}}]
{"result":{"content":[{"type":"text","text":"5"}]}}
```

### Handler Registration

Here is a complete small server in `src/index.ts`. It runs over stdio by default, or over HTTP with `--http`:

```ts
import { createServer } from "node:http";
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { toNodeHandler } from "@modelcontextprotocol/node";
import * as z from "zod";

const orders: Record<string, { item: string; status: string }> = {
  "A-1001": { item: "Desk lamp", status: "shipped" },
};

function buildServer() {
  const server = new McpServer({ name: "brightgrid-returns", version: "1.0.0" });

  server.registerTool(
    "lookup_order",
    {
      title: "Look up an order",
      description: "Get the item and status of one order. Use it when the customer gives an order id.",
      inputSchema: z.object({ order_id: z.string().describe("The order id, like A-1001") }),
      annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
    },
    async ({ order_id }) => {
      const order = orders[order_id];
      if (!order) {
        return { content: [{ type: "text", text: `No order ${order_id}` }], isError: true };
      }
      return { content: [{ type: "text", text: `${order_id}: ${order.item}, ${order.status}` }] };
    },
  );

  server.registerResource(
    "returns-policy",
    "kb://returns-policy",
    { title: "Returns policy", mimeType: "text/plain" },
    async (uri) => ({ contents: [{ uri: uri.href, text: "Returns are free within 30 days." }] }),
  );

  server.registerPrompt(
    "reply_to_customer",
    { description: "A short, kind reply.", argsSchema: z.object({ question: z.string() }) },
    ({ question }) => ({
      messages: [{ role: "user", content: { type: "text", text: `Reply kindly and briefly: ${question}` } }],
    }),
  );

  return server;
}

if (process.argv.includes("--http")) {
  const port = Number(process.env.PORT ?? 3001);
  createServer(toNodeHandler(createMcpHandler(buildServer))).listen(port, "127.0.0.1", () => {
    console.error(`MCP server on http://127.0.0.1:${port}/mcp`);
  });
} else {
  await buildServer().connect(new StdioServerTransport());
  console.error("MCP server running on stdio"); // stderr, never stdout
}
```

This example shows the key pattern for a real server: one factory builds the `McpServer`, and each transport chooses how the host reaches it. The HTTP version is usually a fresh server per request, while stdio keeps a single long-lived process.

Notice:

- `buildServer()` is a **factory**. For HTTP, `createMcpHandler` can build a server for each request, which fits common stateless HTTP patterns.
- Logs use `console.error`, so they go to **stderr**.
- The HTTP server listens on `127.0.0.1` only. Put a proper web server with HTTPS and sign-in in front of it before anyone else uses it. You will see how in Chapter 7.

### Schemas with zod

The SDK turns zod schemas into JSON Schema for `tools/list`.

This matters because the host and the model need to know what arguments a tool accepts before they call it. Without a schema, the model is guessing.

```js runnable title="From zod to JSON Schema"
// How zod schemas in the SDK turn into JSON Schema in tools/list.
const mapping = [
  ["z.string()", '{ "type": "string" }'],
  [
    "z.number().int().min(1)",
    '{ "type": "integer", "minimum": 1, "maximum": 9007199254740991 }',
  ],
  ['z.enum(["low", "high"])', '{ "type": "string", "enum": ["low", "high"] }'],
  ["z.string().regex(/^A-\\d{4}$/)", '{ "type": "string", "pattern": "^A-\\\\d{4}$" }'],
  [
    'z.string().describe("Like A-1001")',
    '{ "type": "string", "description": "Like A-1001" }',
  ],
  ["z.boolean().optional()", '{ "type": "boolean" } and not in "required"'],
];
for (const [zod, json] of mapping) {
  console.log(zod.padEnd(34), "->", json);
}
```

Expected output:

```text
z.string()                         -> { "type": "string" }
z.number().int().min(1)            -> { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
z.enum(["low", "high"])            -> { "type": "string", "enum": ["low", "high"] }
z.string().regex(/^A-\d{4}$/)      -> { "type": "string", "pattern": "^A-\\d{4}$" }
z.string().describe("Like A-1001") -> { "type": "string", "description": "Like A-1001" }
z.boolean().optional()             -> { "type": "boolean" } and not in "required"
```

Everything so far has used the TypeScript SDK, but MCP is language-independent. The same concepts—tools, resources, prompts, handlers, and transports—apply in Python; only the syntax changes.

## The Same Server in Python

### MCPServer and Decorators

The Python SDK (`mcp` v2) uses decorators. Type hints become the input schema, and the docstring becomes the description. This is the same idea as the TypeScript example: you declare the tool, then the framework exposes it over MCP.

```python
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("brightgrid-returns", version="1.0.0")

ORDERS = {"A-1001": {"item": "Desk lamp", "status": "shipped"}}

@mcp.tool(annotations={"readOnlyHint": True})
def lookup_order(order_id: str) -> str:
    """Get the item and status of one order."""
    order = ORDERS.get(order_id)
    if order is None:
        raise ToolError(f"No order {order_id}")
    return f"{order_id}: {order['item']}, {order['status']}"

@mcp.resource("kb://returns-policy", mime_type="text/plain")
def returns_policy() -> str:
    """How returns work."""
    return "Returns are free within 30 days."

@mcp.prompt()
def reply_to_customer(question: str) -> str:
    """A short, kind reply."""
    return f"Reply kindly and briefly: {question}"

if __name__ == "__main__":
    mcp.run()  # stdio; mcp.run(transport="streamable-http") for HTTP
```

Two things to know about the Python SDK:

- Raise `ToolError` for failures the model should read. Other exceptions are reported as a general "Error executing tool" message, so internal details don't leak.
- A return type like `-> str` can also produce an output schema, and the result may include `structuredContent` such as `{"result": "..."}`. The exact shape depends on the SDK version and how the function is wrapped, so keep the example scoped to the basic pattern.

The two SDKs look different, but the wire is the same. A host can't tell whether a server was written in TypeScript or Python. Pick the language your team maintains best.

## Testing

### Level 1: Handlers Directly

Call each handler as a normal function, with good and bad arguments. These tests are fast, need no transport, and catch most logic bugs before you ever run a full server.

```js runnable title="Test a handler directly"
// Test a handler directly, without any transport. Fast and reliable.
async function lookupOrder({ order_id }) {
  const orders = { "A-1001": "shipped" };
  if (!orders[order_id]) {
    return { content: [{ type: "text", text: `No order ${order_id}` }], isError: true };
  }
  return { content: [{ type: "text", text: `${order_id}: ${orders[order_id]}` }] };
}

const cases = [
  { args: { order_id: "A-1001" }, expectError: false, expectText: "A-1001: shipped" },
  { args: { order_id: "A-9999" }, expectError: true, expectText: "No order A-9999" },
];

(async () => {
  for (const c of cases) {
    const r = await lookupOrder(c.args);
    const pass = !!r.isError === c.expectError && r.content[0].text === c.expectText;
    console.log(pass ? "pass" : "FAIL", JSON.stringify(c.args));
  }
})();
```

Expected output:

```text
pass {"order_id":"A-1001"}
pass {"order_id":"A-9999"}
```

### Level 2: The MCP Inspector

Run the real server and talk to it over the real transport:

```text
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name lookup_order --tool-arg order_id=A-1001
```

The web UI (`npx @modelcontextprotocol/inspector`) does the same with buttons and shows every message. This is where you see whether the host and the server agree on the tool schema and argument names.

### Level 3: A Real Host

Add the server to VS Code or another host, as in the last lesson, and try real questions. Watch which tools the model picks and with which arguments. Weak descriptions show up here.

### Smoke Tests in CI

The Inspector CLI prints JSON, so a CI job can start the server, call a tool, and check the answer on every change:

```js runnable title="Check Inspector output in CI"
// Read Inspector CLI output (it prints JSON) and check it in a CI script.
const inspectorOutput = `{
  "content": [{ "type": "text", "text": "A-1001: Desk lamp, shipped, arrives 2026-10-14" }]
}`;

const result = JSON.parse(inspectorOutput);
const checks = [
  ["no isError", !result.isError],
  ["has text content", result.content?.[0]?.type === "text"],
  ["mentions the order", result.content[0].text.includes("A-1001")],
];
for (const [name, ok] of checks) {
  console.log(ok ? "pass" : "FAIL", name);
}
console.log(checks.every(([, ok]) => ok) ? "smoke test passed" : "smoke test failed");
```

Expected output:

```text
pass no isError
pass has text content
pass mentions the order
smoke test passed
```

### Choosing the Transport

The same code can serve both transports. A command-line flag or environment variable chooses one at startup:

```js runnable title="Pick the transport at start-up"
// Choosing the transport at start-up: one server, two ways to run it.
function startPlan(argv, env) {
  if (argv.includes("--http")) {
    const port = Number(env.PORT ?? 3001);
    return `HTTP: createMcpHandler(buildServer) + toNodeHandler, listening on 127.0.0.1:${port}/mcp`;
  }
  return "stdio: server.connect(new StdioServerTransport()), logs to stderr";
}

console.log(startPlan(["node", "build/index.js"], {}));
console.log(startPlan(["node", "build/index.js", "--http"], { PORT: "4000" }));
```

Expected output:

```text
stdio: server.connect(new StdioServerTransport()), logs to stderr
HTTP: createMcpHandler(buildServer) + toNodeHandler, listening on 127.0.0.1:4000/mcp
```

Test the unhappy paths too: unknown ids, wrong types, missing arguments, a backend that is down, and a user who declines a confirmation. Most real problems live there, not in the happy path.

## Common mistakes

- **Forgetting to await `connect`.** The server exits before it reads any message.
- **Binding HTTP to `0.0.0.0` in development.** Anyone on the network can call it. Use `127.0.0.1`.
- **Leaking internals in errors.** Stack traces and SQL in tool errors help attackers. Return clear, short messages.
- **No tests for bad input.** The model will send bad input. Test it before users do.
- **Changing tool names casually.** Hosts, prompts, and approvals may depend on them. Treat names as a public contract.

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- Create an **`McpServer`**, **register** tools, resources, and prompts, then **connect** a transport.
- For HTTP, wrap a **factory** with `createMcpHandler` and serve it with `toNodeHandler`. For stdio, `connect(new StdioServerTransport())`.
- Log to **stderr**. Bind local HTTP servers to **127.0.0.1**.
- In **Python**, `MCPServer` with `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`. Raise `ToolError` for messages the model should see.
- Test at three levels: **handlers**, the **Inspector**, and a **real host**. Add Inspector smoke tests to CI.

Next: **Chapter 7, Multi-Server Architectures**, connecting many servers safely.
