Skip to main content

MCP Integration

The MCP integration turns your Key0 seller into an MCP server. Agents connect over Streamable HTTP, discover your plans, and pay for access tokens using the x402 protocol — all without leaving the tool-call flow.

Enable MCP

Set mcp: true in your SellerConfig. The Express router automatically mounts all MCP routes alongside the existing A2A and x402 HTTP endpoints.
import express from "express";
import { key0Router } from "@key0ai/key0/express";

const app = express();

const config: SellerConfig = {
  mcp: true,
  agentName: "My Service",
  agentDescription: "A payment-gated API",
  agentUrl: "https://my-service.example.com",
  walletAddress: "0x...",
  network: "mainnet",
  plans: [
    { planId: "basic", unitAmount: "1.00", description: "Basic plan" },
  ],
  fetchResourceCredentials: async ({ planId }) => {
    return { apiKey: "sk-..." };
  },
};

app.use(key0Router({ config, adapter, store, seenTxStore }));
app.listen(3000);

Routes

The integration mounts four routes on the Express router:
RouteMethodPurpose
/.well-known/mcp.jsonGETMCP discovery document (name, description, transport URL)
/mcpPOSTStreamable HTTP transport — handles all tool calls
/mcpGETReturns 405 (SSE not supported in stateless mode)
/mcpDELETEReturns 405 (session management not supported in stateless mode)

Tools

The MCP server exposes two tools:
ToolGated?Purpose
discover_plansFreeBrowse the plan catalog: plan IDs, prices (USDC), wallet address, chain ID
request_accessx402Purchase an access token for a plan

discover_plans

Takes no arguments. Returns the full plan catalog as JSON:
{
  "agent": "My Service",
  "description": "A payment-gated API",
  "network": "mainnet",
  "chainId": 8453,
  "walletAddress": "0x...",
  "asset": "USDC",
  "plans": [
    {
      "planId": "basic",
      "unitAmount": "1.00",
      "description": "Basic plan"
    }
  ]
}

request_access

Takes planId (required) and resourceId (optional, defaults to "default"). When called without payment, it returns a PaymentRequired response. When called with a valid x402 payment, it settles on-chain and returns an access grant.

Payment Flow

There are two paths to complete a payment, depending on the client’s capabilities.
This is the path used by current MCP clients like Claude Desktop, Claude Code, and Cursor. The agent uses a companion payment tool (such as Coinbase’s payments-mcp) to settle via HTTP.Step-by-step:
  1. Agent calls discover_plans to get the catalog
  2. Agent calls request_access({ planId: "basic" }) with no payment attached
  3. Server returns isError: true with a PaymentRequired response containing x402PaymentUrl and paymentInstructions
  4. Agent calls make_http_request_with_x402 (from payments-mcp) to POST to the x402PaymentUrl
  5. payments-mcp signs an EIP-3009 authorization off-chain and sends it as a PAYMENT-SIGNATURE header
  6. The /x402/access endpoint settles the payment on-chain and returns an AccessGrant with a JWT
PaymentRequired response:
{
  "isError": true,
  "structuredContent": {
    "error": "Payment required to access this resource",
    "resource": {
      "url": "https://my-service.example.com/x402/access",
      "description": "Basic plan",
      "mimeType": "application/json"
    },
    "accepts": [
      {
        "scheme": "exact",
        "network": "base-mainnet",
        "maxAmountRequired": "1000000",
        "resource": "https://my-service.example.com/x402/access",
        "description": "Basic plan",
        "mimeType": "application/json",
        "payTo": "0x...",
        "extra": {
          "planId": "basic",
          "resourceId": "default"
        }
      }
    ]
  },
  "content": [
    {
      "type": "text",
      "text": "{ ... x402PaymentUrl, paymentInstructions ... }"
    }
  ]
}
The content[0].text field contains x402PaymentUrl and human-readable paymentInstructions so that LLM agents can parse the next step even without structured content support.

Error Handling

ErrorResponse
Plan not foundisError: true with Key0Error JSON (TIER_NOT_FOUND)
Malformed _meta["x402/payment"]isError: true with Key0Error JSON (Zod validation details)
Payment failed / settlement errorisError: true with structuredContent containing error message and accepts[] array for retry
Already redeemed txCached AccessGrant returned (idempotent — no double-charge)
Already-redeemed transactions return the original AccessGrant. The requestId is derived deterministically from the payment signature, so retries with the same payment always resolve to the same challenge.

Connecting from Claude Code

Add your MCP server to .mcp.json in your project root:
{
  "mcpServers": {
    "my-service": {
      "type": "http",
      "url": "https://my-service.example.com/mcp"
    }
  }
}
Claude Code connects over Streamable HTTP and discovers the discover_plans and request_access tools automatically.

Testing with curl

List available tools:
curl -X POST https://my-service.example.com/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Call discover_plans:
curl -X POST https://my-service.example.com/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "discover_plans",
      "arguments": {}
    }
  }'
You must include both application/json and text/event-stream in the Accept header. Without both values, the MCP SDK returns a 406 Not Acceptable response.

Why Stateless?

The MCP transport creates a fresh McpServer and StreamableHTTPServerTransport for every request. This is intentional:
  • Tools are pure request/response. There is no streaming, subscriptions, or server-initiated messages.
  • All state lives in external stores. ChallengeStore and SeenTxStore (Redis or Postgres) hold challenge state and double-spend records.
  • Horizontal scaling. No sticky sessions, no in-memory state, no affinity requirements. Put it behind any load balancer.
  • No memory leaks. Each server instance is created, used, and closed within a single request lifecycle.