Skip to content

Commit a0060d0

Browse files
authored
feat: accept connection name or ID in pause/unpause commands (#277)
* feat: accept connection name or ID in pause/unpause commands * test(cli,mcp): finish connection name/ID resolution for issue #276 Completes the pause/unpause name-or-ID work from the parent commit and aligns MCP, tests, and user-facing copy. CLI - Document pause/unpause with examples (ID and name) in command Long text. - Add httptest coverage for resolveConnectionID: direct web_* ID, lookup by name via ListConnections, and web_* GET 404 falling back to name. Gateway MCP (hookdeck_connections) - Resolve connection ID or name for get, pause, and unpause using the same rules as the CLI (prefix probe, then list-by-name). - Surface API errors during resolution through TranslateAPIError so responses match other tools (e.g. authentication failures). - Validation errors: id or name is required for get/pause/unpause. - Refresh tool description, static help, and JSON schema text for get by name. Tests - MCP server tests: stub GET /connections/{id} before pause/unpause; include pagination limit in list mocks so name resolution succeeds; add get/pause/unpause-by-name cases; update missing-parameter assertions. - Acceptance: TestConnectionPauseUnpauseByName exercises pause/unpause by name against the real API and checks paused_at via connection get. Refs: #276 Made-with: Cursor --------- Co-authored-by: leggetter <leggetter@users.noreply.github.com>
1 parent f7b2890 commit a0060d0

8 files changed

Lines changed: 312 additions & 26 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
15+
)
16+
17+
func TestResolveConnectionID_ByIDPrefix(t *testing.T) {
18+
t.Parallel()
19+
20+
mux := http.NewServeMux()
21+
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections/web_resolve1", func(w http.ResponseWriter, r *http.Request) {
22+
if r.Method != http.MethodGet {
23+
http.Error(w, "method", http.StatusMethodNotAllowed)
24+
return
25+
}
26+
_ = json.NewEncoder(w).Encode(map[string]any{"id": "web_resolve1", "team_id": "tm_1"})
27+
})
28+
srv := httptest.NewServer(mux)
29+
t.Cleanup(srv.Close)
30+
31+
baseURL, err := url.Parse(srv.URL)
32+
require.NoError(t, err)
33+
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}
34+
35+
id, err := resolveConnectionID(context.Background(), client, "web_resolve1")
36+
require.NoError(t, err)
37+
assert.Equal(t, "web_resolve1", id)
38+
}
39+
40+
func TestResolveConnectionID_ByName(t *testing.T) {
41+
t.Parallel()
42+
43+
mux := http.NewServeMux()
44+
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections", func(w http.ResponseWriter, r *http.Request) {
45+
if r.Method != http.MethodGet {
46+
http.Error(w, "method", http.StatusMethodNotAllowed)
47+
return
48+
}
49+
assert.Equal(t, "my-conn", r.URL.Query().Get("name"))
50+
resp := map[string]any{
51+
"models": []map[string]any{{"id": "web_named", "team_id": "tm_1"}},
52+
"pagination": map[string]any{
53+
"limit": float64(100),
54+
},
55+
}
56+
_ = json.NewEncoder(w).Encode(resp)
57+
})
58+
srv := httptest.NewServer(mux)
59+
t.Cleanup(srv.Close)
60+
61+
baseURL, err := url.Parse(srv.URL)
62+
require.NoError(t, err)
63+
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}
64+
65+
id, err := resolveConnectionID(context.Background(), client, "my-conn")
66+
require.NoError(t, err)
67+
assert.Equal(t, "web_named", id)
68+
}
69+
70+
func TestResolveConnectionID_WebPrefix404FallsBackToNameLookup(t *testing.T) {
71+
t.Parallel()
72+
73+
mux := http.NewServeMux()
74+
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections/web_stale", func(w http.ResponseWriter, r *http.Request) {
75+
w.WriteHeader(http.StatusNotFound)
76+
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
77+
})
78+
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections", func(w http.ResponseWriter, r *http.Request) {
79+
assert.Equal(t, "web_stale", r.URL.Query().Get("name"))
80+
resp := map[string]any{
81+
"models": []map[string]any{{"id": "web_real", "team_id": "tm_1"}},
82+
"pagination": map[string]any{
83+
"limit": float64(100),
84+
},
85+
}
86+
_ = json.NewEncoder(w).Encode(resp)
87+
})
88+
srv := httptest.NewServer(mux)
89+
t.Cleanup(srv.Close)
90+
91+
baseURL, err := url.Parse(srv.URL)
92+
require.NoError(t, err)
93+
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}
94+
95+
id, err := resolveConnectionID(context.Background(), client, "web_stale")
96+
require.NoError(t, err)
97+
assert.Equal(t, "web_real", id)
98+
}

pkg/cmd/connection_pause.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,23 @@ func newConnectionPauseCmd() *connectionPauseCmd {
1717
cc := &connectionPauseCmd{}
1818

1919
cc.cmd = &cobra.Command{
20-
Use: "pause <connection-id>",
20+
Use: "pause <connection-id-or-name>",
2121
Args: validators.ExactArgs(1),
2222
Short: "Pause a connection temporarily",
2323
Long: `Pause a connection temporarily.
2424
25-
The connection will queue incoming events until unpaused.`,
25+
The connection will queue incoming events until unpaused.
26+
27+
Examples:
28+
# Pause by connection ID
29+
hookdeck gateway connection pause web_abc123
30+
31+
# Pause by connection name
32+
hookdeck gateway connection pause my-connection`,
2633
RunE: cc.runConnectionPauseCmd,
2734
}
2835
cc.cmd.Annotations = map[string]string{
29-
"cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`,
36+
"cli.arguments": `[{"name":"connection-id-or-name","type":"string","description":"Connection ID or name","required":true}]`,
3037
}
3138

3239
return cc
@@ -40,7 +47,12 @@ func (cc *connectionPauseCmd) runConnectionPauseCmd(cmd *cobra.Command, args []s
4047
client := Config.GetAPIClient()
4148
ctx := context.Background()
4249

43-
conn, err := client.PauseConnection(ctx, args[0])
50+
id, err := resolveConnectionID(ctx, client, args[0])
51+
if err != nil {
52+
return err
53+
}
54+
55+
conn, err := client.PauseConnection(ctx, id)
4456
if err != nil {
4557
return fmt.Errorf("failed to pause connection: %w", err)
4658
}

pkg/cmd/connection_unpause.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,23 @@ func newConnectionUnpauseCmd() *connectionUnpauseCmd {
1717
cc := &connectionUnpauseCmd{}
1818

1919
cc.cmd = &cobra.Command{
20-
Use: "unpause <connection-id>",
20+
Use: "unpause <connection-id-or-name>",
2121
Args: validators.ExactArgs(1),
2222
Short: "Resume a paused connection",
2323
Long: `Resume a paused connection.
2424
25-
The connection will start processing queued events.`,
25+
The connection will start processing queued events.
26+
27+
Examples:
28+
# Unpause by connection ID
29+
hookdeck gateway connection unpause web_abc123
30+
31+
# Unpause by connection name
32+
hookdeck gateway connection unpause my-connection`,
2633
RunE: cc.runConnectionUnpauseCmd,
2734
}
2835
cc.cmd.Annotations = map[string]string{
29-
"cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`,
36+
"cli.arguments": `[{"name":"connection-id-or-name","type":"string","description":"Connection ID or name","required":true}]`,
3037
}
3138

3239
return cc
@@ -40,7 +47,12 @@ func (cc *connectionUnpauseCmd) runConnectionUnpauseCmd(cmd *cobra.Command, args
4047
client := Config.GetAPIClient()
4148
ctx := context.Background()
4249

43-
conn, err := client.UnpauseConnection(ctx, args[0])
50+
id, err := resolveConnectionID(ctx, client, args[0])
51+
if err != nil {
52+
return err
53+
}
54+
55+
conn, err := client.UnpauseConnection(ctx, id)
4456
if err != nil {
4557
return fmt.Errorf("failed to unpause connection: %w", err)
4658
}

pkg/gateway/mcp/server_test.go

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ func callTool(t *testing.T, session *mcpsdk.ClientSession, name string, args map
8282
// listResponse returns a standard paginated API response.
8383
func listResponse(models ...map[string]any) map[string]any {
8484
return map[string]any{
85-
"models": models,
86-
"pagination": map[string]any{},
85+
"models": models,
86+
// limit must be non-zero so connection name resolution (ListConnections) is not treated as empty
87+
"pagination": map[string]any{"limit": 100},
8788
}
8889
}
8990

@@ -381,16 +382,38 @@ func TestConnectionsGet_Success(t *testing.T) {
381382
assert.Contains(t, text, `"active_project_id"`)
382383
}
383384

385+
func TestConnectionsGet_ByName(t *testing.T) {
386+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
387+
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
388+
assert.Equal(t, "GET", r.Method)
389+
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
390+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
391+
},
392+
"/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) {
393+
assert.Equal(t, "GET", r.Method)
394+
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})
395+
},
396+
})
397+
398+
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "stripe-to-backend"})
399+
assert.False(t, result.IsError)
400+
assert.Contains(t, textContent(t, result), "web_conn1")
401+
}
402+
384403
func TestConnectionsGet_MissingID(t *testing.T) {
385404
client := newTestClient("https://api.hookdeck.com", "test-key")
386405
session := connectInMemory(t, client)
387406
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get"})
388407
assert.True(t, result.IsError)
389-
assert.Contains(t, textContent(t, result), "id is required")
408+
assert.Contains(t, textContent(t, result), "id or name is required")
390409
}
391410

392411
func TestConnectionsPause_Success(t *testing.T) {
393412
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
413+
"/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) {
414+
assert.Equal(t, "GET", r.Method)
415+
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})
416+
},
394417
"/2025-07-01/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) {
395418
assert.Equal(t, "PUT", r.Method)
396419
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "paused_at": "2025-01-01T00:00:00Z"})
@@ -402,16 +425,38 @@ func TestConnectionsPause_Success(t *testing.T) {
402425
assert.Contains(t, textContent(t, result), "web_conn1")
403426
}
404427

428+
func TestConnectionsPause_ByName(t *testing.T) {
429+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
430+
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
431+
assert.Equal(t, "GET", r.Method)
432+
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
433+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
434+
},
435+
"/2025-07-01/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) {
436+
assert.Equal(t, "PUT", r.Method)
437+
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "paused_at": "2025-01-01T00:00:00Z"})
438+
},
439+
})
440+
441+
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause", "id": "stripe-to-backend"})
442+
assert.False(t, result.IsError)
443+
assert.Contains(t, textContent(t, result), "web_conn1")
444+
}
445+
405446
func TestConnectionsPause_MissingID(t *testing.T) {
406447
client := newTestClient("https://api.hookdeck.com", "test-key")
407448
session := connectInMemory(t, client)
408449
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause"})
409450
assert.True(t, result.IsError)
410-
assert.Contains(t, textContent(t, result), "id is required")
451+
assert.Contains(t, textContent(t, result), "id or name is required")
411452
}
412453

413454
func TestConnectionsUnpause_Success(t *testing.T) {
414455
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
456+
"/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) {
457+
assert.Equal(t, "GET", r.Method)
458+
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})
459+
},
415460
"/2025-07-01/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) {
416461
assert.Equal(t, "PUT", r.Method)
417462
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1"})
@@ -423,12 +468,30 @@ func TestConnectionsUnpause_Success(t *testing.T) {
423468
assert.Contains(t, textContent(t, result), "web_conn1")
424469
}
425470

471+
func TestConnectionsUnpause_ByName(t *testing.T) {
472+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
473+
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
474+
assert.Equal(t, "GET", r.Method)
475+
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
476+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
477+
},
478+
"/2025-07-01/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) {
479+
assert.Equal(t, "PUT", r.Method)
480+
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1"})
481+
},
482+
})
483+
484+
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause", "id": "stripe-to-backend"})
485+
assert.False(t, result.IsError)
486+
assert.Contains(t, textContent(t, result), "web_conn1")
487+
}
488+
426489
func TestConnectionsUnpause_MissingID(t *testing.T) {
427490
client := newTestClient("https://api.hookdeck.com", "test-key")
428491
session := connectInMemory(t, client)
429492
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause"})
430493
assert.True(t, result.IsError)
431-
assert.Contains(t, textContent(t, result), "id is required")
494+
assert.Contains(t, textContent(t, result), "id or name is required")
432495
}
433496

434497
func TestConnectionsTool_UnknownAction(t *testing.T) {

pkg/gateway/mcp/tool_connections.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package mcp
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"strings"
68

79
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
810

@@ -59,9 +61,13 @@ func connectionsList(ctx context.Context, client *hookdeck.Client, in input) (*m
5961
}
6062

6163
func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
62-
id := in.String("id")
63-
if id == "" {
64-
return ErrorResult("id is required for the get action"), nil
64+
idOrName := in.String("id")
65+
if idOrName == "" {
66+
return ErrorResult("id or name is required for the get action"), nil
67+
}
68+
id, err := resolveMCPConnectionID(ctx, client, idOrName)
69+
if err != nil {
70+
return ErrorResult(err.Error()), nil
6571
}
6672
conn, err := client.GetConnection(ctx, id)
6773
if err != nil {
@@ -71,9 +77,13 @@ func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mc
7177
}
7278

7379
func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
74-
id := in.String("id")
75-
if id == "" {
76-
return ErrorResult("id is required for the pause action"), nil
80+
idOrName := in.String("id")
81+
if idOrName == "" {
82+
return ErrorResult("id or name is required for the pause action"), nil
83+
}
84+
id, err := resolveMCPConnectionID(ctx, client, idOrName)
85+
if err != nil {
86+
return ErrorResult(err.Error()), nil
7787
}
7888
conn, err := client.PauseConnection(ctx, id)
7989
if err != nil {
@@ -83,13 +93,45 @@ func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*
8393
}
8494

8595
func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
86-
id := in.String("id")
87-
if id == "" {
88-
return ErrorResult("id is required for the unpause action"), nil
96+
idOrName := in.String("id")
97+
if idOrName == "" {
98+
return ErrorResult("id or name is required for the unpause action"), nil
99+
}
100+
id, err := resolveMCPConnectionID(ctx, client, idOrName)
101+
if err != nil {
102+
return ErrorResult(err.Error()), nil
89103
}
90104
conn, err := client.UnpauseConnection(ctx, id)
91105
if err != nil {
92106
return ErrorResult(TranslateAPIError(err)), nil
93107
}
94108
return JSONResultEnvelopeForClient(conn, client)
95109
}
110+
111+
// resolveMCPConnectionID resolves a connection ID or name to an ID.
112+
// If the value looks like an ID (starts with conn_ or web_), it is returned as-is after
113+
// verifying it exists; otherwise a name lookup is performed.
114+
func resolveMCPConnectionID(ctx context.Context, client *hookdeck.Client, idOrName string) (string, error) {
115+
if strings.HasPrefix(idOrName, "conn_") || strings.HasPrefix(idOrName, "web_") {
116+
_, err := client.GetConnection(ctx, idOrName)
117+
if err == nil {
118+
return idOrName, nil
119+
}
120+
if !hookdeck.IsNotFoundError(err) {
121+
return "", errors.New(TranslateAPIError(err))
122+
}
123+
}
124+
125+
params := map[string]string{"name": idOrName}
126+
result, err := client.ListConnections(ctx, params)
127+
if err != nil {
128+
return "", errors.New(TranslateAPIError(err))
129+
}
130+
if result.Pagination.Limit == 0 || len(result.Models) == 0 {
131+
return "", fmt.Errorf("connection not found: '%s'", idOrName)
132+
}
133+
if len(result.Models) > 1 {
134+
return "", fmt.Errorf("multiple connections found with name '%s', please use the connection ID instead", idOrName)
135+
}
136+
return result.Models[0].ID, nil
137+
}

0 commit comments

Comments
 (0)