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_refundand 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.jsonin 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:
{
"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:
{
"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/calland closes it, with aDELETEwhen your server set anMcp-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 withinitializeandnotifications/initialized. MCP SDKs do this for you. - JSON or a stream. Answer a POST with
application/jsonor withtext/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
GETstream, never resumes a stream, never sends a cancellation and never sendsMcp-Param-*headers. Do not require any of these.
One attempt on the wire, every request signed:
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) → 2xxThe tool call itself:
{
"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_keyargument, inside the signed body. There is noIdempotency-Keyheader. - 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:
{
"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
structuredContentonly, from the bytes you sent, and validates it exactly as a REST body (section 2, Sizes and time limits): an amount written2000.0or a repeated key fails here too. A text copy incontentis what MCP clients usually expect; Chargeless ignores it.An error is a result with
isError: truewhosestructuredContentis the error envelope:{"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_request400 credentials_rejected401 not_found404 idempotency_conflict,in_progress409 a refusal code (section 6) or an x_code422 rate_limited429 internal_error500 unavailable503 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 bysr_get_operation.A result with a
resultTypeother thancompleteis not a final answer and fails the call.A refused signature is answered at the HTTP layer, before MCP:
401with 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:
{
"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
$defsor other local structure, which the check refuses. - A verbatim copy of our schema breaks later. A copy under our
$idpasses 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
inputSchemamust accept every argument object Chargeless sends that tool; - the
outputSchemamust 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
$refpoints to one of the contract's$ids; - no
$defs,definitions,$id,id,$anchor,$dynamicAnchor,$recursiveAnchor,$dynamicRefor$recursiveRef; - all schemas together are at most 20,000 nodes;
tools/listfits 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.
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// 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 answerin_progress(retryable), not run again. A lock per key does it.- Pending outcomes. A refund your processor confirms later answers
pending, andsr_get_operationreports 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 thesrmps_sandbox secret. During a rotation it accepts both the old and the newsrmp_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
Locally, against the example. Run the conformance kit (download it), with
-allow-privatefor plain http on localhost: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_1001Restart the example between runs: the kit cancels
sub_1001and refunds part ofch_1001.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.
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_profileline checks your tool list right afterdescribe:Failure code Meaning mcp_tool_missingA required tool, or the tool of a capability describedeclares, is not listedmcp_tool_duplicatedA tool is listed twice mcp_schema_incompatibleA schema does not accept what Chargeless sends, or the contract's valid examples mcp_schema_too_complexA schema is over the limits above mcp_tool_list_too_longtools/listtakes 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.