Back to blog
Guides7 min read

Building a custom MCP server with the TypeScript SDK

Tools, resources and prompts on McpServer — what each register* call takes, why ResourceTemplate insists on list, and when to pick stdio over Streamable HTTP.

Three registries behind a transport

An MCP server is the thing an AI host calls into. Claude Desktop, Claude Code, Cursor, or an agent you built with any of the SDKs connects, asks the server what it has, and the model calls it. The Model Context Protocol standardises that conversation, and the Model Context Protocol SDK — @modelcontextprotocol/sdk — brings it to TypeScript.

A server is three registries — tools, resources, prompts — and a transport. The SDK's McpServer exposes each registry as one register* call, and the whole thing starts like this:

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

const server = new McpServer({ name: "orders-mcp-server", version: "1.0.0" })
// register…
await server.connect(new StdioServerTransport())

The name and version go to every client in the initialize handshake. By convention the name is <service>-mcp-server. The free MCP server generator emits one file per registration under src/tools/, src/resources/ and src/prompts/, each named after the identifier the client sees, so the tree reads as the server's API and src/index.ts only assembles it.

One note on versions before the code: the SDK's repository README now documents a v2 with a different package name and an object-first API. What npm latest ships, and what every snippet below targets, is the 1.x line — registerTool(name, config, handler) and friends. Pin ^1.29.0 rather than latest in a new project; a floating pin that crosses to v2 breaks every import path.

Tools: the name is what the model calls

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

export function registerOrdersLookup(server: McpServer): void {
  server.registerTool(
    "orders_lookup",
    {
      title: "Look up an order",
      description: "Look up one order by its id and return its status, items and delivery estimate. Read-only; use orders_refund to act on it.",
      inputSchema: { orderId: z.string().describe("The order id, as printed on the confirmation email.") },
      outputSchema: {
        status: z.enum(["pending", "shipped", "delivered", "refunded"]),
        eta: z.string().optional(),
      },
      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
    },
    async (input) => {
      const output = await orders.get(input.orderId)
      return {
        content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
        structuredContent: output,
      }
    }
  )
}

Four things in that call carry weight.

The name is what the model calls, verbatim. snake_case, and a service prefix — orders_lookup, not lookup — because a host may have several servers connected and their tool names share one flat list. The SDK throws on a duplicate registration within one server.

The description is the entire basis on which a model decides to call the tool. Say what it does, what it returns, and when not to use it; the "use orders_refund to act on it" clause is doing real routing work.

The input and output schemas are plain Zod shapes — every field the model fills in is one z.* expression, and its .describe() is what the model reads. If the schema is more than a few fields, the free Zod schema builder writes it with formats, bounds, enums and nesting, and shows the inferred type. The output schema is optional, and when present the handler returns structuredContent alongside the text content. Clients that understand it read the object; clients that do not read the text. Both must agree.

The annotations are hints to the client, not enforcement. readOnlyHint says nothing changes; destructiveHint that data may be destroyed; idempotentHint that repeating a call is harmless; openWorldHint that the tool reaches outside the server. A host may use them to decide what to auto-approve or run in parallel, so describe the handler honestly — a tool marked both read-only and destructive is a contradiction the generator flags.

Resources: static URIs and templates

A resource is something a client reads by URI rather than something a model calls. A static one is a string:

server.registerResource(
  "policy",
  "docs://refund-policy",
  { title: "Refund policy", mimeType: "text/markdown" },
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: policyMarkdown }] })
)

A URI with {variables} is a template, and the reader receives the variables as its second argument:

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"

server.registerResource(
  "order",
  new ResourceTemplate("orders://{orderId}", { list: undefined }),
  { title: "Order record", mimeType: "application/json" },
  async (uri, { orderId }) => ({
    contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await orders.get(orderId)) }],
  })
)

That list: undefined is not an omission. The ResourceTemplate constructor requires the list callback to be stated, even as undefined, so that forgetting resource listing cannot happen by accident. Return matching URIs from it when a client should be able to browse the template's instances; leave it undefined when the variables only make sense if the client already knows them.

Template variables are destructured into the reader, so they must be identifiers. A URI needs a scheme. The generator checks both, and leaves out a resource that fails either rather than emitting code that does not compile.

Prompts: reusable messages with string arguments

Most hosts expose a prompt as a slash command — /summarize-order — that fills in arguments and sends the resulting message. Every argument is a string; that is what MCP prompt arguments are, so argsSchema is a shape of Zod strings:

server.registerPrompt(
  "summarize-order",
  {
    title: "Summarize an order",
    description: "Explain an order's status to the customer in plain words.",
    argsSchema: { orderId: z.string().describe("The order to summarize.") },
  },
  ({ orderId }) => ({
    messages: [
      {
        role: "user",
        content: { type: "text", text: `Look up order ${orderId} and explain its status in two sentences.` },
      },
    ],
  })
)

The generator writes the body with {{arg}} placeholders and substitutes them — and reports a placeholder that is not a declared argument, because it would otherwise ship as literal text.

stdio or Streamable HTTP

stdio is for a server a host spawns as a child process on the same machine. The host talks over stdin and stdout — which is why an MCP server must only ever log to stderr. A console.log in a tool handler corrupts the protocol stream.

claude mcp add orders -- node /absolute/path/to/orders-mcp-server/dist/index.js

Claude Desktop and most other hosts take the same thing as a JSON entry under mcpServers with command, args and an env block for credentials.

Streamable HTTP is for a server that runs somewhere else — the shape you want for a streamable HTTP MCP server. The simplest shape to scale is stateless: a fresh server and transport per request, no session id, plain JSON responses:

app.post("/mcp", async (req, res) => {
  const server = createServer()
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  })
  res.on("close", () => { void transport.close(); void server.close() })
  await server.connect(transport)
  await transport.handleRequest(req, res, req.body)
})

Creating the server per request, not just the transport, is what keeps request ids from colliding across concurrent clients. Hosts connect with { "type": "http", "url": "…/mcp" }. The generator can emit either, or both behind a TRANSPORT environment variable.

Test it before you install it

npm install && npm run build
npx @modelcontextprotocol/inspector node dist/index.js

The MCP Inspector connects to the built server, lists its tools, resources and prompts, and lets you call each one with real arguments. It is the fastest way to find a description that routes badly or an output that does not match its schema — before a model finds it for you, and before you plug the server into Claude Desktop, Claude Code, Cursor, or another host.

Where to start

npm install @modelcontextprotocol/sdk zod, one registerTool, and a StdioServerTransport. If you would rather see the whole project first — the tool with its output schema, a template resource with its variables destructured, a prompt with arguments, the stateless HTTP endpoint, and the README with the exact host config — the MCP server generator produces it as a downloadable .zip, free and without an account.

Every agent generator on this site mounts MCP servers: the Claude Agent SDK, OpenAI Agents SDK, Google ADK, LangChain Deep Agents and Vercel Eve generators each take a stdio command or an HTTP URL, which is exactly what this server's README hands you.

Q&A

Question: How should I decide whether something belongs in a tool, resource, or prompt?

Short answer: Use a tool when the model needs to act or fetch computed data with structured inputs. Use a resource when the client should read content by URI. Use a prompt when you want a reusable message template — often exposed as a host slash command — with string arguments.

Question: Why is the tool description so important if the input schema already defines the arguments?

Short answer: The schema tells the model what fields it can fill in, but the description tells it whether the tool is the right one to call. A good description explains what the tool does, what it returns, and when not to use it — which is why the example points the model to orders_refund for action instead of lookup.

Question: When should a ResourceTemplate provide a list callback instead of list: undefined?

Short answer: Provide a list callback when clients should browse or discover concrete URIs for the template. Leave it undefined when the variables only matter if the client already has them, such as an orderId arriving from another flow. The constructor makes the choice explicit so resource listing cannot be forgotten by accident.

Question: What is the practical difference between stdio and Streamable HTTP for deployment?

Short answer: stdio is for a server the host launches as a local child process, talking over stdin and stdout — so logs must go to stderr to keep the protocol stream clean. Streamable HTTP is for a server running somewhere else, with hosts connecting to an HTTP endpoint. In the stateless shape, a fresh server and transport per request keeps request ids from colliding across concurrent clients.

Question: Why test with the MCP Inspector before installing the server in a host?

Short answer: The Inspector connects to the built server, lists its tools, resources, and prompts, and lets you call each with real arguments. That catches unclear descriptions, bad routing, or handler output that does not match its schema before a model hits it inside Claude Desktop, Claude Code, Cursor, or another host.

Target-specific scaffolding

Continue this learning path

Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.

Explore platform generators

Design your application

Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.

Open projects

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.