Mert Özen Further With Every Line
Artificial Intelligence AI Agents and MCP — #11

Building an MCP Server in TypeScript: Zod Schemas, Tool/Resource Registration and Node.js

Mert Özen Nov 8, 2026 12 min 4 views
Building an MCP Server in TypeScript: Zod Schemas, Tool/Resource Registration and Node.js

For an MCP server in TypeScript you pick the v2 SDK (@modelcontextprotocol/server 2.4.0), which supports 2026-07-28, register tools with Zod 4 schemas and serve them over stdio and Streamable HTTP with serveStdio and createMcpHandler. Comparing the same Bookstore server with its C# version, we see with real output how schemas give different answers to the same input, the Zod 3 incompatibility, distribution with npx and the supply chain risk.

Version note: The code in this post was built and run with Node.js 24.22, TypeScript 7.0.2, @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2 and Zod 4.6.7; .NET 10 and ModelContextProtocol 2.4.0 were used for comparison.

Guess First

In this post we write the same Bookstore server as the C# one from post 9, this time in TypeScript: same tool names, same descriptions, same catalog. Then we send both servers the same call: search_books with the arguments {"query": "", "genre": "poetry"}. An empty search and a genre that is not in the catalog. Do both servers give the same answer?

They don't. The C# server says No books match ''. Try a shorter query or remove the genre filter.; the TypeScript server returns a validation error without ever running the tool. Both conform to the MCP (Model Context Protocol) spec. The difference lies in where the schema is defined and how strict it is. That is the real subject of this post: two implementations speaking the same protocol are not "the same server".

The SDK Decision: v1 or v2?

On the TypeScript side there are two package families, and which one you pick directly determines 2026-07-28 support. This is what I saw on npm on 8 November 2026:

PackageLatest versionProtocol
@modelcontextprotocol/sdk (v1, single package)1.32.2 (23 October 2026)LATEST_PROTOCOL_VERSION = '2025-11-25'
@modelcontextprotocol/server (v2)2.4.0 (23 October 2026)2026-07-28 and the 2025 era
@modelcontextprotocol/client (v2)2.4.02026-07-28 and the 2025 era
@modelcontextprotocol/node (v2 Node.js adapter)2.1.2-

I looked inside the v1 package: its list of supported versions starts with 2025-11-25; 2026-07-28 is not there. v2 moved from a single package to a split layout: server, client, core and framework adapters (node, express, fastify, hono) are separate packages. The server package's README describes v2 as the "stable release line", and the latest tag on npm points to 2.4.0. So for a new server the decision is easy: v2. If you have a server written with v1, there is the SDK's (Software Development Kit) migration guide and the @modelcontextprotocol/codemod package.

One detail to watch in v2: a hand-built McpServer still speaks the 2025 protocol by default. What turns on 2026-07-28 are the entry points: serveStdio for stdio and createMcpHandler for HTTP (HyperText Transfer Protocol). The code in this post uses both.

Project Setup: package.json and tsconfig

I pinned the packages to exact versions; we discuss why in Common Pitfalls:

{
  "name": "bookstore-mcp-ts",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "bookstore-mcp-ts": "dist/stdio.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p .",
    "start:http": "node dist/http.js"
  },
  "dependencies": {
    "@modelcontextprotocol/node": "2.1.2",
    "@modelcontextprotocol/server": "2.4.0",
    "zod": "4.6.7"
  },
  "devDependencies": {
    "@types/node": "24.19.2",
    "typescript": "7.0.2"
  }
}
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"],
    "declaration": false
  },
  "include": ["src"]
}

The "types": ["node"] line is not decoration. The server package's README says it is needed because TypeScript 6.0 and later no longer include @types/* packages automatically, and the package's type definitions reference Buffer. "type": "module" matters too: in my first attempt I added this line after compiling, so the output stayed CommonJS and Node.js failed with ReferenceError: exports is not defined in ES module scope. module: nodenext decides the output format by looking at package.json.

McpServer, Zod and Tool Registration

The catalog is the same eight books as in post 9:

export interface Book {
  id: number;
  title: string;
  author: string;
  genre: string;
  price: number;
  stock: number;
}

export const books: Book[] = [
  { id: 1, title: 'Distributed Patience', author: 'Alan Brook', genre: 'software', price: 42.5, stock: 12 },
  { id: 2, title: 'The Last Lighthouse', author: 'Mira Holt', genre: 'novel', price: 18.9, stock: 0 },
  { id: 3, title: 'Coffee and Compilers', author: 'Ada Stone', genre: 'software', price: 35.0, stock: 4 },
  { id: 4, title: 'Winter in Kyoto', author: 'Ken Sato', genre: 'novel', price: 21.0, stock: 7 },
  { id: 5, title: 'A Short History of Maps', author: 'Lena Ford', genre: 'history', price: 27.5, stock: 3 },
  { id: 6, title: 'Async All the Way', author: 'Alan Brook', genre: 'software', price: 39.9, stock: 9 },
  { id: 7, title: 'The Quiet Orchard', author: 'Mira Holt', genre: 'novel', price: 16.5, stock: 15 },
  { id: 8, title: 'Numbers Never Lie', author: 'Omar Reed', genre: 'science', price: 24.0, stock: 2 },
];

We write the server as a factory function that builds a new instance every time; we will see why in a moment:

import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod';
import { books } from './catalog.js';

export function buildServer(): McpServer {
  const server = new McpServer(
    { name: 'bookstore-ts', version: '1.0.0' },
    { capabilities: { tools: {}, resources: {}, prompts: {} } },
  );

  server.registerTool(
    'search_books',
    {
      description: "Searches the Bookstore catalog by title or author. Returns at most maxResults books as 'id | title | author | price'.",
      inputSchema: z.object({
        query: z.string().min(1).describe("Words from the title or the author name, e.g. 'Brook'"),
        genre: z.enum(['software', 'novel', 'history', 'science']).optional().describe('Optional genre filter'),
        maxResults: z.int().min(1).max(10).default(5).describe('Maximum number of books to return (1-10)'),
      }),
      annotations: { readOnlyHint: true },
    },
    async ({ query, genre, maxResults }) => {
      const q = query.toLowerCase();
      const found = books
        .filter(b => b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q))
        .filter(b => genre === undefined || b.genre === genre)
        .slice(0, maxResults);
      const text = found.length === 0
        ? `No books match '${query}'. Try a shorter query or remove the genre filter.`
        : found.map(b => `${b.id} | ${b.title} | ${b.author} | ${b.price.toFixed(2)} EUR`).join('\n');
      return { content: [{ type: 'text', text }] };
    },
  );

  const stockSchema = z.object({
    bookId: z.int(),
    title: z.string(),
    inStock: z.int(),
    available: z.boolean(),
  });

  server.registerTool(
    'check_stock',
    {
      description: 'Returns the current stock for a book id.',
      inputSchema: z.object({ bookId: z.int().describe('Book id from search_books') }),
      outputSchema: stockSchema,
      annotations: { readOnlyHint: true },
    },
    async ({ bookId }) => {
      const book = books.find(b => b.id === bookId);
      if (book === undefined) {
        return {
          isError: true,
          content: [{ type: 'text', text: `Book ${bookId} does not exist. Call search_books to find a valid id.` }],
        };
      }
      const output = { bookId: book.id, title: book.title, inStock: book.stock, available: book.stock > 0 };
      return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
    },
  );

  server.registerResource(
    'book',
    new ResourceTemplate('bookstore://books/{bookId}', { list: undefined }),
    { description: 'One book as JSON.', mimeType: 'application/json' },
    async (uri, { bookId }) => {
      const book = books.find(b => b.id === Number(bookId));
      if (book === undefined) {
        throw new Error(`Book ${bookId} not found.`);
      }
      return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(book) }] };
    },
  );

  server.registerPrompt(
    'recommend_books',
    {
      description: 'Asks the assistant for book recommendations in a genre.',
      argsSchema: z.object({ genre: z.string(), maxPrice: z.string() }),
    },
    ({ genre, maxPrice }) => ({
      messages: [{
        role: 'user',
        content: {
          type: 'text',
          text: `Recommend up to three ${genre} books under ${maxPrice} EUR. Use search_books and check_stock; only recommend books that are in stock.`,
        },
      }],
    }),
  );

  return server;
}

In C# the schema came from the method signature and the [Description] attributes; here the Zod object is both the source of the schema and the runtime validator. The { query, genre, maxResults } the handler receives is validated data with defaults applied, and the TypeScript types are inferred from the schema. In v2 the inputSchema should be a z.object(...); the plain shape form left over from v1, { query: z.string() }, still works but is marked deprecated in the type definitions.

Note the price format: toFixed(2) is locale-independent and writes 42.50 on every machine. In post 9 we had to fix the same line in C# with FormattableString.Invariant.

stdio and Streamable HTTP: One Factory, Two Entry Points

#!/usr/bin/env node
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './server.js';

// stdout belongs to the protocol: diagnostics go to stderr
console.error('bookstore-ts: serving MCP on stdio');
serveStdio(() => buildServer());
import { createServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { buildServer } from './server.js';

const handler = createMcpHandler(() => buildServer()); // stateless, one instance per request
const mcp = toNodeHandler(handler);

createServer((req, res) => {
  if (req.url === '/mcp') {
    void mcp(req, res);
    return;
  }
  res.writeHead(404).end();
}).listen(19853, '127.0.0.1', () => console.error('bookstore-ts: http://127.0.0.1:19853/mcp'));

Here is the reason for the factory. createMcpHandler takes a new McpServer from the factory for every HTTP request; with no sessions in the protocol, there is no shared instance for request-specific state to live in. serveStdio looks at the connection's first message to choose the era: on initialize it continues under 2025 rules, on a request carrying _meta it continues with 2026-07-28. The HTTP server deliberately binds only to 127.0.0.1; the DNS (Domain Name System) rebinding warning from post 10 applies here too.

Let's try stdio by hand as in post 8; build with npm run build, then server/discover and two tools/call requests:

bookstore-ts: serving MCP on stdio
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"resultType":"complete","ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}
{"result":{"content":[{"type":"text","text":"Input validation error: Invalid arguments for tool search_books: maxResults: Too big: expected number to be <=10"}],"isError":true,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}}},"jsonrpc":"2.0","id":3}
{"result":{"content":[{"type":"text","text":"{\"bookId\":3,\"title\":\"Coffee and Compilers\",\"inStock\":4,\"available\":true}"}],"structuredContent":{"bookId":3,"title":"Coffee and Compilers","inStock":4,"available":true},"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}}},"jsonrpc":"2.0","id":2}

The first line comes from stderr, the rest from stdout. The maxResults: 50 call with id: 3 was stopped by Zod and the handler never ran; responses are again matched by id, not by the order they were sent. On the HTTP side, the same server with curl:

{"result":{"content":[{"type":"text","text":"{\"bookId\":2,\"title\":\"The Last Lighthouse\",\"inStock\":0,\"available\":false}"}],"structuredContent":{"bookId":2,"title":"The Last Lighthouse","inStock":0,"available":false},"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}
HTTP 200 application/json

The MCP Inspector 2.14.0 CLI (Command-Line Interface), even though it connects with the 2025-style initialize, got the search_books result from the same endpoint without trouble; createMcpHandler serves both eras by default.

Two Servers, One Host: Behavior Differences

I sent the same three calls to both servers. First C#:

[3,"1 | Distributed Patience | Alan Brook | 42.50 EUR\n6 | Async All the Way | Alan Brook | 39.90 EUR"]
[4,"No books match ''. Try a shorter query or remove the genre filter."]
[5,"1 | Distributed Patience | Alan Brook | 42.50 EUR\n2 | The Last Lighthouse | Mira Holt | 18.90 EUR\n3 | Coffee and Compilers | Ada Stone | 35.00 EUR\n4 | Winter in Kyoto | Ken Sato | 21.00 EUR\n5 | A Short History of Maps | Lena Ford | 27.50 EUR"]

id 3 is {"query":"brook","maxResults":50}, 4 is {"query":"","genre":"poetry"}, 5 is {"query":""}. C# silently clamped 50 to 10 and returned the first five books of the catalog for an empty query. TypeScript, for the same call number 4:

[4,"Input validation error: Invalid arguments for tool search_books: query: Too small: expected string to have >=1 characters, genre: Invalid option: expected one of \"software\"|\"novel\"|\"history\"|\"science\""]

The source of the difference shows in the schemas in tools/list. The one TypeScript generates:

{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"query":{"type":"string","minLength":1,"description":"Words from the title or the author name, e.g. 'Brook'"},"genre":{"description":"Optional genre filter","type":"string","enum":["software","novel","history","science"]},"maxResults":{"default":5,"description":"Maximum number of books to return (1-10)","type":"integer","minimum":1,"maximum":10}},"required":["query"]}

In the schema C# generates, genre is ["string","null"] and maxResults is an unbounded integer; the range is only written as plain text in the description. For the model this difference is real: a model that sees the enum is less inclined to send an invalid genre, and if it does, it gets a meaningful error it can correct. If you want the same strictness in C#, you can make genre an enum and write the range validation explicitly inside the tool. What matters is not to assume "same tool name means same behavior" when you connect two servers to the same host.

The second difference is in transport: over HTTP the C# server returned the response as an SSE (Server-Sent Events) stream with data: lines; the TypeScript server returned plain application/json. Both conform to the spec and clients must support both; but it is a difference to know about when you put a proxy in between or debug with curl.

Distributing as an npm Package

The bin and files fields in package.json turn the server into a package that runs with npx. The output of npm pack:

npm notice 878B dist/catalog.js
npm notice 575B dist/http.js
npm notice 3.6kB dist/server.js
npm notice 274B dist/stdio.js
npm notice 451B package.json
npm notice filename: bookstore-mcp-ts-1.0.0.tgz
npm notice package size: 2.3 kB
npm notice total files: 5

Without uploading the tarball to a registry, I ran it from an empty folder with npx --yes ../bookstore-mcp-ts/bookstore-mcp-ts-1.0.0.tgz and fed a check_stock request to stdin; the answer came back with "title":"Numbers Never Lie","inStock":2. Because writing "command": "npx", "args": ["-y", "bookstore-mcp-ts"] in a host configuration is this easy, it is the most common distribution path in the MCP ecosystem. The convenience has a price; more on that shortly.

In Production

First, keep both SDKs on the same version line. The C# and TypeScript SDKs seem to have met at 2.4.0, but the defaults are not identical: the cache fields (ttlMs: 0, cacheScope: "private") match, the response format does not: SSE or JSON (JavaScript Object Notation). Run the same integration tests, with the same client, against both servers.

Second, the cost of the factory. createMcpHandler calls buildServer() on every request; opening a database connection pool or reading a large file in that function slows down every request. Create expensive resources once, outside the factory, and pass them in.

Third, authentication over HTTP. The HTTP server in this post has no authentication; since it binds only to 127.0.0.1, it is a development tool as it stands. If you expose it, the rules from post 10 apply unchanged: OAuth resource server, audience validation, handles bound to users. v2's tool registration also has a scopeChallenge option; look at it for per-tool scope requirements.

Common Pitfalls

First, Zod version mismatches. In a separate folder I installed zod@3.25.76 into the project and tried to register the same tool. npm installed two copies of Zod, one as the SDK's own dependency:

+-- @modelcontextprotocol/server@2.4.0
| +-- @modelcontextprotocol/core@2.4.0
| | `-- zod@4.6.7
| `-- zod@4.6.7
`-- zod@3.25.76

and the compiler said:

index.ts(7,5): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Type 'ZodObject<{ bookId: ZodNumber; }, "strip", ZodTypeAny, { bookId: number; }, { bookId: number; }>' is not assignable to type 'ZodRawShape'.

Trying the zod/v4 subpath of Zod 3.25 produced the same error. The server package depends on zod ^4.2.0; move your project's Zod to 4.2 or later too, and make sure npm ls zod shows a single copy.

Second, the supply chain. npx -y package-name means running code from the registry on the user's machine at every start. Everything we discussed in the "OWASP (Open Worldwide Application Security Project) A03: Software Supply Chain Failures" post of the Web Security series applies twice over here, because this code is also triggered by a model. If you publish your own server, pin dependencies, commit the lockfile and publish with npm provenance; if you use someone else's server, pin the version in the host configuration (bookstore-mcp-ts@1.0.0) and don't write @latest.

Third, breaking stdio. The rule is the same in TypeScript: stdout belongs to the protocol. Text written with process.stdout.write inside a tool sticks to the front of the JSON response:

Looking up order 1043... {"result":{"content":[{"type":"text","text":"Order 1043: Shipped"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"clog","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}

That line is no longer valid JSON; in post 9 we saw the same situation turn into a one-minute timeout on the C# side. console.log writes a separate line, so Inspector skipped it and showed the response, which means the bug can stay invisible. Use console.error or a logger that writes to stderr.

A Small Experiment

★☆☆ Change the genre schema of search_books to z.string().optional() and repeat call number 4. Is the answer the same as C#'s now? What changed in the schema in tools/list?

★★☆ Make the check_stock handler return an object that does not match the outputSchema, for example stock instead of inStock. Does the SDK catch it, and if so, what does the client see? Try the same in C# by renaming the record's property.

★★★ Add Origin validation to http.ts: if an Origin header is present and is not http://localhost:5173, return 403. Then test it with curl by sending -H "Origin: http://evil.example". Should this check run before or after toNodeHandler?

We now have Bookstore servers in two languages, running in three different shapes. In the next post we move to the other side of the table: we write an MCP client in C#, connect these servers' tools to IChatClient and to an agent, resolve name collisions, and handle the confirmation request from post 10 on the client side.