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

ASP.NET Core'da Remote MCP Server: Streamable HTTP, Stateless Mod, State Handle'ları ve Authorization

Mert Özen 8 Kas 2026 13 dk 4 görüntülenme
ASP.NET Core'da Remote MCP Server: Streamable HTTP, Stateless Mod, State Handle'ları ve Authorization

ASP.NET Core'da remote MCP server'ı WithHttpTransport ve MapMcp ile kurar, stateless modda çalıştırır ve OAuth resource server olarak korursun: token yalnız bu server için verilmiş olmalı, state handle'ları kullanıcıya bağlanmalı. Protected resource metadata'yı, audience hatasını, iki instance ile stateless'ın bedelini, MRTR ile imzalı requestState kullanarak onay istemeyi ve Mcp-Method/Mcp-Name header doğrulamasını curl çıktılarıyla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, ASP.NET Core 10, ModelContextProtocol.AspNetCore 2.4.0 ve Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore MCP (Model Context Protocol) server'ı artık HTTP (HyperText Transfer Protocol) üzerinden yayında. Alice create_basket çağırıyor ve bsk_52421f52b401 handle'ını alıyor. Bob'un da geçerli bir access token'ı var; bir şekilde Alice'in handle'ını öğreniyor ve add_to_cart'ı bu basketId ile çağırıyor. İki kullanıcının da token'ı geçerli. Sonuç ne olur?

Cevap tamamen senin koduna bağlı. Protokol seviyesinde session olmadığı için server'ın elinde yalnız iki şey var: token'daki kimlik ve argümandaki handle. Handle'ı kullanıcıya bağlamadıysan Bob, Alice'in sepetine kitap ekler. Bu yazıda 9. yazıdaki tool'ları ASP.NET Core'a taşıyıp bu sorunu, authorization'ı, MRTR (Multi Round-Trip Requests) ile onay istemeyi ve yeni zorunlu header'ları curl ile tek tek deneyeceğiz.

stdio'dan HTTP'ye: WithHttpTransport ve MapMcp

Remote server için paket ModelContextProtocol.AspNetCore. Tool tanımları değişmiyor; değişen yalnız host ve transport:

using System.Text;
using Bookstore.Mcp.HttpServer;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.AspNetCore.Authentication;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

const string McpUrl = "http://localhost:19850/mcp";
string issuer = builder.Configuration["Auth:Issuer"]!;
byte[] signingKey = Encoding.UTF8.GetBytes(builder.Configuration["Auth:DevSigningKey"]!);

builder.Services.AddAuthentication(o =>
    {
        o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        o.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(o =>
    {
        o.MapInboundClaims = false;
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = issuer,
            ValidAudience = McpUrl, // RFC 8707: token BU server için verilmiş olmalı
            IssuerSigningKey = new SymmetricSecurityKey(signingKey),
        };
    })
    .AddMcp(o => o.ResourceMetadata = new()
    {
        Resource = McpUrl,
        AuthorizationServers = { issuer },
        ScopesSupported = ["bookstore.basket"],
    });

builder.Services.AddAuthorization();

// Demo için anahtarlar proje klasöründe; production'da paylaşılan bir key ring kullan
builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "dp-keys")));

builder.Services.AddSingleton<BasketStore>();
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "bookstore-http", Version = "1.0.0" })
    .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) // 2.x varsayılanı, açıkça yazıldı
    .WithTools<BasketTools>();

WebApplication app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapMcp("/mcp").RequireAuthorization();

app.Run(); // --urls http://localhost:19850

Authentication kısmına birazdan geleceğiz. Önce transport: WithHttpTransport(...) Streamable HTTP'yi ekliyor, MapMcp("/mcp") de tek MCP endpoint'ini route'a bağlıyor. Bu yazıdaki server MCP için yalnız POST kabul ediyor; GET isteği 405 alıyor, çünkü 2026-07-28'de server'dan client'a açılan GET stream'i yok.

JSON (JavaScript Object Notation) tabanlı bir JSON-RPC (Remote Procedure Call) isteğini curl ile atmak için küçük bir yardımcı yazdım. Her POST'ta MCP-Protocol-Version ve Mcp-Method, tools/call'da ayrıca Mcp-Name var; body'deki _meta 8. yazıdakiyle aynı:

#!/bin/bash
# kullanım: mcp.sh <token-dosyası> <method> <name-ya-da-boş> <meta-hariç-params-json>
TOKEN=$(cat "$1"); METHOD=$2; NAME=$3; PARAMS=$4
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}'
BODY="{\"jsonrpc\":\"2.0\",\"id\":$RANDOM,\"method\":\"$METHOD\",\"params\":{${PARAMS:+$PARAMS,}$META}}"
H=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: $METHOD")
[ -n "$NAME" ] && H+=(-H "Mcp-Name: $NAME")
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:19850/mcp "${H[@]}" -d "$BODY"

Stateless Mod: Sticky Session Gerekmeden Ölçeklemek

SDK (Software Development Kit) 1.x'te HTTP transport varsayılan olarak stateful'du: server her client için bellekte bir session açıyor, Mcp-Session-Id header'ıyla isteği o session'a bağlıyordu. Load balancer arkasında iki instance varsa ikinci istek diğer makineye düştüğünde session bulunamıyordu; çözüm sticky session ya da paylaşılan bir session store'du. 2.x'te HttpServerTransportOptions.SessionMode varsayılanı Stateless. SDK dokümantasyonu bu ayarı yukarıdaki gibi açıkça yazmanı öneriyor, çünkü ilerideki bir varsayılan değişikliği davranışını sessizce değiştirmesin; eski client'lar için oturum gerekiyorsa StatefulForInitializeClients adında bir geçiş modu da var.

Stateless'ın ne demek olduğunu iki instance açarak gördüm: aynı DLL (Dynamic-Link Library), biri 19850'de, biri 19851'de. Alice'in sepetini A'da oluşturup aynı handle'la B'ye gittim:

basket=bsk_3acd36248b0d
data: {"result":{"content":[{"type":"text","text":"Basket bsk_3acd36248b0d: 1 item(s)"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":24980,"jsonrpc":"2.0"}
== same handle, instance B
data: {"result":{"content":[{"type":"text","text":"An error occurred invoking 'add_to_cart': Basket bsk_3acd36248b0d was not found. Call create_basket first."}],"isError":true,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":26798,"jsonrpc":"2.0"}

Protokol tarafında her şey yolunda: B isteği kabul etti, token'ı doğruladı, tool'u çalıştırdı. Sorun benim BasketStore'umda, çünkü sepetler A'nın belleğinde. Stateless protokol, state'in yok olduğu anlamına gelmiyor; state'in senin sorumluluğuna geçtiği anlamına geliyor. Handle istekle birlikte dolaşıyor, verinin kendisi ise iki instance'ın da göreceği bir yerde, Redis'te ya da veritabanında durmalı. Cevapların data: ile başlaması da dikkat çekici: C# SDK'sı burada tek bir JSON yerine o isteğe ait bir SSE (Server-Sent Events) stream'i döndürüyor. Spec iki biçime de izin veriyor ve client'ın ikisini de desteklemesini istiyor.

State Handle'ları: basketId'yi Kullanıcıya Bağlamak

Spec'in güvenlik bölümü bu konuya "state handle hijacking" adıyla ayrı bir başlık ayırıyor: handle'a sahip olmak authentication sayılamaz, handle'lar tahmin edilemez olmalı ve server tarafında kullanıcıya bağlanmalı. Store'u buna göre yazdım:

using System.Collections.Concurrent;

namespace Bookstore.Mcp.HttpServer;

public sealed record BasketLine(int BookId, int Quantity);

public sealed class Basket(string id, string ownerId)
{
    public string Id { get; } = id;
    public string OwnerId { get; } = ownerId;
    public List<BasketLine> Lines { get; } = [];
}

// Demo için bellekte. Load balancer arkasında birden çok instance varsa bu Redis ya da
// veritabanı olmalı: handle istekle dolaşır, veri tek bir process'te yaşayamaz.
public sealed class BasketStore
{
    private readonly ConcurrentDictionary<string, Basket> _baskets = new();

    public Basket Create(string ownerId)
    {
        string id = "bsk_" + Convert.ToHexString(Guid.NewGuid().ToByteArray())[..12].ToLowerInvariant();
        Basket basket = new(id, ownerId);
        _baskets[id] = basket;
        return basket;
    }

    // Handle tek başına yetmez: sepet çağıranın olmalı
    public Basket? Get(string basketId, string ownerId) =>
        _baskets.TryGetValue(basketId, out Basket? basket) && basket.OwnerId == ownerId ? basket : null;
}

Tool'lar kullanıcıyı ClaimsPrincipal parametresiyle alıyor. SDK bu parametreyi schema'ya koymuyor; model onu göremez ve dolduramaz. Kullanıcı kimliği token'daki sub claim'inden geliyor:

[McpServerTool(Name = "create_basket")]
[Description("Creates an empty basket and returns its basketId. Pass the basketId to the other basket tools.")]
public string CreateBasket(ClaimsPrincipal user) => baskets.Create(UserId(user)).Id;

[McpServerTool(Name = "add_to_cart")]
[Description("Adds a book to a basket created with create_basket.")]
public string AddToCart(
    ClaimsPrincipal user,
    [Description("basketId returned by create_basket")] string basketId,
    [Description("Book id, e.g. 3")] int bookId,
    [Description("How many copies (1-5)")] int quantity = 1)
{
    Basket basket = baskets.Get(basketId, UserId(user))
        ?? throw new McpException($"Basket {basketId} was not found. Call create_basket first.");
    basket.Lines.Add(new BasketLine(bookId, Math.Clamp(quantity, 1, 5)));
    return $"Basket {basket.Id}: {basket.Lines.Sum(l => l.Quantity)} item(s)";
}

private static string UserId(ClaimsPrincipal user) =>
    user.FindFirstValue("sub") ?? throw new McpException("No user in the access token.");

Önce Tahmin Et'teki senaryonun gerçek çıktısı; önce Alice, sonra aynı handle'la Bob:

== alice add
data: {"result":{"content":[{"type":"text","text":"Basket bsk_52421f52b401: 2 item(s)"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":1163,"jsonrpc":"2.0"}
== bob add to alice basket
data: {"result":{"content":[{"type":"text","text":"An error occurred invoking 'add_to_cart': Basket bsk_52421f52b401 was not found. Call create_basket first."}],"isError":true,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":18910,"jsonrpc":"2.0"}

Bob "yetkin yok" değil "bulunamadı" cevabı alıyor; böylece handle'ın var olduğunu da öğrenemiyor. 9. yazıdaki get_order_status ile aynı karar.

Authorization: MCP Server Bir OAuth Resource Server'dır

Spec'in authorization bölümü rolleri net çiziyor: korunan bir MCP server, OAuth 2.1'deki resource server'dır; MCP client, OAuth client'ıdır; token'ı bir authorization server verir. OAuth'un kendisini, authorization code flow ve PKCE (Proof Key for Code Exchange) ayrıntılarını Authentication ve Authorization serisinin "OAuth 2.0'ın Mantığı" yazısında ve bir API'yi (Application Programming Interface) korumayı anlatan "API'yi Korumak" yazısında gördük; burada MCP'nin üstüne eklediklerine bakıyoruz.

10-mcp-auth-flow-tr.png

Akışın ilk iki adımı server'ın işi. Token'sız istek 401 alıyor ve WWW-Authenticate header'ı client'a nereye bakacağını söylüyor:

HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 07:01:27 GMT
Server: Kestrel
WWW-Authenticate: Bearer resource_metadata="http://localhost:19850/.well-known/oauth-protected-resource/mcp"

Bu adresteki doküman RFC (Request for Comments) 9728'deki protected resource metadata'sı; AddMcp(...) ile verdiğimiz ResourceMetadata'dan üretiliyor:

{"resource":"http://localhost:19850/mcp","authorization_servers":["http://localhost:19852"],"bearer_methods_supported":["header"],"scopes_supported":["bookstore.basket"]}

Client buradan authorization server'ı bulur, onun metadata'sını okur, kendini tanıtır ve kullanıcıyı authorization code + PKCE akışına sokar. Token isterken RFC 8707'deki resource parametresine server'ın canonical URI'ını (Uniform Resource Identifier) koymak zorunda; böylece token yalnız bu server için verilir. Client'ın kendini tanıtma biçimi de 2026-07-28'de değişti: önerilen yol Client ID Metadata Documents, yani client'ın kimliğinin HTTPS (HTTP Secure) üzerinde yayımlanmış bir JSON dokümanının URL'i (Uniform Resource Locator) olması. Dynamic Client Registration deprecated oldu, yalnız geriye uyumluluk için duruyor.

Bu yazıda gerçek bir authorization server çalıştırmadım; Keycloak ya da OpenIddict gibi bir sunucu kurmak bu yazının konusu değil. Token'ları çalışma klasöründeki küçük bir konsol programıyla, server'ın doğruladığı anahtarla imzalayarak ürettim. Bu yalnız bir test aracı; production'da imzalama anahtarı server'da değil authorization server'da durur ve server JWKS (JSON Web Key Set) ile yalnız public key'i bilir. Token'ın doğru yere verilmiş olmasının neden önemli olduğunu ise burada görebiliriz. Alice için iki token ürettim: biri aud=http://localhost:19850/mcp, diğeri aynı issuer'dan ama başka bir API için, aud=http://localhost:19860/orders-api. İkincisiyle istek:

HTTP 401
info: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[1]
      Failed to validate the token.
      Microsoft.IdentityModel.Tokens.SecurityTokenInvalidAudienceException: IDX10214: Audience validation failed. See https://aka.ms/identitymodel/app-context-switches

İmza geçerli, issuer doğru, süre dolmamış; ama token bu server için verilmemiş. Spec bu kontrolü zorunlu tutuyor: MCP server yalnız kendisi için verilmiş token'ı kabul eder ve aldığı token'ı arkadaki bir API'ye iletmez. Bu ikinci kurala "token passthrough" yasağı deniyor; MCP server arkadaki sipariş API'sini çağıracaksa o API için kendi token'ını alır. 13. yazıda bu yasağın neden var olduğunu confused deputy problemiyle birlikte konuşacağız.

Bir ayrıntı daha: SDK'nın ürettiği 401 challenge'ında scope parametresi yok. Spec bu durumda client'ın scopes_supported'daki scope'ların hepsini istemesini söylüyor; bu yüzden o listeye yalnız temel işlevin gerektirdiği en küçük kümeyi koy. Her şeyi kapsayan bookstore.* gibi bir scope, çalınan bir token'ın etki alanını sepetten siparişe, siparişten iadeye kadar büyütür.

MRTR ile Onay İstemek: InputRequiredException

clear_basket geri alınamaz bir işlem; kullanıcıya sormak istiyoruz. 2025'te server client'a elicitation/create isteği gönderir, cevabı session üzerinden beklerdi. Session yokken server'ın bekleyeceği bir bağlantı da yok. MRTR'nin cevabı: server "şu bilgiye ihtiyacım var" diye bir ara sonuç döner, client cevabı toplayıp aynı çağrıyı tekrarlar:

[McpServerTool(Name = "clear_basket", Destructive = true)]
[Description("Removes every item from a basket. Asks the user to confirm first.")]
public string ClearBasket(
    McpServer server,
    RequestContext<CallToolRequestParams> context,
    ClaimsPrincipal user,
    [Description("basketId returned by create_basket")] string basketId)
{
    string userId = UserId(user);
    Basket basket = baskets.Get(basketId, userId)
        ?? throw new McpException($"Basket {basketId} was not found.");

    if (context.Params!.RequestState is { } state && context.Params.InputResponses is { } responses)
    {
        // requestState client'tan geri geldi: güvenmeden önce doğrula
        ConfirmState confirmed = Unprotect(state);
        if (confirmed.UserId != userId || confirmed.BasketId != basketId)
        {
            throw new McpException("The confirmation does not match this request.");
        }

        ElicitResult? answer = responses["confirm"].Deserialize(InputResponse.ElicitResultJsonTypeInfo);
        if (answer?.Action != "accept")
        {
            return $"Basket {basketId} was not cleared: the user declined.";
        }

        basket.Lines.Clear();
        return $"Basket {basketId} is now empty.";
    }

    if (!server.IsMrtrSupported)
    {
        return "This client cannot confirm actions; clear the basket from the website instead.";
    }

    throw new InputRequiredException(
        inputRequests: new Dictionary<string, InputRequest>
        {
            ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
            {
                Message = $"Remove all {basket.Lines.Count} line(s) from basket {basketId}?",
                RequestedSchema = new(),
            }),
        },
        requestState: _stateProtector.Protect(
            JsonSerializer.Serialize(new ConfirmState(userId, basketId)), TimeSpan.FromMinutes(5)));
}

_stateProtector, ASP.NET Core Data Protection'dan alınmış bir ITimeLimitedDataProtector; Unprotect da CryptographicException'ı "onay süresi doldu ya da değiştirildi" mesajına çeviren küçük bir yardımcı. Neden bu kadar uğraş? Çünkü spec requestState'i saldırganın kontrolündeki girdi sayıyor: authorization ya da iş mantığını etkiliyorsa bütünlüğü HMAC (Hash-based Message Authentication Code) ya da AEAD (Authenticated Encryption with Associated Data) ile korunmalı, içinde kullanıcı, kısa bir süre ve hangi isteğe ait olduğu bulunmalı. Data Protection üçünü de veriyor: şifreli ve imzalı payload, süre damgası, içinde kullanıcı ve sepet.

İlk tur, curl ile:

data: {"result":{"inputRequests":{"confirm":{"method":"elicitation/create","params":{"mode":"form","message":"Remove all 1 line(s) from basket bsk_52421f52b401?","requestedSchema":{"type":"object","properties":{}}}}},"requestState":"CfDJ8EsWxdwsesFPgrgDsoFZKoN_NqDMtPZFq8mnnwh1SssSbHQNDFt3AAnI1GXI15WEM9vmlXi6ErtZLWymaJjOeJ55zC9apo2JAJJagfik57q0pzGha4V30EE3Qd2e2oo_vSPbnCl-Knhzivk_W4kKiniKHchrUXUkafMUsNyB-w_Ilr2pT__T58TttdABLBo_yw","resultType":"input_required","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":3889,"jsonrpc":"2.0"}

resultType: "input_required", bir inputRequests map'i ve opak bir requestState. İkinci turda client aynı argümanlarla, yeni bir id ile, inputResponses ve aynen geri gönderdiği requestState ile geliyor. Önce son iki karakteri değiştirilmiş bir state ile, sonra doğrusuyla:

== tampered
data: {"result":{"content":[{"type":"text","text":"An error occurred invoking 'clear_basket': The confirmation has expired or was modified. Call clear_basket again."}],"isError":true,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":21094,"jsonrpc":"2.0"}
== round 2 accept
data: {"result":{"content":[{"type":"text","text":"Basket bsk_52421f52b401 is now empty."}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-http","version":"1.0.0"}}},"id":6073,"jsonrpc":"2.0"}

İki tur arasında server hiçbir şey hatırlamadı; ikinci turu başka bir instance da karşılayabilirdi, yeter ki Data Protection key ring'i paylaşılsın. 12. yazıda C# client'ının bu turları kendi başına yürüttüğünü göreceğiz.

Header'lar, Uzun İşler ve Tasks Extension'ı

2026-07-28 ile header'lar body'nin aynası oldu ve server ikisinin tutarlı olduğunu doğrulamak zorunda. Body'de create_basket, header'da başka bir ad:

== mismatch
{"error":{"code":-32020,"message":"Header mismatch: Mcp-Name header value 'search_books' does not match body value 'create_basket'."},"id":7,"jsonrpc":"2.0"}
HTTP 400
== missing name
{"error":{"code":-32020,"message":"Missing required Mcp-Name header."},"id":7,"jsonrpc":"2.0"}
HTTP 400
== GET
HTTP 405

Bu kontrol bir güvenlik özelliği: gateway header'a bakarak "search_books herkese açık" diye isteği geçirirken server body'deki create_basket'i çalıştırsaydı, iki bileşen aynı isteği farklı okumuş olurdu. Tool parametrelerini de header'a taşıyabilirsin: [McpHeader("Region")] ile işaretlenen parametre schema'da x-mcp-header alır ve client onu Mcp-Param-Region header'ı olarak gönderir; bölgeye göre routing yapan bir gateway body'yi açmak zorunda kalmaz.

Saniyeler değil dakikalar süren işler için, ör. bir kataloğu baştan indekslemek, SDK'nın opt-in ModelContextProtocol.Extensions.Tasks paketi var: server'da .WithTasks(store), client'ta CallToolWithPollingAsync. Tasks 2026-07-28'de çekirdekten çıkıp extension oldu ve 1.x'teki deneysel Tasks ile uyumlu değil. InMemoryMcpTaskStore yalnız geliştirme için; birden çok instance'ta ve restart sonrası yaşaması gereken işler için kalıcı bir IMcpTaskStore yazman gerekiyor.

Üretimde Dikkat

Birincisi, paylaşılan state. Stateless protokol üç şeyi paylaşılan bir yere iter: uygulama verisi (sepetler), Data Protection key ring'i (requestState başka instance'ta da açılabilsin) ve varsa task store. Üçünü de tek instance'ta test edip "çalışıyor" demek kolay; iki instance ile entegrasyon testi, sorunu deploy'dan önce gösterir.

İkincisi, Origin doğrulaması. Spec, DNS (Domain Name System) rebinding saldırılarına karşı server'ın gelen Origin header'ını doğrulamasını, geçersizse 403 dönmesini zorunlu tutuyor. Browser'dan çağrılmayacak bir server'da CORS (Cross-Origin Resource Sharing) kapalı kalmalı; browser'dan çağrılacaksa SDK'nın örneğindeki gibi yalnız bilinen origin'lere, yalnız POST'a ve yalnız gereken header'lara izin veren kısıtlı bir policy kur.

Üçüncüsü, rate limiting ve boyut. Her tools/call arkada bir veritabanı sorgusu, belki bir LLM (Large Language Model) çağrısı demek. ASP.NET Core Web API serisindeki rate limiting middleware'i burada da geçerli; partition anahtarı olarak IP (Internet Protocol) adresi yerine token'daki sub'ı kullan. Mcp-Method ve Mcp-Name header'ları sayesinde gateway'de tool bazında limit koymak da mümkün.

Dördüncüsü, log ve trace. Server'ın log'u artık "kim, hangi tool'u, hangi argümanla" sorusunun cevabı. Token'ı log'a yazma; sub, client id ve tool adını yaz. 14. yazıda bu kayıtlardan başarısız agent trajectory'lerini bulacağız.

Sık Düşülen Tuzaklar

Birincisi, state handle'ı authentication sanmak. bsk_52421f52b401 tahmin edilemez olsa bile log'a, ekran görüntüsüne, modelin cevabına düşebilir. Handle'ı her istekte token'daki kullanıcıyla karşılaştırmıyorsan sepet, handle'ı bilen herkesindir.

İkincisi, localhost'ta authentication'sız HTTP server bırakmak. "Nasılsa yalnız benim makinemde" düşüncesi DNS rebinding ile çöker: kötü niyetli bir web sayfası browser'ını kullanarak localhost:19850'ye istek atabilir. Yerel bir server için ilk tercih stdio; HTTP şartsa yalnız 127.0.0.1'e bind et, Origin'i doğrula ve bir token iste.

Üçüncüsü, audience'ı kapatmak. Test sırasında ValidateAudience = false yazmak hatayı hemen susturur; ama o satır production'a giderse aynı authorization server'ın başka herhangi bir API için verdiği token bu server'da geçerli olur. Yukarıdaki IDX10214 hatası bir engel değil, korumanın çalıştığının kanıtı.

Dördüncüsü, requestState'i düz JSON ya da Base64 olarak döndürmek. Client'ın içini okumaması gerekiyor ama okuyamayacağı anlamına gelmiyor. İçinde sepet id'si olan imzasız bir state'i değiştirip başka bir sepeti temizletmek, kontrolü atlayan bir server'da bir satırlık iş.

Küçük Bir Deneme

★☆☆ BasketStore'u IDistributedCache üzerine taşı ve iki instance testini tekrarla. B'deki add_to_cart artık çalışıyor mu? Peki OwnerId kontrolü hâlâ yerinde mi?

★★☆ clear_basket'in ilk turundan aldığın requestState'i Bob'un token'ıyla gönder. Hangi kontrol isteği durduruyor: sepetin sahibi mi, state içindeki kullanıcı mı? İkisinden birini kaldırınca ne değişiyor?

★★★ Server'a ikinci bir scope ekle: bookstore.basket.clear. clear_basket bu scope olmadan çağrılırsa spec'teki gibi 403 ve WWW-Authenticate: Bearer error="insufficient_scope", scope="bookstore.basket.clear" dönsün. Bunu tool içinde mi, endpoint filter'ında mı, yoksa authorization policy ile mi yapmak daha doğru?

Aynı Bookstore server'ını şimdiye kadar iki kez C# ile yazdık. Bir sonraki yazıda TypeScript'e geçiyoruz: Zod schema'larıyla tool kaydı, SDK v1 ile v2 arasındaki karar ve aynı girdiye C# ile TypeScript server'ının verdiği farklı cevaplar.