Skip to content

MCP Server

WarmHub implements the Model Context Protocol (MCP) over HTTP, exposing core repo read/write operations and discovery tools as typed tools that AI agents can discover and call.

  • MCP version: 2026-07-28, with older clients still served. See Version negotiation for what puts a request on the modern path and how rejections are reported.

  • Transport: JSON-RPC 2.0 over HTTP POST

  • Origin check: a request carrying an Origin header outside the deployment’s allowed origins (loopback origins are always allowed) is refused with HTTP 403 and a JSON-RPC error body, on every method; a request with no Origin header — the normal case for non-browser clients — is unaffected.

  • Supported methods depend on which path the request is on. Legacy: initialize, tools/list, tools/call, ping. Modern (2026-07-28): server/discover, tools/list, tools/callping is a legacy-only mechanism there, and server/discover is not served on the legacy path. initialize always selects the legacy path, even when decorated with modern signals. Anything outside a path’s set answers -32601.

  • Every modern-path result carries resultType: "complete". Legacy results carry no resultType.

  • tools/list is cacheable: results carry ttlMs (1 hour by default) and a cacheScope.

    /mcp returns cacheScope: "public". That URL identifies no org or repo, and the catalog is byte-identical for every caller, so shared caches may reuse it for the full TTL window.

    Tool results carry no cache hints and must not be cached.

initialize always answers with protocol version 2024-11-05, whatever version signals the request carries. Everything below applies only to the other methods.

The initialize result also carries instructions — server-level guidance covering anonymous access and the CLI naming convention, built from the same constants warmhub_capabilities returns. It is a standard optional InitializeResult field. Client support varies: Claude Code honors it, while Claude.ai connectors currently ignore it, so treat it as a bonus and never as the only place a fact is stated.

A non-initialize request goes on the 2026-07-28 path when it carries either signal, and the two are not symmetric:

SignalPuts the request on the modern path when
MCP-Protocol-Version headerits value is anything other than a known older revision (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25). Those stay legacy-tolerated because conformant older clients echo their own negotiated version on every request; any other value is a version declaration and gets the modern verdict, including an unrecognized one.
_meta["io.modelcontextprotocol/protocolVersion"]the key is present at all, whatever its value or type — with one exception: the exact string 2024-11-05 is the revision the legacy path serves, so a request declaring it is served as legacy rather than judged against the modern contract. (A 2026-07-28 header alongside it still wins classification, and the two then disagree — see step 2 below.) Other older revisions in _meta are not served and get -32022.

A request carrying neither signal is served exactly as before.

Once a request is on the modern path, it is validated in this fixed order. The order is the contract: each step is free to reject before a later step could judge the request against rules a future revision may have changed.

  1. Version is a string. If _meta["io.modelcontextprotocol/protocolVersion"] is present it must be a string.
  2. Version agreement. If the header and _meta both carry a version string, they must be equal. A request that contradicts itself gets no verdict about either value.
  3. Version verdict. The declared version — the _meta value when present, otherwise the header — must be 2026-07-28.
  4. Required headers. See Required headers. Skipped for notifications (requests with no id).
  5. _meta envelope. _meta exists, is an object, and carries a string _meta["io.modelcontextprotocol/protocolVersion"] and an object _meta["io.modelcontextprotocol/clientCapabilities"].

Because the verdict runs ahead of steps 4 and 5, a request that names an unrecognized version fails with -32022 whatever else is wrong with it — a missing clientCapabilities or a contradicting Mcp-Method never changes that answer.

Every modern-era request that expects a response must carry:

HeaderRule
MCP-Protocol-Versionpresent
Mcp-Methodpresent and exactly equal to the request’s method
Mcp-Namepresent on tools/call, resources/read, and prompts/get, and exactly equal to params.name (params.uri for resources/read). On any other method there is no target to name, so the header is ignored rather than rejected.

Header names are case-insensitive; values are compared case-sensitively. A value outside the RFC 9110 field-value range is rejected. A Mcp-Name may be sent in the =?base64?<base64 of the UTF-8 name>?= sentinel form — the markers are lowercase and case-sensitive — and is decoded before the comparison.

These rules bind only clients that declare 2026-07-28. A legacy-path request needs no protocol headers at all.

WarmHub does not serve resources/read or prompts/get — both answer -32601 with HTTP 404. They still appear in the table because header validation runs before dispatch: a request for either that omits or mismatches Mcp-Name gets -32020 with HTTP 400 first, and never reaches the 404.

OutcomeJSON-RPC codeHTTP
Header and _meta versions both present as strings and disagree-32020400
Version value is a string but not 2026-07-28 (error data carries supported and requested)-32022400
A required header is missing, mismatched, or malformed-32020400
Malformed _meta (missing, not an object, missing or non-string version, missing or non-object clientCapabilities)-32602400
Method not served on the modern path, ping and unknown methods included-32601404
Unknown tool name on tools/call-32602200
Tool execution error-32000 (in an isError result)200
Notification (no id) accepted— (no body)202

Legacy-path responses are unaffected: every legacy JSON-RPC error still rides out on HTTP 200, and a legacy notification also answers 202. A non-string _meta version value classifies the request as modern and fails at step 1 — it does not fall back to the legacy path.

Modern-era responses carry server identity, but its location depends on whether the response succeeded: success responses put it at result._meta["io.modelcontextprotocol/serverInfo"], and error responses at error.data._meta["io.modelcontextprotocol/serverInfo"]. JSON-RPC forbids extra envelope members, so error.data is the only legal slot on a rejection. Legacy-era responses carry no server identity at all.

The following minimal example shows a tools/list request on the 2026-07-28 path. Both _meta members — io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities — are required. Sending the MCP-Protocol-Version: 2026-07-28 header without these _meta fields fails the envelope step and returns -32602 with HTTP 400:

{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

That request must also carry the headers MCP-Protocol-Version: 2026-07-28 and Mcp-Method: tools/list — see Required headers. The _meta members are required whether or not the header is present, and vice versa. A successful response will include result._meta["io.modelcontextprotocol/serverInfo"] with server identity.

server/discover is the modern path’s handshake-free orientation call. It takes no arguments beyond the _meta members every modern request carries, needs no Bearer token, and answers with:

FieldMeaning
supportedVersionsEvery protocol revision this server serves, newest first: ["2026-07-28", "2024-11-05"]. This is the union across both paths — initialize still counter-offers only 2024-11-05.
capabilitiesThe same capability object initialize returns ({ "tools": { "listChanged": false } }).
instructionsThe same server-level guidance the initialize result carries.
ttlMs / cacheScopeCache hints matching tools/list: 1 hour by default, "public" — the result names no org, repo, or caller.

It is served on the modern path only. A legacy-path server/discover answers -32601, and so does a modern-path ping. initialize always selects the legacy path, even when decorated with modern signals.

MethodPathDescription
POST/mcpGlobal MCP endpoint
GET/mcpReturns 405 Method Not Allowed with RFC 9728 discovery challenge. A disallowed Origin is refused with 403 first — see Authentication
GET/.well-known/oauth-protected-resourceOAuth 2.0 Protected Resource Metadata (RFC 9728)
GET/.well-known/oauth-protected-resource/mcpSame protected-resource metadata, at the MCP-suffixed discovery path some clients probe
GET/mcp/.well-known/oauth-protected-resourceSame protected-resource metadata, at the MCP-prefixed discovery path
GET/.well-known/oauth-authorization-serverOAuth 2.0 Authorization Server Metadata (RFC 8414)

POST /mcp serves the full MCP tool catalog. Tools that act on a repository take orgName and repoName as arguments; org-level tools such as warmhub_org_list, warmhub_org_get, warmhub_repo_list, and warmhub_repo_create take whatever locator they need the same way. Organization membership can be read via warmhub_org_member_list (takes orgName and an optional pending filter). Changing membership — adding, removing, or assigning roles to members — is not on the MCP surface, nor are other org and repo admin operations such as renaming, editing org or repo descriptions, archiving, unarchiving, changing repo visibility, or deleting a repo; use the CLI or SDK for those operations. Meta tools — warmhub_doctor and warmhub_capabilities — require no locator at all.

Call warmhub_capabilities for the catalog, then warmhub_repo_describe with a repo locator for that repo’s schema and write contract.

POST /mcp takes an optional Bearer token. Every tool carries a reviewed auth tier, and the token decides which tiers you can call:

  • Anonymous tier — served without credentials, over public orgs and repos.
  • Builder tier — requires a valid Bearer token.

initialize, server/discover, tools/list, and the OAuth discovery endpoints answer pre-auth on the path that serves them — initialize on the legacy path, server/discover on the modern one, tools/list on both. A token that cannot be verified is treated as anonymous rather than rejected; a revoked or disabled account still returns its own error.

Calling a builder-tier tool without a token returns HTTP 401 with an RFC 9728 challenge, before the tool runs:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.warmhub.ai/.well-known/oauth-protected-resource"
Content-Type: application/json
{ "error": { "code": "UNAUTHENTICATED", "message": "Authentication required for \"warmhub_org_list\"" } }

GET requests to /mcp return HTTP 405 Method Not Allowed and include the same RFC 9728 WWW-Authenticate challenge header:

HTTP/1.1 405 Method Not Allowed
Allow: POST
WWW-Authenticate: Bearer resource_metadata="https://mcp.warmhub.ai/.well-known/oauth-protected-resource"

This means a GET probe against /mcp can bootstrap OAuth discovery in the same way as an unauthenticated POST — standards-compliant clients can follow the challenge from either response to discover the protected-resource metadata endpoint and acquire a token.

The Origin check runs ahead of the method check, so this is the response for a request with no Origin header — the normal case for a non-browser client — or one whose Origin is allowlisted or loopback. A GET carrying a disallowed Origin is refused with HTTP 403 and a JSON-RPC -32600 error instead, and that response carries neither Allow nor WWW-Authenticate:

HTTP/1.1 403 Forbidden
Content-Type: application/json
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Origin not allowed"}}

The resource field in the protected-resource metadata document and the resource_metadata URL in every WWW-Authenticate challenge are host-specific: they reflect the MCP hostname the client actually reached, when that host is owned by the deployment. A client reaching mcp.example.com sees resource_metadata pointing at https://mcp.example.com/.well-known/oauth-protected-resource; a client reaching api.example.com sees the equivalent URL for that host. MCP/OAuth clients that follow the challenge to the protected-resource document will therefore always receive metadata scoped to the host they contacted, not a single canonical API origin.

Standards-compliant MCP/OAuth clients use the challenge to discover the protected-resource metadata endpoint and bootstrap auth. The tool catalog is the same for every caller: builder-tier tools are listed to anonymous callers too, so a client can discover them and upgrade when one is first called. Changing membership — adding, removing, or assigning roles to members — is not on the MCP surface; use the CLI or SDK for those operations.

Add a WarmHub entry to the mcpServers section of your MCP client’s configuration. The examples below use the standard format supported by Claude Desktop, Cursor, and other MCP-compatible clients. The type field names the transport; some clients infer it from the URL scheme, but stating it is always safe. On claude mcp add the equivalent flag is spelled --transport, and the entry it writes uses type.

The URL alone gets you the anonymous tier; builder-tier tools need a Bearer token. Standards-compliant MCP/OAuth clients follow the RFC 9728 challenge returned on the first builder-tier call to discover the OAuth flow and acquire a token automatically. Clients without that capability must inject the token themselves; mint a personal access token (see HTTP Authentication) and configure your client to send Authorization: Bearer <token> on every MCP POST. Many clients accept a headers map in the mcpServers entry for this purpose, e.g.:

{
"mcpServers": {
"warmhub": {
"type": "http",
"url": "https://mcp.warmhub.ai/mcp",
"headers": {
"Authorization": "Bearer ${WH_TOKEN}"
}
}
}
}

The MCP endpoint authenticates the bearer token in the Authorization header; the example above sources that value from WH_TOKEN. See Environment Variables for the variables WarmHub reads and their precedence.

Consult your MCP client’s documentation for the exact field name. See Authentication above for the challenge format and discovery contract.

The example above uses the production MCP URL. For self-hosted deployments, replace mcp.warmhub.ai with your deployment URL.

Point new configurations at mcp.warmhub.ai. An existing one on api.warmhub.ai/mcp needs no change — the API hostname serves /mcp as a compatibility path, and both hostnames reach the same server — so switch it the next time you edit the file.

If your agent calls tools by remembered name, check them against the tables below. There are no aliases, so an unrecognized name answers Unknown MCP tool immediately. Calling warmhub_capabilities at the start of a session avoids the problem entirely: it returns the current catalog.

Use thisInstead of
warmhub_subscription_logwarmhub_action_livefeed
warmhub_subscription_attemptswarmhub_action_attempts
warmhub_subscription_runswarmhub_action_runs
warmhub_notificationswarmhub_action_notifications
warmhub_subscription_* with scope: "org"warmhub_org_subscription_* (7 tools)
warmhub_thing_get, which takes wrefs at every cardinalitywarmhub_thing_get_many
warmhub_commit_submitwarmhub_commit_apply
warmhub_repo_content_get / warmhub_repo_content_set, with kindwarmhub_repo_readme_get / _set, warmhub_repo_agents_get / _set, warmhub_repo_llms_txt_get

Some tools have no replacement on the MCP surface. Reach for these instead:

Instead ofDo this
warmhub_wref_resolveCall warmhub_thing_get with includeRetracted: true — a superset of the same identity fields
warmhub_repo_content_generate, warmhub_repo_readme_generate, warmhub_repo_agents_generateDraft with wh repo content prompt, then persist with warmhub_repo_content_set
warmhub_org_set_display_name, warmhub_repo_set_display_namewh org rename or wh repo rename, or the web app
warmhub_org_set_description, warmhub_repo_set_descriptionwh org update or wh repo update, or the web app

warmhub_use and warmhub_channel have no equivalent. Both were local client conveniences rather than repo operations, so there is nothing to call in their place.

Tool responses include both human-readable and structured content:

{
"resultType": "complete",
"content": [{ "type": "text", "text": "..." }],
"structuredContent": { ... },
"isError": false
}
  • resultType — always "complete". Present on every modern-path result, not just tool calls; legacy-path results omit it.
  • content — tool result as text for the model-readable channel. Typically JSON.
  • structuredContent — typed object for programmatic access. Includes an auth field on every response (see below).
  • isErrortrue if the tool call failed

Every tool call still returns structuredContent for programmatic access. WarmHub keeps reviewed output schemas internally for contract tests and schema-clarity audits covering successful structured results (including auth) and structured tool-error payloads (including error and auth), but does not repeat those schemas in tools/list. Each listed tool includes its input schema and an annotations block with explicit safety hints:

  • readOnlyHinttrue only for tools that do not change state.
  • openWorldHinttrue when a write can affect public WarmHub data, another user, or a third-party webhook; always false for read-only tools.
  • destructiveHinttrue when a write can delete, overwrite, retract, revoke access, or cause a comparable irreversible effect.
  • category — one of org, repo, shape, thing-read, collection, commit, subscription, action, meta.
  • Clients can use category to group or filter tools in discovery UIs.
  • warmhub_capabilities uses the same enum, but groups tools under each category rather than repeating the value per tool. Each summary includes name, title, description, tier, readOnly, openWorld, destructive, and annotationJustification; the category is implicit in the enclosing group. The annotationJustification field is a short rationale for why the safety hints were assigned to that tool.
  • Every listed tool also carries a top-level title — a short display name for tool pickers and permission prompts. Tool names follow warmhub_<noun>_<verb>wh <noun> <verb>; warmhub_capabilities returns the two aliases, the eight exceptions, and the two tools with no CLI command under naming. See the CLI name convention.

Tool responses include structuredContent.auth with the caller’s authentication status. An anonymous call reports authenticated: false and carries a hint:

{ "structuredContent": { "items": [...], "auth": { "authenticated": false, "hint": "..." } } }

The tier itself is not in the descriptions. Read it from warmhub_capabilities, where every tool summary carries tier: 'anonymous' | 'builder'; the anonymous-access guidance is stated once there as a usage pattern. Calling a builder-tier tool without a token returns the 401 challenge, which is the signal to authenticate. A miss on a private or missing target from an anonymous caller is reported in auth.hint as possibly private; the MCP handler preserves the backend error code (NOT_FOUND, WREF_UNRESOLVABLE, FORBIDDEN, or UNAUTHENTICATED) rather than rewriting all such failures to a single code, so branch on the specific errorCode in the structured error.

MCP tool failures arrive in three distinct ways. Inspect the tool response, not just the client-level try/catch.

Tool-result errors. The tool dispatched, but its execution failed. The response carries isError: true and a structured error in content / structuredContent. This covers VALIDATION_ERROR, NOT_FOUND, FORBIDDEN, RATE_LIMITED (per-user / per-org tier limits hit during the call), QUERY_TOO_EXPENSIVE (typed where predicate exceeded the query budget), and warmhub_commit_submit ambiguous append failures. Each code is described in the Tool-error code reference below.

warmhub_commit_submit per-operation failures. Per-op failures are not tool errors. The response is a normal success payload in which one or more operations[] rows carry status: "error" plus a non-empty errors[] array. There is no partial flag and no statusCounts aggregate to branch on — scan the rows for status: "error" yourself. Treat partial results as a routine outcome to inspect. See the tool reference for the full row contract, including the legacy receipt version that spells this status differently.

Transport-level errors. Some failures happen before the tool dispatches and surface as HTTP errors instead of tool results:

  • POST with no Authorization header, calling an anonymous-tier tool → HTTP 200 over public data, with structuredContent.auth.authenticated: false.
  • POST with no Authorization header, calling a builder-tier tool → HTTP 401 with the WWW-Authenticate challenge, returned before the tool runs; use it to authenticate.
  • POST with no Authorization header, once the anonymous rate-limit budget is exhausted → HTTP 429 (see the anonymous rate-limit bullet below) rather than the 401 challenge. If you are seeing 429 on headerless requests, authenticate rather than only backing off.
  • POST with a malformed or unverified bearer → treated as anonymous: builder-tier calls get the 401 challenge, and the request draws down the anonymous rate-limit budget. A 429 after presenting a token means the token wasn’t accepted — re-authenticate with a valid one rather than only backing off.
  • POST with a valid bearer the gate never got to verify → treated as anonymous, and therefore able to draw a transport-level 429 even though the token itself is good. Verifying an unrecognized bearer is admitted through its own per-IP budget, separate from the anonymous request budget; when that budget is exhausted the token is not checked at all and the request falls back to the anonymous path. A token verified recently enough to still be cached skips this budget entirely, so this affects bursts of first-time tokens from one IP. Back off and retry rather than re-issuing the token. The 429 body is identical to the anonymous case described below.
  • Request body exceeds the MCP size limit → HTTP 413 with a JSON-RPC error body. The error uses code -32013. This check runs before tool dispatch, so no tool result is produced. Reduce the request payload and retry.
  • Anonymous request over the rate limit → HTTP 429 carrying a JSON-RPC transport error (code -32029) whose retry metadata lives at error.data.warmhub: { code, message, status, hint, retryAfter, details }. A request whose bearer was never verified reaches this same path — see the verification-budget bullet above. The details object has the shape { reason: "rate_limit_reset", retryAfterSeconds, resetAt }. Read retryAfter (seconds) to back off; read details.resetAt for the precise reset timestamp. This transport-level 429 shape — with retry metadata at error.data.warmhub.retryAfter — differs from the tool-result RATE_LIMITED error; see the Tool-error code reference below for the tool-result fields.

Once a bearer is cryptographically verified, MCP traffic bypasses the anonymous rate limit entirely — the pre-authentication gate is skipped rather than passed through, so a verified token never draws a transport-level 429. Per-user and per-org tier limits hit during a call surface instead as the RATE_LIMITED tool-result error described below. The one way a token-bearing request still sees a transport-level 429 is the verification-budget fallback above, where the token was never verified and the request was handled as anonymous.

Parsing transport-level 429 responses. Both origins — an anonymous request, and a bearer the gate never got to verify — produce the same response shape: an HTTP 429 with a JSON-RPC error body where retry metadata lives at error.data.warmhub. Do not look for backendCode or isError on a 429 — those fields belong to tool-result errors and are absent here. Branch first on the HTTP status code: if it is 429, read error.data.warmhub.retryAfter and error.data.warmhub.details.resetAt; if it is 200 with isError: true in the body, parse the tool-result error fields instead.

  • Validation failures — include the validation message and, for warmhub_commit_submit and warmhub_commit_validate, enriched hint data when the failing path is operations (see Validation Hint below).

  • NOT_FOUND — includes the error details. When the caller is authenticated, missing targets surface as NOT_FOUND regardless of which token was presented. When the caller is anonymous, a miss on a hidden or missing target preserves the backend error code (NOT_FOUND, WREF_UNRESOLVABLE, FORBIDDEN, or UNAUTHENTICATED) and appends a hint that the resource may be private.

  • FORBIDDEN — surfaced as errorCode: "FORBIDDEN" in the structured error when the presented token is valid but lacks the scope required for the requested resource. Distinct from NOT_FOUND; use it to decide whether retrying with a more privileged token is sensible.

  • RATE_LIMITED — surfaced as errorCode: "RATE_LIMITED" in structuredContent.error.data when a per-user or per-org write limit is hit during a tool call. The same data object also carries a details field with the shape { reason: "rate_limit_reset", retryAfterSeconds, resetAt }. See Rate Limiting for tier limits and retry guidance. Treat as retryable with a bounded backoff, prefer the errorCode over string-matching the message, and read details.resetAt for the precise reset time. Note that this tool-result shape (errorCode plus details under structuredContent.error.data) differs from the transport-level 429 shape (JSON-RPC error code -32029, with error.data.warmhub carrying code, message, status, hint, retryAfter, and details) — on a transport-level 429 no tool ran, so there is no tool result to read. Parse them separately: check for isError: true in a tool result to identify a RATE_LIMITED tool error; check for an HTTP 429 response with a JSON-RPC error body to identify a transport-level rate limit.

  • QUERY_TOO_EXPENSIVE — surfaced as errorCode: "QUERY_TOO_EXPENSIVE" in structuredContent.error.data when a typed where predicate is rejected as too broad or complex to execute within the query budget. This is distinct from RATE_LIMITED: it signals that the query itself needs to be narrowed, not that a throughput limit was hit. Recovery depends on which tool raised the error:

    • Paged where queries (warmhub_thing_head, warmhub_thing_query, warmhub_thing_about): tighten the where predicate to reduce the result set, or retry with a shallower page size.
    • Count-mode requests (warmhub_thing_head or warmhub_thing_query called with count: true): count mode has no page-size argument, so reducing page size is not applicable. Narrow the query with a more selective equality predicate instead. Backing off without changing the query will not resolve this error in either case.
  • Ambiguous append failures (warmhub_commit_submit only) — a transport-ambiguous failure (network reset, 5xx, timeout) interrupted the stream append. The failed append may have landed server-side.

    The tool returns isError: true. Do not resubmit blind — resolve the outcome first:

    1. Stop sending writes.
    2. Read eventRequestId from structuredContent.error.data.
    3. Call warmhub_commit_receipt_get with that eventRequestId. The receipt is the authoritative answer to whether the append landed.
    4. Retry only what the receipt shows did not land, reusing the original submissionId.

    structuredContent.error.data carries the fields you need for that lookup:

    • tool"warmhub_commit_submit"
    • errorCode — the error code; parse this field
    • backendCode — alias for errorCode; parse errorCode instead
    • submissionId — the submission identity to reuse on a retry
    • eventRequestId — pass this to warmhub_commit_receipt_get
    • lookupTool"warmhub_commit_receipt_get", the tool to call

    Ambiguous errors never carry continuation. Note: streamId, when present, does not provide recovery or idempotency, and skipExisting: true is an idempotency convenience for fixed-name adds — a later noop or conflict is not a receipt for the ambiguous attempt, so prefer the receipt lookup over inferring outcome from repository state. See SDK Streaming Write Failures for the parallel SDK contract.

Tool-result errors (the isError: true responses above) also include structuredContent.auth with the caller’s authentication status, so agents can decide whether to suggest authenticating and retrying. Transport-level failures do not carry this field.

When warmhub_commit_submit or warmhub_commit_validate rejects a malformed operations entry, the tool result’s structuredContent.error.data lists the full set of valid operation variant signatures so agents can self-correct on the next turn — the message is the original validation message.

{
"resultType": "complete",
"isError": true,
"content": [{ "type": "text", "text": "<validation message>" }],
"structuredContent": {
"error": {
"code": -32602,
"message": "<validation message>",
"data": {
"tool": "warmhub_commit_submit",
"errorCode": "VALIDATION_ERROR",
"backendCode": "VALIDATION_ERROR",
"expected": "one of the operation variants",
"operations": [
"ADD shape: { operation:'add', kind:'shape', name, data }",
"ADD thing: { operation:'add', kind:'thing', name, data }",
"ADD assertion: { operation:'add', kind:'assertion', name, about, data, affirmedTargets? }",
"ADD collection: { operation:'add', kind:'collection', name, type, members }",
"REVISE shape: { operation:'revise', kind:'shape', name, data }",
"REVISE thing: { operation:'revise', kind:'thing', name, data }",
"REVISE assertion: { operation:'revise', kind:'assertion', name, data, affirmedTargets? }",
"REVISE collection: { operation:'revise', kind:'collection', name, type, members }",
"RETRACT: { operation:'retract', name, reason?, kind?, expectedVersion? }",
"REAFFIRM assertion: { operation:'reaffirm', name, add?, remove?, expectedVersion? }",
"RENAME: { operation:'rename', name, newName, kind? }"
]
}
},
"auth": { "authenticated": true }
}
}

The expected and operations fields are attached for warmhub_commit_submit and warmhub_commit_validate validation errors when the failing path is operations; validation errors from other tools, or from warmhub_commit_validate on a path other than operations, continue to return data with just tool and errorCode. backendCode may also appear as a compatibility alias for errorCode; parse errorCode. The same variant list is also returned by warmhub_repo_describe under commitContract.operationVariants.