Skip to content

Commit f6f1264

Browse files
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 <cursoragent@cursor.com>
1 parent eeaecc8 commit f6f1264

10 files changed

Lines changed: 544 additions & 46 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,8 @@ The client starts `hookdeck gateway mcp` as a stdio subprocess. If you haven't a
596596
| `hookdeck_metrics` | Query aggregate metrics — counts, failure rates, queue depth over time |
597597
| `hookdeck_help` | Discover available tools and their actions |
598598

599+
`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.
600+
599601
#### Example prompts
600602

601603
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:
621623
622624
"Compare failure rates across all my destinations this week."
623625
→ Agent uses hookdeck_metrics with dimensions set to destination_id and measures like error_rate.
626+
627+
"Find Stripe charge.succeeded events from the last week."
628+
→ Agent uses hookdeck_events list with body filter {"type":"charge.succeeded"} and created_after / created_before ISO datetimes.
629+
630+
"Show failed events that had delivery attempts in the last 24 hours."
631+
→ Agent uses hookdeck_events list with status FAILED and last_attempt_after set to yesterday's ISO datetime.
624632
```
625633

626634
### Manage connections

pkg/gateway/mcp/input.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,44 @@ func setInt(params map[string]string, key string, value int) {
113113
params[key] = strconv.Itoa(value)
114114
}
115115
}
116+
117+
// JSONFilterParam returns a JSON filter value for API query params (body, headers, etc.).
118+
// Accepts a JSON string or object from MCP tool arguments.
119+
func (in input) JSONFilterParam(key string) (string, error) {
120+
v, ok := in[key]
121+
if !ok {
122+
return "", nil
123+
}
124+
switch val := v.(type) {
125+
case string:
126+
return val, nil
127+
case map[string]interface{}:
128+
b, err := json.Marshal(val)
129+
if err != nil {
130+
return "", fmt.Errorf("%s: invalid JSON object: %w", key, err)
131+
}
132+
return string(b), nil
133+
default:
134+
return "", fmt.Errorf("%s must be a JSON string or object", key)
135+
}
136+
}
137+
138+
// setJSONFilter adds a JSON filter param when present and valid.
139+
func setJSONFilter(params map[string]string, key string, in input) error {
140+
value, err := in.JSONFilterParam(key)
141+
if err != nil {
142+
return err
143+
}
144+
setIfNonEmpty(params, key, value)
145+
return nil
146+
}
147+
148+
// setPayloadSearchFilters forwards body, headers, parsed_query, and path list filters.
149+
func setPayloadSearchFilters(params map[string]string, in input) error {
150+
for _, key := range []string{"body", "headers", "parsed_query", "path"} {
151+
if err := setJSONFilter(params, key, in); err != nil {
152+
return err
153+
}
154+
}
155+
return nil
156+
}

pkg/gateway/mcp/input_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package mcp
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestInput_JSONFilterParam_Missing(t *testing.T) {
11+
in := input{}
12+
value, err := in.JSONFilterParam("body")
13+
require.NoError(t, err)
14+
assert.Empty(t, value)
15+
}
16+
17+
func TestInput_JSONFilterParam_String(t *testing.T) {
18+
in := input{"body": `{"type":"payment"}`}
19+
value, err := in.JSONFilterParam("body")
20+
require.NoError(t, err)
21+
assert.Equal(t, `{"type":"payment"}`, value)
22+
}
23+
24+
func TestInput_JSONFilterParam_Object(t *testing.T) {
25+
in := input{"body": map[string]interface{}{"type": "payment", "amount": float64(100)}}
26+
value, err := in.JSONFilterParam("body")
27+
require.NoError(t, err)
28+
assert.JSONEq(t, `{"type":"payment","amount":100}`, value)
29+
}
30+
31+
func TestInput_JSONFilterParam_InvalidType(t *testing.T) {
32+
in := input{"body": 42}
33+
_, err := in.JSONFilterParam("body")
34+
require.Error(t, err)
35+
assert.Contains(t, err.Error(), "body must be a JSON string or object")
36+
}
37+
38+
func TestSetPayloadSearchFilters(t *testing.T) {
39+
params := make(map[string]string)
40+
in := input{
41+
"body": map[string]interface{}{"a": "b"},
42+
"headers": `{"x-test":"1"}`,
43+
"parsed_query": map[string]interface{}{"q": "x"},
44+
"path": "/webhooks",
45+
}
46+
require.NoError(t, setPayloadSearchFilters(params, in))
47+
assert.JSONEq(t, `{"a":"b"}`, params["body"])
48+
assert.Equal(t, `{"x-test":"1"}`, params["headers"])
49+
assert.JSONEq(t, `{"q":"x"}`, params["parsed_query"])
50+
assert.Equal(t, "/webhooks", params["path"])
51+
}

pkg/gateway/mcp/server_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,35 @@ func TestHelpTool_SpecificTopic(t *testing.T) {
191191
assert.Contains(t, text, "raw_body")
192192
}
193193

194+
func TestHelpEventsTopic_DocumentsDateRangeFilters(t *testing.T) {
195+
client := newTestClient("https://api.hookdeck.com", "test-key")
196+
session := connectInMemory(t, client)
197+
198+
result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_events"})
199+
assert.False(t, result.IsError)
200+
text := textContent(t, result)
201+
assert.Contains(t, text, "Date range filters")
202+
assert.Contains(t, text, "created_after")
203+
assert.Contains(t, text, "successful_after")
204+
assert.Contains(t, text, "last_attempt_after")
205+
assert.Contains(t, text, "ISO 8601")
206+
assert.Contains(t, text, "created_at[gte]")
207+
}
208+
209+
func TestHelpRequestsTopic_DocumentsDateRangeFilters(t *testing.T) {
210+
client := newTestClient("https://api.hookdeck.com", "test-key")
211+
session := connectInMemory(t, client)
212+
213+
result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_requests"})
214+
assert.False(t, result.IsError)
215+
text := textContent(t, result)
216+
assert.Contains(t, text, "Date range filters")
217+
assert.Contains(t, text, "created_after")
218+
assert.Contains(t, text, "ingested_after")
219+
assert.Contains(t, text, "ISO 8601")
220+
assert.Contains(t, text, "ingested_at[gte]")
221+
}
222+
194223
func TestHelpTool_ShortTopicName(t *testing.T) {
195224
// "events" should resolve to "hookdeck_events"
196225
client := newTestClient("https://api.hookdeck.com", "test-key")
@@ -695,6 +724,118 @@ func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) {
695724
assert.False(t, result.IsError)
696725
}
697726

727+
func TestEventsList_BodyFilter(t *testing.T) {
728+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
729+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
730+
assert.JSONEq(t, `{"type":"payment"}`, r.URL.Query().Get("body"))
731+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
732+
},
733+
})
734+
735+
result := callTool(t, session, "hookdeck_events", map[string]any{
736+
"action": "list",
737+
"body": map[string]any{"type": "payment"},
738+
})
739+
assert.False(t, result.IsError)
740+
}
741+
742+
func TestEventsList_PayloadFilters(t *testing.T) {
743+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
744+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
745+
assert.Equal(t, `{"x-test":"1"}`, r.URL.Query().Get("headers"))
746+
assert.JSONEq(t, `{"q":"search"}`, r.URL.Query().Get("parsed_query"))
747+
assert.Equal(t, "/webhooks", r.URL.Query().Get("path"))
748+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
749+
},
750+
})
751+
752+
result := callTool(t, session, "hookdeck_events", map[string]any{
753+
"action": "list",
754+
"headers": `{"x-test":"1"}`,
755+
"parsed_query": map[string]any{"q": "search"},
756+
"path": "/webhooks",
757+
})
758+
assert.False(t, result.IsError)
759+
}
760+
761+
func TestEventsList_MetadataFilters(t *testing.T) {
762+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
763+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
764+
assert.Equal(t, "evt_1,evt_2", r.URL.Query().Get("id"))
765+
assert.Equal(t, "3", r.URL.Query().Get("attempts"))
766+
assert.Equal(t, "cli_abc", r.URL.Query().Get("cli_id"))
767+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
768+
},
769+
})
770+
771+
result := callTool(t, session, "hookdeck_events", map[string]any{
772+
"action": "list",
773+
"id": "evt_1,evt_2",
774+
"attempts": "3",
775+
"cli_id": "cli_abc",
776+
})
777+
assert.False(t, result.IsError)
778+
}
779+
780+
func TestEventsList_InvalidBodyFilter(t *testing.T) {
781+
client := newTestClient("https://api.hookdeck.com", "test-key")
782+
session := connectInMemory(t, client)
783+
result := callTool(t, session, "hookdeck_events", map[string]any{"action": "list", "body": 42})
784+
assert.True(t, result.IsError)
785+
assert.Contains(t, textContent(t, result), "body must be a JSON string or object")
786+
}
787+
788+
func TestEventsList_CreatedAtDateRange(t *testing.T) {
789+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
790+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
791+
assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]"))
792+
assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]"))
793+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
794+
},
795+
})
796+
797+
result := callTool(t, session, "hookdeck_events", map[string]any{
798+
"action": "list",
799+
"created_after": "2026-06-01T00:00:00Z",
800+
"created_before": "2026-06-09T23:59:59Z",
801+
})
802+
assert.False(t, result.IsError)
803+
}
804+
805+
func TestEventsList_SuccessfulAtDateRange(t *testing.T) {
806+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
807+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
808+
assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("successful_at[gte]"))
809+
assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("successful_at[lte]"))
810+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
811+
},
812+
})
813+
814+
result := callTool(t, session, "hookdeck_events", map[string]any{
815+
"action": "list",
816+
"successful_after": "2026-06-01T00:00:00Z",
817+
"successful_before": "2026-06-09T23:59:59Z",
818+
})
819+
assert.False(t, result.IsError)
820+
}
821+
822+
func TestEventsList_LastAttemptAtDateRange(t *testing.T) {
823+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
824+
"/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) {
825+
assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("last_attempt_at[gte]"))
826+
assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("last_attempt_at[lte]"))
827+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"}))
828+
},
829+
})
830+
831+
result := callTool(t, session, "hookdeck_events", map[string]any{
832+
"action": "list",
833+
"last_attempt_after": "2026-06-01T00:00:00Z",
834+
"last_attempt_before": "2026-06-09T23:59:59Z",
835+
})
836+
assert.False(t, result.IsError)
837+
}
838+
698839
// ---------------------------------------------------------------------------
699840
// Requests tool
700841
// ---------------------------------------------------------------------------
@@ -824,6 +965,72 @@ func TestRequestsList_VerifiedFilter(t *testing.T) {
824965
assert.False(t, result.IsError)
825966
}
826967

968+
func TestRequestsList_BodyFilter(t *testing.T) {
969+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
970+
"/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) {
971+
assert.JSONEq(t, `{"event":"test"}`, r.URL.Query().Get("body"))
972+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"}))
973+
},
974+
})
975+
976+
result := callTool(t, session, "hookdeck_requests", map[string]any{
977+
"action": "list",
978+
"body": map[string]any{"event": "test"},
979+
})
980+
assert.False(t, result.IsError)
981+
}
982+
983+
func TestRequestsList_CreatedAtDateRange(t *testing.T) {
984+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
985+
"/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) {
986+
assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]"))
987+
assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]"))
988+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"}))
989+
},
990+
})
991+
992+
result := callTool(t, session, "hookdeck_requests", map[string]any{
993+
"action": "list",
994+
"created_after": "2026-06-01T00:00:00Z",
995+
"created_before": "2026-06-09T23:59:59Z",
996+
})
997+
assert.False(t, result.IsError)
998+
}
999+
1000+
func TestRequestsList_IngestedAtDateRange(t *testing.T) {
1001+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
1002+
"/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) {
1003+
assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("ingested_at[gte]"))
1004+
assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("ingested_at[lte]"))
1005+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"}))
1006+
},
1007+
})
1008+
1009+
result := callTool(t, session, "hookdeck_requests", map[string]any{
1010+
"action": "list",
1011+
"ingested_after": "2026-06-01T00:00:00Z",
1012+
"ingested_before": "2026-06-09T23:59:59Z",
1013+
})
1014+
assert.False(t, result.IsError)
1015+
}
1016+
1017+
func TestRequestsList_OrderByAndDir(t *testing.T) {
1018+
session := mockAPIWithClient(t, map[string]http.HandlerFunc{
1019+
"/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) {
1020+
assert.Equal(t, "created_at", r.URL.Query().Get("order_by"))
1021+
assert.Equal(t, "desc", r.URL.Query().Get("dir"))
1022+
json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"}))
1023+
},
1024+
})
1025+
1026+
result := callTool(t, session, "hookdeck_requests", map[string]any{
1027+
"action": "list",
1028+
"order_by": "created_at",
1029+
"dir": "desc",
1030+
})
1031+
assert.False(t, result.IsError)
1032+
}
1033+
8271034
// ---------------------------------------------------------------------------
8281035
// Issues tool
8291036
// ---------------------------------------------------------------------------

pkg/gateway/mcp/tool_events.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,31 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler {
3636

3737
func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
3838
params := make(map[string]string)
39+
setIfNonEmpty(params, "id", in.String("id"))
3940
// connection_id maps to webhook_id in the API
4041
setIfNonEmpty(params, "webhook_id", in.String("connection_id"))
4142
setIfNonEmpty(params, "source_id", in.String("source_id"))
4243
setIfNonEmpty(params, "destination_id", in.String("destination_id"))
4344
setIfNonEmpty(params, "status", in.String("status"))
45+
setIfNonEmpty(params, "attempts", in.String("attempts"))
4446
setIfNonEmpty(params, "issue_id", in.String("issue_id"))
4547
setIfNonEmpty(params, "error_code", in.String("error_code"))
4648
setIfNonEmpty(params, "response_status", in.String("response_status"))
47-
// Date range mapping
49+
setIfNonEmpty(params, "cli_id", in.String("cli_id"))
4850
setIfNonEmpty(params, "created_at[gte]", in.String("created_after"))
4951
setIfNonEmpty(params, "created_at[lte]", in.String("created_before"))
52+
setIfNonEmpty(params, "successful_at[gte]", in.String("successful_after"))
53+
setIfNonEmpty(params, "successful_at[lte]", in.String("successful_before"))
54+
setIfNonEmpty(params, "last_attempt_at[gte]", in.String("last_attempt_after"))
55+
setIfNonEmpty(params, "last_attempt_at[lte]", in.String("last_attempt_before"))
5056
setInt(params, "limit", in.Int("limit", 0))
5157
setIfNonEmpty(params, "order_by", in.String("order_by"))
5258
setIfNonEmpty(params, "dir", in.String("dir"))
5359
setIfNonEmpty(params, "next", in.String("next"))
5460
setIfNonEmpty(params, "prev", in.String("prev"))
61+
if err := setPayloadSearchFilters(params, in); err != nil {
62+
return ErrorResult(err.Error()), nil
63+
}
5564

5665
result, err := client.ListEvents(ctx, params)
5766
if err != nil {

0 commit comments

Comments
 (0)