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

C# ile MCP Server: ModelContextProtocol SDK 2.x, [McpServerTool] ve stdio

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
C# ile MCP Server: ModelContextProtocol SDK 2.x, [McpServerTool] ve stdio

C# ile MCP server'ı ModelContextProtocol 2.x paketi, Generic Host ve [McpServerTool] attribute'larıyla yazarsın; stdio transport'unda log'ları stderr'e yönlendirmek şarttır. Bookstore tool'larını DI'dan servis alarak, structured output, resource ve prompt tanımlarıyla kuruyor, MCP Inspector ile test ediyor ve stdout'u kirletmenin bir dakikalık timeout'a, culture ayarının bozuk tool çıktısına nasıl dönüştüğünü gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, ModelContextProtocol 2.4.0 ve MCP (Model Context Protocol) Inspector 2.14.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore MCP server'ındaki bir tool veritabanına bağlanamıyor ve şu exception'ı fırlatıyor: InvalidOperationException("Login failed for user bookstore_app on sql-prod-01"). Tool'u çağıran MCP client, yani dolaylı olarak model, ne görür? Exception mesajını mı, stack trace'i mi, yoksa hiçbir şey görmeden timeout mu yer?

Cevap: hiçbiri. Model yalnız An error occurred invoking 'get_order_status'. görür; kullanıcı adı, sunucu adı ve stack trace server'ın stderr log'unda kalır. C# SDK'sı (Software Development Kit) bunu bilerek yapıyor. Bu yazıda 8. yazıdaki küçük programı gerçek bir Bookstore server'ına çevirirken bu varsayılanın nerede seni koruduğunu, nerede kendi elinle bozabileceğini göreceğiz.

Paketler: Hangisi Ne İşe Yarar?

MCP C# SDK'sı üç ana paketten oluşuyor; üçü de aynı sürüm numarasıyla yayımlanıyor (bu yazıda 2.4.0):

Paketİçinde ne varNe zaman
ModelContextProtocol.CoreClient, düşük seviyeli server API'si (Application Programming Interface), protokol tipleriYalnız client yazıyorsan ya da en az bağımlılık istiyorsan
ModelContextProtocolCore + stdio server, hosting, DI (Dependency Injection), attribute tabanlı keşifstdio server için başlangıç noktası
ModelContextProtocol.AspNetCoreStreamable HTTP (HyperText Transfer Protocol) server, MapMcp(), authorization yardımcılarıRemote server (10. yazı)

Bunların yanında opt-in iki extension paketi var: uzun süren işler için ModelContextProtocol.Extensions.Tasks ve deneysel ModelContextProtocol.Extensions.Apps. Paketler net8.0, net9.0, net10.0 ve .NET Framework için netstandard2.0'ı hedefliyor. Bu yazıdaki server için iki paket yeter:

dotnet new console -n Bookstore.Mcp.Server
dotnet add package ModelContextProtocol --version 2.4.0
dotnet add package Microsoft.Extensions.Hosting

Generic Host ile stdio Server

Server, ASP.NET Core Web API serisinden tanıdığın Generic Host'un üzerinde çalışıyor. Configuration, logging ve DI aynı:

using Bookstore.Mcp.Server;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// stdout protokole ait: her log satırı stderr'e gider
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddSingleton<BookCatalog>();
builder.Services.AddSingleton(new CurrentCustomer(builder.Configuration.GetValue("Bookstore:CustomerId", 7)));

builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "bookstore", Version = "1.0.0" })
    .WithStdioServerTransport()
    .WithToolsFromAssembly()
    .WithResourcesFromAssembly()
    .WithPromptsFromAssembly();

await builder.Build().RunAsync();

İki satır kritik. LogToStandardErrorThreshold = LogLevel.Trace console logger'ın her seviyeyi stderr'e yazmasını sağlıyor; varsayılan console logger stdout'a yazar ve stdio'da stdout yalnız MCP mesajlarına ait. CurrentCustomer ise şu kararı kodluyor: stdio server'ı başlatan kişi zaten yerel kullanıcıdır, dolayısıyla müşteri kimliği bir tool argümanı olarak modelden değil, configuration'dan gelir. Spec de stdio server'lar için OAuth akışı yerine kimlik bilgilerinin environment'tan alınmasını öneriyor.

namespace Bookstore.Mcp.Server;

// Yerel stdio server'da "kullanıcı" process'i başlatan kişidir;
// müşteri kimliği tool argümanından değil, configuration'dan gelir.
public sealed record CurrentCustomer(int Id);

Tool'lar: Attribute'lar, Description ve DI

Veri tarafı bilerek sıkıcı: 8 kitap ve 3 siparişlik bir in-memory katalog. Gerçekte bunun yerinde EF (Entity Framework) Core ya da Dapper ile yazılmış bir repository olur:

namespace Bookstore.Mcp.Server;

public sealed record Book(int Id, string Title, string Author, string Genre, decimal Price, int Stock);

public sealed record Order(int Id, int CustomerId, string Status, string? TrackingNumber);

public sealed class BookCatalog
{
    private readonly List<Book> _books =
    [
        new(1, "Distributed Patience", "Alan Brook", "software", 42.50m, 12),
        new(2, "The Last Lighthouse", "Mira Holt", "novel", 18.90m, 0),
        new(3, "Coffee and Compilers", "Ada Stone", "software", 35.00m, 4),
        new(4, "Winter in Kyoto", "Ken Sato", "novel", 21.00m, 7),
        new(5, "A Short History of Maps", "Lena Ford", "history", 27.50m, 3),
        new(6, "Async All the Way", "Alan Brook", "software", 39.90m, 9),
        new(7, "The Quiet Orchard", "Mira Holt", "novel", 16.50m, 15),
        new(8, "Numbers Never Lie", "Omar Reed", "science", 24.00m, 2),
    ];

    private readonly List<Order> _orders =
    [
        new(1043, 7, "Shipped", "TRK-88213"),
        new(1044, 7, "Processing", null),
        new(2001, 9, "Delivered", null),
    ];

    public IReadOnlyList<Book> Search(string query, string? genre, int maxResults) =>
        _books
            .Where(b => b.Title.Contains(query, StringComparison.OrdinalIgnoreCase)
                     || b.Author.Contains(query, StringComparison.OrdinalIgnoreCase))
            .Where(b => genre is null || b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
            .Take(maxResults)
            .ToList();

    public Book? Find(int bookId) => _books.FirstOrDefault(b => b.Id == bookId);

    public IReadOnlyList<string> Genres() => _books.Select(b => b.Genre).Distinct().Order().ToList();

    public Order? FindOrder(int orderId, int customerId) =>
        _orders.FirstOrDefault(o => o.Id == orderId && o.CustomerId == customerId);
}

Tool'lar ise primary constructor ile servis alan sıradan bir class. Tool adları serinin ilk yedi yazısındaki adlarla aynı; 3. yazıda konuştuğumuz tool tasarımı kuralları burada da geçerli:

using System.ComponentModel;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

public sealed record StockInfo(int BookId, string Title, int InStock, bool Available);

[McpServerToolType]
public sealed class BookstoreTools(BookCatalog catalog, CurrentCustomer customer, ILogger<BookstoreTools> logger)
{
    [McpServerTool(Name = "search_books", ReadOnly = true)]
    [Description("Searches the Bookstore catalog by title or author. Returns at most maxResults books as 'id | title | author | price'.")]
    public string SearchBooks(
        [Description("Words from the title or the author name, e.g. 'Brook'")] string query,
        [Description("Optional genre filter: software, novel, history or science")] string? genre = null,
        [Description("Maximum number of books to return (1-10)")] int maxResults = 5)
    {
        logger.LogInformation("search_books query={Query} genre={Genre}", query, genre);
        IReadOnlyList<Book> books = catalog.Search(query, genre, Math.Clamp(maxResults, 1, 10));
        if (books.Count == 0)
        {
            return $"No books match '{query}'. Try a shorter query or remove the genre filter.";
        }

        // Tool çıktısını insan değil model okur: server'ın culture'ı ile biçimlendirme
        return string.Join("\n", books.Select(b => FormattableString.Invariant($"{b.Id} | {b.Title} | {b.Author} | {b.Price:0.00} EUR")));
    }

    [McpServerTool(Name = "check_stock", ReadOnly = true, UseStructuredContent = true)]
    [Description("Returns the current stock for a book id.")]
    public StockInfo CheckStock([Description("Book id from search_books")] int bookId)
    {
        Book book = catalog.Find(bookId)
            ?? throw new McpException($"Book {bookId} does not exist. Call search_books to find a valid id.");
        return new StockInfo(book.Id, book.Title, book.Stock, book.Stock > 0);
    }

    [McpServerTool(Name = "get_order_status", ReadOnly = true)]
    [Description("Returns the status of one of the current customer's orders.")]
    public string GetOrderStatus([Description("Order number, e.g. 1043")] int orderId)
    {
        Order? order = catalog.FindOrder(orderId, customer.Id);
        if (order is null)
        {
            // "Yok" ile "başkasının siparişi" aynı cevabı alır: varlığını sızdırma
            return $"Order {orderId} was not found for this customer.";
        }

        return order.TrackingNumber is null
            ? $"Order {order.Id}: {order.Status}"
            : $"Order {order.Id}: {order.Status}, tracking {order.TrackingNumber}";
    }
}

[McpServerToolType] class'ı, WithToolsFromAssembly() ise o class'ları bulmayı sağlıyor. Class her çağrıda DI'dan oluşturuluyor; constructor'daki BookCatalog, CurrentCustomer ve ILogger container'dan geliyor. Method parametrelerinde DI'dan gelen servisler, McpServer, IProgress<ProgressNotificationValue> ve ClaimsPrincipal da kullanılabiliyor; SDK bunları tool schema'sına koymuyor, yalnız modelin dolduracağı parametreleri koyuyor.

ReadOnly = true tool'a readOnlyHint annotation'ı ekliyor. Bu bir ipucu, güvence değil: spec annotation'ların güvenilmeyen server'dan geldiğinde güvenilmez sayılması gerektiğini açıkça yazıyor. Ama kendi host'unda, kendi server'ını bağlarken onay ekranını yalnız yazma yapan tool'lara göstermek için işe yarıyor.

Structured Output: outputSchema ve structuredContent

check_stock string değil bir record döndürüyor ve UseStructuredContent = true taşıyor. SDK bu durumda iki şey yapıyor: tools/list'te tool'a bir outputSchema ekliyor ve sonucu hem metin olarak hem de structuredContent olarak gönderiyor. MCP Inspector'ın CLI'ı (Command-Line Interface) ile çağıralım:

mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll \
  --method tools/call --tool-name check_stock --tool-arg bookId=3
{
  "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
  }
}

Metin kopyası geriye uyumluluk için duruyor: structured output'u bilmeyen bir host da sonucu modele verebiliyor. Bilen host ise structuredContent'i schema'ya göre doğrulayıp kendi kodunda kullanabiliyor, örneğin stok sıfırsa "sepete ekle" düğmesini gizlemek için. 2026-07-28 revizyonu outputSchema'yı JSON (JavaScript Object Notation) Schema 2020-12'nin tamamına açtı; SDK da artık nesne olmayan dönüş değerlerini sarmalamadan gönderiyor, 72 döndüren bir tool structuredContent: 72 üretiyor.

Hata yolunu da görelim. Olmayan bir kitap için McpException fırlattık:

{"result":{"content":[{"type":"text","text":"An error occurred invoking 'check_stock': Book 99 does not exist. Call search_books to find a valid id."}],"isError":true},"id":3,"jsonrpc":"2.0"}

Bu bir JSON-RPC hatası değil, isError: true taşıyan normal bir tool sonucu. Fark önemli: model bu metni görür ve kendini düzeltebilir. Mesajı da buna göre yazdık; 3. yazıdaki kural, hata mesajının modele ne yapacağını söylemesi.

Resource ve Prompt Tanımlamak

Resource'lar URI (Uniform Resource Identifier) ile adreslenen veriler. Sabit URI'li olanlar resources/list'te, parametreli template'ler resources/templates/list'te görünüyor:

using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

[McpServerResourceType]
public sealed class BookstoreResources(BookCatalog catalog)
{
    [McpServerResource(UriTemplate = "bookstore://genres", Name = "genres", MimeType = "application/json")]
    [Description("All genres in the catalog.")]
    public string Genres() => JsonSerializer.Serialize(catalog.Genres());

    [McpServerResource(UriTemplate = "bookstore://books/{bookId}", Name = "book", MimeType = "application/json")]
    [Description("One book as JSON.")]
    public string BookById(int bookId) =>
        JsonSerializer.Serialize(catalog.Find(bookId) ?? throw new McpException($"Book {bookId} not found."));
}
mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll \
  --method resources/read --uri bookstore://books/6
{
  "contents": [
    {
      "uri": "bookstore://books/6",
      "mimeType": "application/json",
      "text": "{\"Id\":6,\"Title\":\"Async All the Way\",\"Author\":\"Alan Brook\",\"Genre\":\"software\",\"Price\":39.90,\"Stock\":9}"
    }
  ]
}

Prompt ise kullanıcının seçtiği hazır bir mesaj. M.E.AI'ın (Microsoft.Extensions.AI) ChatMessage'ını döndürmek yetiyor:

using System.ComponentModel;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

[McpServerPromptType]
public sealed class BookstorePrompts
{
    [McpServerPrompt(Name = "recommend_books")]
    [Description("Asks the assistant for book recommendations in a genre.")]
    public static ChatMessage RecommendBooks(
        [Description("Genre, e.g. software")] string genre,
        [Description("Maximum price in EUR")] decimal maxPrice) =>
        new(ChatRole.User,
            $"Recommend up to three {genre} books under {maxPrice} EUR. " +
            "Use search_books and check_stock; only recommend books that are in stock.");
}
{
  "description": "Asks the assistant for book recommendations in a genre.",
  "messages": [
    {
      "role": "user",
      "content": {
        "type": "text",
        "text": "Recommend up to three software books under 40 EUR. Use search_books and check_stock; only recommend books that are in stock."
      }
    }
  ]
}

MCP Inspector ile Test ve Host'a Bağlamak

MCP Inspector, protokolün resmi test aracı. Web arayüzü de var ama tekrarlanabilir olduğu için ben CLI modunu tercih ediyorum; yukarıdaki çıktıların hepsi ondan. Inspector'ı global kurmak yerine proje klasörüne npm install @modelcontextprotocol/inspector@2.14.0 ile kurdum. Server stderr'e yazdığı log'lar sayesinde Inspector'ın ne yaptığını da görüyoruz:

info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0) method 'initialize' request handler called.
info: ModelContextProtocol.Server.McpServer[1867955179]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'initialize' request handler completed in 6.1051ms.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'logging/setLevel' request handler called.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'tools/list' request handler called.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'tools/call' request handler called.
info: ModelContextProtocol.Server.McpServer[2065726448]
      "check_stock" completed. IsError = False.

Inspector CLI'ı 2.14.0 bile 2025 usulü initialize ve deprecated logging/setLevel ile bağlanıyor. Server'ın iki çağı da konuşması bu yüzden bugün için şart.

Bir host'a bağlamak çoğu zaman bir JSON configuration'dan ibaret. Visual Studio Code'un workspace'teki .vscode/mcp.json dosyası servers anahtarını kullanıyor:

{
  "servers": {
    "bookstore": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll"],
      "env": { "Bookstore__CustomerId": "7" }
    }
  }
}

Claude Desktop ve Copilot CLI'ın kullandığı taşınabilir biçimde üst anahtar mcpServers, içerik aynı. Bu yazıda server'ı hiçbir gerçek host'un configuration'ına kaydetmedim; testlerin hepsi Inspector ve 12. yazıda yazacağımız kendi client'ımızla yapıldı. MCP server'ları IDE'lerde (Integrated Development Environment) ve kod asistanlarında kullanmanın ayrıntıları ilerideki AI (Artificial Intelligence) Destekli Geliştirme serisinin konusu.

env satırı küçük ama önemli: Bookstore__CustomerId environment variable'ı .NET configuration'ında Bookstore:CustomerId'ye dönüşüyor. Aynı çağrıyı müşteri 9 olarak yapınca:

mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll -e Bookstore__CustomerId=9 \
  --method tools/call --tool-name get_order_status --tool-arg orderId=1043
{
  "content": [
    {
      "type": "text",
      "text": "Order 1043 was not found for this customer."
    }
  ]
}

Üretimde Dikkat

Birincisi, tool çıktısının biçimi. search_books'un ilk sürümünde fiyatı {b.Price:0.00} ile yazmıştım. Türkçe bölge ayarlı makinemde Inspector şunu gösterdi:

"text": "1 | Distributed Patience | Alan Brook | 42,50 EUR\n6 | Async All the Way | Alan Brook | 39,90 EUR"

Virgüllü ondalık, satırdaki | ayracıyla birleşince modelin fiyatı yanlış okuma ihtimali artıyor; aynı server başka bir makinede noktayla yazıyor. FormattableString.Invariant ile düzelttikten sonra çıktı 42.50 EUR oldu. Tool çıktısı bir API sözleşmesidir; culture'a, saat dilimine ve makineye bağlı olmamalı.

İkincisi, kimlik. stdio server process'i başlatan kullanıcının yetkileriyle çalışır ve tool argümanları modelden gelir. "Hangi müşteri" gibi yetki kararlarını argümana bırakırsan model, ya da modelin okuduğu bir metin, başka bir müşterinin siparişini sorabilir. Bu yüzden customerId parametre değil, configuration'dan gelen bir servis.

Üçüncüsü, log. stderr'e yazılan log'u host ne yapıyor? Spec'e göre client stderr'i yakalayabilir, iletebilir ya da yok sayabilir ve stderr'deki satırı hata sanmamalıdır. Önemli olayları yalnız stderr'e güvenip bırakma; production'da OpenTelemetry ile bir collector'a gönder.

Dördüncüsü, configuration ve sürüm. Server bir DLL (Dynamic-Link Library) yolu ile başlatılıyor; host configuration'ı yolu sabit tutar. Yeni sürümü aynı klasöre deploy etmek, açık bir host'un kullandığı process'i değiştirmez; host server'ı yeniden başlatana kadar eski kod çalışır.

Sık Düşülen Tuzaklar

Birincisi, stdout'u kirletmek. Server'ın içinde unutulmuş bir Console.WriteLine("Looking up order 1043...") ile Inspector'ı çalıştırdım; Inspector JSON olmayan satırı sessizce atlayıp doğru cevabı gösterdi. Bu, sorunun görünmez kalabileceğini gösteriyor. Asıl kötü durum yeni satırsız bir Console.Write: metin bir sonraki JSON mesajının başına yapışıyor ve o satır artık geçerli JSON değil. Inspector'ın sonucu:

{"error":{"code":"error","message":"Request timed out after 1m00s (tools/call). 1 request is unanswered: tools/call (sent 1m00s ago). Last response received 1m00s ago (tools/list).","cause":"Request timed out"}}

Server tool'u başarıyla çalıştırdı, cevabı da yazdı; ama client cevabı okuyamadı ve bir dakika bekledi. Log'da hata yok, server tarafında her şey yeşil. stdio server'da Console.Out'a hiçbir şey yazma; üçüncü taraf bir library stdout'a yazıyorsa onu izole et.

İkincisi, exception'ı kendi elinle sızdırmak. Önce Tahmin Et'teki exception'ı 8. yazıdaki küçük server'ın get_order_status tool'una ekleyip Inspector ile çağırdım; stderr ve client'ın gördüğü sonuç (dosya yolu kısaltıldı):

fail: ModelContextProtocol.Server.McpServer[1433779783]
      "get_order_status" threw an unhandled exception.
      System.InvalidOperationException: Login failed for user bookstore_app on sql-prod-01
         at OrderTools.GetOrderStatus(Int32 orderId) in .../Program.cs:line 30
{
  "content": [
    {
      "type": "text",
      "text": "An error occurred invoking 'get_order_status'."
    }
  ],
  "isError": true
}

SDK yalnız McpException mesajlarını client'a geçiriyor; diğer exception'larda genel bir mesaj veriyor, ayrıntıyı log'a yazıyor. Bu korumayı catch (Exception ex) { throw new McpException(ex.ToString()); } gibi "daha yardımcı olsun" diye yazılmış bir satırla kendin kaldırabilirsin. McpException mesajı modelin göreceği metindir; içine yalnız modelin işine yarayacak ve dışarı çıkabilecek bilgiyi koy.

Üçüncüsü, McpProtocolException ile McpException'ı karıştırmak. İlki JSON-RPC hata cevabı üretir ve model onu görmez; ikincisi isError taşıyan tool sonucu üretir ve model görür. Modelin düzeltebileceği hatalar, ör. yanlış kitap id'si, tool sonucu olarak dönmeli.

Dördüncüsü, girdi doğrulamayı schema'ya bırakmak. C# server'ı maxResults'a 50 gelirse sessizce 10'a kırpıyor, boş query ile de tüm kataloğu döndürüyor. Bunlar bilinçli kararlar olmalı; 11. yazıda aynı server'ın TypeScript sürümünün aynı girdilere bambaşka cevap verdiğini göreceğiz.

Küçük Bir Deneme

★☆☆ search_books'un genre parametresini string? yerine bir enum yap ve tools/list çıktısında schema'nın nasıl değiştiğine bak. Model artık geçersiz bir tür gönderebilir mi?

★★☆ check_stock'a bir IProgress<ProgressNotificationValue> parametresi ekle ve yavaş bir stok sorgusunu üç adımda raporla. Inspector CLI'ında bu bildirimler görünüyor mu, schema'da parametre var mı?

★★★ Server'ı stdout'a hiçbir şey yazamayacak şekilde koru: Console.SetOut ile stdout'u stderr'e yönlendiren bir başlangıç adımı yaz ve SDK'nın kendi transport'unun hâlâ çalıştığını doğrula. SDK stdout'u nasıl açıyor ki bu yöntem onu etkilemiyor?

Server yerel ve tek kullanıcılıyken kimlik meselesini configuration ile çözdük. Bir sonraki yazıda aynı tool'ları ASP.NET Core'a taşıyoruz: Streamable HTTP, stateless mod, kullanıcıya bağlı state handle'ları ve MCP server'ı bir OAuth resource server'ı olarak korumak.