From 185301385a82097469fb690670615b6179c0aeca Mon Sep 17 00:00:00 2001 From: leggetter Date: Mon, 23 Mar 2026 17:22:08 +0000 Subject: [PATCH 1/6] 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."}, }), From 04a9a2f164d73fc78d73ca58999a535a10deaaca Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Mar 2026 09:38:08 +0000 Subject: [PATCH 2/6] feat(hookdeck): add ProjectOrg to Client for MCP context ProjectName remains the short project name; ProjectOrg holds the organization label for telemetry clones and MCP meta. WithTelemetry copies both fields. Made-with: Cursor --- pkg/hookdeck/client.go | 10 ++++++++++ pkg/hookdeck/client_telemetry_test.go | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 11edd204..4826a34a 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -49,6 +49,14 @@ type Client struct { ProjectID string + // ProjectOrg is the organization segment for the active project (MCP meta), + // when applicable. Not sent on API requests. + ProjectOrg string + + // ProjectName is the short project name (not including org). Used for MCP + // meta and display composition with ProjectOrg. Not sent on API requests. + ProjectName string + // When this is enabled, request and response headers will be printed to // stdout. Verbose bool @@ -79,6 +87,8 @@ func (c *Client) WithTelemetry(t *CLITelemetry) *Client { BaseURL: c.BaseURL, APIKey: c.APIKey, ProjectID: c.ProjectID, + ProjectOrg: c.ProjectOrg, + ProjectName: c.ProjectName, Verbose: c.Verbose, SuppressRateLimitErrors: c.SuppressRateLimitErrors, Telemetry: t, diff --git a/pkg/hookdeck/client_telemetry_test.go b/pkg/hookdeck/client_telemetry_test.go index 4ad6a5e7..e2d96ff1 100644 --- a/pkg/hookdeck/client_telemetry_test.go +++ b/pkg/hookdeck/client_telemetry_test.go @@ -17,6 +17,8 @@ func TestWithTelemetry(t *testing.T) { BaseURL: baseURL, APIKey: "test-key", ProjectID: "proj-123", + ProjectOrg: "Acme", + ProjectName: "prod", Verbose: true, SuppressRateLimitErrors: true, TelemetryDisabled: false, @@ -43,6 +45,8 @@ func TestWithTelemetry(t *testing.T) { require.Equal(t, original.BaseURL, cloned.BaseURL) require.Equal(t, original.APIKey, cloned.APIKey) require.Equal(t, original.ProjectID, cloned.ProjectID) + require.Equal(t, original.ProjectOrg, cloned.ProjectOrg) + require.Equal(t, original.ProjectName, cloned.ProjectName) require.Equal(t, original.Verbose, cloned.Verbose) require.Equal(t, original.SuppressRateLimitErrors, cloned.SuppressRateLimitErrors) require.Equal(t, original.TelemetryDisabled, cloned.TelemetryDisabled) From fc556d142da98d77ce1cee44e59a34e42cc85100 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Mar 2026 09:38:14 +0000 Subject: [PATCH 3/6] feat(mcp): envelope meta with active_project_org and short name JSONResultEnvelope takes separate org and short name; active_project_org is omitted when empty. Add fillProjectDisplayNameIfNeeded before handlers to resolve org/name from ListProjects when only project id is cached. Made-with: Cursor --- pkg/gateway/mcp/project_display.go | 32 +++++++++ pkg/gateway/mcp/project_display_test.go | 42 ++++++++++++ pkg/gateway/mcp/response.go | 66 +++++++++++------- pkg/gateway/mcp/response_test.go | 91 +++++++++++++++++++++++++ pkg/gateway/mcp/server.go | 2 + 5 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 pkg/gateway/mcp/project_display.go create mode 100644 pkg/gateway/mcp/project_display_test.go create mode 100644 pkg/gateway/mcp/response_test.go diff --git a/pkg/gateway/mcp/project_display.go b/pkg/gateway/mcp/project_display.go new file mode 100644 index 00000000..c16cffd8 --- /dev/null +++ b/pkg/gateway/mcp/project_display.go @@ -0,0 +1,32 @@ +package mcp + +import ( + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/project" +) + +// fillProjectDisplayNameIfNeeded sets client.ProjectOrg and client.ProjectName from +// ListProjects when the client has an API key and project id but no cached org/name +// (typical after loading profile from disk). Fails silently on API errors. +// Stdio MCP invokes tools sequentially, so this is safe without locking. +func fillProjectDisplayNameIfNeeded(client *hookdeck.Client) { + if client == nil || client.APIKey == "" || client.ProjectID == "" { + return + } + if client.ProjectName != "" || client.ProjectOrg != "" { + return + } + projects, err := client.ListProjects() + if err != nil { + return + } + items := project.NormalizeProjects(projects, client.ProjectID) + for i := range items { + if items[i].Id != client.ProjectID { + continue + } + client.ProjectOrg = items[i].Org + client.ProjectName = items[i].Project + return + } +} diff --git a/pkg/gateway/mcp/project_display_test.go b/pkg/gateway/mcp/project_display_test.go new file mode 100644 index 00000000..51beac71 --- /dev/null +++ b/pkg/gateway/mcp/project_display_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/require" +) + +func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/2025-07-01/teams" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": "proj_x", "name": "[Acme] production", "mode": "console"}, + }) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + client := &hookdeck.Client{ + BaseURL: u, + APIKey: "k", + ProjectID: "proj_x", + } + fillProjectDisplayNameIfNeeded(client) + require.Equal(t, "Acme", client.ProjectOrg) + require.Equal(t, "production", client.ProjectName) +} + +func TestFillProjectDisplayNameIfNeeded_NoOpWhenNameSet(t *testing.T) { + client := &hookdeck.Client{ProjectID: "p", ProjectName: "already"} + fillProjectDisplayNameIfNeeded(client) + require.Equal(t, "already", client.ProjectName) +} diff --git a/pkg/gateway/mcp/response.go b/pkg/gateway/mcp/response.go index b9d0a416..b25a04b7 100644 --- a/pkg/gateway/mcp/response.go +++ b/pkg/gateway/mcp/response.go @@ -4,36 +4,46 @@ import ( "encoding/json" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) -// 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) +// JSONResultEnvelope returns a CallToolResult whose text body is always: +// +// {"data":,"meta":{...}} +// +// When projectID is non-empty, meta always includes active_project_id and +// active_project_name (short name; may be ""). active_project_org is included +// when projectOrg is non-empty. When projectID is empty, meta is {}. +func JSONResultEnvelope(data any, projectID, projectOrg, projectShortName string) (*mcpsdk.CallToolResult, error) { + dataBytes, err := json.Marshal(data) if err != nil { return nil, err } + var metaBytes []byte if projectID == "" { - return &mcpsdk.CallToolResult{ - Content: []mcpsdk.Content{ - &mcpsdk.TextContent{Text: string(data)}, - }, - }, nil + metaBytes = []byte("{}") + } else { + m := map[string]string{ + "active_project_id": projectID, + "active_project_name": projectShortName, + } + if projectOrg != "" { + m["active_project_org"] = projectOrg + } + metaBytes, err = json.Marshal(m) + if err != nil { + return nil, err + } } - 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 + env := struct { + Data json.RawMessage `json:"data"` + Meta json.RawMessage `json:"meta"` + }{ + Data: dataBytes, + Meta: metaBytes, } - pid, _ := json.Marshal(projectID) - m["active_project_id"] = pid - out, err := json.Marshal(m) + out, err := json.Marshal(env) if err != nil { return nil, err } @@ -44,9 +54,17 @@ func JSONResultWithProjectID(v any, projectID string) (*mcpsdk.CallToolResult, e }, nil } +// JSONResultEnvelopeForClient wraps data using the client's project id, org, and short name. +func JSONResultEnvelopeForClient(data any, c *hookdeck.Client) (*mcpsdk.CallToolResult, error) { + if c == nil { + return JSONResultEnvelope(data, "", "", "") + } + return JSONResultEnvelope(data, c.ProjectID, c.ProjectOrg, c.ProjectName) +} + // 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. +// text content. Prefer JSONResultEnvelope for Hookdeck MCP tools so responses +// follow the standard data/meta shape. func JSONResult(v any) (*mcpsdk.CallToolResult, error) { data, err := json.Marshal(v) if err != nil { diff --git a/pkg/gateway/mcp/response_test.go b/pkg/gateway/mcp/response_test.go new file mode 100644 index 00000000..e466e123 --- /dev/null +++ b/pkg/gateway/mcp/response_test.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "encoding/json" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func firstText(t *testing.T, res *mcpsdk.CallToolResult) string { + t.Helper() + require.NotEmpty(t, res.Content) + tc, ok := res.Content[0].(*mcpsdk.TextContent) + require.True(t, ok, "expected TextContent, got %T", res.Content[0]) + return tc.Text +} + +func TestJSONResultEnvelope_NoProject_MetaEmptyObject(t *testing.T) { + res, err := JSONResultEnvelope(map[string]any{"items": []int{1}}, "", "", "") + require.NoError(t, err) + text := firstText(t, res) + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(text), &root)) + require.Contains(t, root, "data") + require.Contains(t, root, "meta") + var inner map[string]any + require.NoError(t, json.Unmarshal(root["data"], &inner)) + items := inner["items"].([]any) + require.Equal(t, float64(1), items[0]) + var meta map[string]any + require.NoError(t, json.Unmarshal(root["meta"], &meta)) + require.NotContains(t, meta, "active_project_id") + require.NotContains(t, meta, "active_project_name") + require.NotContains(t, meta, "active_project_org") +} + +func TestJSONResultEnvelope_WithProject_FlatMetaFields(t *testing.T) { + res, err := JSONResultEnvelope( + map[string]any{"count": 2}, + "tm_Mcf7DGlOQmds", + "Demos", + "trigger-dev-github", + ) + require.NoError(t, err) + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root)) + var dataObj struct { + Count int `json:"count"` + } + require.NoError(t, json.Unmarshal(root["data"], &dataObj)) + require.Equal(t, 2, dataObj.Count) + + var meta struct { + ActiveProjectID string `json:"active_project_id"` + ActiveProjectOrg string `json:"active_project_org"` + ActiveProjectName string `json:"active_project_name"` + } + require.NoError(t, json.Unmarshal(root["meta"], &meta)) + require.Equal(t, "tm_Mcf7DGlOQmds", meta.ActiveProjectID) + require.Equal(t, "Demos", meta.ActiveProjectOrg) + require.Equal(t, "trigger-dev-github", meta.ActiveProjectName) +} + +func TestJSONResultEnvelope_DataCanBeArray(t *testing.T) { + res, err := JSONResultEnvelope([]int{1, 2}, "proj_x", "", "Name") + require.NoError(t, err) + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root)) + var arr []int + require.NoError(t, json.Unmarshal(root["data"], &arr)) + require.Equal(t, []int{1, 2}, arr) + var meta map[string]any + require.NoError(t, json.Unmarshal(root["meta"], &meta)) + require.Equal(t, "proj_x", meta["active_project_id"]) + require.Equal(t, "Name", meta["active_project_name"]) + require.NotContains(t, meta, "active_project_org") +} + +func TestJSONResultEnvelope_IDOnly_IncludesEmptyName(t *testing.T) { + res, err := JSONResultEnvelope(map[string]int{"n": 1}, "proj_only", "", "") + require.NoError(t, err) + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root)) + var meta map[string]any + require.NoError(t, json.Unmarshal(root["meta"], &meta)) + require.Equal(t, "proj_only", meta["active_project_id"]) + require.Contains(t, meta, "active_project_name") + require.Equal(t, "", meta["active_project_name"]) + require.NotContains(t, meta, "active_project_org") +} diff --git a/pkg/gateway/mcp/server.go b/pkg/gateway/mcp/server.go index 6b07636b..e1ba7d88 100644 --- a/pkg/gateway/mcp/server.go +++ b/pkg/gateway/mcp/server.go @@ -105,6 +105,8 @@ func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler) } defer func() { s.client.Telemetry = nil }() + fillProjectDisplayNameIfNeeded(s.client) + return handler(ctx, req) } } From fa126f4a5c67c512d8844ca5b4469fb2e25b3124 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Mar 2026 09:38:20 +0000 Subject: [PATCH 4/6] feat(mcp): use JSONResultEnvelopeForClient on resource tools Wire list/get and login/projects handlers so meta includes split org and short project name from the shared client. Made-with: Cursor --- pkg/gateway/mcp/tool_attempts.go | 4 ++-- pkg/gateway/mcp/tool_connections.go | 8 ++++---- pkg/gateway/mcp/tool_destinations.go | 4 ++-- pkg/gateway/mcp/tool_events.go | 6 +++--- pkg/gateway/mcp/tool_issues.go | 4 ++-- pkg/gateway/mcp/tool_login.go | 11 +++++++++++ pkg/gateway/mcp/tool_metrics.go | 8 ++++---- pkg/gateway/mcp/tool_projects.go | 20 ++++++++++++-------- pkg/gateway/mcp/tool_requests.go | 10 +++++----- pkg/gateway/mcp/tool_sources.go | 4 ++-- pkg/gateway/mcp/tool_transformations.go | 4 ++-- 11 files changed, 49 insertions(+), 34 deletions(-) diff --git a/pkg/gateway/mcp/tool_attempts.go b/pkg/gateway/mcp/tool_attempts.go index 01fdd983..07af6f44 100644 --- a/pkg/gateway/mcp/tool_attempts.go +++ b/pkg/gateway/mcp/tool_attempts.go @@ -45,7 +45,7 @@ func attemptsList(ctx context.Context, client *hookdeck.Client, in input) (*mcps if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } func attemptsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -57,5 +57,5 @@ func attemptsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsd if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(attempt) + return JSONResultEnvelopeForClient(attempt, client) } diff --git a/pkg/gateway/mcp/tool_connections.go b/pkg/gateway/mcp/tool_connections.go index 97268b34..c38579fa 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -67,7 +67,7 @@ func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mc if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(conn) + return JSONResultEnvelopeForClient(conn, client) } func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -79,7 +79,7 @@ func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (* if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(conn) + return JSONResultEnvelopeForClient(conn, client) } func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -91,5 +91,5 @@ func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in input) if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(conn) + return JSONResultEnvelopeForClient(conn, client) } diff --git a/pkg/gateway/mcp/tool_destinations.go b/pkg/gateway/mcp/tool_destinations.go index db890911..f0630921 100644 --- a/pkg/gateway/mcp/tool_destinations.go +++ b/pkg/gateway/mcp/tool_destinations.go @@ -43,7 +43,7 @@ func destinationsList(ctx context.Context, client *hookdeck.Client, in input) (* if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } func destinationsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -55,5 +55,5 @@ func destinationsGet(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(dest) + return JSONResultEnvelopeForClient(dest, client) } diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 44141fef..a63a4ed1 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } func eventsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -69,7 +69,7 @@ func eventsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk. if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(event) + return JSONResultEnvelopeForClient(event, client) } func eventsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -85,6 +85,6 @@ func eventsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcp if len(body) > maxRawBodyBytes { text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" } - return JSONResult(map[string]string{"raw_body": text}) + return JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) } diff --git a/pkg/gateway/mcp/tool_issues.go b/pkg/gateway/mcp/tool_issues.go index eb4d0670..c66fb962 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } func issuesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -59,6 +59,6 @@ func issuesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk. if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(issue) + return JSONResultEnvelopeForClient(issue, client) } diff --git a/pkg/gateway/mcp/tool_login.go b/pkg/gateway/mcp/tool_login.go index 0650ffc7..502e34c8 100644 --- a/pkg/gateway/mcp/tool_login.go +++ b/pkg/gateway/mcp/tool_login.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/project" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -122,6 +124,15 @@ func handleLogin(client *hookdeck.Client, cfg *config.Config, mcpServer *mcpsdk. // Update the shared client so all resource tools start working. client.APIKey = response.APIKey client.ProjectID = response.ProjectID + org, proj, err := project.ParseProjectName(response.ProjectName) + if err != nil { + org, proj = "", response.ProjectName + } + if o := strings.TrimSpace(response.OrganizationName); o != "" { + org = o + } + client.ProjectOrg = org + client.ProjectName = proj // Remove the login tool now that auth is complete. mcpServer.RemoveTools("hookdeck_login") diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 17d15647..9866b815 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } diff --git a/pkg/gateway/mcp/tool_projects.go b/pkg/gateway/mcp/tool_projects.go index 1f1be0fd..b3303394 100644 --- a/pkg/gateway/mcp/tool_projects.go +++ b/pkg/gateway/mcp/tool_projects.go @@ -60,7 +60,9 @@ func projectsList(client *hookdeck.Client) (*mcpsdk.CallToolResult, error) { Current: it.Current, } } - return JSONResult(entries) + return JSONResultEnvelopeForClient(map[string]any{ + "projects": entries, + }, client) } func projectsUse(client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -87,15 +89,17 @@ func projectsUse(client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, err } client.ProjectID = id + client.ProjectOrg = found.Org + client.ProjectName = found.Project - displayName := found.Project - if found.Org != "" { - displayName = found.Org + " / " + found.Project - } - return JSONResult(map[string]string{ + out := map[string]string{ "project_id": id, - "project_name": displayName, + "project_name": found.Project, "type": config.ProjectTypeToJSON(found.Type), "status": "ok", - }) + } + if found.Org != "" { + out["project_org"] = found.Org + } + return JSONResultEnvelopeForClient(out, client) } diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 08be02c4..42d9ac47 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 JSONResultWithProjectID(result, client.ProjectID) + return JSONResultEnvelopeForClient(result, client) } func requestsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -73,7 +73,7 @@ func requestsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsd if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(r) + return JSONResultEnvelopeForClient(r, client) } func requestsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -89,7 +89,7 @@ func requestsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*m if len(body) > maxRawBodyBytes { text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" } - return JSONResult(map[string]string{"raw_body": text}) + return JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) } func requestsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -101,7 +101,7 @@ func requestsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mc if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -113,6 +113,6 @@ func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in inpu if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } diff --git a/pkg/gateway/mcp/tool_sources.go b/pkg/gateway/mcp/tool_sources.go index 542d8d44..44843611 100644 --- a/pkg/gateway/mcp/tool_sources.go +++ b/pkg/gateway/mcp/tool_sources.go @@ -43,7 +43,7 @@ func sourcesList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsd if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } func sourcesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -55,5 +55,5 @@ func sourcesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(source) + return JSONResultEnvelopeForClient(source, client) } diff --git a/pkg/gateway/mcp/tool_transformations.go b/pkg/gateway/mcp/tool_transformations.go index 0ce8b28a..f8de2cb0 100644 --- a/pkg/gateway/mcp/tool_transformations.go +++ b/pkg/gateway/mcp/tool_transformations.go @@ -43,7 +43,7 @@ func transformationsList(ctx context.Context, client *hookdeck.Client, in input) if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(result) + return JSONResultEnvelopeForClient(result, client) } func transformationsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { @@ -55,5 +55,5 @@ func transformationsGet(ctx context.Context, client *hookdeck.Client, in input) if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - return JSONResult(t) + return JSONResultEnvelopeForClient(t, client) } From 9e7927f324fc1bd01d5e7308f462ab8a290b307b Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Mar 2026 09:38:24 +0000 Subject: [PATCH 5/6] docs(mcp): help and tests for active_project_org meta Update hookdeck_help copy, tool descriptions, and assertions for the split org / short name fields in meta and current-project display. Made-with: Cursor --- pkg/gateway/mcp/server_test.go | 12 +++++++- pkg/gateway/mcp/tool_help.go | 51 +++++++++++++++++++++++++++---- pkg/gateway/mcp/tool_help_test.go | 29 ++++++++++++++++++ pkg/gateway/mcp/tools.go | 4 +-- 4 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 pkg/gateway/mcp/tool_help_test.go diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 8488f78e..f9372572 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -374,7 +374,11 @@ func TestConnectionsGet_Success(t *testing.T) { result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "web_conn1"}) assert.False(t, result.IsError) - assert.Contains(t, textContent(t, result), "web_conn1") + text := textContent(t, result) + assert.Contains(t, text, "web_conn1") + assert.Contains(t, text, `"data"`) + assert.Contains(t, text, `"meta"`) + assert.Contains(t, text, `"active_project_id"`) } func TestConnectionsGet_MissingID(t *testing.T) { @@ -817,10 +821,15 @@ func TestProjectsList_Success(t *testing.T) { result := callTool(t, session, "hookdeck_projects", map[string]any{"action": "list"}) assert.False(t, result.IsError) text := textContent(t, result) + assert.Contains(t, text, `"data"`) + assert.Contains(t, text, `"meta"`) + assert.Contains(t, text, `"projects"`) assert.Contains(t, text, "Production") assert.Contains(t, text, "Staging") // Current project should be marked assert.Contains(t, text, "proj_test123") + // newTestClient sets ProjectID — scope lives in meta.active_project_* + assert.Contains(t, text, `"active_project_id"`) } func TestProjectsUse_Success(t *testing.T) { @@ -839,6 +848,7 @@ func TestProjectsUse_Success(t *testing.T) { assert.Contains(t, text, "proj_new") assert.Contains(t, text, "Staging") assert.Contains(t, text, "ok") + assert.Contains(t, text, `"active_project_id"`) } func TestProjectsUse_MissingProjectID(t *testing.T) { diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 8bdcbbea..d9573138 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -25,16 +25,54 @@ func handleHelp(client *hookdeck.Client) mcpsdk.ToolHandler { } } -func helpOverview(client *hookdeck.Client) *mcpsdk.CallToolResult { - projectInfo := "not set" +// formatCurrentProject builds a display label from org + short name (or a legacy +// combined ProjectName), and appends the project id in parentheses when set. +func formatCurrentProject(client *hookdeck.Client) string { + if client.ProjectID == "" && client.ProjectName == "" && client.ProjectOrg == "" { + return "not set" + } + var label string + switch { + case client.ProjectOrg != "" && client.ProjectName != "": + label = client.ProjectOrg + " / " + client.ProjectName + case client.ProjectName != "": + label = client.ProjectName + case client.ProjectOrg != "": + label = client.ProjectOrg + } if client.ProjectID != "" { - projectInfo = client.ProjectID + if label != "" { + return fmt.Sprintf("%s (%s)", label, client.ProjectID) + } + return client.ProjectID } + return label +} + +// mcpJSONSuccessResponseHelp documents the envelope returned by every resource tool. +// Keep in sync with JSONResultEnvelope in response.go. +const mcpJSONSuccessResponseHelp = `Common JSON response shape (all resource tools) +Successful tool calls that return JSON share one envelope. Parse the tool result body as JSON: + + • "data" — Domain payload for this tool and action (same shapes as Hookdeck list/get APIs, + or { "raw_body": "..." } for raw_body actions, or { "projects": [...] } for hookdeck_projects list). + • "meta" — Cross-cutting fields. When a Hookdeck project is in scope: "active_project_id" (string) + and "active_project_name" (string, short name without org) are always present; name may be "" if + unresolved. "active_project_org" (string) is included when known; omitted when empty. + If no project id is set, "meta" is {}. + +Plain text (not this shape): hookdeck_help text, hookdeck_login prompts, and error messages. +Errors use the host error flag; bodies are plain text, not JSON envelopes.` + +func helpOverview(client *hookdeck.Client) *mcpsdk.CallToolResult { + projectInfo := formatCurrentProject(client) text := fmt.Sprintf(`Hookdeck MCP Server — Available Tools Current project: %s +%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. @@ -50,7 +88,8 @@ hookdeck_issues — Inspect aggregated failure signals (actions: list, hookdeck_metrics — Query aggregate metrics (actions: events, requests, attempts, transformations) hookdeck_help — This help text -Use hookdeck_help with topic="" for detailed help on a specific tool.`, projectInfo) +Use hookdeck_help with topic="" for detailed help on a specific tool; each topic +repeats the common JSON response shape above for convenience.`, projectInfo, mcpJSONSuccessResponseHelp) return TextResult(text) } @@ -65,7 +104,7 @@ scoped to the active project — if the wrong project is active, all results wil 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. + list — List all projects. data.projects is the array (id, org, project, type gateway/outpost/console, current). meta includes active_project_id, active_project_name (short), and active_project_org when known. Outbound projects are excluded. use — Switch the active project for this session (in-memory only). Parameters: @@ -251,7 +290,7 @@ func helpTopic(topic string) *mcpsdk.CallToolResult { } text, ok := toolHelp[topic] if ok { - return TextResult(text) + return TextResult(text + "\n\n" + mcpJSONSuccessResponseHelp) } // If the topic doesn't match a tool name exactly, it may be a natural diff --git a/pkg/gateway/mcp/tool_help_test.go b/pkg/gateway/mcp/tool_help_test.go new file mode 100644 index 00000000..a1401ed7 --- /dev/null +++ b/pkg/gateway/mcp/tool_help_test.go @@ -0,0 +1,29 @@ +package mcp + +import ( + "net/url" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/require" +) + +func TestFormatCurrentProject(t *testing.T) { + u, _ := url.Parse("https://api.example.com") + tests := []struct { + name string + client *hookdeck.Client + expected string + }{ + {"empty", &hookdeck.Client{BaseURL: u}, "not set"}, + {"id only", &hookdeck.Client{BaseURL: u, ProjectID: "tm_abc"}, "tm_abc"}, + {"name only", &hookdeck.Client{BaseURL: u, ProjectName: "Demos / app"}, "Demos / app"}, + {"name and id", &hookdeck.Client{BaseURL: u, ProjectID: "tm_abc", ProjectName: "Demos / app"}, "Demos / app (tm_abc)"}, + {"org name and id", &hookdeck.Client{BaseURL: u, ProjectID: "tm_abc", ProjectOrg: "Demos", ProjectName: "app"}, "Demos / app (tm_abc)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, formatCurrentProject(tt.client)) + }) + } +} diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 95dfda44..af835d49 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: "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.", + 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. JSON successes use a standard data/meta envelope; see hookdeck_help (overview or any tool topic).", 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)"}, @@ -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. Note: all tools operate on the active project — use `hookdeck_projects` to verify or switch project context before querying.", + 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. The overview and each tool topic document the common JSON response shape (data + meta). 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."}, }), From 8bfe2969498416ed68f541c1de1526e77eb32f06 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Mar 2026 09:38:27 +0000 Subject: [PATCH 6/6] docs(agents): require GPG-signed commits for maintainers Document that commits must be signed and that agents should use full permissions when gpg-agent is otherwise unreachable. Made-with: Cursor --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1603f6f8..bc48bcef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,6 +249,12 @@ Commands that need network (e.g. `git push`, `gh pr create`, `npm install`) or f **Prefer requesting elevated permissions** (e.g. `required_permissions: ["all"]` or `["network"]`) and asking the user to approve so the agent can retry the command. Do not default to prompting the user to run commands themselves when elevation is available. Only fall back to copy-pasteable commands when elevated permissions are not an option. +### Git commits (GPG) + +**Always use GPG-signed commits** (`git commit -S`, or `commit.gpgsign=true` in git config). **Do not** use `--no-gpg-sign` to bypass signing. + +In restricted environments, signing may fail with errors like “No agent running” or “Operation not permitted” on `~/.gnupg`. **Re-run the commit with full permissions** so `gpg-agent` is reachable, or sign from a normal local terminal. Unsigned commits should not be pushed as a shortcut. + ### Linting and Formatting ```bash # Format code