Mert Özen Her Satırda Daha İleri
Yapay Zeka AI Agent'ları ve MCP — #11

TypeScript ile MCP Server: Zod Şemaları, Tool/Resource Kaydı ve Node.js

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
TypeScript ile MCP Server: Zod Şemaları, Tool/Resource Kaydı ve Node.js

TypeScript'te MCP server için 2026-07-28'i destekleyen v2 SDK'yı (@modelcontextprotocol/server 2.4.0) seçer, tool'ları Zod 4 schema'larıyla kaydeder, serveStdio ve createMcpHandler ile stdio ve Streamable HTTP'den sunarsın. Aynı Bookstore server'ını C# sürümüyle karşılaştırıp aynı girdiye farklı cevaplar veren schema'ları, Zod 3 uyumsuzluğunu, npx ile dağıtımı ve supply chain riskini gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar Node.js 24.22, TypeScript 7.0.2, @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2 ve Zod 4.6.7 ile derlenip denenmiştir; karşılaştırma için .NET 10 ve ModelContextProtocol 2.4.0 kullanıldı.

Önce Tahmin Et

9. yazıdaki C# Bookstore server'ının aynısını bu yazıda TypeScript ile yazacağız: aynı tool adları, aynı açıklamalar, aynı katalog. Sonra iki server'a da aynı çağrıyı göndereceğiz: search_books, argümanlar {"query": "", "genre": "poetry"}. Boş bir arama ve katalogda olmayan bir tür. İki server aynı cevabı verir mi?

Vermiyor. C# server No books match ''. Try a shorter query or remove the genre filter. diyor; TypeScript server ise tool'u hiç çalıştırmadan bir doğrulama hatası dönüyor. İkisi de MCP (Model Context Protocol) spec'ine uygun. Fark, schema'nın nerede ve ne kadar sıkı tanımlandığında. Bu yazının asıl konusu da bu: aynı protokolü konuşan iki implementasyonun "aynı server" olmadığını görmek.

SDK Sürüm Kararı: v1 mi, v2 mi?

TypeScript tarafında iki paket ailesi var ve hangisini seçeceğin 2026-07-28 desteğini doğrudan belirliyor. npm'de 8 Kasım 2026'da gördüğüm durum şu:

PaketSon sürümProtokol
@modelcontextprotocol/sdk (v1, tek paket)1.32.2 (23 Ekim 2026)LATEST_PROTOCOL_VERSION = '2025-11-25'
@modelcontextprotocol/server (v2)2.4.0 (23 Ekim 2026)2026-07-28 ve 2025 çağı
@modelcontextprotocol/client (v2)2.4.02026-07-28 ve 2025 çağı
@modelcontextprotocol/node (v2 Node.js adapter'ı)2.1.2-

v1 paketinin içine baktım: desteklenen sürüm listesi 2025-11-25 ile başlıyor, 2026-07-28 yok. v2 ise tek paket yerine bölünmüş bir yapıya geçti: server, client, core ve framework adapter'ları (node, express, fastify, hono) ayrı paketler. Server paketinin README'si v2'yi "stable release line" olarak tanımlıyor; npm'de latest etiketi de 2.4.0'ı gösteriyor. Yeni bir server için karar bu yüzden kolay: v2. Elinde v1 ile yazılmış bir server varsa SDK'nın (Software Development Kit) migration rehberi ve @modelcontextprotocol/codemod paketi var.

v2'de dikkat edilecek bir ayrıntı: elle kurulan bir McpServer varsayılan olarak hâlâ 2025 protokolünü konuşuyor. 2026-07-28'i açan şey, stdio için serveStdio, HTTP (HyperText Transfer Protocol) için createMcpHandler giriş noktaları. Bu yazıdaki kod ikisini de kullanıyor.

Proje Kurulumu: package.json ve tsconfig

Paketleri sürümleriyle sabitledim; neden sabitlediğimi Sık Düşülen Tuzaklar'da konuşacağız:

{
  "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"]
}

"types": ["node"] satırı süs değil. Server paketinin README'si, TypeScript 6.0 ve sonrası @types/* paketlerini artık otomatik eklemediği için bu satırın gerektiğini yazıyor; paketin tip tanımları Buffer'a referans veriyor. "type": "module" da önemli: ilk denememde bu satırı derlemeden sonra eklediğim için çıktı CommonJS kaldı ve Node.js ReferenceError: exports is not defined in ES module scope hatası verdi. module: nodenext, çıktının biçimine package.json'a bakarak karar veriyor.

McpServer, Zod ve Tool Kaydı

Katalog 9. yazıdakiyle aynı sekiz kitap:

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 },
];

Server'ı her seferinde yeni bir instance üreten bir factory fonksiyonu olarak yazıyoruz; birazdan neden factory olduğunu göreceğiz:

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;
}

C#'ta schema'yı method imzası ve [Description] attribute'ları üretiyordu; burada Zod object'i hem schema'nın kaynağı hem de runtime doğrulayıcısı. Handler'ın aldığı { query, genre, maxResults } doğrulanmış ve default'ları uygulanmış veri; TypeScript tipleri de schema'dan çıkarılıyor. v2'de inputSchema bir z.object(...) olmalı; v1'den kalma { query: z.string() } biçimindeki düz shape hâlâ çalışıyor ama tip tanımında deprecated olarak işaretli.

Fiyat biçimine dikkat: toFixed(2) locale'den bağımsız, her makinede 42.50 yazar. 9. yazıda C#'ta aynı satırı FormattableString.Invariant ile düzeltmek zorunda kalmıştık.

stdio ve Streamable HTTP: Aynı Factory, İki Giriş

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

// stdout protokole ait: tanılama mesajları stderr'e
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, her istek için bir instance
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'));

Factory'nin sebebi burada. createMcpHandler her HTTP isteği için factory'den yeni bir McpServer alıyor; protokolde session olmadığı için isteğe özel state'in yaşayacağı paylaşılan bir instance da yok. serveStdio ise bağlantının ilk mesajına bakıp çağı seçiyor: initialize gelirse 2025 kurallarıyla, _meta taşıyan bir istek gelirse 2026-07-28 ile devam ediyor. HTTP server bilerek yalnız 127.0.0.1'e bind ediliyor; 10. yazıdaki DNS (Domain Name System) rebinding uyarısı burada da geçerli.

stdio'yu 8. yazıdaki gibi elle deneyelim; derleme npm run build, sonra server/discover ve iki tools/call:

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}

İlk satır stderr'den geliyor, gerisi stdout'tan. id: 3'teki maxResults: 50 çağrısı Zod'a takıldı ve handler hiç çalışmadı; cevaplar yine gönderim sırasına göre değil, id'ye göre eşleşiyor. HTTP tarafında aynı server'a curl ile:

{"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

MCP Inspector 2.14.0 CLI (Command-Line Interface), 2025 usulü initialize ile bağlanmasına rağmen aynı endpoint'ten search_books sonucunu sorunsuz aldı; createMcpHandler varsayılan olarak iki çağa da hizmet ediyor.

Aynı Host'tan İki Server: Davranış Farkları

İki server'a aynı üç çağrıyı gönderdim. Önce 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 {"query":"brook","maxResults":50}, 4 {"query":"","genre":"poetry"}, 5 {"query":""}. C# 50'yi sessizce 10'a kırptı, boş sorguyla katalogdaki ilk beş kitabı döndü. TypeScript aynı 4 numaralı çağrıya:

[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\""]

Farkın kaynağı tools/list'teki schema'larda görünüyor. TypeScript'in ürettiği:

{"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"]}

C#'ın ürettiği schema'da ise genre ["string","null"], maxResults sınırsız bir integer; aralık yalnız açıklamada, düz metin olarak yazıyor. Model için bu fark gerçek: enum'u gören bir model geçersiz tür göndermeye daha az meyilli, gönderirse de anlamlı bir hata alıp düzeltebiliyor. C#'ta aynı sıkılığı istiyorsan genre'yi enum yapabilir, aralık için doğrulamayı tool içinde açıkça yazabilirsin. Önemli olan, iki server'ı aynı host'a bağladığında "aynı tool adı aynı davranış demek" varsaymamak.

İkinci fark taşımada: C# server'ı HTTP'de cevabı SSE (Server-Sent Events) stream'i olarak, data: satırlarıyla döndü; TypeScript server'ı düz application/json. İkisi de spec'e uygun ve client ikisini de desteklemek zorunda; ama araya bir proxy koyarken ya da curl ile hata ayıklarken bilmen gereken bir fark.

npm Paketi Olarak Dağıtmak

package.json'daki bin ve files alanları server'ı npx ile çalışan bir pakete çeviriyor. npm pack çıktısı:

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

Tarball'ı registry'ye yüklemeden boş bir klasörden npx --yes ../bookstore-mcp-ts/bookstore-mcp-ts-1.0.0.tgz ile çalıştırdım ve stdin'e bir check_stock isteği verdim; cevap "title":"Numbers Never Lie","inStock":2 ile geldi. Host configuration'ında "command": "npx", "args": ["-y", "bookstore-mcp-ts"] yazmak bu kadar kolay olduğu için MCP ekosisteminde en yaygın dağıtım yolu bu. Kolaylığın bedeli de var; birazdan.

Üretimde Dikkat

Birincisi, iki SDK'yı aynı sürüm çizgisinde tut. C# ve TypeScript SDK'ları 2.4.0'da buluşmuş görünüyor, ama varsayılanlar aynı değil: cache alanları (ttlMs: 0, cacheScope: "private") aynı, cevap biçimi farklı: SSE ya da JSON (JavaScript Object Notation). Her iki server için de aynı entegrasyon testlerini, aynı client'la çalıştır.

İkincisi, factory'nin maliyeti. createMcpHandler her istekte buildServer()'ı çağırıyor; bu fonksiyonda veritabanı bağlantı havuzu açmak ya da büyük bir dosya okumak her isteği yavaşlatır. Pahalı kaynakları factory'nin dışında bir kez oluşturup içeri ver.

Üçüncüsü, HTTP'de authentication. Bu yazıdaki HTTP server authentication'sız; yalnız 127.0.0.1'e bind edildiği için bu haliyle bir geliştirme aracı. Yayına açacaksan 10. yazıdaki kurallar aynen geçerli: OAuth resource server, audience doğrulaması, kullanıcıya bağlı handle'lar. v2'nin tool kaydında scopeChallenge seçeneği de var; tool bazında scope istemek için ona bak.

Sık Düşülen Tuzaklar

Birincisi, Zod sürüm uyumsuzluğu. Ayrı bir klasörde projeye zod@3.25.76 kurup aynı tool'u kaydetmeye çalıştım. npm iki Zod kopyası kurdu, biri SDK'nın kendi bağımlılığı olarak:

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

ve derleyici şunu söyledi:

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'.

Zod 3.25'in zod/v4 alt yolunu denediğimde de aynı hata geldi. Server paketi zod ^4.2.0'a bağımlı; projendeki Zod'u da 4.2 ve üstüne çıkar ve npm ls zod çıktısında tek bir kopya gördüğünden emin ol.

İkincisi, supply chain. npx -y package-name, kullanıcının makinesinde her başlatmada registry'den gelen kodu çalıştırmak demek. Web Güvenliği serisindeki "OWASP (Open Worldwide Application Security Project) A03: Software Supply Chain Failures" yazısında konuştuğumuz her şey burada iki kat geçerli, çünkü bu kod bir de model tarafından tetikleniyor. Kendi server'ını yayımlıyorsan bağımlılıkları sabitle, lockfile'ı commit et ve npm provenance ile yayımla; başkasının server'ını kullanıyorsan host configuration'ında sürümü sabitle (bookstore-mcp-ts@1.0.0), @latest yazma.

Üçüncüsü, stdio'yu bozmak. TypeScript'te de kural aynı: stdout protokole ait. Tool içinde process.stdout.write ile yazılan bir metin JSON cevabının başına yapışıyor:

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}

Bu satır artık geçerli JSON değil; 9. yazıda C# tarafında aynı durumun bir dakikalık timeout'a dönüştüğünü görmüştük. console.log ise ayrı bir satır yazdığı için Inspector onu atlayıp cevabı gösterdi, yani hata görünmez kalabiliyor. Log için console.error ya da stderr'e yazan bir logger kullan.

Küçük Bir Deneme

★☆☆ search_books'un genre schema'sını z.string().optional() yap ve 4 numaralı çağrıyı tekrarla. Cevap C#'ınkiyle aynı oldu mu? tools/list'teki schema'da ne değişti?

★★☆ check_stock'un handler'ı outputSchema'ya uymayan bir nesne döndürsün, örneğin inStock yerine stock. SDK bunu yakalıyor mu, yakalıyorsa client ne görüyor? Aynı şeyi C#'ta record'un property adını değiştirerek dene.

★★★ http.ts'e Origin doğrulaması ekle: Origin header'ı varsa ve http://localhost:5173 değilse 403 dön. Sonra curl ile -H "Origin: http://evil.example" göndererek dene. Bu kontrolü toNodeHandler'dan önce mi sonra mı yapmalısın?

Elimizde artık iki dilde, üç farklı biçimde çalışan Bookstore server'ları var. Bir sonraki yazıda masanın öbür tarafına geçiyoruz: C# ile bir MCP client yazıp bu server'ların tool'larını IChatClient'a ve bir agent'a bağlıyor, isim çakışmalarını çözüyor ve 10. yazıdaki onay isteğini client tarafında karşılıyoruz.