From 5778fd100790cac85cf729243e487211eefc8edf Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 4 Aug 2026 14:54:46 +0200 Subject: [PATCH] fix(mcp_impl): reject balance amounts beyond the safe JSON integer range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluate tool's balances now go through BindArguments into a typed *big.Int, which rejects fractional amounts but not magnitude loss: the MCP transport decodes incoming JSON numbers into a generic float64 before this handler ever runs, so an amount past 2^53-1 is already silently rounded by the time it reaches *big.Int's JSON unmarshaling - at that point it looks like a perfectly valid, exact integer. Reject any amount outside ±(2^53-1) before it's converted, instead of silently executing with a possibly-corrupted balance. --- internal/mcp_impl/handlers.go | 46 +++++++++++++++ internal/mcp_impl/handlers_test.go | 95 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/internal/mcp_impl/handlers.go b/internal/mcp_impl/handlers.go index 8f9ca546..0ac81047 100644 --- a/internal/mcp_impl/handlers.go +++ b/internal/mcp_impl/handlers.go @@ -48,6 +48,48 @@ func addEvalTool(s *server.MCPServer) { s.AddTool(tool, handleEvalTool) } +// maxExactJSONInt is the largest integer float64 can represent without loss +// (2^53 - 1). The MCP transport decodes incoming JSON numbers into a generic +// float64 (via Params.Arguments any) before this handler ever runs, so a +// balance amount past this magnitude may already have been silently rounded +// by the time BindArguments hands it to *big.Int - it will look like a +// perfectly valid, exact integer at that point, indistinguishable from one +// that was never corrupted. We can't recover the original value, so we +// refuse to execute with one instead of risking a silently wrong balance. +const maxExactJSONInt = float64(9_007_199_254_740_991) + +// checkBalanceAmountsInSafeRange rejects any "balances" entry whose amount +// falls outside the range a JSON number can represent exactly. Must run +// before the amount is converted to *big.Int, since that conversion can no +// longer tell a corrupted value from a genuine one. +func checkBalanceAmountsInSafeRange(args map[string]any) *mcp.CallToolResult { + balancesRaw, ok := args["balances"].([]any) + if !ok { + return nil + } + + for _, rowRaw := range balancesRaw { + row, ok := rowRaw.(map[string]any) + if !ok { + continue + } + + amount, ok := row["amount"].(float64) + if !ok { + continue + } + + if amount < -maxExactJSONInt || amount > maxExactJSONInt { + return mcp.NewToolResultError(fmt.Sprintf( + "amount %v for account=%v asset=%v exceeds the range a JSON number can represent exactly (±(2^53-1)); it may have already lost precision before reaching the server", + amount, row["account"], row["asset"], + )) + } + } + + return nil +} + func handleEvalTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { script, err := request.RequireString("script") if err != nil { @@ -63,6 +105,10 @@ func handleEvalTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.Call return mcp.NewToolResultError(strings.Join(out, ", ")), nil } + if result := checkBalanceAmountsInSafeRange(request.GetArguments()); result != nil { + return result, nil + } + var args struct { Vars map[string]string `json:"vars"` Balances interpreter.Balances `json:"balances"` diff --git a/internal/mcp_impl/handlers_test.go b/internal/mcp_impl/handlers_test.go index 05de57e7..4a6885a9 100644 --- a/internal/mcp_impl/handlers_test.go +++ b/internal/mcp_impl/handlers_test.go @@ -2,12 +2,18 @@ package mcp_impl import ( "context" + "encoding/json" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" ) +const evalScript = `send [USD/2 100] ( + source = @alice + destination = @bob +)` + func TestHandleEvalToolRejectsParseErrors(t *testing.T) { result, err := handleEvalTool(context.Background(), mcp.CallToolRequest{ Params: mcp.CallToolParams{ @@ -76,3 +82,92 @@ func TestHandleEvalToolAllowsSameBalanceKeyDifferentScope(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) } + +func TestHandleEvalToolRejectsAmountBeyondSafeIntegerRange(t *testing.T) { + // simulates the real MCP transport: the incoming JSON-RPC message is + // decoded generically before this handler ever runs, so an amount past + // float64's exact-integer range (2^53 - 1) is already rounded by the + // time we see it - 9007199254740993 becomes 9007199254740992. + wire := []byte(`{ + "method": "tools/call", + "params": { + "name": "evaluate", + "arguments": { + "script": ` + jsonString(evalScript) + `, + "vars": {}, + "balances": [{"account":"alice","asset":"USD/2","amount": 9007199254740993}] + } + } + }`) + + var request mcp.CallToolRequest + require.NoError(t, json.Unmarshal(wire, &request)) + + result, err := handleEvalTool(context.Background(), request) + require.NoError(t, err) + require.True(t, result.IsError, "expected an error result for an amount beyond the safe integer range, got: %#v", result) +} + +func TestHandleEvalToolRejectsUnsafelyLargeNegativeAmount(t *testing.T) { + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "evaluate", + Arguments: map[string]any{ + "script": evalScript, + "vars": map[string]any{}, + "balances": []any{ + map[string]any{"account": "alice", "asset": "USD/2", "amount": float64(-1e18)}, + }, + }, + }, + } + + result, err := handleEvalTool(context.Background(), request) + require.NoError(t, err) + require.True(t, result.IsError, "expected an error result for a negative amount beyond the safe integer range, got: %#v", result) +} + +func TestHandleEvalToolAcceptsAmountsWithinSafeRange(t *testing.T) { + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "evaluate", + Arguments: map[string]any{ + "script": evalScript, + "vars": map[string]any{}, + "balances": []any{ + map[string]any{"account": "alice", "asset": "USD/2", "amount": float64(100)}, + }, + }, + }, + } + + result, err := handleEvalTool(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError, "expected a successful result, got: %#v", result) +} + +func TestHandleEvalToolRejectsFractionalAmount(t *testing.T) { + // unrelated to the safe-integer-range check: a fractional amount is + // still rejected downstream by *big.Int's own JSON unmarshaling. + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "evaluate", + Arguments: map[string]any{ + "script": evalScript, + "vars": map[string]any{}, + "balances": []any{ + map[string]any{"account": "alice", "asset": "USD/2", "amount": float64(100.9)}, + }, + }, + }, + } + + result, err := handleEvalTool(context.Background(), request) + require.NoError(t, err) + require.True(t, result.IsError, "expected an error result for a fractional amount, got: %#v", result) +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +}