task/migrate-x402-v2 #1
6 changed files with 704 additions and 458 deletions
rewrite wrapper
migrate server from v1 to v2
commit
d5b16cba3a
12
go.mod
12
go.mod
|
|
@ -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
18
go.sum
|
|
@ -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
84
internal/server/auth.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue