Skip to content

Commit f7b2890

Browse files
feat(mcp): improve project context awareness in MCP tool descriptions (#274)
* feat(mcp): improve project context awareness in MCP tool descriptions * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: leggetter <leggetter@users.noreply.github.com> Co-authored-by: Phil Leggetter <phil@leggetter.co.uk>
1 parent 65b64e4 commit f7b2890

23 files changed

+408
-50
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ Commands that need network (e.g. `git push`, `gh pr create`, `npm install`) or f
249249
250250
**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.
251251
252+
### Git commits (GPG)
253+
254+
**Always use GPG-signed commits** (`git commit -S`, or `commit.gpgsign=true` in git config). **Do not** use `--no-gpg-sign` to bypass signing.
255+
256+
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.
257+
252258
### Linting and Formatting
253259
```bash
254260
# Format code

pkg/gateway/mcp/project_display.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package mcp
2+
3+
import (
4+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
5+
"github.com/hookdeck/hookdeck-cli/pkg/project"
6+
)
7+
8+
// fillProjectDisplayNameIfNeeded sets client.ProjectOrg and client.ProjectName from
9+
// ListProjects when the client has an API key and project id but no cached org/name
10+
// (typical after loading profile from disk). Fails silently on API errors.
11+
// Stdio MCP invokes tools sequentially, so this is safe without locking.
12+
func fillProjectDisplayNameIfNeeded(client *hookdeck.Client) {
13+
if client == nil || client.APIKey == "" || client.ProjectID == "" {
14+
return
15+
}
16+
if client.ProjectName != "" || client.ProjectOrg != "" {
17+
return
18+
}
19+
projects, err := client.ListProjects()
20+
if err != nil {
21+
return
22+
}
23+
items := project.NormalizeProjects(projects, client.ProjectID)
24+
for i := range items {
25+
if items[i].Id != client.ProjectID {
26+
continue
27+
}
28+
client.ProjectOrg = items[i].Org
29+
client.ProjectName = items[i].Project
30+
return
31+
}
32+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) {
15+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
if r.URL.Path != "/2025-07-01/teams" {
17+
http.NotFound(w, r)
18+
return
19+
}
20+
_ = json.NewEncoder(w).Encode([]map[string]any{
21+
{"id": "proj_x", "name": "[Acme] production", "mode": "console"},
22+
})
23+
}))
24+
t.Cleanup(srv.Close)
25+
26+
u, err := url.Parse(srv.URL)
27+
require.NoError(t, err)
28+
client := &hookdeck.Client{
29+
BaseURL: u,
30+
APIKey: "k",
31+
ProjectID: "proj_x",
32+
}
33+
fillProjectDisplayNameIfNeeded(client)
34+
require.Equal(t, "Acme", client.ProjectOrg)
35+
require.Equal(t, "production", client.ProjectName)
36+
}
37+
38+
func TestFillProjectDisplayNameIfNeeded_NoOpWhenNameSet(t *testing.T) {
39+
client := &hookdeck.Client{ProjectID: "p", ProjectName: "already"}
40+
fillProjectDisplayNameIfNeeded(client)
41+
require.Equal(t, "already", client.ProjectName)
42+
}

pkg/gateway/mcp/response.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,67 @@ import (
44
"encoding/json"
55

66
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
7+
8+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
79
)
810

11+
// JSONResultEnvelope returns a CallToolResult whose text body is always:
12+
//
13+
// {"data":<payload>,"meta":{...}}
14+
//
15+
// When projectID is non-empty, meta always includes active_project_id and
16+
// active_project_name (short name; may be ""). active_project_org is included
17+
// when projectOrg is non-empty. When projectID is empty, meta is {}.
18+
func JSONResultEnvelope(data any, projectID, projectOrg, projectShortName string) (*mcpsdk.CallToolResult, error) {
19+
dataBytes, err := json.Marshal(data)
20+
if err != nil {
21+
return nil, err
22+
}
23+
var metaBytes []byte
24+
if projectID == "" {
25+
metaBytes = []byte("{}")
26+
} else {
27+
m := map[string]string{
28+
"active_project_id": projectID,
29+
"active_project_name": projectShortName,
30+
}
31+
if projectOrg != "" {
32+
m["active_project_org"] = projectOrg
33+
}
34+
metaBytes, err = json.Marshal(m)
35+
if err != nil {
36+
return nil, err
37+
}
38+
}
39+
env := struct {
40+
Data json.RawMessage `json:"data"`
41+
Meta json.RawMessage `json:"meta"`
42+
}{
43+
Data: dataBytes,
44+
Meta: metaBytes,
45+
}
46+
out, err := json.Marshal(env)
47+
if err != nil {
48+
return nil, err
49+
}
50+
return &mcpsdk.CallToolResult{
51+
Content: []mcpsdk.Content{
52+
&mcpsdk.TextContent{Text: string(out)},
53+
},
54+
}, nil
55+
}
56+
57+
// JSONResultEnvelopeForClient wraps data using the client's project id, org, and short name.
58+
func JSONResultEnvelopeForClient(data any, c *hookdeck.Client) (*mcpsdk.CallToolResult, error) {
59+
if c == nil {
60+
return JSONResultEnvelope(data, "", "", "")
61+
}
62+
return JSONResultEnvelope(data, c.ProjectID, c.ProjectOrg, c.ProjectName)
63+
}
64+
965
// JSONResult creates a CallToolResult containing the JSON-encoded value as
10-
// text content. This is the standard way to return structured data from a
11-
// tool handler.
66+
// text content. Prefer JSONResultEnvelope for Hookdeck MCP tools so responses
67+
// follow the standard data/meta shape.
1268
func JSONResult(v any) (*mcpsdk.CallToolResult, error) {
1369
data, err := json.Marshal(v)
1470
if err != nil {

pkg/gateway/mcp/response_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func firstText(t *testing.T, res *mcpsdk.CallToolResult) string {
12+
t.Helper()
13+
require.NotEmpty(t, res.Content)
14+
tc, ok := res.Content[0].(*mcpsdk.TextContent)
15+
require.True(t, ok, "expected TextContent, got %T", res.Content[0])
16+
return tc.Text
17+
}
18+
19+
func TestJSONResultEnvelope_NoProject_MetaEmptyObject(t *testing.T) {
20+
res, err := JSONResultEnvelope(map[string]any{"items": []int{1}}, "", "", "")
21+
require.NoError(t, err)
22+
text := firstText(t, res)
23+
var root map[string]json.RawMessage
24+
require.NoError(t, json.Unmarshal([]byte(text), &root))
25+
require.Contains(t, root, "data")
26+
require.Contains(t, root, "meta")
27+
var inner map[string]any
28+
require.NoError(t, json.Unmarshal(root["data"], &inner))
29+
items := inner["items"].([]any)
30+
require.Equal(t, float64(1), items[0])
31+
var meta map[string]any
32+
require.NoError(t, json.Unmarshal(root["meta"], &meta))
33+
require.NotContains(t, meta, "active_project_id")
34+
require.NotContains(t, meta, "active_project_name")
35+
require.NotContains(t, meta, "active_project_org")
36+
}
37+
38+
func TestJSONResultEnvelope_WithProject_FlatMetaFields(t *testing.T) {
39+
res, err := JSONResultEnvelope(
40+
map[string]any{"count": 2},
41+
"tm_Mcf7DGlOQmds",
42+
"Demos",
43+
"trigger-dev-github",
44+
)
45+
require.NoError(t, err)
46+
var root map[string]json.RawMessage
47+
require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root))
48+
var dataObj struct {
49+
Count int `json:"count"`
50+
}
51+
require.NoError(t, json.Unmarshal(root["data"], &dataObj))
52+
require.Equal(t, 2, dataObj.Count)
53+
54+
var meta struct {
55+
ActiveProjectID string `json:"active_project_id"`
56+
ActiveProjectOrg string `json:"active_project_org"`
57+
ActiveProjectName string `json:"active_project_name"`
58+
}
59+
require.NoError(t, json.Unmarshal(root["meta"], &meta))
60+
require.Equal(t, "tm_Mcf7DGlOQmds", meta.ActiveProjectID)
61+
require.Equal(t, "Demos", meta.ActiveProjectOrg)
62+
require.Equal(t, "trigger-dev-github", meta.ActiveProjectName)
63+
}
64+
65+
func TestJSONResultEnvelope_DataCanBeArray(t *testing.T) {
66+
res, err := JSONResultEnvelope([]int{1, 2}, "proj_x", "", "Name")
67+
require.NoError(t, err)
68+
var root map[string]json.RawMessage
69+
require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root))
70+
var arr []int
71+
require.NoError(t, json.Unmarshal(root["data"], &arr))
72+
require.Equal(t, []int{1, 2}, arr)
73+
var meta map[string]any
74+
require.NoError(t, json.Unmarshal(root["meta"], &meta))
75+
require.Equal(t, "proj_x", meta["active_project_id"])
76+
require.Equal(t, "Name", meta["active_project_name"])
77+
require.NotContains(t, meta, "active_project_org")
78+
}
79+
80+
func TestJSONResultEnvelope_IDOnly_IncludesEmptyName(t *testing.T) {
81+
res, err := JSONResultEnvelope(map[string]int{"n": 1}, "proj_only", "", "")
82+
require.NoError(t, err)
83+
var root map[string]json.RawMessage
84+
require.NoError(t, json.Unmarshal([]byte(firstText(t, res)), &root))
85+
var meta map[string]any
86+
require.NoError(t, json.Unmarshal(root["meta"], &meta))
87+
require.Equal(t, "proj_only", meta["active_project_id"])
88+
require.Contains(t, meta, "active_project_name")
89+
require.Equal(t, "", meta["active_project_name"])
90+
require.NotContains(t, meta, "active_project_org")
91+
}

pkg/gateway/mcp/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler)
105105
}
106106
defer func() { s.client.Telemetry = nil }()
107107

108+
fillProjectDisplayNameIfNeeded(s.client)
109+
108110
return handler(ctx, req)
109111
}
110112
}

pkg/gateway/mcp/server_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,11 @@ func TestConnectionsGet_Success(t *testing.T) {
374374

375375
result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "web_conn1"})
376376
assert.False(t, result.IsError)
377-
assert.Contains(t, textContent(t, result), "web_conn1")
377+
text := textContent(t, result)
378+
assert.Contains(t, text, "web_conn1")
379+
assert.Contains(t, text, `"data"`)
380+
assert.Contains(t, text, `"meta"`)
381+
assert.Contains(t, text, `"active_project_id"`)
378382
}
379383

380384
func TestConnectionsGet_MissingID(t *testing.T) {
@@ -817,10 +821,15 @@ func TestProjectsList_Success(t *testing.T) {
817821
result := callTool(t, session, "hookdeck_projects", map[string]any{"action": "list"})
818822
assert.False(t, result.IsError)
819823
text := textContent(t, result)
824+
assert.Contains(t, text, `"data"`)
825+
assert.Contains(t, text, `"meta"`)
826+
assert.Contains(t, text, `"projects"`)
820827
assert.Contains(t, text, "Production")
821828
assert.Contains(t, text, "Staging")
822829
// Current project should be marked
823830
assert.Contains(t, text, "proj_test123")
831+
// newTestClient sets ProjectID — scope lives in meta.active_project_*
832+
assert.Contains(t, text, `"active_project_id"`)
824833
}
825834

826835
func TestProjectsUse_Success(t *testing.T) {
@@ -839,6 +848,7 @@ func TestProjectsUse_Success(t *testing.T) {
839848
assert.Contains(t, text, "proj_new")
840849
assert.Contains(t, text, "Staging")
841850
assert.Contains(t, text, "ok")
851+
assert.Contains(t, text, `"active_project_id"`)
842852
}
843853

844854
func TestProjectsUse_MissingProjectID(t *testing.T) {

pkg/gateway/mcp/tool_attempts.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func attemptsList(ctx context.Context, client *hookdeck.Client, in input) (*mcps
4545
if err != nil {
4646
return ErrorResult(TranslateAPIError(err)), nil
4747
}
48-
return JSONResult(result)
48+
return JSONResultEnvelopeForClient(result, client)
4949
}
5050

5151
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
5757
if err != nil {
5858
return ErrorResult(TranslateAPIError(err)), nil
5959
}
60-
return JSONResult(attempt)
60+
return JSONResultEnvelopeForClient(attempt, client)
6161
}

pkg/gateway/mcp/tool_connections.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func connectionsList(ctx context.Context, client *hookdeck.Client, in input) (*m
5555
if err != nil {
5656
return ErrorResult(TranslateAPIError(err)), nil
5757
}
58-
return JSONResult(result)
58+
return JSONResultEnvelopeForClient(result, client)
5959
}
6060

6161
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
6767
if err != nil {
6868
return ErrorResult(TranslateAPIError(err)), nil
6969
}
70-
return JSONResult(conn)
70+
return JSONResultEnvelopeForClient(conn, client)
7171
}
7272

7373
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) (*
7979
if err != nil {
8080
return ErrorResult(TranslateAPIError(err)), nil
8181
}
82-
return JSONResult(conn)
82+
return JSONResultEnvelopeForClient(conn, client)
8383
}
8484

8585
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)
9191
if err != nil {
9292
return ErrorResult(TranslateAPIError(err)), nil
9393
}
94-
return JSONResult(conn)
94+
return JSONResultEnvelopeForClient(conn, client)
9595
}

pkg/gateway/mcp/tool_destinations.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func destinationsList(ctx context.Context, client *hookdeck.Client, in input) (*
4343
if err != nil {
4444
return ErrorResult(TranslateAPIError(err)), nil
4545
}
46-
return JSONResult(result)
46+
return JSONResultEnvelopeForClient(result, client)
4747
}
4848

4949
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
5555
if err != nil {
5656
return ErrorResult(TranslateAPIError(err)), nil
5757
}
58-
return JSONResult(dest)
58+
return JSONResultEnvelopeForClient(dest, client)
5959
}

0 commit comments

Comments
 (0)