From f6f1264325a4f9e44da4eaf803c799600090bef2 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 9 Jun 2026 13:03:38 +0100 Subject: [PATCH 1/7] feat(mcp): add full list filter parity for events and requests Expose payload search (body, headers, parsed_query, path), date range after/before params, and remaining CLI list filters on hookdeck_events and hookdeck_requests. Document ISO 8601 date windows and gte/lte mapping for agents; add unit and MCP acceptance smoke tests. Co-authored-by: Cursor --- README.md | 8 ++ pkg/gateway/mcp/input.go | 41 ++++++ pkg/gateway/mcp/input_test.go | 51 ++++++++ pkg/gateway/mcp/server_test.go | 207 +++++++++++++++++++++++++++++++ pkg/gateway/mcp/tool_events.go | 11 +- pkg/gateway/mcp/tool_help.go | 70 ++++++++--- pkg/gateway/mcp/tool_requests.go | 10 ++ pkg/gateway/mcp/tools.go | 79 ++++++++---- test/acceptance/helpers.go | 77 ++++++++++++ test/acceptance/mcp_test.go | 36 ++++++ 10 files changed, 544 insertions(+), 46 deletions(-) create mode 100644 pkg/gateway/mcp/input_test.go diff --git a/README.md b/README.md index a42eb4c5..254fac4a 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,8 @@ The client starts `hookdeck gateway mcp` as a stdio subprocess. If you haven't a | `hookdeck_metrics` | Query aggregate metrics — counts, failure rates, queue depth over time | | `hookdeck_help` | Discover available tools and their actions | +`hookdeck_events` and `hookdeck_requests` **list** actions support the same filters as `hookdeck gateway event list` and `hookdeck gateway request list` — including payload search (`body`, `headers`, `parsed_query`, `path`) and date windows via `*_after` / `*_before` (ISO 8601; maps to API `field[gte]` / `field[lte]`). See `hookdeck_help` with topic `hookdeck_events` or `hookdeck_requests` for the full parameter list. + #### Example prompts Once the MCP server is configured, you can ask your agent questions like: @@ -621,6 +623,12 @@ Once the MCP server is configured, you can ask your agent questions like: "Compare failure rates across all my destinations this week." → Agent uses hookdeck_metrics with dimensions set to destination_id and measures like error_rate. + +"Find Stripe charge.succeeded events from the last week." +→ Agent uses hookdeck_events list with body filter {"type":"charge.succeeded"} and created_after / created_before ISO datetimes. + +"Show failed events that had delivery attempts in the last 24 hours." +→ Agent uses hookdeck_events list with status FAILED and last_attempt_after set to yesterday's ISO datetime. ``` ### Manage connections diff --git a/pkg/gateway/mcp/input.go b/pkg/gateway/mcp/input.go index ad91feb0..f2f3a20c 100644 --- a/pkg/gateway/mcp/input.go +++ b/pkg/gateway/mcp/input.go @@ -113,3 +113,44 @@ func setInt(params map[string]string, key string, value int) { params[key] = strconv.Itoa(value) } } + +// JSONFilterParam returns a JSON filter value for API query params (body, headers, etc.). +// Accepts a JSON string or object from MCP tool arguments. +func (in input) JSONFilterParam(key string) (string, error) { + v, ok := in[key] + if !ok { + return "", nil + } + switch val := v.(type) { + case string: + return val, nil + case map[string]interface{}: + b, err := json.Marshal(val) + if err != nil { + return "", fmt.Errorf("%s: invalid JSON object: %w", key, err) + } + return string(b), nil + default: + return "", fmt.Errorf("%s must be a JSON string or object", key) + } +} + +// setJSONFilter adds a JSON filter param when present and valid. +func setJSONFilter(params map[string]string, key string, in input) error { + value, err := in.JSONFilterParam(key) + if err != nil { + return err + } + setIfNonEmpty(params, key, value) + return nil +} + +// setPayloadSearchFilters forwards body, headers, parsed_query, and path list filters. +func setPayloadSearchFilters(params map[string]string, in input) error { + for _, key := range []string{"body", "headers", "parsed_query", "path"} { + if err := setJSONFilter(params, key, in); err != nil { + return err + } + } + return nil +} diff --git a/pkg/gateway/mcp/input_test.go b/pkg/gateway/mcp/input_test.go new file mode 100644 index 00000000..885e6d13 --- /dev/null +++ b/pkg/gateway/mcp/input_test.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInput_JSONFilterParam_Missing(t *testing.T) { + in := input{} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.Empty(t, value) +} + +func TestInput_JSONFilterParam_String(t *testing.T) { + in := input{"body": `{"type":"payment"}`} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.Equal(t, `{"type":"payment"}`, value) +} + +func TestInput_JSONFilterParam_Object(t *testing.T) { + in := input{"body": map[string]interface{}{"type": "payment", "amount": float64(100)}} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"payment","amount":100}`, value) +} + +func TestInput_JSONFilterParam_InvalidType(t *testing.T) { + in := input{"body": 42} + _, err := in.JSONFilterParam("body") + require.Error(t, err) + assert.Contains(t, err.Error(), "body must be a JSON string or object") +} + +func TestSetPayloadSearchFilters(t *testing.T) { + params := make(map[string]string) + in := input{ + "body": map[string]interface{}{"a": "b"}, + "headers": `{"x-test":"1"}`, + "parsed_query": map[string]interface{}{"q": "x"}, + "path": "/webhooks", + } + require.NoError(t, setPayloadSearchFilters(params, in)) + assert.JSONEq(t, `{"a":"b"}`, params["body"]) + assert.Equal(t, `{"x-test":"1"}`, params["headers"]) + assert.JSONEq(t, `{"q":"x"}`, params["parsed_query"]) + assert.Equal(t, "/webhooks", params["path"]) +} diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 80fcc344..6164fd9c 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -191,6 +191,35 @@ func TestHelpTool_SpecificTopic(t *testing.T) { assert.Contains(t, text, "raw_body") } +func TestHelpEventsTopic_DocumentsDateRangeFilters(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_events"}) + assert.False(t, result.IsError) + text := textContent(t, result) + assert.Contains(t, text, "Date range filters") + assert.Contains(t, text, "created_after") + assert.Contains(t, text, "successful_after") + assert.Contains(t, text, "last_attempt_after") + assert.Contains(t, text, "ISO 8601") + assert.Contains(t, text, "created_at[gte]") +} + +func TestHelpRequestsTopic_DocumentsDateRangeFilters(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_requests"}) + assert.False(t, result.IsError) + text := textContent(t, result) + assert.Contains(t, text, "Date range filters") + assert.Contains(t, text, "created_after") + assert.Contains(t, text, "ingested_after") + assert.Contains(t, text, "ISO 8601") + assert.Contains(t, text, "ingested_at[gte]") +} + func TestHelpTool_ShortTopicName(t *testing.T) { // "events" should resolve to "hookdeck_events" client := newTestClient("https://api.hookdeck.com", "test-key") @@ -695,6 +724,118 @@ func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) { assert.False(t, result.IsError) } +func TestEventsList_BodyFilter(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.JSONEq(t, `{"type":"payment"}`, r.URL.Query().Get("body")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "body": map[string]any{"type": "payment"}, + }) + assert.False(t, result.IsError) +} + +func TestEventsList_PayloadFilters(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, `{"x-test":"1"}`, r.URL.Query().Get("headers")) + assert.JSONEq(t, `{"q":"search"}`, r.URL.Query().Get("parsed_query")) + assert.Equal(t, "/webhooks", r.URL.Query().Get("path")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "headers": `{"x-test":"1"}`, + "parsed_query": map[string]any{"q": "search"}, + "path": "/webhooks", + }) + assert.False(t, result.IsError) +} + +func TestEventsList_MetadataFilters(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "evt_1,evt_2", r.URL.Query().Get("id")) + assert.Equal(t, "3", r.URL.Query().Get("attempts")) + assert.Equal(t, "cli_abc", r.URL.Query().Get("cli_id")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "id": "evt_1,evt_2", + "attempts": "3", + "cli_id": "cli_abc", + }) + assert.False(t, result.IsError) +} + +func TestEventsList_InvalidBodyFilter(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + result := callTool(t, session, "hookdeck_events", map[string]any{"action": "list", "body": 42}) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "body must be a JSON string or object") +} + +func TestEventsList_CreatedAtDateRange(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]")) + assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "created_after": "2026-06-01T00:00:00Z", + "created_before": "2026-06-09T23:59:59Z", + }) + assert.False(t, result.IsError) +} + +func TestEventsList_SuccessfulAtDateRange(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("successful_at[gte]")) + assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("successful_at[lte]")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "successful_after": "2026-06-01T00:00:00Z", + "successful_before": "2026-06-09T23:59:59Z", + }) + assert.False(t, result.IsError) +} + +func TestEventsList_LastAttemptAtDateRange(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("last_attempt_at[gte]")) + assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("last_attempt_at[lte]")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", + "last_attempt_after": "2026-06-01T00:00:00Z", + "last_attempt_before": "2026-06-09T23:59:59Z", + }) + assert.False(t, result.IsError) +} + // --------------------------------------------------------------------------- // Requests tool // --------------------------------------------------------------------------- @@ -824,6 +965,72 @@ func TestRequestsList_VerifiedFilter(t *testing.T) { assert.False(t, result.IsError) } +func TestRequestsList_BodyFilter(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + assert.JSONEq(t, `{"event":"test"}`, r.URL.Query().Get("body")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", + "body": map[string]any{"event": "test"}, + }) + assert.False(t, result.IsError) +} + +func TestRequestsList_CreatedAtDateRange(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]")) + assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", + "created_after": "2026-06-01T00:00:00Z", + "created_before": "2026-06-09T23:59:59Z", + }) + assert.False(t, result.IsError) +} + +func TestRequestsList_IngestedAtDateRange(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("ingested_at[gte]")) + assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("ingested_at[lte]")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", + "ingested_after": "2026-06-01T00:00:00Z", + "ingested_before": "2026-06-09T23:59:59Z", + }) + assert.False(t, result.IsError) +} + +func TestRequestsList_OrderByAndDir(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "created_at", r.URL.Query().Get("order_by")) + assert.Equal(t, "desc", r.URL.Query().Get("dir")) + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", + "order_by": "created_at", + "dir": "desc", + }) + assert.False(t, result.IsError) +} + // --------------------------------------------------------------------------- // Issues tool // --------------------------------------------------------------------------- diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index a63a4ed1..5874143b 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -36,22 +36,31 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) + setIfNonEmpty(params, "id", in.String("id")) // connection_id maps to webhook_id in the API setIfNonEmpty(params, "webhook_id", in.String("connection_id")) setIfNonEmpty(params, "source_id", in.String("source_id")) setIfNonEmpty(params, "destination_id", in.String("destination_id")) setIfNonEmpty(params, "status", in.String("status")) + setIfNonEmpty(params, "attempts", in.String("attempts")) setIfNonEmpty(params, "issue_id", in.String("issue_id")) setIfNonEmpty(params, "error_code", in.String("error_code")) setIfNonEmpty(params, "response_status", in.String("response_status")) - // Date range mapping + setIfNonEmpty(params, "cli_id", in.String("cli_id")) setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) + setIfNonEmpty(params, "successful_at[gte]", in.String("successful_after")) + setIfNonEmpty(params, "successful_at[lte]", in.String("successful_before")) + setIfNonEmpty(params, "last_attempt_at[gte]", in.String("last_attempt_after")) + setIfNonEmpty(params, "last_attempt_at[lte]", in.String("last_attempt_before")) setInt(params, "limit", in.Int("limit", 0)) setIfNonEmpty(params, "order_by", in.String("order_by")) setIfNonEmpty(params, "dir", in.String("dir")) setIfNonEmpty(params, "next", in.String("next")) setIfNonEmpty(params, "prev", in.String("prev")) + if err := setPayloadSearchFilters(params, in); err != nil { + return ErrorResult(err.Error()), nil + } result, err := client.ListEvents(ctx, params) if err != nil { diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 87271c15..ae1310f0 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -185,6 +185,8 @@ Parameters: Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. +List supports the same filters as hookdeck gateway request list. + Actions: list — List requests with optional filters get — Get a single request by ID @@ -194,39 +196,69 @@ Actions: Parameters: action (string, required) — list, get, raw_body, events, or ignored_events - id (string) — Required for get/raw_body/events/ignored_events + id (string) — List: filter by request ID(s), comma-separated. Get/raw_body/events/ignored_events: required. source_id (string) — Filter by source (list) - status (string) — Filter by status (list) + status (string) — accepted or rejected (list) rejection_cause (string) — Filter by rejection cause (list) verified (boolean) — Filter by verification status (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, + +Date range filters (list): + Use *_after / *_before with ISO 8601 datetimes (e.g. 2026-06-01T00:00:00Z). Do not pass API bracket keys like created_at[gte] in MCP args. + created_after → created_at[gte] (inclusive lower bound) + created_before → created_at[lte] (inclusive upper bound) + ingested_after → ingested_at[gte] + ingested_before → ingested_at[lte] + Example: {"action":"list","ingested_after":"2026-06-09T12:00:00Z","source_id":"src_abc"} + +Payload search (list): + body, headers, parsed_query — Hookdeck JSON filter syntax (object or string). Same as hookdeck listen --filter-body. + path — partial URL path match (string) + Example: {"action":"list","body":{"type":"charge.succeeded"}} + +Pagination and sort (list): + order_by, dir (asc/desc), limit (default 100), next, prev`, "hookdeck_events": `hookdeck_events — Query events (processed deliveries) Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. +List supports the same filters as hookdeck gateway event list. + Actions: list — List events with optional filters get — Get a single event by ID (metadata and headers only; no payload) raw_body — Get the event payload (body) directly by event ID. Use this when you need the payload; no need to call hookdeck_requests. Parameters: - action (string, required) — list, get, or raw_body - id (string) — Required for get/raw_body - connection_id (string) — Filter by connection (list, maps to webhook_id) - source_id (string) — Filter by source (list) - destination_id (string) — Filter by destination (list) - status (string) — SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED - issue_id (string) — Filter by issue (list) - error_code (string) — Filter by error code (list) - response_status (string) — Filter by HTTP response status (list) - created_after (string) — ISO datetime, lower bound (list) - created_before (string) — ISO datetime, upper bound (list) - limit (integer) — Max results (list, default 100) - order_by (string) — Sort field (list) - dir (string) — "asc" or "desc" (list) - next/prev (string) — Pagination cursors (list)`, + action (string, required) — list, get, or raw_body + id (string) — List: filter by event ID(s), comma-separated. Get/raw_body: required. + connection_id (string) — Filter by connection (list, maps to webhook_id) + source_id (string) — Filter by source (list) + destination_id (string) — Filter by destination (list) + status (string) — SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED + attempts (string) — Filter by attempt count (list); integer or API operator syntax + issue_id (string) — Filter by issue (list) + error_code (string) — Filter by error code (list) + response_status (string) — Filter by HTTP response status (list) + cli_id (string) — Filter by CLI listen session (list) + +Date range filters (list): + Use *_after / *_before with ISO 8601 datetimes. Do not pass API bracket keys in MCP args. + created_after → created_at[gte] + created_before → created_at[lte] + successful_after → successful_at[gte] + successful_before → successful_at[lte] + last_attempt_after → last_attempt_at[gte] + last_attempt_before → last_attempt_at[lte] + Example: {"action":"list","status":"FAILED","last_attempt_after":"2026-06-08T00:00:00Z"} + +Payload search (list): + body, headers, parsed_query — Hookdeck JSON filter syntax (object or string) + path — partial URL path match + Example: {"action":"list","body":{"type":"charge.succeeded"}} + +Pagination and sort (list): + order_by, dir (asc/desc), limit (default 100), next, prev`, "hookdeck_attempts": `hookdeck_attempts — Query delivery attempts diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 42d9ac47..655ae625 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -42,12 +42,22 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { func requestsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) + setIfNonEmpty(params, "id", in.String("id")) setIfNonEmpty(params, "source_id", in.String("source_id")) setIfNonEmpty(params, "status", in.String("status")) setIfNonEmpty(params, "rejection_cause", in.String("rejection_cause")) + setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) + setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) + setIfNonEmpty(params, "ingested_at[gte]", in.String("ingested_after")) + setIfNonEmpty(params, "ingested_at[lte]", in.String("ingested_before")) setInt(params, "limit", in.Int("limit", 0)) + setIfNonEmpty(params, "order_by", in.String("order_by")) + setIfNonEmpty(params, "dir", in.String("dir")) setIfNonEmpty(params, "next", in.String("next")) setIfNonEmpty(params, "prev", in.String("prev")) + if err := setPayloadSearchFilters(params, in); err != nil { + return ErrorResult(err.Error()), nil + } if bp := in.BoolPtr("verified"); bp != nil { if *bp { diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 501d8539..59fba1ec 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -96,17 +96,27 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_requests", - Description: "Query inbound requests (raw HTTP data received by Hookdeck before routing). List with filters, get details, inspect the raw body, or view the events and ignored events generated from a request. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", + Description: "Query inbound requests (raw HTTP data received by Hookdeck before routing). List supports the same filters as `hookdeck gateway request list` (metadata, date range, payload search, sort). Get details, inspect raw body, or view events and ignored events from a request. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", InputSchema: schema(map[string]prop{ - "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, - "id": {Type: "string", Desc: "Request ID (required for get/raw_body/events/ignored_events)"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "status": {Type: "string", Desc: "Filter by status (list)"}, + "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, + "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, - "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, + "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, + "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, handler: handleRequests(client), @@ -114,24 +124,34 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_events", - Description: "Query events (processed deliveries routed through connections to destinations). List with filters by status, source, destination, or date range. Get event details (get) or the event payload (raw_body). Use action raw_body with the event id to get the payload directly — do not use hookdeck_requests for the payload when you already have an event id. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", + Description: "Query events (processed deliveries routed through connections to destinations). List supports the same filters as `hookdeck gateway event list` (metadata, date range, payload search, sort). Get event details (get) or the event payload (raw_body). Use action raw_body with the event id to get the payload directly — do not use hookdeck_requests for the payload when you already have an event id. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", InputSchema: schema(map[string]prop{ - "action": {Type: "string", Desc: "Action: list, get, or raw_body. Use raw_body to get the event payload (body); get returns metadata and headers only.", Enum: []string{"list", "get", "raw_body"}}, - "id": {Type: "string", Desc: "Event ID (required for get/raw_body). Use with raw_body to fetch the event payload without querying the request."}, - "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, - "status": {Type: "string", Desc: "Event status: SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED"}, - "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, - "error_code": {Type: "string", Desc: "Filter by error code (list)"}, - "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, - "created_after": {Type: "string", Desc: "ISO datetime lower bound (list)"}, - "created_before": {Type: "string", Desc: "ISO datetime upper bound (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "order_by": {Type: "string", Desc: "Sort field (list)"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, + "action": {Type: "string", Desc: "Action: list, get, or raw_body. Use raw_body to get the event payload (body); get returns metadata and headers only.", Enum: []string{"list", "get", "raw_body"}}, + "id": {Type: "string", Desc: "Event ID: filter by ID(s) on list (comma-separated), or required for get/raw_body"}, + "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, + "status": {Type: "string", Desc: "Event status: SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED"}, + "attempts": {Type: "string", Desc: "Filter by attempt count (list). Integer or API operator syntax; pass through as string."}, + "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, + "error_code": {Type: "string", Desc: "Filter by error code (list)"}, + "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, + "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, + "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, + "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, + "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound. " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, handler: handleEvents(client), @@ -213,6 +233,13 @@ type prop struct { Items *prop `json:"items,omitempty"` } +const ( + descDateAfter = "ISO 8601 datetime lower bound (list). Maps to API field[gte]; do not pass bracket keys in MCP args. Combinable with the matching *_before param." + descDateBefore = "ISO 8601 datetime upper bound (list). Maps to API field[lte]; do not pass bracket keys in MCP args." + descJSONFilter = "Hookdeck JSON filter (object or string). Same syntax as hookdeck listen --filter-body." + descPathFilter = "Partial URL path match (string)." +) + // schema builds a JSON Schema object with the given properties and required fields. func schema(properties map[string]prop, required ...string) json.RawMessage { s := map[string]interface{}{ diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index aa572b2b..38765da4 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -591,6 +591,83 @@ func RunGatewayMCPSubprocess(t *testing.T, projectRoot, configPath string, extra return stdoutBuf.String(), stderrBuf.String(), waitErr } +// MCPToolCallResult is the parsed JSON-RPC response for an MCP tools/call request. +type MCPToolCallResult struct { + IsError bool + Text string + Raw map[string]any +} + +// parseJSONRPCLines returns decoded JSON-RPC messages from stdout (one object per line). +func parseJSONRPCLines(t *testing.T, stdout string) []map[string]any { + t.Helper() + var messages []map[string]any + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + if _, ok := msg["jsonrpc"]; ok { + messages = append(messages, msg) + } + } + return messages +} + +// findJSONRPCResponseByID returns the response object for the given request id. +func findJSONRPCResponseByID(t *testing.T, stdout string, id int) map[string]any { + t.Helper() + for _, msg := range parseJSONRPCLines(t, stdout) { + if msgID, ok := msg["id"].(float64); ok && int(msgID) == id { + return msg + } + } + t.Fatalf("no JSON-RPC response with id %d in stdout: %q", id, stdout) + return nil +} + +// CallGatewayMCPTool runs initialize + notifications/initialized + tools/call over gateway mcp stdio. +func CallGatewayMCPTool(t *testing.T, projectRoot, configPath, toolName string, arguments map[string]any, runDuration time.Duration) MCPToolCallResult { + t.Helper() + argsJSON, err := json.Marshal(arguments) + require.NoError(t, err) + stdin := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"acceptance-test","version":"1.0"},"capabilities":{}}}`, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, toolName, string(argsJSON)), + }, "\n") + "\n" + + extra := map[string]string{} + if configPath != "" { + extra["HOOKDECK_CONFIG_FILE"] = configPath + } + stdout, stderr, waitErr := RunGatewayMCPSubprocess(t, projectRoot, configPath, extra, stdin, runDuration) + if waitErr != nil { + t.Logf("gateway mcp subprocess wait: %v (stderr=%q)", waitErr, stderr) + } + + resp := findJSONRPCResponseByID(t, stdout, 2) + result, ok := resp["result"].(map[string]any) + require.True(t, ok, "tools/call result missing in %v", resp) + + out := MCPToolCallResult{Raw: result} + if isError, ok := result["isError"].(bool); ok { + out.IsError = isError + } + if content, ok := result["content"].([]any); ok && len(content) > 0 { + if block, ok := content[0].(map[string]any); ok { + if text, ok := block["text"].(string); ok { + out.Text = text + } + } + } + return out +} + // RunFromCwd executes the CLI from the current working directory. // This is useful for tests that need to test --local flag behavior, // which creates config in the current directory. diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index 351e6c51..5d531a57 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -119,6 +119,42 @@ func TestGatewayMCPStdio_NoProjectExitsWithStderr(t *testing.T) { } } +func TestMCPEventsList_DateRangeAndBodyFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "hookdeck_events", map[string]any{ + "action": "list", + "created_after": "2020-01-01T00:00:00Z", + "created_before": "2030-01-01T00:00:00Z", + "body": map[string]any{}, + "limit": 5, + }, 20*time.Second) + assert.False(t, result.IsError, "tool error: %s", result.Text) + assert.Contains(t, result.Text, `"data"`) + assert.True(t, strings.Contains(result.Text, `"models"`) || strings.Contains(result.Text, `"count"`), + "expected list payload in %s", result.Text) +} + +func TestMCPRequestsList_DateRangeAndBodyFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "hookdeck_requests", map[string]any{ + "action": "list", + "ingested_after": "2020-01-01T00:00:00Z", + "created_before": "2030-01-01T00:00:00Z", + "body": map[string]any{}, + "limit": 5, + }, 20*time.Second) + assert.False(t, result.IsError, "tool error: %s", result.Text) + assert.Contains(t, result.Text, `"data"`) + assert.True(t, strings.Contains(result.Text, `"models"`) || strings.Contains(result.Text, `"count"`), + "expected list payload in %s", result.Text) +} + func TestGatewayMCPStdio_OutpostProjectRejected(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") From c130d14eabce75f15dd3c88ab91791562586b1d1 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:27:08 +0000 Subject: [PATCH 2/7] Update package.json version to 2.3.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5cec825f..7f3fbba0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "2.2.0", + "version": "2.3.0-beta.1", "description": "Hookdeck CLI", "repository": { "type": "git", From e362e9d92e3723ee3736330e47588dccd79b8c29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:43:15 +0000 Subject: [PATCH 3/7] chore(deps): bump golang.org/x/sys from 0.45.0 to 0.47.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.45.0 to 0.47.0. - [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.47.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.47.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8205bee5..0a7193d4 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 - golang.org/x/sys v0.45.0 + golang.org/x/sys v0.47.0 golang.org/x/term v0.43.0 ) diff --git a/go.sum b/go.sum index 7deb8a41..f0df3a52 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= From 34572fe790efca7970f7dfe286e2445f08061034 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:55:01 +0000 Subject: [PATCH 4/7] chore(deps): bump golang.org/x/term from 0.43.0 to 0.45.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.43.0 to 0.45.0. - [Commits](https://github.com/golang/term/compare/v0.43.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.45.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 8205bee5..d0f03a33 100644 --- a/go.mod +++ b/go.mod @@ -22,8 +22,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 ) require ( diff --git a/go.sum b/go.sum index 7deb8a41..bfda0dbe 100644 --- a/go.sum +++ b/go.sum @@ -205,12 +205,12 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= From d89fdd2c80feebfe4c3cc296aa6ee48f2f234cb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:42:57 +0000 Subject: [PATCH 5/7] chore(deps): bump github.com/modelcontextprotocol/go-sdk Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.6.1 to 1.7.0. - [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.6.1...v1.7.0) --- updated-dependencies: - dependency-name: github.com/modelcontextprotocol/go-sdk dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 4 +++- go.sum | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8205bee5..40ecf8cf 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gosimple/slug v1.15.0 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mitchellh/go-homedir v1.1.0 - github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -70,7 +70,9 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.15.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 7deb8a41..dcc610db 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= -github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -191,6 +191,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -218,6 +220,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 5c5f2f97d236ad30edb507da69f3fd70ab2ff090 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 30 Jul 2026 10:32:11 +0100 Subject: [PATCH 6/7] docs(listen): surface --cli-key/--api-key on listen for ad-hoc auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: document that `listen` accepts a key directly — add an authentication example to `hookdeck listen --help` and a section in the README listen docs covering `--cli-key` (user-scoped CLI key) and `--api-key` (project-scoped key). Why: passing a key to `listen` already works (both are global persistent flags bound to the profile key), but the flags are hidden, so the capability was undiscoverable. Users authenticating in CI or switching accounts had no signal that `listen --cli-key ` is supported. A CLI key is user-scoped and can navigate across projects; a project API/CI key is scoped to one project — the docs now make that distinction explicit. No behavior change: this is help-text and README only. Docs are hand-maintained (no cobra doc-gen in the repo), so the README is updated directly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016QCuRsTPjpi6hbu9WSaE5J --- README.md | 14 ++++++++++++++ pkg/cmd/listen.go | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index cf5d737b..c845ac24 100644 --- a/README.md +++ b/README.md @@ -197,8 +197,22 @@ Flags: --filter-headers string Filter events by request headers using Hookdeck filter syntax (JSON) --filter-query string Filter events by query parameters using Hookdeck filter syntax (JSON) --filter-path string Filter events by request path using Hookdeck filter syntax (JSON) + --cli-key string Authenticate with a user-scoped CLI key instead of the stored login + --api-key string Authenticate with a project-scoped key instead of the stored login ``` +By default `listen` uses the credentials saved by `hookdeck login`. To authenticate a single invocation without logging in first — for example in CI or when switching accounts — pass a key directly: + +```sh +# User-scoped CLI key (created by `hookdeck login`; can access all your projects) +$ hookdeck listen 3000 stripe --cli-key + +# Project-scoped key (e.g. a Project API key or a key from `hookdeck ci`) +$ hookdeck listen 3000 stripe --api-key +``` + +Both flags are global, so they work with any command. A **CLI key** is tied to your user account and can navigate across projects; a **project API key** is scoped to a single project. Within the CLI both are stored and used the same way (see [Credential Types](#security-config-files-and-source-control)). + Hookdeck works by routing events received for a given `source` (i.e., Shopify, Github, etc.) to its defined `destination` by connecting them with a `connection` to a `destination`. The CLI allows you to receive events for any given connection and forward them to your localhost at the specified port or any valid URL. Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time. diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index b26bc0c2..f77f22d3 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -226,6 +226,12 @@ Examples: Filter using operators (see https://hookdeck.com/docs/filters for syntax): hookdeck listen %[1]d api --filter-body '{"amount": {"$gte": 100}}' + + Authenticate with a specific key instead of the stored login, e.g. in CI or + when switching accounts (--cli-key takes a user-scoped CLI key; --api-key a + project-scoped key): + + hookdeck listen %[1]d stripe --cli-key `, 3000) lc.cmd.SetUsageTemplate(usage) From 78ea2440517ccef94a034e5da9f23e074a40b15a Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 30 Jul 2026 10:39:58 +0100 Subject: [PATCH 7/7] docs: name the Event Gateway product correctly in the listen intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listen overview said "Hookdeck works by routing events…", attributing a product capability to the company/brand. Hookdeck is the company; the Event Gateway is the product that routes events. Reword so the product is named correctly, and note that `hookdeck listen` is a standalone command that works with whichever product you're authenticated with (Hookdeck Console or the Event Gateway). Also tidy the duplicated `destination`, `i.e.`→`e.g.`, and `Github`→`GitHub`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016QCuRsTPjpi6hbu9WSaE5J --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c845ac24..3e1378f1 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ $ hookdeck listen 3000 stripe --api-key Both flags are global, so they work with any command. A **CLI key** is tied to your user account and can navigate across projects; a **project API key** is scoped to a single project. Within the CLI both are stored and used the same way (see [Credential Types](#security-config-files-and-source-control)). -Hookdeck works by routing events received for a given `source` (i.e., Shopify, Github, etc.) to its defined `destination` by connecting them with a `connection` to a `destination`. The CLI allows you to receive events for any given connection and forward them to your localhost at the specified port or any valid URL. +The Event Gateway routes events received for a given `source` (e.g. Shopify, GitHub) to a `destination` via a `connection`. `hookdeck listen` is a standalone command that works with whichever product you're authenticated with — Hookdeck Console or the Event Gateway — receiving events for a given connection and forwarding them to your localhost at the specified port or any valid URL. Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time.