Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: changed
body: Size MCP paid tool-call timeouts from the accept's maxTimeoutSeconds (default 300s) and use a 300s ceiling for the initial 402 probe.
time: 2026-09-10T07:00:00Z
26 changes: 24 additions & 2 deletions go/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
Expand Down Expand Up @@ -83,13 +84,18 @@ func (c *X402MCPClient) OnAfterPayment(hook AfterPaymentHook) *X402MCPClient {
}

// CallTool calls a tool with automatic payment handling.
// The initial 402 probe uses a 300s timeout unless ctx already has a deadline.
// Paid retries size their timeout from the accept's maxTimeoutSeconds (default 300s).
func (c *X402MCPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*MCPToolCallResult, error) {
params := &mcp.CallToolParams{
Name: name,
Arguments: args,
}

result, err := c.caller.CallTool(ctx, params)
probeCtx, probeCancel := withTimeoutIfNone(ctx, 300*time.Second)
defer probeCancel()

result, err := c.caller.CallTool(probeCtx, params)
if err != nil {
return nil, fmt.Errorf("tool call failed: %w", err)
}
Expand Down Expand Up @@ -200,7 +206,14 @@ func (c *X402MCPClient) callToolWithPayload(ctx context.Context, name string, ar
Meta: mcp.Meta{MCP_PAYMENT_META_KEY: payload},
}

result, err := c.caller.CallTool(ctx, params)
timeoutSeconds := payload.Accepted.MaxTimeoutSeconds
if timeoutSeconds == 0 {
timeoutSeconds = 300
}
paidCtx, paidCancel := withTimeoutIfNone(ctx, time.Duration(timeoutSeconds)*time.Second)
defer paidCancel()

result, err := c.caller.CallTool(paidCtx, params)
if err != nil {
return nil, fmt.Errorf("paid tool call failed: %w", err)
}
Expand Down Expand Up @@ -656,3 +669,12 @@ func paymentRequiredV1ToView(pr *types.PaymentRequiredV1) types.PaymentRequired
}
return types.PaymentRequired{X402Version: 1, Error: pr.Error, Accepts: accepts}
}

// withTimeoutIfNone applies timeout when ctx has no deadline. A caller deadline
// is treated as an explicit timeout override.
func withTimeoutIfNone(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
}
148 changes: 148 additions & 0 deletions go/mcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
Expand All @@ -20,9 +21,18 @@ type mockMCPCaller struct {
callToolResults []MCPToolResult // For multi-call scenarios
callToolErrors []error // For multi-call scenarios
callCount int
timeouts []time.Duration // remaining until ctx deadline at each CallTool; 0 if none
hasDeadline []bool
}

func (m *mockMCPCaller) CallTool(ctx context.Context, params *mcp.CallToolParams) (*mcp.CallToolResult, error) {
if d, ok := ctx.Deadline(); ok {
m.hasDeadline = append(m.hasDeadline, true)
m.timeouts = append(m.timeouts, time.Until(d))
} else {
m.hasDeadline = append(m.hasDeadline, false)
m.timeouts = append(m.timeouts, 0)
}
var mcpResult MCPToolResult
if len(m.callToolResults) > 0 {
idx := m.callCount
Expand Down Expand Up @@ -1097,3 +1107,141 @@ func (m *mockSchemeNetworkClientV1) FindDefaultAsset(asset string, network x402.
func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1) (types.PaymentPayloadV1, error) {
return types.PaymentPayloadV1{X402Version: 1, Scheme: m.scheme, Network: requirements.Network, Payload: map[string]interface{}{"signature": "0xmock"}}, nil
}

func assertApproxTimeout(t *testing.T, got, want time.Duration) {
t.Helper()
delta := got - want
if delta < 0 {
delta = -delta
}
if delta > 2*time.Second {
t.Errorf("timeout remaining %v, want ~%v", got, want)
}
}

func TestX402MCPClient_CallTool_ProbeTimeoutDefault300s(t *testing.T) {
mockCaller := &mockMCPCaller{
callToolResult: MCPToolResult{
Content: []MCPContentItem{{Type: "text", Text: "pong"}},
},
}
client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{})

if _, err := client.CallTool(context.Background(), "ping", map[string]interface{}{}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] {
t.Fatal("expected probe CallTool ctx to have a deadline")
}
assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second)
}

func TestX402MCPClient_CallTool_PaidTimeoutFromAcceptMaxTimeoutSeconds(t *testing.T) {
paymentRequired := types.PaymentRequired{
X402Version: 2,
Accepts: []types.PaymentRequirements{{
Scheme: "exact",
Network: "eip155:84532",
Amount: "1000",
Asset: "USDC",
PayTo: "0xrecipient",
MaxTimeoutSeconds: 120,
}},
}
structuredBytes, _ := json.Marshal(paymentRequired)
var structuredContent map[string]interface{}
if err := json.Unmarshal(structuredBytes, &structuredContent); err != nil {
t.Fatalf("Failed to unmarshal structured content: %v", err)
}

mockCaller := &mockMCPCaller{
callToolResults: []MCPToolResult{
{IsError: true, StructuredContent: structuredContent},
{
Content: []MCPContentItem{{Type: "text", Text: "success"}},
Meta: map[string]interface{}{
MCP_PAYMENT_RESPONSE_META_KEY: map[string]interface{}{
"success": true, "transaction": "0xtxhash", "network": "eip155:84532",
},
},
},
},
}
paymentClient := x402.Newx402Client()
paymentClient.Register("eip155:84532", &mockSchemeNetworkClient{scheme: "exact"})
client := NewX402MCPClient(mockCaller, paymentClient, Options{AutoPayment: BoolPtr(true)})

if _, err := client.CallTool(context.Background(), "paid_tool", map[string]interface{}{}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockCaller.hasDeadline) != 2 {
t.Fatalf("expected 2 CallTool ctxs, got %d", len(mockCaller.hasDeadline))
}
if !mockCaller.hasDeadline[0] || !mockCaller.hasDeadline[1] {
t.Fatal("expected probe and paid CallTool ctxs to have deadlines")
}
assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second)
assertApproxTimeout(t, mockCaller.timeouts[1], 120*time.Second)
}

func TestX402MCPClient_CallToolWithPayment_TimeoutFromAccept(t *testing.T) {
mockCaller := &mockMCPCaller{
callToolResult: MCPToolResult{
Content: []MCPContentItem{{Type: "text", Text: "success"}},
},
}
client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{})
payload := types.PaymentPayload{
X402Version: 2,
Accepted: types.PaymentRequirements{MaxTimeoutSeconds: 90},
Payload: map[string]interface{}{"signature": "0x123"},
}

if _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] {
t.Fatal("expected paid CallTool ctx to have a deadline")
}
assertApproxTimeout(t, mockCaller.timeouts[0], 90*time.Second)
}

func TestX402MCPClient_CallToolWithPayment_DefaultTimeoutWhenAcceptOmitsMaxTimeoutSeconds(t *testing.T) {
mockCaller := &mockMCPCaller{
callToolResult: MCPToolResult{
Content: []MCPContentItem{{Type: "text", Text: "success"}},
},
}
client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{})
payload := types.PaymentPayload{
X402Version: 2,
Payload: map[string]interface{}{"signature": "0x123"},
}

if _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] {
t.Fatal("expected paid CallTool ctx to have a deadline")
}
assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second)
}

func TestX402MCPClient_CallTool_RespectsCallerDeadline(t *testing.T) {
mockCaller := &mockMCPCaller{
callToolResult: MCPToolResult{
Content: []MCPContentItem{{Type: "text", Text: "pong"}},
},
}
client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

if _, err := client.CallTool(ctx, "ping", map[string]interface{}{}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] {
t.Fatal("expected CallTool ctx to keep the caller deadline")
}
assertApproxTimeout(t, mockCaller.timeouts[0], 15*time.Second)
}
Loading