task/migrate-x402-v2 #1

Merged
nacorid merged 6 commits from task/migrate-x402-v2 into main 2026-07-30 08:13:05 +00:00
6 changed files with 704 additions and 458 deletions
Showing only changes of commit d5b16cba3a - Show all commits

rewrite wrapper

migrate server from v1 to v2
Nacorid 2026-07-29 00:03:11 +00:00
Signed by: nacorid
SSH key fingerprint: SHA256:zAJkAgjXXOAJqP6R2fp8eKCNlnKgpf33G/Baa1xtNGA

12
go.mod
View file

@ -4,9 +4,10 @@ go 1.25.4
require (
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/mark3labs/mcp-go v0.43.2
github.com/mark3labs/x402-go v0.12.1
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0
@ -19,11 +20,9 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bits-and-blooms/bitset v1.24.2 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -37,29 +36,23 @@ require (
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/tyler-smith/go-bip32 v1.0.0 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.38.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
@ -73,7 +66,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect

18
go.sum
View file

@ -17,8 +17,6 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@ -30,8 +28,6 @@ github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHf
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk=
@ -84,8 +80,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
@ -154,8 +148,6 @@ github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
@ -217,10 +209,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/x402-go v0.12.1 h1:yWlT/mjF0P1j2sB0xAM4GJb+1/Ycv6p6EqUrT6RB2X0=
github.com/mark3labs/x402-go v0.12.1/go.mod h1:srAvV9FosjBiqrclF15thrQbz0fVVfNXtMcqD0e1hKU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@ -318,8 +306,6 @@ github.com/sokkalf/slog-seq v0.5.1 h1:4LicZGsMhuCtmZkbS++JOm3Wn8PLIrjFkuvEp0yeGH
github.com/sokkalf/slog-seq v0.5.1/go.mod h1:B82pc/cMpdQQg6hkBbstHEL4vqI1eZ1MISuN1IK7h14=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 h1:VMA0pZ3MI8BErRA3kh8dKJThP5d0Xh5vZVk5yFIgH/A=
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845/go.mod h1:BtDq81Tyc7H8up5aXNi/I95nPmG3C0PLEqGWY/iWQ2E=
@ -353,8 +339,6 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x402-foundation/x402/go/v2 v2.19.0 h1:vyU9cKFfqw8oI6To66r+UH67vFwWpoMDRX9zGM8bCJI=
github.com/x402-foundation/x402/go/v2 v2.19.0/go.mod h1:OAsOuB+FYhCoKzijOGZ+PcJk9g4RBfCJvpABGxi+NcE=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
@ -494,8 +478,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

84
internal/server/auth.go Normal file
View file

@ -0,0 +1,84 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
x402http "github.com/x402-foundation/x402/go/v2/http"
)
type CDPAuth struct {
keyName string
privateKey *ecdsa.PrivateKey
signingMethod jwt.SigningMethod
}
func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
var signingMethod jwt.SigningMethod
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
var ok bool
privKey, ok = parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA private key")
}
signingMethod = jwt.SigningMethodEdDSA
} else {
signingMethod = jwt.SigningMethodES256
}
return &CDPAuth{
keyName: keyName,
privateKey: privKey,
signingMethod: signingMethod,
}, nil
}
func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return "", err
}
nonce := hex.EncodeToString(nonceBytes)
uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path)
now := time.Now().Unix()
claims := jwt.MapClaims{
"sub": a.keyName,
"iss": "cdp",
"nbf": now,
"exp": now + 120,
"uris": []string{uri},
}
token := jwt.NewWithClaims(a.signingMethod, claims)
token.Header["kid"] = a.keyName
token.Header["nonce"] = nonce
return token.SignedString(a.privateKey)
}
func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify")
settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle")
supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported")
return x402http.AuthHeaders{
Verify: map[string]string{"Authorization": "Bearer " + verify},
Settle: map[string]string{"Authorization": "Bearer " + settle},
Supported: map[string]string{"Authorization": "Bearer " + supported},
}, nil
}

View file

@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
@ -9,11 +10,7 @@ import (
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
x402go "github.com/mark3labs/x402-go"
x402http "github.com/mark3labs/x402-go/http"
"github.com/mark3labs/x402-go/signers/coinbase"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/bluesky"
@ -22,6 +19,8 @@ import (
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/utils"
"github.com/nacorid/naco-api/internal/x402"
x402v2 "github.com/x402-foundation/x402/go/v2"
"github.com/x402-foundation/x402/go/v2/types"
)
type Server struct {
@ -39,7 +38,7 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"})
metrics := NewMetricsHook()
toolChain := x402.X402Middlewares{
MCPMiddleware: []mcpserver.ToolHandlerMiddleware{
MCPMiddleware: []mcp.Middleware{
metrics.OnCall,
rateLimits.OnCall,
},
@ -48,86 +47,82 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
rateLimits.OnCallHTTP,
},
}
auth, err := coinbase.NewCDPAuth(cfg.ApiKey, cfg.ApiSecret, "")
auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret)
if err != nil {
log.FatalWithContext(ctx, "failed to create CDP auth", "error", err)
}
srv, err := x402.NewX402Server(
"x402 enabled Bluesky lookingglass for autonomous Agents",
"0.1.0",
&x402http.Config{
FacilitatorURL: cfg.X402FacilitatorURL,
FacilitatorAuthorizationProvider: func(r *http.Request) string {
token, err := auth.GenerateBearerToken(r.Method, r.URL.Path)
if err != nil {
log.FatalWithContext(ctx, "failed to generate auth token", "error", err)
return ""
}
return "Bearer " + token
},
FacilitatorOnAfterSettle: func(ctx context.Context, pp x402go.PaymentPayload, pr x402go.PaymentRequirement, sr *x402go.SettlementResponse, errVal error) {
var amount string
if errVal != nil {
log.ErrorWithContext(ctx, "Payment settlement failed", "error", errVal)
amount = "0"
} else {
if payload, ok := pp.Payload.(map[string]interface{}); ok {
if auth, ok := payload["Authorization"].(map[string]interface{}); ok {
if value, ok := auth["Value"].(string); ok {
amount = value
}
}
}
}
var to string
if payload, ok := pp.Payload.(map[string]interface{}); ok {
if auth, ok := payload["Authorization"].(map[string]interface{}); ok {
if value, ok := auth["To"].(string); ok {
to = value
}
}
}
var from string
if payload, ok := pp.Payload.(map[string]interface{}); ok {
if auth, ok := payload["Authorization"].(map[string]interface{}); ok {
if value, ok := auth["From"].(string); ok {
from = value
}
}
}
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
X402TransactionHash: sr.Transaction,
Amount: amount,
Asset: pr.Asset,
Network: sr.Network,
Recipient: to,
Sender: from,
})
var id int64 = 0
if len(ret) > 0 {
id = ret[0].ID
}
fields := log.Fields{
"transaction_id": id,
"X402_transaction_hash": sr.Transaction,
"amount": amount,
"asset": pr.Asset,
"network": sr.Network,
"recipient": to,
"sender": from,
}
if err != nil {
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err)
}
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
},
},
"0.2.0",
cfg.X402FacilitatorURL,
auth,
toolChain,
)
if err != nil {
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
}
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
var amount, to, from, asset string
var payloadMap map[string]any
if err := json.Unmarshal(settleCtx.PayloadBytes, &payloadMap); err == nil {
if payload, ok := payloadMap["payload"].(map[string]interface{}); ok {
if auth, ok := payload["Authorization"].(map[string]interface{}); ok {
if value, ok := auth["Value"].(string); ok {
amount = value
}
if value, ok := auth["To"].(string); ok {
to = value
}
if value, ok := auth["From"].(string); ok {
from = value
}
}
}
}
var reqMap map[string]interface{}
if err := json.Unmarshal(settleCtx.RequirementsBytes, &reqMap); err == nil {
if value, ok := reqMap["asset"].(string); ok {
asset = value
}
}
ret, err := db.SaveTransaction(settleCtx.Ctx, store.SaveTransactionParams{
X402TransactionHash: settleCtx.Result.Transaction,
Amount: amount,
Asset: asset,
Network: string(settleCtx.Result.Network),
Recipient: to,
Sender: from,
})
var id int64 = 0
if len(ret) > 0 {
id = ret[0].ID
}
fields := log.Fields{
"transaction_id": id,
"X402_transaction_hash": settleCtx.Result.Transaction,
"amount": amount,
"asset": asset,
"network": settleCtx.Result.Network,
"recipient": to,
"sender": from,
}
if err != nil {
log.WithFields(fields).ErrorWithContext(settleCtx.Ctx, "Failed to save transaction", "value", ret, "error", err)
return err
}
log.WithFields(fields).DebugWithContext(settleCtx.Ctx, "Saved transaction")
return nil
})
s := &Server{
listenAddress: cfg.ListenAddress,
permissions: cfg.UnixSocketPermissions,
@ -145,244 +140,245 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
}
func (s *Server) registerTools() {
chainConfig := x402go.BaseMainnet
requirement, err := newUSDCPaymentRequirement(x402go.USDCRequirementConfig{
Chain: chainConfig,
Amount: "0.01",
RecipientAddress: s.x402Wallet,
Description: "Get a specific post from a specific actor",
requirement := types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "10000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
},
&x402go.OutputSchema{
Input: x402go.InputSchema{
Type: x402go.InputSchemaTypeHTTP,
Method: x402go.InputSchemaMethodPOST,
BodyType: x402go.InputSchemaBodyTypeJSON,
BodyFields: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"rkey": {
Type: "string",
Description: "Record key of the post",
Required: true,
},
"collection": {
Type: "string",
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
Required: false,
Extra: map[string]any{
"OutputSchema": x402.Schema{
Input: x402.InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"rkey": Bodyfield{
Type: "string",
Description: "Record key of the post",
Required: true,
},
"collection": Bodyfield{
Type: "string",
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
Required: false,
},
},
},
},
Output: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did of the actor",
},
"post": {
Type: "object",
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of the actor",
},
"post": Bodyfield{
Type: "object",
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
},
)
if err != nil {
log.Fatal("failed to create payment requirement", "error", err)
}
requirements := []x402go.PaymentRequirement{requirement}
requirements := []types.PaymentRequirements{requirement}
s.mcpHandler.AddPayableTool(
mcp.NewTool("getPost",
mcp.WithDescription("Get a specific post from a specific actor"),
mcp.WithInputSchema[GetPostRequest](),
mcp.WithOutputSchema[GetPostResponsePost](),
),
mcp.NewStructuredToolHandler(s.getPost),
mcp.Tool{Name: "getPost",
Description: "Get a specific post from a specific actor",
InputSchema: GetPostRequest{},
OutputSchema: GetPostResponsePost{},
},
s.getPost,
requirements...,
)
requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{
Chain: chainConfig,
Amount: "0.10",
RecipientAddress: s.x402Wallet,
Description: "Get the latest n posts from a specific actor",
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
},
&x402go.OutputSchema{
Input: x402go.InputSchema{
Type: x402go.InputSchemaTypeHTTP,
Method: x402go.InputSchemaMethodPOST,
BodyType: x402go.InputSchemaBodyTypeJSON,
BodyFields: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did or handle of an actor",
Required: true,
Extra: map[string]any{
"OutputSchema": x402.Schema{
Input: x402.InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"n": Bodyfield{
Type: "integer",
Description: "Number of posts to retrieve, maximum 50",
Required: true,
},
},
"n": {
},
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of an actor",
},
"posts": Bodyfield{
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
},
}
requirements = []types.PaymentRequirements{requirement}
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getLatestPosts",
Description: "Get the latest n posts from a specific actor",
InputSchema: GetLatestPostsRequest{},
OutputSchema: GetLatestPostsResponse{},
},
s.getLatestPosts,
requirements...,
)
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": x402.Schema{
Input: x402.InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"start": Bodyfield{
Type: "string",
Description: "Start time in RFC3339 format",
Required: true,
},
"end": Bodyfield{
Type: "string",
Description: "End time in RFC3339 format",
Required: true,
},
"n": Bodyfield{
Type: "integer",
Description: "Maximum number of posts to retrieve, maximum 50",
Required: true,
},
},
},
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of an actor",
},
"posts": Bodyfield{
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
},
}
requirements = []types.PaymentRequirements{requirement}
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getPostsInTime",
Description: "Get posts from a specific actor in a time range",
InputSchema: GetPostsInTimeRequest{},
OutputSchema: GetPostsInTimeResponse{},
},
s.getPostsInTime,
requirements...,
)
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": x402.Schema{
Input: x402.InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
},
},
Output: map[string]any{
"PersonaEmbedding": Bodyfield{
Type: "array",
Description: "Embedding vector representing the actor's persona",
},
"TopKeywords": Bodyfield{
Type: "array",
Description: "Top words used by the actor",
},
"StyleSummary": Bodyfield{
Type: "string",
Description: "Summary of the actor's writing style",
},
"PostCount": Bodyfield{
Type: "integer",
Description: "Number of posts to retrieve, maximum 50",
Required: true,
Description: "Number of posts analyzed",
},
},
},
Output: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did of an actor",
},
"posts": {
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
)
if err != nil {
log.Fatal("failed to create payment requirement", "error", err)
}
requirements = []x402go.PaymentRequirement{requirement}
requirements = []types.PaymentRequirements{requirement}
s.mcpHandler.AddPayableTool(
mcp.NewTool("getLatestPosts",
mcp.WithDescription("Get the latest n posts from a specific actor"),
mcp.WithInputSchema[GetLatestPostsRequest](),
mcp.WithOutputSchema[GetLatestPostsResponse](),
),
mcp.NewStructuredToolHandler(s.getLatestPosts),
requirements...,
)
requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{
Chain: chainConfig,
Amount: "0.10",
RecipientAddress: s.x402Wallet,
Description: "Get posts from a specific actor in a time range",
MaxTimeoutSeconds: 60,
},
&x402go.OutputSchema{
Input: x402go.InputSchema{
Type: x402go.InputSchemaTypeHTTP,
Method: x402go.InputSchemaMethodPOST,
BodyType: x402go.InputSchemaBodyTypeJSON,
BodyFields: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"start": {
Type: "string",
Description: "Start time in RFC3339 format",
Required: true,
},
"end": {
Type: "string",
Description: "End time in RFC3339 format",
Required: true,
},
"n": {
Type: "integer",
Description: "Maximum number of posts to retrieve, maximum 50",
Required: true,
},
},
},
Output: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did of an actor",
},
"posts": {
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
mcp.Tool{Name: "getStyleProfile",
Description: "Get an actor's style Profile",
InputSchema: GetStyleProfileRequest{},
OutputSchema: GetStyleProfileResponse{},
},
)
if err != nil {
log.Fatal("failed to create payment requirement", "error", err)
}
requirements = []x402go.PaymentRequirement{requirement}
s.mcpHandler.AddPayableTool(
mcp.NewTool("getPostsInTime",
mcp.WithDescription("Get posts from a specific actor in a time range"),
mcp.WithInputSchema[GetPostsInTimeRequest](),
mcp.WithOutputSchema[GetPostsInTimeResponse](),
),
mcp.NewStructuredToolHandler(s.getPostsInTime),
requirements...,
)
requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{
Chain: chainConfig,
Amount: "0.50",
RecipientAddress: s.x402Wallet,
Description: "Get an actor's style Profile",
MaxTimeoutSeconds: 60,
},
&x402go.OutputSchema{
Input: x402go.InputSchema{
Type: x402go.InputSchemaTypeHTTP,
Method: x402go.InputSchemaMethodPOST,
BodyType: x402go.InputSchemaBodyTypeJSON,
BodyFields: map[string]x402go.FieldDef{
"actor": {
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
},
},
Output: map[string]x402go.FieldDef{
"PersonaEmbedding": {
Type: "array",
Description: "Embedding vector representing the actor's persona",
},
"TopKeywords": {
Type: "array",
Description: "Top words used by the actor",
},
"StyleSummary": {
Type: "string",
Description: "Summary of the actor's writing style",
},
"PostCount": {
Type: "integer",
Description: "Number of posts analyzed",
},
},
},
)
if err != nil {
log.Fatal("failed to create payment requirement", "error", err)
}
requirements = []x402go.PaymentRequirement{requirement}
s.mcpHandler.AddPayableTool(
mcp.NewTool("getStyleProfile",
mcp.WithDescription("Get an actor's style Profile"),
mcp.WithInputSchema[GetStyleProfileRequest](),
mcp.WithOutputSchema[GetStyleProfileResponse](),
),
mcp.NewStructuredToolHandler(s.getStyleProfile),
s.getStyleProfile,
requirements...,
)
s.mcpHandler.AddTool(
mcp.NewTool("getFeed",
mcp.WithDescription("Get a feed by its URI"),
mcp.WithInputSchema[GetFeedRequest](),
mcp.WithOutputSchema[GetFeedResponse](),
),
mcp.NewStructuredToolHandler(s.getFeed),
mcp.Tool{Name: "getFeed",
Description: "Get a feed by its URI",
InputSchema: GetFeedRequest{},
OutputSchema: GetFeedResponse{},
},
s.getFeed,
)
}
func (s *Server) getPost(ctx context.Context, request mcp.CallToolRequest, args GetPostRequest) (*GetPostResponsePost, error) {
func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
@ -397,7 +393,7 @@ func (s *Server) getPost(ctx context.Context, request mcp.CallToolRequest, args
if !ok {
return nil, fmt.Errorf("record is not a feed post")
}
return &GetPostResponsePost{
resp := GetPostResponsePost{
Author: res.Author.Did,
Content: post.Text,
Likes: *res.LikeCount,
@ -405,10 +401,25 @@ func (s *Server) getPost(ctx context.Context, request mcp.CallToolRequest, args
Quotes: *res.QuoteCount,
Replies: *res.ReplyCount,
CreatedAt: post.CreatedAt,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getLatestPosts(ctx context.Context, request mcp.CallToolRequest, args GetLatestPostsRequest) (*GetLatestPostsResponse, error) {
func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetLatestPostsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
@ -442,13 +453,28 @@ func (s *Server) getLatestPosts(ctx context.Context, request mcp.CallToolRequest
if author == "" {
author = args.Author
}
return &GetLatestPostsResponse{
resp := GetLatestPostsResponse{
Author: author,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getPostsInTime(ctx context.Context, request mcp.CallToolRequest, args GetPostsInTimeRequest) (*GetPostsInTimeResponse, error) {
func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostsInTimeRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
@ -493,13 +519,28 @@ func (s *Server) getPostsInTime(ctx context.Context, request mcp.CallToolRequest
if author == "" {
author = args.Author
}
return &GetPostsInTimeResponse{
resp := &GetPostsInTimeResponse{
Author: author,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getFeed(ctx context.Context, request mcp.CallToolRequest, args GetFeedRequest) (*GetFeedResponse, error) {
func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetFeedRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.URI == "" {
return nil, fmt.Errorf("feedURI must be a valid feed URI")
}
@ -524,13 +565,28 @@ func (s *Server) getFeed(ctx context.Context, request mcp.CallToolRequest, args
CreatedAt: post.CreatedAt,
})
}
return &GetFeedResponse{
resp := GetFeedResponse{
URI: args.URI,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getStyleProfile(ctx context.Context, request mcp.CallToolRequest, args GetStyleProfileRequest) (*GetStyleProfileResponse, error) {
func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetStyleProfileRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
@ -545,15 +601,34 @@ func (s *Server) getStyleProfile(ctx context.Context, request mcp.CallToolReques
if err != nil {
return nil, err
}
return &GetStyleProfileResponse{
resp := GetStyleProfileResponse{
Author: styleProfile.UserID,
PersonaEmbedding: embedding,
TopKeywords: styleProfile.TopKeywords,
PostCount: int(styleProfile.PostCount),
StyleSummary: styleProfile.StyleSummary,
}, err
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
switch platform {
case Bluesky:
}
res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, 50)
if err != nil {
return nil, err
@ -604,16 +679,16 @@ func (s *Server) getStyleProfile(ctx context.Context, request mcp.CallToolReques
SourcePlatform: "bluesky",
}
return profile, nil
}
func newUSDCPaymentRequirement(config x402go.USDCRequirementConfig, schema *x402go.OutputSchema) (x402go.PaymentRequirement, error) {
req, err := x402go.NewUSDCPaymentRequirement(config)
profileBytes, err := json.Marshal(profile)
if err != nil {
return x402go.PaymentRequirement{}, err
return nil, err
}
req.OutputSchema = schema
return req, nil
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(profileBytes)},
},
}, nil
}
func determinePlatform(author string) (platform, error) {

View file

@ -72,3 +72,16 @@ type DCAPMessage struct {
TS int64 `json:"ts" jsonschema_description:"Unix timestamp of the message"`
SID string `json:"sid" jsonschema_description:"Server ID, 8-12 characters"`
}
type platform int
const (
ActivityPub platform = iota
Bluesky
Nostr
Twitter
)
func (p platform) String() string {
return [...]string{"ActivityPub", "Bluesky", "Nostr", "Twitter"}[p]
}

View file

@ -1,8 +1,6 @@
package x402
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -10,89 +8,44 @@ import (
"net"
"net/http"
"os"
"reflect"
"strings"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp"
x402mcp "github.com/x402-foundation/x402/go/v2/mcp"
evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server"
)
type ToolHandlerFunc func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, error)
type X402Middlewares struct {
MCPMiddleware []mcp.Middleware
HTTPMiddleware []func(http.Handler) http.Handler
}
type HTTPMiddleware func(http.Handler) http.Handler
type ToolInfo struct {
Tool mcp.Tool
IsPayable bool
Requirements []x402.PaymentRequirements
Handler mcp.ToolHandler
}
type X402Server struct {
mcpServer *mcp.Server
x402ResourceServer *x402.X402ResourceServer
facilitatorClient *x402http.HTTPFacilitatorClient
x402Middlewares map[string]func(http.Handler) http.Handler
mux *http.ServeMux
name string
version string
middlewareChain func(http.Handler) http.Handler
AllEndpoints []string
Handlers map[string]ToolHandlerFunc
tools []ToolInfo
}
func (s *X402Server) AddTool(tool mcp.Tool, handler ToolHandlerFunc) {
mcp.AddTool(s.mcpServer, &tool, func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
result, err := handler(ctx, req, args)
return result, nil, err
})
s.AllEndpoints = append(s.AllEndpoints, tool.Name)
s.Handlers[tool.Name] = handler
s.x402Middlewares[tool.Name] = noOpMiddleware
}
func (s *X402Server) AddPayableTool(
tool mcp.Tool,
handler ToolHandlerFunc,
requirements ...x402.PaymentRequirements,
) {
s.AddTool(tool, handler)
if len(requirements) == 0 {
log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
}
var options x402http.PaymentOptions
for _, req := range requirements {
options = append(options, x402http.PaymentOptions{x402http.PaymentOption{
Scheme: req.Scheme,
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
},
}...)
}
s.AllEndpoints = append(s.AllEndpoints, tool.Name)
s.x402Middlewares[tool.Name] = nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + tool.Name: {
Accepts: options,
},
},
Facilitator: s.facilitatorClient,
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
})
}
func noOpMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
func NewX402Server(name, version string, facilitatorURL string, mws X402Middlewares) (*X402Server, error) {
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares) (*X402Server, error) {
mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: name,
@ -108,7 +61,8 @@ func NewX402Server(name, version string, facilitatorURL string, mws X402Middlewa
facilitatorURL = "https://facilitator.mogami.tech"
}
facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
URL: facilitatorURL,
URL: facilitatorURL,
AuthProvider: auth,
})
resourceServer := x402.Newx402ResourceServer(
@ -117,129 +71,219 @@ func NewX402Server(name, version string, facilitatorURL string, mws X402Middlewa
resourceServer.Register("eip155:8453", evm.NewExactEvmScheme())
httpChain := chainHTTPMiddlewares(mws.HTTPMiddleware...)
return &X402Server{
s := &X402Server{
mcpServer: mcpServer,
x402ResourceServer: resourceServer,
facilitatorClient: facilitator,
x402Middlewares: make(map[string]func(http.Handler) http.Handler),
middlewareChain: httpChain,
Handlers: make(map[string]ToolHandlerFunc),
}, nil
}
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
func (s *X402Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
toolName := strings.TrimPrefix(r.URL.Path, "/")
addr := "@"
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
ip := strings.Split(forwarded, ",")[0]
addr = strings.TrimSpace(ip)
} else {
realIP := r.Header.Get("X-Real-Ip")
if realIP != "" {
addr = realIP
}
mux: http.NewServeMux(),
name: name,
version: version,
middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...),
tools: make([]ToolInfo, 0),
}
if r.URL.Path == "/healthz" {
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
return
}
})
log.InfoWithContext(r.Context(), "Request received", "host", r.Host, "method", r.Method, "path", r.URL.Path, "remoteAddr", addr)
s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI)
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
return s.mcpServer
}, nil)
s.mux.Handle("/mcp", mcpHandler)
if handler, ok := s.Handlers[toolName]; ok {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.WarnWithContext(r.Context(), "Failed to read request body", "error", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var args map[string]any
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &args); err != nil {
log.WarnWithContext(r.Context(), "Failed to parse request body as JSON", "tool", toolName, "error", err)
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}
} else {
log.DebugWithContext(r.Context(), "Empty request body for tool", "tool", toolName)
args = make(map[string]any)
}
// Store parsed args in context
type contextKey string
const argsKey contextKey = "parsed_args"
ctx := context.WithValue(r.Context(), argsKey, args)
r = r.WithContext(ctx)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body for further reading
functionHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
args := r.Context().Value(argsKey).(map[string]any)
argsJSON, err := json.Marshal(args)
if err != nil {
log.WarnWithContext(r.Context(), "Failed to marshal args to raw JSON", "error", err)
http.Error(w, "Failed to encode arguments", http.StatusInternalServerError)
return
}
callReq := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: toolName,
Arguments: json.RawMessage(argsJSON),
},
}
resp, err := handler(r.Context(), callReq, args)
if err != nil {
log.WarnWithContext(r.Context(), "Tool handler error in direct handler", "tool", toolName, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respBytes, err := json.Marshal(resp)
if err != nil {
log.WarnWithContext(r.Context(), "Failed to marshal response", "error", err)
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(respBytes)
})
middleware, ok := s.x402Middlewares[toolName]
if !ok {
log.ErrorWithContext(r.Context(), "No X402 middleware found for tool", "tool", toolName)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
s.middlewareChain(middleware(functionHandler)).ServeHTTP(w, r)
} else {
http.NotFound(w, r)
}
return s, nil
}
func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
s.mcpServer.AddTool(&tool, handler)
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: false,
Handler: handler,
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /v1/"+tool.Name, s.middlewareChain(restHandler))
}
func (s *X402Server) AddPayableTool(
tool mcp.Tool,
handler mcp.ToolHandler,
requirements ...x402.PaymentRequirements,
) {
s.AddPayableTool(tool, handler, requirements...)
if len(requirements) == 0 {
log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
}
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{
Accepts: requirements,
Resource: &x402mcp.ResourceInfo{
URL: "mcp://tool/" + tool.Name,
Description: tool.Description,
},
})
s.mcpServer.AddTool(&tool, wrapper.Wrap(handler))
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: true,
Requirements: requirements,
Handler: handler,
})
var options x402http.PaymentOptions
for _, req := range requirements {
options = append(options, x402http.PaymentOption{
Scheme: req.Scheme,
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
})
}
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + tool.Name: {
Accepts: options,
Description: tool.Description,
},
},
Facilitator: s.facilitatorClient,
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
chained := s.middlewareChain(restHandler)
s.mux.Handle("POST /"+tool.Name, paymentMiddleware(chained))
}
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
callReq := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: toolName,
Arguments: json.RawMessage(bodyBytes),
},
}
resp, err := handler(r.Context(), callReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if len(resp.Content) == 1 {
if textContent, ok := resp.Content[0].(*mcp.TextContent); ok {
var js json.RawMessage
if json.Unmarshal([]byte(textContent.Text), &js) == nil {
w.Write([]byte(textContent.Text))
return
}
}
}
json.NewEncoder(w).Encode(resp)
}
func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) {
s.x402ResourceServer.OnAfterSettle(hook)
}
func noOpMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
func (s *X402Server) serveOpenAPI(w http.ResponseWriter, r *http.Request) {
paths := make(map[string]any)
for _, info := range s.tools {
inputSchema := parseSchema(info.Tool.InputSchema)
outputSchema := parseSchema(info.Tool.OutputSchema)
postOp := map[string]any{
"summary": info.Tool.Description,
"description": fmt.Sprintf("Exposes the MCP tool '%s' as a RESTful endpoint.", info.Tool.Name),
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": inputSchema,
},
},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Successful operation",
"content": map[string]any{
"application/json": map[string]any{
"schema": outputSchema,
},
},
},
},
}
if info.IsPayable {
postOp["description"] = fmt.Sprintf("%s\n\n**Paid Route**: Requires payments via x402.", postOp["description"])
postOp["responses"].(map[string]any)["402"] = map[string]any{
"description": "Payment Required. Responds with the PAYMENT-REQUIRED header challenge.",
"headers": map[string]any{
"PAYMENT-REQUIRED": map[string]any{
"schema": map[string]any{"type": "string"},
"description": "Base64 encoded PaymentRequired challenge matching the x402 specification.",
},
},
}
}
paths["/"+info.Tool.Name] = map[string]any{
"post": postOp,
}
}
openapiDoc := map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": s.name,
"version": s.version,
"description": "Auto-generated REST API matching active Model Context Protocol (MCP) tools.",
},
"paths": paths,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(openapiDoc)
}
// Start starts the x402 server on the specified address
func (s *X402Server) Start(addr string, permissions os.FileMode) error {
var (
listener net.Listener
@ -258,10 +302,8 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
}
log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath)
log.Info("MCP endpoint (unix socket)", "socketPath", socketPath)
} else {
log.Info("Starting X402 MCP Server on TCP", "addr", addr)
log.Infof("MCP endpoint: http://localhost%s", addr)
listener, err = net.Listen("tcp", addr)
if err != nil {
@ -271,8 +313,66 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
defer listener.Close()
srv := &http.Server{
Handler: s,
Handler: s.mux,
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
}
return srv.Serve(listener)
}
func toJSONSchema(val any) json.RawMessage {
if val == nil {
return nil
}
switch v := val.(type) {
case json.RawMessage:
return v
case []byte:
return json.RawMessage(v)
case string:
return json.RawMessage(v)
}
t := reflect.TypeOf(val)
schema, err := jsonschema.ForType(t, &jsonschema.ForOptions{})
if err != nil {
return nil
}
bytes, err := json.Marshal(schema)
if err != nil {
return nil
}
return bytes
}
func parseSchema(schemaAny any) any {
if schemaAny == nil {
return map[string]any{"type": "object"}
}
switch v := schemaAny.(type) {
case json.RawMessage:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case []byte:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case string:
var m any
if err := json.Unmarshal([]byte(v), &m); err == nil {
return m
}
}
return schemaAny
}
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}