Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions pkg/cmd/connection_get_resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cmd

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
)

func TestResolveConnectionID_ByIDPrefix(t *testing.T) {
t.Parallel()

mux := http.NewServeMux()
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections/web_resolve1", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "web_resolve1", "team_id": "tm_1"})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)

baseURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}

id, err := resolveConnectionID(context.Background(), client, "web_resolve1")
require.NoError(t, err)
assert.Equal(t, "web_resolve1", id)
}

func TestResolveConnectionID_ByName(t *testing.T) {
t.Parallel()

mux := http.NewServeMux()
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
assert.Equal(t, "my-conn", r.URL.Query().Get("name"))
resp := map[string]any{
"models": []map[string]any{{"id": "web_named", "team_id": "tm_1"}},
"pagination": map[string]any{
"limit": float64(100),
},
}
_ = json.NewEncoder(w).Encode(resp)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)

baseURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}

id, err := resolveConnectionID(context.Background(), client, "my-conn")
require.NoError(t, err)
assert.Equal(t, "web_named", id)
}

func TestResolveConnectionID_WebPrefix404FallsBackToNameLookup(t *testing.T) {
t.Parallel()

mux := http.NewServeMux()
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections/web_stale", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
})
mux.HandleFunc(hookdeck.APIPathPrefix+"/connections", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "web_stale", r.URL.Query().Get("name"))
resp := map[string]any{
"models": []map[string]any{{"id": "web_real", "team_id": "tm_1"}},
"pagination": map[string]any{
"limit": float64(100),
},
}
_ = json.NewEncoder(w).Encode(resp)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)

baseURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}

id, err := resolveConnectionID(context.Background(), client, "web_stale")
require.NoError(t, err)
assert.Equal(t, "web_real", id)
}
20 changes: 16 additions & 4 deletions pkg/cmd/connection_pause.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,23 @@ func newConnectionPauseCmd() *connectionPauseCmd {
cc := &connectionPauseCmd{}

cc.cmd = &cobra.Command{
Use: "pause <connection-id>",
Use: "pause <connection-id-or-name>",
Args: validators.ExactArgs(1),
Short: "Pause a connection temporarily",
Long: `Pause a connection temporarily.

The connection will queue incoming events until unpaused.`,
The connection will queue incoming events until unpaused.

Examples:
# Pause by connection ID
hookdeck gateway connection pause web_abc123

# Pause by connection name
hookdeck gateway connection pause my-connection`,
RunE: cc.runConnectionPauseCmd,
}
cc.cmd.Annotations = map[string]string{
"cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`,
"cli.arguments": `[{"name":"connection-id-or-name","type":"string","description":"Connection ID or name","required":true}]`,
}

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

conn, err := client.PauseConnection(ctx, args[0])
id, err := resolveConnectionID(ctx, client, args[0])
if err != nil {
return err
}

conn, err := client.PauseConnection(ctx, id)
if err != nil {
return fmt.Errorf("failed to pause connection: %w", err)
}
Expand Down
20 changes: 16 additions & 4 deletions pkg/cmd/connection_unpause.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,23 @@ func newConnectionUnpauseCmd() *connectionUnpauseCmd {
cc := &connectionUnpauseCmd{}

cc.cmd = &cobra.Command{
Use: "unpause <connection-id>",
Use: "unpause <connection-id-or-name>",
Args: validators.ExactArgs(1),
Short: "Resume a paused connection",
Long: `Resume a paused connection.

The connection will start processing queued events.`,
The connection will start processing queued events.

Examples:
# Unpause by connection ID
hookdeck gateway connection unpause web_abc123

# Unpause by connection name
hookdeck gateway connection unpause my-connection`,
RunE: cc.runConnectionUnpauseCmd,
}
cc.cmd.Annotations = map[string]string{
"cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`,
"cli.arguments": `[{"name":"connection-id-or-name","type":"string","description":"Connection ID or name","required":true}]`,
}

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

conn, err := client.UnpauseConnection(ctx, args[0])
id, err := resolveConnectionID(ctx, client, args[0])
if err != nil {
return err
}

conn, err := client.UnpauseConnection(ctx, id)
if err != nil {
return fmt.Errorf("failed to unpause connection: %w", err)
}
Expand Down
73 changes: 68 additions & 5 deletions pkg/gateway/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ func callTool(t *testing.T, session *mcpsdk.ClientSession, name string, args map
// listResponse returns a standard paginated API response.
func listResponse(models ...map[string]any) map[string]any {
return map[string]any{
"models": models,
"pagination": map[string]any{},
"models": models,
// limit must be non-zero so connection name resolution (ListConnections) is not treated as empty
"pagination": map[string]any{"limit": 100},
}
}

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

func TestConnectionsGet_ByName(t *testing.T) {
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
},
"/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})
},
})

result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "stripe-to-backend"})
assert.False(t, result.IsError)
assert.Contains(t, textContent(t, result), "web_conn1")
}

func TestConnectionsGet_MissingID(t *testing.T) {
client := newTestClient("https://api.hookdeck.com", "test-key")
session := connectInMemory(t, client)
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get"})
assert.True(t, result.IsError)
assert.Contains(t, textContent(t, result), "id is required")
assert.Contains(t, textContent(t, result), "id or name is required")
}

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

func TestConnectionsPause_ByName(t *testing.T) {
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
},
"/2025-07-01/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "paused_at": "2025-01-01T00:00:00Z"})
},
})

result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause", "id": "stripe-to-backend"})
assert.False(t, result.IsError)
assert.Contains(t, textContent(t, result), "web_conn1")
}

func TestConnectionsPause_MissingID(t *testing.T) {
client := newTestClient("https://api.hookdeck.com", "test-key")
session := connectInMemory(t, client)
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause"})
assert.True(t, result.IsError)
assert.Contains(t, textContent(t, result), "id is required")
assert.Contains(t, textContent(t, result), "id or name is required")
}

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

func TestConnectionsUnpause_ByName(t *testing.T) {
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
"/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name"))
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}))
},
"/2025-07-01/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1"})
},
})

result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause", "id": "stripe-to-backend"})
assert.False(t, result.IsError)
assert.Contains(t, textContent(t, result), "web_conn1")
}

func TestConnectionsUnpause_MissingID(t *testing.T) {
client := newTestClient("https://api.hookdeck.com", "test-key")
session := connectInMemory(t, client)
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause"})
assert.True(t, result.IsError)
assert.Contains(t, textContent(t, result), "id is required")
assert.Contains(t, textContent(t, result), "id or name is required")
}

func TestConnectionsTool_UnknownAction(t *testing.T) {
Expand Down
60 changes: 51 additions & 9 deletions pkg/gateway/mcp/tool_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package mcp

import (
"context"
"errors"
"fmt"
"strings"

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

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

func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
id := in.String("id")
if id == "" {
return ErrorResult("id is required for the get action"), nil
idOrName := in.String("id")
if idOrName == "" {
return ErrorResult("id or name is required for the get action"), nil
}
id, err := resolveMCPConnectionID(ctx, client, idOrName)
if err != nil {
return ErrorResult(err.Error()), nil
}
conn, err := client.GetConnection(ctx, id)
if err != nil {
Expand All @@ -71,9 +77,13 @@ func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mc
}

func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
id := in.String("id")
if id == "" {
return ErrorResult("id is required for the pause action"), nil
idOrName := in.String("id")
if idOrName == "" {
return ErrorResult("id or name is required for the pause action"), nil
}
id, err := resolveMCPConnectionID(ctx, client, idOrName)
if err != nil {
return ErrorResult(err.Error()), nil
}
conn, err := client.PauseConnection(ctx, id)
if err != nil {
Expand All @@ -83,13 +93,45 @@ func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*
}

func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
id := in.String("id")
if id == "" {
return ErrorResult("id is required for the unpause action"), nil
idOrName := in.String("id")
if idOrName == "" {
return ErrorResult("id or name is required for the unpause action"), nil
}
id, err := resolveMCPConnectionID(ctx, client, idOrName)
if err != nil {
return ErrorResult(err.Error()), nil
}
conn, err := client.UnpauseConnection(ctx, id)
if err != nil {
return ErrorResult(TranslateAPIError(err)), nil
}
return JSONResultEnvelopeForClient(conn, client)
}

// resolveMCPConnectionID resolves a connection ID or name to an ID.
// If the value looks like an ID (starts with conn_ or web_), it is returned as-is after
// verifying it exists; otherwise a name lookup is performed.
func resolveMCPConnectionID(ctx context.Context, client *hookdeck.Client, idOrName string) (string, error) {
if strings.HasPrefix(idOrName, "conn_") || strings.HasPrefix(idOrName, "web_") {
_, err := client.GetConnection(ctx, idOrName)
if err == nil {
return idOrName, nil
}
if !hookdeck.IsNotFoundError(err) {
return "", errors.New(TranslateAPIError(err))
}
}

params := map[string]string{"name": idOrName}
result, err := client.ListConnections(ctx, params)
if err != nil {
return "", errors.New(TranslateAPIError(err))
}
if result.Pagination.Limit == 0 || len(result.Models) == 0 {
return "", fmt.Errorf("connection not found: '%s'", idOrName)
}
if len(result.Models) > 1 {
return "", fmt.Errorf("multiple connections found with name '%s', please use the connection ID instead", idOrName)
}
return result.Models[0].ID, nil
}
Loading
Loading