From bae5763859cd617401e59aa37e851b5d32349b71 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Thu, 10 Sep 2026 07:31:12 +0000 Subject: [PATCH] fix(mcp,go): size tool-call timeouts from accept maxTimeoutSeconds Paid MCP tool calls were forwarding the parent context unchanged, so slow-finality settlements could abort while TypeScript waits through the accept window. Probe with a 300s ceiling when the caller sets no deadline, and size paid retries from accepted.maxTimeoutSeconds. Co-authored-by: phdargen --- ...hanged-20260910-mcp-tool-call-timeout.yaml | 3 + go/mcp/client.go | 26 ++- go/mcp/client_test.go | 148 ++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 go/.changes/unreleased/changed-20260910-mcp-tool-call-timeout.yaml diff --git a/go/.changes/unreleased/changed-20260910-mcp-tool-call-timeout.yaml b/go/.changes/unreleased/changed-20260910-mcp-tool-call-timeout.yaml new file mode 100644 index 0000000000..87328ff931 --- /dev/null +++ b/go/.changes/unreleased/changed-20260910-mcp-tool-call-timeout.yaml @@ -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 diff --git a/go/mcp/client.go b/go/mcp/client.go index ba95f37aea..f2621c5676 100644 --- a/go/mcp/client.go +++ b/go/mcp/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" @@ -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) } @@ -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) } @@ -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) +} diff --git a/go/mcp/client_test.go b/go/mcp/client_test.go index e3b8e5746e..9d80665c6c 100644 --- a/go/mcp/client_test.go +++ b/go/mcp/client_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" @@ -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 @@ -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) +}