MCP
There are two MCP endpoints, and they do different jobs:
| Endpoint | Host | For |
|---|---|---|
Gateway MCP proxy, POST /p/{project_id}/mcp | gw.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 /mcp | mcp.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:
| Version | Era | How it is selected |
|---|---|---|
2025-06-18 | legacy | initialize with that protocolVersion, then the MCP-Protocol-Version header. Also the default when a legacy request omits the header. |
2025-11-25 | legacy | Same, and enables core (experimental) tasks on the proxy. |
2026-07-28 | modern | Every request carries _meta["io.modelcontextprotocol/protocolVersion"] and client capabilities; no initialize. |
Behaviour common to both eras:
- Stateless. No
Mcp-Session-Idis 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.GETandDELETEon the endpoint return405withAllow: POST. - No batching. A JSON-RPC batch is
400 Invalid Request. Notifications get202with no body. - Origin check. A browser
Originother than Axiom's own origins is refused with403(DNS-rebinding protection). - An
initializeasking 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:
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"
}
}
}| Check | Failure |
|---|---|
_meta protocol version is a supported modern version | 400, JSON-RPC -32022 with supported |
_meta["io.modelcontextprotocol/clientCapabilities"] is an object | 400, -32602 |
MCP-Protocol-Version header equals the body version | 400, -32020 header mismatch |
Mcp-Method header equals method | 400, -32020 |
Mcp-Name equals params.name (tools/call, prompts/get) or params.uri (resources/read); non-ASCII values use the =?base64?…?= form | 400, -32020 |
| A legacy-era request sends a modern version in the header | 400, -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:
| Tool | Input | Use |
|---|---|---|
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.
| Situation | Result |
|---|---|
| Allowed within the standing grant | Dispatched upstream at once; the upstream's tool result with _meta["com.sectigoaxiom/action_id"]. |
| Needs approval, client negotiated tasks | A task whose taskId is the action ID (see below). Nothing reached the upstream. |
| Needs approval, client without tasks | A structured pending result (below). |
| Denied, expired, revoked, cancelled | isError: true, structuredContent is the status document, text says it was not executed. |
| Validation or idempotency errors | isError: true, structuredContent is the problem document. |
The structured pending result, for clients without tasks:
{
"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:
{
"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 notasks/resultin this version, so atasks/getfrom the original agent on an approved task is its resume request: the proxy claims, dispatches and returnsstatus: "completed"withresult(the tool result). Latertasks/getcalls return the same stored result; the upstream is called once.tasks/cancelcancels a pending or approved task; anything else is-32602.- A client that did not declare the extension gets
400, JSON-RPC-32021, withrequiredCapabilitieson any task method. - Task status mapping: open states are
working,cancellediscancelled,succeedediscompleted; a denied, expired, revoked or failed action iscompletedwith anisErrorresult.
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/getreturns the task without side effects.tasks/resultresumes 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/cancelcancels and returns the task.- Here
failedis 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)
| Tool | Does |
|---|---|
axiom_capabilities | The capabilities document. Call it before choosing how to wait. |
axiom_submit_action | Submit (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:
| Tool | Scope needed | Does |
|---|---|---|
axiom_list_projects | projects:read | Projects you belong to. |
axiom_list_actions | actions:read | Recent actions in a project, filtered by state or agent_id, with a cursor. |
axiom_action_timeline | actions:read | The action timeline with the "who is responsible next" diagnosis. |
axiom_simulate_policy | projects:read | The policy simulator for a hypothetical request; creates nothing. |
axiom_list_pending_approvals | approvals:read | Approvals waiting on you, each with an approval_url. |
axiom_receipt_verify | none | Offline 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/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.sectigoaxiom.com/.well-known/oauth-protected-resource/mcp"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" }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 methodsnoneandprivate_key_jwt.Dynamic client registration (RFC 7591):
POST /oauth/registerwithclient_nameandredirect_uris(HTTPS, loopback HTTP, or a reverse-DNS private-use scheme such ascom.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"] }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 withcode,stateandiss. Codes last 60 seconds and are single-use.Token:
POST /oauth/tokenwithgrant_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.