TypeScript ile kendi MCP sunucunuzu yazın
Bir AI asistanından "son yayınlanan blog yazılarını listele" ya da "veritabanındaki kullanıcı sayısını söyle" gibi bir şey istediğinizde, model tek başına bunu yapamaz. Eğitim verisinde olmayan, anlık değişen bilgiye erişmesi gerekir. Tam bu noktada MCP devreye giriyor.
MCP ne işe yarıyor?
Model Context Protocol (MCP), Anthropic'in Kasım 2024'te açık kaynak olarak yayınladığı bir protokol. Amacı basit: AI modelleri ile dış veri kaynakları, araçlar ve API'ler arasında standart bir iletişim katmanı sağlamak. Protokol JSON-RPC 2.0 üzerine kurulu. Bir MCP sunucusu yazdığınızda, Claude Desktop, Claude Code, Cursor gibi MCP destekleyen herhangi bir istemci o sunucuyu kullanabiliyor. Her istemci için ayrı entegrasyon yazmak gerekmiyor.
MCP'nin üç temel ilkesi (primitive) var: tools, resources ve prompts.
Tools, resources ve prompts
Bu üç kavram MCP'nin omurgası. Aralarındaki farkı net anlamak, sunucu tasarlarken doğru kararlar vermeyi sağlıyor.
Tools
Model tarafından tetiklenen fonksiyonlar. Yan etkisi olabilir: veritabanına yazma, API çağrısı yapma, dosya oluşturma. Model, kullanıcı isteğine göre hangi tool'u çağıracağına kendisi karar verir. Bir tool tanımlarken isim, açıklama ve JSON Schema formatında parametre şeması veriyorsunuz.
Resources
Salt okunur veri kaynakları. URI şeması ile adreslenir (örneğin blog://posts/recent). Model ya da istemci bu kaynakları okuyarak bağlama ekler. Yan etki beklenmez.
Prompts
Önceden tanımlanmış şablonlar. Kullanıcı bir prompt seçtiğinde, MCP sunucusu o şablonu parametrelerle doldurarak modele gönderilecek mesaj dizisini döner. Tekrar eden görevler için kullanışlı.
Proje yapısı ve kurulum
Bir blog API'sine bağlanan MCP sunucusu yazacağız. Sunucu şu yeteneklere sahip olacak: yazıları listeleme (resource), yeni yazı oluşturma (tool), yazı taslağı hazırlama prompt'u (prompt).
Önce projeyi oluşturalım:
mkdir blog-mcp-server && cd blog-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --inittsconfig.json içinde hedefi ayarlayın:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}package.json dosyasına "type": "module" eklemeyi unutmayın.
Sunucu iskeletini yazma
src/index.ts dosyasını oluşturalım. MCP SDK'sının McpServer sınıfı ve StdioServerTransport ile başlıyoruz. stdio transport, sunucunun stdin/stdout üzerinden iletişim kurmasını sağlıyor; Claude Desktop ve Claude Code bu yöntemi kullanıyor.
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: "blog-mcp",
version: "1.0.0",
});
// Basit bir in-memory blog deposu
interface BlogPost {
id: number;
title: string;
content: string;
publishedAt: string;
}
const posts: BlogPost[] = [
{
id: 1,
title: "MCP ile ilk adımlar",
content: "Model Context Protocol AI araçları için standart bir iletişim katmanı.",
publishedAt: "2025-01-15",
},
{
id: 2,
title: "TypeScript ipuçları",
content: "Discriminated union kullanarak tip güvenliğini artırabilirsiniz.",
publishedAt: "2025-02-01",
},
];Burada gerçek bir veritabanı yerine bellek içi dizi kullanıyorum. Gerçek projede bu kısmı Drizzle ORM ya da başka bir veritabanı katmanıyla değiştirirsiniz.
Resource tanımlama
Yazıları listelemek için bir resource ve bir resource template tanımlayalım:
// Tüm yazıları dönen sabit URI'li resource
server.resource("all-posts", "blog://posts", async (uri) => {
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(posts, null, 2),
},
],
};
});
// Tek bir yazıyı ID ile dönen resource template
server.resource(
"post-by-id",
"blog://posts/{id}",
{ description: "Belirli bir blog yazısını ID ile getirir" },
async (uri, { id }) => {
const post = posts.find((p) => p.id === Number(id));
if (!post) {
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: "Yazı bulunamadı",
},
],
};
}
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(post, null, 2),
},
],
};
}
);İlk resource çağrısı sabit bir URI alıyor. İkincisi {id} yer tutucusu ile bir URI template. MCP SDK bunu RFC 6570 formatında yorumluyor ve istemciden gelen parametreyi callback'e aktarıyor.
Tool tanımlama
Yeni yazı oluşturma işlemi yan etkiye sahip, bu yüzden tool olarak tanımlamak doğru:
server.tool(
"create-post",
"Yeni bir blog yazısı oluşturur",
{
title: z.string().min(1).describe("Yazı başlığı"),
content: z.string().min(10).describe("Yazı içeriği, en az 10 karakter"),
},
async ({ title, content }) => {
const newPost: BlogPost = {
id: posts.length + 1,
title,
content,
publishedAt: new Date().toISOString().split("T")[0],
};
posts.push(newPost);
return {
content: [
{
type: "text",
text: `Yazı oluşturuldu: ID=${newPost.id}, Başlık="${newPost.title}"`,
},
],
};
}
);Parametre şemasını Zod ile tanımlıyoruz. MCP SDK, Zod şemasını otomatik olarak JSON Schema'ya dönüştürüp istemciye sunuyor. Model bu şemayı okuyarak doğru parametreleri üretiyor.
Bir tool daha ekleyelim, yazı aramak için:
server.tool(
"search-posts",
"Blog yazılarında anahtar kelime araması yapar",
{
query: z.string().min(1).describe("Aranacak kelime"),
},
async ({ query }) => {
const results = posts.filter(
(p) =>
p.title.toLowerCase().includes(query.toLowerCase()) ||
p.content.toLowerCase().includes(query.toLowerCase())
);
if (results.length === 0) {
return {
content: [{ type: "text", text: "Sonuç bulunamadı." }],
};
}
return {
content: [
{
type: "text",
text: results
.map((p) => `[${p.id}] ${p.title} (${p.publishedAt})`)
.join("\n"),
},
],
};
}
);Prompt tanımlama
Bir yazı taslağı hazırlamak için tekrar tekrar aynı talimatları yazmak yerine bir prompt şablonu tanımlayabiliriz:
server.prompt(
"draft-post",
"Belirli bir konu hakkında blog yazısı taslağı hazırlar",
{
topic: z.string().describe("Yazı konusu"),
tone: z
.enum(["teknik", "genel", "beginner"])
.optional()
.describe("Yazının tonu"),
},
async ({ topic, tone }) => {
const selectedTone = tone ?? "teknik";
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: [
`"${topic}" konusunda ${selectedTone} bir blog yazısı taslağı hazırla.`,
"Yazı 800-1200 kelime arasında olsun.",
"Kod örnekleri içersin.",
"Girişte konuyu doğrudan somut bir problemle aç.",
].join("\n"),
},
},
],
};
}
);Prompt'lar, istemci tarafından kullanıcıya sunulan seçenekler gibi düşünülebilir. Claude Desktop'ta slash komutları olarak görünürler.
Sunucuyu başlatma
Dosyanın sonuna transport bağlantısını ekleyin:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Blog MCP sunucusu çalışıyor");
}
main().catch((error) => {
console.error("Sunucu başlatılamadı:", error);
process.exit(1);
});console.error kullandığıma dikkat edin. stdout MCP protokol iletişimi için ayrılmış durumda; log mesajlarını stderr'e yazmak gerekiyor.
Derleyin ve test edin:
npx tsc
node dist/index.jsSunucu başladığında stdin'den JSON-RPC mesajları bekleyecek. Ama elle test etmek zahmetli. MCP Inspector aracını kullanmak çok daha pratik:
npx @modelcontextprotocol/inspector node dist/index.jsInspector tarayıcıda bir arayüz açıyor. Orada tanımladığınız tools, resources ve prompts'u görebilir, tek tek çağırabilirsiniz.
Claude Code ile entegrasyon
Claude Code, MCP sunucularını doğrudan komut satırından ekleyebiliyor:
claude mcp add blog-mcp node /tam/yol/dist/index.jsBu komut, projenin .mcp.json dosyasına sunucu tanımını yazar. Dosya şöyle görünür:
{
"mcpServers": {
"blog-mcp": {
"command": "node",
"args": ["/tam/yol/dist/index.js"]
}
}
}Ekleme sonrası Claude Code'da artık "blog yazılarını listele" dediğinizde model all-posts resource'unu okuyabiliyor, "TypeScript hakkında yazı ara" dediğinizde search-posts tool'unu çağırabiliyor.
Claude Desktop kullanıyorsanız ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) dosyasına aynı yapıyı eklersiniz.
Gerçek bir API'ye bağlama
Yukarıdaki örnek bellek içi veri kullanıyor. Gerçek bir REST API'ye bağlamak istediğinizde tool callback'lerinde fetch çağrısı yaparsınız:
server.tool(
"fetch-live-posts",
"Canlı blog API'sinden son yazıları çeker",
{
limit: z.number().min(1).max(20).default(5).describe("Kaç yazı getirilsin"),
},
async ({ limit }) => {
const response = await fetch(
`https://api.example.com/posts?limit=${limit}`
);
if (!response.ok) {
return {
content: [
{ type: "text", text: `API hatası: ${response.status}` },
],
isError: true,
};
}
const data = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
);isError: true döndüğünüzde model bu sonucu bir hata olarak yorumlar ve kullanıcıya bunu iletir.
Ne zaman tool, ne zaman resource?
Bu ayrımda takılan çok kişi gördüm. Basit bir kural var: veri okuma işlemi idempotent mi, yani tekrar çağırınca aynı sonucu mu veriyor? Resource yapın. Bir şeyi değiştiriyor mu, bir yan etkisi var mı? Tool yapın.
Bir GET isteği bile tool olabilir eğer her çağrıda rate limit tüketiyorsa ya da pahalı bir hesaplama tetikliyorsa. O zaman modelin bilinçli karar vermesini istersiniz. Resource'lar ise istemci tarafından otomatik olarak bağlama eklenebilir, model izni olmadan bile okunabilir.
Hata yönetimi ve güvenlik
MCP sunucunuz dış dünyayla etkileşim kuruyor. Birkaç nokta gözden kaçmamalı:
Tool callback'lerinde try-catch kullanın ve hata durumunda isError: true ile anlamlı bir mesaj dönün. Modelin hata detayını görmesi, kullanıcıya daha iyi cevap vermesini sağlar.
Kullanıcı girdisini doğrudan SQL sorgusuna ya da shell komutuna aktarmayın. Zod şeması ilk katman doğrulama sağlasa da, sunucu tarafında ek sanitizasyon şart.
Eğer sunucunuz yazma işlemleri yapıyorsa, MCP istemcileri genellikle tool çağrısı öncesi kullanıcıdan onay ister. Ama buna güvenmeyin; sunucu tarafında da yetkilendirme mantığı olsun.
Yapının tamamı
Son haliyle src/index.ts dosyası yaklaşık 150 satır. Derlendikten sonra tek bir node dist/index.js komutuyla çalışıyor. Dağıtım için npm paketi olarak yayınlayabilirsiniz; package.json'a "bin" alanı ekleyip npx ile çağrılabilir hale getirmek, başkalarının sunucunuzu kullanmasını kolaylaştırır.
MCP henüz genç bir protokol ama hızla benimseniyor. Hono gibi framework'lerle HTTP transport üzerinden de sunucu yazabilirsiniz; SDK'nın SSEServerTransport sınıfı bunu destekliyor. Başlamak için stdio transport yeterli, üretim ortamında HTTP'ye geçiş de birkaç satırlık değişiklik.