package x402 import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "sync" "sync/atomic" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" x402http "github.com/x402-foundation/x402/go/v2/http" "github.com/x402-foundation/x402/go/v2/types" ) 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(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) 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 }, 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(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) 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 }, 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 func() { _ = 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 func() { _ = 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 TestX402Server_MCPChallengesAdvertiseConvertedAmounts(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(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453", "eip155:84532"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } srv.AddPayableTool( mcp.Tool{Name: "getExact", Description: "exact gated tool"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil }, nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", Amount: "0.01", PayTo: "0x0000000000000000000000000000000000000000", }, ) srv.AddPayableTool( mcp.Tool{Name: "getUpto", Description: "upto gated tool"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil }, nil, x402.PaymentRequirements{ Scheme: "upto", Network: "eip155:84532", Amount: "0.51", PayTo: "0x0000000000000000000000000000000000000000", }, ) serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil) if err != nil { t.Fatalf("failed to connect server session: %v", err) } defer func() { _ = 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 func() { _ = clientSession.Close() }() cases := []struct { tool string scheme string want string }{ {tool: "getExact", scheme: "exact", want: "10000"}, {tool: "getUpto", scheme: "upto", want: "510000"}, } for _, tc := range cases { t.Run(tc.tool, func(t *testing.T) { result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tc.tool, Arguments: json.RawMessage(`{}`)}) if err != nil { t.Fatalf("protocol-level error: %v", err) } if !result.IsError || len(result.Content) == 0 { t.Fatalf("expected a payment challenge for %s, got %+v", tc.tool, result) } text, ok := result.Content[0].(*mcp.TextContent) if !ok { t.Fatalf("unexpected content type %T", result.Content[0]) } var pr types.PaymentRequired if err := json.Unmarshal([]byte(text.Text), &pr); err != nil { t.Fatalf("failed to decode payment challenge: %v", err) } if len(pr.Accepts) == 0 { t.Fatalf("expected at least one accept in challenge, got %d", len(pr.Accepts)) } if got := pr.Accepts[0].Amount; got != tc.want { t.Errorf("accept amount = %q, want %q (converted to smallest units)", got, tc.want) } if got := pr.Accepts[0].Scheme; got != tc.scheme { t.Errorf("accept scheme = %q, want %q", got, tc.scheme) } }) } } // toggleFacilitatorServer is a mock facilitator whose availability can be // flipped at runtime to simulate an outage and recovery without a restart. type toggleFacilitatorServer struct { available atomic.Bool srv *httptest.Server } func newToggleFacilitatorServer(t *testing.T) *toggleFacilitatorServer { f := &toggleFacilitatorServer{} f.available.Store(true) f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !f.available.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } 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) } })) t.Cleanup(f.srv.Close) return f } func TestX402Server_MCPVerifyResolvesFacilitator(t *testing.T) { ctx := context.Background() f := newToggleFacilitatorServer(t) srv, err := NewX402Server( "Test Agent API", "1.0.0", f.srv.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { t.Fatal("expected a facilitator client resolved for eip155:8453/exact after construction") } req := x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", Amount: "10000", Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", PayTo: "0x0000000000000000000000000000000000000000", } payload := types.PaymentPayload{ X402Version: 2, Payload: map[string]interface{}{"chainId": 1}, Accepted: req, } resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) if err != nil { t.Fatalf("VerifyPayment should succeed once a facilitator is registered, got: %v", err) } if resp == nil || !resp.IsValid { t.Errorf("expected VerifyPayment to return isValid=true, got %+v", resp) } } func TestX402Server_MCPVerifySelfHeals(t *testing.T) { ctx := context.Background() f := newToggleFacilitatorServer(t) f.available.Store(false) // facilitator down at boot srv, err := NewX402Server( "Test Agent API", "1.0.0", f.srv.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } if srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { t.Fatal("expected no facilitator client while the facilitator is down") } req := x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", Amount: "10000", Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", PayTo: "0x0000000000000000000000000000000000000000", } payload := types.PaymentPayload{ X402Version: 2, Payload: map[string]interface{}{"chainId": 1}, Accepted: req, } _, verifyErr := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) if verifyErr == nil || !strings.Contains(verifyErr.Error(), x402.ErrNoFacilitatorForNetwork) { t.Fatalf("expected ErrNoFacilitatorForNetwork while facilitator is down, got: %v", verifyErr) } // Facilitator comes back; no restart required. f.available.Store(true) srv.ensureFacilitatorReady(ctx) if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { t.Fatal("expected facilitator client to be resolved after recovery") } resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) if err != nil { t.Fatalf("VerifyPayment should succeed after recovery, got: %v", err) } if resp == nil || !resp.IsValid { t.Errorf("expected VerifyPayment to return isValid=true after recovery, got %+v", resp) } } func TestComputeTwitterStyleProfileCost(t *testing.T) { cases := []struct { name string platform string postCount int want string }{ {name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.25"}, {name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.31"}, {name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.26"}, {name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.51"}, {name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.275"}, {name: "posts at the max counted cap", platform: "Twitter", postCount: 100, want: "$0.76"}, {name: "beyond cap is not clamped", platform: "Twitter", postCount: 200, want: "$1.26"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} payload, _ := json.Marshal(resp) r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) got := computeTwitterStyleProfileCost(r, string(payload)) if got != tc.want { t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) } }) } } // Verify that every Twitter charge strictly exceeds the flat base cost, so the // endpoint always clears the underlying API cost (never break-even or loss). func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) { for postCount := 0; postCount <= 50; postCount++ { resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount} payload, _ := json.Marshal(resp) r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) got := computeTwitterStyleProfileCost(r, string(payload)) // Cut the "$" prefix and compare numerically to BaseProfileCostMicro. dollars := strings.TrimPrefix(got, "$") parsed, err := strconv.ParseFloat(dollars, 64) if err != nil { t.Fatalf("failed to parse %q: %v", got, err) } micro := int64(parsed * 1_000_000) if micro <= BaseProfileCostMicro { t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, BaseProfileCostMicro) } } } 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" }, { "x402Version": 2, "scheme": "upto", "network": "eip155:84532" } ] }`)) 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 } func TestAddTool_NonPayable(t *testing.T) { mockFacilitator := setupMockFacilitator() defer mockFacilitator.Close() srv, err := NewX402Server( "Test Agent API", "1.0.0", mockFacilitator.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } srv.AddTool( mcp.Tool{ Name: "freeTool", Description: "Free test endpoint", }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}}, }, nil }, ) // Test HTTP endpoint works without payment reqRec := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`)) srv.mux.ServeHTTP(reqRec, req) if reqRec.Code != http.StatusOK { t.Errorf("expected status 200 for free tool, got %d", reqRec.Code) } // Test OpenAPI includes non-payable tool reqRecOpenAPI := httptest.NewRecorder() reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil) srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI) if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") { t.Error("OpenAPI spec did not render non-payable tool") } // Should not have 402 response for free tool if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) { t.Error("OpenAPI spec should not have 402 for free tool") } } func TestAddPayableTool_EmptyRequirements(t *testing.T) { mockFacilitator := setupMockFacilitator() defer mockFacilitator.Close() srv, err := NewX402Server( "Test Agent API", "1.0.0", mockFacilitator.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } // The library validates requirements at AddPayableTool call time // and returns an error (or panics via log.Fatal) for empty requirements defer func() { if r := recover(); r != nil { errMsg := fmt.Sprintf("%v", r) // The library may panic with log.Fatal or return an error if !strings.Contains(errMsg, "requires at least one payment requirement") && !strings.Contains(errMsg, "Accepts must have at least one") { t.Errorf("expected panic about empty requirements, got: %v", r) } } else { t.Error("expected panic for empty payment requirements") } }() srv.AddPayableTool( mcp.Tool{ Name: "badTool", Description: "Bad endpoint", }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, }, nil }, nil, // No requirements provided ) } func TestX402Server_MultipleTools(t *testing.T) { mockFacilitator := setupMockFacilitator() defer mockFacilitator.Close() srv, err := NewX402Server( "Test Agent API", "1.0.0", mockFacilitator.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } // Add multiple tools toolNames := []string{"tool1", "tool2", "tool3"} for _, name := range toolNames { name := name srv.AddPayableTool( mcp.Tool{ Name: name, Description: "Tool " + name, }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}}, }, nil }, nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Amount: "1000", PayTo: "0x0000000000000000000000000000000000000000", }, ) } // Verify all tools are registered in OpenAPI reqRec := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/openapi.json", nil) srv.mux.ServeHTTP(reqRec, req) body := reqRec.Body.String() for _, name := range toolNames { if !strings.Contains(body, name) { t.Errorf("OpenAPI missing tool: %s", name) } } // Verify each tool requires payment for _, name := range toolNames { reqRec := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`)) srv.mux.ServeHTTP(reqRec, req) if reqRec.Code != http.StatusPaymentRequired { t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code) } } } func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) { cases := []struct { name string platform string postCount int want string }{ {name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.25"}, // not "Twitter" so flat base {name: "empty platform", platform: "", postCount: 10, want: "$0.25"}, {name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.26"}, // should clamp to 0 {name: "few posts", platform: "Twitter", postCount: 1, want: "$0.265"}, {name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.26"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} payload, _ := json.Marshal(resp) r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) got := computeTwitterStyleProfileCost(r, string(payload)) if got != tc.want { t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) } }) } } // TestStyleProfileMaxPriceStaysAdvertised guards the advertised getStyleProfile // price in internal/server/tools.go against silent drift from the x402 pricing // model. internal/server shares the x402 constants (BaseProfileCostMicro / // MaxProfileCountedPosts) to compute the upto maximum, so this only needs to // catch a hand-edited Price string going out of sync. func TestStyleProfileMaxPriceStaysAdvertised(t *testing.T) { if got := formatDollarAmount(maxProfileCostMicro); got != "$0.76" { t.Fatalf("maxProfileCostMicro = %d (%s), want $0.76; update getStyleProfile base Price in internal/server/tools.go if the pricing model changed", maxProfileCostMicro, got) } } // TestX402Server_MCPStyleProfileSettlementOverride drives the full MCP flow for // the getStyleProfile upto tool and asserts the facilitator is told to settle // the computed per-request cost (not the advertised $0.76 maximum). func TestX402Server_MCPStyleProfileSettlementOverride(t *testing.T) { ctx := context.Background() var mu sync.Mutex var settleAmounts []string mock := 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": "upto", "network": "eip155:84532"} ] }`)) case r.Method == "POST" && r.URL.Path == "/verify": _, _ = w.Write([]byte(`{"isValid": true, "invalidReason": ""}`)) case r.Method == "POST" && r.URL.Path == "/settle": var body struct { PaymentRequirements map[string]json.RawMessage `json:"paymentRequirements"` } _ = json.NewDecoder(r.Body).Decode(&body) if raw := body.PaymentRequirements["amount"]; len(raw) > 0 { var s string var f float64 mu.Lock() if json.Unmarshal(raw, &s) == nil { settleAmounts = append(settleAmounts, s) } else if json.Unmarshal(raw, &f) == nil { settleAmounts = append(settleAmounts, strconv.FormatFloat(f, 'f', -1, 64)) } mu.Unlock() } _, _ = w.Write([]byte(`{ "success": true, "transaction": "0x1234abcd", "network": "eip155:84532", "payer": "0xabcd1234" }`)) default: w.WriteHeader(http.StatusNotFound) } })) defer mock.Close() serverTransport, clientTransport := mcp.NewInMemoryTransports() srv, err := NewX402Server( "Test Agent API", "1.0.0", mock.URL, &DummyAuthProvider{}, X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, []string{"eip155:84532"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) } responses := []GetStyleProfileResponse{ {Author: "@a", TopKeywords: []string{}, StyleSummary: "s", PostCount: 10, SourcePlatform: "Bluesky"}, {Author: "@b", TopKeywords: []string{}, StyleSummary: "s", PostCount: 50, SourcePlatform: "Twitter"}, } var idx int srv.AddPayableTool( mcp.Tool{Name: "getStyleProfile", Description: "get a style profile"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { resp := responses[idx%len(responses)] idx++ b, _ := json.Marshal(resp) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil }, NewStyleProfileCostResolver(), x402.PaymentRequirements{ Scheme: "upto", Network: "eip155:84532", Amount: "0.76", PayTo: "0x0000000000000000000000000000000000000000", }, ) serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil) if err != nil { t.Fatalf("failed to connect server session: %v", err) } defer func() { _ = 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 func() { _ = clientSession.Close() }() paidCall := func() (*mcp.CallToolResult, error) { challenge, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "getStyleProfile", Arguments: json.RawMessage(`{}`)}) if err != nil { return nil, fmt.Errorf("challenge call failed: %w", err) } if !challenge.IsError { return nil, fmt.Errorf("expected a payment challenge, got %+v", challenge) } text, ok := challenge.Content[0].(*mcp.TextContent) if !ok { return nil, fmt.Errorf("unexpected challenge content type %T", challenge.Content[0]) } var pr types.PaymentRequired if err := json.Unmarshal([]byte(text.Text), &pr); err != nil { return nil, fmt.Errorf("failed to decode payment challenge: %w", err) } if len(pr.Accepts) == 0 { return nil, fmt.Errorf("expected at least one accept, got %d", len(pr.Accepts)) } payload := types.PaymentPayload{ X402Version: 2, Payload: map[string]interface{}{"chainId": 84532}, Accepted: pr.Accepts[0], } return clientSession.CallTool(ctx, &mcp.CallToolParams{ Name: "getStyleProfile", Arguments: json.RawMessage(`{}`), Meta: mcp.Meta{PaymentMetaKey: payload}, }) } for i := 0; i < 2; i++ { result, err := paidCall() if err != nil { t.Fatalf("paid call: %v", err) } if result.IsError { text, _ := result.Content[0].(*mcp.TextContent) t.Fatalf("expected successful paid call, got error result: %+v", text) } if resp := result.Meta[PaymentResponseMetaKey]; resp == nil { t.Fatalf("expected %q in result meta", PaymentResponseMetaKey) } } mu.Lock() got := append([]string(nil), settleAmounts...) mu.Unlock() // The "$0.25" / "$0.51" overrides resolve to 250000 / 510000 atomic units, // with up to 1 micro tolerance for float rounding in the SDK's dollar parser. wants := []int64{250000, 510000} if len(got) != len(wants) { t.Fatalf("settle amount count = %d (%v), want %d: %v", len(got), got, len(wants), wants) } for i, want := range wants { v, err := strconv.ParseInt(got[i], 10, 64) if err != nil { t.Fatalf("settle amount[%d] = %q is not an integer: %v", i, got[i], err) } diff := v - want if diff < 0 { diff = -diff } if diff > 1 { t.Errorf("settle amount[%d] = %d, want ~%d", i, v, want) } } } func TestNewStyleProfileCostResolver(t *testing.T) { resolver := NewStyleProfileCostResolver() cases := []struct { name string resp GetStyleProfileResponse want string }{ {name: "bluesky base", resp: GetStyleProfileResponse{SourcePlatform: "Bluesky", PostCount: 10}, want: "$0.25"}, {name: "twitter with posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 50}, want: "$0.51"}, {name: "twitter no posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 0}, want: "$0.26"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { payload, _ := json.Marshal(tc.resp) result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}} got, ok := resolver(result) if !ok { t.Fatalf("resolver returned ok=false") } if got != tc.want { t.Fatalf("resolver = %q, want %q", got, tc.want) } }) } // Non-text content resolves to "no override"; malformed JSON falls back to // the flat base cost via computeTwitterStyleProfileCost. if _, ok := resolver(&mcp.CallToolResult{}); ok { t.Fatalf("expected no override for empty content") } got, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}) if !ok { t.Fatalf("expected a base-cost fallback for malformed JSON") } if got != "$0.25" { t.Fatalf("malformed JSON resolver = %q, want base fallback $0.25", got) } } // TestNewTwitterCountCostResolver verifies the actual-cost settlement for // tools whose responses carry a `twitterPostCount` field (analytics tools, // findSimilarUsers): flat base price when no Twitter posts went into the // response, and base + lookup + per-post when they did. func TestNewTwitterCountCostResolver(t *testing.T) { const baseMicro = 150_000 // getEngagementMetrics base resolver := NewTwitterCountCostResolver(baseMicro) cases := []struct { name string twitterCount int want string }{ {name: "no twitter posts", twitterCount: 0, want: "$0.15"}, {name: "one twitter post", twitterCount: 1, want: "$0.165"}, {name: "ten twitter posts", twitterCount: 10, want: "$0.21"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "metrics": []any{}, "twitterPostCount": tc.twitterCount, }) result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}} got, ok := resolver(result) if !ok { t.Fatalf("resolver returned ok=false") } if got != tc.want { t.Fatalf("resolver = %q, want %q", got, tc.want) } }) } if _, ok := resolver(&mcp.CallToolResult{}); ok { t.Fatalf("expected no override for empty content") } if _, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}); ok { t.Fatalf("expected no override for malformed JSON") } } // TestBuildSchemeConfigs verifies scheme servers are derived from the payment // requirements rather than the tool name, so upto is only registered for tools // that actually declare it. func TestBuildSchemeConfigs(t *testing.T) { req := func(scheme string) x402.PaymentRequirements { return x402.PaymentRequirements{Scheme: scheme, Network: "eip155:8453", Asset: "usdc", Amount: "1", PayTo: "to"} } t.Run("exact only", func(t *testing.T) { got := buildSchemeConfigs([]string{"eip155:8453"}, req("exact")) if len(got) != 1 || got[0].Network != x402.Network("eip155:8453") { t.Fatalf("expected 1 exact scheme for one network, got %+v", got) } }) t.Run("upto only", func(t *testing.T) { got := buildSchemeConfigs([]string{"eip155:8453"}, req("upto")) if len(got) != 1 { t.Fatalf("expected 1 upto scheme, got %d", len(got)) } }) t.Run("both schemes per network", func(t *testing.T) { got := buildSchemeConfigs([]string{"eip155:8453", "eip155:84532"}, req("exact"), req("upto")) if len(got) != 4 { t.Fatalf("expected 2 schemes x 2 networks = 4, got %d", len(got)) } }) } func TestPricingHelpers(t *testing.T) { if got := FormatPriceMicro(MaxTwitterCostMicro(BaseProfileCostMicro, MaxProfileCountedPosts)); got != "0.76" { t.Fatalf("style profile upto max = %q, want 0.76", got) } if got := FormatPriceMicro(MaxTwitterCostMicro(150_000, MaxAnalyticsCountedPosts)); got != "1.16" { t.Fatalf("engagement metrics upto max = %q, want 1.16", got) } if got := MicroFromPriceString("0.25"); got != BaseProfileCostMicro { t.Fatalf("MicroFromPriceString(0.25) = %d, want %d", got, BaseProfileCostMicro) } if got := MicroFromPriceString("garbage"); got != 0 { t.Fatalf("MicroFromPriceString(garbage) = %d, want 0", got) } }