diff --git a/README.md b/README.md index a42eb4c..254fac4 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/package.json b/package.json index 5cec825..7f3fbba 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", diff --git a/pkg/gateway/mcp/input.go b/pkg/gateway/mcp/input.go index ad91feb..f2f3a20 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 0000000..885e6d1 --- /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 80fcc34..6164fd9 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 a63a4ed..5874143 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 87271c1..ae1310f 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 42d9ac4..655ae62 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 501d853..59fba1e 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 aa572b2..38765da 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 351e6c5..5d531a5 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")