Skip to content

Commit cb8e598

Browse files
leggetterclaude
andauthored
Tighten error matching, improve login context handling, and tests (#281)
* fix(review): tighten error matching, add safety comments, revert noise - tool_projects_errors.go: match "status code: 4xx" instead of bare "401"/"403" to avoid false positives on IDs or timestamps - tool_login.go: remove `_ = ctx` suppression, add TODO for context propagation to polling goroutine, document happens-before on loginState.err via channel close - root.go: add maintenance comment linking flagNeedsNextArg to init() - helpers.go: revert unrelated Attempt struct alignment change https://claude.ai/code/session_01EpXZqTmgybtjgmSALukH8d * fix(review): ListProjects returns *APIError, add reauth + argv tests - projects.go: use checkAndPrintError instead of manual status check so ListProjects errors are structured *APIError — the errors.As path in shouldSuggestReauthAfterListProjectsFailure now matches directly - tool_projects_errors_test.go: unit tests for reauth hint logic covering APIError 401/403, plain error fallback, and false-positive resistance - root_argv_test.go: document boolean-flag-between-subcommands limitation https://claude.ai/code/session_01EpXZqTmgybtjgmSALukH8d * fix(mcp): cancel login polling goroutine on MCP session close Thread the request context into the login polling goroutine so it stops promptly when the MCP transport closes, instead of running for up to ~4 minutes after the client disconnects. WaitForAPIKey blocks with time.Sleep and doesn't accept a context, so we run it in an inner goroutine and select on both its result channel and ctx.Done(). The inner goroutine is bounded by loginMaxAttempts. https://claude.ai/code/session_01EpXZqTmgybtjgmSALukH8d --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 310146b commit cb8e598

7 files changed

Lines changed: 109 additions & 17 deletions

File tree

pkg/cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ func argvContainsGatewayMCP(argv []string) bool {
146146
return false
147147
}
148148

149+
// flagNeedsNextArg lists global flags that consume the next argv token as their value.
150+
// Keep in sync with the PersistentFlags registered in init() below.
149151
var flagNeedsNextArg = map[string]bool{
150152
"profile": true,
151153
"p": true,

pkg/cmd/root_argv_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func TestArgvContainsGatewayMCP(t *testing.T) {
2121
{"wrong order", []string{"hookdeck", "mcp", "gateway"}, false},
2222
{"too short", []string{"hookdeck", "gateway"}, false},
2323
{"api key flag before", []string{"hookdeck", "--api-key", "k", "gateway", "mcp"}, true},
24+
// Boolean flags (--insecure, --version) are not in flagNeedsNextArg, so
25+
// globalPositionalArgs treats them as single-token flags and skips them.
26+
{"bool flag before gateway", []string{"hookdeck", "--insecure", "gateway", "mcp"}, true},
27+
{"bool flag between gateway and mcp", []string{"hookdeck", "gateway", "--insecure", "mcp"}, false},
2428
}
2529
for _, tt := range tests {
2630
t.Run(tt.name, func(t *testing.T) {

pkg/gateway/mcp/tool_login.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const (
2626

2727
// loginState tracks a background login poll so that repeated calls to
2828
// hookdeck_login don't start duplicate auth flows.
29+
//
30+
// Synchronization: err is written by the goroutine before close(done).
31+
// The handler only reads err after receiving from done, so the channel
32+
// close provides the happens-before guarantee — no separate mutex needed.
2933
type loginState struct {
3034
browserURL string // URL the user must open
3135
done chan struct{} // closed when polling finishes
@@ -37,7 +41,6 @@ func handleLogin(client *hookdeck.Client, cfg *config.Config) mcpsdk.ToolHandler
3741
var state *loginState
3842

3943
return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
40-
_ = ctx
4144
in, err := parseInput(req.Params.Arguments)
4245
if err != nil {
4346
return ErrorResult(err.Error()), nil
@@ -118,14 +121,34 @@ func handleLogin(client *hookdeck.Client, cfg *config.Config) mcpsdk.ToolHandler
118121
}
119122

120123
// Poll in the background so we return the URL to the agent immediately.
121-
go func(s *loginState) {
124+
// WaitForAPIKey blocks with time.Sleep; run it in a goroutine and
125+
// select on ctx so we abandon the attempt when the session closes.
126+
go func(s *loginState, ctx context.Context) {
122127
defer close(s.done)
123128

124-
response, err := session.WaitForAPIKey(loginPollInterval, loginMaxAttempts)
125-
if err != nil {
126-
s.err = err
127-
log.WithError(err).Debug("Login polling failed")
129+
type pollResult struct {
130+
resp *hookdeck.PollAPIKeyResponse
131+
err error
132+
}
133+
ch := make(chan pollResult, 1)
134+
go func() {
135+
resp, err := session.WaitForAPIKey(loginPollInterval, loginMaxAttempts)
136+
ch <- pollResult{resp, err}
137+
}()
138+
139+
var response *hookdeck.PollAPIKeyResponse
140+
select {
141+
case <-ctx.Done():
142+
s.err = fmt.Errorf("login cancelled: MCP session closed")
143+
log.Debug("Login polling cancelled — MCP session closed")
128144
return
145+
case r := <-ch:
146+
if r.err != nil {
147+
s.err = r.err
148+
log.WithError(r.err).Debug("Login polling failed")
149+
return
150+
}
151+
response = r.resp
129152
}
130153

131154
if err := validators.APIKey(response.APIKey); err != nil {
@@ -164,7 +187,7 @@ func handleLogin(client *hookdeck.Client, cfg *config.Config) mcpsdk.ToolHandler
164187
"user": response.UserName,
165188
"project": response.ProjectName,
166189
}).Info("MCP login completed successfully")
167-
}(state)
190+
}(state, ctx)
168191

169192
// Return the URL immediately so the agent can show it to the user.
170193
return TextResult(fmt.Sprintf(

pkg/gateway/mcp/tool_projects_errors.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ func shouldSuggestReauthAfterListProjectsFailure(err error) bool {
2626
}
2727
return strings.Contains(strings.ToLower(apiErr.Message), "fatal")
2828
}
29-
// ListProjects wraps some failures as plain fmt.Errorf with status in the text.
29+
// ListProjects wraps some failures as plain fmt.Errorf with the status code
30+
// in the text (e.g. "unexpected http status code: 403 <nil>").
31+
// Match "status code: 4xx" to avoid false positives on IDs containing "401"/"403".
3032
msg := strings.ToLower(err.Error())
31-
if strings.Contains(msg, "403") || strings.Contains(msg, "401") {
33+
if strings.Contains(msg, "status code: 403") || strings.Contains(msg, "status code: 401") {
3234
return true
3335
}
3436
return strings.Contains(msg, "fatal")
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package mcp
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestShouldSuggestReauthAfterListProjectsFailure(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
err error
15+
want bool
16+
}{
17+
{
18+
name: "APIError 403",
19+
err: &hookdeck.APIError{StatusCode: 403, Message: "not allowed"},
20+
want: true,
21+
},
22+
{
23+
name: "APIError 401",
24+
err: &hookdeck.APIError{StatusCode: 401, Message: "unauthorized"},
25+
want: true,
26+
},
27+
{
28+
name: "APIError 500 no match",
29+
err: &hookdeck.APIError{StatusCode: 500, Message: "internal error"},
30+
want: false,
31+
},
32+
{
33+
name: "APIError with fatal message",
34+
err: &hookdeck.APIError{StatusCode: 500, Message: "fatal: cannot proceed"},
35+
want: true,
36+
},
37+
{
38+
name: "plain error with status code 403",
39+
err: fmt.Errorf("unexpected http status code: 403 <nil>"),
40+
want: true,
41+
},
42+
{
43+
name: "plain error with status code 401",
44+
err: fmt.Errorf("unexpected http status code: 401 <nil>"),
45+
want: true,
46+
},
47+
{
48+
name: "plain error without status code",
49+
err: fmt.Errorf("network timeout"),
50+
want: false,
51+
},
52+
{
53+
name: "plain error with 403 in ID should not match",
54+
err: fmt.Errorf("project proj_403_abc not found"),
55+
want: false,
56+
},
57+
}
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
assert.Equal(t, tt.want, shouldSuggestReauthAfterListProjectsFailure(tt.err))
61+
})
62+
}
63+
}

pkg/hookdeck/projects.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package hookdeck
22

33
import (
44
"context"
5-
"fmt"
6-
"net/http"
75
)
86

97
type Project struct {
@@ -17,8 +15,8 @@ func (c *Client) ListProjects() ([]Project, error) {
1715
if err != nil {
1816
return []Project{}, err
1917
}
20-
if res.StatusCode != http.StatusOK {
21-
return []Project{}, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, err)
18+
if err := checkAndPrintError(res); err != nil {
19+
return []Project{}, err
2220
}
2321
projects := []Project{}
2422
postprocessJsonResponse(res, &projects)

test/acceptance/helpers.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -759,10 +759,10 @@ type Request struct {
759759

760760
// Attempt represents a Hookdeck attempt for testing
761761
type Attempt struct {
762-
ID string `json:"id"`
763-
EventID string `json:"event_id"`
764-
AttemptNumber int `json:"attempt_number"`
765-
Status string `json:"status"`
762+
ID string `json:"id"`
763+
EventID string `json:"event_id"`
764+
AttemptNumber int `json:"attempt_number"`
765+
Status string `json:"status"`
766766
}
767767

768768
// createTestConnection creates a basic test connection and returns its ID

0 commit comments

Comments
 (0)