Gerçek Bir AI Agent Projesinde MCP Kullanırken Öğrendiklerim
Model Context Protocol (MCP) için resmi dokümantasyon temiz ve basit görünüyor. Bir tutorial'da da öyle. Ama gerçek bir iş akışına — her gün çalışan, production verisine dokunan ve birden fazla harici servisle entegre olan — MCP tabanlı bir agent pipeline'ı deploy ettiğinizde, çok farklı bir gerçeklikle karşılaşıyorsunuz.
Bu bir tutorial değil. Bu, production'da bir MCP agent sistemi kurma ve çalıştırmadan çıkan zor derslerin post-mortem'i. Yaptığım hataları, doğru yaptığım mimari kararları ve başlamadan önce kimsenin söylemediği şeyleri ele alacağım.
Ne İnşa Ediyorduk?
Proje, gucluyumhe.dev için dahili bir otomasyon pipeline'ıydı — Claude tabanlı bir AI agent'ının şunları yapmasına izin veren bir MCP sunucusu:
- Log'lardan site performans verilerini okuma ve analiz etme
- Yeni blog yazısı taslakları hazırlama ve içerik dizinine kaydetme
- Yayınlanan yazıların yerel veritabanını tekrar kontrol için sorgulama
- Bir shell komutu aracılığıyla build önizlemelerini tetikleme
Teoride basit. Production'da şaşırtıcı derecede karmaşık.
Ders 1: Araç Tasarımı Her Şeydir — Ve Zordur
En yaygın MCP tutorial hatası çok geniş araçlar tasarlamaktır. İşte kaçınılması gerekenin bir örneği:
// COK GENIS — production'da tehlikeli
{
name: "execute_command",
description: "Execute any shell command on the local machine",
inputSchema: {
type: "object",
properties: {
command: { type: "string", description: "The shell command to run" }
},
required: ["command"]
}
}
Bu neden tehlikeli? Çünkü LLM onu tam tasarlandığı gibi kullanacak. Bir agent ayrıştıramadığı bir dosyayla karşılaşırsa, geçerli shell çözümleri olduğu için yıkıcı komutlar deneyecek. Ve önce sizden izin istemeyecek.
Çözüm: Dar, Amaca Özel Araçlar
// DOGRU: spesifik, güvenli, niyetli
{
name: "save_blog_draft",
description: "Yeni bir blog yazısı taslağını content/posts dizinine kaydet. Yalnızca geçerli frontmatter ile markdown içeriği kabul eder.",
inputSchema: {
type: "object",
properties: {
slug: {
type: "string",
pattern: "^[a-z0-9-]+$",
description: "Dosya adı için URL güvenli slug"
},
content: {
type: "string",
description: "Frontmatter dahil tam markdown içeriği"
},
locale: {
type: "string",
enum: ["en", "tr"],
description: "Blog yazısının dili"
}
},
required: ["slug", "content", "locale"]
}
}
Pratik kural: bir araç tam olarak bir şey yapmalı ve şeması geçersiz girdileri yapısal olarak imkânsız kılmalıdır.
Ders 2: Araçlarda Hata Yönetimi Sizin Sorumluluğunuz
Bir araç işlenmemiş bir hata fırlattığında, MCP ham bir exception'ı LLM'e iletir. Bazı durumlarda model bunu izin sorunu olarak yorumlar ve eskalasyona gider — daha agresif yaklaşımlar dener.
Bizi Kurtaran Pattern
Her araç işleyicisi fırlatmak yerine yapılandırılmış bir hata yanıtı döndürmelidir:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'save_blog_draft':
return await handleSaveBlogDraft(args);
case 'get_post_list':
return await handleGetPostList(args);
default:
return {
content: [{
type: "text",
text: JSON.stringify({
error: "UNKNOWN_TOOL",
message: `'${name}' aracı bu sunucuda kayıtlı değil.`,
availableTools: ['save_blog_draft', 'get_post_list']
})
}],
isError: true
};
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
console.error(`[MCP Araç Hatası] ${name}:`, error.message);
return {
content: [{
type: "text",
text: JSON.stringify({
error: "TOOL_EXECUTION_FAILED",
tool: name,
message: error.message,
hint: "İşlem tamamlanamadı. Lütfen giriş parametrelerini doğrulayın."
})
}],
isError: true
};
}
});
isError: true bayrağı, LLM'e aracın başarısız olduğunu söyler. Bu da sessizce devam etmek yerine yeniden deneme veya geri dönüş mantığını tetikler.
Ders 3: LLM Düşünmediğiniz Araçları Kötüye Kullanacak
Test sırasında agent'a blog yazılarını okumak için bir read_file aracı verdim. Tek bir oturumda:
- Posts dizinindeki tüm markdown dosyalarını okudu — beklenen, doğru
- Proje yapısını anlamak için
package.jsonokudu — beklenmedik, ama zararsız - Proje konfigürasyonunu anlamaya çalıştığı için
.envokudu — tehlikeli, güvenlik riski
LLM kötü niyetli değil. Gerçekten yardımcı olmaya çalışıyor. Ama bu bağlamda yardımcı olmak, hiç amaçlamadığınız dosyaları okumak anlamına geliyordu.
Çözüm: Katı Yol İzin Listesi
const ALLOWED_DIRECTORIES = [
'/content/posts',
'/public/images'
] as const;
async function handleReadFile(args: unknown): Promise<ToolResult> {
const { path: filePath } = args as { path: string };
const resolvedPath = path.resolve(PROJECT_ROOT, filePath);
const isAllowed = ALLOWED_DIRECTORIES.some(dir => {
const allowedResolved = path.resolve(PROJECT_ROOT, dir);
return resolvedPath.startsWith(allowedResolved + path.sep) ||
resolvedPath === allowedResolved;
});
if (!isAllowed) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: "ACCESS_DENIED",
message: "Bu dosya yoluna erişilemiyor. Yalnızca /content/posts ve /public/images dizinleri okunabilir."
})
}],
isError: true
};
}
try {
const content = await fs.readFile(resolvedPath, 'utf-8');
return { content: [{ type: "text", text: content }] };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
return {
content: [{ type: "text", text: JSON.stringify({ error: "FILE_READ_FAILED", message: error.message }) }],
isError: true
};
}
}
Ders 4: Production'da Rate Limiting Zorunludur
Rate limiting olmadan, tek bir hatalı davranışlı agent oturumu sistem kaynaklarınızı tüketebilir. Bir test oturumunda agent yeniden deneme döngüsüne girdiğinde, get_post_list'i 90 saniyede 47 kez çağırdı.
Basit Token Bucket Uygulaması
interface RateLimitEntry {
tokens: number;
lastRefill: number;
}
const rateLimits = new Map<string, RateLimitEntry>();
function checkRateLimit(toolName: string, maxTokens = 10, refillRateMs = 60000): boolean {
const now = Date.now();
const entry = rateLimits.get(toolName);
if (!entry) {
rateLimits.set(toolName, { tokens: maxTokens - 1, lastRefill: now });
return true;
}
const elapsed = now - entry.lastRefill;
const refillAmount = Math.floor(elapsed / refillRateMs) * maxTokens;
if (refillAmount > 0) {
entry.tokens = Math.min(maxTokens, entry.tokens + refillAmount);
entry.lastRefill = now;
}
if (entry.tokens <= 0) {
return false;
}
entry.tokens--;
return true;
}
async function handleGetPostList(args: unknown): Promise<ToolResult> {
if (!checkRateLimit('get_post_list', 20, 60000)) {
return {
content: [{
type: "text",
text: JSON.stringify({ error: "RATE_LIMITED", message: "Çok fazla istek. Lütfen bu aracı tekrar çağırmadan önce bekleyin." })
}],
isError: true
};
}
// gerçek uygulama devam eder
}
Ders 5: Resource ve Tool Ayrımı Önemlidir
MCP, veri açığa çıkarmak için iki primitive'e sahip: Resources (salt okunur veri) ve Tools (çalıştırılabilir eylemler). Çoğu tutorial yalnızca Tools kullanır çünkü gösterimde daha basittir. Ama okuma ve yazma işlemlerini tek bir primitive'e karıştırmak tasarım sorunlarına yol açar.
Doğru model:
- Resources: Blog yazısı listesi, site metrikleri, yayınlanan içerik kataloğu — Resource olarak açığa çıkarın
- Tools: Taslak yaz, build tetikle, bildirim gönder — Tool olarak açığa çıkarın
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "content://posts/list",
name: "Yayınlanan Blog Yazıları",
description: "Tüm yayınlanan blog yazılarının metadata ile tam listesi",
mimeType: "application/json"
}
]
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "content://posts/list") {
const posts = await getAllPosts();
return {
contents: [{
uri,
mimeType: "application/json",
text: JSON.stringify(posts, null, 2)
}]
};
}
throw new Error(`Kaynak bulunamadı: ${uri}`);
});
Bu ayrım aynı zamanda güvenlik açısından da yardımcı olur: tüm yazma işlemlerini (Tools) okuma işlemlerinden (Resources) ayrı olarak denetleyebilirsiniz.
Gerçekten İşe Yarayan Şeyler
Tüm zor derslerden sonra production mimarisi:
Claude Desktop / Agent Host
|
MCP Server (TypeScript)
|
Izin Listeli Resources (salt okunur)
content://posts/list
metrics://pagespeed/latest
|
Rate-Limited Tools (yazma islemleri)
save_blog_draft
trigger_build_preview
|
Yapilandirilmis Hata Yanitlari (her zaman donduruluyor)
Kararlı yapan temel tasarım kararları:
- Dar araçlar — her araç bir şey yapıyor
- İzin listeli yollar — LLM hassas dosyalara erişemiyor
- Yapılandırılmış hatalar — LLM her zaman ayrıştırılabilir yanıt alıyor
- Rate limiting — kontrolden çıkan döngüleri önlüyor
- Resource ve Tool ayrımı — okuma ve yazma mimari olarak zorunlu kılındı
- Denetim loglama — her araç çağrısı argümanlar ve sonuçlarla loglanıyor
Sonuç
MCP gerçekten güçlü. Bir LLM'e yerel ortamınıza ve production sistemlerinize güvenli, yapılandırılmış erişim verme yeteneği, daha önce pratik olmayan otomasyon olanaklarını açıyor.
Ama güvenli varsayılan değil — kasıtlı olarak tasarladığınız bir şey. LLM size verdiğiniz her yeteneği, her zaman beklediğiniz şekillerde kullanacak. Mimar olarak işiniz, yapabileceği şeyin tam olarak istediğiniz şey olmasını ve bundan fazlasını olmamasını sağlamak.
Dar araçlar tasarlayın. Katı sınırlar uygulayın. Yapılandırılmış hatalar döndürün. Her şeyi rate limit'leyin. O zaman sihir gerçekten işe yarar.
