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

MCP Client Yazmak: McpClient, Tool'ları IChatClient ve Agent'a Bağlamak, Elicitation

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
MCP Client Yazmak: McpClient, Tool'ları IChatClient ve Agent'a Bağlamak, Elicitation

C#'ta MCP client'ı McpClient.CreateAsync ile stdio ya da HTTP transport üzerinden kurarsın; ListToolsAsync'in döndürdüğü McpClientTool bir AIFunction olduğu için doğrudan IChatClient'a ve MAF agent'ına verilebilir. İki server'daki aynı adlı tool'ları önekle ayırıyor, tool tanımlarını parmak iziyle pinliyor, yerel qwen3:4b ile gerçek tool çağrılarını ve server'ın onay isteğini SDK'nın MRTR turlarıyla nasıl karşıladığını görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, ModelContextProtocol.Core 2.4.0, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 ve Microsoft.Agents.AI 1.28.0 ile derlenip denenmiştir; model çağrıları yerel Ollama'daki qwen3:4b ile yapıldı.

Önce Tahmin Et

10. yazıdaki remote server'da clear_basket tool'u, çalışmadan önce kullanıcıdan onay istiyordu. Şimdi client tarafındasın ve tek satır yazıyorsun: await client.CallToolAsync("clear_basket", ...). Kullanıcı "evet" diyor ve sepet boşalıyor. Bu tek satır server'a kaç HTTP (HyperText Transfer Protocol) isteği gönderdi ve iki tur arasındaki döngüyü kim yürüttü?

Server log'u iki tools/call gösteriyor; öncesinde de bir server/discover. Döngüyü SDK (Software Development Kit) yürütüyor: ilk cevap input_required gelince senin verdiğin elicitation handler'ını çağırıyor, cevabı ve requestState'i ekleyip aynı çağrıyı tekrarlıyor, sana yalnız son sonucu veriyor. Bu yazıda MCP (Model Context Protocol) client'ını yazıyor, 9, 10 ve 11. yazılardaki server'lara bağlanıyor ve tool'larını önce IChatClient'a, sonra bir Microsoft Agent Framework agent'ına veriyoruz.

McpClient: stdio ve HTTP Transport

Client için yalnız ModelContextProtocol.Core paketi yeter; hosting ya da DI (Dependency Injection) gerektirmiyor. stdio'da client server process'ini kendisi başlatıyor:

using ModelContextProtocol.Client;

namespace Bookstore.Mcp.Client;

public static class Servers
{
    public static Task<McpClient> CSharpAsync() => McpClient.CreateAsync(
        new StdioClientTransport(new()
        {
            Name = "bookstore-cs",
            Command = "dotnet",
            Arguments = ["../Bookstore.Mcp.Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll"],
        }));

    public static Task<McpClient> TypeScriptAsync() => McpClient.CreateAsync(
        new StdioClientTransport(new()
        {
            Name = "bookstore-ts",
            Command = "node",
            Arguments = ["../bookstore-mcp-ts/dist/stdio.js"],
        }));
}

dotnet run --project yerine derlenmiş DLL'i (Dynamic-Link Library) çalıştırdığıma dikkat et. dotnet run önce build eder ve build çıktısını stdout'a yazabilir; stdio'da stdout protokole ait. CreateAsync döndüğünde bağlantı hazır: 2.x client önce 2026-07-28 ile server/discover deniyor, server modern değilse initialize'a düşüyor. 8. yazıdaki uyumluluk kuralını senin yerine uygulayan kod bu.

HTTP'de ise transport'a endpoint'i ve header'ları veriyorsun:

await using McpClient client = await McpClient.CreateAsync(
    new HttpClientTransport(new()
    {
        Endpoint = new Uri("http://localhost:19850/mcp"),
        TransportMode = HttpTransportMode.StreamableHttp,
        AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" },
    }),
    options);

Burada token'ı kendim veriyorum, çünkü 10. yazıdaki gibi test token'ı elimde. Gerçek bir host'ta token'ı OAuth akışıyla almak client'ın işi; SDK'nın ProtectedMcpClient örneği 401 cevabından başlayıp protected resource metadata'yı okuyan, authorization server'a giden akışı gösteriyor.

ListToolsAsync: McpClientTool Bir AIFunction'dır

İki stdio server'a bağlanıp tool listelerini alalım:

using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;

namespace Bookstore.Mcp.Client;

public static class ListDemo
{
    public static async Task RunAsync()
    {
        await using McpClient cs = await Servers.CSharpAsync();
        await using McpClient ts = await Servers.TypeScriptAsync();

        List<AITool> all = [];
        foreach ((string prefix, McpClient client) in new[] { ("cs", cs), ("ts", ts) })
        {
            IList<McpClientTool> tools = await client.ListToolsAsync();
            Console.WriteLine($"{client.ServerInfo?.Name} {client.ServerInfo?.Version} protocol={client.NegotiatedProtocolVersion}: " +
                string.Join(", ", tools.Select(t => t.Name)));

            // İki server da search_books sunuyor: her tool'a server önekini ekle
            all.AddRange(tools.Select(t => t.WithName($"{prefix}_{t.Name}")));
        }

        Console.WriteLine($"tools for the model: {string.Join(", ", all.Select(t => t.Name))}");

        AIFunction stock = (AIFunction)all.Single(t => t.Name == "ts_check_stock");
        object? result = await stock.InvokeAsync(new AIFunctionArguments { ["bookId"] = 5 });
        Console.WriteLine($"ts_check_stock(5) -> {result}");
    }
}
bookstore 1.0.0 protocol=2026-07-28: search_books, check_stock, get_order_status
bookstore-ts 1.0.0 protocol=2026-07-28: search_books, check_stock
tools for the model: cs_search_books, cs_check_stock, cs_get_order_status, ts_search_books, ts_check_stock
ts_check_stock(5) -> {"content":[{"type":"text","text":"{"bookId":5,"title":"A Short History of Maps","inStock":3,"available":true}"}],"structuredContent":{"bookId":5,"title":"A Short History of Maps","inStock":3,"available":true},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}},"resultType":"complete"}

Üç şey görülüyor. İki server da 2026-07-28'de anlaştı. McpClientTool, M.E.AI'ın (Microsoft.Extensions.AI) AIFunction'ından türüyor; LLM (Large Language Model) Temelleri serisinin 12. yazısında AIFunctionFactory.Create ile ürettiğimiz tool'larla aynı yere takılabiliyor. WithName yalnız modelin gördüğü adı değiştiriyor: ts_check_stock çağrıldığında server'a giden istek yine check_stock.

Üçüncüsü daha az göze çarpıyor: tool'un sonucu, content, structuredContent ve _meta ile birlikte CallToolResult'ın tamamının JSON (JavaScript Object Notation) hali. Modele giden de bu. Aynı veri bir kez metin, bir kez structured olarak, üstüne server kimliğiyle gidiyor; her tool sonucu context'te gereğinden fazla yer kaplıyor. 4. yazıdaki context engineering kuralları burada da geçerli; gerekiyorsa sonucu modele vermeden önce kendin kısalt.

Modelin görmediği ama senin görmen gereken bilgiler de var. McpClientTool.ProtocolTool, server'ın tools/list'te gönderdiği Tool nesnesinin kendisi: annotation'lar, outputSchema, _meta. Aşağıda okuma yapan tool'ları seçerken buradaki readOnlyHint'i kullanıyorum; 13. yazıda bu ipucunun neden tek başına yetmediğini konuşacağız.

Tool'ları IChatClient'a Vermek

MCP tool'ları AITool olduğuna göre ChatOptions.Tools'a doğrudan girebilir. Ama hepsini değil: server'ın readOnlyHint ile okuma yaptığını söylediği tool'ları seçiyorum.

using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OllamaSharp;

namespace Bookstore.Mcp.Client;

public static class ChatDemo
{
    public static IChatClient CreateChatClient()
    {
        HttpClient http = new() { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(5) };
        return new ChatClientBuilder(new OllamaApiClient(http, "qwen3:4b"))
            .UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 5)
            .Build();
    }

    public static async Task RunAsync(string question)
    {
        await using McpClient bookstore = await Servers.CSharpAsync();

        // Modele yalnız okuma yapan tool'lar gider; hangileri olduğunu server söylüyor
        IList<McpClientTool> tools = await bookstore.ListToolsAsync();
        List<AITool> readOnly = [.. tools.Where(t => t.ProtocolTool.Annotations?.ReadOnlyHint == true)];

        using IChatClient chat = CreateChatClient();
        ChatOptions options = new() { Tools = readOnly, Temperature = 0, Seed = 42 };
        ChatResponse response = await chat.GetResponseAsync(question, options);

        foreach (ChatMessage message in response.Messages)
        {
            foreach (AIContent content in message.Contents)
            {
                if (content is FunctionCallContent call)
                {
                    Console.WriteLine($"call   {call.Name}({string.Join(", ", call.Arguments!.Select(a => $"{a.Key}={a.Value}"))})");
                }
                else if (content is FunctionResultContent result)
                {
                    Console.WriteLine($"result {Shorten(result.Result?.ToString())}");
                }
            }
        }

        Console.WriteLine($"answer {response.Text}");
    }

    private static string Shorten(string? text) =>
        text is null ? "" : text.Length <= 140 ? text : text[..140] + "...";
}

"Do you have any books by Alan Brook in stock? Give me titles and how many copies." sorusuyla:

call   search_books(query=Brook)
result 1 | Distributed Patience | Alan Brook | 42.50 EUR
6 | Async All the Way | Alan Brook | 39.90 EUR
call   check_stock(bookId=1)
call   check_stock(bookId=6)
result {"content":[{"type":"text","text":"{"bookId":1,"title":"Distributed Patience","inStock":12,"ava...
result {"content":[{"type":"text","text":"{"bookId":6,"title":"Async All the Way","inStock":9,"availab...
answer We have two books by Alan Brook in stock:
1. **Distributed Patience** - 12 copies
2. **Async All the Way** - 9 copies

Model önce aradı, sonra iki check_stock'u aynı turda paralel istedi; FunctionInvokingChatClient ikisini de MCP üzerinden çağırıp sonuçları geri verdi. Bu koşu 10:12:45'te başlayıp 10:18:01'de bitti, yani 5 dakika 16 saniye sürdü. Rakam qwen3:4b'nin ya da MCP'nin hızını değil, aynı makinede aynı Ollama'yı paylaşan başka işlerin yarattığı kuyruğu gösteriyor; production'da model gecikmesini ayrı ölçmen gerektiğinin küçük bir hatırlatıcısı.

Aynı Tool'lar Bir Agent'ta

MAF (Microsoft Agent Framework) agent'ı da aynı AITool listesini alıyor. Serinin 2. yazısında ChatClientAgent'ı tanımıştık; burada değişen yalnız tool'ların kaynağı:

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;

namespace Bookstore.Mcp.Client;

public static class AgentDemo
{
    public static async Task RunAsync(string question)
    {
        await using McpClient bookstore = await Servers.CSharpAsync();
        IList<McpClientTool> tools = await bookstore.ListToolsAsync();

        using IChatClient chat = ChatDemo.CreateChatClient();
        AIAgent agent = chat.AsAIAgent(
            instructions: "You are the Bookstore assistant. Use the tools for every fact about books and stock.",
            name: "bookstore-agent",
            tools: [.. tools.Where(t => t.ProtocolTool.Annotations?.ReadOnlyHint == true)]);

        AgentResponse response = await agent.RunAsync(question);
        Console.WriteLine(response.Text);
    }
}

"Which Mira Holt books can I buy today, and how many copies are left?" sorusunun cevabı, 10:34:11'den 10:38:10'a, yaklaşık dört dakikada geldi:

The Mira Holt books available for purchase today are:

- **The Quiet Orchard** (Book ID 7) – **15 copies left**

*Note: "The Last Lighthouse" (Book ID 2) is currently out of stock (0 copies available).*

Cevap doğru: The Last Lighthouse'un stoğu sıfır, The Quiet Orchard'unki 15. Agent'ın hangi adımları attığı bu çıktıda görünmüyor; RunAsync sana son metni veriyor. Agent'ın yolunu görmek, yani hangi tool'u hangi argümanla kaç kez çağırdığını kaydetmek 14. yazının konusu. Burada önemli olan, MCP tool'larının MAF'a da M.E.AI'a da aynı nesne olarak, ek bir adaptör yazmadan girmesi.

Birden Çok Server, İsim Çakışması ve Tool Alt Kümesi

Yukarıdaki liste örneğinde iki server da search_books ve check_stock sunuyordu. Tool adları modelin gözünde tek ad alanı; aynı adı taşıyan iki tool'u aynı listeye koymak, modelin hangisini kastettiğini belirsiz bırakır. SDK bunu senin yerine çözmüyor. Ben iki yolu birlikte kullanıyorum: her server'a bir önek (cs_, ts_) ve modele yalnız işin gerektirdiği alt küme. 3. yazıda konuştuğumuz gibi tool sayısı arttıkça modelin doğru tool'u seçme oranı düşer; on server'ın yüz tool'unu tek seferde modele vermek yerine, göreve göre seçilmiş birkaç tool ver.

Listeyi ne sıklıkla yeniden istemelisin? 2026-07-28 ile tools/list cevabı ttlMs ve cacheScope taşıyor; server listeyi ne kadar süre cache'leyebileceğini kendisi söylüyor. Bizim server'larımız ttlMs: 0 döndürüyor. Liste değiştiğinde haber almak için 2026-07-28'de subscriptions/listen var; client toolsListChanged bildirimine abone olursa server listeyi değiştirdiğinde bildirim alıyor. Spec ayrıca server'ların listeyi deterministik sırada döndürmesini istiyor; böylece sıra değişmedikçe modele giden tool listesi de değişmiyor.

Elicitation ve MRTR: Client Tarafı

Şimdi Önce Tahmin Et'teki senaryo. 10. yazıdaki server clear_basket'te InputRequiredException fırlatıyordu; client tarafında yapmamız gereken tek şey bir elicitation handler'ı vermek:

using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;

namespace Bookstore.Mcp.Client;

public static class ElicitDemo
{
    public static async Task RunAsync(string accessToken)
    {
        McpClientOptions options = new()
        {
            Handlers = new McpClientHandlers
            {
                // Server sorar, client insana sorar, SDK çağrıyı tekrarlar
                ElicitationHandler = (request, ct) =>
                {
                    Console.Write($"[server asks] {request?.Message} [y/N] ");
                    string? answer = Console.ReadLine();
                    Console.WriteLine(answer);
                    return ValueTask.FromResult(new ElicitResult { Action = answer == "y" ? "accept" : "decline" });
                },
            },
        };

        await using McpClient client = await McpClient.CreateAsync(
            new HttpClientTransport(new()
            {
                Endpoint = new Uri("http://localhost:19850/mcp"),
                TransportMode = HttpTransportMode.StreamableHttp,
                AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" },
            }),
            options);

        string basketId = Text(await client.CallToolAsync("create_basket"));
        Console.WriteLine(Text(await client.CallToolAsync("add_to_cart",
            new Dictionary<string, object?> { ["basketId"] = basketId, ["bookId"] = 3, ["quantity"] = 2 })));

        CallToolResult cleared = await client.CallToolAsync("clear_basket",
            new Dictionary<string, object?> { ["basketId"] = basketId });
        Console.WriteLine(Text(cleared));
    }

    private static string Text(CallToolResult result) =>
        result.Content.OfType<TextContentBlock>().First().Text;
}

Önce "y", sonra "n" cevabıyla iki koşu:

Basket bsk_a9b6eeeb4c6a: 2 item(s)
[server asks] Remove all 1 line(s) from basket bsk_a9b6eeeb4c6a? [y/N] y
Basket bsk_a9b6eeeb4c6a is now empty.
== decline
Basket bsk_401ae2a6ffda: 2 item(s)
[server asks] Remove all 1 line(s) from basket bsk_401ae2a6ffda? [y/N] n
Basket bsk_401ae2a6ffda was not cleared: the user declined.

Server'ın log'unda ilk koşunun izi:

      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'server/discover' request handler called.
      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
      "create_basket" completed. IsError = False.
      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
      "add_to_cart" completed. IsError = False.
      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler completed in 15.1519ms.
      Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
      "clear_basket" completed. IsError = False.

clear_basket için iki tools/call var: ilki input_required ile bitiyor ve tool "completed" log'u yazmıyor, ikincisi sepeti boşaltıyor. Kodumuzda tek bir CallToolAsync var. Bu soyutlama kullanışlı ama bir sonucu var: handler senin UI'ın (User Interface); ne gösterdiğin, ne kadar bekleyeceğin ve kullanıcı cevap vermezse ne olacağı senin kararın. Spec de server'ın client'ın isteği tekrarlayacağını varsaymamasını söylüyor.

Üretimde Dikkat

Birincisi, process ve bağlantı ömrü. Her McpClient bir stdio server için bir process demek. Bunları her istekte açıp kapatmak pahalı; uygulama ömrü boyunca açık tutulan, çökünce yeniden başlatılan bir havuz kur. Spec, server beklenmedik şekilde ölürse client'ın onu yeniden başlatmasını öneriyor; protokol stateless olduğu için yarıda kalan istekler yeniden gönderilebilir.

İkincisi, tool tanımlarını pinlemek. Bir server'ın tool açıklaması, schema'sı ya da annotation'ı sen farkında olmadan değişebilir: yeni sürüm, başka bir paket, hatta aynı ada sahip başka bir server. Gözden geçirdiğin tanımların parmak izini saklayıp değişeni modele vermeyen küçük bir kapı yazdım:

using System.Security.Cryptography;
using System.Text;
using ModelContextProtocol.Client;

namespace Bookstore.Mcp.Client;

// Modelin hangi tool'ları göreceğine host karar verir; server yalnız önerir
public sealed class ToolGate(IReadOnlyDictionary<string, string> pinned)
{
    public static string Fingerprint(McpClientTool tool) =>
        Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
            tool.Name + "\n" + tool.Description + "\n" + tool.JsonSchema.GetRawText())))[..16];

    public IEnumerable<McpClientTool> Allow(IEnumerable<McpClientTool> tools)
    {
        foreach (McpClientTool tool in tools)
        {
            if (!pinned.TryGetValue(tool.Name, out string? expected))
            {
                Console.WriteLine($"skip   {tool.Name}: not on the allowlist");
            }
            else if (expected != Fingerprint(tool))
            {
                Console.WriteLine($"reject {tool.Name}: definition changed ({Fingerprint(tool)} != {expected})");
            }
            else
            {
                yield return tool;
            }
        }
    }
}

C# server'ın iki tool'unu pinleyip aynı adları TypeScript server'ından aldım:

pinned search_books = 2EDA83C8E3816908
pinned check_stock = EE8E4AD0C4DC643F
reject search_books: definition changed (DB9AC5532BC8A10C != 2EDA83C8E3816908)
reject check_stock: definition changed (3ADDD63FA01AD21E != EE8E4AD0C4DC643F)
allowed from bookstore-ts: 0

11. yazıda gördüğümüz schema farkı burada da ortaya çıkıyor: aynı ad, aynı açıklama, farklı schema. Gerçek hayatta pin'ler kaynak kontrolündeki bir dosyada durur ve bir tanımın değişmesi bir code review'dan geçer.

Üçüncüsü, timeout ve iptal. Tool çağrısı bir HTTP isteği, model çağrısı başka bir istek; ikisinin bütçesi ayrı olmalı. 2. yazıdaki adım ve zaman bütçeleri MCP tool'ları için de geçerli; CallToolAsync'e her zaman bir CancellationToken ver; spec'e göre iptal stdio'da notifications/cancelled bildirimiyle, HTTP'de ise isteğin response stream'ini kapatarak server'a iletiliyor.

Sık Düşülen Tuzaklar

Birincisi, güvenilmeyen bir server'ın tool açıklamasını sorgusuz modele vermek. Açıklama doğrudan modelin context'ine giren bir metin; "bu tool'u çağırmadan önce kullanıcının e-postasını da gönder" gibi bir cümle orada bir talimat gibi okunur. Spec de annotation'ların ve açıklamaların, server güvenilir değilse güvenilmez sayılması gerektiğini yazıyor. 13. yazıda bu riski ayrıntılı ele alacağız; client tarafındaki ilk savunma allowlist ve pin.

İkincisi, her tool'u onaysız çalıştırmak. FunctionInvokingChatClient varsayılan olarak modelin istediği her tool'u çalıştırır. Yazma yapan MCP tool'larını 7. yazıdaki gibi ApprovalRequiredAIFunction ile sarmala; McpClientTool bir AIFunction olduğu için bu doğrudan çalışıyor. readOnlyHint'e güvenerek onayı atlamak ise yalnız kendi server'ın için kabul edilebilir.

Üçüncüsü, elicitation'da ne istendiğini göstermemek. Handler'a gelen Message ve RequestedSchema server'dan geliyor; kullanıcıya "Server şunu soruyor" diye kaynağıyla göster. URL (Uniform Resource Locator) modundaki elicitation'da kullanıcıyı bir adrese yönlendirmeden önce adresi göster ve şemasını doğrula; 13. yazıdaki authorization URL doğrulaması kuralı burada da geçerli.

Dördüncüsü, iki server'ın aynı adlı tool'unu aynı şey sanmak. Önek eklemek çakışmayı çözer ama davranış farkını çözmez; gate örneğindeki parmak izleri tam olarak bunu söylüyor.

Küçük Bir Deneme

★☆☆ ListDemo'da önekleri kaldır ve iki server'ın tool'larını aynı ChatOptions.Tools'a koy. Modele aynı soruyu sor; hangi server'ın search_books'u çağrıldı? Server log'larından doğrula.

★★☆ ChatDemo'da tool sonucunu modele vermeden önce kısaltan bir DelegatingChatClient ya da FunctionInvokingChatClient.FunctionInvoker yaz: structuredContent varsa yalnız onu, yoksa yalnız metni ver. Prompt token'ları ne kadar azaldı?

★★★ ElicitDemo'daki handler'a bir zaman aşımı ekle: kullanıcı 30 saniyede cevap vermezse Action = "cancel" dön. Server bu durumda ne cevap veriyor? decline ile cancel arasındaki farkı server kodunda nasıl ele alırdın?

Client tarafını da kurduk: tool'ları keşfediyor, filtreliyor, modele veriyor ve server'ın sorularını kullanıcıya iletiyoruz. Bir sonraki yazıda bu zincirin her halkasına saldırgan gözüyle bakıyoruz: tool poisoning, confused deputy, least privilege ve sandboxing.