MCP Bağlantısı

Bu sayfa, merchant-provider/v1 sözleşmesini REST uç noktaları yerine MCP araçları (tools) olarak sunan mühendisler içindir. Sözleşme iki bağlantı türünde de aynıdır: Kendi faturalama sisteminizi bağlamak sayfasındaki operasyonlar, nesneler, imza, idempotency, kesinlik (finality) ve ret kuralları burada da aynen geçerlidir. Yalnızca istek ve yanıtların biçimi farklıdır; bu sayfa da onu anlatır: oturum, araç çağrıları, sonuçlar, yayımlayacağınız şemalar, nasıl test edeceğiniz ve başlangıç noktası olarak kullanabileceğiniz eksiksiz bir sunucu.

MCP Bağlantısı Nedir, Ne Değildir

  • MCP sunucusunu siz çalıştırırsınız; Chargeless istemcidir. Sunucunuz sr_describe, sr_refund ve aşağıda listelenen diğer sabit araçları Streamable HTTP üzerinden sunar. Chargeless platformu bilgileri okumak ve politikasının onayladığı iade ve iptalleri uygulamak için sunucunuza bağlanır.
  • Araçlarınızı hiçbir dil modeli çağırmaz. MCP burada yalnızca bir iletişim biçimidir. Çağrılar, REST'te olduğu gibi, politika kararı verip kaydettikten sonra Chargeless'ın kendi kodundan gelir. Müşterilerinizle konuşan asistan araçlarınızı, adlarını ya da yanıtlarını hiçbir zaman görmez.
  • Yalnızca profildeki araçlar çağrılır. Chargeless yalnızca profilde tanımlı araçları (sözleşme dosyalarındaki mcp/merchant-provider/v1.json) çağırır; sunucunuzun listelediği diğer tüm araçları yok sayar. Hiçbir zaman prompt, kaynak okuma (resource read) ya da sampling isteği göndermez.
  • Chargeless'ın kendine ait bir MCP sunucusu yoktur. Chargeless'ta bir yapay zekâ asistanının bağlanabileceği bir MCP uç noktası bulunmaz. Kendi sistemlerinizden Chargeless'ı çağırmak için API'yi, olup bitenden haberdar olmak için webhook'ları kullanın.

Faturalama sisteminiz zaten MCP kullanıyorsa ya da tipli operasyonları sunmanın sizin için en kısa yolu bir MCP SDK'sıysa MCP'yi seçin. Aksi halde REST'i seçin: düz HTTP'dir ve curl ile hata ayıklaması daha kolaydır. İkisi de aynı kontrollerden geçer ve canlıya aynı şekilde alınır.

Araçlar

Araç REST karşılığı Argümanlar Sonuç (structuredContent) Zorunlu
sr_describe GET /v1/describe {} describe nesnesi evet
sr_find_customers POST /v1/customers/search sorgu: email, phone, customer_ref, transaction_ref, order_ref, card_last4 (bir veya daha fazlası) {"candidates": [{"customer_ref"}]} evet
sr_get_customer GET /v1/customers/{customer_ref} {customer_ref} customer nesnesi evet
sr_list_transactions GET /v1/customers/{customer_ref}/transactions {customer_ref} {"transactions": […], "subscriptions": […]} evet
sr_get_transaction GET /v1/transactions/{transaction_ref} {transaction_ref} transaction nesnesi evet
sr_get_subscription GET /v1/subscriptions/{subscription_ref} {subscription_ref} subscription nesnesi cancel_subscription yeteneği varsa
sr_refund POST /v1/refunds {idempotency_key, request}; deneme çalıştırmasında (dry run) idempotency_key yoktur iade sonucu evet
sr_cancel_subscription POST /v1/subscriptions/{subscription_ref}/cancel {idempotency_key, subscription_ref, request}; deneme çalıştırmasında idempotency_key yoktur iptal sonucu cancel_subscription yeteneği varsa
sr_get_operation GET /v1/operations/{idempotency_key} {idempotency_key} operation nesnesi evet
sr_list_payment_attempts POST /v1/payment-attempts/list sorgu: {updated_since, cursor?, limit?} bir sayfa ödeme denemesi payment_attempts yeteneği varsa

Nesneler sözleşmenin 2. bölümünde anlatılır; sözleşme dosyalarında her birinin geçerli bir örneği bulunur (schemas/merchant-provider/examples/v1/). Gerçek bir iadenin argümanları:

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
  }
}

dönem sonunda geçerli olacak bir iptalin argümanları:

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}
}

Aktarım ve Oturumlar

  • Streamable HTTP. MCP uç noktanız, yapılandırdığınız temel URL'dir (base URL); örneğin https://billing.example.com/mcp. /v1/… yolları kullanılmaz. 3. bölümdeki ağ kuralları geçerlidir: 443 portunda HTTPS, herkese açık (public) bir sertifika, yönlendirme (redirect) yok.
  • Her deneme için ayrı oturum. Her deneme kendi oturumunu açar, tek bir tools/call yapar ve oturumu kapatır; sunucunuz bir Mcp-Session-Id atadıysa kapatma bir DELETE isteğiyle yapılır. Denemeler arasında hiçbir şey saklanmaz. Aynı çağrı yeniden denendiğinde aynı argümanlarla yeni bir oturum açılır.
  • Keşif (discovery). 2026-07-28 revizyonunu kullanan bir sunucu server/discover ile, daha eski bir sunucu ise initialize ve notifications/initialized ile keşfedilir. MCP SDK'ları bunu sizin yerinize yapar.
  • JSON ya da akış. POST isteklerini application/json ya da text/event-stream ile yanıtlayabilirsiniz; ikisi de desteklenir.
  • İstemcinin hiçbir zaman yapmadıkları. İstemci hiçbir yetenek (capability) bildirmez ve sunucudan gelen istekleri (sampling, elicitation, roots, ping) hiçbir zaman yanıtlamaz. GET akışı açmaz; bir akışı hiçbir zaman sürdürmez (resume), iptal bildirimi (cancellation) ya da Mcp-Param-* başlıkları göndermez. Sunucunuz bunların hiçbirini zorunlu tutmamalıdır.

Tek bir denemenin istek ve yanıtları; her istek imzalıdır:

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

Araç çağrısının kendisi:

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, REST'te başlık olarak gönderilen korelasyon kimliklerini taşır (3. bölüm, Korelasyon): dev.supportroute/request_id (her denemede yenilenir), dev.supportroute/correlation_id, dev.supportroute/channel ve istek bir yardım masası talebinden geliyorsa dev.supportroute/external_ticket. Bunları günlüklerinize yazın.

İmzalama

  • Oturumdaki her HTTP isteği imzalanır: el sıkışma (handshake), araç çağrısı ve DELETE. İmza 3. bölümde anlatıldığı gibi atılır, ancak anahtar alanı boş bırakılır. /mcp yoluna yapılan bir POST için imzalanan dize {timestamp}\nPOST\n{host}\n/mcp\n\n{body} şeklindedir.
  • Idempotency anahtarı, aracın idempotency_key argümanında, yani imzalı gövdenin içinde taşınır. Idempotency-Key başlığı gönderilmez.
  • Yalnızca el sıkışmayı değil, araç çağrısının kendisini de doğrulayın. Sözleşme kontrolü el sıkışmayı doğru imzalar, yalnızca araç çağrısının imzasını bozar ve 401 bekler.
  • İmzayı, MCP kütüphaneniz gövdeyi okumadan önce doğrulayın. Ham baytları okuyun, imzayı doğrulayın, ardından aynı baytları kütüphaneye verin (aşağıdaki örnek bunu yapar).
  • Bearer token ve edge başlıkları tanımladıysanız her istekte gönderilir.

Sonuçlar

Başarılı bir araç çağrısı nesneyi structuredContent içinde döndürür:

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 yalnızca structuredContent'i okur; onu gönderdiğiniz baytlardan okur ve bir REST gövdesiyle tamamen aynı şekilde doğrular (2. bölüm, Boyutlar ve süre sınırları): 2000.0 olarak yazılmış bir tutar ya da tekrarlanan bir anahtar burada da reddedilir. MCP istemcileri genellikle content içinde bir metin kopyası bekler; Chargeless bu kopyayı yok sayar.

  • Hata, isError: true taşıyan ve structuredContent alanında hata zarfı (error envelope) bulunan bir sonuçtur:

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

    Hata kodu bir REST durum koduna karşılık gelir; hata bundan sonra tamamen 5. bölümde anlatıldığı gibi işlenir:

    Kod Durum
    invalid_request 400
    credentials_rejected 401
    not_found 404
    idempotency_conflict, in_progress 409
    bir ret kodu (6. bölüm) ya da bir x_ kodu 422
    rate_limited 429
    internal_error 500
    unavailable 503
  • JSON-RPC hatası ya da yanıt gelmeden biten bir akış 5xx sayılır: çağrı aynı anahtarla yeniden denenir, ardından sonucu bilinmeyen bir işlem olarak sr_get_operation ile doğrulanır.

  • resultType değeri complete dışında olan bir sonuç nihai yanıt sayılmaz ve çağrı başarısız olur.

  • Reddedilen bir imza, MCP katmanına geçilmeden HTTP katmanında yanıtlanır: REST hata zarfıyla birlikte 401.

Araç Şemalarını Yayımlama

Profildeki her araç bir inputSchema ve bir outputSchema bildirmelidir. Her birini, sözleşmedeki $id değerine işaret eden bir $ref olarak yayımlayın:

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"}
}
Araç 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 (cancel_subscription ile) tool-inputs.json#/$defs/get_subscription subscription.json
sr_refund tool-inputs.json#/$defs/refund refund-result.json
sr_cancel_subscription (cancel_subscription ile) tool-inputs.json#/$defs/cancel_subscription cancel-result.json
sr_get_operation tool-inputs.json#/$defs/get_operation operation.json
sr_list_payment_attempts (payment_attempts ile) payment-attempt-query.json payment-attempt-list.json

Her $ref, https://schemas.supportroute.dev/merchant-provider/v1/ adresine göre görelidir. Bir yeteneğe ait araçları yalnızca sr_describe o yeteneği bildiriyorsa listeleyin. Şu iki yaklaşım işe yaramaz:

  • Kendi tiplerinizden üretilen şema reddedilir. Şema üreticileri genellikle $defs ya da başka yerel yapılar üretir; kontrol bunları kabul etmez.
  • Şemamızın birebir kopyası ileride bozulur. Bizim $id değerimizle yayımlanan bir kopya, yalnızca belgemizle birebir aynı olduğu sürece kontrolden geçer; o dosyada bir sonraki değişikliğimizde, yalnızca bir açıklamayı değiştirsek bile geçemez hale gelir.

$id değerleri yalnızca birer tanımlayıcıdır. Chargeless bunları kendi kopyalarından çözümler ve hiçbir zaman ağdan indirmez. MCP kütüphaneniz kendi sonuçlarınızı bildirilen şemaya göre doğruluyorsa, sözleşmenin şema dosyalarını kütüphanenin doğrulayıcısına kendi $id değerleriyle yükleyin ya da bu araçlar için bu doğrulamayı kapatın.

Profil kontrolü uyumluluğu örnekler üzerinden, üç kurala göre belirler:

  • inputSchema, Chargeless'ın o araca gönderdiği her argüman nesnesini kabul etmelidir;
  • outputSchema, sözleşmenin geçerli örneklerini kabul etmelidir;
  • daha dar tanımlanmış kapalı bir nesne (closed object) reddedilir.

Kontrol ayrıca şu sınırları uygular:

  • bir şema en fazla 64 KiB, 32 seviye, 4.096 düğüm ve 8 $ref içerebilir;
  • her $ref, sözleşmenin $id değerlerinden birine işaret etmelidir;
  • $defs, definitions, $id, id, $anchor, $dynamicAnchor, $recursiveAnchor, $dynamicRef ya da $recursiveRef kullanılamaz;
  • tüm şemalar toplamda en fazla 20.000 düğüm içerebilir;
  • tools/list en fazla 5 sayfaya sığmalıdır;
  • profil kontrolünün tamamı en fazla 15 sn sürebilir.

Eksiksiz Bir Sunucu

Aşağıdaki sunucu, MCP bağlantısını resmî MCP Go SDK'sıyla uygular: faturalama sisteminizin yerine bellekte tutulan bir müşteri, bir ödeme ve bir abonelik kullanır. Her isteğin imzasını ham baytlar üzerinden doğrular, idempotency ve kesinlik kurallarını anahtar bazında uygular, deneme çalıştırmalarını (dry run) hiçbir şeyi değiştirmeden yanıtlar, kalan tutarı aşan iadeleri reddeder ve providercheck -sandbox -transport mcp komutunun tüm satırlarından geçer (providercheck/2 ve go-sdk v1.8.0 ile denenmiştir). Bellekteki depoyu faturalama sisteminize yapılan çağrılarla değiştirin; kuralları koruyun.

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))
}

Örnekte bulunmayan, ancak gerçek bir sunucuda olması gerekenler:

  • Kalıcılık (persistence). Anahtarlar ve sonuçları yeniden başlatmadan sonra da korunmalı ve en az 30 gün saklanmalıdır (4. bölüm). Anahtarı, kaydettiği iadeyle aynı veritabanı işleminde (transaction) saklayın.
  • in_progress. İlk çağrısı hâlâ süren bir anahtarla gelen ikinci çağrı yeniden çalıştırılmamalı, in_progress (yeniden denenebilir) ile yanıtlanmalıdır. Bunun için anahtar başına bir kilit yeterlidir.
  • Bekleyen sonuçlar. İşlemcinizin (processor) daha sonra onayladığı bir iade pending ile yanıtlanır; sr_get_operation sonuç kesinleşene kadar bu durumu bildirir.
  • Tüm ret durumları. Örnek yalnızca uyumluluk kitinin tetiklediği durumları reddeder. Gerçek bir sunucu ayrıca subscription_not_active ile (o anda iptal edilemeyen bir abonelik için) ve kendi kurallarının bir işlemi durdurduğu durumlarda kendi koruma kodlarıyla (account_restricted, merchant_hold, x_…) ret döner.
  • Canlı ortamın imzalama anahtarı. Canlı uç noktanız imzaları srmp_ imzalama anahtarıyla doğrular ve srmps_ sandbox anahtarını reddetmelidir. Anahtar yenileme (rotation) sırasında hem eski hem de yeni srmp_ anahtarını kabul eder (3. bölüm).
  • Proxy arkasında taban yol (base path). İmzayı, yolu ve host'u Chargeless'ın gönderdiği haliyle kullanarak doğrulayın: herkese açık host adınızla ve proxy'nizin kırptığı ön ekler dahil tam yolla.

Test Etme

  1. Yerelde, örnek sunucuya karşı. Uyumluluk kitini (indirin), localhost üzerinde düz http kullanabilmesi için -allow-private ile çalıştırın:

    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

    Çalıştırmalar arasında örnek sunucuyu yeniden başlatın: kit sub_1001 aboneliğini iptal eder ve ch_1001 ödemesinin bir kısmını iade eder.

  2. Sandbox'ınıza karşı. Kiti sandbox'ınızın MCP uç noktası, çalışma alanı kimliğiniz ve yeni test verileriyle (fixture) çalıştırın, ardından kitin ürettiği mühürlü raporu yükleyin (10. bölüm, 1. adım). MCP'de kit önce profil kontrolünü çalıştırır; sandbox'ta veri değiştiren işlemler ancak bu kontrol geçtikten sonra çalışır.

  3. Canlı sözleşme kontrolü. Panelde Ayarlar → Ödeme Sağlayıcısı altında MCP aktarımını seçin ve sözleşme kontrolünü çalıştırın (10. bölüm, 2. adım). Kontrolün mcp_profile satırı, describe adımının hemen ardından araç listenizi denetler:

    Hata kodu Anlamı
    mcp_tool_missing Zorunlu bir araç ya da describe yanıtının bildirdiği bir yeteneğin aracı listelenmemiş
    mcp_tool_duplicated Bir araç iki kez listelenmiş
    mcp_schema_incompatible Bir şema, Chargeless'ın gönderdiklerini ya da sözleşmenin geçerli örneklerini kabul etmiyor
    mcp_schema_too_complex Bir şema yukarıdaki sınırları aşıyor
    mcp_tool_list_too_long tools/list 5 sayfadan uzun

Chargeless'ın bir MCP sunucusunu uçtan uca nasıl çağırdığını görmek için fakemerchant'ı kullanabilirsiniz; iki bağlantı türünü de sunar (11. bölüm, Referans test sağlayıcısını yerelde çalıştırmak). MCP uç noktası http://localhost:12112/mcp adresindedir.