Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Query vs Command

Audience:engineerarchitectsecuritynon-technicalMCP spec 2026-07-28

Summary

Split your MCP tools into two disjoint categories: queries that only read state, and commands that change it. Each category gets different consent handling, different idempotency guarantees, and different error semantics, and each category declares itself through tool annotations so the host can tell them apart without guessing. A tool that does both is a tool that does neither well.

Problem addressed

When tools mix reads and writes, the host cannot tell which calls are safe to retry, which need explicit approval, and which can run freely in an agent loop. A model-generated call to a hybrid tool may produce an unintended side effect on retry, fail consent because the user did not know it would write, or get over-prompted because the host assumes every call is destructive.

Serverless makes this worse, not better. On Vercel, a Vercel Function invocation can be terminated at its maxDuration mid-call, clients retry on cold starts and network blips, and there is no resident process to remember that a write already happened. Separating queries from commands lets the host treat each call appropriately: queries flow freely and retry safely, commands stop for approval and carry idempotency keys, and the failure model of each is obvious from the schema.

When to use

  • The same backend supports both reads and writes (most do).
  • The model needs to inspect state before acting on it, the standard shape of an agent loop.
  • The host wants different UI for “look up” and “do” (different icons, different consent dialogs, different rendering).
  • Retries are expected at any layer (network, model, agent loop, serverless timeout), and you cannot afford a retry to double-charge, double-send, or double-create.
  • You want auditability: read access and write access must be distinguishable in logs.

When not to use

  • The integration is genuinely read-only or genuinely write-only. The distinction is moot.
  • Tools are trivial wrappers over an idempotent backend API where reads and writes are already commutative. Even then, naming conventions (get_* vs create_*) and annotations cost nothing and aid both the model and the host.

Architecture / flow diagram

BackendServerClientHostBackendServerClientHosttools/call get_item (query)1tools/call get_item2GET /items/423200 OK4result (safe to retry)5render6ask user for approval7tools/call create_item (command)8tools/call create_item9POST /items (idempotency key)10201 Created11result plus new state12render plus audit13
BackendServerClientHostBackendServerClientHosttools/call get_item (query)1tools/call get_item2GET /items/423200 OK4result (safe to retry)5render6ask user for approval7tools/call create_item (command)8tools/call create_item9POST /items (idempotency key)10201 Created11result plus new state12render plus audit13
Mermaid sequence diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
sequenceDiagram
    autonumber
    participant Host
    participant Client
    participant Server
    participant Backend

    Host->>Client: tools/call get_item (query)
    Client->>Server: tools/call get_item
    Server->>Backend: GET /items/42
    Backend-->>Server: 200 OK
    Server-->>Client: result (safe to retry)
    Client-->>Host: render

    Host->>Host: ask user for approval
    Host->>Client: tools/call create_item (command)
    Client->>Server: tools/call create_item
    Server->>Backend: POST /items (idempotency key)
    Backend-->>Server: 201 Created
    Server-->>Client: result plus new state
    Client-->>Host: render plus audit

Protocol implications

  • Tools are declared via tools/list and invoked via tools/call. The protocol does not enforce a query/command distinction; the pattern is a discipline the server applies on top.
  • Declare the category with ToolAnnotations: readOnlyHint: true on queries; destructiveHint and idempotentHint set honestly on commands. The spec defines readOnlyHint, destructiveHint, idempotentHint, and openWorldHint; there is no dedicated “requires approval” field, so the host derives the consent requirement from these hints plus the read/write split. Annotations are untrusted hints, not enforcement: a host must not skip consent because a server claimed readOnlyHint.
  • Name tools so the category is obvious without reading the schema: get_*, list_*, search_* for queries; create_*, update_*, delete_*, send_* for commands. MCP’s official tool-naming guidance (SEP-986) applies; align your conventions with it so the split is legible to both the model and the host.
  • Return tools/list in deterministic order, which 2026-07-28 recommends for client caching and LLM prompt-cache hit rates. A stable order also keeps the split legible: group queries and commands consistently instead of interleaving them by registration accident, and the model’s prompt prefix stays cacheable as tools are added.
  • Queries should be safe to retry and should not require user approval beyond the host’s standing consent for the connection.
  • Commands should accept and honor an idempotency key so the same call retried produces the same effect, not a duplicated one. First write wins; the replay returns the original result.
  • Errors from queries are informational; errors from commands must indicate whether the side effect occurred. Succeeded-then-lost-the-response is a different failure from never-ran, and on a platform that can terminate an invocation at the deadline, your command results must let the caller tell them apart.
  • Per SEP-1303 (adopted in MCP 2025-11-25 and unchanged in 2026-07-28), return input-validation failures as tool execution errors (a tools/call result with isError: true), not JSON-RPC protocol errors, so the model can read the rejection and self-correct. This applies to both categories but matters most for commands, where strict argument validation is the gate in front of a side effect. The TypeScript SDK v2 follows this: schema-invalid arguments come back as isError: true results, and callTool does not throw for them. Calling a tool that does not exist is the opposite case, a protocol error: SDK v2 rejects the tools/call outright rather than returning a tool result.

Vercel mapping

  • Annotations are one line of registration. With the TypeScript SDK v2 under mcp-handler 2.x, server.registerTool(name, { description, inputSchema: z.object({ ... }), annotations: { readOnlyHint: true } }, handler) declares a query; commands set destructiveHint and idempotentHint instead. The split costs nothing at runtime; it is pure contract.
  • Serverless is a retry machine, so idempotency is not optional. A function can hit maxDuration after the backend write but before the response is sent, and the client’s natural response is to retry. Every command needs an idempotency key with first-write-wins replay.
  • The replay store cannot live in module scope. Fluid compute reuses instances best-effort; an in-memory idempotency cache works on the instance that took the first call and silently fails on every other one. Production replay state belongs in a Marketplace Redis or Postgres store keyed by principal plus idempotency key. Read Serverless sessions before trusting any module-scope state.
  • Environments split naturally along the same line. Give preview deployments a credential that can only serve queries, or a separate scratch backend for commands; the environment-scoped variable is the enforcement point. A preview URL that can run production commands is an incident waiting for a crawler.

The platform will retry your commands whether you designed for it or not. A retry you did not design for is a write you did not intend.

Security considerations

  • Every command must require explicit user approval before invocation; queries may run under broader session consent. Derive the requirement from the read/write split, never from the server’s self-reported hints alone. See Consent & user approval.
  • Approval prompts for commands must include the tool name, the resolved arguments, and the target system. See Consent & user approval.
  • Commands must enforce per-principal authorization server-side using the authenticated principal; queries should too, but commands are the higher-blast-radius case. See Authorization & scoping.
  • Validate command arguments more strictly than query arguments: a malformed query returns wrong data; a malformed command writes wrong data. See Input validation.
  • Log queries and commands at the same level but tag them distinctly so audit reviews can prioritize commands. See Monitoring & audit.
  • Pagination and size caps apply to queries; idempotency keys, side-effect-occurred semantics, and rollback on cancellation apply to commands. See Session handling.

Example implementation

  • examples/query-command-server (in the repository) - the paired implementation of this page: read-only list_items / get_item queries alongside a create_item command, each declaring honest ToolAnnotations (readOnlyHint on the queries, destructiveHint/idempotentHint on the command). The command takes an idempotency key backed by a first-write-wins replay store keyed by principal plus idempotency key (the principal comes from the verified token via ctx.http.authInfo, falling back to an anonymous namespace, never from a tool argument): a retried call returns the original item, not a duplicate, and another principal replaying the same key gets its own item rather than the first caller’s; the tests assert both negatives. The name and idempotency_key bounds (1..64 and 1..128 characters) live in the zod schema and are advertised in the command’s inputSchema as minLength/maxLength. The in-memory store keeps the tests deterministic and offline; the README says what a production deployment moves to Redis or Postgres.
  • examples/secure-tools-server (in the repository) - the server-side controls a command needs once it exists: input validation, default-deny authorization, and output minimization.
  • examples/minimal-server (in the repository) - a single echo tool showing the bare request/response shape; because it reads no external state it is not a true query, only the simplest possible tool.

Trade-offs

ProsCons
Host can give queries low-friction consent and commands strong consent.Two tool variants per operation roughly doubles the surface.
Retries are safe by construction; idempotency lives in the command path.Discipline must be maintained; a hybrid tool added later silently breaks the contract.
Audit logs cleanly separate “what was looked at” from “what was changed”.Naming and annotation conventions must be enforced in review; the protocol does not check them.
The model can plan aggressively because read-only exploration is cheap.Idempotency needs a durable replay store on serverless; module scope is not one.
  • adapter - the natural place to apply this split; every adapter should declare its tools as queries or commands, not both.
  • async-jobs - long-running commands graduate to async jobs with explicit handles; the start tool is a command, the status tool a query.
  • least-privilege - commands typically require broader credential scope than queries; split credentials accordingly when feasible.
  • trust-boundaries - commands cross more trust boundaries than queries and deserve correspondingly stricter validation.
  • facade - the central place to enforce naming and annotation conventions across many backends.

Vercel deployment (Terraform)

There is deliberately no terraform/patterns/query-vs-command/. This is a tool-design pattern: the split lives in tool names, annotations, and handler code, not in provisioned infrastructure, and an infra file would only show generic project config that teaches nothing this page does not. See the “What’s deliberately not here” section of terraform/README.md (in the repository).

Bibliography