Skip to content

MCP ​

There are two MCP endpoints, and they do different jobs:

EndpointHostFor
Gateway MCP proxy, POST /p/{project_id}/mcpgw.sectigoaxiom.com (local: /gw/p/{project_id}/mcp)An agent calling a provider's real MCP tools. Protected tool calls become Axiom actions.
Axiom MCP server, POST /mcpmcp.sectigoaxiom.com (local: /mcp)Agents using the action contract as tools, and people using read-only developer tools.

Both are implemented in apps/api/src/mcp/ over the same action services as the REST API, so the same idempotency, approval and single-claim rules apply.

Transport and protocol versions ​

Both endpoints speak MCP over Streamable HTTP and negotiate these versions:

VersionEraHow it is selected
2025-06-18legacyinitialize with that protocolVersion, then the MCP-Protocol-Version header. Also the default when a legacy request omits the header.
2025-11-25legacySame, and enables core (experimental) tasks on the proxy.
2026-07-28modernEvery request carries _meta["io.modelcontextprotocol/protocolVersion"] and client capabilities; no initialize.

Behaviour common to both eras:

  • Stateless. No Mcp-Session-Id is issued and nothing about a client is held between requests. A reconnect, a new session or a different server instance sees the same business state.
  • JSON only. Responses are application/json; SSE is never used. GET and DELETE on the endpoint return 405 with Allow: POST.
  • No batching. A JSON-RPC batch is 400 Invalid Request. Notifications get 202 with no body.
  • Origin check. A browser Origin other than Axiom's own origins is refused with 403 (DNS-rebinding protection).
  • An initialize asking for an unknown version is answered with the newest legacy version (2025-11-25).

2026-07-28 requests ​

A modern request mirrors parts of the body in headers, and the server checks them:

http
POST /p/prj_01kd0m3a2s1d0f9g8h7j6k5l4z/mcp HTTP/1.1
Host: gw.sectigoaxiom.com
Authorization: DPoP axa_…
DPoP: eyJ…
Content-Type: application/json
Accept: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: issue_refund

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "issue_refund",
    "arguments": { "invoice_id": "417", "amount": "25000", "currency": "USD", "beneficiary": "acct_acme_ops" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } },
      "com.sectigoaxiom/operation_key": "refund-417-7Qm2xK9pLr"
    }
  }
}
CheckFailure
_meta protocol version is a supported modern version400, JSON-RPC -32022 with supported
_meta["io.modelcontextprotocol/clientCapabilities"] is an object400, -32602
MCP-Protocol-Version header equals the body version400, -32020 header mismatch
Mcp-Method header equals method400, -32020
Mcp-Name equals params.name (tools/call, prompts/get) or params.uri (resources/read); non-ASCII values use the =?base64?…?= form400, -32020
A legacy-era request sends a modern version in the header400, -32602 (missing _meta version)

server/discover (modern only) returns supportedVersions, capabilities and instructions. Modern results carry resultType and _meta["io.modelcontextprotocol/serverInfo"]; unknown methods are HTTP 404, capability failures 400.

Gateway MCP proxy ​

The proxy fronts each proxy-enforced service's upstream_mcp_url. Authentication is the agent's DPoP-bound token with a proof for the proxy URL; people and API keys are refused. Without a token the answer is 401 with WWW-Authenticate: DPoP algs="ES256 EdDSA".

Tool listing ​

tools/list fetches each upstream's tools and returns only tools mapped to an operation (mcp_tool on the operation), plus two tools of the proxy's own:

ToolInputUse
axiom_action_status{ action_id }Status of a protected call that returned pending_approval.
axiom_action_resume{ action_id, arguments? }Execute an approved call as the agent that made it; returns the upstream tool result. arguments defaults to the recorded ones; a changed material argument is refused.

Mapped tools keep the upstream's description and input schema; their outputSchema is dropped (a protected call can return a pending status instead) and _meta["com.sectigoaxiom/operation"] names the operation. Under 2025-11-25 each mapped tool also advertises execution: { taskSupport: "optional" }. Calling an unmapped tool is JSON-RPC -32602 Unknown tool and never reaches the upstream.

What a tool call returns ​

tools/call on a mapped tool becomes an Axiom action. The resource ID comes from the argument named by resource_id_arg and the material params from params_args ({ field: argument }). The operation key is _meta["com.sectigoaxiom/operation_key"] if you send one, otherwise derived from the exact arguments for writes. A derived key means a retried call after a lost response converges on one action, but two deliberate, identical write calls are also merged into one; send an explicit key in _meta when you intend separate operations.

SituationResult
Allowed within the standing grantDispatched upstream at once; the upstream's tool result with _meta["com.sectigoaxiom/action_id"].
Needs approval, client negotiated tasksA task whose taskId is the action ID (see below). Nothing reached the upstream.
Needs approval, client without tasksA structured pending result (below).
Denied, expired, revoked, cancelledisError: true, structuredContent is the status document, text says it was not executed.
Validation or idempotency errorsisError: true, structuredContent is the problem document.

The structured pending result, for clients without tasks:

json
{
  "isError": false,
  "content": [
    {
      "type": "text",
      "text": "Pending approval (finance_approval_required): Billing agent requests USD 25,000 against invoice 417 for customer Acme Corp (sample customer). Waiting on finance_approver in axiom. Nothing has been executed. Save action_id act_01kd0m7s2v8x9yz3abcdefghjk; check it with axiom_action_status (every 15s at most) and, once approved, call axiom_action_resume with that action_id."
    }
  ],
  "structuredContent": {
    "action_id": "act_01kd0m7s2v8x9yz3abcdefghjk",
    "state": "pending_approval",
    "reason_code": "finance_approval_required",
    "resume_actor": "original_agent",
    "retry_after_seconds": 15,
    "…": "the full status document"
  },
  "_meta": { "com.sectigoaxiom/action_id": "act_01kd0m7s2v8x9yz3abcdefghjk" }
}

After approval, axiom_action_resume with the action ID executes the call once and returns the upstream's result. A later call returns the stored result; a call while still pending is isError: true with structuredContent.code: "invalid_state". This works from a brand-new connection: the action, not the transport session, holds the state.

Tasks (2026-07-28 extension) ​

A modern client that declares extensions["io.modelcontextprotocol/tasks"] in its client capabilities gets, for a call that is still open:

json
{
  "resultType": "task",
  "taskId": "act_01kd0m7s2v8x9yz3abcdefghjk",
  "status": "working",
  "statusMessage": "Pending approval (finance_approval_required): Billing agent requests …. Waiting on finance_approver in axiom. Nothing has been executed.",
  "createdAt": "2026-11-14T18:00:00.000Z",
  "lastUpdatedAt": "2026-11-14T18:00:00.000Z",
  "ttlMs": null,
  "pollIntervalMs": 15000
}
  • tasks/get { taskId } returns the task. There is no tasks/result in this version, so a tasks/get from the original agent on an approved task is its resume request: the proxy claims, dispatches and returns status: "completed" with result (the tool result). Later tasks/get calls return the same stored result; the upstream is called once.
  • tasks/cancel cancels a pending or approved task; anything else is -32602.
  • A client that did not declare the extension gets 400, JSON-RPC -32021, with requiredCapabilities on any task method.
  • Task status mapping: open states are working, cancelled is cancelled, succeeded is completed; a denied, expired, revoked or failed action is completed with an isError result.

Core tasks (2025-11-25) ​

A 2025-11-25 client sends params.task on tools/call and always gets a CreateTaskResult ({ task: { taskId, status, statusMessage, createdAt, lastUpdatedAt, ttl: null, pollInterval } }), even when the call completed at once. Then:

  • tasks/get returns the task without side effects.
  • tasks/result resumes an approved task as the original agent and returns the tool result with _meta["io.modelcontextprotocol/related-task"]. While still pending it returns the structured pending status rather than holding the request open.
  • tasks/cancel cancels and returns the task.
  • Here failed is used for denied, expired, revoked or failed actions.

Task methods are -32601 Method not found under 2025-06-18.

Outcomes from MCP upstreams ​

The proxy sends the upstream tools/call with the same Idempotency-Key, Axiom-Action-Id, Axiom-Root-Action-Id and signed Axiom-Authorization headers as the HTTP proxy. The upstream confirms its committed outcome with a signed axiom.outcome.v1 attestation, either in the axiom-outcome response header or in the result's _meta["com.sectigoaxiom/outcome"]. Only an attestation gives target_confirmed or target_failed. Without one, a successful tool result is succeeded with gateway_observed_success, and a result with isError or a JSON-RPC error is a failed (not committed) call with gateway_observed_failure; a transport failure is outcome_unknown and is reconciled by operation key. The sample provider (apps/sample-billing/src/mcp.ts) shows the provider side.

Axiom MCP server ​

POST /mcp on mcp.sectigoaxiom.com. The tools depend on who authenticates. Browser sessions are not accepted (no ambient cookies), and project API keys are refused.

Tools for agents (DPoP-bound agent token) ​

ToolDoes
axiom_capabilitiesThe capabilities document. Call it before choosing how to wait.
axiom_submit_actionSubmit (same input as POST /v1/actions, including execute and payload). Returns the status.
axiom_action_status{ action_id }: authoritative status.
axiom_action_resume{ action_id, operation_key, resource, params, payload? }: resume with the approved parameters.
axiom_action_cancel{ action_id }: cancel a pending or approved action.
axiom_receipt_get{ action_id, reveal? }: the signed receipt bundle, optionally redacted.
axiom_receipt_verify{ bundle }: verify a bundle offline with its embedded keys (also available to people).

Pending actions are returned as structured results (the status document in structuredContent and a text summary saying what to do next), not as tasks: status and resume are explicit tools. Tool failures are isError: true results carrying the problem document. Actions are recorded with via: mcp.

Embedded-SDK services and cutover

For a service whose enforcement mode is sdk, axiom_action_resume returns an isError result with invalid_state ("repeat the original request to the provider with the same operation key"), as the REST resume does: the provider executes. Resume such actions by repeating the original request to the provider; see embedded SDK. After a cutover to a customer-hosted gateway, this tool and the Gateway MCP proxy also refuse to claim (invalid_state, "owned by gateway …"); see fleet, gateways and cutover.

Tools for people (OAuth authorization-code token) ​

All read-only:

ToolScope neededDoes
axiom_list_projectsprojects:readProjects you belong to.
axiom_list_actionsactions:readRecent actions in a project, filtered by state or agent_id, with a cursor.
axiom_action_timelineactions:readThe action timeline with the "who is responsible next" diagnosis.
axiom_simulate_policyprojects:readThe policy simulator for a hypothetical request; creates nothing.
axiom_list_pending_approvalsapprovals:readApprovals waiting on you, each with an approval_url.
axiom_receipt_verifynoneOffline receipt verification.

No MCP tool can approve or deny. A decision must be made on the authenticated approval page (approval_url) or in the organisation's approval system, so that the person who decides is the person who saw the exact request. Calling an agent tool with a person's token (or the reverse) is -32602 with an explanation.

OAuth discovery for MCP clients ​

An MCP client acting for a person discovers everything from a 401:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.sectigoaxiom.com/.well-known/oauth-protected-resource/mcp"
  1. Protected resource metadata (RFC 9728) at that URL:

    json
    {
      "resource": "https://mcp.sectigoaxiom.com/mcp",
      "authorization_servers": ["https://api.sectigoaxiom.com"],
      "scopes_supported": ["actions:submit", "actions:read", "projects:read", "projects:write", "approvals:read"],
      "bearer_methods_supported": ["header"],
      "dpop_bound_access_tokens_required": false,
      "resource_documentation": "https://docs.sectigoaxiom.com/reference/api"
    }
  2. Authorization server metadata (RFC 8414) at https://api.sectigoaxiom.com/.well-known/oauth-authorization-server: authorization_endpoint/oauth/authorize, token_endpoint /oauth/token, registration_endpoint /oauth/register, revocation_endpoint /oauth/revoke, code_challenge_methods_supported: ["S256"], token endpoint auth methods none and private_key_jwt.

  3. Dynamic client registration (RFC 7591): POST /oauth/register with client_name and redirect_uris (HTTPS, loopback HTTP, or a reverse-DNS private-use scheme such as com.example.app:/cb). Clients are public (token_endpoint_auth_method: none).

    json
    { "client_name": "My MCP client", "redirect_uris": ["http://127.0.0.1:33418/callback"] }
  4. Authorization code with PKCE (S256 only): GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&scope=actions:read%20projects:read%20approvals:read&state=…&resource=https://mcp.sectigoaxiom.com/mcp. The browser is sent to the console consent page (/oauth/consent), where the signed-in person sees the client name, redirect host and scopes. Approving redirects back with code, state and iss. Codes last 60 seconds and are single-use.

  5. Token: POST /oauth/token with grant_type=authorization_code, code, client_id, redirect_uri, code_verifier. The access token lasts one hour; the refresh token 30 days and is rotated on every use. Send a DPoP proof to get a DPoP-bound token (token_type: DPoP); otherwise it is a Bearer token.

Default scopes when none are requested are actions:read projects:read. A token issued for a different resource is refused by the MCP server with 401 invalid_token.

Agents do not use this flow; they use their DPoP-bound client_credentials token (authentication) against either endpoint.

Developer preview. Contract version 2026-11-14.