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 472 additions and 214 deletions
Showing only changes of commit 252eede995 - Show all commits

add x402 unit test
All checks were successful
CI/CD Go Verification / build_and_test (pull_request) Successful in 59s

add test workflow
Nacorid 2026-07-29 14:09:49 +00:00
Signed by: nacorid
SSH key fingerprint: SHA256:zAJkAgjXXOAJqP6R2fp8eKCNlnKgpf33G/Baa1xtNGA

View file

@ -0,0 +1,36 @@
name: CI/CD Go Verification
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main ]
jobs:
build_and_test:
runs-on: node20
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Install dependencies
run: go mod download
- name: Check code formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Code is not formatted correctly. Run 'gofmt -w .'"
exit 1
fi
- name: Build Application
run: go build -v ./...
- name: Run Tests (with Mocked Boundaries)
run: go test -v -cover ./...

2
go.mod
View file

@ -10,7 +10,7 @@ require (
github.com/lib/pq v1.10.9
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
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80
github.com/ollama/ollama v0.12.10
github.com/x402-foundation/x402/go/v2 v2.19.0
golang.org/x/time v0.14.0

4
go.sum
View file

@ -248,8 +248,8 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0 h1:vKVU0JbEOiG7xI+UNwb8M72mo98uHCiPNyPUd9oBC9c=
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI=
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA=
github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=

View file

@ -20,6 +20,7 @@ import (
"github.com/nacorid/naco-api/internal/utils"
"github.com/nacorid/naco-api/internal/x402"
x402v2 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
"github.com/x402-foundation/x402/go/v2/types"
)
@ -58,69 +59,30 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
cfg.X402FacilitatorURL,
auth,
toolChain,
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
requirements, ok := x402http.RequirementsFromContext(r.Context())
if !ok {
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during settlement hook")
return
}
amount := requirements.Amount
asset := requirements.Asset
to := requirements.PayTo
_ = settleHook(r.Context(), db, resp.Transaction, amount, asset, string(resp.Network), to, resp.Payer)
},
)
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
amount := settleCtx.Requirements.GetAmount()
asset := settleCtx.Requirements.GetAsset()
to := settleCtx.Requirements.GetPayTo()
from := settleCtx.Result.Payer
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
return settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from)
})
s := &Server{
@ -140,6 +102,43 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
}
func (s *Server) registerTools() {
extra := map[string]any{
"OutputSchema": Schema{
Input: 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]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",
},
},
},
}
requirement := types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -147,42 +146,7 @@ func (s *Server) registerTools() {
Amount: "10000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": Schema{
Input: 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]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",
},
},
},
},
Extra: extra,
}
requirements := []types.PaymentRequirements{requirement}
s.mcpHandler.AddPayableTool(
@ -195,6 +159,38 @@ func (s *Server) registerTools() {
requirements...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: 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,
},
},
},
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",
},
},
},
}
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -202,37 +198,7 @@ func (s *Server) registerTools() {
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": Schema{
Input: 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,
},
},
},
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",
},
},
},
},
Extra: extra,
}
requirements = []types.PaymentRequirements{requirement}
@ -247,6 +213,48 @@ func (s *Server) registerTools() {
requirements...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: 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",
},
},
},
}
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -254,47 +262,7 @@ func (s *Server) registerTools() {
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": Schema{
Input: 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",
},
},
},
},
Extra: extra,
}
requirements = []types.PaymentRequirements{requirement}
@ -309,6 +277,41 @@ func (s *Server) registerTools() {
requirements...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: 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 analyzed",
},
},
},
}
requirement = types.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -316,40 +319,7 @@ func (s *Server) registerTools() {
Amount: "100000",
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: map[string]any{
"OutputSchema": Schema{
Input: 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 analyzed",
},
},
},
},
Extra: extra,
}
requirements = []types.PaymentRequirements{requirement}
@ -729,6 +699,40 @@ func determinePlatform(author string) (platform, error) {
return -1, fmt.Errorf("unknown author format: %s", author)
}
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
X402TransactionHash: transaction,
Amount: amount,
Asset: asset,
Network: 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": transaction,
"amount": amount,
"asset": asset,
"network": network,
"recipient": to,
"sender": from,
}
if err != nil {
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err)
return err
}
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
return nil
}
func (s *Server) Start() error {
return s.mcpHandler.Start(s.listenAddress, s.permissions)
}

View file

@ -37,6 +37,7 @@ type X402Server struct {
mcpServer *mcp.Server
x402ResourceServer *x402.X402ResourceServer
facilitatorClient *x402http.HTTPFacilitatorClient
httpSettlementHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)
mux *http.ServeMux
name string
@ -45,7 +46,9 @@ type X402Server struct {
tools []ToolInfo
}
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares) (*X402Server, error) {
const VERSION1 = "v1/"
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, settleHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)) (*X402Server, error) {
mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: name,
@ -75,6 +78,7 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
mcpServer: mcpServer,
x402ResourceServer: resourceServer,
facilitatorClient: facilitator,
httpSettlementHook: settleHook,
mux: http.NewServeMux(),
name: name,
version: version,
@ -112,7 +116,7 @@ func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
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))
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(restHandler))
}
func (s *X402Server) AddPayableTool(
@ -157,7 +161,7 @@ func (s *X402Server) AddPayableTool(
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + tool.Name: {
"POST /" + VERSION1 + tool.Name: {
Accepts: options,
Description: tool.Description,
},
@ -166,13 +170,14 @@ func (s *X402Server) AddPayableTool(
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
SettlementHandler: s.httpSettlementHook,
})
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))
s.mux.Handle("POST /"+VERSION1+tool.Name, paymentMiddleware(chained))
}
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
@ -182,6 +187,8 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
return
}
log.DebugWithContext(r.Context(), "RESTful API request.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
callReq := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: toolName,
@ -215,15 +222,11 @@ 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)
log.DebugWithContext(r.Context(), "OpenAPI Spec requested.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
for _, info := range s.tools {
inputSchema := parseSchema(info.Tool.InputSchema)
outputSchema := parseSchema(info.Tool.OutputSchema)
@ -320,25 +323,34 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
func toJSONSchema(val any) json.RawMessage {
if val == nil {
return nil
return json.RawMessage(`{"type": "object"}`)
}
switch v := val.(type) {
case json.RawMessage:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return v
case []byte:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
case string:
if v == "" {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
}
t := reflect.TypeOf(val)
schema, err := jsonschema.ForType(t, &jsonschema.ForOptions{})
if err != nil {
return nil
return json.RawMessage(`{"type": "object"}`)
}
bytes, err := json.Marshal(schema)
if err != nil {
return nil
return json.RawMessage(`{"type": "object"}`)
}
return bytes
}

206
internal/x402/x402_test.go Normal file
View file

@ -0,0 +1,206 @@
package x402
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
)
type DummyAuthProvider struct{}
func (d *DummyAuthProvider) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
return x402http.AuthHeaders{}, nil
}
func TestX402Server_HTTPBoundaries(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
reqRec := httptest.NewRecorder()
reqHealth, _ := http.NewRequest("GET", "/healthz", nil)
srv.mux.ServeHTTP(reqRec, reqHealth)
if reqRec.Code != http.StatusOK {
t.Errorf("expected healthz status 200, got %d", reqRec.Code)
}
reqRecOpenAPI := httptest.NewRecorder()
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
if reqRecOpenAPI.Code != http.StatusOK {
t.Errorf("expected OpenAPI status 200, got %d", reqRecOpenAPI.Code)
}
if !strings.Contains(reqRecOpenAPI.Body.String(), "getTestPost") {
t.Error("OpenAPI spec did not render registered tool definitions")
}
reqRecPayable := httptest.NewRecorder()
reqPayable, _ := http.NewRequest("POST", "/v1/getTestPost", strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRecPayable, reqPayable)
if reqRecPayable.Code != http.StatusPaymentRequired {
t.Errorf("expected status 402 (Payment Required), got %d", reqRecPayable.Code)
}
payHeader := reqRecPayable.Header().Get("PAYMENT-REQUIRED")
if payHeader == "" {
t.Error("missing PAYMENT-REQUIRED response header on 402 challenge")
}
}
func TestX402Server_MCPBoundaries(t *testing.T) {
ctx := context.Background()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer clientSession.Close()
toolsResponse, err := clientSession.ListTools(ctx, nil)
if err != nil {
t.Fatalf("failed to list tools over MCP: %v", err)
}
found := false
for _, tool := range toolsResponse.Tools {
if tool.Name == "getTestPost" {
found = true
break
}
}
if !found {
t.Error("expected tool 'getTestPost' to be discovered by the MCP client, but it was missing")
}
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: "getTestPost",
Arguments: json.RawMessage(`{}`),
})
if err != nil {
t.Errorf("Unexpected protocol-level error: %v", err)
} else {
if !result.IsError {
t.Error("expected CallTool to return a payment challenge, but it returned a successful, free result")
} else {
t.Logf("Intercepted expected application-level payment challenge.")
t.Logf("Content: %v", result.Content)
t.Logf("Meta fields: %v", result.Meta)
}
}
}
func setupMockFacilitator() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && r.URL.Path == "/supported":
_, _ = w.Write([]byte(`{
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:8453"
}
]
}`))
case r.Method == "POST" && r.URL.Path == "/verify":
_, _ = w.Write([]byte(`{
"isValid": true,
"invalidReason": ""
}`))
case r.Method == "POST" && r.URL.Path == "/settle":
_, _ = w.Write([]byte(`{
"success": true,
"transaction": "0x1234abcd",
"network": "eip155:8453",
"payer": "0xabcd1234"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
return s
}