2026-07-16

MCP Sampling Tool Loops: A 26-Case Transcript Admission Drill

A local 26-case drill tests MCP sampling capability negotiation, tool-call/result balance, budgets, cancellation, and finalization without sending a prompt to a live model.

MCP Sampling Tool Loops: A 26-Case Transcript Admission Drill cover illustration

I ran a synthetic MCP sampling transcript through 26 admission cases before letting any request reach a model. Two cases reached READY. The other 24 stopped on a named boundary: missing capability, denial, cancellation, an unknown tool, an unbalanced tool result, an exhausted loop budget, or another protocol or local-policy mismatch.

The interesting failure was not the unknown tool. It was a missing result for one of two parallel tool calls. The transcript still looked plausible to a person skimming JSON, yet it could not be continued without inventing state. The current MCP sampling specification treats that accounting rule as mandatory: every tool-use block must be followed by a matching tool-result block before the conversation moves on.

This note rebuilds MCP sampling around that observation. The server may ask for a language-model generation, but the client keeps control of model access, selection, permissions, and review. Once tools enter the sampling request, the server also owns a small state machine. A prompt alone is not enough.

The first mismatch was not a model problem

MCP sampling reverses a common integration assumption. An MCP server does not need to hold a model-provider key. It sends sampling/createMessage to the client. The client decides whether the request is allowed, which available model to use, and whether a person should review or edit the prompt and response.

The 2025-11-25 specification adds an important wrinkle: a server can include tools in the sampling request. The client must advertise sampling.tools support before receiving such a request. If the model returns tool-use blocks, the client returns those blocks to the server. The server executes the tools, appends tool results to the transcript, and submits another sampling request. That is a loop, even if the product UI presents it as one feature.

The loop crosses four authorities:

  • the server proposes a prompt, tool catalog, and preferences;
  • the client admits or rejects the sampling request and chooses the model;
  • the model proposes tool calls but does not execute them;
  • the server validates and runs allowed tools, then constructs the continuation.

When those roles blur, an error is easily assigned to “the model” even though the real defect is missing capability negotiation, weak tool admission, or a malformed transcript.

A controlled data path passing through an illuminated capability gate before reaching a model chamber
Tool-enabled sampling starts with negotiated client capability, not with a hopeful request from the server.

One admission record controls the loop

The local probe used a deliberately small record. It describes the negotiated protocol version, client capabilities, human decision, request IDs, token ceilings, tool calls, tool results, cancellation state, recursion depth, and final response. It contains no provider credential and does not open a network connection.

{
  "protocolVersion": "2025-11-25",
  "capabilities": { "sampling": true, "samplingTools": true },
  "humanDecision": "approved",
  "requests": [
    { "id": "sample-001", "toolChoice": "auto", "maxTokens": 256 },
    { "id": "sample-002", "toolChoice": "none", "maxTokens": 128 }
  ],
  "toolUses": ["call-sum", "call-rate"],
  "toolResults": ["call-sum", "call-rate"],
  "cancelled": false,
  "recursionDepth": 1,
  "finalStopReason": "endTurn"
}

The fixture permits two inert tools, sum_values and lookup_fact. It caps a sampling request at 512 output tokens, the loop at three sampling requests and four tool calls, each tool result at 2,048 bytes, and nested sampling depth at one. These numbers are local operating policy, not MCP defaults. Their purpose is to make exhaustion behavior measurable.

The baseline asks for two parallel tool calls. Both IDs are unique. The next user-role message contains only the two matching tool results. A second sampling request uses toolChoice: {mode: "none"} and ends with an assistant endTurn. That transcript reached READY.

The fixture SHA-256 is 78ed08c130f85d2f47a955a6732736405e885f81617a4c02159c455318fbf8ff. Re-running the dependency-free Node probe produced 26 matches, with zero expected/observed differences.

Validation ledger: what the 26 cases proved

MutationObserved decisionWhy it matters
Balanced two-call transcriptREADYParallel tool use can continue when every call is uniquely and completely resolved.
sampling.tools absentBLOCKEDA server must not send a tool-enabled sampling request without negotiated support.
One tool result missingBLOCKEDThe continuation cannot safely guess what happened to the unresolved call.
Text mixed into the tool-result messageBLOCKEDThe current specification requires that message to contain only tool results.
Unknown send_payment toolBLOCKEDA model-visible schema is not execution authority.
Human decision changed to deniedBLOCKEDDenial is terminal for that request rather than a hint to retry.
Fourth sampling continuationBLOCKEDThe local request budget ends a recursive or unproductive loop.
Final turn forced to no toolsREADYA bounded loop can deliberately ask for a final answer after results are present.

The remaining cases covered protocol-version mismatch, the wrong method, cancellation, provider error, token overflow, invalid tool input, duplicate call IDs, extra results, wrong roles, pending tool use at finalization, too many calls, recursion depth, oversized results, sensitive data, duplicate request IDs, and a tool call while tool choice was none. Each stopped on the first applicable blocker.

That first-blocker rule is useful in operations. A request can be wrong in five ways, but the operator needs one stable reason to fix before a retry. The complete findings artifact still preserves every case and expected result for regression testing.

Capability negotiation is a hard gate

Basic sampling and tool-enabled sampling are distinct capabilities in the current specification. A client that supports sampling declares sampling during initialization. A client willing to receive tool-enabled requests declares the sampling.tools sub-capability. The server must respect the negotiated protocol version and capability set during operation.

Treat that exchange as runtime input. Do not infer support from a client name, SDK version, or successful basic completion. Cache the negotiated record only for the lifetime of that connection. A reconnect can negotiate a different version or feature set.

The older 2025-06-18 sampling page described client-controlled completions but did not define the current tool loop. That difference is a good reason to attach the protocol version to logs and test fixtures. “MCP sampling works” is too vague when the message grammar changes between revisions.

Context inclusion also deserves a version-aware check. In the 2025-11-25 specification, includeContext values that pull context from one or all servers are soft-deprecated. A conservative server can omit the field, accept the default none, and provide the exact context it intends the client to review.

Tool calls and tool results are an accounting problem

A tool-use block is a proposed obligation. Before another assistant turn, the transcript must account for it with a matching result. IDs are the ledger keys. Names and array position are not reliable substitutes, especially when the model asks for several tools in parallel.

The admission check in the probe applies four rules:

  1. every tool-use ID is unique;
  2. the next result message uses the user role and contains only tool-result blocks;
  3. the result-ID set exactly matches the tool-use-ID set;
  4. no final response is accepted while a tool use remains pending.

An execution failure still needs a result. The result may describe an error, but it closes the obligation and gives the model truthful state. Dropping the failed call produces a prettier transcript and a worse system.

The server should also validate a proposed tool name and input against its own allowlist and schema immediately before execution. The client may have shown the tool to the model, and a person may have approved the sampling prompt, but neither event grants arbitrary server-side authority. A tool catalog is descriptive; the execution policy is decisive.

A loop budget needs more than maxTokens

maxTokens limits one model response. It does not cap the number of sampling continuations, parallel calls, nested sampling requests, result size, wall-clock time, or external cost of tools. A production loop needs separate counters.

The fixture uses five independent ceilings: sampling requests, tool calls, tokens per request, bytes per result, and recursion depth. A real host would usually add elapsed time, provider spend, aggregate input size, and per-tool side-effect budgets. Store the counters next to the transcript so a retry cannot quietly reset them.

A model chamber surrounded by concentric policy walls representing request, token, tool-call, result-size, and recursion budgets
A token ceiling protects one response. Concentric loop budgets protect the operation around it.

There is a trade-off. A tight loop may stop a legitimate investigation before it reaches a useful answer. A loose loop may spend indefinitely or repeat a consequential tool. The right response to exhaustion is not an automatic larger budget. Return a terminal record containing the consumed counters, unresolved goal, last safe transcript hash, and the person or policy that may authorize a new run.

Cancellation and denial leave a terminal record

The lifecycle specification recommends timeouts for sent requests and a cancellation notification when the request no longer receives a response. It also says implementations should stop waiting after the timeout. That needs to show up in application state. A canceled sampling request must not later resume because a delayed provider response arrived.

Human denial has the same shape. The sampling specification recommends review controls and gives users the ability to deny a request. Record the denied request ID, transcript digest, policy version, time, and reason category. Do not silently rewrite the prompt and resubmit it under the original approval.

Provider errors are different from denial but still terminal for the attempt. A retry may be allowed by policy, yet it should receive a new request ID and consume the same run budget. This distinction makes postmortems possible: the operator can tell whether the client rejected the work, the user denied it, the server canceled it, or the provider failed.

The last turn should be intentionally boring

The current sampling spec permits three tool-choice modes: auto, required, and none. After the server has appended all required tool results, a final request can select none to ask for synthesis without opening another tool round. The specification presents this as one way a server might cap iterations.

That final response still needs admission. It must use the assistant role, end for an accepted reason, and contain no pending tool use. The server then decides what the response can influence. A generated paragraph is data until another explicit policy grants it a side effect.

The safe endpoint of an MCP sampling loop is not “the model stopped.” It is “the transcript balanced, the budgets held, the final response passed policy, and the server recorded what happens next.”

Failure modes the fixture cannot measure

This was a deterministic transcript test, not an interoperability or model-quality benchmark. It did not start an MCP client or server. It did not call a model provider, execute a real tool, use credentials, measure tokens, incur cost, test latency, or create an external side effect. The two example tools return no live data.

The probe therefore supports a narrow claim: given the fixture and its local policy, all 26 expected admission decisions matched. It does not prove that an SDK serializes every block correctly, that a provider preserves parallel-call semantics, that a user interface communicates risk well, or that a model will choose sensible tools.

The next useful test is an adapter conformance suite. Feed the same balanced and malformed transcripts through the exact SDK/provider adapter, capture wire-safe messages, and compare role, content, ID, and stop-reason mappings. Keep external tools inert until that layer passes.

Implementation handoff

  • Persist the negotiated protocol version and sampling capabilities per connection.
  • Give every sampling request and tool use a unique ID.
  • Show the exact prompt, tool catalog, token ceiling, and server identity at the client approval boundary.
  • Validate tool names and inputs again on the server immediately before execution.
  • Require a result for every proposed tool call, including failed executions.
  • Keep tool-result messages free of unrelated text or media.
  • Count requests, calls, bytes, depth, time, and spend independently.
  • Make denial, cancellation, timeout, exhaustion, and provider error terminal for that attempt.
  • Force a no-tool final turn when the local policy says the loop is complete.
  • Hash and retain the admitted transcript plus the terminal decision for replay and incident review.

A persistent worker can host the server process, fixtures, logs, schedules, and review channel around this loop. If that is the operating shape you need, create an AI agent on GolemWorkers. Keep the model credential and permission decision in the client, and treat the worker as the server-side operator—not as an excuse to collapse the boundary.

Primary sources