Simplify the payable-tool registration API by merging AddPayableToolWithResolver into AddPayableTool, and thread a configurable MaxTimeoutSeconds plus Extra through the payment options. getStyleProfile keeps its 600s advertise timeout.
1028 lines
32 KiB
Go
1028 lines
32 KiB
Go
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)
|
|
}
|
|
}
|