Skip to content
Open
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions pkg/gateway/mcp/project_display.go
Original file line number Diff line number Diff line change
@@ -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
}
}
42 changes: 42 additions & 0 deletions pkg/gateway/mcp/project_display_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 58 additions & 2 deletions pkg/gateway/mcp/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,67 @@ import (
"encoding/json"

mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
)

// JSONResultEnvelope returns a CallToolResult whose text body is always:
//
// {"data":<payload>,"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 == "" {
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
}
}
env := struct {
Data json.RawMessage `json:"data"`
Meta json.RawMessage `json:"meta"`
}{
Data: dataBytes,
Meta: metaBytes,
}
out, err := json.Marshal(env)
if err != nil {
return nil, err
}
return &mcpsdk.CallToolResult{
Content: []mcpsdk.Content{
&mcpsdk.TextContent{Text: string(out)},
},
}, 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 {
Expand Down
91 changes: 91 additions & 0 deletions pkg/gateway/mcp/response_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 2 additions & 0 deletions pkg/gateway/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
12 changes: 11 additions & 1 deletion pkg/gateway/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions pkg/gateway/mcp/tool_attempts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
8 changes: 4 additions & 4 deletions pkg/gateway/mcp/tool_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 JSONResultEnvelopeForClient(result, client)
}

func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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)
}
4 changes: 2 additions & 2 deletions pkg/gateway/mcp/tool_destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
6 changes: 3 additions & 3 deletions pkg/gateway/mcp/tool_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 JSONResultEnvelopeForClient(result, client)
}

func eventsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) {
Expand All @@ -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) {
Expand All @@ -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)
}

Loading
Loading