naco-api/internal/x402/x402_test.go
Nacorid f4ae7d9c9f
refactor(x402): fold resolver into AddPayableTool and honor per-tool timeouts
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.
2026-09-13 22:24:34 +00:00

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)
}
}