
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will build the same bookshop search tool from lesson 14, this time in TypeScript. You will register a tool with a Zod schema and serve it over stdio with a server factory. Then you will test both protocol eras and compile it for a host.
The TypeScript SDK works differently from the Python one in a few places. Schemas are explicit Zod objects instead of type hints. Servers are built by a factory function. And a few names changed in v2, so v1 tutorials will mislead you.
The code adapts the TypeScript SDK's first-server guide and Tools guide. It ran on 28 October 2026 with @modelcontextprotocol/server 2.4.0, zod 4.6.7 and Node.js 24.
Key Takeaways
- Install
@modelcontextprotocol/server, not the v1 package@modelcontextprotocol/sdk. server.registerTool(name, config, handler)registers a tool; the ZodinputSchemadrives the advertised JSON Schema, argument validation and handler types.serveStdio(createServer)takes a factory that returns a fresh server, and it serves both the 2026-07-28 era and older clients.- Arguments that fail the schema come back as
isError: trueresults before your handler runs. - Log with
console.error(); oneconsole.log()corrupts a stdio server.
Step 1: Start from the TypeScript project
Use the bookshop-ts folder from lesson 13. It should have "type": "module" in package.json and these packages installed:
npm install @modelcontextprotocol/server zod tsx
The TypeScript SDK README says tool schemas use Standard Schema, so Zod v4 works, as do Valibot and ArkType. This lesson uses Zod.
Step 2: Write the server
Create src/index.ts:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const CATALOG: Record<string, string> = {
Dune: 'Frank Herbert',
Neuromancer: 'William Gibson',
'The Left Hand of Darkness': 'Ursula K. Le Guin'
};
function createServer(): McpServer {
const server = new McpServer({ name: 'bookshop', version: '1.0.0' });
server.registerTool(
'search_books',
{
description: 'Search the catalog by title or author. Returns matching titles.',
inputSchema: z.object({
query: z.string().describe('Part of a title or author name'),
limit: z.number().int().min(1).max(20).optional()
})
},
async ({ query, limit }) => {
const needle = query.toLowerCase();
const hits = Object.entries(CATALOG)
.filter(([title, author]) =>
title.toLowerCase().includes(needle) || author.toLowerCase().includes(needle))
.map(([title]) => title)
.slice(0, limit ?? 5);
return { content: [{ type: 'text', text: hits.join('\n') || 'No matches.' }] };
}
);
return server;
}
void serveStdio(createServer);
console.error('bookshop MCP server running on stdio');
What each part does:
new McpServer({ name, version })creates the server. Clients see both values.registerTooltakes a name, a config and an async handler. In v2 it replaces the v1tool()method, per the Tools guide.inputSchemais a Zod object. The guide says the SDK derives the JSON Schema the model sees from it, validates arguments before the handler runs, and infers the handler's argument types..describe()text becomes the argument'sdescriptionin the schema. It is the only documentation the model gets for that argument.- The handler returns
content: a list of typed blocks. Here it is onetextblock. serveStdio(createServer)reads requests on stdin and writes responses on stdout. It calls your factory to build the server for each connection.
The official Build an MCP server tutorial explains why a factory matters. serveStdio negotiates the protocol version with each client, so the same code answers 2026-07-28 clients and older ones.
You may see a different pattern in the SDK README: new StdioServerTransport() and server.connect(transport). Both are official. This lesson follows the tutorial's factory pattern.
Step 3: Run it
npx tsx src/index.ts
You should see only the banner on stderr. Then nothing happens, which is correct. A stdio server waits for a client to speak first. Press Ctrl+C to stop it.
Step 4: Test both eras with the Inspector CLI
List the tools in the modern era, then the legacy era:
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts \
--protocol-era modern --method tools/list --format json
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts \
--protocol-era legacy --method tools/list --format json
Both runs returned the same tool on 28 October 2026. The advertised input schema was:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"query": { "type": "string", "description": "Part of a title or author name" },
"limit": { "type": "integer", "minimum": 1, "maximum": 20 }
},
"required": ["query"]
}
Notice that .optional() kept limit out of required, and .int().min(1).max(20) became integer with bounds.
Now call it with a value the schema rejects:
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts \
--method tools/call --tool-name search_books \
--tool-args-json '{"query":"x","limit":99}' --format json
The result was an error the model can read, and the Inspector exited with code 5:
{"result":{"content":[{"type":"text","text":"Input validation error: Invalid arguments for tool search_books: limit: Too big: expected number to be <=20"}],"isError":true}}
Your handler never ran. That is the SDK doing input validation for you.
Step 5: Compile it for a host
tsx is great while learning. Hosts are simpler to configure with plain node and compiled JavaScript. Install TypeScript and add a tsconfig.json like the one in the official tutorial:
npm install -D typescript @types/node
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
npx tsc
npx @modelcontextprotocol/inspector --cli node build/index.js \
--protocol-era modern --method initialize --format json
The initialize probe should report "protocolVersion":"2026-07-28". This build step ran with TypeScript 7.0.2.
To use the server in Claude Desktop, the tutorial's config adds an entry under mcpServers with "command": "node" and the absolute path to build/index.js in args. Restart the host afterward.
Python vs TypeScript at a glance
| Task | Python SDK | TypeScript SDK |
|---|---|---|
| Create a server | MCPServer("Bookshop") | new McpServer({ name, version }) |
| Register a tool | @mcp.tool() decorator | server.registerTool(name, config, handler) |
| Input schema | Type hints plus Field | Zod object in inputSchema |
| Tool description | Docstring | description in config |
| Serve over stdio | mcp.run() | serveStdio(createServer) |
| Safe logging | logging module | console.error() |
Try it
Add a get_author tool that takes an exact title. When the title is missing, return { content: [...], isError: true } with a message that lists the known titles. Then:
- Call it with
"Dun"through the Inspector and confirm exit code 5. - Call it with
"Dune"and confirm the author comes back. - Run
tools/listin both eras and confirm the two tools appear in the same order. The tools spec says servers should return tools in a deterministic order.
Recap
- Install
@modelcontextprotocol/serverand useregisterToolwith a ZodinputSchema. - Describe every argument with
.describe(); the model reads nothing else. - Serve with
serveStdio(createServer)so one build works for both protocol eras. - Use
tsxwhile learning andtscfor a host.
Next step
Lesson 16 adds resources, the data your server exposes for the app to read. For how hosts and servers find each other's features, see MCP capabilities and the official SDKs overview.
Course navigation
Previous lesson: Lesson 14: Build Your First Tool in Python.
Next lesson: Lesson 16: Add Resources.
Back to the Learn MCP course outline.
Frequently asked questions
Why zod/v4 in the import?
The SDK's own docs import * as z from 'zod/v4'. The official tutorial imports from zod. With Zod 4 installed, both resolve to the v4 API, and this lesson's code ran with the zod/v4 form.
Do I need Express or another web framework?
Not for stdio. For HTTP, the SDK publishes optional thin adapters for Express, Fastify, Hono and Node's http module. Lesson 21 uses the Node one.
What happened to server.tool() and McpError?
They are v1 names. The v2 docs say registerTool replaces tool(), and ProtocolError replaces McpError. The SDK's upgrade guide lists the rest and mentions a codemod.
Can one server serve old and new clients?
Yes. Both serveStdio and the HTTP handler in v2 serve the legacy era and the 2026-07-28 era from the same factory. Testing with --protocol-era legacy and modern confirms it.
Should a tool return text or structured data?
Both, when the data has a shape. Add an outputSchema and return structuredContent next to content. The tools spec says a tool returning structured content should also return the serialized JSON as text.











