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_refundve 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ı:
{
"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ı:
{
"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/callyapar ve oturumu kapatır; sunucunuz birMcp-Session-Idatadıysa kapatma birDELETEisteğ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/discoverile, daha eski bir sunucu iseinitializevenotifications/initializedile keşfedilir. MCP SDK'ları bunu sizin yerinize yapar. - JSON ya da akış. POST isteklerini
application/jsonya datext/event-streamile 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.
GETakışı açmaz; bir akışı hiçbir zaman sürdürmez (resume), iptal bildirimi (cancellation) ya daMcp-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:
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) → 2xxAraç çağrısının kendisi:
{
"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./mcpyoluna yapılan bir POST için imzalanan dize{timestamp}\nPOST\n{host}\n/mcp\n\n{body}şeklindedir. - Idempotency anahtarı, aracın
idempotency_keyargümanında, yani imzalı gövdenin içinde taşınır.Idempotency-Keybaş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
401bekler. - İ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:
{
"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.0olarak yazılmış bir tutar ya da tekrarlanan bir anahtar burada da reddedilir. MCP istemcileri genelliklecontentiçinde bir metin kopyası bekler; Chargeless bu kopyayı yok sayar.Hata,
isError: truetaşıyan vestructuredContentalanında hata zarfı (error envelope) bulunan bir sonuçtur:{"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_request400 credentials_rejected401 not_found404 idempotency_conflict,in_progress409 bir ret kodu (6. bölüm) ya da bir x_kodu422 rate_limited429 internal_error500 unavailable503 JSON-RPC hatası ya da yanıt gelmeden biten bir akış
5xxsayılır: çağrı aynı anahtarla yeniden denenir, ardından sonucu bilinmeyen bir işlem olaraksr_get_operationile doğrulanır.resultTypedeğericompletedışı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:
{
"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
$defsya da başka yerel yapılar üretir; kontrol bunları kabul etmez. - Şemamızın birebir kopyası ileride bozulur. Bizim
$iddeğ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
$refiçerebilir; - her
$ref, sözleşmenin$iddeğerlerinden birine işaret etmelidir; $defs,definitions,$id,id,$anchor,$dynamicAnchor,$recursiveAnchor,$dynamicRefya da$recursiveRefkullanılamaz;- tüm şemalar toplamda en fazla 20.000 düğüm içerebilir;
tools/listen 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.
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))
}Ö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
pendingile yanıtlanır;sr_get_operationsonuç 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_activeile (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 vesrmps_sandbox anahtarını reddetmelidir. Anahtar yenileme (rotation) sırasında hem eski hem de yenisrmp_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
Yerelde, örnek sunucuya karşı. Uyumluluk kitini (indirin), localhost üzerinde düz http kullanabilmesi için
-allow-privateile çalıştırın: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_1001aboneliğini iptal eder vech_1001ödemesinin bir kısmını iade eder.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.
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_profilesatırı,describeadımının hemen ardından araç listenizi denetler:Hata kodu Anlamı mcp_tool_missingZorunlu bir araç ya da describeyanıtının bildirdiği bir yeteneğin aracı listelenmemişmcp_tool_duplicatedBir araç iki kez listelenmiş mcp_schema_incompatibleBir şema, Chargeless'ın gönderdiklerini ya da sözleşmenin geçerli örneklerini kabul etmiyor mcp_schema_too_complexBir şema yukarıdaki sınırları aşıyor mcp_tool_list_too_longtools/list5 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.