Skip to content
Open
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
46 changes: 46 additions & 0 deletions internal/mcp_impl/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Reject rounded fractional amounts near the safe boundary

For JSON numbers just over the safe bound that round back inside it, such as 9007199254740991.1 decoding to the float64 value 9007199254740991, this comparison passes and BindArguments then accepts the rounded integer as a big.Int. Because the original token is already lost here, this still allows silently corrupted out-of-range/fractional balances; the check needs access to the raw JSON/json.Number value or another conservative rejection strategy before binding.

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 {
Expand All @@ -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"`
Expand Down
95 changes: 95 additions & 0 deletions internal/mcp_impl/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Loading