From 185301385a82097469fb690670615b6179c0aeca Mon Sep 17 00:00:00 2001 From: leggetter Date: Mon, 23 Mar 2026 17:22:08 +0000 Subject: [PATCH] feat(mcp): improve project context awareness in MCP tool descriptions --- pkg/gateway/mcp/response.go | 38 +++++++++++++++++++++++++++++ pkg/gateway/mcp/tool_connections.go | 2 +- pkg/gateway/mcp/tool_events.go | 2 +- pkg/gateway/mcp/tool_help.go | 22 +++++++++++++++++ pkg/gateway/mcp/tool_issues.go | 2 +- pkg/gateway/mcp/tool_metrics.go | 8 +++--- pkg/gateway/mcp/tool_requests.go | 2 +- pkg/gateway/mcp/tools.go | 14 +++++------ 8 files changed, 75 insertions(+), 15 deletions(-) diff --git a/pkg/gateway/mcp/response.go b/pkg/gateway/mcp/response.go index 4b584815..b9d0a416 100644 --- a/pkg/gateway/mcp/response.go +++ b/pkg/gateway/mcp/response.go @@ -6,6 +6,44 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" ) +// JSONResultWithProjectID creates a CallToolResult containing the JSON-encoded +// value with an additional "active_project_id" field merged into the top-level +// object. This allows agents to self-verify that results came from the intended +// project. If projectID is empty, the result is identical to JSONResult. +func JSONResultWithProjectID(v any, projectID string) (*mcpsdk.CallToolResult, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + if projectID == "" { + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(data)}, + }, + }, nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + // v is not a JSON object; return as-is + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(data)}, + }, + }, nil + } + pid, _ := json.Marshal(projectID) + m["active_project_id"] = pid + out, err := json.Marshal(m) + if err != nil { + return nil, err + } + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(out)}, + }, + }, nil +} + // JSONResult creates a CallToolResult containing the JSON-encoded value as // text content. This is the standard way to return structured data from a // tool handler. diff --git a/pkg/gateway/mcp/tool_connections.go b/pkg/gateway/mcp/tool_connections.go index 6d7c327e..97268b34 100644 --- a/pkg/gateway/mcp/tool_connections.go +++ b/pkg/gateway/mcp/tool_connections.go @@ -55,7 +55,7 @@ func connectionsList(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index fcd709cb..44141fef 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -57,7 +57,7 @@ func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func eventsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index b27f5e90..8bdcbbea 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -35,6 +35,9 @@ func helpOverview(client *hookdeck.Client) *mcpsdk.CallToolResult { Current project: %s +All tools operate on the active project. Call hookdeck_projects first when the user +references a project by name, or when unsure which project is active. + hookdeck_projects — List or switch projects (actions: list, use) hookdeck_connections — Inspect connections and control delivery flow (actions: list, get, pause, unpause) hookdeck_sources — Inspect inbound sources (actions: list, get) @@ -55,6 +58,12 @@ Use hookdeck_help with topic="" for detailed help on a specific tool. var toolHelp = map[string]string{ "hookdeck_projects": `hookdeck_projects — List or switch the active project +Always call this first when the user references a specific project by name. List available +projects to find the matching project ID, then use the "use" action to switch to it before +calling any other tools. All queries (events, issues, connections, metrics, requests) are +scoped to the active project — if the wrong project is active, all results will be wrong. +Also use this when unsure which project is currently active. + Actions: list — List all projects. Returns id, org, project, type (gateway/outpost/console), and which is current. Outbound projects are excluded. use — Switch the active project for this session (in-memory only). @@ -65,6 +74,8 @@ Parameters: "hookdeck_connections": `hookdeck_connections — Inspect connections and control delivery flow +Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. + Actions: list — List connections with optional filters get — Get a single connection by ID @@ -122,6 +133,8 @@ Parameters: "hookdeck_requests": `hookdeck_requests — Query inbound requests +Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. + Actions: list — List requests with optional filters get — Get a single request by ID @@ -141,6 +154,8 @@ Parameters: "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. + Actions: list — List events with optional filters get — Get a single event by ID (metadata and headers only; no payload) @@ -180,6 +195,8 @@ Parameters: "hookdeck_issues": `hookdeck_issues — Inspect aggregated failure signals +Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. + Actions: list — List issues with optional filters get — Get a single issue by ID @@ -197,6 +214,8 @@ Parameters: "hookdeck_metrics": `hookdeck_metrics — Query aggregate metrics +Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. + Actions: events — Event metrics (auto-routes to queue-depth, pending, or by-issue as needed) requests — Request metrics @@ -218,6 +237,9 @@ Parameters: "hookdeck_help": `hookdeck_help — Get an overview of available tools or detailed help for a specific tool +Note: all tools operate on the active project — use hookdeck_projects to verify or switch +project context before querying. + Parameters: topic (string) — Tool name for detailed help (e.g. "hookdeck_events"). Omit for overview.`, } diff --git a/pkg/gateway/mcp/tool_issues.go b/pkg/gateway/mcp/tool_issues.go index f66c15c9..eb4d0670 100644 --- a/pkg/gateway/mcp/tool_issues.go +++ b/pkg/gateway/mcp/tool_issues.go @@ -47,7 +47,7 @@ func issuesList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func issuesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 05da027d..17d15647 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -95,7 +95,7 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func metricsRequests(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -107,7 +107,7 @@ func metricsRequests(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func metricsAttempts(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -119,7 +119,7 @@ func metricsAttempts(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func metricsTransformations(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -131,5 +131,5 @@ func metricsTransformations(ctx context.Context, client *hookdeck.Client, in inp if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index ad5cbc9c..08be02c4 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -61,7 +61,7 @@ func requestsList(ctx context.Context, client *hookdeck.Client, in input) (*mcps if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultWithProjectID(result, client.ProjectID) } func requestsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 4ae255fe..95dfda44 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -22,7 +22,7 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_projects", - Description: "List available Hookdeck projects or switch the active project for this session. Use this to see which project you're querying and to change project context.", + Description: "Always call this first when the user references a specific project by name. List available projects to find the matching project ID, then use the `use` action to switch to it before calling any other tools. All queries (events, issues, connections, metrics, requests) are scoped to the active project — if the wrong project is active, all results will be wrong. Also use this when unsure which project is currently active.", InputSchema: schema(map[string]prop{ "action": {Type: "string", Desc: "Action to perform: list or use", Enum: []string{"list", "use"}}, "project_id": {Type: "string", Desc: "Project ID (required for use action)"}, @@ -33,7 +33,7 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_connections", - Description: "Inspect connections (routes linking sources to destinations). List connections with filters, get details by ID, or pause/unpause a connection's delivery pipeline.", + Description: "Inspect connections (routes linking sources to destinations). List connections with filters, get details by ID, or pause/unpause a connection's delivery pipeline. 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, pause, or unpause", Enum: []string{"list", "get", "pause", "unpause"}}, "id": {Type: "string", Desc: "Connection ID (required for get/pause/unpause)"}, @@ -96,7 +96,7 @@ 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.", + 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.", 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)"}, @@ -114,7 +114,7 @@ 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.", + 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.", 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."}, @@ -156,7 +156,7 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_issues", - Description: "List and inspect Hookdeck issues — aggregated failure signals such as repeated delivery failures, transformation errors, and backpressure alerts. Use this to identify systemic problems across your event pipeline.", + Description: "List and inspect Hookdeck issues — aggregated failure signals such as repeated delivery failures, transformation errors, and backpressure alerts. Use this to identify systemic problems across your event pipeline. 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 or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Issue ID (required for get)"}, @@ -175,7 +175,7 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_metrics", - Description: "Query aggregate metrics over a time range. Get counts, failure rates, error rates, queue depth, and pending event data for events, requests, attempts, and transformations. Supports grouping by dimensions like source, destination, or connection.", + Description: "Query aggregate metrics over a time range. Get counts, failure rates, error rates, queue depth, and pending event data for events, requests, attempts, and transformations. Supports grouping by dimensions like source, destination, or connection. 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: "Metric type: events, requests, attempts, or transformations", Enum: []string{"events", "requests", "attempts", "transformations"}}, "start": {Type: "string", Desc: "Start datetime (ISO 8601, required)"}, @@ -195,7 +195,7 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_help", - Description: "Get an overview of all available Hookdeck tools or detailed help for a specific tool. Use this when unsure which tool to use for a task.", + Description: "Get an overview of all available Hookdeck tools or detailed help for a specific tool. Use this when unsure which tool to use for a task. Note: all tools operate on the active project — use `hookdeck_projects` to verify or switch project context before querying.", InputSchema: schema(map[string]prop{ "topic": {Type: "string", Desc: "Tool name for detailed help (e.g. hookdeck_events). Omit for overview."}, }),