Skip to content

MCP Integration

This guide explains how to integrate the Nevermined Payments Python SDK with MCP (Model Context Protocol) servers.

Overview

MCP (Model Context Protocol) enables AI applications to interact with external tools, resources, and prompts. The Nevermined SDK provides built-in MCP integration to:

  • Protect tools, resources, and prompts with paywalls
  • Handle OAuth 2.1 authentication
  • Manage credit consumption per operation

MCP Integration API

Access the MCP integration through payments.mcp:

from payments_py import Payments, PaymentOptions

payments = Payments.get_instance(
    PaymentOptions(nvm_api_key="nvm:your-key", environment="sandbox")
)

# MCP integration is available as:
mcp = payments.mcp

The simplified API handles server setup automatically:

Register a Tool

async def hello_handler(args, context=None):
    """Handle the hello tool request."""
    name = args.get("name", "World")
    return {
        "content": [{"type": "text", "text": f"Hello, {name}!"}]
    }

# Register the tool
payments.mcp.register_tool(
    name="hello_world",
    config={
        "description": "Says hello to someone",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Name to greet"}
            }
        }
    },
    handler=hello_handler,
    options={"credits": 1}  # Cost: 1 credit per call
)

Register a Resource

async def config_handler(uri, variables, context=None):
    """Handle the configuration resource request."""
    return {
        "contents": [{
            "uri": str(uri),
            "mimeType": "application/json",
            "text": '{"version": "1.0.0", "feature_flags": {"beta": true}}'
        }]
    }

payments.mcp.register_resource(
    uri="data://config",
    config={
        "name": "Configuration",
        "description": "Application configuration",
        "mimeType": "application/json"
    },
    handler=config_handler,
    options={"credits": 2}  # Cost: 2 credits per access
)

Register a Prompt

async def greeting_handler(args, context=None):
    """Handle the greeting prompt request."""
    style = args.get("style", "formal")
    return {
        "messages": [{
            "role": "user",
            "content": {
                "type": "text",
                "text": f"Please greet me in a {style} way."
            }
        }]
    }

payments.mcp.register_prompt(
    name="greeting",
    config={
        "name": "Greeting",
        "description": "Generates a greeting"
    },
    handler=greeting_handler,
    options={"credits": 1}
)

Start the Server

import asyncio

async def main():
    # Register handlers first
    payments.mcp.register_tool("hello", {...}, hello_handler)

    # Start the MCP server
    result = await payments.mcp.start({
        "port": 5001,
        "planId": "your-plan-id",  # required
        "serverName": "my-mcp-server",
        # "agentId": "your-agent-id",  # optional (informational only)
        "version": "1.0.0",
        "description": "My MCP server with Nevermined payments"
    })

    print(f"Server running at: {result['info']['baseUrl']}")
    print(f"Tools: {result['info']['tools']}")

    # Server runs until stopped
    # To stop: await payments.mcp.stop()

asyncio.run(main())

Advanced API

For more control, use the advanced API:

Configure and Protect Handlers

# Configure shared options (planId required; agentId optional/informational)
payments.mcp.configure({
    "planId": "your-plan-id",
    "serverName": "my-mcp-server"
})

# Wrap a handler with paywall
async def my_handler(args):
    return {"result": "processed"}

protected_handler = payments.mcp.with_paywall(
    handler=my_handler,
    options={
        "kind": "tool",
        "name": "my_tool",
        "credits": 1
    }
)

Attach to Existing Server

from mcp.server import MCPServer

# Create your own MCP server
server = MCPServer()

# Attach payments integration
registrar = payments.mcp.attach(server)

# Register protected handlers
registrar.register_tool(
    name="hello",
    config={"description": "Hello tool"},
    handler=hello_handler,
    options={"credits": 1}
)

registrar.register_resource(
    name="config",
    template="data://{path}",
    config={"name": "Config"},
    handler=config_handler,
    options={"credits": 2}
)

Complete Example

import asyncio
from payments_py import Payments, PaymentOptions

# Initialize payments
payments = Payments.get_instance(
    PaymentOptions(nvm_api_key="nvm:your-key", environment="sandbox")
)

# Define handlers
async def analyze_code(args, context=None):
    """Analyze code for issues."""
    code = args.get("code", "")
    language = args.get("language", "python")

    # Your analysis logic here
    issues = analyze(code, language)

    return {
        "content": [{
            "type": "text",
            "text": f"Found {len(issues)} issues in {language} code."
        }]
    }

async def get_docs(uri, variables, context=None):
    """Return documentation."""
    topic = variables.get("topic", "general")

    return {
        "contents": [{
            "uri": str(uri),
            "mimeType": "text/markdown",
            "text": f"# Documentation for {topic}\n\nContent here..."
        }]
    }

async def code_review_prompt(args, context=None):
    """Generate code review prompt."""
    return {
        "messages": [{
            "role": "user",
            "content": {
                "type": "text",
                "text": "Please review the following code for best practices..."
            }
        }]
    }

# Register handlers
payments.mcp.register_tool(
    "analyze_code",
    {
        "description": "Analyzes code for potential issues",
        "inputSchema": {
            "type": "object",
            "properties": {
                "code": {"type": "string"},
                "language": {"type": "string", "default": "python"}
            },
            "required": ["code"]
        }
    },
    analyze_code,
    {"credits": 5}  # 5 credits per analysis
)

payments.mcp.register_resource(
    "docs://{topic}",
    {
        "name": "Documentation",
        "description": "Technical documentation",
        "mimeType": "text/markdown"
    },
    get_docs,
    {"credits": 1}
)

payments.mcp.register_prompt(
    "code_review",
    {
        "name": "Code Review",
        "description": "Generates a code review prompt"
    },
    code_review_prompt,
    {"credits": 2}
)

# Start server
async def main():
    result = await payments.mcp.start({
        "port": 5001,
        "planId": "plan-123",
        "serverName": "code-assistant-mcp",
        "version": "1.0.0"
    })

    print(f"MCP Server running at {result['info']['baseUrl']}")
    print(f"Tools: {result['info']['tools']}")
    print(f"Resources: {result['info']['resources']}")
    print(f"Prompts: {result['info']['prompts']}")

    # Keep running
    try:
        while True:
            await asyncio.sleep(1)
    except KeyboardInterrupt:
        await payments.mcp.stop()

asyncio.run(main())

Server Configuration

Option Type Required Description
port int Yes Server port
planId str Yes Nevermined plan ID the server charges against
serverName str Yes Human-readable name
agentId str No Nevermined agent DID (informational; the facilitator resolves access from the plan + token)
baseUrl str No Base URL (default: localhost)
version str No Server version
description str No Server description
oauthUrls dict No Overrides for the OAuth URLs the discovery documents publish (authorizationUri, tokenUri, jwksUri, userinfoUri, issuer). Set authorizationUri with ?network=sandbox\|live when your custom backend host cannot be classified

Handler Options

Option Type Description
credits int or callable Credits to consume per call
planId str Per-handler plan ID override. A server-level planId (set via configure/start) is required; set this only to charge a different plan for this handler.
maxAmount int Max credits to verify during authentication (default: 1)
onRedeemError str On post-execution settlement failure: "ignore" (default) returns the in-band payment error; "propagate" raises a JSON-RPC error. Tool content is always suppressed either way (a paid result is never delivered without settlement).

In-band x402 signaling (_meta)

The MCP transport follows the x402 v2 MCP transport specification: payments are signalled in band through the MCP tool-call machinery, not via HTTP status codes or headers.

Request — payment payload. The client sends the x402 PaymentPayload as plain JSON in the tool-call request params under _meta["x402/payment"]. This is the payment channel — separate from session auth: the MCP session is an OAuth-protected resource, so the client must also send an Authorization: Bearer <access_token> header when opening the transport to establish the session (initialize returns 401 without it).

{
  "method": "tools/call",
  "params": {
    "name": "premium_tool",
    "arguments": { "...": "..." },
    "_meta": { "x402/payment": { "x402Version": 2, "accepted": { "...": "..." }, "payload": { "...": "..." } } }
  }
}

For backward compatibility the server still falls back to reading the access token from the Authorization: Bearer header when _meta["x402/payment"] is absent, but that path is deprecated under the x402 v2 MCP transport.

Response — settlement receipt. On a successful paid call the SDK injects the settlement receipt under the spec key _meta["x402/payment-response"], alongside Nevermined-specific observability under the namespaced _meta["nevermined/credits"] key (not part of the x402 spec):

{
    "content": [{"type": "text", "text": "result"}],
    "_meta": {
        "x402/payment-response": {
            "success": True,
            "transaction": "0xabc...",
            "network": "eip155:84532",
            "payer": "0x123..."
        },
        "nevermined/credits": {
            "success": True,
            "txHash": "0xabc...",
            "billingModel": "credits",
            "creditsRedeemed": "5",
            "planId": "plan-123",
            "subscriberAddress": "0x123..."
        }
    }
}

Payment required. When the caller has not paid (or cannot be authorized), the tool returns an error tool result carrying the PaymentRequired object in both structuredContent (the object) and content[0].text (its JSON-stringified copy):

{
    "isError": True,
    "structuredContent": {
        "x402Version": 2,
        "error": "payment required",
        "resource": { "url": "mcp://my-server/tools/premium_tool", "...": "..." },
        "accepts": [ { "scheme": "nvm:erc4337", "planId": "plan-123", "...": "..." } ]
    },
    "content": [{"type": "text", "text": "{\"x402Version\": 2, ...}"}]
}

Settlement failure after execution. If settlement fails after the tool has already executed, the server returns the same payment-required error result and suppresses the tool's content — a paid result is never delivered without payment landing.

Note on onRedeemError. Under the in-band MCP transport, content is always suppressed when post-execution settlement fails — even with the default onRedeemError: "ignore" — because the x402 v2 spec forbids delivering a paid result without settlement. onRedeemError no longer controls whether content is returned; it now only affects the kind of error surfaced: "ignore" yields the in-band payment-required error result, while "propagate" raises a JSON-RPC misconfiguration error instead.

nevermined/credits field Type Description
success bool Whether credit redemption succeeded
txHash str or None Blockchain transaction hash (only on success)
billingModel str credits or pay-as-you-go. Omitted when the settle carried no discriminator — read it before creditsRedeemed
creditsRedeemed str Number of credits burned ("0" on failure — and always "0" on a pay-as-you-go plan, including a successful charge)
orderTx str Order / per-request charge reference. Omitted when absent; on fiat pay-as-you-go this is what proves the charge
planId str Plan used for the operation
subscriberAddress str Subscriber's wallet address
errorReason str Error message (only on failure)

On a pay-as-you-go plan a paid call reports creditsRedeemed: "0". Those plans hold no credit balance — each call is charged directly — so both credit fields read "0" even though the buyer was charged. billingModel is what tells the two apart, and it is carried on both _meta keys: x402/payment-response passes the whole settle receipt through, and nevermined/credits carries billingModel and orderTx alongside its condensed fields. See Was the buyer charged?.

Endpoints

The MCP server exposes:

  • /.well-known/oauth-authorization-server - OAuth 2.1 discovery. Its authorization_endpoint names the API tier your server runs against — https://nevermined.app/oauth/authorize?network=sandbox for sandbox, ?network=live for live — because one Nevermined web app serves the consent screens for both tiers and boots on whatever tier the user's browser last chose. Clients keep that query string when they add their own parameters (RFC 6749 §3.1); if you configure a client by hand, copy the endpoint from the discovery document, query string included. A custom environment derives the tier from its backend host (api.sandbox.… / api.live.…, branded subdomains included — the first api label a tier follows, so an org slugged api classifies too); behind a host the SDK cannot classify — localhost, a proxy — no tier is stamped, so state it yourself with the oauthUrls option: oauthUrls={"authorizationUri": "<webapp>/oauth/authorize?network=<tier>"} on payments.mcp.start() (or create_oauth_router()). Its issuer (and the OpenID configuration's) is the canonical API origin of that tier — https://api.sandbox.nevermined.app for sandbox, https://api.live.nevermined.app for live — the same identifier the Nevermined API's own discovery document publishes and the value the consent page returns as the RFC 9207 iss parameter on every authorization response (which clients compare with the discovered issuer by simple string comparison). A named environment keeps its canonical origin whatever tokenUri proxy sits in front of it; a custom environment follows the backend it publishes, reduced to the canonical api.<tier>.nevermined.… origin for a branded or MCP subdomain and to the host's own origin otherwise — and when that host is a proxy or gateway in front of a Nevermined API rather than the API itself, set oauthUrls.issuer (and oauthUrls.tokenUri) to that API's real origin too, otherwise the document publishes your proxy's origin as issuer, which will not match the iss the authorization server returns (the SDK warns once when it derives an issuer from a non-Nevermined host). For sandbox and live the documents also advertise authorization_response_iss_parameter_supported: true (RFC 9207) — the consent page returns iss on every response, so clients can reject a mix-up; it is omitted — which RFC 9207 defines as false — for custom (even on a Nevermined backend: the line is the environment name) and for an overridden authorizationUri, where the SDK cannot vouch for the consent page. Omitting is a trade, not a non-event: RFC 9207 §2.4 has a client reject an iss-less response from a server that advertises support, and discard a response that carries iss from one that does not — so if your override still points at the Nevermined web app, which returns iss on every response, a strict client may discard its responses. (A client that follows authorization_servers never sees the omission: the Nevermined API's own document advertises the flag unconditionally.) And an oauthUrls.issuer override on sandbox or live that differs from the canonical origin can never match the iss the consent page returns — the SDK publishes it and warns once. (Earlier releases published the web app's origin here, identical for both tiers; a client that keyed its authorization-server state on that identifier re-discovers, and may re-consent, once.)
  • /.well-known/oauth-protected-resource - Resource metadata
  • /.well-known/oauth-protected-resource/mcp - MCP-specific protected resource metadata
  • /register - Client registration
  • /mcp - MCP protocol endpoint (POST/GET/DELETE)
  • /health - Health check

OAuth 401 vs. payment-required

OAuth and x402 payment-required live at different layers, so they never collide:

Layer Signal Meaning
HTTP transport 401 Unauthorized + WWW-Authenticate: Bearer (OAuth 2.1) The request is not authenticated; follow OAuth discovery to obtain a token.
MCP tool call tool result with isError: true + PaymentRequired in structuredContent The caller is authenticated but has not paid for this tool.

Because payment-required is signalled in band as a tool result (not as an HTTP 402), there is no clash with the OAuth 401 challenge and no need to special-case the /mcp status code. The /mcp endpoint keeps the standard OAuth 401 behavior; payment is negotiated entirely through the tool-call _meta / tool-result mechanism described above.

x402 discovery is therefore implicit on the first tool call: a client that has not paid receives the PaymentRequired object (with its accepts array) in the error tool result and pays on the next call. There is no /.well-known/x402-payment endpoint.

Next Steps