11package mcpcore
22
33import (
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+
16109func 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
40133func 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}
0 commit comments