25 min

The empty server

The server is done in twenty lines. The decision is the transport, not the code.

Milestone One tool answers over stdio and over Streamable HTTP, both times against the same client.

An MCP server is smaller than its reputation suggests. Twenty lines, and a client can query it. That is why most guides stop after those twenty lines. And why the second server then gets so unpleasant.

Let us start there anyway. What you build in this chapter you can throw away later; what you see while doing it decides the rest.

The layout

mcp-kurs/
  package.json
  k1/
    server.ts
    client.ts

The package.json needs exactly three things:

{
  "name": "simhaven-helpdesk",
  "private": true,
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^3.23.8"
  }
}

You must not leave out "type": "module". The SDK ships ESM, and whoever loads it from a CommonJS module gets an error about require of an ES module that mentions MCP nowhere. Ten minutes of searching, every time.

Twenty lines

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

const server = new McpServer({ name: "simhaven-helpdesk", version: "0.1.0" });

server.registerTool(
  "ticket_count",
  {
    description: "Wie viele Tickets liegen im Helpdesk von Simhaven?",
    inputSchema: { status: z.enum(["offen", "geschlossen"]).optional() },
  },
  async ({ status }) => ({
    content: [
      { type: "text", text: JSON.stringify({ status: status ?? "alle", count: 8 }) },
    ],
  }),
);

await server.connect(new StdioServerTransport());

Three things are in there. McpServer is the registry: it knows which tools exist and answers tools/list. registerTool hangs one into it, with a name, a description, an input schema and a body. And connect pins the whole thing to a transport that from then on speaks JSON-RPC over standard input and output.

What is striking is what is not there. No routing. No serialising. No input checking. The zod schema is translated into JSON Schema by the SDK, shipped with the tool list and enforced before every call, so your body gets the arguments already validated and typed.

The client is code too

An MCP server without a client only makes a claim. Before any harness enters the picture, a test rig of your own should stand next to it, one you wrote yourself and that shows you on every call what really goes over the wire instead of what some other program made of it. It runs faster. It says more. And it does not lie.

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

const transport = new StdioClientTransport({
  command: "node",
  args: ["--experimental-strip-types", "k1/server.ts"],
});

const client = new Client({ name: "pruefstand", version: "0.1.0" });
await client.connect(transport);

console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "ticket_count", arguments: { status: "offen" } }));

await client.close();

The client starts the server itself. With stdio that is the normal case, and that is the whole difference to the network. There is no running service you would have to bring up first.

npm install
node --experimental-strip-types k1/client.ts
[ 'ticket_count' ]
{ content: [ { type: 'text', text: '{"status":"offen","count":8}' } ] }

That is the complete three-beat from the component: connect, tools/list, tools/call. Two lines of output, nothing more happens.

The same server over HTTP

And if the server should not run on the user's machine? Then you swap the transport. And find that the transport was the smaller problem.

// k1/http.ts
import http from "node:http";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

function buildServer(): McpServer {
  const server = new McpServer({ name: "simhaven-helpdesk", version: "0.1.0" });
  server.registerTool(
    "ticket_count",
    {
      description: "Wie viele Tickets liegen im Helpdesk von Simhaven?",
      inputSchema: { status: z.enum(["offen", "geschlossen"]).optional() },
    },
    async ({ status }) => ({
      content: [
        { type: "text", text: JSON.stringify({ status: status ?? "alle", count: 8 }) },
      ],
    }),
  );
  return server;
}

const transports = new Map<string, StreamableHTTPServerTransport>();

const httpServer = http.createServer(async (req, res) => {
  if ((req.url ?? "/").split("?")[0] !== "/mcp") {
    res.statusCode = 404;
    res.end();
    return;
  }

  const header = req.headers["mcp-session-id"];
  const sessionId = Array.isArray(header) ? header[0] : header;
  const known = sessionId ? transports.get(sessionId) : undefined;
  if (known) {
    await known.handleRequest(req, res);
    return;
  }

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
    onsessioninitialized: (sid) => transports.set(sid, transport),
  });
  transport.onclose = () => {
    if (transport.sessionId) transports.delete(transport.sessionId);
  };
  await buildServer().connect(transport);
  await transport.handleRequest(req, res);
});

httpServer.listen(8787, () => console.log("helpdesk auf :8787"));

Twenty lines have become forty, and the twenty new ones are about something other than tools. They are about sessions. buildServer is a function now because every client gets its own server. Why that is not a matter of taste is in chapter 4.

The matching client knows no process any more, only an address:

// k1/client-http.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "pruefstand", version: "0.1.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8787/mcp")));

console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "ticket_count", arguments: {} }));

await client.close();
helpdesk auf :8787
[ 'ticket_count' ]
{ content: [ { type: 'text', text: '{"status":"alle","count":8}' } ] }

Same answer, same tool body, a different way of getting there.

Which transport, and why

The rule of thumb is in the component and it holds. If it runs at the user, take stdio; if it runs for several people, take Streamable HTTP. It is just that in practice the reasoning usually goes the wrong way round.

stdioStreamable HTTP
Who starts itthe clientyour operations
Authorised iswhoever may start the processwhoever identifies themselves
Sessionsone, it is the processmany, you manage them
Reachable fromthis machine onlyanywhere the address reaches
What you additionally buildnothingauth, caps, reaper, health check

The last row is the decision. stdio is the simpler way to operate and not the simpler way to build. The tool code stays the same line for line; what falls away is everything around it. And of that surrounding work you typically need either none of it or all of it.

A tool that reads the clipboard, drives the local git or opens a file on the user's disk belongs on stdio. A helpdesk with ten people on it, where nobody may see another person's session, belongs on the network. The helpdesk of this course gets both in the end. It can afford that because both ways use the same tool code, which shrinks the difference to two start files of seven lines each.