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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hookdeck-cli",
"version": "2.2.0",
"version": "2.3.0-beta.1",
"description": "Hookdeck CLI",
"repository": {
"type": "git",
Expand Down
41 changes: 41 additions & 0 deletions pkg/gateway/mcp/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
51 changes: 51 additions & 0 deletions pkg/gateway/mcp/input_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
207 changes: 207 additions & 0 deletions pkg/gateway/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
11 changes: 10 additions & 1 deletion pkg/gateway/mcp/tool_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading