← Blog
AI & DEVOPS·1 AĞUSTOS 2026AUG 1, 2026·11 DK OKUMA11 MIN READ

Agent'lara API tasarlamak: retry'a dayanıklı MCP tool contract'larıDesigning agent-facing APIs: MCP tool contracts that survive a retry

MCP'nin 2026-07-28 spec'i, kopan isteklerin yeniden gönderilmesini zorunlu kıldı. İki kez çağrılması güvenli olmayan bir tool artık production tool'u değil — şeması olan bir bug. Idempotency, handle'lar, hata şekilleri, MRTR ve şemanızın token faturası.

MCP's 2026-07-28 spec made re-issuing broken requests mandatory. A tool that is not safe to call twice is no longer a production tool — it is a bug with a schema. Idempotency, handles, error shapes, MRTR, and the token bill your schema runs up.

Naylalabs · MühendislikEngineering
Snapshot: Bu yazı MCP'nin 2026-07-28 spesifikasyonu esas alınarak yazıldı; son doğrulama: 1 Ağustos 2026. Tüm şemalar, testler ve ölçümler demo repo'dan: agent-native-ops-demo (tag v0.1.0). Snapshot: Written against the MCP 2026-07-28 specification; last verified August 1, 2026. All schemas, tests and measurements come from the demo repo: agent-native-ops-demo (tag v0.1.0).
Agent-Native Operations · 2/5. Beş bölümlük serinin 2. yazısı — 1. Stateless MCP server · 2. Tool contract'ları · 3. Agent kimliği · 4. OpenTelemetry GenAI · 5. Auto-remediation guardrail'leri Agent-Native Operations · 2/5. Part 2 of a five-part series — 1. Stateless MCP server · 2. Tool contracts · 3. Agent identity · 4. OpenTelemetry GenAI · 5. Auto-remediation guardrails

On yıllık REST, bize API'yi doküman okuyan, bağlamı kafasında tutan ve POST /orders'ı bir kez çağıran geliştiriciler için tasarlamayı öğretti. Bir MCP tool'unun çağıranı bunların hiçbirini yapmıyor. Açıklamanızı bir context window'dan örnekliyor, iki tur önce ne yaptığını unutuyor ve — 2026-07-28 spec'i itibarıyla — bir response stream'i uçuşta koptuğunda tool'unuzu ikinci kez ateşlemeye mecbur.

Ten years of REST taught us to design APIs for developers who read docs, hold context in their heads, and call POST /orders once. An MCP tool's caller does none of these things. It samples your description through a context window, forgets what it did two turns ago, and — as of the 2026-07-28 spec — is required to fire your tool a second time whenever a response stream breaks mid-flight.

Şu son cümle yavaş okunmayı hak ediyor. Spec, SSE resumability'yi kaldırdı: kopan stream uçuştaki isteği kaybeder ve client bunu yepyeni bir request ID ile tekrar göndermek zorundadır. "Retry" ile "yeni niyet"i protokol seviyesinde ayırt etmenin hiçbir yolu yok. Bunun operasyon tarafını serinin ilk yazısında işlettik ve ölçtük; bu yazı tasarım tarafı: ayakta kalan sözleşmeler.

That last sentence deserves a slow read. The spec removed SSE resumability: a broken stream loses the in-flight request, and the client must re-issue it with a brand-new request ID. There is no protocol-level way to tell "retry" apart from "new intent". We operated and measured the ops side in the first post of this series; this one is the design side: contracts that hold up.

01Tool contract, bir makineye verilmiş sözdürA tool contract is a promise to a machine

Tool contract'ı, bir agent'ın tool'unuzu çağırmadan önce bilebileceği her şeydir: isim, açıklama, inputSchema, outputSchema ve — örtük olarak — işler ters gittiğinde ne olacağı. Bir contract'ı şema dökümünden ayıran dört özellik var:

A tool contract is everything an agent can know about your tool before calling it: the name, the description, inputSchema, outputSchema, and — implicitly — what happens when things go wrong. Four properties separate a contract from a schema dump:

Yazının kalanı, en sık yanlış yapıldığını gördüğümüz sırayla bu dört özellik.

The rest of this post is those four properties, in order of how often we have seen them go wrong.

02Retry güvenliği: ilk çağrı için değil, ikinci çağrı için tasarlayınRetry-safety: design for the second call, not the first

Naif tool:

The naive tool:

invoice.controller.ts — ❌
// Mutlu yolda doğru. Bir stream ilk koptuğunda yanlış.// Correct on the happy path. Wrong the first time a stream breaks.
@Tool({
  name: 'create_invoice_naive',
  description: 'Müşteri için fatura oluşturur.''Creates an invoice for a customer.',
  parameters: z.object({
    customerId: z.string(),
    amountCents: z.number().int().positive(),
  }),
})
async createInvoice(@Payload() input: CreateInvoiceInput) {
  return this.invoices.create(input); // iki çağrı → iki fatura// two calls → two invoices
}

2026-07-28 semantiğinde, iş commit olduktan sonra ama sonuç client'a ulaşmadan kopan bir stream, zorunlu bir retry üretir — ve bir duplicate fatura. Bunu ilk yazının pod-kill deneyinde ölçtük: koşu başına 8 crash ile, naif tool 2.700 niyetin 37'sinde çift fatura kesti (%1,37) — her 73 niyetten biri. Aynı deneyde aşağıdaki key'li tasarım bunu %0,05'e (2.108 niyette 1) indirdi. Çözümün yeri yorum satırı değil, contract'ın kendisi:

Under 2026-07-28 semantics, a stream that breaks after the work commits but before the result reaches the client produces a mandatory retry — and a duplicate invoice. We measured this in the first post's pod-kill experiment: with 8 crashes per run, the naive tool double-billed 37 of 2,700 intents (1.37%) — one in every 73. In the same experiment, the keyed design below cut that to 0.05% (1 in 2,108). The fix belongs in the contract, not in a comment:

invoice.controller.ts — ✅
// Retry burada baş tacı. Aynı key → aynı fatura, replay edilmiş sonuç.// Retries are welcome here. Same key → same invoice, replayed result.
@Tool({
  name: 'create_invoice',
  description:
    'Fatura oluşturur. Her iş niyeti için benzersiz bir idempotency_key verin; ''Creates an invoice. Pass an idempotency_key that is unique per business intent; ' +
    'aynı key ile gelen retry, yeni fatura oluşturmak yerine ilk faturayı döner.''a retry with the same key returns the first invoice instead of creating a new one.',
  parameters: z.object({
    customerId: z.string(),
    amountCents: z.number().int().positive(),
    idempotency_key: z.string().max(64)
      .describe('Amaçlanan her fatura için benzersiz. SADECE aynı niyeti retry ederken tekrar kullanın.''Unique per intended invoice. Reuse ONLY when retrying the same intent.'),
  }),
})
async createInvoice(@Payload() input: CreateInvoiceInput) {
  return this.idempotent.execute(input.idempotency_key, () =>
    this.invoices.create(input),
  );
}

Orada üç tasarım kararı saklı ve her biri tutmanız gereken bir söz:

Three design decisions hide in there, and each one is a promise you must keep:

Bunu açıklamada da söyleyin. Şema biçimi zorlar; modelin muhakemesine ulaşan tek kanal açıklamadır. "SADECE aynı niyeti retry ederken tekrar kullanın" dokümantasyon süsü değil — bir prompt'tur.

Say it in the description, too. The schema enforces shape; the description is the only channel that reaches the model's reasoning. "Reuse ONLY when retrying the same intent" is not documentation fluff — it is a prompt.

03Handle'lar: kendini gösteren stateHandles: state that shows itself

Spec'in çağrılar arası state cevabı, server-minted handle: sunucunun döndüğü, client'ın sıradan bir argüman olarak geri getirdiği opak bir string. Bu, state'i contract'ın içine koyuyor — ve bu bir hediye; sonuna kadar alın:

The spec's answer to cross-call state is the server-minted handle: an opaque string the server returns and the client passes back as an ordinary argument. This puts state inside the contract, and that is a gift — take it fully:

search.controller.ts — outputSchema
outputSchema: z.object({
  results: z.array(SearchResult).max(20),
  next_page: z.string().nullable()
    .describe('Opak cursor. Devam etmek için search_next aracına verin. ~15 dakikada expire olur.''Opaque cursor. Pass it to search_next to continue. Expires in ~15 minutes.'),
})

04Hata sözleşmeleri: hatalarınız artık birer promptError contracts: your errors are prompts now

Bir insan hataya çarptığında en kötü ihtimalle okur, iç çeker, başka bir şey dener. Bir agent hataya çarptığında hata metni context window'a girer ve sonraki her kararı şekillendirir. Testi basit: bir dil modeli, yalnızca bu hatayı okuyarak bir sonraki hamlesini doğru seçebilir mi?

When a human hits an error, worst case they read it, sigh, and try something else. When an agent hits an error, the error text enters the context window and shapes every subsequent decision. The test is simple: can a language model, reading only this error, choose its next action correctly?

structuredContent — hata şeklistructuredContent — error shape
{
  "code": "quota_exceeded",          // stabil, snake_case, enumerable
  "message": "Bu workspace için aylık fatura kotası doldu.""The monthly invoice quota for this workspace is exhausted.",
  "retryable": false,                 // yükün çoğunu taşıyan tek bit// the single bit doing most of the work
  "retry_after_ms": null,
  "recovery": "Kullanıcıdan kotayı artırmasını iste veya fatura oluşturmayı ertele.""Ask the user to raise the quota, or defer creating the invoice."
}

Ve bir yasak: başarısızlığı asla özür cümleli başarılı sonuç olarak kodlamayın. "Üzgünüm, bir şeyler ters gitti" mesajlı bir resultType: "complete", sahadaki en yaygın contract bug'ıdır — çağırana tool'unuzun bazen sessizce başarısız olduğunu öğretir, ki bu gürültülü başarısız olmaktan kötüdür.

And one prohibition: never encode failure as a successful result with an apologetic string. A resultType: "complete" carrying "Sorry, something went wrong" is the single most common contract bug in the wild — it teaches the caller that your tool sometimes silently fails, which is worse than failing loudly.

05MRTR: tool'un geriye soru sorması gerektiğindeMRTR: when the tool needs to ask a question back

2026-07-28, sunucu-başlatmalı istekleri (elicitation/create, sampling/createMessage) Multi Round-Trip Request ile değiştirdi: tool, inputRequests taşıyan bir resultType: "input_required" döner; client, orijinal isteği inputResponses ve sizin korelasyon için verdiğiniz requestState ile yeniden gönderir.

2026-07-28 replaced server-initiated requests (elicitation/create, sampling/createMessage) with Multi Round-Trip Requests: the tool returns a resultType: "input_required" carrying inputRequests, and the client retries the original request with inputResponses plus your requestState for correlation.

MRTR akışı — multi round-trip requestMRTR flow — multi round-trip request
1. TUR — eksik girdiyle çağrıROUND 1 — call with missing input
1AgentMCPtools/call (request-id: r1)tools/call (request id r1)
2MCPAgentresultType: "input_required" · inputRequests ("hangi müşteri?") + requestStateresultType: "input_required" · inputRequests ("which customer?") + requestState
2. TUR — girdiyle retryROUND 2 — retry with the input
3Agenteksik girdiyi toplar (kullanıcıdan ya da kendi kararından)gathers the missing input (from the user or its own decision)
4AgentMCPAYNI istek + inputResponses + requestStateyeni request-id (r2), herhangi bir replicathe SAME request + inputResponses + requestState — a new request id (r2), any replica
5MCPAgentresultType: "complete"
requestState: imzalı · boyutu sınırlı · TTL'li requestState: signed · size-capped · TTL'd process belleği YOK — retry başka replica'ya düşebilir NO process memory — the retry can land on another replica
Şekil 1. MRTR'da korelasyonu protokol değil sizin requestState'iniz taşır; retry herhangi bir replica'ya düşebileceği için state ya kendi içinde ya da handle store'unuzda yaşamak zorunda. Figure 1. In MRTR, correlation is carried by your requestState, not the protocol; since the retry can land on any replica, the state must live either inside itself or in your handle store.

06Token bütçesi: şemanız prompt'tur, prompt ise paraToken budgets: your schema is prompt, and prompt is money

Yayınladığınız her tool açıklaması ve şeması, sizi listeleyen her agent'ın context'ine enjekte edilir. Yani tool kataloğu, çağıranlarınızın ödediği tekrarlayan, istek-başına bir maliyettir — ve bir noktadan sonra doğruluk maliyetidir de, çünkü şişmiş kataloglar tool seçimini bozar. Demo repo'nun 6 tool'luk kataloğunu üç yazım stiliyle tokenladık (scripts/schema-tokens.py, o200k_base):

Every tool description and schema you publish is injected into the context of every agent that lists you. A tool catalog is therefore a recurring, per-request cost paid by your callers — and past a point, an accuracy cost too, because bloated catalogs degrade tool selection. We tokenized the demo repo's 6-tool catalog in three writing styles (scripts/schema-tokens.py, o200k_base):

StilStyleToken / katalogTokens / catalog1M oturumdaPer 1M sessionsOranRatio
(a) Kuru — açıklamasız şema(a) Terse — schema, no descriptions652652M0.62×
(b) İşlenmiş — 1-2 cümle + kritik kurallar(b) Engineered — 1-2 sentences + critical rules1 0521.05B1.00×
(c) Şişkin — her alana paragraf boilerplate(c) Bloated — a boilerplate paragraph per field2 5162.52B2.39×

Dolar karşılığını sağlayıcınızın güncel girdi fiyatıyla kendiniz çarpın; oran değişmiyor: şişkin stil, işlenmiş stilin 2,4 katı — ve 1M oturumda aradaki fark 1,5 milyar token. Kuru stil %38 tasarruf ettiriyor ama modelin muhakemesine ulaşan tek kanalı (kural cümlelerini) da atıyor. Tatlı nokta (b): bir cümle ne yaptığı, bir cümle modelin çiğnememesi gereken kurallar (idempotency tekrar kullanımı, handle expiry), başka hiçbir şey.

Multiply into dollars with your provider's current input price; the ratio does not change: the bloated style costs 2.4× the engineered one — and the gap per 1M sessions is 1.5 billion tokens. The terse style saves 38% but throws away the only channel that reaches the model's reasoning (the rule sentences). The sweet spot is (b): one sentence of what, one sentence of rules the model must not break (idempotency reuse, handle expiry), nothing else.

07Contract'ı çağıranları şaşırtmadan evriltmekEvolving a contract without gaslighting your callers

Agent'lar kataloğunuzu cache'ler (ttlMs ne kadar süreyle olduğunu söyler) ve modeller oturum boyunca semantiğinizi içselleştirir. Contract değişikliğinin tam olarak üç güvenli çeşidi vardır:

Agents cache your catalog (ttlMs says for how long) and models internalize your semantics across a session. Contract changes come in exactly three safe flavors:

08Implementasyonu değil, contract'ı test edinTest the contract, not the implementation

Demo repo'daki contract testleri bilerek aptal — bir şeyleri yakalamalarının sebebi de bu:

The contract tests in the demo repo are deliberately dumb, which is why they catch things:

contract.spec.ts
// Retry güvenliği için önemli olan yegâne üç test.// The only three tests that matter for retry safety.
it('tekrar değil replay: aynı idempotency_key → tek side effect, özdeş sonuç''replays rather than repeats: same idempotency_key → one side effect, identical result', async () => {
  const key = 'itest-' + randomUUID();
  const a = await callTool('create_invoice', { ...input, idempotency_key: key });
  const b = await callTool('create_invoice', { ...input, idempotency_key: key });
  expect(await countInvoices()).toBe(1);
  expect(b).toStrictEqual(a); // bayt-bayt replay
});

it('süresi geçmiş handle, 500 değil recovery taşıyan handle_expired döner''an expired handle returns handle_expired carrying recovery, not a 500', async () => { /* … */ });

it('katalog deterministik ve token bütçesinin içinde', async () => {
  const l1 = await listTools(), l2 = await listTools();
  expect(l1).toStrictEqual(l2);
  expect(countTokens(JSON.stringify(l1))).toBeLessThan(BUDGET);
});

Kataloğun kendisini CI'da snapshot-test edin: herhangi bir tool açıklamasını değiştiren PR, review'da bir contract diff'i olarak görünür — çünkü tam olarak odur. Bir contract bug'ı production'a sızdığında isteyeceğiniz iz ise girdileri ve retry bağlantısıyla tool-call span'ı — serinin observability yazısının bölgesi.

Snapshot-test the catalog itself in CI: a PR that changes any tool description shows up in review as a contract diff — because that is what it is. And when a contract bug does reach production, the trail you will want is the tool-call span with its inputs and retry linkage — this series' observability post's territory.

09Sık sorulanlarFAQ

Her tool idempotency key almalı mı?
Salt-okur tool'lar almamalı — aramaya takılan gereksiz bir key, şema gürültüsüdür (ve 06'daki tabloya göre token harcamasıdır). Side effect'i olan her tool almalı. Bir tool'un "gerçekten" side effect'i var mı emin değilseniz, vardır.

Should every tool take an idempotency key?
Read-only tools should not — a spurious key on a search is schema noise (and per section 06's table, token spend). Every tool with a side effect should. If you are unsure whether a tool "really" has side effects, it has side effects.

Neden payload hash'iyle dedupe etmek yerine client'ın verdiği key?
Çünkü niyet çağıranda yaşar. İki özdeş payload iki gerçek niyet olabilir ("iki kez aldılar, iki kez tahsil et"); bunu yalnızca agent bilir. Hash-dedup, sunucunun tahmin yürütmesidir — ve yanlış tahmin ettiğinde gerçek işi sessizce yutar.

Why client-supplied keys instead of deduplicating by payload hash?
Because intent lives in the caller. Two identical payloads can be two genuine intents ("charge them twice, they bought twice"); only the agent knows. Hash-dedup is the server guessing — and silently swallowing real work when it guesses wrong.

Mutasyon yapan tool'lara hint koyup dikkatli olmayı client'a bırakamaz mıyım?
Hint'ler client'ın onay isteyip istemeyeceğini şekillendirir; kopan stream sonrası zorunlu yeniden gönderime hiçbir şey yapmaz. Retry güvenliği çağırana devredilemez — çağıran, retry etmeye mecbur.

Can I just add hints to mutating tools and rely on the client to be careful?
Hints shape whether a client asks for confirmation; they do nothing about the mandatory re-issue after a broken stream. Retry-safety cannot be delegated to the caller — the caller is required to retry.

Açıklamalar ne kadar ayrıntılı olmalı?
Bir cümle amaç, bir cümle kural. Ötesi, kendi tool-seçim doğruluğunuzu düşürmek için token ödemektir — 06'daki tablo: boilerplate stil, işlenmiş stilin 2,4 katı.

How verbose should descriptions be?
One sentence of purpose, one sentence of rules. Past that you are paying tokens to lower your own tool-selection accuracy — per the table in section 06, the boilerplate style costs 2.4× the engineered one.

10Changelog

MCPNestJSAPI TasarımıAPI DesignIdempotency2026-07-28