Model Context Protocol · AI agents · Security

MCP Sampling with Tools: Build a Bounded Server-Initiated Model Loop

Let an MCP server request model work through its client while enforcing tool capability checks, iteration budgets, approvals, and message balance.

Published
Updated
Code examples
Illustrative
Reading time
5 minutes

MCP sampling lets a server request language-model generation through its client, so the client retains model selection, permissions, and credentials. In the 2025-11-25 specification, the server may also offer tools inside that sampling request and run a multi-turn tool loop. The safe design is a small, bounded loop with explicit capability negotiation, narrow tools, human review, and complete tool-result pairing.

Sampling is not a way for an MCP server to seize control of the host model. The client may review, edit, reject, or constrain the request. The specification recommends a human in the loop who can deny sampling, inspect prompts, and review generated responses (MCP sampling user-interaction model).

Start with a use case that belongs on the server

Consider a compliance server that owns a policy corpus and a deterministic rule checker. Its draft_policy_exception tool needs model help to summarize relevant clauses, but the deployment should not contain a model-provider API key.

The MCP server can ask the connected client to sample, offering only two read-only helper tools:

  • read_policy_section(sectionId) returns a bounded, versioned excerpt;
  • check_exception_fields(draft) returns structured validation findings.

Do not include the server's entire MCP tool surface. A nested sampling model should see only the tools required for this task, and write-capable operations should remain outside the loop.

Negotiate tool-enabled sampling

A client that supports basic sampling declares sampling. Tool-enabled requests require the nested sampling.tools capability:

{
  "capabilities": {
    "sampling": {
      "tools": {}
    }
  }
}

A server must not send tools when the client did not declare that capability. If only basic sampling is available, fall back to a prompt with already retrieved bounded context or return an actionable unsupported-capability error.

The includeContext values thisServer and allServers are soft-deprecated in 2025-11-25; omit includeContext so it defaults to none unless a carefully reviewed compatibility case requires otherwise. Explicitly construct context instead of importing unknown data from every connected server.

Send a narrow sampling request

An illustrative request is:

{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "sampling/createMessage",
  "params": {
    "systemPrompt": "Draft a policy exception using only supplied policy sections. Mark unsupported claims.",
    "messages": [
      {
        "role": "user",
        "content": {
          "type": "text",
          "text": "Draft an exception for case CASE-381. Required sections: scope, justification, expiry, controls."
        }
      }
    ],
    "tools": [
      {
        "name": "read_policy_section",
        "description": "Read one current policy section by an allowlisted identifier.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "sectionId": { "type": "string", "pattern": "^[A-Z]+-[0-9]+$" }
          },
          "required": ["sectionId"],
          "additionalProperties": false
        }
      }
    ],
    "toolChoice": { "mode": "auto" },
    "maxTokens": 1200
  }
}

The numbers are illustrative policy limits, not measured recommendations. Select budgets from your task and evaluation data. A server may provide model hints and abstract intelligence, speed, or cost priorities, but the client chooses the actual model and may not recognize a provider-specific name.

The response may contain text or one or more tool_use blocks. Each tool use has an ID, name, and input. Validate all three before dispatch.

Implement the loop as an explicit state machine

type LoopLimits = Readonly<{
  maxTurns: number;
  maxToolCalls: number;
  deadlineMs: number;
}>;

async function runSamplingLoop(limits: LoopLimits) {
  const startedAt = Date.now();
  let turns = 0;
  let toolCalls = 0;
  let messages = initialMessages();

  while (turns < limits.maxTurns) {
    if (Date.now() - startedAt >= limits.deadlineMs) {
      throw new Error("sampling deadline exceeded");
    }

    const response = await requestSampling(messages);
    turns += 1;

    const uses = collectToolUses(response.content);
    if (uses.length === 0) return requireFinalText(response);

    toolCalls += uses.length;
    if (toolCalls > limits.maxToolCalls) {
      return requestFinalAnswer(messages, response);
    }

    const results = await executeAllowedTools(uses);
    messages = appendBalancedTurn(messages, response, results);
  }

  return requestFinalAnswer(messages);
}

This is framework-neutral pseudocode. executeAllowedTools must reject unknown names, validate inputs, enforce principal and tenant authorization, set per-call timeouts, bound parallelism, sanitize results, and record audit events. requestFinalAnswer can set toolChoice to { "mode": "none" } so the last turn cannot ask for more tools.

Count turns and tool calls independently: one model response may contain several parallel uses. Also bound cumulative input bytes, output bytes, tokens where measurable, and external cost. A deadline should not reset forever on progress.

Preserve the required message balance

After an assistant response contains tool_use blocks, the next user message must contain only matching tool_result blocks. Every tool-use ID needs exactly one result before any other content. This is a normative compatibility constraint in the sampling message rules.

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "toolUseId": "call_72a",
      "content": [
        {
          "type": "text",
          "text": "POL-14 revision 8: Exceptions must expire within the authorized period."
        }
      ]
    }
  ]
}

Do not add “Here is the result” as a text block in the same user message. If a requested tool fails, still produce a matching tool_result carrying a bounded failure description so the conversation remains structurally valid.

Apply tool policy before execution

The server offered the tools, but the returned tool calls remain untrusted model output. For each call:

  1. verify the tool name is in the per-request allowlist;
  2. validate its arguments against the exact schema;
  3. derive identity from the authenticated MCP context, never model input;
  4. authorize the object and tenant;
  5. classify the tool as read, preview, or commit;
  6. require approval before a sensitive commit;
  7. enforce timeout, concurrency, and result-size limits; and
  8. redact credentials and sensitive fields from results.

Tool descriptions are context for the model, not enforcement. Keep the enforcement table in server code.

Parallel tool use is allowed by MCP. Execute in parallel only when calls are independent, read-only or otherwise concurrency-safe, and within a small bound. Preserve each result's original toolUseId; completion order must not change the pairing.

Keep nested authority smaller than outer authority

Sampling can create a subtle privilege escalation: a user calls one harmless-looking MCP tool, which then asks the client model to call a more powerful nested tool. Prevent this by making nested authority a strict subset of the original operation's approved purpose.

Show the user the sampling prompt and nested tool set when the host supports review. Never include access tokens or hidden client data. Treat outputs from other MCP servers as untrusted context, which is another reason to avoid the soft-deprecated all-server context mode.

If the model drafts an externally visible artifact, return a preview. A separate, explicit outer tool should commit it after authorization and approval.

Failure modes and recovery

  • Client lacks sampling.tools: use a non-tool sampling request or stop with a clear capability error.
  • Model requests an unknown tool: return a matched tool error; do not dynamically widen the allowlist.
  • One parallel call fails: return results for every tool-use ID, including a safe failure result.
  • Loop repeats: enforce hard turn, call, time, byte, and cost ceilings.
  • Sampling request is denied: preserve a deterministic fallback or report that user approval was withheld.
  • Prompt injection arrives in a tool result: label provenance, keep instructions out of data, and constrain the next turn.
  • Client disconnects: do not assume the operation was cancelled; use explicit cancellation and idempotency for effects.

For work that must survive disconnection, the 2025-11-25 specification can task-augment sampling requests, but MCP Tasks remain experimental.

Release checklist

  • Require the sampling.tools capability before sending tools.
  • Offer the smallest read-oriented tool set.
  • Keep context explicit and avoid soft-deprecated automatic inclusion.
  • Validate and authorize every returned tool call.
  • Preserve one-to-one tool-use/result balance.
  • Bound turns, calls, parallelism, bytes, time, tokens, and cost.
  • Force a no-tools final turn when the budget is exhausted.
  • Give users review and denial controls.
  • Keep commits outside the sampling loop when possible.
  • Evaluate malicious tool results, repeated calls, and missing result pairs.