Skip to content

Commit a450eb4

Browse files
authored
Merge pull request #480 from hookdeck/fix/mcp-tool-call-stall
fix(mcp): stop a slow display-name lookup stalling tool calls; harness returns at the response
2 parents 6a43726 + c6dcab3 commit a450eb4

6 files changed

Lines changed: 356 additions & 39 deletions

File tree

‎pkg/hookdeck/auth.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,13 @@ func (c *Client) withoutProjectScope() *Client {
175175

176176
// ValidateAPIKey validates an API key and returns user/project information
177177
func (c *Client) ValidateAPIKey() (*ValidateAPIKeyResponse, error) {
178-
res, err := c.clientForCLIAuthValidate().Get(context.Background(), APIPathPrefix+"/cli-auth/validate", "", nil)
178+
return c.ValidateAPIKeyContext(context.Background())
179+
}
180+
181+
// ValidateAPIKeyContext is ValidateAPIKey bounded by ctx, for callers that must
182+
// not wait indefinitely on the API.
183+
func (c *Client) ValidateAPIKeyContext(ctx context.Context) (*ValidateAPIKeyResponse, error) {
184+
res, err := c.clientForCLIAuthValidate().Get(ctx, APIPathPrefix+"/cli-auth/validate", "", nil)
179185
if err != nil {
180186
return nil, err
181187
}

‎pkg/hookdeck/projects.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ type Project struct {
1212
}
1313

1414
func (c *Client) ListProjects() ([]Project, error) {
15-
res, err := c.clientForCLIAuthValidate().Get(context.Background(), APIPathPrefix+"/projects", "", nil)
15+
return c.ListProjectsContext(context.Background())
16+
}
17+
18+
// ListProjectsContext is ListProjects bounded by ctx, for callers that must not
19+
// wait indefinitely on the API.
20+
func (c *Client) ListProjectsContext(ctx context.Context) ([]Project, error) {
21+
res, err := c.clientForCLIAuthValidate().Get(ctx, APIPathPrefix+"/projects", "", nil)
1622
if err != nil {
1723
return []Project{}, err
1824
}

‎pkg/mcpcore/project_display.go‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
package mcpcore
22

33
import (
4+
"context"
5+
"time"
6+
47
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
58
"github.com/hookdeck/hookdeck-cli/pkg/project"
69
)
710

11+
// projectDisplayLookupTimeout bounds the whole display-name lookup. The lookup
12+
// runs before every tool call until it succeeds, including calls that never
13+
// reach the API (help, a write refused in read-only mode, a rejected argument),
14+
// and the API client has no deadline of its own. Without this bound one slow
15+
// API response held the tool call open for as long as the API took: the client
16+
// saw initialize answered and then nothing. The name is cosmetic, so giving up
17+
// costs only an id where a name would have been, and the next call tries again.
18+
// A variable so tests can shorten it.
19+
var projectDisplayLookupTimeout = 5 * time.Second
20+
821
// FillProjectDisplayNameIfNeeded sets target.ProjectOrg and target.ProjectName
922
// when target has an API key and project id but no cached org/name (typical
1023
// after loading the profile from disk). Fails silently on API errors. Stdio MCP
1124
// invokes tools sequentially, so this is safe without locking.
1225
//
26+
// The lookup gives up after projectDisplayLookupTimeout, or sooner when ctx is
27+
// done, so it can never hold up the tool call it runs ahead of.
28+
//
1329
// lookup is the client account-level requests are made from, which is not always
1430
// target: a product API served from its own host does not answer them, so the
1531
// lookup has to go to the account API.
@@ -19,26 +35,36 @@ import (
1935
// projects only works for a user-associated key, so a project-scoped key from
2036
// `hookdeck ci` — a common way to configure an MCP server — would otherwise
2137
// leave the name empty and callers with nothing but an opaque id to show.
22-
func FillProjectDisplayNameIfNeeded(lookup, target *hookdeck.Client) {
38+
func FillProjectDisplayNameIfNeeded(ctx context.Context, lookup, target *hookdeck.Client) {
2339
if lookup == nil || target == nil || target.APIKey == "" || target.ProjectID == "" {
2440
return
2541
}
2642
if target.ProjectName != "" || target.ProjectOrg != "" {
2743
return
2844
}
45+
if ctx == nil {
46+
ctx = context.Background()
47+
}
48+
ctx, cancel := context.WithTimeout(ctx, projectDisplayLookupTimeout)
49+
defer cancel()
2950

3051
// The key's own project, available whatever kind of key it is.
31-
if response, err := lookup.ValidateAPIKey(); err == nil && response.ProjectID == target.ProjectID {
52+
if response, err := lookup.ValidateAPIKeyContext(ctx); err == nil && response.ProjectID == target.ProjectID {
3253
target.ProjectName = response.ProjectName
3354
target.ProjectOrg = response.OrganizationName
3455
if target.ProjectName != "" || target.ProjectOrg != "" {
3556
return
3657
}
3758
}
3859

60+
// Out of time: the listing would fail too.
61+
if ctx.Err() != nil {
62+
return
63+
}
64+
3965
// The active project differs from the key's, so it has to be looked up.
4066
// Only a user-associated key can do this.
41-
projects, err := lookup.ListProjects()
67+
projects, err := lookup.ListProjectsContext(ctx)
4268
if err != nil {
4369
return
4470
}

‎pkg/mcpcore/project_display_test.go‎

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,111 @@
11
package mcpcore
22

33
import (
4+
"context"
45
"encoding/json"
56
"net/http"
67
"net/http/httptest"
78
"net/url"
89
"strings"
10+
"sync/atomic"
911
"testing"
12+
"time"
13+
14+
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
1015

1116
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
1217
"github.com/stretchr/testify/assert"
1318
"github.com/stretchr/testify/require"
1419
)
1520

21+
// stalledAPI answers nothing until the request is abandoned, which is what a
22+
// slow API looks like to a client with no deadline of its own.
23+
func stalledAPI(t *testing.T) (*hookdeck.Client, *atomic.Int64) {
24+
t.Helper()
25+
var requests atomic.Int64
26+
release := make(chan struct{})
27+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+
requests.Add(1)
29+
select {
30+
case <-r.Context().Done():
31+
case <-release:
32+
}
33+
}))
34+
t.Cleanup(func() {
35+
close(release)
36+
server.Close()
37+
})
38+
baseURL, err := url.Parse(server.URL)
39+
require.NoError(t, err)
40+
return &hookdeck.Client{BaseURL: baseURL, APIKey: "k", ProjectID: "tm_1"}, &requests
41+
}
42+
43+
func shortenProjectDisplayLookup(t *testing.T, d time.Duration) {
44+
t.Helper()
45+
previous := projectDisplayLookupTimeout
46+
projectDisplayLookupTimeout = d
47+
t.Cleanup(func() { projectDisplayLookupTimeout = previous })
48+
}
49+
50+
// TestFillProjectDisplayName_GivesUpOnAStalledAPI: the lookup is cosmetic, so a
51+
// slow API must cost at most the timeout, not however long the API takes.
52+
func TestFillProjectDisplayName_GivesUpOnAStalledAPI(t *testing.T) {
53+
shortenProjectDisplayLookup(t, 200*time.Millisecond)
54+
client, requests := stalledAPI(t)
55+
56+
start := time.Now()
57+
FillProjectDisplayNameIfNeeded(context.Background(), client, client)
58+
59+
assert.Less(t, time.Since(start), 3*time.Second, "the lookup must give up at its timeout")
60+
assert.GreaterOrEqual(t, requests.Load(), int64(1), "the lookup should have been attempted")
61+
assert.Empty(t, client.ProjectName, "a lookup that gave up leaves the name for the next call to fill")
62+
assert.Empty(t, client.ProjectOrg)
63+
}
64+
65+
// TestToolCallIsNotHeldByAStalledDisplayNameLookup is the case the acceptance
66+
// suite kept hitting: a tool that never needs the API (here, one standing in for
67+
// a write refused in read-only mode) answered nothing, because the lookup ahead
68+
// of it waited on the API with no deadline.
69+
func TestToolCallIsNotHeldByAStalledDisplayNameLookup(t *testing.T) {
70+
shortenProjectDisplayLookup(t, 200*time.Millisecond)
71+
client, _ := stalledAPI(t)
72+
s := &Server{client: client, accountClient: client}
73+
74+
refusal := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
75+
return &mcpsdk.CallToolResult{IsError: true}, nil
76+
})
77+
78+
done := make(chan *mcpsdk.CallToolResult, 1)
79+
go func() {
80+
result, _ := s.wrapWithTelemetry("gateway_sources_read", refusal)(context.Background(), newCallToolRequest(`{"action":"delete"}`))
81+
done <- result
82+
}()
83+
84+
select {
85+
case result := <-done:
86+
require.NotNil(t, result)
87+
assert.True(t, result.IsError)
88+
case <-time.After(5 * time.Second):
89+
t.Fatal("the tool call is still waiting on the display-name lookup")
90+
}
91+
}
92+
93+
// TestFillProjectDisplayName_StopsWhenTheCallIsCancelled: a client that cancels
94+
// the tool call must not leave the lookup running to its own timeout.
95+
func TestFillProjectDisplayName_StopsWhenTheCallIsCancelled(t *testing.T) {
96+
shortenProjectDisplayLookup(t, time.Minute)
97+
client, _ := stalledAPI(t)
98+
99+
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
100+
defer cancel()
101+
102+
start := time.Now()
103+
FillProjectDisplayNameIfNeeded(ctx, client, client)
104+
105+
assert.Less(t, time.Since(start), 3*time.Second, "cancelling the call must end the lookup")
106+
assert.Empty(t, client.ProjectName)
107+
}
108+
16109
func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) {
17110
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18111
if r.URL.Path != "/2026-09-01/projects" {
@@ -32,14 +125,14 @@ func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) {
32125
APIKey: "k",
33126
ProjectID: "proj_x",
34127
}
35-
FillProjectDisplayNameIfNeeded(client, client)
128+
FillProjectDisplayNameIfNeeded(context.Background(), client, client)
36129
require.Equal(t, "Acme", client.ProjectOrg)
37130
require.Equal(t, "production", client.ProjectName)
38131
}
39132

40133
func TestFillProjectDisplayNameIfNeeded_NoOpWhenNameSet(t *testing.T) {
41134
client := &hookdeck.Client{ProjectID: "p", ProjectName: "already"}
42-
FillProjectDisplayNameIfNeeded(client, client)
135+
FillProjectDisplayNameIfNeeded(context.Background(), client, client)
43136
require.Equal(t, "already", client.ProjectName)
44137
}
45138

@@ -72,7 +165,7 @@ func TestFillProjectDisplayNameIfNeeded_LooksUpThroughTheAccountClient(t *testin
72165
account := &hookdeck.Client{BaseURL: accountURL, APIKey: "k", ProjectID: "proj_x"}
73166
product := &hookdeck.Client{BaseURL: productURL, APIKey: "k", ProjectID: "proj_x"}
74167

75-
FillProjectDisplayNameIfNeeded(account, product)
168+
FillProjectDisplayNameIfNeeded(context.Background(), account, product)
76169
require.Equal(t, "Acme", product.ProjectOrg)
77170
require.Equal(t, "production", product.ProjectName)
78171
}
@@ -100,7 +193,7 @@ func TestFillProjectDisplayName_ProjectScopedKey(t *testing.T) {
100193
require.NoError(t, err)
101194
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "ci-key", ProjectID: "tm_1"}
102195

103-
FillProjectDisplayNameIfNeeded(client, client)
196+
FillProjectDisplayNameIfNeeded(context.Background(), client, client)
104197

105198
assert.Equal(t, "cli outpost testing", client.ProjectName)
106199
assert.Equal(t, "Automated Testing", client.ProjectOrg)
@@ -126,7 +219,7 @@ func TestFillProjectDisplayName_FallsBackToListing(t *testing.T) {
126219
require.NoError(t, err)
127220
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "user-key", ProjectID: "tm_1"}
128221

129-
FillProjectDisplayNameIfNeeded(client, client)
222+
FillProjectDisplayNameIfNeeded(context.Background(), client, client)
130223

131224
assert.Equal(t, "the active one", client.ProjectName, "the key's own project must not be used when it is not the active one")
132225
}

‎pkg/mcpcore/server.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler)
270270
}
271271
}()
272272

273-
FillProjectDisplayNameIfNeeded(s.accountClient, s.client)
273+
FillProjectDisplayNameIfNeeded(ctx, s.accountClient, s.client)
274274

275275
return handler(ctx, req)
276276
}

0 commit comments

Comments
 (0)