Skip to content

Commit e8e82d1

Browse files
leggetterclaude
andcommitted
fix(mcp): a write, pause or use tool with no action is an error, not a read
Every tool split from one resource shares that resource's handler, and the handler fills in an omitted action with a read -- list or get. On a write, pause or use tool, an omitted action therefore ran a sibling tool's read and reported success: hookdeck_projects_use {} returned the project list; switched nothing gateway_connections_pause {} listed connections; paused nothing An agent that forgot the argument was told the call worked. On projects_use that means every later answer comes from the project it meant to leave. rejectUnknownArgs already refused an action belonging to a sibling tool; it never handled an omitted one. It now requires the action on every non-read tool, with the actions that tool offers. The schema has always marked action required; read tools keep their list/get default. Nine tools gave the false success. A tenth, gateway_bulk_write, already errored, but listed gateway_bulk_read's actions as its own; it now names only create and cancel. The test walks every registered tool with writes enabled rather than a list, so a tool split in future is covered without anyone adding it. All ten fail against v3.0.2. Verified on a built binary against a live project. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XtSQ2kfpcRXXqweskgXkH
1 parent 1b131fc commit e8e82d1

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
13+
)
14+
15+
// Every tool split from one resource shares that resource's handler, and the
16+
// handler fills in an omitted action with a read -- list or get. So on a write,
17+
// pause or use tool, an omitted action ran a sibling tool's read and reported
18+
// success: hookdeck_projects_use {} returned the project list and switched
19+
// nothing, and gateway_connections_pause {} listed connections. An agent that
20+
// forgot the argument was told it had worked.
21+
//
22+
// The schema marks action as required on every tool. Read tools keep their
23+
// default; every other tool now enforces it.
24+
//
25+
// This walks every registered tool rather than a list, so a tool split in
26+
// future is covered without anyone remembering to add it.
27+
func TestNonReadToolsRequireAnAction(t *testing.T) {
28+
var apiCalls int
29+
session := mockAPIWithClientWriteEnabled(t, map[string]http.HandlerFunc{
30+
hookdeck.APIPathPrefix + "/": func(w http.ResponseWriter, r *http.Request) {
31+
apiCalls++
32+
w.WriteHeader(http.StatusOK)
33+
_, _ = w.Write([]byte(`{"models":[],"pagination":{}}`))
34+
},
35+
})
36+
37+
tools, err := session.ListTools(context.Background(), nil)
38+
require.NoError(t, err)
39+
40+
checked := 0
41+
for _, tool := range tools.Tools {
42+
props, _ := tool.InputSchema.(map[string]any)["properties"].(map[string]any)
43+
if _, takesAction := props["action"]; !takesAction {
44+
continue // login, help: no action to omit
45+
}
46+
if strings.HasSuffix(tool.Name, "_read") {
47+
continue // read tools may default to list or get
48+
}
49+
checked++
50+
51+
t.Run(tool.Name, func(t *testing.T) {
52+
before := apiCalls
53+
result := callTool(t, session, tool.Name, map[string]any{})
54+
55+
require.True(t, result.IsError, "%s with no action must be refused, not run a sibling's read", tool.Name)
56+
body := textContent(t, result)
57+
assert.Contains(t, body, "action is required")
58+
assert.Contains(t, body, "This tool offers:")
59+
assert.Equal(t, before, apiCalls, "nothing may reach the API")
60+
})
61+
}
62+
require.GreaterOrEqual(t, checked, 9, "expected at least the nine split tools that share a list/get default")
63+
}
64+
65+
// Read tools keep defaulting, so the change is confined to the tools where the
66+
// default ran something the caller could not have meant.
67+
func TestReadToolsStillDefaultWhenActionIsOmitted(t *testing.T) {
68+
session := mockAPIWithClientWriteEnabled(t, map[string]http.HandlerFunc{
69+
hookdeck.APIPathPrefix + "/": func(w http.ResponseWriter, r *http.Request) {
70+
_, _ = w.Write([]byte(`{"models":[],"pagination":{}}`))
71+
},
72+
})
73+
result := callTool(t, session, "gateway_connections_read", map[string]any{})
74+
assert.False(t, result.IsError, textContent(t, result))
75+
}

‎pkg/mcpcore/toolspec.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,21 @@ func rejectUnknownArgs(srv *Server, spec ToolSpec, group string, visible map[str
631631
return next(ctx, req)
632632
}
633633

634+
// An omitted action is filled in by the handler, and that handler is
635+
// shared by every tool split from the same resource. Its fallback is a
636+
// read -- list or get -- so on a write, pause or use tool an omitted
637+
// action ran a sibling tool's read and reported success:
638+
// hookdeck_projects_use {} returned the project list and switched
639+
// nothing, and gateway_connections_pause {} listed connections. Only a
640+
// read tool may default; everywhere else the schema's "required" is
641+
// enforced here, with the actions this tool actually offers.
642+
if in.String(actionArgName) == "" && group != GroupRead {
643+
return ErrorResult(fmt.Sprintf(
644+
"%s is required. This tool offers: %s.",
645+
actionArgName, strings.Join(spec.Actions.InGroup(group).Names(), ", "),
646+
)), nil
647+
}
648+
634649
// Only defer when the caller is reaching for an action this tool does
635650
// not carry. On an action this tool does have, a hidden argument is
636651
// just as ignorable as an invented one, and has to be rejected.

0 commit comments

Comments
 (0)