Skip to content

Commit f1e1923

Browse files
committed
Merge remote-tracking branch 'origin/integrate/outpost-mcp' into feat/outpost-api-client
2 parents 2b4ec87 + 3b48ac6 commit f1e1923

65 files changed

Lines changed: 5042 additions & 1106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ For a complete reference of all commands and flags, see [REFERENCE.md](REFERENCE
3737
- [Event Gateway](#event-gateway)
3838
- [Event Gateway MCP](#event-gateway-mcp)
3939
- [Outpost](#outpost)
40+
- [Outpost MCP](#outpost-mcp)
4041
- [Manage connections](#manage-connections)
4142
- [Transformations](#transformations)
4243
- [Requests, events, and attempts](#requests-events-and-attempts)
@@ -721,6 +722,56 @@ Publishing is asynchronous: a successful response means the event was accepted,
721722

722723
For complete command and flag reference, see [REFERENCE.md](REFERENCE.md).
723724

725+
### Outpost MCP
726+
727+
`hookdeck outpost mcp` starts an [MCP](https://modelcontextprotocol.io/) server exposing your Outpost project to AI agents: tenants, their destinations, the events published to them, and every delivery attempt. Tools are prefixed `outpost_`, so this server and [Event Gateway MCP](#event-gateway-mcp) can be configured in the same client.
728+
729+
```json
730+
{
731+
"mcpServers": {
732+
"hookdeck-outpost": {
733+
"command": "hookdeck",
734+
"args": ["outpost", "mcp"]
735+
}
736+
}
737+
}
738+
```
739+
740+
The client starts `hookdeck outpost mcp` as a stdio subprocess. If you haven't authenticated yet, the `outpost_login` tool logs in via the browser. The active project must be an Outpost project; `outpost_projects` lists the Outpost projects available to you and switches between them.
741+
742+
#### Read-only by default
743+
744+
The server starts read-only. Each tool advertises only the actions that read data, so an agent is never offered an action it cannot perform. Add `--allow-write` (or set `HOOKDECK_MCP_ALLOW_WRITE=true`; the flag wins) to enable the rest:
745+
746+
```json
747+
"args": ["outpost", "mcp", "--allow-write"]
748+
```
749+
750+
`--read-only` is accepted as an explicit way to ask for the default, and wins if both are passed.
751+
752+
Two actions that only read are gated with the writes, because both return a reusable credential: `outpost_tenants` `token` mints a tenant-scoped access token, and `outpost_tenants` `portal` returns a URL granting access to a tenant's portal.
753+
754+
Publishing needs a Hookdeck **Project API key**, which the credentials stored by `hookdeck login` cannot substitute for. Without one the `outpost_publish` tool is not registered at all; pass `--api-key` or set `HOOKDECK_API_KEY` to enable it.
755+
756+
#### Available tools
757+
758+
| Tool | Description |
759+
|------|-------------|
760+
| `outpost_projects` | List Outpost projects or switch the active one for this session |
761+
| `outpost_tenants` | Inspect tenants (list, get) and manage them (upsert, delete, token, portal) |
762+
| `outpost_destinations` | Inspect a tenant's destinations (list, get) and manage them (create, update, delete, enable, disable) |
763+
| `outpost_events` | Query published events (list, get) and retry delivery |
764+
| `outpost_attempts` | Query delivery attempts — status, response codes, retry history |
765+
| `outpost_publish` | Publish an event to a topic |
766+
| `outpost_topics` | List the topics available in the project |
767+
| `outpost_destination_types` | Inspect destination types and the config and credential fields each accepts |
768+
| `outpost_metrics` | Query aggregate publish and delivery metrics |
769+
| `outpost_config` | Read and change project configuration, including the portal's custom domain |
770+
| `outpost_status` | Show the deployment status |
771+
| `outpost_help` | Discover the available tools, their actions, and the current mode |
772+
773+
Call `outpost_help` at any time to see which mode the session is in and which actions it can perform.
774+
724775
### Manage connections
725776

726777
Create and manage webhook connections between sources and destinations with inline resource creation, authentication, processing rules, and lifecycle management. Use `hookdeck gateway connection` (or the backward-compatible alias `hookdeck connection`). For detailed examples with authentication, filters, retry rules, and rate limiting, see the complete [connection management](#manage-connections) section below.

‎REFERENCE.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3027,6 +3027,78 @@ hookdeck outpost status [flags]
30273027
hookdeck outpost status
30283028
```
30293029
<!-- GENERATE_END -->
3030+
### Outpost MCP server
3031+
3032+
`hookdeck outpost mcp` exposes the Outpost resources above as MCP tools, prefixed `outpost_` so it can be configured alongside `hookdeck gateway mcp` in the same client.
3033+
3034+
It starts **read-only**: each tool advertises only the actions that read data, so an agent is never offered an action it cannot perform. `--allow-write` enables the rest. Two reads are gated with the writes because both return a reusable credential — `outpost_tenants token` mints a tenant-scoped access token, and `outpost_tenants portal` returns a URL granting access to a tenant's portal.
3035+
3036+
The publish tool is only registered when a Hookdeck Project API key is available, since the publish API does not accept the credentials stored by `hookdeck login`.
3037+
3038+
<!-- GENERATE:outpost mcp:START -->
3039+
### hookdeck outpost mcp
3040+
3041+
Starts a Model Context Protocol (MCP) server over stdio.
3042+
3043+
The server exposes Hookdeck Outpost resources — tenants, destinations, events,
3044+
attempts, topics, metrics and project configuration — as MCP tools that AI
3045+
agents and LLM-based clients can invoke. Tools are prefixed outpost_, so this
3046+
server and 'hookdeck gateway mcp' can be configured in the same client.
3047+
3048+
The server starts read-only: tools advertise only the actions that read data,
3049+
so an agent is never offered an action it cannot perform. Pass `--allow-write` to
3050+
enable creating, changing and deleting. Two reads count as writes and are also
3051+
gated, because both return a reusable credential: 'outpost_tenants token' mints
3052+
a tenant-scoped access token, and 'outpost_tenants portal' returns a URL
3053+
granting access to a tenant's portal.
3054+
3055+
Publishing needs a Hookdeck Project API key, which the credentials stored by
3056+
'hookdeck login' cannot substitute for. Without one the publish tool is not
3057+
registered at all; pass `--publish-api-key` or set HOOKDECK_OUTPOST_PUBLISH_API_KEY.
3058+
3059+
This deliberately does not read HOOKDECK_API_KEY, which elsewhere in the CLI
3060+
means "a key to exchange for CLI credentials". Publishing sends real events to
3061+
real destinations and cannot be undone, so it should not be switched on by a
3062+
variable that happens to be exported for something else.
3063+
3064+
If the CLI is already authenticated, all tools are available immediately. If
3065+
not, the server still starts and outpost_login initiates browser-based sign-in.
3066+
Protocol traffic uses stdout only (JSON-RPC); status and errors from the CLI
3067+
before the server runs go to stderr.
3068+
3069+
[BETA] This feature is in beta. Please share bugs and feedback via:
3070+
https://github.com/hookdeck/hookdeck-cli/issues
3071+
3072+
**Usage:**
3073+
3074+
```bash
3075+
hookdeck outpost mcp [flags]
3076+
```
3077+
3078+
**Flags:**
3079+
3080+
| Flag | Type | Description |
3081+
|------|------|-------------|
3082+
| `--allow-write` | `bool` | Enable tools that create, change or delete data, and that return tenant credentials. Also read from HOOKDECK_MCP_ALLOW_WRITE; the flag wins. |
3083+
| `--publish-api-key` | `string` | Hookdeck Project API key, required by the publish tool. Also read from HOOKDECK_OUTPOST_PUBLISH_API_KEY. HOOKDECK_API_KEY is deliberately not used here. |
3084+
| `--read-only` | `bool` | Run without write actions. This is the default; the flag is accepted so it can be passed explicitly, and wins over `--allow-write`. |
3085+
3086+
**Examples:**
3087+
3088+
```bash
3089+
# Start the MCP server, read-only (stdio transport)
3090+
hookdeck outpost mcp
3091+
3092+
# Allow tools that change data
3093+
hookdeck outpost mcp --allow-write
3094+
3095+
# Allow writes, including publishing events
3096+
hookdeck outpost mcp --allow-write --publish-api-key $HOOKDECK_OUTPOST_PUBLISH_API_KEY
3097+
3098+
# Pipe a JSON-RPC initialize request for testing
3099+
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}' | hookdeck outpost mcp
3100+
```
3101+
<!-- GENERATE_END -->
30303102
## Utilities
30313103
30323104
<!-- GENERATE:completion|ci:START -->

‎pkg/cmd/outpost.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ These commands require an Outpost project. Use 'hookdeck project use' to switch.
107107
oc.cmd.AddCommand(newOutpostPublishCmd().cmd)
108108
oc.cmd.AddCommand(newOutpostMetricsCmd().cmd)
109109
oc.cmd.AddCommand(newOutpostConfigCmd().cmd)
110+
addOutpostMCPCmdTo(oc.cmd)
110111

111112
return oc
112113
}

‎pkg/cmd/outpost_mcp.go‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"os"
6+
"strconv"
7+
8+
"github.com/spf13/cobra"
9+
10+
outpostmcp "github.com/hookdeck/hookdeck-cli/pkg/outpost/mcp"
11+
"github.com/hookdeck/hookdeck-cli/pkg/validators"
12+
)
13+
14+
// allowWriteEnvVar enables write actions without a flag, for MCP clients whose
15+
// config makes environment variables easier to set than arguments.
16+
const allowWriteEnvVar = "HOOKDECK_MCP_ALLOW_WRITE"
17+
18+
// publishAPIKeyEnvVar carries the Project API key the publish tool needs.
19+
//
20+
// It is deliberately distinct from HOOKDECK_API_KEY. That variable means
21+
// "exchange this for CLI credentials" everywhere else in the CLI, and is
22+
// commonly exported for CI; reusing it here would give one name two meanings and
23+
// let an ambient variable silently enable sending real events.
24+
const publishAPIKeyEnvVar = "HOOKDECK_OUTPOST_PUBLISH_API_KEY"
25+
26+
type outpostMCPCmd struct {
27+
cmd *cobra.Command
28+
29+
allowWrite bool
30+
readOnly bool
31+
apiKey string
32+
}
33+
34+
func newOutpostMCPCmd() *outpostMCPCmd {
35+
mc := &outpostMCPCmd{}
36+
mc.cmd = &cobra.Command{
37+
Use: "mcp",
38+
Args: validators.NoArgs,
39+
Short: ShortBeta("Start an MCP server for AI agent access to Outpost"),
40+
Long: LongBeta(`Starts a Model Context Protocol (MCP) server over stdio.
41+
42+
The server exposes Hookdeck Outpost resources — tenants, destinations, events,
43+
attempts, topics, metrics and project configuration — as MCP tools that AI
44+
agents and LLM-based clients can invoke. Tools are prefixed outpost_, so this
45+
server and 'hookdeck gateway mcp' can be configured in the same client.
46+
47+
The server starts read-only: tools advertise only the actions that read data,
48+
so an agent is never offered an action it cannot perform. Pass --allow-write to
49+
enable creating, changing and deleting. Two reads count as writes and are also
50+
gated, because both return a reusable credential: 'outpost_tenants token' mints
51+
a tenant-scoped access token, and 'outpost_tenants portal' returns a URL
52+
granting access to a tenant's portal.
53+
54+
Publishing needs a Hookdeck Project API key, which the credentials stored by
55+
'hookdeck login' cannot substitute for. Without one the publish tool is not
56+
registered at all; pass --publish-api-key or set HOOKDECK_OUTPOST_PUBLISH_API_KEY.
57+
58+
This deliberately does not read HOOKDECK_API_KEY, which elsewhere in the CLI
59+
means "a key to exchange for CLI credentials". Publishing sends real events to
60+
real destinations and cannot be undone, so it should not be switched on by a
61+
variable that happens to be exported for something else.
62+
63+
If the CLI is already authenticated, all tools are available immediately. If
64+
not, the server still starts and outpost_login initiates browser-based sign-in.
65+
Protocol traffic uses stdout only (JSON-RPC); status and errors from the CLI
66+
before the server runs go to stderr.`),
67+
Example: ` # Start the MCP server, read-only (stdio transport)
68+
hookdeck outpost mcp
69+
70+
# Allow tools that change data
71+
hookdeck outpost mcp --allow-write
72+
73+
# Allow writes, including publishing events
74+
hookdeck outpost mcp --allow-write --publish-api-key $HOOKDECK_OUTPOST_PUBLISH_API_KEY
75+
76+
# Pipe a JSON-RPC initialize request for testing
77+
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}' | hookdeck outpost mcp`,
78+
RunE: mc.runOutpostMCPCmd,
79+
}
80+
81+
mc.cmd.Flags().BoolVar(&mc.allowWrite, "allow-write", false, "Enable tools that create, change or delete data, and that return tenant credentials. Also read from "+allowWriteEnvVar+"; the flag wins.")
82+
// Users arriving from other MCP servers type --read-only reflexively. It is
83+
// already the default, so accept it rather than failing on an unknown flag.
84+
mc.cmd.Flags().BoolVar(&mc.readOnly, "read-only", false, "Run without write actions. This is the default; the flag is accepted so it can be passed explicitly, and wins over --allow-write.")
85+
// The env var is read at run time rather than used as the flag default, so a
86+
// key that is already in the environment is not printed back out by --help.
87+
mc.cmd.Flags().StringVar(&mc.apiKey, "publish-api-key", "", "Hookdeck Project API key, required by the publish tool. Also read from "+publishAPIKeyEnvVar+". HOOKDECK_API_KEY is deliberately not used here.")
88+
89+
return mc
90+
}
91+
92+
func addOutpostMCPCmdTo(parent *cobra.Command) {
93+
parent.AddCommand(newOutpostMCPCmd().cmd)
94+
}
95+
96+
// resolveAllowWrite decides whether write actions are enabled.
97+
//
98+
// --read-only wins over everything so an explicit request for a safe session is
99+
// never overridden; otherwise --allow-write wins over the environment variable,
100+
// which is the more distant and easier-to-forget setting.
101+
func resolveAllowWrite(allowWriteFlag, allowWriteFlagSet, readOnly bool, envValue string) bool {
102+
if readOnly {
103+
return false
104+
}
105+
if allowWriteFlagSet {
106+
return allowWriteFlag
107+
}
108+
enabled, err := strconv.ParseBool(envValue)
109+
if err != nil {
110+
return false
111+
}
112+
return enabled
113+
}
114+
115+
func (mc *outpostMCPCmd) runOutpostMCPCmd(cmd *cobra.Command, args []string) error {
116+
// Always build the client — it may have an empty APIKey if the CLI is not
117+
// yet authenticated. The server handles that by registering outpost_login
118+
// rather than failing to start.
119+
//
120+
// This must be the Outpost client: the projects and login tools set the
121+
// project on the client they are given, and setting it on the Gateway client
122+
// would leave every Outpost call pointed at the previous project.
123+
client := Config.GetOutpostAPIClient()
124+
125+
// Deliberately not HOOKDECK_API_KEY. That variable means "exchange this for
126+
// CLI credentials" for `hookdeck ci` and `listen`, and the CLI encourages
127+
// exporting it for CI — so reading it here would let an unrelated ambient
128+
// variable silently grant an agent the ability to send real events.
129+
publishAPIKey := mc.apiKey
130+
if publishAPIKey == "" {
131+
publishAPIKey = os.Getenv(publishAPIKeyEnvVar)
132+
}
133+
134+
writeEnabled := resolveAllowWrite(
135+
mc.allowWrite,
136+
cmd.Flags().Changed("allow-write"),
137+
mc.readOnly,
138+
os.Getenv(allowWriteEnvVar),
139+
)
140+
141+
srv := outpostmcp.NewServer(outpostmcp.ServerOptions{
142+
Client: client,
143+
// Listing projects and validating credentials are account-level calls
144+
// that the Outpost host does not serve, so they go to the main API.
145+
AccountClient: Config.GetAPIClient(),
146+
Config: &Config,
147+
WriteEnabled: writeEnabled,
148+
PublishAPIKey: publishAPIKey,
149+
})
150+
return srv.RunStdio(context.Background())
151+
}

‎pkg/cmd/outpost_mcp_test.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestResolveAllowWrite(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
name string
15+
allowWrite bool
16+
allowWriteSet bool
17+
readOnly bool
18+
env string
19+
want bool
20+
}{
21+
{name: "default is read-only", want: false},
22+
{name: "flag enables writes", allowWrite: true, allowWriteSet: true, want: true},
23+
{name: "env var enables writes", env: "true", want: true},
24+
{name: "env var accepts 1", env: "1", want: true},
25+
{name: "env var off", env: "false", want: false},
26+
{name: "unparseable env var is ignored", env: "yes please", want: false},
27+
{
28+
name: "flag wins over the env var",
29+
allowWrite: false,
30+
allowWriteSet: true,
31+
env: "true",
32+
want: false,
33+
},
34+
{
35+
name: "read-only wins over the flag",
36+
allowWrite: true,
37+
allowWriteSet: true,
38+
readOnly: true,
39+
want: false,
40+
},
41+
{name: "read-only wins over the env var", readOnly: true, env: "true", want: false},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
t.Parallel()
47+
got := resolveAllowWrite(tt.allowWrite, tt.allowWriteSet, tt.readOnly, tt.env)
48+
assert.Equal(t, tt.want, got)
49+
})
50+
}
51+
}
52+
53+
func TestOutpostMCPCommandIsRegistered(t *testing.T) {
54+
t.Parallel()
55+
56+
cmd, _, err := RootCmd().Find([]string{"outpost", "mcp"})
57+
require.NoError(t, err)
58+
assert.Equal(t, "mcp", cmd.Name())
59+
require.True(t, isOutpostMCPLeafCommand(cmd), "the project gate must let MCP start unauthenticated")
60+
61+
for _, name := range []string{"allow-write", "read-only", "publish-api-key"} {
62+
assert.NotNil(t, cmd.Flags().Lookup(name), "missing --%s", name)
63+
}
64+
65+
// The credential is publish-specific on purpose. A generic --api-key would
66+
// read as the server's own authentication, which is the stored CLI login,
67+
// and publishing is the one action here that cannot be undone.
68+
assert.Nil(t, cmd.Flags().Lookup("api-key"), "the publish credential must not be named as if it authenticated the server")
69+
70+
// Read-only is the default, so its help must not promise otherwise.
71+
assert.Equal(t, "false", cmd.Flags().Lookup("allow-write").DefValue)
72+
assert.Contains(t, cmd.Long, "read-only")
73+
}

0 commit comments

Comments
 (0)