The MCP binding

This page is for engineers who expose merchant-provider/v1 as MCP tools instead of REST endpoints. The contract is the same in both bindings: the operations, the objects, the signature, idempotency, finality and refusals in Connecting your own billing system apply word for word. Only the wire differs, and this page describes it: the session, the tool calls, the results, the schemas you publish, how to test it, and a complete server you can start from.

What it is, and what it is not

  • You run an MCP server; Chargeless is the client. Your server offers the fixed tools sr_describe, sr_refund and the others below over Streamable HTTP. Chargeless's platform connects to it to read facts and to execute the refunds and cancellations its policy approved.
  • No language model calls your tools. MCP is only the wire format. The calls come from Chargeless's own code, after a decision the policy made and recorded, exactly as REST calls do. The assistant that talks to your customers never sees your tools, their names or their answers.
  • Only the profile is called. Chargeless calls the tools in the profile (mcp/merchant-provider/v1.json in the contracts) and ignores every other tool your server lists. It never sends a prompt, a resource read or a sampling request.
  • Chargeless does not offer an MCP server of its own. There is no MCP endpoint at Chargeless for an AI assistant to connect to. To call Chargeless from your systems, use the API; to hear what happened, use webhooks.

Choose MCP when your billing system already speaks MCP, or when an MCP SDK is the shortest way for you to serve typed operations. Choose REST otherwise: it is plain HTTP and easier to debug with curl. Both pass the same checks and go live the same way.

The tools

Tool REST twin Arguments Result (structuredContent) Required
sr_describe GET /v1/describe {} describe yes
sr_find_customers POST /v1/customers/search the query: email, phone, customer_ref, transaction_ref, order_ref, card_last4 (one or more) {"candidates": [{"customer_ref"}]} yes
sr_get_customer GET /v1/customers/{customer_ref} {customer_ref} customer yes
sr_list_transactions GET /v1/customers/{customer_ref}/transactions {customer_ref} {"transactions": […], "subscriptions": […]} yes
sr_get_transaction GET /v1/transactions/{transaction_ref} {transaction_ref} transaction yes
sr_get_subscription GET /v1/subscriptions/{subscription_ref} {subscription_ref} subscription with cancel_subscription
sr_refund POST /v1/refunds {idempotency_key, request}; a dry run has no idempotency_key refund result yes
sr_cancel_subscription POST /v1/subscriptions/{subscription_ref}/cancel {idempotency_key, subscription_ref, request}; a dry run has no idempotency_key cancellation result with cancel_subscription
sr_get_operation GET /v1/operations/{idempotency_key} {idempotency_key} operation yes
sr_list_payment_attempts POST /v1/payment-attempts/list the query: {updated_since, cursor?, limit?} a page of attempts with payment_attempts

The objects are described in section 2 of the contract, and the contracts hold a valid example of each (schemas/merchant-provider/examples/v1/). The arguments of a real refund:

json
{
  "idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
  "request": {
    "transaction_ref": "ch_1001",
    "amount": {"amount_minor": 2000, "currency": "USD"},
    "reason": "service_issue",
    "dry_run": false
  }
}

and of a cancellation at the end of the period:

json
{
  "idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:cancel_subscription:0199e2c4-8888-7c8d-9e0f-a1b2c3d4e5f6",
  "subscription_ref": "sub_88",
  "request": {"at_period_end": true, "reason": "forgot_to_cancel", "dry_run": false}
}

Transport and sessions

  • Streamable HTTP. Your MCP endpoint is the base URL you configure, such as https://billing.example.com/mcp, with no /v1/… paths. The network rules of section 3 apply: HTTPS on 443, a public certificate, no redirects.
  • One session per attempt. Each attempt opens its own session, makes one tools/call and closes it, with a DELETE when your server set an Mcp-Session-Id. Nothing is kept between attempts. A retry of the same call is a new session with the same arguments.
  • Discovery. A server on the 2026-07-28 revision is discovered with server/discover; an older server with initialize and notifications/initialized. MCP SDKs do this for you.
  • JSON or a stream. Answer a POST with application/json or with text/event-stream; both work.
  • What the client never does. It declares no capabilities and never answers server requests (sampling, elicitation, roots, ping). It opens no GET stream, never resumes a stream, never sends a cancellation and never sends Mcp-Param-* headers. Do not require any of these.

One attempt on the wire, every request signed:

text
POST /mcp   {"jsonrpc":"2.0","id":1,"method":"initialize","params":{…}}        → 200, Mcp-Session-Id: s-1
POST /mcp   {"jsonrpc":"2.0","method":"notifications/initialized"}              → 202
POST /mcp   {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{…}}         → 200, the result
DELETE /mcp (Mcp-Session-Id: s-1)                                               → 2xx

The tool call itself:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "sr_get_transaction",
    "arguments": {"transaction_ref": "ch_1001"},
    "_meta": {
      "dev.supportroute/request_id": "01a1b7d7-f079-7ba8-97a7-851c6d4c1303",
      "dev.supportroute/correlation_id": "0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6",
      "dev.supportroute/channel": "ticket"
    }
  }
}

_meta carries the correlation ids that REST sends as headers (section 3, Correlation): dev.supportroute/request_id (new for every attempt), dev.supportroute/correlation_id, dev.supportroute/channel and, when a helpdesk ticket is the origin, dev.supportroute/external_ticket. Log them.

Signing

  • Every HTTP request of the session is signed as in section 3, with an empty key field: the handshake, the tool call and the DELETE. The signed string is {timestamp}\nPOST\n{host}\n/mcp\n\n{body} for a POST to /mcp.
  • The idempotency key travels in the tool's idempotency_key argument, inside the signed body. There is no Idempotency-Key header.
  • Verify the tool call itself, not only the handshake. The contract check signs the handshake correctly and breaks only the tool call's signature, and expects a 401.
  • Verify before your MCP library reads the body. Read the raw bytes, verify, then hand the same bytes to the library (the example below does this).
  • The bearer token and the edge headers, when you set them, go on every request.

Results

A successful tool call returns the object in structuredContent:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "structuredContent": {
      "idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
      "status": "succeeded",
      "transaction_ref": "ch_1001",
      "amount": {"amount_minor": 2000, "currency": "USD"},
      "refund_ref": "re_77",
      "processed_at": "2026-10-15T09:12:44Z"
    },
    "content": [{"type": "text", "text": "{\"idempotency_key\":…}"}],
    "isError": false
  }
}
  • Chargeless reads structuredContent only, from the bytes you sent, and validates it exactly as a REST body (section 2, Sizes and time limits): an amount written 2000.0 or a repeated key fails here too. A text copy in content is what MCP clients usually expect; Chargeless ignores it.

  • An error is a result with isError: true whose structuredContent is the error envelope:

    json
    {"structuredContent": {"error": {"code": "amount_exceeds_refundable", "message": "Only 34.99 USD is left to refund.", "retryable": false}}, "isError": true}

    Its code stands for a REST status, and from there it is handled exactly as section 5 says:

    Code Status
    invalid_request 400
    credentials_rejected 401
    not_found 404
    idempotency_conflict, in_progress 409
    a refusal code (section 6) or an x_ code 422
    rate_limited 429
    internal_error 500
    unavailable 503
  • A JSON-RPC error, or a stream that ends without the response, counts as a 5xx: retried with the same key, then an unknown outcome confirmed by sr_get_operation.

  • A result with a resultType other than complete is not a final answer and fails the call.

  • A refused signature is answered at the HTTP layer, before MCP: 401 with the REST error envelope.

Publishing the tool schemas

Every profile tool must declare an inputSchema and an outputSchema. Publish each one as a $ref to the contract's $id:

json
{
  "name": "sr_refund",
  "inputSchema":  {"type": "object", "$ref": "https://schemas.supportroute.dev/merchant-provider/v1/tool-inputs.json#/$defs/refund"},
  "outputSchema": {"type": "object", "$ref": "https://schemas.supportroute.dev/merchant-provider/v1/refund-result.json"}
}
Tool inputSchema $ref outputSchema $ref
sr_describe tool-inputs.json#/$defs/describe describe.json
sr_find_customers customer-query.json customer-candidates.json
sr_get_customer tool-inputs.json#/$defs/get_customer customer.json
sr_list_transactions tool-inputs.json#/$defs/list_transactions transaction-list.json
sr_get_transaction tool-inputs.json#/$defs/get_transaction transaction.json
sr_get_subscription (with cancel_subscription) tool-inputs.json#/$defs/get_subscription subscription.json
sr_refund tool-inputs.json#/$defs/refund refund-result.json
sr_cancel_subscription (with cancel_subscription) tool-inputs.json#/$defs/cancel_subscription cancel-result.json
sr_get_operation tool-inputs.json#/$defs/get_operation operation.json
sr_list_payment_attempts (with payment_attempts) payment-attempt-query.json payment-attempt-list.json

Each $ref is relative to https://schemas.supportroute.dev/merchant-provider/v1/. List a capability's tools only when sr_describe declares that capability. Two approaches fail:

  • A schema generated from your own types fails. Generators usually emit $defs or other local structure, which the check refuses.
  • A verbatim copy of our schema breaks later. A copy under our $id passes only while it is identical to our document, and it breaks the next time we edit that file, even if we only change a description.

The $ids are identifiers. Chargeless resolves them from its own copies and never fetches them. If your MCP library validates your own results against the declared schema, load the contract's schema files into its validator under their $ids, or turn that validation off for these tools.

The profile check decides compatibility by examples, in three ways:

  • the inputSchema must accept every argument object Chargeless sends that tool;
  • the outputSchema must accept the contract's valid examples;
  • a narrower closed object fails.

The check also enforces these limits:

  • a schema is at most 64 KiB, 32 levels, 4,096 nodes and 8 $refs;
  • every $ref points to one of the contract's $ids;
  • no $defs, definitions, $id, id, $anchor, $dynamicAnchor, $recursiveAnchor, $dynamicRef or $recursiveRef;
  • all schemas together are at most 20,000 nodes;
  • tools/list fits in 5 pages;
  • the whole profile check takes at most 15 s.

A complete server

The server below implements the MCP binding with the official MCP Go SDK: one customer, one charge and one subscription in memory stand in for your billing system. It verifies every request's signature over the raw bytes, keeps idempotency and finality per key, answers dry runs without changing anything, refuses over-refunds, and passes every line of providercheck -sandbox -transport mcp (checked with providercheck/2 and go-sdk v1.8.0). Replace the in-memory store with calls to your billing system; keep the rules.

sh
mkdir provider && cd provider
go mod init example.com/provider
go get github.com/modelcontextprotocol/[email protected]
# save main.go below, then
SIGNING_SECRET=srmps_… go run .     # serves http://127.0.0.1:8080/mcp
go
// A minimal merchant-provider/v1 server, MCP binding, with the official MCP Go SDK.
// One customer, one charge and one subscription kept in memory stand in for your billing system.
//
//	go mod init example.com/provider && go get github.com/modelcontextprotocol/[email protected]
//	SIGNING_SECRET=srmps_… go run .
package main

import (
    "bytes"
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

const schemaBase = "https://schemas.supportroute.dev/merchant-provider/v1/"

type Money struct {
    AmountMinor int64  `json:"amount_minor"`
    Currency    string `json:"currency"`
}

type Refund struct {
    At             string  `json:"at"`
    Amount         Money   `json:"amount"`
    IdempotencyKey *string `json:"idempotency_key"`
    ReversedAt     *string `json:"reversed_at"`
}

type Transaction struct {
    TransactionRef  string   `json:"transaction_ref"`
    CustomerRef     string   `json:"customer_ref"`
    OrderRef        *string  `json:"order_ref"`
    SubscriptionRef *string  `json:"subscription_ref"`
    State           string   `json:"state"`
    Amount          Money    `json:"amount"`
    CreatedAt       string   `json:"created_at"`
    Description     *string  `json:"description"`
    ProductType     *string  `json:"product_type"`
    ChargeType      string   `json:"charge_type"`
    Processor       string   `json:"processor"`
    RefundRoute     string   `json:"refund_route"`
    CardLast4       *string  `json:"card_last4"`
    CardBrand       *string  `json:"card_brand"`
    Disputed        bool     `json:"disputed"`
    Refunds         []Refund `json:"refunds"`
    Settlement      *Money   `json:"settlement"`
}

type Subscription struct {
    SubscriptionRef   string  `json:"subscription_ref"`
    CustomerRef       string  `json:"customer_ref"`
    Phase             string  `json:"phase"`
    CancelAtPeriodEnd bool    `json:"cancel_at_period_end"`
    CurrentPeriodEnd  *string `json:"current_period_end"`
    TrialEnd          *string `json:"trial_end"`
    BillingInterval   struct {
        Unit  string `json:"unit"`
        Count int    `json:"count"`
    } `json:"billing_interval"`
    PlanRef      *string `json:"plan_ref"`
    RenewalCount int     `json:"renewal_count"`
    CreatedAt    string  `json:"created_at"`
    LastCharge   any     `json:"last_charge"`
}

// Operation is what get_operation answers for a key.
type Operation struct {
    IdempotencyKey string            `json:"idempotency_key"`
    Status         string            `json:"status"`
    Operation      string            `json:"operation,omitempty"`
    TargetRef      string            `json:"target_ref,omitempty"`
    Result         any               `json:"result,omitempty"`
    Refusal        map[string]string `json:"refusal,omitempty"`
    UpdatedAt      string            `json:"updated_at,omitempty"`
}

type store struct {
    mu   sync.Mutex
    txs  map[string]*Transaction
    subs map[string]*Subscription
    ops  map[string]stored // every key Chargeless sent, kept at least 30 days
}

type stored struct {
    fingerprint string
    op          Operation
}

func ptr[T any](v T) *T { return &v }
func now() string       { return time.Now().UTC().Format(time.RFC3339) }
func days(n int) string { return time.Now().UTC().AddDate(0, 0, n).Format(time.RFC3339) }

func seed() *store {
    tx := &Transaction{TransactionRef: "ch_1001", CustomerRef: "cus_1001", SubscriptionRef: ptr("sub_1001"),
        State: "succeeded", Amount: Money{4999, "USD"}, CreatedAt: days(-3), Description: ptr("Pro plan, monthly"),
        ChargeType: "renewal", Processor: "in_house", RefundRoute: "provider", CardLast4: ptr("4242"),
        CardBrand: ptr("visa"), Refunds: []Refund{}}
    sub := &Subscription{SubscriptionRef: "sub_1001", CustomerRef: "cus_1001", Phase: "active",
        CurrentPeriodEnd: ptr(days(27)), PlanRef: ptr("pro_monthly"), RenewalCount: 3, CreatedAt: days(-93),
        LastCharge: map[string]any{"transaction_ref": "ch_1001", "at": days(-3), "amount": Money{4999, "USD"}}}
    sub.BillingInterval.Unit, sub.BillingInterval.Count = "month", 1
    return &store{txs: map[string]*Transaction{tx.TransactionRef: tx},
        subs: map[string]*Subscription{sub.SubscriptionRef: sub}, ops: map[string]stored{}}
}

// An answer: 200 with the object, or an error envelope whose code maps to a REST status.
type answer struct {
    ok   bool
    body any
}

func ok(body any) answer { return answer{true, body} }
func fail(code, message string, retryable bool) answer {
    return answer{false, map[string]any{"error": map[string]any{"code": code, "message": message, "retryable": retryable}}}
}
func invalid(message string) answer { return fail("invalid_request", message, false) }
func notFound() answer              { return fail("not_found", "no such object", false) }

func (s *store) customer(ref string) (map[string]any, bool) {
    if ref != "cus_1001" {
        return nil, false
    }
    prior := []Refund{}
    for _, t := range s.txs {
        if t.CustomerRef == ref {
            prior = append(prior, t.Refunds...)
        }
    }
    return map[string]any{"customer_ref": ref, "emails": []string{"[email protected]"}, "phones": []string{},
        "account_status": "active", "account_created_at": "2025-03-02T10:15:00Z",
        "prior_refunds": prior, "prior_chargebacks": []any{}}, true
}

func refundable(t *Transaction) int64 {
    left := t.Amount.AmountMinor
    for _, r := range t.Refunds {
        if r.ReversedAt == nil {
            left -= r.Amount.AmountMinor
        }
    }
    return left
}

// keyRule, replay and refuse hold the idempotency terms (section 4) and finality (section 5).
func keyRule(key string, dryRun bool) (answer, bool) {
    switch {
    case dryRun && key != "":
        return invalid("a dry run carries no idempotency_key"), false
    case !dryRun && key == "":
        return invalid("idempotency_key is required"), false
    }
    return answer{}, true
}

func (s *store) replay(key, fingerprint string) answer {
    first := s.ops[key]
    switch {
    case first.fingerprint != fingerprint:
        return fail("idempotency_conflict", "this key was used with other parameters", false)
    case first.op.Status == "failed":
        return fail(first.op.Refusal["code"], first.op.Refusal["message"], false)
    }
    return ok(first.op.Result)
}

func (s *store) refuse(key string, dryRun bool, fingerprint, kind, target, code, message string) answer {
    if !dryRun {
        s.ops[key] = stored{fingerprint, Operation{IdempotencyKey: key, Status: "failed", Operation: kind,
            TargetRef: target, Refusal: map[string]string{"code": code, "message": message}, UpdatedAt: now()}}
    }
    return fail(code, message, false)
}

type args struct {
    CustomerRef     string          `json:"customer_ref"`
    TransactionRef  string          `json:"transaction_ref"`
    SubscriptionRef string          `json:"subscription_ref"`
    IdempotencyKey  string          `json:"idempotency_key"`
    Request         json.RawMessage `json:"request"`
}

func (s *store) call(tool string, raw json.RawMessage) answer {
    var a args
    if len(raw) > 0 && json.Unmarshal(raw, &a) != nil {
        return invalid("the arguments do not decode")
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    switch tool {
    case "sr_describe":
        return ok(map[string]any{"contract_version": "1.0", "currencies": []string{"USD"},
            "refunds_to_original_payment_method": true,
            "capabilities": map[string]bool{"partial_refunds": true, "cancel_subscription": true,
                "cancel_at_period_end": true, "outcome_callbacks": false}})
    case "sr_find_customers":
        return s.findCustomers(raw)
    case "sr_get_customer":
        if c, found := s.customer(a.CustomerRef); found {
            return ok(c)
        }
        return notFound()
    case "sr_list_transactions":
        if _, found := s.customer(a.CustomerRef); !found {
            return notFound()
        }
        txs, subs := []*Transaction{}, []*Subscription{}
        for _, t := range s.txs {
            if t.CustomerRef == a.CustomerRef {
                txs = append(txs, t)
            }
        }
        for _, sub := range s.subs {
            if sub.CustomerRef == a.CustomerRef {
                subs = append(subs, sub)
            }
        }
        return ok(map[string]any{"transactions": txs, "subscriptions": subs})
    case "sr_get_transaction":
        if t, found := s.txs[a.TransactionRef]; found {
            return ok(t)
        }
        return notFound()
    case "sr_get_subscription":
        if sub, found := s.subs[a.SubscriptionRef]; found {
            return ok(sub)
        }
        return notFound()
    case "sr_refund":
        return s.refund(a.IdempotencyKey, a.Request)
    case "sr_cancel_subscription":
        return s.cancel(a.IdempotencyKey, a.SubscriptionRef, a.Request)
    case "sr_get_operation":
        if st, found := s.ops[a.IdempotencyKey]; found {
            return ok(st.op)
        }
        return ok(Operation{IdempotencyKey: a.IdempotencyKey, Status: "unknown"})
    }
    return invalid("not a profile tool")
}

func (s *store) findCustomers(raw json.RawMessage) answer {
    var q map[string]string
    if json.Unmarshal(raw, &q) != nil || len(q) == 0 {
        return invalid("give one or more known identifiers")
    }
    t := s.txs["ch_1001"]
    for k, v := range q {
        match := false
        switch k {
        case "email":
            match = strings.EqualFold(v, "[email protected]")
        case "customer_ref":
            match = v == "cus_1001"
        case "transaction_ref":
            match = v == t.TransactionRef
        case "card_last4":
            match = t.CardLast4 != nil && v == *t.CardLast4
        case "phone", "order_ref":
        default:
            return invalid("unknown identifier " + k)
        }
        if !match {
            return ok(map[string]any{"candidates": []any{}})
        }
    }
    return ok(map[string]any{"candidates": []any{map[string]string{"customer_ref": "cus_1001"}}})
}

func (s *store) refund(key string, raw json.RawMessage) answer {
    var r struct {
        TransactionRef string `json:"transaction_ref"`
        Amount         Money  `json:"amount"`
        Reason         string `json:"reason"`
        DryRun         bool   `json:"dry_run"`
    }
    if json.Unmarshal(raw, &r) != nil || r.TransactionRef == "" || r.Amount.AmountMinor < 1 || r.Reason == "" {
        return invalid("the request does not match refund-request.json")
    }
    if a, good := keyRule(key, r.DryRun); !good {
        return a
    }
    fp := fmt.Sprintf("refund|%s|%d|%s|%s", r.TransactionRef, r.Amount.AmountMinor, r.Amount.Currency, r.Reason)
    if _, seen := s.ops[key]; seen && !r.DryRun {
        return s.replay(key, fp)
    }
    t, found := s.txs[r.TransactionRef]
    if !found {
        return notFound()
    }
    switch {
    case r.Amount.Currency != t.Amount.Currency:
        return s.refuse(key, r.DryRun, fp, "refund", t.TransactionRef, "currency_mismatch", "the charge is in another currency")
    case t.State != "succeeded" || t.Disputed:
        return s.refuse(key, r.DryRun, fp, "refund", t.TransactionRef, "not_refundable", "this charge cannot be refunded")
    case r.Amount.AmountMinor > refundable(t):
        return s.refuse(key, r.DryRun, fp, "refund", t.TransactionRef, "amount_exceeds_refundable",
            fmt.Sprintf("only %d minor units are left to refund", refundable(t)))
    }
    if r.DryRun {
        return ok(map[string]any{"idempotency_key": nil, "status": "would_succeed",
            "transaction_ref": t.TransactionRef, "amount": r.Amount, "refund_ref": nil})
    }
    // Execute once, at your processor, to the original payment method; then record it under the key.
    at := now()
    t.Refunds = append(t.Refunds, Refund{At: at, Amount: r.Amount, IdempotencyKey: ptr(key)})
    result := map[string]any{"idempotency_key": key, "status": "succeeded", "transaction_ref": t.TransactionRef,
        "amount": r.Amount, "refund_ref": "re_" + strconv.Itoa(len(s.ops)+1), "processed_at": at}
    s.ops[key] = stored{fp, Operation{IdempotencyKey: key, Status: "succeeded", Operation: "refund",
        TargetRef: t.TransactionRef, Result: result, UpdatedAt: at}}
    return ok(result)
}

func (s *store) cancel(key, ref string, raw json.RawMessage) answer {
    var r struct {
        AtPeriodEnd *bool  `json:"at_period_end"`
        Reason      string `json:"reason"`
        DryRun      bool   `json:"dry_run"`
    }
    if json.Unmarshal(raw, &r) != nil || r.AtPeriodEnd == nil || r.Reason == "" {
        return invalid("the request does not match cancel-request.json")
    }
    if a, good := keyRule(key, r.DryRun); !good {
        return a
    }
    fp := fmt.Sprintf("cancel_subscription|%s|%t|%s", ref, *r.AtPeriodEnd, r.Reason)
    if _, seen := s.ops[key]; seen && !r.DryRun {
        return s.replay(key, fp)
    }
    sub, found := s.subs[ref]
    if !found {
        return notFound()
    }
    if sub.Phase == "canceled" || sub.Phase == "expired" || (*r.AtPeriodEnd && sub.CancelAtPeriodEnd) {
        return s.refuse(key, r.DryRun, fp, "cancel_subscription", ref, "already_canceled", "already canceled")
    }
    if r.DryRun {
        return ok(map[string]any{"idempotency_key": nil, "status": "would_succeed", "subscription_ref": ref,
            "at_period_end": *r.AtPeriodEnd, "effective_at": nil})
    }
    at, effective := now(), now()
    if *r.AtPeriodEnd {
        sub.CancelAtPeriodEnd, effective = true, *sub.CurrentPeriodEnd
    } else {
        sub.Phase, sub.CancelAtPeriodEnd, sub.CurrentPeriodEnd = "canceled", false, nil
    }
    result := map[string]any{"idempotency_key": key, "status": "succeeded", "subscription_ref": ref,
        "at_period_end": *r.AtPeriodEnd, "effective_at": effective}
    s.ops[key] = stored{fp, Operation{IdempotencyKey: key, Status: "succeeded", Operation: "cancel_subscription",
        TargetRef: ref, Result: result, UpdatedAt: at}}
    return ok(result)
}

// verified checks a request's signature (section 3). Over MCP the key field is always empty.
func verified(r *http.Request, body []byte, secret string) bool {
    ts := r.Header.Get("X-SupportRoute-Timestamp")
    n, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || n < 0 || time.Since(time.Unix(n, 0)).Abs() > 5*time.Minute {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%s\n%s\n%s\n%s\n\n", ts, r.Method, strings.ToLower(r.Host), r.URL.EscapedPath())
    mac.Write(body)
    want := "v1=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(want), []byte(r.Header.Get("X-SupportRoute-Signature")))
}

func main() {
    secret := os.Getenv("SIGNING_SECRET") // srmps_… on your sandbox, srmp_… in production
    if secret == "" {
        log.Fatal("set SIGNING_SECRET")
    }
    st := seed()
    server := mcp.NewServer(&mcp.Implementation{Name: "example-billing", Version: "1.0"}, nil)
    tools := [][3]string{ // name, inputSchema, outputSchema: each a $ref to the contract's $id
        {"sr_describe", "tool-inputs.json#/$defs/describe", "describe.json"},
        {"sr_find_customers", "customer-query.json", "customer-candidates.json"},
        {"sr_get_customer", "tool-inputs.json#/$defs/get_customer", "customer.json"},
        {"sr_list_transactions", "tool-inputs.json#/$defs/list_transactions", "transaction-list.json"},
        {"sr_get_transaction", "tool-inputs.json#/$defs/get_transaction", "transaction.json"},
        {"sr_get_subscription", "tool-inputs.json#/$defs/get_subscription", "subscription.json"},
        {"sr_refund", "tool-inputs.json#/$defs/refund", "refund-result.json"},
        {"sr_cancel_subscription", "tool-inputs.json#/$defs/cancel_subscription", "cancel-result.json"},
        {"sr_get_operation", "tool-inputs.json#/$defs/get_operation", "operation.json"},
    }
    ref := func(id string) json.RawMessage {
        return json.RawMessage(`{"type":"object","$ref":"` + schemaBase + id + `"}`)
    }
    for _, t := range tools {
        name := t[0]
        server.AddTool(&mcp.Tool{Name: name, InputSchema: ref(t[1]), OutputSchema: ref(t[2])},
            func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
                a := st.call(name, req.Params.Arguments)
                b, err := json.Marshal(a.body)
                if err != nil {
                    return nil, err
                }
                return &mcp.CallToolResult{IsError: !a.ok, StructuredContent: json.RawMessage(b),
                    Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil
            })
    }
    mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
    // Every HTTP request of the session is signed: verify the raw bytes before the SDK reads them.
    http.Handle("/mcp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
        if err != nil || !verified(r, body, secret) {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)
            io.WriteString(w, `{"error":{"code":"credentials_rejected","message":"bad signature","retryable":false}}`)
            return
        }
        r.Body = io.NopCloser(bytes.NewReader(body))
        mcpHandler.ServeHTTP(w, r)
    }))
    addr := os.Getenv("ADDR")
    if addr == "" {
        addr = "127.0.0.1:8080"
    }
    log.Printf("merchant-provider/v1 (MCP) on http://%s/mcp", addr)
    log.Fatal(http.ListenAndServe(addr, nil))
}

What the example leaves out, and a real server needs:

  • Persistence. Keys and their outcomes must survive a restart and be kept at least 30 days (section 4). Store the key in the same transaction as the refund it records.
  • in_progress. A second call with a key whose first call is still running must answer in_progress (retryable), not run again. A lock per key does it.
  • Pending outcomes. A refund your processor confirms later answers pending, and sr_get_operation reports it until it settles.
  • Every refusal. The example refuses only what the conformance kit provokes. A real server also refuses with subscription_not_active (a subscription it cannot cancel now), and with its own guard codes (account_restricted, merchant_hold, x_…) where its rules hold an action.
  • The production secret. Your production endpoint verifies with the srmp_ signing secret and must refuse the srmps_ sandbox secret. During a rotation it accepts both the old and the new srmp_ secret (section 3).
  • A base path behind a proxy. Verify against the path and host as Chargeless sent them: your public host name, and the path including any prefix your proxy strips.

Testing it

  1. Locally, against the example. Run the conformance kit (download it), with -allow-private for plain http on localhost:

    sh
    export PROVIDERCHECK_SANDBOX_SIGNING_SECRET=srmps_…    # the server's SIGNING_SECRET
    ./providercheck -sandbox -transport mcp -allow-private \
      -base-url http://localhost:8080/mcp \
      -tenant 0199a3c1-0a0b-7c0d-8e0f-101112131415 \
      -customer cus_1001 -transaction ch_1001 -subscription sub_1001

    Restart the example between runs: the kit cancels sub_1001 and refunds part of ch_1001.

  2. Against your sandbox. Run the kit with your sandbox's MCP endpoint, your workspace id and fresh fixtures, then upload its sealed report (section 10, Step 1). Over MCP the kit runs the profile check first; the sandbox mutations run only after it passes.

  3. The live contract check. In the panel, choose the MCP transport in Settings → Payment provider and run the contract check (section 10, Step 2). Its mcp_profile line checks your tool list right after describe:

    Failure code Meaning
    mcp_tool_missing A required tool, or the tool of a capability describe declares, is not listed
    mcp_tool_duplicated A tool is listed twice
    mcp_schema_incompatible A schema does not accept what Chargeless sends, or the contract's valid examples
    mcp_schema_too_complex A schema is over the limits above
    mcp_tool_list_too_long tools/list takes more than 5 pages

To see Chargeless call an MCP server end to end, fakemerchant serves both bindings (section 11, Running the reference fake locally); its MCP endpoint is http://localhost:12112/mcp.