diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index cefd262b..d7969357 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.26.5" + go-version: "1.26.6" - name: Run Go Acceptance Tests (slice ${{ matrix.slice }}) run: go test -tags="${{ matrix.tags }}" ./test/acceptance/... -v -timeout 12m @@ -64,7 +64,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.26.5" + go-version: "1.26.6" - name: Run telemetry acceptance tests run: go test -tags=telemetry ./test/acceptance/... -v -timeout 12m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2eba61f2..3da3b883 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -56,7 +56,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -76,7 +76,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -151,7 +151,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Build npm binaries with GoReleaser uses: goreleaser/goreleaser-action@v5 diff --git a/.github/workflows/test-npm-build.yml b/.github/workflows/test-npm-build.yml index 6fcb9668..cb6e7285 100644 --- a/.github/workflows/test-npm-build.yml +++ b/.github/workflows/test-npm-build.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c76b66ba..2f6856be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run unit tests run: go test -short ./pkg/... @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run govulncheck # Pinned rather than @latest: a new govulncheck release could change # behaviour or fail this job with no change to the repo. Bump @@ -63,7 +63,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -95,7 +95,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -114,7 +114,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.26.5 + go-version: 1.26.6 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: diff --git a/AGENTS.md b/AGENTS.md index 6da1829a..48a012ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This repository contains the Hookdeck CLI, a Go-based command-line tool for mana - `pkg/hookdeck/` - API client and models - `pkg/config/` - Configuration management - `pkg/listen/` - Local webhook forwarding functionality -- `cmd/hookdeck/` - Main entry point +- `main.go` - Main entry point (repo root) - `REFERENCE.md` - Complete CLI documentation and examples ### Key Files @@ -240,7 +240,7 @@ This applies to **`go test`**, **`go mod download`**, **`git`**, **`gh`**, build ### Building and Testing ```bash # Build the CLI -go build -o hookdeck cmd/hookdeck/main.go +go build -o hookdeck . # Run all unit tests (default packages; run from repo root) go test ./... @@ -317,10 +317,10 @@ go vet ./... ### Local Development ```bash # Run CLI directly during development -go run cmd/hookdeck/main.go +go run . # Example: Test login command -go run cmd/hookdeck/main.go login --help +go run . login --help ``` ### Sandbox and command execution @@ -430,8 +430,8 @@ Acceptance tests in `test/acceptance/` are partitioned by **feature build tags** | Command | Purpose | |---------|---------| -| `go run cmd/hookdeck/main.go --help` | View CLI help | -| `go build -o hookdeck cmd/hookdeck/main.go` | Build CLI binary | +| `go run . --help` | View CLI help | +| `go build -o hookdeck .` | Build CLI binary | | `go test ./...` | All unit tests (repo root; agents: run with `required_permissions: ["all"]`) | | `go test ./pkg/cmd/` | Test command implementations only | | `go generate ./...` | Run code generation (if used) | diff --git a/README.md b/README.md index b82f10ec..e0de3e9e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ For a complete reference of all commands and flags, see [REFERENCE.md](REFERENCE - [Event Gateway](#event-gateway) - [Event Gateway MCP](#event-gateway-mcp) - [Outpost](#outpost) + - [Outpost MCP](#outpost-mcp) - [Manage connections](#manage-connections) - [Transformations](#transformations) - [Requests, events, and attempts](#requests-events-and-attempts) @@ -721,6 +722,56 @@ Publishing is asynchronous: a successful response means the event was accepted, For complete command and flag reference, see [REFERENCE.md](REFERENCE.md). +### Outpost MCP + +`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. + +```json +{ + "mcpServers": { + "hookdeck-outpost": { + "command": "hookdeck", + "args": ["outpost", "mcp"] + } + } +} +``` + +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. + +#### Read-only by default + +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: + +```json +"args": ["outpost", "mcp", "--allow-write"] +``` + +`--read-only` is accepted as an explicit way to ask for the default, and wins if both are passed. + +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. + +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. + +#### Available tools + +| Tool | Description | +|------|-------------| +| `outpost_projects` | List Outpost projects or switch the active one for this session | +| `outpost_tenants` | Inspect tenants (list, get) and manage them (upsert, delete, token, portal) | +| `outpost_destinations` | Inspect a tenant's destinations (list, get) and manage them (create, update, delete, enable, disable) | +| `outpost_events` | Query published events (list, get) and retry delivery | +| `outpost_attempts` | Query delivery attempts — status, response codes, retry history | +| `outpost_publish` | Publish an event to a topic | +| `outpost_topics` | List the topics available in the project | +| `outpost_destination_types` | Inspect destination types and the config and credential fields each accepts | +| `outpost_metrics` | Query aggregate publish and delivery metrics | +| `outpost_config` | Read and change project configuration, including the portal's custom domain | +| `outpost_status` | Show the deployment status | +| `outpost_help` | Discover the available tools, their actions, and the current mode | + +Call `outpost_help` at any time to see which mode the session is in and which actions it can perform. + ### Manage connections 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. diff --git a/REFERENCE.md b/REFERENCE.md index abf94861..36d1a7be 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -3027,6 +3027,78 @@ hookdeck outpost status [flags] hookdeck outpost status ``` +### Outpost MCP server + +`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. + +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. + +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`. + + +### hookdeck outpost mcp + +Starts a Model Context Protocol (MCP) server over stdio. + +The server exposes Hookdeck Outpost resources — tenants, destinations, events, +attempts, topics, metrics and project configuration — as MCP tools that AI +agents and LLM-based clients can invoke. Tools are prefixed outpost_, so this +server and 'hookdeck gateway mcp' can be configured in the same client. + +The server starts read-only: tools advertise only the actions that read data, +so an agent is never offered an action it cannot perform. Pass `--allow-write` to +enable creating, changing and deleting. Two reads count as writes and are also +gated, 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. + +Publishing needs a Hookdeck Project API key, which the credentials stored by +'hookdeck login' cannot substitute for. Without one the publish tool is not +registered at all; pass `--publish-api-key` or set HOOKDECK_OUTPOST_PUBLISH_API_KEY. + +This deliberately does not read HOOKDECK_API_KEY, which elsewhere in the CLI +means "a key to exchange for CLI credentials". Publishing sends real events to +real destinations and cannot be undone, so it should not be switched on by a +variable that happens to be exported for something else. + +If the CLI is already authenticated, all tools are available immediately. If +not, the server still starts and outpost_login initiates browser-based sign-in. +Protocol traffic uses stdout only (JSON-RPC); status and errors from the CLI +before the server runs go to stderr. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost mcp [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--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. | +| `--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. | +| `--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`. | + +**Examples:** + +```bash +# Start the MCP server, read-only (stdio transport) +hookdeck outpost mcp + +# Allow tools that change data +hookdeck outpost mcp --allow-write + +# Allow writes, including publishing events +hookdeck outpost mcp --allow-write --publish-api-key $HOOKDECK_OUTPOST_PUBLISH_API_KEY + +# Pipe a JSON-RPC initialize request for testing +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}' | hookdeck outpost mcp +``` + ## Utilities diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go index d9dccf70..af27e629 100644 --- a/pkg/cmd/outpost.go +++ b/pkg/cmd/outpost.go @@ -107,6 +107,7 @@ These commands require an Outpost project. Use 'hookdeck project use' to switch. oc.cmd.AddCommand(newOutpostPublishCmd().cmd) oc.cmd.AddCommand(newOutpostMetricsCmd().cmd) oc.cmd.AddCommand(newOutpostConfigCmd().cmd) + addOutpostMCPCmdTo(oc.cmd) return oc } diff --git a/pkg/cmd/outpost_mcp.go b/pkg/cmd/outpost_mcp.go new file mode 100644 index 00000000..8a286d87 --- /dev/null +++ b/pkg/cmd/outpost_mcp.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "os" + "strconv" + + "github.com/spf13/cobra" + + outpostmcp "github.com/hookdeck/hookdeck-cli/pkg/outpost/mcp" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// allowWriteEnvVar enables write actions without a flag, for MCP clients whose +// config makes environment variables easier to set than arguments. +const allowWriteEnvVar = "HOOKDECK_MCP_ALLOW_WRITE" + +// publishAPIKeyEnvVar carries the Project API key the publish tool needs. +// +// It is deliberately distinct from HOOKDECK_API_KEY. That variable means +// "exchange this for CLI credentials" everywhere else in the CLI, and is +// commonly exported for CI; reusing it here would give one name two meanings and +// let an ambient variable silently enable sending real events. +const publishAPIKeyEnvVar = "HOOKDECK_OUTPOST_PUBLISH_API_KEY" + +type outpostMCPCmd struct { + cmd *cobra.Command + + allowWrite bool + readOnly bool + apiKey string +} + +func newOutpostMCPCmd() *outpostMCPCmd { + mc := &outpostMCPCmd{} + mc.cmd = &cobra.Command{ + Use: "mcp", + Args: validators.NoArgs, + Short: ShortBeta("Start an MCP server for AI agent access to Outpost"), + Long: LongBeta(`Starts a Model Context Protocol (MCP) server over stdio. + +The server exposes Hookdeck Outpost resources — tenants, destinations, events, +attempts, topics, metrics and project configuration — as MCP tools that AI +agents and LLM-based clients can invoke. Tools are prefixed outpost_, so this +server and 'hookdeck gateway mcp' can be configured in the same client. + +The server starts read-only: tools advertise only the actions that read data, +so an agent is never offered an action it cannot perform. Pass --allow-write to +enable creating, changing and deleting. Two reads count as writes and are also +gated, 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. + +Publishing needs a Hookdeck Project API key, which the credentials stored by +'hookdeck login' cannot substitute for. Without one the publish tool is not +registered at all; pass --publish-api-key or set HOOKDECK_OUTPOST_PUBLISH_API_KEY. + +This deliberately does not read HOOKDECK_API_KEY, which elsewhere in the CLI +means "a key to exchange for CLI credentials". Publishing sends real events to +real destinations and cannot be undone, so it should not be switched on by a +variable that happens to be exported for something else. + +If the CLI is already authenticated, all tools are available immediately. If +not, the server still starts and outpost_login initiates browser-based sign-in. +Protocol traffic uses stdout only (JSON-RPC); status and errors from the CLI +before the server runs go to stderr.`), + Example: ` # Start the MCP server, read-only (stdio transport) + hookdeck outpost mcp + + # Allow tools that change data + hookdeck outpost mcp --allow-write + + # Allow writes, including publishing events + hookdeck outpost mcp --allow-write --publish-api-key $HOOKDECK_OUTPOST_PUBLISH_API_KEY + + # Pipe a JSON-RPC initialize request for testing + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}' | hookdeck outpost mcp`, + RunE: mc.runOutpostMCPCmd, + } + + 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.") + // Users arriving from other MCP servers type --read-only reflexively. It is + // already the default, so accept it rather than failing on an unknown flag. + 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.") + // The env var is read at run time rather than used as the flag default, so a + // key that is already in the environment is not printed back out by --help. + 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.") + + return mc +} + +func addOutpostMCPCmdTo(parent *cobra.Command) { + parent.AddCommand(newOutpostMCPCmd().cmd) +} + +// resolveAllowWrite decides whether write actions are enabled. +// +// --read-only wins over everything so an explicit request for a safe session is +// never overridden; otherwise --allow-write wins over the environment variable, +// which is the more distant and easier-to-forget setting. +func resolveAllowWrite(allowWriteFlag, allowWriteFlagSet, readOnly bool, envValue string) bool { + if readOnly { + return false + } + if allowWriteFlagSet { + return allowWriteFlag + } + enabled, err := strconv.ParseBool(envValue) + if err != nil { + return false + } + return enabled +} + +func (mc *outpostMCPCmd) runOutpostMCPCmd(cmd *cobra.Command, args []string) error { + // Always build the client — it may have an empty APIKey if the CLI is not + // yet authenticated. The server handles that by registering outpost_login + // rather than failing to start. + // + // This must be the Outpost client: the projects and login tools set the + // project on the client they are given, and setting it on the Gateway client + // would leave every Outpost call pointed at the previous project. + client := Config.GetOutpostAPIClient() + + // Deliberately not HOOKDECK_API_KEY. That variable means "exchange this for + // CLI credentials" for `hookdeck ci` and `listen`, and the CLI encourages + // exporting it for CI — so reading it here would let an unrelated ambient + // variable silently grant an agent the ability to send real events. + publishAPIKey := mc.apiKey + if publishAPIKey == "" { + publishAPIKey = os.Getenv(publishAPIKeyEnvVar) + } + + writeEnabled := resolveAllowWrite( + mc.allowWrite, + cmd.Flags().Changed("allow-write"), + mc.readOnly, + os.Getenv(allowWriteEnvVar), + ) + + srv := outpostmcp.NewServer(outpostmcp.ServerOptions{ + Client: client, + // Listing projects and validating credentials are account-level calls + // that the Outpost host does not serve, so they go to the main API. + AccountClient: Config.GetAPIClient(), + Config: &Config, + WriteEnabled: writeEnabled, + PublishAPIKey: publishAPIKey, + }) + return srv.RunStdio(context.Background()) +} diff --git a/pkg/cmd/outpost_mcp_test.go b/pkg/cmd/outpost_mcp_test.go new file mode 100644 index 00000000..e64997ff --- /dev/null +++ b/pkg/cmd/outpost_mcp_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAllowWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + allowWrite bool + allowWriteSet bool + readOnly bool + env string + want bool + }{ + {name: "default is read-only", want: false}, + {name: "flag enables writes", allowWrite: true, allowWriteSet: true, want: true}, + {name: "env var enables writes", env: "true", want: true}, + {name: "env var accepts 1", env: "1", want: true}, + {name: "env var off", env: "false", want: false}, + {name: "unparseable env var is ignored", env: "yes please", want: false}, + { + name: "flag wins over the env var", + allowWrite: false, + allowWriteSet: true, + env: "true", + want: false, + }, + { + name: "read-only wins over the flag", + allowWrite: true, + allowWriteSet: true, + readOnly: true, + want: false, + }, + {name: "read-only wins over the env var", readOnly: true, env: "true", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := resolveAllowWrite(tt.allowWrite, tt.allowWriteSet, tt.readOnly, tt.env) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOutpostMCPCommandIsRegistered(t *testing.T) { + t.Parallel() + + cmd, _, err := RootCmd().Find([]string{"outpost", "mcp"}) + require.NoError(t, err) + assert.Equal(t, "mcp", cmd.Name()) + require.True(t, isOutpostMCPLeafCommand(cmd), "the project gate must let MCP start unauthenticated") + + for _, name := range []string{"allow-write", "read-only", "publish-api-key"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing --%s", name) + } + + // The credential is publish-specific on purpose. A generic --api-key would + // read as the server's own authentication, which is the stored CLI login, + // and publishing is the one action here that cannot be undone. + assert.Nil(t, cmd.Flags().Lookup("api-key"), "the publish credential must not be named as if it authenticated the server") + + // Read-only is the default, so its help must not promise otherwise. + assert.Equal(t, "false", cmd.Flags().Lookup("allow-write").DefValue) + assert.Contains(t, cmd.Long, "read-only") +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 0305eb11..768c0746 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -121,9 +121,9 @@ const ( // Interactive sign-in is only viable with a terminal: it blocks on Enter, opens a // browser, and polls for ~4 minutes, so choosing it in CI, Docker or an agent // turns a fast, fixable error into a hang. -func resolveAuthFallback(gatewayMCP, interactiveStdin bool) authFallback { +func resolveAuthFallback(isMCP, interactiveStdin bool) authFallback { switch { - case gatewayMCP: + case isMCP: return authFallbackMCP case !interactiveStdin: return authFallbackNonInteractive @@ -146,7 +146,9 @@ Or run ` + "`hookdeck login`" + ` in an interactive terminal.` // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - gatewayMCP := argvContainsGatewayMCP(os.Args) + mcpGroup := argvMCPGroup(os.Args) + isMCP := mcpGroup != "" + mcpLoginTool := mcpLoginToolName(mcpGroup) if err := rootCmd.Execute(); err != nil { errString := err.Error() isLoginRequiredError := errString == validators.ErrAPIKeyNotConfigured.Error() || errString == validators.ErrDeviceNameNotConfigured.Error() @@ -157,10 +159,10 @@ func Execute() { errRunes[0] = unicode.ToUpper(errRunes[0]) capitalized := string(errRunes) - switch resolveAuthFallback(gatewayMCP, stdinIsTerminal()) { + switch resolveAuthFallback(isMCP, stdinIsTerminal()) { case authFallbackMCP: // MCP uses JSON-RPC on stdout; do not run interactive login or print recovery text there. - fmt.Fprintf(os.Stderr, "%s. Use hookdeck_login in the MCP session (or run `hookdeck login` in a terminal).\n", capitalized) + fmt.Fprintf(os.Stderr, "%s. Use %s in the MCP session (or run `hookdeck login` in a terminal).\n", capitalized, mcpLoginTool) os.Exit(1) case authFallbackNonInteractive: fmt.Fprintf(os.Stderr, "%s.\n\n%s\n", capitalized, nonInteractiveAuthHelp) @@ -191,7 +193,7 @@ func Execute() { msg := fmt.Sprintf("Unknown command \"%s\" for \"%s\".%s"+ "ee \"hookdeck --help\" for a list of available commands.", os.Args[1], rootCmd.CommandPath(), suggStr) - if gatewayMCP { + if isMCP { fmt.Fprintln(os.Stderr, msg) } else { fmt.Println(msg) @@ -200,7 +202,7 @@ func Execute() { case errors.As(err, new(*actionableError)): // The command already explained what to do; do not replace it with // the generic recovery text below. - if gatewayMCP { + if isMCP { fmt.Fprintln(os.Stderr, err) } else { fmt.Println(err) @@ -210,13 +212,13 @@ func Execute() { if hookdeck.IsUnauthorizedError(err) { msg := "Authentication failed: your API key is invalid or expired.\n\n" + "Sign in again: run `hookdeck login` (browser sign-in), or `hookdeck login -i` / `hookdeck --api-key login`.\n\n" + - "MCP: use hookdeck_login with reauth: true." - if gatewayMCP { + "MCP: use " + mcpLoginTool + " with reauth: true." + if isMCP { fmt.Fprintln(os.Stderr, msg) } else { fmt.Println(msg) } - } else if gatewayMCP { + } else if isMCP { fmt.Fprintln(os.Stderr, err) } else { fmt.Println(err) @@ -227,19 +229,44 @@ func Execute() { } } -// argvContainsGatewayMCP reports whether argv invokes `hookdeck gateway mcp`, ignoring -// global flags and flag values (e.g. --profile name, -p name) so detection stays accurate. -func argvContainsGatewayMCP(argv []string) bool { +// mcpCommandGroups are the command groups that have an `mcp` subcommand. Every +// one of them speaks JSON-RPC on stdout, so they share the stdout hygiene and +// authentication fallback rules. +var mcpCommandGroups = []string{"gateway", "outpost"} + +// argvContainsMCP reports whether argv invokes a ` mcp` command. +func argvContainsMCP(argv []string) bool { + return argvMCPGroup(argv) != "" +} + +// argvMCPGroup returns the command group of a ` mcp` invocation, or "". +// It ignores global flags and flag values (e.g. --profile name, -p name) so +// detection stays accurate. +func argvMCPGroup(argv []string) string { if len(argv) < 3 { - return false + return "" } pos := globalPositionalArgs(argv[1:]) for i := 0; i < len(pos)-1; i++ { - if pos[i] == "gateway" && pos[i+1] == "mcp" { - return true + if pos[i+1] != "mcp" { + continue } + for _, group := range mcpCommandGroups { + if pos[i] == group { + return group + } + } + } + return "" +} + +// mcpLoginToolName returns the login tool exposed by a group's MCP server, so +// pre-startup errors point at a tool that exists in that session. +func mcpLoginToolName(group string) string { + if group == "outpost" { + return "outpost_login" } - return false + return "hookdeck_login" } // flagNeedsNextArg lists global flags that consume the next argv token as their value. diff --git a/pkg/cmd/root_argv_test.go b/pkg/cmd/root_argv_test.go index 57524b87..6be8bc68 100644 --- a/pkg/cmd/root_argv_test.go +++ b/pkg/cmd/root_argv_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestArgvContainsGatewayMCP(t *testing.T) { +func TestArgvContainsMCP(t *testing.T) { tests := []struct { name string argv []string @@ -25,10 +25,29 @@ func TestArgvContainsGatewayMCP(t *testing.T) { // globalPositionalArgs treats them as single-token flags and skips them. {"bool flag before gateway", []string{"hookdeck", "--insecure", "gateway", "mcp"}, true}, {"bool flag between gateway and mcp", []string{"hookdeck", "gateway", "--insecure", "mcp"}, false}, + + // Outpost has its own MCP server and needs the same stdout hygiene. + {"outpost minimal", []string{"hookdeck", "outpost", "mcp"}, true}, + {"outpost with profile", []string{"hookdeck", "--profile", "p1", "outpost", "mcp"}, true}, + {"outpost with allow-write", []string{"hookdeck", "outpost", "mcp", "--allow-write"}, true}, + {"outpost not mcp", []string{"hookdeck", "outpost", "tenant", "list"}, false}, + + {"unrelated group", []string{"hookdeck", "project", "mcp"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, argvContainsGatewayMCP(tt.argv)) + assert.Equal(t, tt.want, argvContainsMCP(tt.argv)) }) } } + +func TestArgvMCPGroupNamesTheLoginTool(t *testing.T) { + assert.Equal(t, "gateway", argvMCPGroup([]string{"hookdeck", "gateway", "mcp"})) + assert.Equal(t, "outpost", argvMCPGroup([]string{"hookdeck", "outpost", "mcp"})) + assert.Equal(t, "", argvMCPGroup([]string{"hookdeck", "listen", "3000"})) + + assert.Equal(t, "hookdeck_login", mcpLoginToolName("gateway")) + assert.Equal(t, "outpost_login", mcpLoginToolName("outpost")) + // A non-MCP invocation still needs a sensible name for the shared message. + assert.Equal(t, "hookdeck_login", mcpLoginToolName("")) +} diff --git a/pkg/gateway/mcp/auth.go b/pkg/gateway/mcp/auth.go deleted file mode 100644 index 6b8d34cb..00000000 --- a/pkg/gateway/mcp/auth.go +++ /dev/null @@ -1,17 +0,0 @@ -package mcp - -import ( - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" -) - -// requireAuth checks whether the API client has a valid API key. If not, it -// returns an error result directing the agent to call hookdeck_login. Callers -// should return immediately when the result is non-nil. -func requireAuth(client *hookdeck.Client) *mcpsdk.CallToolResult { - if client.APIKey == "" { - return ErrorResult("Not authenticated. Please call the hookdeck_login tool to authenticate with Hookdeck.") - } - return nil -} diff --git a/pkg/gateway/mcp/input_test.go b/pkg/gateway/mcp/input_test.go deleted file mode 100644 index 885e6d13..00000000 --- a/pkg/gateway/mcp/input_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package mcp - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestInput_JSONFilterParam_Missing(t *testing.T) { - in := input{} - value, err := in.JSONFilterParam("body") - require.NoError(t, err) - assert.Empty(t, value) -} - -func TestInput_JSONFilterParam_String(t *testing.T) { - in := input{"body": `{"type":"payment"}`} - value, err := in.JSONFilterParam("body") - require.NoError(t, err) - assert.Equal(t, `{"type":"payment"}`, value) -} - -func TestInput_JSONFilterParam_Object(t *testing.T) { - in := input{"body": map[string]interface{}{"type": "payment", "amount": float64(100)}} - value, err := in.JSONFilterParam("body") - require.NoError(t, err) - assert.JSONEq(t, `{"type":"payment","amount":100}`, value) -} - -func TestInput_JSONFilterParam_InvalidType(t *testing.T) { - in := input{"body": 42} - _, err := in.JSONFilterParam("body") - require.Error(t, err) - assert.Contains(t, err.Error(), "body must be a JSON string or object") -} - -func TestSetPayloadSearchFilters(t *testing.T) { - params := make(map[string]string) - in := input{ - "body": map[string]interface{}{"a": "b"}, - "headers": `{"x-test":"1"}`, - "parsed_query": map[string]interface{}{"q": "x"}, - "path": "/webhooks", - } - require.NoError(t, setPayloadSearchFilters(params, in)) - assert.JSONEq(t, `{"a":"b"}`, params["body"]) - assert.Equal(t, `{"x-test":"1"}`, params["headers"]) - assert.JSONEq(t, `{"q":"x"}`, params["parsed_query"]) - assert.Equal(t, "/webhooks", params["path"]) -} diff --git a/pkg/gateway/mcp/project_display.go b/pkg/gateway/mcp/project_display.go deleted file mode 100644 index c16cffd8..00000000 --- a/pkg/gateway/mcp/project_display.go +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 51beac71..00000000 --- a/pkg/gateway/mcp/project_display_test.go +++ /dev/null @@ -1,42 +0,0 @@ -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/server.go b/pkg/gateway/mcp/server.go deleted file mode 100644 index 99a99914..00000000 --- a/pkg/gateway/mcp/server.go +++ /dev/null @@ -1,144 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "os" - - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/hookdeck/hookdeck-cli/pkg/config" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/version" -) - -// Server wraps the MCP SDK server and the Hookdeck API client. -type Server struct { - client *hookdeck.Client - cfg *config.Config - mcpServer *mcpsdk.Server - - // sessionCtx is the context passed to RunStdio. It is cancelled when the - // MCP transport closes (stdin EOF). Background goroutines (e.g. login - // polling) should select on this — NOT on the per-request ctx passed to - // tool handlers, which is cancelled when the handler returns. - sessionCtx context.Context -} - -// NewServer creates an MCP server with all Hookdeck tools registered. -// The supplied client is shared across all tool handlers; changing its -// ProjectID (e.g. via the projects.use action) affects subsequent calls -// within the same session. -// -// hookdeck_login is always registered: it signs in when unauthenticated, or -// with reauth: true clears stored credentials and starts a fresh browser login. -func NewServer(client *hookdeck.Client, cfg *config.Config) *Server { - s := &Server{client: client, cfg: cfg} - - s.mcpServer = mcpsdk.NewServer( - &mcpsdk.Implementation{ - Name: "hookdeck-gateway", - Version: version.Version, - }, - nil, // default options; tools capability is inferred from AddTool calls - ) - - s.registerTools() - return s -} - -// registerTools adds all tool definitions to the MCP server. -func (s *Server) registerTools() { - for _, td := range toolDefs(s.client) { - s.mcpServer.AddTool(td.tool, s.wrapWithTelemetry(td.tool.Name, td.handler)) - } - - s.mcpServer.AddTool( - &mcpsdk.Tool{ - Name: "hookdeck_login", - Description: "Authenticate the Hookdeck CLI or sign in again. Without arguments, returns a URL for browser login when not yet authenticated, or confirms if already signed in. Set reauth: true to clear the current session and start a new browser login (use when hookdeck_projects list fails and the stored key may be a single-project or dashboard API key).", - InputSchema: schema(map[string]prop{ - "reauth": {Type: "boolean", Desc: "If true, clear stored credentials and start a new browser login. Use when project listing fails — complete login in the browser, then retry hookdeck_projects."}, - }), - }, - s.wrapWithTelemetry("hookdeck_login", handleLogin(s)), - ) -} - -// mcpClientInfo extracts the MCP client name/version string from the -// session's initialize params. Returns "" if unavailable. -func mcpClientInfo(req *mcpsdk.CallToolRequest) string { - if req.Session == nil { - return "" - } - params := req.Session.InitializeParams() - if params == nil || params.ClientInfo == nil { - return "" - } - ci := params.ClientInfo - if ci.Version != "" { - return fmt.Sprintf("%s/%s", ci.Name, ci.Version) - } - return ci.Name -} - -// wrapWithTelemetry returns a handler that sets per-invocation telemetry on the -// shared client before delegating to the original handler. The stdio transport -// processes tool calls sequentially, so setting telemetry on the shared client -// is safe (no concurrent access). -func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler) mcpsdk.ToolHandler { - return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - // Extract the action from the request arguments for command_path. - action := extractAction(req) - commandPath := toolName - if action != "" { - commandPath = toolName + "/" + action - } - - deviceName, _ := os.Hostname() - - s.client.Telemetry = &hookdeck.CLITelemetry{ - Source: "mcp", - Environment: hookdeck.DetectEnvironment(), - CommandPath: commandPath, - InvocationID: hookdeck.NewInvocationID(), - DeviceName: deviceName, - MCPClient: mcpClientInfo(req), - } - defer func() { s.client.Telemetry = nil }() - - fillProjectDisplayNameIfNeeded(s.client) - - return handler(ctx, req) - } -} - -// extractAction parses the "action" field from the tool call arguments. -func extractAction(req *mcpsdk.CallToolRequest) string { - if req.Params.Arguments == nil { - return "" - } - var args map[string]interface{} - if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { - return "" - } - if action, ok := args["action"].(string); ok { - return action - } - return "" -} - -// RunStdio starts the MCP server on stdin/stdout and blocks until the -// connection is closed (i.e. stdin reaches EOF). -func (s *Server) RunStdio(ctx context.Context) error { - return s.Run(ctx, &mcpsdk.StdioTransport{}) -} - -// Run starts the MCP server on the given transport. It stores ctx as the -// session-level context so background goroutines (e.g. login polling) can -// detect when the session ends. -func (s *Server) Run(ctx context.Context, transport mcpsdk.Transport) error { - s.sessionCtx = ctx - return s.mcpServer.Run(ctx, transport) -} diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 1867bfcc..384e047a 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -3,7 +3,6 @@ package mcp import ( "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "net/url" @@ -17,6 +16,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) // --------------------------------------------------------------------------- @@ -287,33 +287,6 @@ func TestAuthGuard_UnauthenticatedReturnsError(t *testing.T) { // Error translation // --------------------------------------------------------------------------- -func TestTranslateAPIError(t *testing.T) { - tests := []struct { - name string - err error - wantSubstr string - }{ - {"401 Unauthorized", &hookdeck.APIError{StatusCode: 401, Message: "bad key"}, "Authentication failed"}, - {"404 Not Found", &hookdeck.APIError{StatusCode: 404, Message: "resource xyz"}, "Resource not found"}, - {"410 Gone", &hookdeck.APIError{StatusCode: 410, Message: "resource xyz"}, "Resource not found"}, - {"422 Validation", &hookdeck.APIError{StatusCode: 422, Message: "invalid field foo"}, "invalid field foo"}, - {"429 Rate Limit", &hookdeck.APIError{StatusCode: 429, Message: "slow down"}, "Rate limited"}, - {"500 Server Error", &hookdeck.APIError{StatusCode: 500, Message: "internal"}, "Hookdeck API error"}, - {"Non-API error", fmt.Errorf("network timeout"), "network timeout"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := TranslateAPIError(tt.err) - assert.Contains(t, msg, tt.wantSubstr) - }) - } -} - -// --------------------------------------------------------------------------- -// Sources tool -// --------------------------------------------------------------------------- - func TestSourcesList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/sources": func(w http.ResponseWriter, r *http.Request) { @@ -1589,8 +1562,8 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { assert.Contains(t, textContent(t, result), "https://hookdeck.com/auth?code=survive") // Wait for the background poll loop: first unclaimed response is followed by - // loginPollInterval sleep inside pollForAPIKey before the second poll succeeds. - time.Sleep(loginPollInterval + 300*time.Millisecond) + // mcpcore.LoginPollInterval sleep inside pollForAPIKey before the second poll succeeds. + time.Sleep(mcpcore.LoginPollInterval + 300*time.Millisecond) // Second call — if the goroutine survived, the client is now authenticated. result2 := callTool(t, session, "hookdeck_login", map[string]any{}) @@ -1660,44 +1633,6 @@ func TestEventsGet_APIError(t *testing.T) { // Input parsing edge cases // --------------------------------------------------------------------------- -func TestInput_Accessors(t *testing.T) { - raw := json.RawMessage(`{ - "name": "test", - "count": 42, - "active": true, - "tags": ["a", "b"], - "missing_bool": null - }`) - - in, err := parseInput(raw) - require.NoError(t, err) - - assert.Equal(t, "test", in.String("name")) - assert.Equal(t, "", in.String("nonexistent")) - assert.Equal(t, 42, in.Int("count", 0)) - assert.Equal(t, 99, in.Int("nonexistent", 99)) - assert.Equal(t, true, in.Bool("active")) - assert.Equal(t, false, in.Bool("nonexistent")) - assert.Equal(t, []string{"a", "b"}, in.StringSlice("tags")) - assert.Nil(t, in.StringSlice("nonexistent")) - - bp := in.BoolPtr("active") - require.NotNil(t, bp) - assert.True(t, *bp) - assert.Nil(t, in.BoolPtr("nonexistent")) -} - -func TestInput_EmptyArgs(t *testing.T) { - in, err := parseInput(nil) - require.NoError(t, err) - assert.Equal(t, "", in.String("anything")) -} - -func TestInput_InvalidJSON(t *testing.T) { - _, err := parseInput(json.RawMessage(`{invalid`)) - assert.Error(t, err) -} - // --------------------------------------------------------------------------- // Server instructions // --------------------------------------------------------------------------- @@ -1866,25 +1801,3 @@ func TestAttemptsList_429RateLimitError(t *testing.T) { // --------------------------------------------------------------------------- // Error translation: additional cases // --------------------------------------------------------------------------- - -func TestTranslateAPIError_RetryAfterMessage(t *testing.T) { - msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 429, Message: "rate limited"}) - assert.Contains(t, msg, "Rate limited") - assert.Contains(t, msg, "Retry after") -} - -func TestTranslateAPIError_GenericClientError(t *testing.T) { - // A 4xx status not explicitly handled should pass through the message - msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 409, Message: "conflict on resource"}) - assert.Contains(t, msg, "conflict on resource") -} - -func TestTranslateAPIError_502GatewayError(t *testing.T) { - msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 502, Message: "bad gateway"}) - assert.Contains(t, msg, "Hookdeck API error") -} - -func TestTranslateAPIError_503ServiceUnavailable(t *testing.T) { - msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 503, Message: "service unavailable"}) - assert.Contains(t, msg, "Hookdeck API error") -} diff --git a/pkg/gateway/mcp/telemetry_test.go b/pkg/gateway/mcp/telemetry_test.go index 366b9d40..dcce7a85 100644 --- a/pkg/gateway/mcp/telemetry_test.go +++ b/pkg/gateway/mcp/telemetry_test.go @@ -1,138 +1,17 @@ package mcp import ( - "context" "encoding/json" "net/http" "strings" "sync" "testing" - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) -// newCallToolRequest creates a CallToolRequest with the given arguments JSON. -func newCallToolRequest(argsJSON string) *mcpsdk.CallToolRequest { - return &mcpsdk.CallToolRequest{ - Params: &mcpsdk.CallToolParamsRaw{ - Arguments: json.RawMessage(argsJSON), - }, - } -} - -func TestExtractAction(t *testing.T) { - tests := []struct { - name string - req *mcpsdk.CallToolRequest - expected string - }{ - {"valid action", newCallToolRequest(`{"action":"list"}`), "list"}, - {"no action field", newCallToolRequest(`{"id":"123"}`), ""}, - {"empty object", newCallToolRequest(`{}`), ""}, - {"action with other fields", newCallToolRequest(`{"action":"get","id":"evt_123"}`), "get"}, - {"nil arguments", &mcpsdk.CallToolRequest{Params: &mcpsdk.CallToolParamsRaw{}}, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractAction(tt.req) - require.Equal(t, tt.expected, got) - }) - } -} - -func TestMCPClientInfoNilSession(t *testing.T) { - req := newCallToolRequest(`{}`) - req.Session = nil - got := mcpClientInfo(req) - require.Equal(t, "", got) -} - -func TestWrapWithTelemetrySetsAndClears(t *testing.T) { - client := &hookdeck.Client{} - s := &Server{client: client} - - var capturedTelemetry *hookdeck.CLITelemetry - - innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - require.NotNil(t, s.client.Telemetry) - require.Equal(t, "mcp", s.client.Telemetry.Source) - require.Equal(t, "hookdeck_events/list", s.client.Telemetry.CommandPath) - require.NotEmpty(t, s.client.Telemetry.InvocationID) - require.NotEmpty(t, s.client.Telemetry.DeviceName) - // Capture a copy - cp := *s.client.Telemetry - capturedTelemetry = &cp - return &mcpsdk.CallToolResult{}, nil - }) - - wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) - - req := newCallToolRequest(`{"action":"list"}`) - result, err := wrapped(context.Background(), req) - require.NoError(t, err) - require.NotNil(t, result) - - // Telemetry should have been captured inside the handler - require.NotNil(t, capturedTelemetry) - require.Equal(t, "mcp", capturedTelemetry.Source) - require.Equal(t, "hookdeck_events/list", capturedTelemetry.CommandPath) - - // After the wrapper returns, telemetry should be cleared on the shared client - require.Nil(t, s.client.Telemetry) -} - -func TestWrapWithTelemetryNoAction(t *testing.T) { - client := &hookdeck.Client{} - s := &Server{client: client} - - var capturedPath string - - innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - capturedPath = s.client.Telemetry.CommandPath - return &mcpsdk.CallToolResult{}, nil - }) - - wrapped := s.wrapWithTelemetry("hookdeck_help", innerHandler) - - req := newCallToolRequest(`{"topic":"hookdeck_events"}`) - _, err := wrapped(context.Background(), req) - require.NoError(t, err) - - // No "action" field, so command path should just be the tool name - require.Equal(t, "hookdeck_help", capturedPath) -} - -func TestWrapWithTelemetryUniqueInvocationIDs(t *testing.T) { - client := &hookdeck.Client{} - s := &Server{client: client} - - var ids []string - - innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - ids = append(ids, s.client.Telemetry.InvocationID) - return &mcpsdk.CallToolResult{}, nil - }) - - wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) - - for i := 0; i < 5; i++ { - req := newCallToolRequest(`{"action":"list"}`) - _, _ = wrapped(context.Background(), req) - } - - require.Len(t, ids, 5) - // All IDs should be unique - seen := make(map[string]bool) - for _, id := range ids { - require.False(t, seen[id], "duplicate invocation ID: %s", id) - seen[id] = true - } -} - // --------------------------------------------------------------------------- // End-to-end integration tests: MCP tool call → HTTP request → telemetry header // These tests use the full MCP server pipeline (mockAPIWithClient) and verify diff --git a/pkg/gateway/mcp/tool_attempts.go b/pkg/gateway/mcp/tool_attempts.go index 07af6f44..74d736b2 100644 --- a/pkg/gateway/mcp/tool_attempts.go +++ b/pkg/gateway/mcp/tool_attempts.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleAttempts(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -27,35 +28,35 @@ func handleAttempts(client *hookdeck.Client) mcpsdk.ToolHandler { case "get": return attemptsGet(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } } } -func attemptsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func attemptsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "event_id", in.String("event_id")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "order_by", in.String("order_by")) - setIfNonEmpty(params, "dir", in.String("dir")) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "event_id", in.String("event_id")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "order_by", in.String("order_by")) + mcpcore.SetIfNonEmpty(params, "dir", in.String("dir")) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) result, err := client.ListAttempts(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func attemptsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func attemptsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } attempt, err := client.GetAttempt(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(attempt, client) + return mcpcore.JSONResultEnvelopeForClient(attempt, client) } diff --git a/pkg/gateway/mcp/tool_connections.go b/pkg/gateway/mcp/tool_connections.go index a942040d..40b7a187 100644 --- a/pkg/gateway/mcp/tool_connections.go +++ b/pkg/gateway/mcp/tool_connections.go @@ -9,17 +9,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleConnections(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -33,19 +34,19 @@ func handleConnections(client *hookdeck.Client) mcpsdk.ToolHandler { case "unpause": return connectionsUnpause(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, pause, or unpause", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, pause, or unpause", action)), nil } } } -func connectionsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func connectionsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "name", in.String("name")) - setIfNonEmpty(params, "source_id", in.String("source_id")) - setIfNonEmpty(params, "destination_id", in.String("destination_id")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "name", in.String("name")) + mcpcore.SetIfNonEmpty(params, "source_id", in.String("source_id")) + mcpcore.SetIfNonEmpty(params, "destination_id", in.String("destination_id")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) if bp := in.BoolPtr("disabled"); bp != nil { if *bp { @@ -55,57 +56,57 @@ func connectionsList(ctx context.Context, client *hookdeck.Client, in input) (*m result, err := client.ListConnections(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func connectionsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func connectionsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { idOrName := in.String("id") if idOrName == "" { - return ErrorResult("id or name is required for the get action"), nil + return mcpcore.ErrorResult("id or name is required for the get action"), nil } id, err := resolveMCPConnectionID(ctx, client, idOrName) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } conn, err := client.GetConnection(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(conn, client) + return mcpcore.JSONResultEnvelopeForClient(conn, client) } -func connectionsPause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func connectionsPause(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { idOrName := in.String("id") if idOrName == "" { - return ErrorResult("id or name is required for the pause action"), nil + return mcpcore.ErrorResult("id or name is required for the pause action"), nil } id, err := resolveMCPConnectionID(ctx, client, idOrName) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } conn, err := client.PauseConnection(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(conn, client) + return mcpcore.JSONResultEnvelopeForClient(conn, client) } -func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func connectionsUnpause(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { idOrName := in.String("id") if idOrName == "" { - return ErrorResult("id or name is required for the unpause action"), nil + return mcpcore.ErrorResult("id or name is required for the unpause action"), nil } id, err := resolveMCPConnectionID(ctx, client, idOrName) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } conn, err := client.UnpauseConnection(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(conn, client) + return mcpcore.JSONResultEnvelopeForClient(conn, client) } // resolveMCPConnectionID resolves a connection ID or name to an ID. @@ -118,14 +119,14 @@ func resolveMCPConnectionID(ctx context.Context, client *hookdeck.Client, idOrNa return idOrName, nil } if !hookdeck.IsNotFoundError(err) { - return "", errors.New(TranslateAPIError(err)) + return "", errors.New(mcpcore.TranslateAPIError(err)) } } params := map[string]string{"name": idOrName} result, err := client.ListConnections(ctx, params) if err != nil { - return "", errors.New(TranslateAPIError(err)) + return "", errors.New(mcpcore.TranslateAPIError(err)) } if result.Pagination.Limit == 0 || len(result.Models) == 0 { return "", fmt.Errorf("connection not found: '%s'", idOrName) diff --git a/pkg/gateway/mcp/tool_destinations.go b/pkg/gateway/mcp/tool_destinations.go index f0630921..c2a59053 100644 --- a/pkg/gateway/mcp/tool_destinations.go +++ b/pkg/gateway/mcp/tool_destinations.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleDestinations(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -27,33 +28,33 @@ func handleDestinations(client *hookdeck.Client) mcpsdk.ToolHandler { case "get": return destinationsGet(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } } } -func destinationsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func destinationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "name", in.String("name")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "name", in.String("name")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) result, err := client.ListDestinations(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func destinationsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func destinationsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } dest, err := client.GetDestination(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(dest, client) + return mcpcore.JSONResultEnvelopeForClient(dest, client) } diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 5874143b..07eef1e4 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -29,71 +30,70 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { case "raw_body": return eventsRawBody(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, or raw_body", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, or raw_body", action)), nil } } } -func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "id", in.String("id")) + mcpcore.SetIfNonEmpty(params, "id", in.String("id")) // connection_id maps to webhook_id in the API - setIfNonEmpty(params, "webhook_id", in.String("connection_id")) - setIfNonEmpty(params, "source_id", in.String("source_id")) - setIfNonEmpty(params, "destination_id", in.String("destination_id")) - setIfNonEmpty(params, "status", in.String("status")) - setIfNonEmpty(params, "attempts", in.String("attempts")) - setIfNonEmpty(params, "issue_id", in.String("issue_id")) - setIfNonEmpty(params, "error_code", in.String("error_code")) - setIfNonEmpty(params, "response_status", in.String("response_status")) - setIfNonEmpty(params, "cli_id", in.String("cli_id")) - setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) - setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) - setIfNonEmpty(params, "successful_at[gte]", in.String("successful_after")) - setIfNonEmpty(params, "successful_at[lte]", in.String("successful_before")) - setIfNonEmpty(params, "last_attempt_at[gte]", in.String("last_attempt_after")) - setIfNonEmpty(params, "last_attempt_at[lte]", in.String("last_attempt_before")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "order_by", in.String("order_by")) - setIfNonEmpty(params, "dir", in.String("dir")) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) - if err := setPayloadSearchFilters(params, in); err != nil { - return ErrorResult(err.Error()), nil + mcpcore.SetIfNonEmpty(params, "webhook_id", in.String("connection_id")) + mcpcore.SetIfNonEmpty(params, "source_id", in.String("source_id")) + mcpcore.SetIfNonEmpty(params, "destination_id", in.String("destination_id")) + mcpcore.SetIfNonEmpty(params, "status", in.String("status")) + mcpcore.SetIfNonEmpty(params, "attempts", in.String("attempts")) + mcpcore.SetIfNonEmpty(params, "issue_id", in.String("issue_id")) + mcpcore.SetIfNonEmpty(params, "error_code", in.String("error_code")) + mcpcore.SetIfNonEmpty(params, "response_status", in.String("response_status")) + mcpcore.SetIfNonEmpty(params, "cli_id", in.String("cli_id")) + mcpcore.SetIfNonEmpty(params, "created_at[gte]", in.String("created_after")) + mcpcore.SetIfNonEmpty(params, "created_at[lte]", in.String("created_before")) + mcpcore.SetIfNonEmpty(params, "successful_at[gte]", in.String("successful_after")) + mcpcore.SetIfNonEmpty(params, "successful_at[lte]", in.String("successful_before")) + mcpcore.SetIfNonEmpty(params, "last_attempt_at[gte]", in.String("last_attempt_after")) + mcpcore.SetIfNonEmpty(params, "last_attempt_at[lte]", in.String("last_attempt_before")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "order_by", in.String("order_by")) + mcpcore.SetIfNonEmpty(params, "dir", in.String("dir")) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) + if err := mcpcore.SetPayloadSearchFilters(params, in); err != nil { + return mcpcore.ErrorResult(err.Error()), nil } result, err := client.ListEvents(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func eventsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func eventsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } event, err := client.GetEvent(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(event, client) + return mcpcore.JSONResultEnvelopeForClient(event, client) } -func eventsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func eventsRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the raw_body action"), nil + return mcpcore.ErrorResult("id is required for the raw_body action"), nil } body, err := client.GetEventRawBody(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } text := string(body) if len(body) > maxRawBodyBytes { text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" } - return JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) + return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) } - diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index ae1310f0..3e529737 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -3,18 +3,18 @@ package mcp import ( "context" "fmt" - "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleHelp(client *hookdeck.Client) mcpsdk.ToolHandler { return func(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } topic := in.String("topic") @@ -92,7 +92,7 @@ hookdeck_help — This help text 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) + return mcpcore.TextResult(text) } var toolHelp = map[string]string{ @@ -326,24 +326,8 @@ Parameters: topic (string) — Tool name for detailed help (e.g. "hookdeck_events"). Omit for overview.`, } +// helpTopic resolves a topic name, accepting both the "hookdeck_events" and +// "events" forms. func helpTopic(topic string) *mcpsdk.CallToolResult { - // Allow both "hookdeck_events" and "events" forms - if !strings.HasPrefix(topic, "hookdeck_") { - topic = "hookdeck_" + topic - } - text, ok := toolHelp[topic] - if ok { - return TextResult(text + "\n\n" + mcpJSONSuccessResponseHelp) - } - - // If the topic doesn't match a tool name exactly, it may be a natural - // language question. List all available tools so the caller can pick. - var names []string - for k := range toolHelp { - names = append(names, k) - } - return ErrorResult(fmt.Sprintf( - "No help found for %q. The topic parameter expects a tool name, not a question.\n\nAvailable tools: %s\n\nOmit the topic parameter for a general overview.", - topic, strings.Join(names, ", "), - )) + return mcpcore.HelpTopic(helpTopicPrefix, toolHelp, topic, mcpJSONSuccessResponseHelp) } diff --git a/pkg/gateway/mcp/tool_issues.go b/pkg/gateway/mcp/tool_issues.go index c66fb962..9417b12c 100644 --- a/pkg/gateway/mcp/tool_issues.go +++ b/pkg/gateway/mcp/tool_issues.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleIssues(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -27,38 +28,37 @@ func handleIssues(client *hookdeck.Client) mcpsdk.ToolHandler { case "get": return issuesGet(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } } } -func issuesList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func issuesList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "type", in.String("type")) - setIfNonEmpty(params, "status", in.String("filter_status")) - setIfNonEmpty(params, "issue_trigger_id", in.String("issue_trigger_id")) - setIfNonEmpty(params, "order_by", in.String("order_by")) - setIfNonEmpty(params, "dir", in.String("dir")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "type", in.String("type")) + mcpcore.SetIfNonEmpty(params, "status", in.String("filter_status")) + mcpcore.SetIfNonEmpty(params, "issue_trigger_id", in.String("issue_trigger_id")) + mcpcore.SetIfNonEmpty(params, "order_by", in.String("order_by")) + mcpcore.SetIfNonEmpty(params, "dir", in.String("dir")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) result, err := client.ListIssues(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func issuesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func issuesGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } issue, err := client.GetIssue(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(issue, client) + return mcpcore.JSONResultEnvelopeForClient(issue, client) } - diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 9866b815..dc45363c 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -31,12 +32,12 @@ func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { case "transformations": return metricsTransformations(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected events, requests, attempts, or transformations", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected events, requests, attempts, or transformations", action)), nil } } } -func buildMetricsParams(in input) (hookdeck.MetricsQueryParams, error) { +func buildMetricsParams(in mcpcore.Input) (hookdeck.MetricsQueryParams, error) { start := in.String("start") end := in.String("end") if start == "" || end == "" { @@ -73,10 +74,10 @@ func containsAny(haystack []string, needles ...string) bool { return false } -func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func metricsEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params, err := buildMetricsParams(in) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } // Route to the correct events metrics endpoint based on measures/dimensions @@ -93,43 +94,43 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp } if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func metricsRequests(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func metricsRequests(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params, err := buildMetricsParams(in) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } result, err := client.QueryRequestMetrics(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func metricsAttempts(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func metricsAttempts(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params, err := buildMetricsParams(in) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } result, err := client.QueryAttemptMetrics(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func metricsTransformations(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func metricsTransformations(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params, err := buildMetricsParams(in) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } result, err := client.QueryTransformationMetrics(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } diff --git a/pkg/gateway/mcp/tool_projects.go b/pkg/gateway/mcp/tool_projects.go deleted file mode 100644 index 514c7782..00000000 --- a/pkg/gateway/mcp/tool_projects.go +++ /dev/null @@ -1,113 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/hookdeck/hookdeck-cli/pkg/config" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/project" -) - -func handleProjects(client *hookdeck.Client) mcpsdk.ToolHandler { - return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { - return r, nil - } - - in, err := parseInput(req.Params.Arguments) - if err != nil { - return ErrorResult(err.Error()), nil - } - - action := in.String("action") - switch action { - case "list", "": - return projectsList(client) - case "use": - return projectsUse(client, in) - default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or use", action)), nil - } - } -} - -type projectEntry struct { - ID string `json:"id"` - Org string `json:"org"` - Project string `json:"project"` - Type string `json:"type"` // lowercase: gateway, outpost, console - Current bool `json:"current"` -} - -func projectsList(client *hookdeck.Client) (*mcpsdk.CallToolResult, error) { - if err := project.EnsureUserAssociatedClient(client); err != nil { - return ErrorResult(listProjectsFailureMessage(err)), nil - } - - projects, err := client.ListProjects() - if err != nil { - return ErrorResult(listProjectsFailureMessage(err)), nil - } - - items := project.NormalizeProjects(projects, client.ProjectID) - - entries := make([]projectEntry, len(items)) - for i, it := range items { - entries[i] = projectEntry{ - ID: it.Id, - Org: it.Org, - Project: it.Project, - Type: config.ProjectTypeToJSON(it.Type), - Current: it.Current, - } - } - return JSONResultEnvelopeForClient(map[string]any{ - "projects": entries, - }, client) -} - -func projectsUse(client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { - id := in.String("project_id") - if id == "" { - return ErrorResult("project_id is required for the use action"), nil - } - - if err := project.EnsureUserAssociatedClient(client); err != nil { - return ErrorResult(listProjectsFailureMessage(err)), nil - } - - projects, err := client.ListProjects() - if err != nil { - return ErrorResult(listProjectsFailureMessage(err)), nil - } - - items := project.NormalizeProjects(projects, client.ProjectID) - var found *project.ProjectListItem - for i := range items { - if items[i].Id == id { - found = &items[i] - break - } - } - if found == nil { - return ErrorResult(fmt.Sprintf("project %q not found", id)), nil - } - - client.ProjectID = id - client.ProjectOrg = found.Org - client.ProjectName = found.Project - - out := map[string]string{ - "project_id": id, - "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 655ae625..6702d069 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -7,19 +7,20 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) const maxRawBodyBytes = 100 * 1024 // 100 KB func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -35,28 +36,28 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { case "ignored_events": return requestsIgnoredEvents(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, raw_body, events, or ignored_events", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, raw_body, events, or ignored_events", action)), nil } } } -func requestsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func requestsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "id", in.String("id")) - setIfNonEmpty(params, "source_id", in.String("source_id")) - setIfNonEmpty(params, "status", in.String("status")) - setIfNonEmpty(params, "rejection_cause", in.String("rejection_cause")) - setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) - setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) - setIfNonEmpty(params, "ingested_at[gte]", in.String("ingested_after")) - setIfNonEmpty(params, "ingested_at[lte]", in.String("ingested_before")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "order_by", in.String("order_by")) - setIfNonEmpty(params, "dir", in.String("dir")) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) - if err := setPayloadSearchFilters(params, in); err != nil { - return ErrorResult(err.Error()), nil + mcpcore.SetIfNonEmpty(params, "id", in.String("id")) + mcpcore.SetIfNonEmpty(params, "source_id", in.String("source_id")) + mcpcore.SetIfNonEmpty(params, "status", in.String("status")) + mcpcore.SetIfNonEmpty(params, "rejection_cause", in.String("rejection_cause")) + mcpcore.SetIfNonEmpty(params, "created_at[gte]", in.String("created_after")) + mcpcore.SetIfNonEmpty(params, "created_at[lte]", in.String("created_before")) + mcpcore.SetIfNonEmpty(params, "ingested_at[gte]", in.String("ingested_after")) + mcpcore.SetIfNonEmpty(params, "ingested_at[lte]", in.String("ingested_before")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "order_by", in.String("order_by")) + mcpcore.SetIfNonEmpty(params, "dir", in.String("dir")) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) + if err := mcpcore.SetPayloadSearchFilters(params, in); err != nil { + return mcpcore.ErrorResult(err.Error()), nil } if bp := in.BoolPtr("verified"); bp != nil { @@ -69,60 +70,59 @@ func requestsList(ctx context.Context, client *hookdeck.Client, in input) (*mcps result, err := client.ListRequests(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func requestsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func requestsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } r, err := client.GetRequest(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(r, client) + return mcpcore.JSONResultEnvelopeForClient(r, client) } -func requestsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func requestsRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the raw_body action"), nil + return mcpcore.ErrorResult("id is required for the raw_body action"), nil } body, err := client.GetRequestRawBody(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } text := string(body) if len(body) > maxRawBodyBytes { text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" } - return JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) + return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) } -func requestsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func requestsEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the events action"), nil + return mcpcore.ErrorResult("id is required for the events action"), nil } result, err := client.GetRequestEvents(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the ignored_events action"), nil + return mcpcore.ErrorResult("id is required for the ignored_events action"), nil } result, err := client.GetRequestIgnoredEvents(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } - diff --git a/pkg/gateway/mcp/tool_sources.go b/pkg/gateway/mcp/tool_sources.go index 44843611..8a257679 100644 --- a/pkg/gateway/mcp/tool_sources.go +++ b/pkg/gateway/mcp/tool_sources.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleSources(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -27,33 +28,33 @@ func handleSources(client *hookdeck.Client) mcpsdk.ToolHandler { case "get": return sourcesGet(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } } } -func sourcesList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func sourcesList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "name", in.String("name")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "name", in.String("name")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) result, err := client.ListSources(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func sourcesGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func sourcesGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } source, err := client.GetSource(ctx, id, nil) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(source, client) + return mcpcore.JSONResultEnvelopeForClient(source, client) } diff --git a/pkg/gateway/mcp/tool_transformations.go b/pkg/gateway/mcp/tool_transformations.go index f8de2cb0..097d2fe3 100644 --- a/pkg/gateway/mcp/tool_transformations.go +++ b/pkg/gateway/mcp/tool_transformations.go @@ -7,17 +7,18 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) func handleTransformations(client *hookdeck.Client) mcpsdk.ToolHandler { return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := requireAuth(client); r != nil { + if r := mcpcore.RequireAuth(client, loginToolName); r != nil { return r, nil } - in, err := parseInput(req.Params.Arguments) + in, err := mcpcore.ParseInput(req.Params.Arguments) if err != nil { - return ErrorResult(err.Error()), nil + return mcpcore.ErrorResult(err.Error()), nil } action := in.String("action") @@ -27,33 +28,33 @@ func handleTransformations(client *hookdeck.Client) mcpsdk.ToolHandler { case "get": return transformationsGet(ctx, client, in) default: - return ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } } } -func transformationsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func transformationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) - setIfNonEmpty(params, "name", in.String("name")) - setInt(params, "limit", in.Int("limit", 0)) - setIfNonEmpty(params, "next", in.String("next")) - setIfNonEmpty(params, "prev", in.String("prev")) + mcpcore.SetIfNonEmpty(params, "name", in.String("name")) + mcpcore.SetInt(params, "limit", in.Int("limit", 0)) + mcpcore.SetIfNonEmpty(params, "next", in.String("next")) + mcpcore.SetIfNonEmpty(params, "prev", in.String("prev")) result, err := client.ListTransformations(ctx, params) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(result, client) + return mcpcore.JSONResultEnvelopeForClient(result, client) } -func transformationsGet(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { +func transformationsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { id := in.String("id") if id == "" { - return ErrorResult("id is required for the get action"), nil + return mcpcore.ErrorResult("id is required for the get action"), nil } t, err := client.GetTransformation(ctx, id) if err != nil { - return ErrorResult(TranslateAPIError(err)), nil + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return JSONResultEnvelopeForClient(t, client) + return mcpcore.JSONResultEnvelopeForClient(t, client) } diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 59fba1ec..af721a16 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -1,40 +1,53 @@ package mcp import ( - "encoding/json" - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +// Tool names. The gateway server namespaces its tools with "hookdeck_". +const ( + toolPrefix = "hookdeck" + loginToolName = toolPrefix + "_login" + helpToolName = toolPrefix + "_help" + helpTopicPrefix = toolPrefix + "_" + loginToolDesc = "Authenticate the Hookdeck CLI or sign in again. Without arguments, returns a URL for browser login when not yet authenticated, or confirms if already signed in. Set reauth: true to clear the current session and start a new browser login (use when hookdeck_projects list fails and the stored key may be a single-project or dashboard API key)." + projectsToolDesc = "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. If list or use fails (especially 401/403), the error may suggest hookdeck_login with reauth: true. JSON successes use a standard data/meta envelope; see hookdeck_help (overview or any tool topic)." ) +// NewServer creates an MCP server exposing the Event Gateway tools. +// +// The supplied client is shared across all tool handlers; changing its +// ProjectID (e.g. via the projects tool's use action) affects subsequent calls +// within the same session. +// +// hookdeck_login is always registered: it signs in when unauthenticated, or +// with reauth: true clears stored credentials and starts a fresh browser login. +func NewServer(client *hookdeck.Client, cfg *config.Config) *mcpcore.Server { + return mcpcore.NewServer(mcpcore.Options{ + Name: "hookdeck-gateway", + ToolPrefix: toolPrefix, + Client: client, + Config: cfg, + ToolDefs: toolDefs, + }) +} + // toolDefs lists every tool the MCP server exposes. Each entry pairs a Tool // definition (with a proper JSON Schema) with a handler that calls the // Hookdeck API. -func toolDefs(client *hookdeck.Client) []struct { - tool *mcpsdk.Tool - handler mcpsdk.ToolHandler -} { - return []struct { - tool *mcpsdk.Tool - handler mcpsdk.ToolHandler - }{ - { - 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. If list or use fails (especially 401/403), the error may suggest hookdeck_login with reauth: true. 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)"}, - }, "action"), - }, - handler: handleProjects(client), - }, +func toolDefs(srv *mcpcore.Server) []mcpcore.ToolDef { + client := srv.Client() + return []mcpcore.ToolDef{ + srv.ProjectsToolDef(projectsToolDesc), { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_connections", Description: "Inspect connections (routes linking sources to destinations). List connections with filters, get details by ID or name, 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{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list, get, pause, or unpause", Enum: []string{"list", "get", "pause", "unpause"}}, "id": {Type: "string", Desc: "Connection ID or name (required for get/pause/unpause)"}, "name": {Type: "string", Desc: "Filter by name (list)"}, @@ -46,13 +59,13 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleConnections(client), + Handler: handleConnections(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_sources", Description: "List and inspect inbound sources (HTTP endpoints that receive events). Returns source configuration including URL, verification settings, and allowed HTTP methods.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Source ID (required for get)"}, "name": {Type: "string", Desc: "Filter by name (list)"}, @@ -61,13 +74,13 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleSources(client), + Handler: handleSources(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_destinations", Description: "List and inspect delivery destinations where events are sent. Destination types include HTTP endpoints, CLI (local development), and MOCK (testing). Returns destination configuration including URL, authentication, and rate limiting settings.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Destination ID (required for get)"}, "name": {Type: "string", Desc: "Filter by name (list)"}, @@ -76,13 +89,13 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleDestinations(client), + Handler: handleDestinations(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_transformations", Description: "List and inspect JavaScript transformations applied to event payloads. Returns transformation code and configuration for debugging payload processing.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Transformation ID (required for get)"}, "name": {Type: "string", Desc: "Filter by name (list)"}, @@ -91,13 +104,13 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleTransformations(client), + Handler: handleTransformations(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_requests", Description: "Query inbound requests (raw HTTP data received by Hookdeck before routing). List supports the same filters as `hookdeck gateway request list` (metadata, date range, payload search, sort). Get details, inspect raw body, or view events and ignored events 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{ + InputSchema: mcpcore.Schema(map[string]mcpcore.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: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, "source_id": {Type: "string", Desc: "Filter by source (list)"}, @@ -119,48 +132,48 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleRequests(client), + Handler: handleRequests(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_events", Description: "Query events (processed deliveries routed through connections to destinations). List supports the same filters as `hookdeck gateway event list` (metadata, date range, payload search, sort). 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: filter by ID(s) on list (comma-separated), or required for get/raw_body"}, - "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, - "status": {Type: "string", Desc: "Event status: SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED"}, - "attempts": {Type: "string", Desc: "Filter by attempt count (list). Integer or API operator syntax; pass through as string."}, - "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, - "error_code": {Type: "string", Desc: "Filter by error code (list)"}, - "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, - "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, - "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, - "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, + InputSchema: mcpcore.Schema(map[string]mcpcore.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: filter by ID(s) on list (comma-separated), or required for get/raw_body"}, + "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, + "status": {Type: "string", Desc: "Event status: SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED"}, + "attempts": {Type: "string", Desc: "Filter by attempt count (list). Integer or API operator syntax; pass through as string."}, + "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, + "error_code": {Type: "string", Desc: "Filter by error code (list)"}, + "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, + "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, + "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, + "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound. " + descDateBefore}, - "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, - "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, - "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, - "path": {Type: "string", Desc: descPathFilter}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "order_by": {Type: "string", Desc: "Sort field (list)"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, + "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleEvents(client), + Handler: handleEvents(client), }, { - tool: &mcpsdk.Tool{ + Tool: &mcpsdk.Tool{ Name: "hookdeck_attempts", Description: "Query delivery attempts (each HTTP request made to deliver an event to its destination). Filter by event to see retry history, response status codes, and error details.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Attempt ID (required for get)"}, "event_id": {Type: "string", Desc: "Filter by event (list)"}, @@ -171,13 +184,13 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleAttempts(client), + Handler: handleAttempts(client), }, { - tool: &mcpsdk.Tool{ + 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. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "action": {Type: "string", Desc: "Action: list or get", Enum: []string{"list", "get"}}, "id": {Type: "string", Desc: "Issue ID (required for get)"}, "type": {Type: "string", Desc: "Filter: delivery, transformation, or backpressure (list)"}, @@ -190,19 +203,19 @@ func toolDefs(client *hookdeck.Client) []struct { "prev": {Type: "string", Desc: "Previous page cursor"}, }, "action"), }, - handler: handleIssues(client), + Handler: handleIssues(client), }, { - tool: &mcpsdk.Tool{ + 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. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", - InputSchema: schema(map[string]prop{ + InputSchema: mcpcore.Schema(map[string]mcpcore.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)"}, "end": {Type: "string", Desc: "End datetime (ISO 8601, required)"}, "granularity": {Type: "string", Desc: "Time bucket size, e.g. 1h, 5m, 1d"}, - "measures": {Type: "array", Desc: "Metrics to retrieve (required). Common: count, successful_count, failed_count, error_count", Items: &prop{Type: "string"}}, - "dimensions": {Type: "array", Desc: "Grouping dimensions", Items: &prop{Type: "string"}}, + "measures": {Type: "array", Desc: "Metrics to retrieve (required). Common: count, successful_count, failed_count, error_count", Items: &mcpcore.Prop{Type: "string"}}, + "dimensions": {Type: "array", Desc: "Grouping dimensions", Items: &mcpcore.Prop{Type: "string"}}, "source_id": {Type: "string", Desc: "Filter by source"}, "destination_id": {Type: "string", Desc: "Filter by destination"}, "connection_id": {Type: "string", Desc: "Filter by connection (maps to webhook_id)"}, @@ -210,45 +223,25 @@ func toolDefs(client *hookdeck.Client) []struct { "issue_id": {Type: "string", Desc: "Filter by issue (events only)"}, }, "action", "start", "end", "measures"), }, - handler: handleMetrics(client), + Handler: handleMetrics(client), }, { - tool: &mcpsdk.Tool{ - Name: "hookdeck_help", + Tool: &mcpsdk.Tool{ + Name: helpToolName, 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{ + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ "topic": {Type: "string", Desc: "Tool name for detailed help (e.g. hookdeck_events). Omit for overview."}, }), }, - handler: handleHelp(client), + Handler: handleHelp(client), }, + srv.LoginToolDef(loginToolDesc), } } -// prop describes a single JSON Schema property. -type prop struct { - Type string `json:"type"` - Desc string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *prop `json:"items,omitempty"` -} - const ( - descDateAfter = "ISO 8601 datetime lower bound (list). Maps to API field[gte]; do not pass bracket keys in MCP args. Combinable with the matching *_before param." + descDateAfter = "ISO 8601 datetime lower bound (list). Maps to API field[gte]; do not pass bracket keys in MCP args. Combinable with the matching *_before param." descDateBefore = "ISO 8601 datetime upper bound (list). Maps to API field[lte]; do not pass bracket keys in MCP args." descJSONFilter = "Hookdeck JSON filter (object or string). Same syntax as hookdeck listen --filter-body." descPathFilter = "Partial URL path match (string)." ) - -// schema builds a JSON Schema object with the given properties and required fields. -func schema(properties map[string]prop, required ...string) json.RawMessage { - s := map[string]interface{}{ - "type": "object", - "properties": properties, - } - if len(required) > 0 { - s["required"] = required - } - data, _ := json.Marshal(s) - return data -} diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index e3234aa1..276a4325 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -116,6 +116,23 @@ func (c *Client) WithTelemetry(t *CLITelemetry) *Client { type ErrorResponse struct { Handled bool `json:"Handled"` Message string `json:"message"` + + // Data carries per-field detail on a validation failure. Without it a 422 + // surfaces as a bare "validation error", which says nothing about what to + // change — the Outpost API, for instance, returns + // {"message":"validation error","data":["topic is invalid"]}. + Data []string `json:"data,omitempty"` +} + +// Detail returns the message with any field-level detail appended. +func (e *ErrorResponse) Detail() string { + if len(e.Data) == 0 { + return e.Message + } + if e.Message == "" { + return strings.Join(e.Data, "; ") + } + return e.Message + ": " + strings.Join(e.Data, "; ") } // APIError is a structured error returned by the Hookdeck API. @@ -353,10 +370,10 @@ func checkAndPrintError(res *http.Response) error { Message: fmt.Sprintf("unexpected http status code: %d, raw response body: %s", res.StatusCode, body), } } - if response.Message != "" { + if detail := response.Detail(); detail != "" { return &APIError{ StatusCode: res.StatusCode, - Message: response.Message, + Message: detail, } } return &APIError{ diff --git a/pkg/hookdeck/outpost_publish.go b/pkg/hookdeck/outpost_publish.go index 99679ea7..7eb288ec 100644 --- a/pkg/hookdeck/outpost_publish.go +++ b/pkg/hookdeck/outpost_publish.go @@ -82,3 +82,44 @@ func (c *Client) PublishOutpostEvent(ctx context.Context, apiKey string, req *Ou return &result, nil } + +// TenantExistsForPublish reports whether a tenant exists in the project the +// publish credential routes to. +// +// This matters because publishing follows the credential, not the client's +// active project. A publish for a tenant that does not exist there is accepted +// with a 202 and an event id, matches nothing, is never delivered, and does not +// appear in any event list — so the caller sees a success and no trace of it. +// Checking first turns that into an answerable error. +// +// The lookup deliberately uses the same credential and host as the publish, so +// it resolves to the same project the event would go to. +func (c *Client) TenantExistsForPublish(ctx context.Context, apiKey, tenantID string) (bool, error) { + if apiKey == "" || tenantID == "" { + return false, fmt.Errorf("an API key and tenant are required to check a tenant") + } + + lookup := c.withoutStoredAuth() + // Publishing resolves the project from the credential alone. Resource reads + // additionally honour the project header, so leaving it set would check a + // different project from the one the event goes to — and, when the key is not + // valid for it, fail with a 401 that hides the answer entirely. + lookup.ProjectID = "" + + req, err := lookup.newRequest(ctx, http.MethodGet, outpostPath("tenants", tenantID), nil) + if err != nil { + return false, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := lookup.PerformRequest(ctx, req) + if err != nil { + if IsNotFoundError(err) { + return false, nil + } + return false, err + } + defer resp.Body.Close() + + return true, nil +} diff --git a/pkg/login/claimed_cli_key.go b/pkg/login/claimed_cli_key.go index 8200aeb7..58906d9f 100644 --- a/pkg/login/claimed_cli_key.go +++ b/pkg/login/claimed_cli_key.go @@ -1,54 +1,54 @@ -package login - -import ( - "fmt" - "os" - "strings" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" - "github.com/hookdeck/hookdeck-cli/pkg/validators" -) - -// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console -// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser -// device auth or guest sandbox claim—even when the existing profile is a guest Console session. -func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { - cli_key = strings.TrimSpace(cli_key) - if cli_key == "" { - return fmt.Errorf("--cli-key is required") - } - if err := validators.APIKey(cli_key); err != nil { - return err - } - - config.Profile.APIKey = cli_key - - spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) - response, err := config.GetAPIClient().ValidateAPIKey() - if err != nil { - ansi.StopSpinner(spinner, "", os.Stdout) - return err - } - - message := SuccessMessage( - response.UserName, - response.UserEmail, - response.OrganizationName, - response.ProjectName, - response.ProjectMode == "console", - ) - ansi.StopSpinner(spinner, message, os.Stdout) - - config.Profile.ApplyValidateAPIKeyResponse(response, true) - - if err := config.Profile.SaveProfile(); err != nil { - return err - } - if err := config.Profile.UseProfile(); err != nil { - return err - } - config.RefreshCachedAPIClient() - - return nil -} +package login + +import ( + "fmt" + "os" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console +// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser +// device auth or guest sandbox claim—even when the existing profile is a guest Console session. +func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { + cli_key = strings.TrimSpace(cli_key) + if cli_key == "" { + return fmt.Errorf("--cli-key is required") + } + if err := validators.APIKey(cli_key); err != nil { + return err + } + + config.Profile.APIKey = cli_key + + spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) + response, err := config.GetAPIClient().ValidateAPIKey() + if err != nil { + ansi.StopSpinner(spinner, "", os.Stdout) + return err + } + + message := SuccessMessage( + response.UserName, + response.UserEmail, + response.OrganizationName, + response.ProjectName, + response.ProjectMode == "console", + ) + ansi.StopSpinner(spinner, message, os.Stdout) + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err := config.Profile.SaveProfile(); err != nil { + return err + } + if err := config.Profile.UseProfile(); err != nil { + return err + } + config.RefreshCachedAPIClient() + + return nil +} diff --git a/pkg/mcpcore/auth.go b/pkg/mcpcore/auth.go new file mode 100644 index 00000000..03fc16b5 --- /dev/null +++ b/pkg/mcpcore/auth.go @@ -0,0 +1,35 @@ +package mcpcore + +import ( + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// RequireAuth checks whether the API client has a valid API key. If not, it +// returns an error result directing the agent to the server's login tool. +// Callers should return immediately when the result is non-nil. +func RequireAuth(client *hookdeck.Client, loginTool string) *mcpsdk.CallToolResult { + if client.APIKey == "" { + return ErrorResult(fmt.Sprintf("Not authenticated. Please call the %s tool to authenticate with Hookdeck.", loginTool)) + } + return nil +} + +// RequireWrite guards a write action on a server started in read-only mode. +// +// The primary gate is the tool schema: a read-only server does not advertise +// write actions at all. This is the second line of defence, for a client that +// calls an action it was never offered. Callers should return immediately when +// the result is non-nil. +func RequireWrite(enabled bool, action string) *mcpsdk.CallToolResult { + if enabled { + return nil + } + return ErrorResult(fmt.Sprintf( + "The %q action modifies data or returns a credential, and this MCP server is running in read-only mode. Restart it with --allow-write (or set HOOKDECK_MCP_ALLOW_WRITE=true) to enable write actions.", + action, + )) +} diff --git a/pkg/mcpcore/auth_test.go b/pkg/mcpcore/auth_test.go new file mode 100644 index 00000000..899f8eee --- /dev/null +++ b/pkg/mcpcore/auth_test.go @@ -0,0 +1,39 @@ +package mcpcore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +func TestRequireAuth(t *testing.T) { + t.Run("no API key names the server's login tool", func(t *testing.T) { + result := RequireAuth(&hookdeck.Client{}, "outpost_login") + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, firstText(t, result), "outpost_login") + }) + + t.Run("API key present passes", func(t *testing.T) { + assert.Nil(t, RequireAuth(&hookdeck.Client{APIKey: "key"}, "hookdeck_login")) + }) +} + +func TestRequireWrite(t *testing.T) { + t.Run("write mode enabled passes", func(t *testing.T) { + assert.Nil(t, RequireWrite(true, "delete")) + }) + + t.Run("read-only mode names the action and the flag", func(t *testing.T) { + result := RequireWrite(false, "delete") + require.NotNil(t, result) + assert.True(t, result.IsError) + text := firstText(t, result) + assert.Contains(t, text, `"delete"`) + assert.Contains(t, text, "--allow-write") + assert.Contains(t, text, "read-only mode") + }) +} diff --git a/pkg/gateway/mcp/errors.go b/pkg/mcpcore/errors.go similarity index 69% rename from pkg/gateway/mcp/errors.go rename to pkg/mcpcore/errors.go index cd656ffd..a61d6e5e 100644 --- a/pkg/gateway/mcp/errors.go +++ b/pkg/mcpcore/errors.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "errors" @@ -20,6 +20,14 @@ func TranslateAPIError(err error) string { switch apiErr.StatusCode { case http.StatusUnauthorized: return "Authentication failed. Check your API key." + case http.StatusForbidden: + // Distinct from 401: the credential is valid but is not permitted to do + // this. Saying "check your API key" would send the caller down the wrong + // path, so keep the API's explanation and name the likely cause. + if apiErr.Message != "" { + return fmt.Sprintf("Not permitted: %s", apiErr.Message) + } + return "Not permitted. The credential in use does not have access to this resource or project." case http.StatusNotFound, http.StatusGone: return fmt.Sprintf("Resource not found: %s", apiErr.Message) case http.StatusUnprocessableEntity: diff --git a/pkg/mcpcore/errors_test.go b/pkg/mcpcore/errors_test.go new file mode 100644 index 00000000..d1d9f693 --- /dev/null +++ b/pkg/mcpcore/errors_test.go @@ -0,0 +1,73 @@ +package mcpcore + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +func TestTranslateAPIError_403Forbidden(t *testing.T) { + t.Run("keeps the API explanation", func(t *testing.T) { + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 403, Message: "missing scope tenants:write"}) + assert.Contains(t, msg, "Not permitted") + assert.Contains(t, msg, "missing scope tenants:write") + assert.NotContains(t, msg, "Check your API key") + }) + + t.Run("falls back when the API gives no message", func(t *testing.T) { + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 403}) + assert.Contains(t, msg, "Not permitted") + }) +} + +func TestTranslateAPIError(t *testing.T) { + tests := []struct { + name string + err error + wantSubstr string + }{ + {"401 Unauthorized", &hookdeck.APIError{StatusCode: 401, Message: "bad key"}, "Authentication failed"}, + {"404 Not Found", &hookdeck.APIError{StatusCode: 404, Message: "resource xyz"}, "Resource not found"}, + {"410 Gone", &hookdeck.APIError{StatusCode: 410, Message: "resource xyz"}, "Resource not found"}, + {"422 Validation", &hookdeck.APIError{StatusCode: 422, Message: "invalid field foo"}, "invalid field foo"}, + {"429 Rate Limit", &hookdeck.APIError{StatusCode: 429, Message: "slow down"}, "Rate limited"}, + {"500 Server Error", &hookdeck.APIError{StatusCode: 500, Message: "internal"}, "Hookdeck API error"}, + {"Non-API error", fmt.Errorf("network timeout"), "network timeout"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := TranslateAPIError(tt.err) + assert.Contains(t, msg, tt.wantSubstr) + }) + } +} + +// --------------------------------------------------------------------------- +// Sources tool +// --------------------------------------------------------------------------- + +func TestTranslateAPIError_RetryAfterMessage(t *testing.T) { + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 429, Message: "rate limited"}) + assert.Contains(t, msg, "Rate limited") + assert.Contains(t, msg, "Retry after") +} + +func TestTranslateAPIError_GenericClientError(t *testing.T) { + // A 4xx status not explicitly handled should pass through the message + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 409, Message: "conflict on resource"}) + assert.Contains(t, msg, "conflict on resource") +} + +func TestTranslateAPIError_502GatewayError(t *testing.T) { + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 502, Message: "bad gateway"}) + assert.Contains(t, msg, "Hookdeck API error") +} + +func TestTranslateAPIError_503ServiceUnavailable(t *testing.T) { + msg := TranslateAPIError(&hookdeck.APIError{StatusCode: 503, Message: "service unavailable"}) + assert.Contains(t, msg, "Hookdeck API error") +} diff --git a/pkg/mcpcore/help.go b/pkg/mcpcore/help.go new file mode 100644 index 00000000..63cb727d --- /dev/null +++ b/pkg/mcpcore/help.go @@ -0,0 +1,45 @@ +package mcpcore + +import ( + "fmt" + "sort" + "strings" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// HelpTopic resolves a help topic against a product's topic map and returns the +// help text, or an error result listing the available topics. +// +// prefix is the server's tool-name prefix (e.g. "hookdeck_"), so both the +// qualified name ("hookdeck_events") and the bare resource ("events") resolve. +// suffix is appended to every topic that resolves — products use it to repeat +// shared documentation such as the JSON response shape. +func HelpTopic(prefix string, topics map[string]string, topic, suffix string) *mcpsdk.CallToolResult { + // An exact tool name always wins. Platform tools (hookdeck_login, + // hookdeck_projects) do not carry the product prefix, so prepending it + // unconditionally would turn a valid topic into a miss. + text, ok := topics[topic] + if !ok && prefix != "" && !strings.HasPrefix(topic, prefix) { + topic = prefix + topic + text, ok = topics[topic] + } + if ok { + if suffix != "" { + return TextResult(text + "\n\n" + suffix) + } + return TextResult(text) + } + + // If the topic doesn't match a tool name exactly, it may be a natural + // language question. List all available tools so the caller can pick. + var names []string + for k := range topics { + names = append(names, k) + } + sort.Strings(names) + return ErrorResult(fmt.Sprintf( + "No help found for %q. The topic parameter expects a tool name, not a question.\n\nAvailable tools: %s\n\nOmit the topic parameter for a general overview.", + topic, strings.Join(names, ", "), + )) +} diff --git a/pkg/mcpcore/help_test.go b/pkg/mcpcore/help_test.go new file mode 100644 index 00000000..e4ccbdb5 --- /dev/null +++ b/pkg/mcpcore/help_test.go @@ -0,0 +1,45 @@ +package mcpcore + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHelpTopic(t *testing.T) { + topics := map[string]string{ + "outpost_events": "events help", + "outpost_tenants": "tenants help", + } + + t.Run("qualified name resolves", func(t *testing.T) { + result := HelpTopic("outpost_", topics, "outpost_events", "") + assert.False(t, result.IsError) + assert.Equal(t, "events help", firstText(t, result)) + }) + + t.Run("bare name is prefixed", func(t *testing.T) { + result := HelpTopic("outpost_", topics, "events", "") + assert.False(t, result.IsError) + assert.Equal(t, "events help", firstText(t, result)) + }) + + t.Run("suffix is appended", func(t *testing.T) { + result := HelpTopic("outpost_", topics, "events", "shared docs") + assert.Equal(t, "events help\n\nshared docs", firstText(t, result)) + }) + + t.Run("unknown topic lists the available tools", func(t *testing.T) { + result := HelpTopic("outpost_", topics, "how do I retry", "") + assert.True(t, result.IsError) + text := firstText(t, result) + assert.Contains(t, text, "No help found") + assert.Contains(t, text, "outpost_events") + assert.Contains(t, text, "outpost_tenants") + }) + + t.Run("another server's prefix does not resolve these topics", func(t *testing.T) { + result := HelpTopic("hookdeck_", topics, "events", "") + assert.True(t, result.IsError) + }) +} diff --git a/pkg/gateway/mcp/input.go b/pkg/mcpcore/input.go similarity index 66% rename from pkg/gateway/mcp/input.go rename to pkg/mcpcore/input.go index f2f3a20c..9f359f26 100644 --- a/pkg/gateway/mcp/input.go +++ b/pkg/mcpcore/input.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "encoding/json" @@ -6,24 +6,24 @@ import ( "strconv" ) -// input is a thin wrapper around the raw JSON arguments from an MCP tool call. +// Input is a thin wrapper around the raw JSON arguments from an MCP tool call. // It provides typed accessors that return zero values when a key is missing. -type input map[string]interface{} +type Input map[string]interface{} -// parseInput unmarshals the raw JSON arguments into an input map. -func parseInput(raw json.RawMessage) (input, error) { +// ParseInput unmarshals the raw JSON arguments into an Input map. +func ParseInput(raw json.RawMessage) (Input, error) { if len(raw) == 0 { - return input{}, nil + return Input{}, nil } var m map[string]interface{} if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } - return input(m), nil + return Input(m), nil } // String returns the string value for a key, or "" if missing/wrong type. -func (in input) String(key string) string { +func (in Input) String(key string) string { v, ok := in[key] if !ok { return "" @@ -36,7 +36,7 @@ func (in input) String(key string) string { } // Int returns the integer value for a key, or the given default if missing. -func (in input) Int(key string, def int) int { +func (in Input) Int(key string, def int) int { v, ok := in[key] if !ok { return def @@ -56,7 +56,7 @@ func (in input) Int(key string, def int) int { } // Bool returns the boolean value for a key, or false if missing. -func (in input) Bool(key string) bool { +func (in Input) Bool(key string) bool { v, ok := in[key] if !ok { return false @@ -69,7 +69,7 @@ func (in input) Bool(key string) bool { } // BoolPtr returns a *bool for a key, or nil if missing. -func (in input) BoolPtr(key string) *bool { +func (in Input) BoolPtr(key string) *bool { v, ok := in[key] if !ok { return nil @@ -82,7 +82,7 @@ func (in input) BoolPtr(key string) *bool { } // StringSlice returns the string slice for a key, or nil if missing. -func (in input) StringSlice(key string) []string { +func (in Input) StringSlice(key string) []string { v, ok := in[key] if !ok { return nil @@ -100,15 +100,15 @@ func (in input) StringSlice(key string) []string { return result } -// setIfNonEmpty adds the value to the map if it is not empty. -func setIfNonEmpty(params map[string]string, key, value string) { +// SetIfNonEmpty adds the value to the map if it is not empty. +func SetIfNonEmpty(params map[string]string, key, value string) { if value != "" { params[key] = value } } -// setInt adds the int value to the map if it is > 0. -func setInt(params map[string]string, key string, value int) { +// SetInt adds the int value to the map if it is > 0. +func SetInt(params map[string]string, key string, value int) { if value > 0 { params[key] = strconv.Itoa(value) } @@ -116,7 +116,7 @@ func setInt(params map[string]string, key string, value int) { // JSONFilterParam returns a JSON filter value for API query params (body, headers, etc.). // Accepts a JSON string or object from MCP tool arguments. -func (in input) JSONFilterParam(key string) (string, error) { +func (in Input) JSONFilterParam(key string) (string, error) { v, ok := in[key] if !ok { return "", nil @@ -135,20 +135,20 @@ func (in input) JSONFilterParam(key string) (string, error) { } } -// setJSONFilter adds a JSON filter param when present and valid. -func setJSONFilter(params map[string]string, key string, in input) error { +// SetJSONFilter adds a JSON filter param when present and valid. +func SetJSONFilter(params map[string]string, key string, in Input) error { value, err := in.JSONFilterParam(key) if err != nil { return err } - setIfNonEmpty(params, key, value) + SetIfNonEmpty(params, key, value) return nil } -// setPayloadSearchFilters forwards body, headers, parsed_query, and path list filters. -func setPayloadSearchFilters(params map[string]string, in input) error { +// SetPayloadSearchFilters forwards body, headers, parsed_query, and path list filters. +func SetPayloadSearchFilters(params map[string]string, in Input) error { for _, key := range []string{"body", "headers", "parsed_query", "path"} { - if err := setJSONFilter(params, key, in); err != nil { + if err := SetJSONFilter(params, key, in); err != nil { return err } } diff --git a/pkg/mcpcore/input_test.go b/pkg/mcpcore/input_test.go new file mode 100644 index 00000000..66cbf56e --- /dev/null +++ b/pkg/mcpcore/input_test.go @@ -0,0 +1,90 @@ +package mcpcore + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInput_JSONFilterParam_Missing(t *testing.T) { + in := Input{} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.Empty(t, value) +} + +func TestInput_JSONFilterParam_String(t *testing.T) { + in := Input{"body": `{"type":"payment"}`} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.Equal(t, `{"type":"payment"}`, value) +} + +func TestInput_JSONFilterParam_Object(t *testing.T) { + in := Input{"body": map[string]interface{}{"type": "payment", "amount": float64(100)}} + value, err := in.JSONFilterParam("body") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"payment","amount":100}`, value) +} + +func TestInput_JSONFilterParam_InvalidType(t *testing.T) { + in := Input{"body": 42} + _, err := in.JSONFilterParam("body") + require.Error(t, err) + assert.Contains(t, err.Error(), "body must be a JSON string or object") +} + +func TestSetPayloadSearchFilters(t *testing.T) { + params := make(map[string]string) + in := Input{ + "body": map[string]interface{}{"a": "b"}, + "headers": `{"x-test":"1"}`, + "parsed_query": map[string]interface{}{"q": "x"}, + "path": "/webhooks", + } + require.NoError(t, SetPayloadSearchFilters(params, in)) + assert.JSONEq(t, `{"a":"b"}`, params["body"]) + assert.Equal(t, `{"x-test":"1"}`, params["headers"]) + assert.JSONEq(t, `{"q":"x"}`, params["parsed_query"]) + assert.Equal(t, "/webhooks", params["path"]) +} + +func TestInput_Accessors(t *testing.T) { + raw := json.RawMessage(`{ + "name": "test", + "count": 42, + "active": true, + "tags": ["a", "b"], + "missing_bool": null + }`) + + in, err := ParseInput(raw) + require.NoError(t, err) + + assert.Equal(t, "test", in.String("name")) + assert.Equal(t, "", in.String("nonexistent")) + assert.Equal(t, 42, in.Int("count", 0)) + assert.Equal(t, 99, in.Int("nonexistent", 99)) + assert.Equal(t, true, in.Bool("active")) + assert.Equal(t, false, in.Bool("nonexistent")) + assert.Equal(t, []string{"a", "b"}, in.StringSlice("tags")) + assert.Nil(t, in.StringSlice("nonexistent")) + + bp := in.BoolPtr("active") + require.NotNil(t, bp) + assert.True(t, *bp) + assert.Nil(t, in.BoolPtr("nonexistent")) +} + +func TestInput_EmptyArgs(t *testing.T) { + in, err := ParseInput(nil) + require.NoError(t, err) + assert.Equal(t, "", in.String("anything")) +} + +func TestInput_InvalidJSON(t *testing.T) { + _, err := ParseInput(json.RawMessage(`{invalid`)) + assert.Error(t, err) +} diff --git a/pkg/mcpcore/project_display.go b/pkg/mcpcore/project_display.go new file mode 100644 index 00000000..6de8767a --- /dev/null +++ b/pkg/mcpcore/project_display.go @@ -0,0 +1,54 @@ +package mcpcore + +import ( + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/project" +) + +// FillProjectDisplayNameIfNeeded sets target.ProjectOrg and target.ProjectName +// when target has an API key and project id but no cached org/name (typical +// after loading the profile from disk). Fails silently on API errors. Stdio MCP +// invokes tools sequentially, so this is safe without locking. +// +// lookup is the client account-level requests are made from, which is not always +// target: a product API served from its own host does not answer them, so the +// lookup has to go to the account API. +// +// Resolution order matters. Validating the key is tried first because it works +// for every credential and returns the name of the key's own project. Listing +// projects only works for a user-associated key, so a project-scoped key from +// `hookdeck ci` — a common way to configure an MCP server — would otherwise +// leave the name empty and callers with nothing but an opaque id to show. +func FillProjectDisplayNameIfNeeded(lookup, target *hookdeck.Client) { + if lookup == nil || target == nil || target.APIKey == "" || target.ProjectID == "" { + return + } + if target.ProjectName != "" || target.ProjectOrg != "" { + return + } + + // The key's own project, available whatever kind of key it is. + if response, err := lookup.ValidateAPIKey(); err == nil && response.ProjectID == target.ProjectID { + target.ProjectName = response.ProjectName + target.ProjectOrg = response.OrganizationName + if target.ProjectName != "" || target.ProjectOrg != "" { + return + } + } + + // The active project differs from the key's, so it has to be looked up. + // Only a user-associated key can do this. + projects, err := lookup.ListProjects() + if err != nil { + return + } + items := project.NormalizeProjects(projects, target.ProjectID) + for i := range items { + if items[i].Id != target.ProjectID { + continue + } + target.ProjectOrg = items[i].Org + target.ProjectName = items[i].Project + return + } +} diff --git a/pkg/mcpcore/project_display_test.go b/pkg/mcpcore/project_display_test.go new file mode 100644 index 00000000..66fa8848 --- /dev/null +++ b/pkg/mcpcore/project_display_test.go @@ -0,0 +1,132 @@ +package mcpcore + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" + "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, 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, client) + require.Equal(t, "already", client.ProjectName) +} + +// A product API served from its own host cannot answer the project list, so the +// lookup has to go to the account API while the product client is the one +// updated. +func TestFillProjectDisplayNameIfNeeded_LooksUpThroughTheAccountClient(t *testing.T) { + accountAPI := 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": "outpost"}, + }) + })) + t.Cleanup(accountAPI.Close) + + productAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("the product API must not be asked for the project list: %s", r.URL.Path) + http.NotFound(w, r) + })) + t.Cleanup(productAPI.Close) + + accountURL, err := url.Parse(accountAPI.URL) + require.NoError(t, err) + productURL, err := url.Parse(productAPI.URL) + require.NoError(t, err) + + account := &hookdeck.Client{BaseURL: accountURL, APIKey: "k", ProjectID: "proj_x"} + product := &hookdeck.Client{BaseURL: productURL, APIKey: "k", ProjectID: "proj_x"} + + FillProjectDisplayNameIfNeeded(account, product) + require.Equal(t, "Acme", product.ProjectOrg) + require.Equal(t, "production", product.ProjectName) +} + +// TestFillProjectDisplayName_ProjectScopedKey covers the case that left MCP +// responses carrying a bare project id: a key from `hookdeck ci` cannot list +// projects, so name resolution has to come from validating the key instead. +func TestFillProjectDisplayName_ProjectScopedKey(t *testing.T) { + var listCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + _, _ = w.Write([]byte(`{"team_id":"tm_1","team_name_no_org":"cli outpost testing","organization_name":"Automated Testing"}`)) + case strings.HasSuffix(r.URL.Path, "/teams"): + listCalled = true + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"This credential is scoped to a single project"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + client := &hookdeck.Client{BaseURL: baseURL, APIKey: "ci-key", ProjectID: "tm_1"} + + FillProjectDisplayNameIfNeeded(client, client) + + assert.Equal(t, "cli outpost testing", client.ProjectName) + assert.Equal(t, "Automated Testing", client.ProjectOrg) + assert.False(t, listCalled, "validating the key is enough; listing projects would fail for this credential") +} + +// TestFillProjectDisplayName_FallsBackToListing covers the other direction: the +// active project is not the one the key belongs to, so only a listing can name it. +func TestFillProjectDisplayName_FallsBackToListing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + _, _ = w.Write([]byte(`{"team_id":"tm_other","team_name_no_org":"wrong one","organization_name":"Org"}`)) + case strings.HasSuffix(r.URL.Path, "/teams"): + _, _ = w.Write([]byte(`[{"id":"tm_1","name":"[Acme] the active one","mode":"outpost"}]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + client := &hookdeck.Client{BaseURL: baseURL, APIKey: "user-key", ProjectID: "tm_1"} + + FillProjectDisplayNameIfNeeded(client, client) + + assert.Equal(t, "the active one", client.ProjectName, "the key's own project must not be used when it is not the active one") +} diff --git a/pkg/gateway/mcp/response.go b/pkg/mcpcore/response.go similarity index 99% rename from pkg/gateway/mcp/response.go rename to pkg/mcpcore/response.go index b25a04b7..e5363aab 100644 --- a/pkg/gateway/mcp/response.go +++ b/pkg/mcpcore/response.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "encoding/json" diff --git a/pkg/gateway/mcp/response_test.go b/pkg/mcpcore/response_test.go similarity index 99% rename from pkg/gateway/mcp/response_test.go rename to pkg/mcpcore/response_test.go index e466e123..07cd5dc5 100644 --- a/pkg/gateway/mcp/response_test.go +++ b/pkg/mcpcore/response_test.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "encoding/json" diff --git a/pkg/mcpcore/schema.go b/pkg/mcpcore/schema.go new file mode 100644 index 00000000..fcbf75b5 --- /dev/null +++ b/pkg/mcpcore/schema.go @@ -0,0 +1,24 @@ +package mcpcore + +import "encoding/json" + +// Prop describes a single JSON Schema property. +type Prop struct { + Type string `json:"type"` + Desc string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Items *Prop `json:"items,omitempty"` +} + +// Schema builds a JSON Schema object with the given properties and required fields. +func Schema(properties map[string]Prop, required ...string) json.RawMessage { + s := map[string]interface{}{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + s["required"] = required + } + data, _ := json.Marshal(s) + return data +} diff --git a/pkg/mcpcore/server.go b/pkg/mcpcore/server.go new file mode 100644 index 00000000..4dbf7d42 --- /dev/null +++ b/pkg/mcpcore/server.go @@ -0,0 +1,271 @@ +package mcpcore + +import ( + "context" + "encoding/json" + "fmt" + "os" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/version" +) + +// ToolDef pairs a tool definition (with its JSON Schema) with the handler that +// serves it. +type ToolDef struct { + Tool *mcpsdk.Tool + Handler mcpsdk.ToolHandler +} + +// Options configure a product-specific MCP server built on this package. +type Options struct { + // Name is the MCP server identity reported at initialize + // (e.g. "hookdeck-gateway", "hookdeck-outpost"). + Name string + + // ToolPrefix namespaces every tool this server exposes (e.g. "hookdeck", + // "outpost") so several Hookdeck MCP servers can be configured in one client + // without colliding. + ToolPrefix string + + // PlatformPrefix namespaces tools that belong to the Hookdeck platform + // rather than to a single product (login, projects). Defaults to + // DefaultPlatformPrefix, which is what every server should use. + PlatformPrefix string + + // Client is the API client shared by every tool handler. Handlers mutate it + // in place (e.g. ProjectID on a project switch), so each server must be given + // the client for its own API. + Client *hookdeck.Client + + // AccountClient answers the account-level requests that are not part of a + // product API: listing projects and validating credentials. A product served + // from its own host cannot answer those, so it must supply the account API + // client here. Defaults to Client. + AccountClient *hookdeck.Client + + // Config is the CLI configuration, used by the login tool to persist + // credentials. + Config *config.Config + + // WriteEnabled reports whether write actions are available in this session. + WriteEnabled bool + + // ProjectFilter, when set, is the project type (see pkg/config) the projects + // tool lists and allows switching to. Empty means no filtering. + ProjectFilter string + + // ToolDefs supplies the tools to register. It receives the constructed + // server so definitions can reach the client, write mode and tool names. + ToolDefs func(*Server) []ToolDef +} + +// Server wraps the MCP SDK server and the Hookdeck API client. +type Server struct { + opts Options + client *hookdeck.Client + accountClient *hookdeck.Client + cfg *config.Config + mcpServer *mcpsdk.Server + + // sessionCtx is the context passed to RunStdio. It is cancelled when the + // MCP transport closes (stdin EOF). Background goroutines (e.g. login + // polling) should select on this — NOT on the per-request ctx passed to + // tool handlers, which is cancelled when the handler returns. + sessionCtx context.Context +} + +// NewServer creates an MCP server from the given options and registers the +// tools returned by Options.ToolDefs. +// +// The client is shared across all tool handlers; changing its ProjectID (e.g. +// via the projects tool's use action) affects subsequent calls within the same +// session. +func NewServer(opts Options) *Server { + s := &Server{opts: opts, client: opts.Client, accountClient: opts.AccountClient, cfg: opts.Config} + if s.accountClient == nil { + s.accountClient = opts.Client + } + + s.mcpServer = mcpsdk.NewServer( + &mcpsdk.Implementation{ + Name: opts.Name, + Version: version.Version, + }, + nil, // default options; tools capability is inferred from AddTool calls + ) + + if opts.ToolDefs != nil { + for _, td := range opts.ToolDefs(s) { + s.mcpServer.AddTool(td.Tool, s.wrapWithTelemetry(td.Tool.Name, td.Handler)) + } + } + + return s +} + +// Client returns the API client shared by this server's tool handlers. +func (s *Server) Client() *hookdeck.Client { return s.client } + +// AccountClient returns the client used for account-level requests: listing +// projects and validating credentials. +func (s *Server) AccountClient() *hookdeck.Client { return s.accountClient } + +// projectClients returns every client whose project and credentials must stay +// in step. The account client is only listed separately when it is a different +// client from the product one. +func (s *Server) projectClients() []*hookdeck.Client { + if s.accountClient == nil || s.accountClient == s.client { + return []*hookdeck.Client{s.client} + } + return []*hookdeck.Client{s.client, s.accountClient} +} + +// Config returns the CLI configuration this server was built with. +func (s *Server) Config() *config.Config { return s.cfg } + +// WriteEnabled reports whether write actions are available in this session. +func (s *Server) WriteEnabled() bool { return s.opts.WriteEnabled } + +// ProjectFilter returns the project type this server serves, or "" when it +// serves any project type. +func (s *Server) ProjectFilter() string { return s.opts.ProjectFilter } + +// ToolName returns the fully qualified name for a resource, e.g. "outpost_events". +func (s *Server) ToolName(resource string) string { + if s.opts.ToolPrefix == "" { + return resource + } + return s.opts.ToolPrefix + "_" + resource +} + +// ToolPrefix returns the tool-name prefix including the separator, e.g. "outpost_". +func (s *Server) ToolPrefix() string { + if s.opts.ToolPrefix == "" { + return "" + } + return s.opts.ToolPrefix + "_" +} + +// LoginToolName returns the name of this server's login tool. +func (s *Server) LoginToolName() string { return s.platformToolName("login") } + +// DefaultPlatformPrefix is the prefix for platform-level tools. You log in to +// Hookdeck and switch Hookdeck projects, whichever product's server you are in. +const DefaultPlatformPrefix = "hookdeck" + +// ProjectsToolName returns the name of this server's projects tool. +func (s *Server) ProjectsToolName() string { return s.platformToolName("projects") } + +// platformToolName names a tool that belongs to the Hookdeck platform rather +// than to one product. +// +// Logging in and switching projects are Hookdeck operations, not Gateway or +// Outpost ones, so they keep the platform prefix in every server. Product tools +// (ToolName) take the product's own prefix. Both servers therefore expose the +// same hookdeck_login and hookdeck_projects, which is correct: it is the same +// operation, and a client that has both configured sees one consistent name for +// it. +func (s *Server) platformToolName(resource string) string { + prefix := s.opts.PlatformPrefix + if prefix == "" { + prefix = DefaultPlatformPrefix + } + return prefix + "_" + resource +} + +// RequireAuth guards a handler on an unauthenticated session, naming this +// server's login tool. +func (s *Server) RequireAuth() *mcpsdk.CallToolResult { + return RequireAuth(s.client, s.LoginToolName()) +} + +// mcpClientInfo extracts the MCP client name/version string from the +// session's initialize params. Returns "" if unavailable. +func mcpClientInfo(req *mcpsdk.CallToolRequest) string { + if req.Session == nil { + return "" + } + params := req.Session.InitializeParams() + if params == nil || params.ClientInfo == nil { + return "" + } + ci := params.ClientInfo + if ci.Version != "" { + return fmt.Sprintf("%s/%s", ci.Name, ci.Version) + } + return ci.Name +} + +// wrapWithTelemetry returns a handler that sets per-invocation telemetry on the +// shared client before delegating to the original handler. The stdio transport +// processes tool calls sequentially, so setting telemetry on the shared client +// is safe (no concurrent access). +func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler) mcpsdk.ToolHandler { + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + // Extract the action from the request arguments for command_path. + action := extractAction(req) + commandPath := toolName + if action != "" { + commandPath = toolName + "/" + action + } + + deviceName, _ := os.Hostname() + + telemetry := &hookdeck.CLITelemetry{ + Source: "mcp", + Environment: hookdeck.DetectEnvironment(), + CommandPath: commandPath, + InvocationID: hookdeck.NewInvocationID(), + DeviceName: deviceName, + MCPClient: mcpClientInfo(req), + } + // One invocation can reach both APIs (a projects call lists through the + // account API and then scopes the product one), so both carry the same + // telemetry rather than only the first. + for _, c := range s.projectClients() { + c.Telemetry = telemetry + } + defer func() { + for _, c := range s.projectClients() { + c.Telemetry = nil + } + }() + + FillProjectDisplayNameIfNeeded(s.accountClient, s.client) + + return handler(ctx, req) + } +} + +// extractAction parses the "action" field from the tool call arguments. +func extractAction(req *mcpsdk.CallToolRequest) string { + if req.Params.Arguments == nil { + return "" + } + var args map[string]interface{} + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return "" + } + if action, ok := args["action"].(string); ok { + return action + } + return "" +} + +// RunStdio starts the MCP server on stdin/stdout and blocks until the +// connection is closed (i.e. stdin reaches EOF). +func (s *Server) RunStdio(ctx context.Context) error { + return s.Run(ctx, &mcpsdk.StdioTransport{}) +} + +// Run starts the MCP server on the given transport. It stores ctx as the +// session-level context so background goroutines (e.g. login polling) can +// detect when the session ends. +func (s *Server) Run(ctx context.Context, transport mcpsdk.Transport) error { + s.sessionCtx = ctx + return s.mcpServer.Run(ctx, transport) +} diff --git a/pkg/mcpcore/server_test.go b/pkg/mcpcore/server_test.go new file mode 100644 index 00000000..c73160e7 --- /dev/null +++ b/pkg/mcpcore/server_test.go @@ -0,0 +1,131 @@ +package mcpcore + +import ( + "context" + "encoding/json" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// newCallToolRequest creates a CallToolRequest with the given arguments JSON. +func newCallToolRequest(argsJSON string) *mcpsdk.CallToolRequest { + return &mcpsdk.CallToolRequest{ + Params: &mcpsdk.CallToolParamsRaw{ + Arguments: json.RawMessage(argsJSON), + }, + } +} + +func TestExtractAction(t *testing.T) { + tests := []struct { + name string + req *mcpsdk.CallToolRequest + expected string + }{ + {"valid action", newCallToolRequest(`{"action":"list"}`), "list"}, + {"no action field", newCallToolRequest(`{"id":"123"}`), ""}, + {"empty object", newCallToolRequest(`{}`), ""}, + {"action with other fields", newCallToolRequest(`{"action":"get","id":"evt_123"}`), "get"}, + {"nil arguments", &mcpsdk.CallToolRequest{Params: &mcpsdk.CallToolParamsRaw{}}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractAction(tt.req) + require.Equal(t, tt.expected, got) + }) + } +} + +func TestMCPClientInfoNilSession(t *testing.T) { + req := newCallToolRequest(`{}`) + req.Session = nil + got := mcpClientInfo(req) + require.Equal(t, "", got) +} + +func TestWrapWithTelemetrySetsAndClears(t *testing.T) { + client := &hookdeck.Client{} + s := &Server{client: client} + + var capturedTelemetry *hookdeck.CLITelemetry + + innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + require.NotNil(t, s.client.Telemetry) + require.Equal(t, "mcp", s.client.Telemetry.Source) + require.Equal(t, "hookdeck_events/list", s.client.Telemetry.CommandPath) + require.NotEmpty(t, s.client.Telemetry.InvocationID) + require.NotEmpty(t, s.client.Telemetry.DeviceName) + // Capture a copy + cp := *s.client.Telemetry + capturedTelemetry = &cp + return &mcpsdk.CallToolResult{}, nil + }) + + wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) + + req := newCallToolRequest(`{"action":"list"}`) + result, err := wrapped(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + + // Telemetry should have been captured inside the handler + require.NotNil(t, capturedTelemetry) + require.Equal(t, "mcp", capturedTelemetry.Source) + require.Equal(t, "hookdeck_events/list", capturedTelemetry.CommandPath) + + // After the wrapper returns, telemetry should be cleared on the shared client + require.Nil(t, s.client.Telemetry) +} + +func TestWrapWithTelemetryNoAction(t *testing.T) { + client := &hookdeck.Client{} + s := &Server{client: client} + + var capturedPath string + + innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + capturedPath = s.client.Telemetry.CommandPath + return &mcpsdk.CallToolResult{}, nil + }) + + wrapped := s.wrapWithTelemetry("hookdeck_help", innerHandler) + + req := newCallToolRequest(`{"topic":"hookdeck_events"}`) + _, err := wrapped(context.Background(), req) + require.NoError(t, err) + + // No "action" field, so command path should just be the tool name + require.Equal(t, "hookdeck_help", capturedPath) +} + +func TestWrapWithTelemetryUniqueInvocationIDs(t *testing.T) { + client := &hookdeck.Client{} + s := &Server{client: client} + + var ids []string + + innerHandler := mcpsdk.ToolHandler(func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + ids = append(ids, s.client.Telemetry.InvocationID) + return &mcpsdk.CallToolResult{}, nil + }) + + wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) + + for i := 0; i < 5; i++ { + req := newCallToolRequest(`{"action":"list"}`) + _, _ = wrapped(context.Background(), req) + } + + require.Len(t, ids, 5) + // All IDs should be unique + seen := make(map[string]bool) + for _, id := range ids { + require.False(t, seen[id], "duplicate invocation ID: %s", id) + seen[id] = true + } +} diff --git a/pkg/gateway/mcp/tool_login.go b/pkg/mcpcore/tool_login.go similarity index 71% rename from pkg/gateway/mcp/tool_login.go rename to pkg/mcpcore/tool_login.go index 7e9bfeb8..40145f23 100644 --- a/pkg/gateway/mcp/tool_login.go +++ b/pkg/mcpcore/tool_login.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "context" @@ -19,12 +19,14 @@ import ( ) const ( - loginPollInterval = 2 * time.Second + // LoginPollInterval is how long the login tool waits between polls while the + // user completes browser sign-in. + LoginPollInterval = 2 * time.Second loginMaxAttempts = 120 // ~4 minutes ) -// loginState tracks a background login poll so that repeated calls to -// hookdeck_login don't start duplicate auth flows. +// loginState tracks a background login poll so that repeated calls to the +// login tool don't start duplicate auth flows. // // Synchronization: err is written by the goroutine before close(done). // The handler only reads err after receiving from done, so the channel @@ -35,14 +37,37 @@ type loginState struct { err error // non-nil if polling failed } +// LoginToolDef returns the login tool for this server, named "_login". +// +// It is always registered: it signs in when unauthenticated, or with +// reauth: true clears stored credentials and starts a fresh browser login. +// The description is supplied by the product so it can speak about its own +// tools; the behaviour is shared. +func (s *Server) LoginToolDef(description string) ToolDef { + return ToolDef{ + Tool: &mcpsdk.Tool{ + Name: s.LoginToolName(), + Description: description, + InputSchema: Schema(map[string]Prop{ + "reauth": {Type: "boolean", Desc: fmt.Sprintf("If true, clear stored credentials and start a new browser login. Use when project listing fails — complete login in the browser, then retry %s.", s.ProjectsToolName())}, + }), + }, + Handler: handleLogin(s), + } +} + func handleLogin(srv *Server) mcpsdk.ToolHandler { + loginTool := srv.LoginToolName() client := srv.client + // Credential checks are account-level, so they go to the account API rather + // than a product API that would not answer them. + accountClient := srv.accountClient cfg := srv.cfg var stateMu sync.Mutex var state *loginState return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - in, err := parseInput(req.Params.Arguments) + in, err := ParseInput(req.Params.Arguments) if err != nil { return ErrorResult(err.Error()), nil } @@ -57,24 +82,27 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { case <-state.done: state = nil default: - return ErrorResult( - "A login flow is already in progress. Call hookdeck_login again after it completes, then use reauth: true if you still need to sign in again.", - ), nil + return ErrorResult(fmt.Sprintf( + "A login flow is already in progress. Call %s again after it completes, then use reauth: true if you still need to sign in again.", + loginTool, + )), nil } } if err := cfg.ClearActiveProfileCredentials(); err != nil { return ErrorResult(fmt.Sprintf("reauth: could not clear stored credentials: %v", err)), nil } - client.APIKey = "" - client.ProjectID = "" - client.ProjectOrg = "" - client.ProjectName = "" + for _, c := range srv.projectClients() { + c.APIKey = "" + c.ProjectID = "" + c.ProjectOrg = "" + c.ProjectName = "" + } } // Already authenticated with a user-associated key — nothing to do. loginPrefix := "" if client.APIKey != "" { - lacks_user, err := project.CredentialsLackUserAssociation(client) + lacks_user, err := project.CredentialsLackUserAssociation(accountClient) if err != nil && !hookdeck.IsUnauthorizedError(err) { return ErrorResult(fmt.Sprintf("Failed to verify credentials: %s", err)), nil } @@ -96,8 +124,8 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { browserURL := state.browserURL state = nil // allow a fresh retry return ErrorResult(fmt.Sprintf( - "Authentication failed: %s\n\nPlease call hookdeck_login again to retry.\nThe user needs to open this URL in their browser:\n\n%s", - errMsg, browserURL, + "Authentication failed: %s\n\nPlease call %s again to retry.\nThe user needs to open this URL in their browser:\n\n%s", + errMsg, loginTool, browserURL, )), nil } // Success was already handled by the goroutine (client.APIKey set). @@ -105,8 +133,8 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { default: // Still polling — remind the agent about the URL. return TextResult(fmt.Sprintf( - "Login is already in progress. Waiting for the user to complete authentication.\n\nThe user needs to open this URL in their browser:\n\n%s\n\nCall hookdeck_login again to check status.", - state.browserURL, + "Login is already in progress. Waiting for the user to complete authentication.\n\nThe user needs to open this URL in their browser:\n\n%s\n\nCall %s again to check status.", + state.browserURL, loginTool, )), nil } } @@ -147,7 +175,7 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { } ch := make(chan pollResult, 1) go func() { - resp, err := session.WaitForAPIKey(loginPollInterval, loginMaxAttempts) + resp, err := session.WaitForAPIKey(LoginPollInterval, loginMaxAttempts) ch <- pollResult{resp, err} }() @@ -179,8 +207,6 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { // Update the server-held client (in production this is the same pointer as // config.GetAPIClient(); tests inject a separate *hookdeck.Client, so we must // mutate this handle — RefreshCachedAPIClient only touches the global singleton). - client.APIKey = response.APIKey - client.ProjectID = response.ProjectID org, proj, err := project.ParseProjectName(response.ProjectName) if err != nil { org, proj = "", response.ProjectName @@ -188,8 +214,12 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { if o := strings.TrimSpace(response.OrganizationName); o != "" { org = o } - client.ProjectOrg = org - client.ProjectName = proj + for _, c := range srv.projectClients() { + c.APIKey = response.APIKey + c.ProjectID = response.ProjectID + c.ProjectOrg = org + c.ProjectName = proj + } log.WithFields(log.Fields{ "user": response.UserName, @@ -199,9 +229,10 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { // Return the URL immediately so the agent can show it to the user. return TextResult(fmt.Sprintf( - "%sLogin initiated. The user must open the following URL in their browser to authenticate:\n\n%s\n\nOnce the user completes authentication in the browser, all Hookdeck tools will become available.\nCall hookdeck_login again to check if authentication has completed.", + "%sLogin initiated. The user must open the following URL in their browser to authenticate:\n\n%s\n\nOnce the user completes authentication in the browser, all Hookdeck tools will become available.\nCall %s again to check if authentication has completed.", loginPrefix, session.BrowserURL, + loginTool, )), nil } } diff --git a/pkg/mcpcore/tool_projects.go b/pkg/mcpcore/tool_projects.go new file mode 100644 index 00000000..9ed1dd38 --- /dev/null +++ b/pkg/mcpcore/tool_projects.go @@ -0,0 +1,167 @@ +package mcpcore + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/project" +) + +// ProjectsToolDef returns the projects tool for this server, named +// "_projects". The description is supplied by the product; the list and +// use actions are shared. +// +// When Options.ProjectFilter is set, only projects of that type are listed and +// only those can be switched to — a server can only serve the product its API +// belongs to. +func (s *Server) ProjectsToolDef(description string) ToolDef { + return ToolDef{ + Tool: &mcpsdk.Tool{ + Name: s.ProjectsToolName(), + Description: description, + 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)"}, + }, "action"), + }, + Handler: handleProjects(s), + } +} + +func handleProjects(srv *Server) mcpsdk.ToolHandler { + client := srv.client + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + + in, err := ParseInput(req.Params.Arguments) + if err != nil { + return ErrorResult(err.Error()), nil + } + + action := in.String("action") + switch action { + case "list", "": + return projectsList(srv, client) + case "use": + return projectsUse(srv, client, in) + default: + return ErrorResult(fmt.Sprintf("unknown action %q; expected list or use", action)), nil + } + } +} + +type projectEntry struct { + ID string `json:"id"` + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` // lowercase: gateway, outpost, console + Current bool `json:"current"` +} + +// listProjectItems fetches the projects visible to the credentials, restricted +// to the server's project type when one is configured. +// +// The list comes from the account API, not the product one: a product served +// from its own host does not answer account-level requests. +func listProjectItems(srv *Server, client *hookdeck.Client) ([]project.ProjectListItem, error) { + accountClient := srv.AccountClient() + if err := project.EnsureUserAssociatedClient(accountClient); err != nil { + return nil, err + } + + projects, err := accountClient.ListProjects() + if err != nil { + return nil, err + } + + items := project.NormalizeProjects(projects, client.ProjectID) + filter := srv.ProjectFilter() + if filter == "" { + return items, nil + } + + filtered := make([]project.ProjectListItem, 0, len(items)) + for _, it := range items { + if it.Type == filter { + filtered = append(filtered, it) + } + } + return filtered, nil +} + +func projectsList(srv *Server, client *hookdeck.Client) (*mcpsdk.CallToolResult, error) { + items, err := listProjectItems(srv, client) + if err != nil { + return ErrorResult(listProjectsFailureMessage(srv, err)), nil + } + + entries := make([]projectEntry, len(items)) + for i, it := range items { + entries[i] = projectEntry{ + ID: it.Id, + Org: it.Org, + Project: it.Project, + Type: config.ProjectTypeToJSON(it.Type), + Current: it.Current, + } + } + return JSONResultEnvelopeForClient(map[string]any{ + "projects": entries, + }, client) +} + +func projectsUse(srv *Server, client *hookdeck.Client, in Input) (*mcpsdk.CallToolResult, error) { + id := in.String("project_id") + if id == "" { + return ErrorResult("project_id is required for the use action"), nil + } + + items, err := listProjectItems(srv, client) + if err != nil { + return ErrorResult(listProjectsFailureMessage(srv, err)), nil + } + + var found *project.ProjectListItem + for i := range items { + if items[i].Id == id { + found = &items[i] + break + } + } + if found == nil { + if filter := srv.ProjectFilter(); filter != "" { + // The project may well exist — it is just not one this server can + // serve, and switching to it would make every later call fail. + return ErrorResult(fmt.Sprintf( + "project %q not found among the %s projects available to this server. Use action list to see them.", + id, config.ProjectTypeToJSON(filter), + )), nil + } + return ErrorResult(fmt.Sprintf("project %q not found", id)), nil + } + + // Every client this server holds has to move together, or a later call would + // still be scoped to the previous project. + for _, c := range srv.projectClients() { + c.ProjectID = id + c.ProjectOrg = found.Org + c.ProjectName = found.Project + } + + out := map[string]string{ + "project_id": id, + "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_projects_errors.go b/pkg/mcpcore/tool_projects_errors.go similarity index 70% rename from pkg/gateway/mcp/tool_projects_errors.go rename to pkg/mcpcore/tool_projects_errors.go index 08f19d68..c24e98e9 100644 --- a/pkg/gateway/mcp/tool_projects_errors.go +++ b/pkg/mcpcore/tool_projects_errors.go @@ -1,7 +1,8 @@ -package mcp +package mcpcore import ( "errors" + "fmt" "net/http" "strings" @@ -9,12 +10,13 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/project" ) -const listProjectsReauthHint = `This may happen if the stored key is a dashboard or single-project API key that cannot list all teams/projects. Try hookdeck_login with reauth: true so the user can sign in via the browser and replace the credential with a full CLI session, then retry hookdeck_projects.` +const listProjectsReauthHintFormat = `This may happen if the stored key is a dashboard or single-project API key that cannot list all teams/projects. Try %s with reauth: true so the user can sign in via the browser and replace the credential with a full CLI session, then retry %s.` -func listProjectsFailureMessage(err error) string { +func listProjectsFailureMessage(srv *Server, err error) string { base := TranslateAPIError(err) if shouldSuggestReauthAfterListProjectsFailure(err) { - return base + "\n\n" + listProjectsReauthHint + hint := fmt.Sprintf(listProjectsReauthHintFormat, srv.LoginToolName(), srv.ProjectsToolName()) + return base + "\n\n" + hint } return base } diff --git a/pkg/gateway/mcp/tool_projects_errors_test.go b/pkg/mcpcore/tool_projects_errors_test.go similarity index 99% rename from pkg/gateway/mcp/tool_projects_errors_test.go rename to pkg/mcpcore/tool_projects_errors_test.go index 0f80f93d..13408595 100644 --- a/pkg/gateway/mcp/tool_projects_errors_test.go +++ b/pkg/mcpcore/tool_projects_errors_test.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "fmt" diff --git a/pkg/mcpcore/tool_projects_test.go b/pkg/mcpcore/tool_projects_test.go new file mode 100644 index 00000000..7913756d --- /dev/null +++ b/pkg/mcpcore/tool_projects_test.go @@ -0,0 +1,120 @@ +package mcpcore + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// projectsAPI stubs the endpoints the projects tool needs: the CLI key check and +// the project list. +func projectsAPI(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/2025-07-01/cli-auth/validate", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "user_id": "usr_test", + "user_name": "Test User", + "team_id": "proj_gateway", + "team_mode": "inbound", + }) + }) + mux.HandleFunc("/2025-07-01/teams", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": "proj_gateway", "name": "[Acme] gateway-project", "mode": "inbound"}, + {"id": "proj_outpost", "name": "[Acme] outpost-project", "mode": "outpost"}, + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newProjectsServer(t *testing.T, api *httptest.Server, filter string) (*Server, *hookdeck.Client) { + t.Helper() + u, err := url.Parse(api.URL) + require.NoError(t, err) + client := &hookdeck.Client{BaseURL: u, APIKey: "test-key", ProjectID: "proj_gateway"} + srv := NewServer(Options{ + Name: "hookdeck-test", + ToolPrefix: "outpost", + Client: client, + Config: &config.Config{APIBaseURL: api.URL}, + ProjectFilter: filter, + }) + return srv, client +} + +func callProjects(t *testing.T, srv *Server, args map[string]any) (string, bool) { + t.Helper() + raw, err := json.Marshal(args) + require.NoError(t, err) + result, err := handleProjects(srv)(t.Context(), newCallToolRequest(string(raw))) + require.NoError(t, err) + return firstText(t, result), result.IsError +} + +func TestProjectsTool_ProjectFilter(t *testing.T) { + t.Run("list returns only projects of the server's type", func(t *testing.T) { + api := projectsAPI(t) + srv, _ := newProjectsServer(t, api, config.ProjectTypeOutpost) + + text, isErr := callProjects(t, srv, map[string]any{"action": "list"}) + require.False(t, isErr, text) + assert.Contains(t, text, "outpost-project") + assert.NotContains(t, text, "gateway-project") + }) + + t.Run("list is unfiltered when no type is configured", func(t *testing.T) { + api := projectsAPI(t) + srv, _ := newProjectsServer(t, api, "") + + text, isErr := callProjects(t, srv, map[string]any{"action": "list"}) + require.False(t, isErr, text) + assert.Contains(t, text, "outpost-project") + assert.Contains(t, text, "gateway-project") + }) + + t.Run("use switches to a project of the server's type", func(t *testing.T) { + api := projectsAPI(t) + srv, client := newProjectsServer(t, api, config.ProjectTypeOutpost) + + text, isErr := callProjects(t, srv, map[string]any{"action": "use", "project_id": "proj_outpost"}) + require.False(t, isErr, text) + assert.Equal(t, "proj_outpost", client.ProjectID) + assert.Equal(t, "outpost-project", client.ProjectName) + }) + + t.Run("use refuses a project of another type and leaves the client alone", func(t *testing.T) { + api := projectsAPI(t) + srv, client := newProjectsServer(t, api, config.ProjectTypeOutpost) + + text, isErr := callProjects(t, srv, map[string]any{"action": "use", "project_id": "proj_gateway"}) + assert.True(t, isErr) + assert.Contains(t, text, "outpost") + assert.Equal(t, "proj_gateway", client.ProjectID, "the client must not be switched") + }) +} + +func TestProjectsTool_ToolNamesFollowThePrefix(t *testing.T) { + api := projectsAPI(t) + srv, _ := newProjectsServer(t, api, config.ProjectTypeOutpost) + + assert.Equal(t, "hookdeck_projects", srv.ProjectsToolName()) + assert.Equal(t, "hookdeck_login", srv.LoginToolName()) + assert.Equal(t, "outpost_events", srv.ToolName("events")) + assert.Equal(t, "outpost_", srv.ToolPrefix()) + + def := srv.ProjectsToolDef("desc") + assert.Equal(t, "hookdeck_projects", def.Tool.Name) + assert.Equal(t, "desc", def.Tool.Description) +} diff --git a/pkg/outpost/mcp/input.go b/pkg/outpost/mcp/input.go new file mode 100644 index 00000000..63410ce1 --- /dev/null +++ b/pkg/outpost/mcp/input.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "fmt" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +// stringList reads a value that may be given either as an array of strings or, +// mirroring the CLI's comma-separated flags, as a single string. +func stringList(in mcpcore.Input, key string) []string { + if values := in.StringSlice(key); len(values) > 0 { + return values + } + raw := in.String(key) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// object reads a JSON object argument. A missing key yields nil, not an error. +func object(in mcpcore.Input, key string) (map[string]interface{}, error) { + v, ok := in[key] + if !ok || v == nil { + return nil, nil + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be a JSON object", key) + } + return m, nil +} + +// stringMap reads a JSON object whose values must all be strings, such as +// resource metadata. +func stringMap(in mcpcore.Input, key string) (map[string]string, error) { + raw, err := object(in, key) + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + out := make(map[string]string, len(raw)) + for k, v := range raw { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("%s.%s must be a string", key, k) + } + out[k] = s + } + return out, nil +} + +// requireString returns the value for key, or an error naming the action that +// needs it. +func requireString(in mcpcore.Input, key, action string) (string, error) { + value := in.String(key) + if value == "" { + return "", fmt.Errorf("%s is required for the %s action", key, action) + } + return value, nil +} diff --git a/pkg/outpost/mcp/projects_test.go b/pkg/outpost/mcp/projects_test.go new file mode 100644 index 00000000..6c54f310 --- /dev/null +++ b/pkg/outpost/mcp/projects_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The Outpost API is served from its own host and does not answer account-level +// requests, so the projects tool has to list through the main Hookdeck API while +// switching the Outpost client. Getting this wrong is invisible until the next +// Outpost call silently uses the previous project. + +func accountAPI(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/2025-07-01/cli-auth/validate", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "user_id": "usr_1", + "user_name": "Test User", + "team_id": "proj_outpost", + "team_mode": "outpost", + }) + }) + mux.HandleFunc("/2025-07-01/teams", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": "proj_outpost", "name": "[Acme] outpost-project", "mode": "outpost"}, + {"id": "proj_other", "name": "[Acme] second-outpost", "mode": "outpost"}, + {"id": "proj_gateway", "name": "[Acme] gateway-project", "mode": "inbound"}, + }) + }) + return mux +} + +func TestProjectsTool_UsesTheAccountAPIAndSwitchesTheOutpostClient(t *testing.T) { + account := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/cli-auth/validate": accountAPI(t).ServeHTTP, + "/2025-07-01/teams": accountAPI(t).ServeHTTP, + }) + // The Outpost API must never be asked for the project list. + outpost := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/teams": func(w http.ResponseWriter, r *http.Request) { + t.Error("the Outpost API was asked to list projects") + }, + }) + + outpostClient := newTestClient(t, outpost.URL) + accountClient := newTestClient(t, account.URL) + session := connect(t, ServerOptions{Client: outpostClient, AccountClient: accountClient}) + + t.Run("list returns only Outpost projects", func(t *testing.T) { + result := callTool(t, session, "hookdeck_projects", map[string]any{"action": "list"}) + require.False(t, result.IsError, resultText(t, result)) + text := resultText(t, result) + assert.Contains(t, text, "outpost-project") + assert.Contains(t, text, "second-outpost") + assert.NotContains(t, text, "gateway-project", "this server cannot serve a Gateway project") + }) + + t.Run("use switches the Outpost client, not just the account one", func(t *testing.T) { + result := callTool(t, session, "hookdeck_projects", map[string]any{ + "action": "use", + "project_id": "proj_other", + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "proj_other", outpostClient.ProjectID, + "later Outpost calls would otherwise still hit the previous project") + assert.Equal(t, "proj_other", accountClient.ProjectID) + assert.Equal(t, "second-outpost", outpostClient.ProjectName) + }) + + t.Run("use refuses a Gateway project", func(t *testing.T) { + result := callTool(t, session, "hookdeck_projects", map[string]any{ + "action": "use", + "project_id": "proj_gateway", + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "outpost") + assert.Equal(t, "proj_other", outpostClient.ProjectID, "the client must not have moved") + }) +} diff --git a/pkg/outpost/mcp/tool_attempts.go b/pkg/outpost/mcp/tool_attempts.go new file mode 100644 index 00000000..d68dd2ea --- /dev/null +++ b/pkg/outpost/mcp/tool_attempts.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var attemptsActions = actionSet{ + {name: "list", desc: "list delivery attempts"}, + {name: "get", desc: "get one attempt, including the response data"}, +} + +var attemptsSpec = toolSpec{ + resource: "attempts", + summary: "Query delivery attempts — each individual HTTP request made to deliver an event to a destination, with its status, response code and retry number. This is where to look when a customer reports a missing or failed delivery.", + actions: attemptsActions, + props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Attempt ID (required for get)."}, + "tenant_id": {Type: "string", Desc: "Filter by tenant. " + descListValue}, + "destination_id": {Type: "string", Desc: "Filter by destination. " + descListValue}, + "event_id": {Type: "string", Desc: "Filter by event — use this to see an event's full retry history. " + descListValue}, + "destination_type": {Type: "string", Desc: "Filter by destination type (list). " + descListValue}, + "topic": {Type: "string", Desc: "Filter by topic(s) (list). " + descListValue}, + "status": {Type: "string", Desc: "Filter by outcome: success or failed (list).", Enum: []string{"success", "failed"}}, + "include": {Type: "array", Desc: `Embed related records in the response: "event", "destination".`, Items: &mcpcore.Prop{Type: "string"}}, + "time_after": {Type: "string", Desc: descTimeAfter + " (list)"}, + "time_before": {Type: "string", Desc: descTimeBefore + " (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field: time (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor (list)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list)"}, + }, + handler: handleAttempts, +} + +func handleAttempts(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, attemptsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + if action == "list" { + return attemptsList(ctx, client, in) + } + return attemptsGet(ctx, client, in) + } +} + +// singleOrEmpty returns the value when exactly one was supplied. The +// tenant-scoped attempts route needs one tenant and one destination; anything +// else has to go through the global route as a filter. +func singleOrEmpty(values []string) string { + if len(values) == 1 { + return values[0] + } + return "" +} + +func attemptsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + tenantIDs := stringList(in, "tenant_id") + destinationIDs := stringList(in, "destination_id") + + result, err := client.ListOutpostAttempts(ctx, hookdeck.OutpostAttemptListParams{ + TenantID: singleOrEmpty(tenantIDs), + DestinationID: singleOrEmpty(destinationIDs), + TenantIDs: tenantIDs, + EventIDs: stringList(in, "event_id"), + DestinationIDs: destinationIDs, + DestinationType: stringList(in, "destination_type"), + Topics: stringList(in, "topic"), + Status: in.String("status"), + TimeAfter: in.String("time_after"), + TimeBefore: in.String("time_before"), + Include: stringList(in, "include"), + Limit: in.Int("limit", 0), + OrderBy: in.String("order_by"), + Dir: in.String("dir"), + Next: in.String("next"), + Prev: in.String("prev"), + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +func attemptsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "get") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + attempt, err := client.GetOutpostAttempt(ctx, id, hookdeck.OutpostAttemptGetParams{ + TenantID: singleOrEmpty(stringList(in, "tenant_id")), + DestinationID: singleOrEmpty(stringList(in, "destination_id")), + Include: stringList(in, "include"), + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(attempt, client) +} diff --git a/pkg/outpost/mcp/tool_catalog.go b/pkg/outpost/mcp/tool_catalog.go new file mode 100644 index 00000000..d344580c --- /dev/null +++ b/pkg/outpost/mcp/tool_catalog.go @@ -0,0 +1,147 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +// Topics and destination types are both read-only catalogues describing what a +// destination may be created with, which is why they live together here. + +var topicsActions = actionSet{ + {name: "list", desc: "list the topics configured for this project"}, +} + +var topicsSpec = toolSpec{ + resource: "topics", + summary: "List the topics destinations can subscribe to and events can be published on. Topics are project configuration rather than a resource, so they are changed with outpost_config, not created here.", + actions: topicsActions, + handler: handleTopics, +} + +func handleTopics(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if _, blocked := dispatch(srv, topicsActions, in.String("action")); blocked != nil { + return blocked, nil + } + + topics, err := client.ListOutpostTopics(ctx) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]any{"topics": topics}, client) + } +} + +var destinationTypesActions = actionSet{ + {name: "list", desc: "list the available destination types"}, + {name: "get", desc: "get one type's full field schema"}, +} + +var destinationTypesSpec = toolSpec{ + resource: "destination_types", + summary: "Describe the destination types available in this project and the config and credential fields each one accepts. Call this before outpost_destinations create or update so the payload matches the type's schema.", + actions: destinationTypesActions, + props: map[string]mcpcore.Prop{ + "type": {Type: "string", Desc: "Destination type, e.g. webhook (required for get)."}, + "include_setup_docs": {Type: "boolean", Desc: "Include the provider setup instructions and icon. These are long and meant for rendering a setup UI, so they are omitted by default."}, + }, + handler: handleDestinationTypes, +} + +func handleDestinationTypes(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, destinationTypesActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + verbose := in.Bool("include_setup_docs") + + if action == "get" { + destinationType, err := requireString(in, "type", "get") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + schema, err := client.GetOutpostDestinationType(ctx, destinationType) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(trimSetupDocs(*schema, verbose), client) + } + + schemas, err := client.ListOutpostDestinationTypes(ctx) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + trimmed := make([]hookdeck.OutpostDestinationTypeSchema, len(schemas)) + for i, schema := range schemas { + trimmed[i] = trimSetupDocs(schema, verbose) + } + return mcpcore.JSONResultEnvelopeForClient(trimmed, client) + } +} + +// trimSetupDocs drops the icon and setup instructions unless they were asked +// for. Both are sized for a setup UI and would otherwise dominate the response. +func trimSetupDocs(schema hookdeck.OutpostDestinationTypeSchema, verbose bool) hookdeck.OutpostDestinationTypeSchema { + if verbose { + return schema + } + schema.Icon = "" + schema.Instructions = "" + return schema +} + +var statusActions = actionSet{ + {name: "get", desc: "report the deployment status for this project"}, +} + +var statusSpec = toolSpec{ + resource: "status", + summary: "Report the state of this project's Outpost deployment, including the portal hostname. Configuration changes take a short while to reach the deployment, so check here after outpost_config set.", + actions: statusActions, + handler: handleStatus, +} + +func handleStatus(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if _, blocked := dispatch(srv, statusActions, in.String("action")); blocked != nil { + return blocked, nil + } + + status, err := client.GetOutpostStatus(ctx) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(status, client) + } +} diff --git a/pkg/outpost/mcp/tool_config.go b/pkg/outpost/mcp/tool_config.go new file mode 100644 index 00000000..205c5d9b --- /dev/null +++ b/pkg/outpost/mcp/tool_config.go @@ -0,0 +1,132 @@ +package mcp + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var configActions = actionSet{ + {name: "get", desc: "show the project configuration"}, + {name: "set", desc: "change configuration values", write: true, destructive: true}, + {name: "custom_domain_get", desc: "show the tenant portal's custom domain"}, + {name: "custom_domain_set", desc: "configure a custom domain for the tenant portal", write: true}, + {name: "custom_domain_delete", desc: "remove the custom domain", write: true, destructive: true}, +} + +var configSpec = toolSpec{ + resource: "config", + summary: "Read and change this project's Outpost configuration. These settings apply to the whole project — every tenant and every destination — so a change here affects all delivery, and takes a short while to reach the deployment (check outpost_status). Some keys are managed for you and are rejected if set directly.", + actions: configActions, + props: map[string]mcpcore.Prop{ + "key": {Type: "string", Desc: "A single configuration key to read (get). Omit to read everything that is set."}, + "values": {Type: "object", Desc: `Configuration values to set, as {"KEY": "value"} (set). Only the keys given are changed.`}, + "unset": {Type: "array", Desc: "Configuration keys to return to their default (set).", Items: &mcpcore.Prop{Type: "string"}}, + "hostname": {Type: "string", Desc: "Hostname to serve the tenant portal from (required for custom_domain_set)."}, + }, + handler: handleConfig, +} + +func handleConfig(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, configActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + switch action { + case "get": + return configGet(ctx, client, in) + case "set": + return configSet(ctx, client, in) + case "custom_domain_get": + domain, err := client.GetOutpostCustomDomain(ctx) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(domain, client) + case "custom_domain_set": + hostname, err := requireString(in, "hostname", "custom_domain_set") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + domain, err := client.AddOutpostCustomDomain(ctx, hostname) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(domain, client) + default: + if err := client.DeleteOutpostCustomDomain(ctx); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{"status": "deleted"}, client) + } + } +} + +func configGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + cfg, err := client.GetOutpostConfig(ctx) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + if key := in.String("key"); key != "" { + value, present := cfg[key] + if !present { + return mcpcore.ErrorResult(fmt.Sprintf("no configuration key named %q", key)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]*string{key: value}, client) + } + return mcpcore.JSONResultEnvelopeForClient(cfg, client) +} + +func configSet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + update := hookdeck.OutpostManagedConfig{} + + values, err := object(in, "values") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + for key, raw := range values { + switch v := raw.(type) { + case string: + value := v + update[key] = &value + case nil: + // A null clears the key back to its default, same as unset. + update[key] = nil + default: + return mcpcore.ErrorResult(fmt.Sprintf("values.%s must be a string, or null to clear it", key)), nil + } + } + + for _, key := range stringList(in, "unset") { + update[key] = nil + } + + if len(update) == 0 { + return mcpcore.ErrorResult("nothing to change: pass values, unset, or both"), nil + } + + updated, err := client.UpdateOutpostConfig(ctx, update) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]any{ + "config": updated, + "changed": len(update), + "note": "Changes take a short while to reach the deployment. Check outpost_status.", + }, client) +} diff --git a/pkg/outpost/mcp/tool_destinations.go b/pkg/outpost/mcp/tool_destinations.go new file mode 100644 index 00000000..8e39332d --- /dev/null +++ b/pkg/outpost/mcp/tool_destinations.go @@ -0,0 +1,162 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var destinationsActions = actionSet{ + {name: "list", desc: "list a tenant's destinations"}, + {name: "get", desc: "get one destination"}, + {name: "create", desc: "create a destination for a tenant", write: true}, + {name: "update", desc: "update a destination", write: true}, + {name: "delete", desc: "delete a destination", write: true, destructive: true}, + {name: "enable", desc: "resume delivery to a destination", write: true}, + {name: "disable", desc: "stop delivery to a destination without deleting it", write: true}, +} + +var destinationsSpec = toolSpec{ + resource: "destinations", + summary: "Inspect and manage the destinations events are delivered to. Every destination belongs to a tenant, so tenant_id is always required. Config and credentials are specific to the destination type — call outpost_destination_types to see the fields a type accepts before creating or updating one. Destinations have no name: identify one to a human by its type and target (for example \"webhook -> https://example.com/hooks\"), not by its id, which means nothing on its own.", + actions: destinationsActions, + required: []string{"tenant_id"}, + props: map[string]mcpcore.Prop{ + "tenant_id": {Type: "string", Desc: "Tenant the destination belongs to (required for every action)."}, + "id": {Type: "string", Desc: "Destination ID. Required for get/update/delete/enable/disable."}, + "type": {Type: "string", Desc: "Destination type, e.g. webhook (required for create). On list, filters by type(s). " + descListValue}, + "topics": {Type: "array", Desc: `Topics to subscribe to, or ["*"] for all. On list, filters by topic(s).`, Items: &mcpcore.Prop{Type: "string"}}, + "config": {Type: "object", Desc: "Type-specific configuration, e.g. {\"url\": \"https://example.com/hooks\"} for a webhook (create/update)."}, + "credentials": {Type: "object", Desc: "Type-specific credentials (create/update). Values are write-only; the API does not return them."}, + "filter": {Type: "object", Desc: "Delivery filter (create/update). Replaced wholesale on update, not merged."}, + "metadata": {Type: "object", Desc: "Destination metadata as a JSON object of string values (create/update)."}, + }, + handler: handleDestinations, +} + +func handleDestinations(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, destinationsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + tenantID, err := requireString(in, "tenant_id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + switch action { + case "list": + return destinationsList(ctx, client, in, tenantID) + case "create": + return destinationsCreate(ctx, client, in, tenantID) + } + + id, err := requireString(in, "id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + switch action { + case "get": + return destinationResult(client)(client.GetOutpostDestination(ctx, tenantID, id)) + case "update": + return destinationsUpdate(ctx, client, in, tenantID, id) + case "enable": + return destinationResult(client)(client.EnableOutpostDestination(ctx, tenantID, id)) + case "disable": + return destinationResult(client)(client.DisableOutpostDestination(ctx, tenantID, id)) + default: + if err := client.DeleteOutpostDestination(ctx, tenantID, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "tenant_id": tenantID, + "destination_id": id, + "status": "deleted", + }, client) + } + } +} + +// destinationResult adapts the client's (destination, error) returns into a +// tool result, so the single-destination actions do not each repeat it. +func destinationResult(client *hookdeck.Client) func(*hookdeck.OutpostDestination, error) (*mcpsdk.CallToolResult, error) { + return func(destination *hookdeck.OutpostDestination, err error) (*mcpsdk.CallToolResult, error) { + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(destination, client) + } +} + +func destinationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, tenantID string) (*mcpsdk.CallToolResult, error) { + destinations, err := client.ListOutpostDestinations(ctx, tenantID, stringList(in, "type"), stringList(in, "topics")) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(destinations, client) +} + +func destinationsCreate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, tenantID string) (*mcpsdk.CallToolResult, error) { + destinationType, err := requireString(in, "type", "create") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + cfg, credentials, filter, metadata, err := destinationPayload(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + return destinationResult(client)(client.CreateOutpostDestination(ctx, tenantID, &hookdeck.OutpostDestinationCreateRequest{ + Type: destinationType, + Topics: hookdeck.OutpostTopics(stringList(in, "topics")), + Config: cfg, + Credentials: credentials, + Filter: filter, + Metadata: metadata, + })) +} + +func destinationsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, tenantID, id string) (*mcpsdk.CallToolResult, error) { + cfg, credentials, filter, metadata, err := destinationPayload(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + return destinationResult(client)(client.UpdateOutpostDestination(ctx, tenantID, id, &hookdeck.OutpostDestinationUpdateRequest{ + Topics: hookdeck.OutpostTopics(stringList(in, "topics")), + Config: cfg, + Credentials: credentials, + Filter: filter, + Metadata: metadata, + })) +} + +// destinationPayload reads the object arguments shared by create and update. +func destinationPayload(in mcpcore.Input) (cfg, credentials, filter map[string]interface{}, metadata map[string]string, err error) { + if cfg, err = object(in, "config"); err != nil { + return nil, nil, nil, nil, err + } + if credentials, err = object(in, "credentials"); err != nil { + return nil, nil, nil, nil, err + } + if filter, err = object(in, "filter"); err != nil { + return nil, nil, nil, nil, err + } + if metadata, err = stringMap(in, "metadata"); err != nil { + return nil, nil, nil, nil, err + } + return cfg, credentials, filter, metadata, nil +} diff --git a/pkg/outpost/mcp/tool_events.go b/pkg/outpost/mcp/tool_events.go new file mode 100644 index 00000000..a51d7da1 --- /dev/null +++ b/pkg/outpost/mcp/tool_events.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var eventsActions = actionSet{ + {name: "list", desc: "list published events, most recent first"}, + {name: "get", desc: "get one event, including its payload"}, + {name: "retry", desc: "queue another delivery of an event to a destination", write: true}, +} + +var eventsSpec = toolSpec{ + resource: "events", + summary: "Query published events. An event is one publish, fanned out to every destination whose topic subscription matched it. Use outpost_attempts to see how delivery of an event actually went.", + actions: eventsActions, + props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Event ID. Required for get/retry. On list, filters by event ID(s). " + descListValue}, + "tenant_id": {Type: "string", Desc: "Tenant ID. Filters on list; optional on get. " + descListValue}, + "destination_id": {Type: "string", Desc: "Destination to deliver to (required for retry). On list, filters by matched destination(s). " + descListValue}, + "topic": {Type: "string", Desc: "Filter by topic(s) (list). " + descListValue}, + "time_after": {Type: "string", Desc: descTimeAfter + " (list)"}, + "time_before": {Type: "string", Desc: descTimeBefore + " (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field: time (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor (list)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list)"}, + }, + handler: handleEvents, +} + +func handleEvents(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, eventsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + switch action { + case "list": + return eventsList(ctx, client, in) + case "get": + return eventsGet(ctx, client, in) + default: + return eventsRetry(ctx, client, in) + } + } +} + +func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + result, err := client.ListOutpostEvents(ctx, hookdeck.OutpostEventListParams{ + IDs: stringList(in, "id"), + TenantIDs: stringList(in, "tenant_id"), + DestinationIDs: stringList(in, "destination_id"), + Topics: stringList(in, "topic"), + TimeAfter: in.String("time_after"), + TimeBefore: in.String("time_before"), + Limit: in.Int("limit", 0), + OrderBy: in.String("order_by"), + Dir: in.String("dir"), + Next: in.String("next"), + Prev: in.String("prev"), + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +func eventsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "get") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + event, err := client.GetOutpostEvent(ctx, id, in.String("tenant_id")) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(event, client) +} + +func eventsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "retry") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + destinationID, err := requireString(in, "destination_id", "retry") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + result, err := client.RetryOutpostEvent(ctx, &hookdeck.OutpostRetryRequest{ + EventID: id, + DestinationID: destinationID, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + // The retry is queued, not performed inline, so report acceptance rather + // than delivery. + return mcpcore.JSONResultEnvelopeForClient(map[string]any{ + "event_id": id, + "destination_id": destinationID, + "accepted": result.Success, + "status": "queued", + }, client) +} diff --git a/pkg/outpost/mcp/tool_help.go b/pkg/outpost/mcp/tool_help.go new file mode 100644 index 00000000..b4b14e1c --- /dev/null +++ b/pkg/outpost/mcp/tool_help.go @@ -0,0 +1,279 @@ +package mcp + +import ( + "context" + "fmt" + "sort" + "strings" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +func handleHelp(srv *mcpcore.Server, opts ServerOptions) mcpsdk.ToolHandler { + client := srv.Client() + return func(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + topic := in.String("topic") + if topic == "" { + return helpOverview(srv, opts, client), nil + } + return mcpcore.HelpTopic(helpTopicPrefix, toolHelp(srv), topic, jsonResponseShapeHelp), nil + } +} + +// jsonResponseShapeHelp documents the envelope every resource tool returns. +// Keep in sync with mcpcore.JSONResultEnvelope. +const jsonResponseShapeHelp = `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 (the same shapes as the Outpost list/get APIs; + list actions that are paginated return { "models": [...], "pagination": {...} }). + • "meta" — Cross-cutting fields. When a 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 {}. + When reporting which project is active, use "active_project_org" and + "active_project_name" — a bare project id tells a human nothing. + +Plain text (not this shape): outpost_help text, hookdeck_login prompts, and error messages. +Errors use the host error flag; bodies are plain text, not JSON envelopes.` + +// formatCurrentProject builds a display label from org + short name, 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 != "" { + if label != "" { + return fmt.Sprintf("%s (%s)", label, client.ProjectID) + } + return client.ProjectID + } + return label +} + +// modeHelp explains what this session may do and, in read-only mode, how to +// change that. +func modeHelp(srv *mcpcore.Server, opts ServerOptions) string { + if srv.WriteEnabled() { + text := `Mode: write enabled. Every action below is available, including the ones that create, +change or delete data. Destructive actions (delete, config set, publish) are real and immediate.` + if opts.PublishAPIKey == "" { + text += "\n\noutpost_publish is not registered in this session: publishing needs a Hookdeck Project API key,\n" + + "which the credentials stored by 'hookdeck login' cannot substitute for. Restart the server with\n" + + "--publish-api-key , or set HOOKDECK_OUTPOST_PUBLISH_API_KEY, to publish." + } + return text + } + + return `Mode: read-only. Actions that change data are not offered, and the tools above list only +the actions this session can perform. Two reads are treated as writes and are also unavailable: +outpost_tenants token mints a tenant-scoped access token, and outpost_tenants portal returns a URL +granting access to a tenant's portal — both hand back reusable credentials, so a read-only session +must not be able to produce them. outpost_publish is not registered at all. + +To enable everything, restart the server with --allow-write, or set HOOKDECK_MCP_ALLOW_WRITE=true +(the flag wins). Publishing additionally needs a Hookdeck Project API key via --api-key or +HOOKDECK_OUTPOST_PUBLISH_API_KEY.` +} + +func helpOverview(srv *mcpcore.Server, opts ServerOptions, client *hookdeck.Client) *mcpsdk.CallToolResult { + var tools strings.Builder + for _, line := range toolSummaryLines(srv, opts) { + tools.WriteString(line) + tools.WriteString("\n") + } + + text := fmt.Sprintf(`Hookdeck Outpost MCP Server — Available Tools + +Current project: %s + +%s + +%s + +All tools operate on the active project, which must be an Outpost project. Call hookdeck_projects +first when the user references a project by name, or when unsure which project is active. + +%s +Use outpost_help with topic="" for detailed help on a specific tool.`, + formatCurrentProject(client), + modeHelp(srv, opts), + jsonResponseShapeHelp, + tools.String(), + ) + + return mcpcore.TextResult(text) +} + +// toolSummaryLines renders one line per registered tool, listing only the +// actions this session can perform. +func toolSummaryLines(srv *mcpcore.Server, opts ServerOptions) []string { + type entry struct { + name string + summary string + } + + entries := []entry{ + {srv.ProjectsToolName(), "List or switch the active Outpost project (actions: list, use)"}, + {srv.LoginToolName(), "Sign in, or reauth: true for a fresh browser session when listing projects fails"}, + } + + specs := []toolSpec{ + tenantsSpec, destinationsSpec, eventsSpec, attemptsSpec, + topicsSpec, destinationTypesSpec, metricsSpec, configSpec, statusSpec, + } + for _, spec := range specs { + available := spec.actions.available(srv.WriteEnabled()) + if len(available) == 0 { + continue + } + entries = append(entries, entry{ + name: srv.ToolName(spec.resource), + summary: "Actions: " + strings.Join(available.names(), ", "), + }) + } + if srv.WriteEnabled() && opts.PublishAPIKey != "" { + entries = append(entries, entry{srv.ToolName("publish"), "Publish an event (actions: publish)"}) + } + entries = append(entries, entry{helpToolName, "This help text"}) + + width := 0 + for _, e := range entries { + if len(e.name) > width { + width = len(e.name) + } + } + + lines := make([]string, len(entries)) + for i, e := range entries { + lines[i] = fmt.Sprintf("%-*s — %s", width, e.name, e.summary) + } + return lines +} + +// toolHelp builds the per-tool help topics for the current mode, so a topic +// never documents an action this session cannot perform. +func toolHelp(srv *mcpcore.Server) map[string]string { + topics := map[string]string{ + srv.ProjectsToolName(): `hookdeck_projects — List or switch the active project + +Always call this first when the user references a specific project by name. Every other tool is +scoped to the active project. Only Outpost projects are listed and only an Outpost project can be +switched to: this server talks to the Outpost API and has no access to Event Gateway projects. + +Actions: + list — List the Outpost projects available to your credentials + use — Switch the active project for this session + +Switching affects this session only. Unlike 'hookdeck project use' on the command line, it does not +write to the config file, so it will not change which project the user's own CLI is pointed at. Say +so if the user asks whether their CLI was affected. Signing in does persist, because that is an +explicit action the user took. + +Parameters: + action (string, required) — "list" or "use" + project_id (string) — Required for "use"`, + + srv.LoginToolName(): `hookdeck_login — Browser sign-in for the Hookdeck CLI inside MCP + +Without arguments when already authenticated: confirms the session is active. +When not authenticated: returns a URL the user opens in a browser; poll by calling this tool again. + +Note: signing in here does not supply a Project API key, which outpost_publish needs separately. + +Parameters: + reauth (boolean) — If true, clears stored credentials and starts a new browser login. Use when + hookdeck_projects list fails and the key may be a single-project or dashboard + API key that cannot list projects.`, + + helpToolName: `outpost_help — Overview of the Outpost tools, or detailed help for one + +The overview reports the current mode (read-only or write) and which tools are registered. + +Parameters: + topic (string) — Tool name for detailed help (e.g. "outpost_events"). Omit for the overview.`, + } + + specs := []toolSpec{ + tenantsSpec, destinationsSpec, eventsSpec, attemptsSpec, + topicsSpec, destinationTypesSpec, metricsSpec, configSpec, statusSpec, + publishSpec(""), + } + for _, spec := range specs { + available := spec.actions.available(srv.WriteEnabled()) + if len(available) == 0 { + continue + } + topics[srv.ToolName(spec.resource)] = specHelp(srv, spec, available) + } + + return topics +} + +// specHelp renders a tool's help from its definition, so help cannot drift from +// the schema the agent is actually given. +func specHelp(srv *mcpcore.Server, spec toolSpec, available actionSet) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n%s\n\nActions:\n", srv.ToolName(spec.resource), spec.summary) + + width := 0 + for _, a := range available { + if len(a.name) > width { + width = len(a.name) + } + } + for _, a := range available { + fmt.Fprintf(&b, " %-*s — %s\n", width, a.name, a.desc) + } + + if hidden := spec.actions.hasWrite() && !srv.WriteEnabled(); hidden { + b.WriteString("\nFurther actions exist but are unavailable in read-only mode. See outpost_help for how to enable them.\n") + } + + if len(spec.props) > 0 { + b.WriteString("\nParameters:\n") + names := make([]string, 0, len(spec.props)) + for name := range spec.props { + names = append(names, name) + } + sort.Strings(names) + + width = 0 + for _, name := range names { + if len(name) > width { + width = len(name) + } + } + for _, name := range names { + prop := spec.props[name] + required := "" + for _, r := range spec.required { + if r == name { + required = ", required" + break + } + } + fmt.Fprintf(&b, " %-*s (%s%s) — %s\n", width, name, prop.Type, required, prop.Desc) + } + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/pkg/outpost/mcp/tool_metrics.go b/pkg/outpost/mcp/tool_metrics.go new file mode 100644 index 00000000..8ed28b8e --- /dev/null +++ b/pkg/outpost/mcp/tool_metrics.go @@ -0,0 +1,126 @@ +package mcp + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var metricsActions = actionSet{ + {name: "events", desc: "aggregated publish metrics"}, + {name: "attempts", desc: "aggregated delivery metrics"}, +} + +var metricsSpec = toolSpec{ + resource: "metrics", + summary: "Query aggregate metrics over a time range. " + + "Event measures: count, rate; dimensions: tenant_id, topic, destination_id. " + + "Attempt measures: count, successful_count, failed_count, error_rate, first_attempt_count, retry_count, manual_retry_count, avg_attempt_number, rate, successful_rate, failed_rate; dimensions: tenant_id, destination_id, destination_type, topic, status, code, manual, attempt_number. " + + "Omit granularity for a single total over the whole range.", + actions: metricsActions, + required: []string{"start", "end", "measures"}, + props: map[string]mcpcore.Prop{ + "start": {Type: "string", Desc: "Start of the range (ISO 8601 datetime, required)."}, + "end": {Type: "string", Desc: "End of the range (ISO 8601 datetime, required)."}, + "granularity": {Type: "string", Desc: "Time bucket size, e.g. 1h, 5m, 1d. Omit for one total over the whole range."}, + "measures": {Type: "array", Desc: "Measures to return (required). See the tool description for the measures each action supports.", Items: &mcpcore.Prop{Type: "string"}}, + "dimensions": {Type: "array", Desc: "Dimensions to group by.", Items: &mcpcore.Prop{Type: "string"}}, + "filters": {Type: "object", Desc: `Filter by dimension, e.g. {"topic": "user.created"} or {"status": ["failed"]}.`}, + }, + handler: handleMetrics, +} + +func handleMetrics(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, metricsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + params, err := metricsParams(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + var result *hookdeck.OutpostMetricsResponse + if action == "events" { + result, err = client.GetOutpostEventMetrics(ctx, params) + } else { + result, err = client.GetOutpostAttemptMetrics(ctx, params) + } + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) + } +} + +func metricsParams(in mcpcore.Input) (hookdeck.OutpostMetricsParams, error) { + start := in.String("start") + end := in.String("end") + if start == "" || end == "" { + return hookdeck.OutpostMetricsParams{}, fmt.Errorf("start and end are required (ISO 8601 datetimes)") + } + measures := stringList(in, "measures") + if len(measures) == 0 { + return hookdeck.OutpostMetricsParams{}, fmt.Errorf(`measures is required, e.g. ["count"]`) + } + + filters, err := metricsFilters(in) + if err != nil { + return hookdeck.OutpostMetricsParams{}, err + } + + return hookdeck.OutpostMetricsParams{ + Start: start, + End: end, + Granularity: in.String("granularity"), + Measures: measures, + Dimensions: stringList(in, "dimensions"), + Filters: filters, + }, nil +} + +// metricsFilters reads the filters object, accepting a single value or an array +// per dimension. +func metricsFilters(in mcpcore.Input) (map[string][]string, error) { + raw, err := object(in, "filters") + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + + filters := make(map[string][]string, len(raw)) + for dimension, value := range raw { + switch v := value.(type) { + case string: + filters[dimension] = []string{v} + case []interface{}: + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("filters.%s must contain only strings", dimension) + } + filters[dimension] = append(filters[dimension], s) + } + default: + return nil, fmt.Errorf("filters.%s must be a string or an array of strings", dimension) + } + } + return filters, nil +} diff --git a/pkg/outpost/mcp/tool_publish.go b/pkg/outpost/mcp/tool_publish.go new file mode 100644 index 00000000..b04e6e6c --- /dev/null +++ b/pkg/outpost/mcp/tool_publish.go @@ -0,0 +1,122 @@ +package mcp + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var publishActions = actionSet{ + {name: "publish", desc: "publish an event to a topic", write: true, destructive: true}, +} + +// publishSpec builds the publish tool for a given Project API key. +// +// Publishing needs a Hookdeck Project API key: the publish API does not accept +// the credentials stored by `hookdeck login`. The tool is therefore only +// registered when a key is available, rather than being offered and then +// failing on every call. +func publishSpec(apiKey string) toolSpec { + return toolSpec{ + resource: "publish", + summary: "Publish an event to a topic, for delivery to a tenant's matching destinations. Publishing is asynchronous: a successful response means the event was accepted, not that it has been delivered — check outpost_attempts for that. This delivers real events to real destinations.", + actions: publishActions, + required: []string{"tenant_id", "topic"}, + props: map[string]mcpcore.Prop{ + "tenant_id": {Type: "string", Desc: "Tenant to publish for (required)."}, + "topic": {Type: "string", Desc: "Topic to publish on (required). Must be one of the project's topics — see outpost_topics."}, + "data": {Type: "object", Desc: "Event payload as a JSON object."}, + "destination_id": {Type: "string", Desc: "Deliver only to this destination instead of every matching one."}, + "event_id": {Type: "string", Desc: "Event ID, for idempotent publishing. Republishing the same ID reports a duplicate instead of creating a second event."}, + "metadata": {Type: "object", Desc: "Event metadata as a JSON object of string values."}, + "eligible_for_retry": {Type: "boolean", Desc: "Whether failed deliveries should be retried. Omit to use the project default."}, + }, + handler: func(srv *mcpcore.Server) mcpsdk.ToolHandler { + return handlePublish(srv, apiKey) + }, + } +} + +func handlePublish(srv *mcpcore.Server, apiKey string) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + if _, blocked := dispatch(srv, publishActions, in.String("action")); blocked != nil { + return blocked, nil + } + + tenantID, err := requireString(in, "tenant_id", "publish") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + topic, err := requireString(in, "topic", "publish") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + data, err := object(in, "data") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + metadata, err := stringMap(in, "metadata") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + // Publishing follows the credential, not the active project, and the two + // can disagree: the credential is fixed at startup while the active + // project moves with hookdeck_projects use. When they disagree the event + // is accepted, matches nothing, and leaves no trace — a success response + // for something that never happened. + // + // Checking the tenant with the publish credential resolves to the same + // project the event would go to, so it catches that and a mistyped or + // unprovisioned tenant alike. + exists, checkErr := client.TenantExistsForPublish(ctx, apiKey, tenantID) + if checkErr == nil && !exists { + return mcpcore.ErrorResult(fmt.Sprintf( + "tenant %q does not exist in the project the publish credential belongs to, so this event "+ + "would be accepted, delivered nowhere, and leave no trace. Publishing follows the credential "+ + "rather than the active project (%s), and the two can differ. Check the tenant id, or restart "+ + "the server with a publish key for the project you are working in.", + tenantID, client.ProjectID, + )), nil + } + + result, err := client.PublishOutpostEvent(ctx, apiKey, &hookdeck.OutpostPublishRequest{ + ID: in.String("event_id"), + TenantID: tenantID, + Topic: topic, + DestinationID: in.String("destination_id"), + EligibleForRetry: in.BoolPtr("eligible_for_retry"), + Metadata: metadata, + Data: data, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + + // An event matching nothing is accepted, given an id, and then leaves no + // trace: it is not delivered and does not appear in the events list. A + // bare success response is indistinguishable from one that was delivered, + // so say plainly that nothing will happen. + payload := map[string]any{"result": result} + if len(result.DestinationIDs) == 0 { + payload["warning"] = "This event matched no destinations, so it will not be delivered and will not " + + "appear in the events list. Check that the tenant exists and has a destination subscribed to this topic — " + + "publishing for a tenant that does not exist is accepted rather than rejected." + } + + return mcpcore.JSONResultEnvelopeForClient(payload, client) + } +} diff --git a/pkg/outpost/mcp/tool_tenants.go b/pkg/outpost/mcp/tool_tenants.go new file mode 100644 index 00000000..4f96eeb2 --- /dev/null +++ b/pkg/outpost/mcp/tool_tenants.go @@ -0,0 +1,148 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +var tenantsActions = actionSet{ + {name: "list", desc: "list tenants"}, + {name: "get", desc: "get one tenant by id"}, + {name: "upsert", desc: "create a tenant or update its metadata", write: true}, + {name: "delete", desc: "delete a tenant and everything belonging to it", write: true, destructive: true}, + {name: "token", desc: "mint a tenant-scoped access token", write: true}, + {name: "portal", desc: "get a URL granting access to the tenant portal", write: true}, +} + +var tenantsSpec = toolSpec{ + resource: "tenants", + summary: "Inspect and manage tenants — the end customers whose destinations events are delivered to. Tenant IDs are chosen by the operator, not generated, so upsert is the way to create one — which also means the id is usually meaningful to a human and worth quoting directly.", + actions: tenantsActions, + props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Tenant ID. Required for get/upsert/delete/token/portal. On list, filters by tenant ID(s). " + descListValue}, + "metadata": {Type: "object", Desc: "Tenant metadata as a JSON object of string values (upsert). Replaces the stored metadata."}, + "theme": {Type: "string", Desc: "Portal colour scheme: light or dark (portal)."}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor (list)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list)"}, + }, + handler: handleTenants, +} + +func handleTenants(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if r := srv.RequireAuth(); r != nil { + return r, nil + } + in, err := mcpcore.ParseInput(req.Params.Arguments) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + action, blocked := dispatch(srv, tenantsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + + switch action { + case "list": + return tenantsList(ctx, client, in) + case "get": + return tenantsGet(ctx, client, in) + case "upsert": + return tenantsUpsert(ctx, client, in) + case "delete": + return tenantsDelete(ctx, client, in) + case "token": + return tenantsToken(ctx, client, in) + default: + return tenantsPortal(ctx, client, in) + } + } +} + +func tenantsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + result, err := client.ListOutpostTenants(ctx, hookdeck.OutpostTenantListParams{ + IDs: stringList(in, "id"), + Limit: in.Int("limit", 0), + Dir: in.String("dir"), + Next: in.String("next"), + Prev: in.String("prev"), + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +func tenantsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "get") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + tenant, err := client.GetOutpostTenant(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(tenant, client) +} + +func tenantsUpsert(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "upsert") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + metadata, err := stringMap(in, "metadata") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + tenant, err := client.UpsertOutpostTenant(ctx, id, &hookdeck.OutpostTenantUpsertRequest{Metadata: metadata}) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(tenant, client) +} + +func tenantsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "delete") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := client.DeleteOutpostTenant(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "tenant_id": id, + "status": "deleted", + }, client) +} + +func tenantsToken(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "token") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + token, err := client.GetOutpostTenantToken(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(token, client) +} + +func tenantsPortal(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := requireString(in, "id", "portal") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + portal, err := client.GetOutpostTenantPortalURL(ctx, id, in.String("theme")) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(portal, client) +} diff --git a/pkg/outpost/mcp/tools.go b/pkg/outpost/mcp/tools.go new file mode 100644 index 00000000..f617417e --- /dev/null +++ b/pkg/outpost/mcp/tools.go @@ -0,0 +1,274 @@ +package mcp + +import ( + "fmt" + "strings" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +// Tool names. The Outpost server namespaces its tools with "outpost_" so it can +// be configured alongside the Event Gateway server without colliding. +const ( + toolPrefix = "outpost" + helpToolName = toolPrefix + "_help" + helpTopicPrefix = toolPrefix + "_" + + loginToolDesc = "Authenticate the Hookdeck CLI or sign in again. Without arguments, returns a URL for browser login when not yet authenticated, or confirms if already signed in. Set reauth: true to clear the current session and start a new browser login (use when hookdeck_projects list fails and the stored key may be a single-project or dashboard API key)." + projectsToolDesc = "Always call this first when the user references a specific project by name. List available Outpost projects to find the matching project ID, then use the `use` action to switch to it before calling any other tools. Every other tool is scoped to the active project — if the wrong project is active, all results will be wrong. Only Outpost projects are listed: this server has no access to Event Gateway projects. If list or use fails (especially 401/403), the error may suggest hookdeck_login with reauth: true. JSON successes use a standard data/meta envelope; see outpost_help." +) + +// ServerOptions configure the Outpost MCP server. +type ServerOptions struct { + // Client must be the Outpost API client. Tool handlers mutate it in place + // (the projects and login tools set ProjectID), so passing the Event Gateway + // client would leave every Outpost call pointed at the previous project. + Client *hookdeck.Client + + // AccountClient is the Hookdeck API client. Listing projects and validating + // credentials are account-level requests, which the Outpost host does not + // serve, so they need a client for the main API. + AccountClient *hookdeck.Client + + // Config is the CLI configuration, used by the login tool. + Config *config.Config + + // WriteEnabled turns on the actions that change data or return a credential. + WriteEnabled bool + + // PublishAPIKey is a Hookdeck Project API key. The publish tool is only + // registered when one is available, because the publish API does not accept + // the credentials stored by `hookdeck login`. + PublishAPIKey string +} + +// NewServer creates an MCP server exposing the Outpost tools. +func NewServer(opts ServerOptions) *mcpcore.Server { + return mcpcore.NewServer(mcpcore.Options{ + Name: "hookdeck-outpost", + ToolPrefix: toolPrefix, + Client: opts.Client, + AccountClient: opts.AccountClient, + Config: opts.Config, + WriteEnabled: opts.WriteEnabled, + ProjectFilter: config.ProjectTypeOutpost, + ToolDefs: func(srv *mcpcore.Server) []mcpcore.ToolDef { + return toolDefs(srv, opts) + }, + }) +} + +// action is one action a tool supports. +// +// write marks an action that a read-only server must not offer. That covers +// anything that changes data, and also the reads that hand back a credential: +// a tenant token and a portal URL are both reusable access to a tenant's data, +// so treating them as reads would let a read-only session mint them at will. +// +// destructive drives the client-facing DestructiveHint annotation. +type action struct { + name string + desc string + write bool + destructive bool +} + +// enabled reports whether the action is available in this mode. +func (a action) enabled(writeEnabled bool) bool { return writeEnabled || !a.write } + +// actionSet is a tool's action list. +type actionSet []action + +// available returns the actions offered in this mode. +func (as actionSet) available(writeEnabled bool) actionSet { + out := make(actionSet, 0, len(as)) + for _, a := range as { + if a.enabled(writeEnabled) { + out = append(out, a) + } + } + return out +} + +// names returns the action names, for the schema enum. +func (as actionSet) names() []string { + out := make([]string, len(as)) + for i, a := range as { + out[i] = a.name + } + return out +} + +// summary renders "list — …, get — …" for a tool description. +func (as actionSet) summary() string { + parts := make([]string, 0, len(as)) + for _, a := range as { + if a.desc == "" { + parts = append(parts, a.name) + continue + } + parts = append(parts, fmt.Sprintf("%s (%s)", a.name, a.desc)) + } + return strings.Join(parts, ", ") +} + +// find returns the named action. +func (as actionSet) find(name string) (action, bool) { + for _, a := range as { + if a.name == name { + return a, true + } + } + return action{}, false +} + +// hasWrite reports whether any action in the set is a write. +func (as actionSet) hasWrite() bool { + for _, a := range as { + if a.write { + return true + } + } + return false +} + +// hasDestructive reports whether any action in the set is destructive. +func (as actionSet) hasDestructive() bool { + for _, a := range as { + if a.destructive { + return true + } + } + return false +} + +// toolSpec describes one Outpost tool before write mode is applied. +type toolSpec struct { + resource string // e.g. "tenants" — the tool is named "outpost_" + summary string // what the tool is for, without listing actions + actions actionSet // every action, including the write-only ones + props map[string]mcpcore.Prop + required []string + handler func(*mcpcore.Server) mcpsdk.ToolHandler +} + +// define builds the tool definition for the current write mode. +// +// The schema is the primary gate: in read-only mode the write actions are +// absent from the enum and from the description, so an agent is never told +// about an action it cannot use. Tools whose every action is a write are not +// registered at all rather than registered to always fail. +func (spec toolSpec) define(srv *mcpcore.Server) (mcpcore.ToolDef, bool) { + available := spec.actions.available(srv.WriteEnabled()) + if len(available) == 0 { + return mcpcore.ToolDef{}, false + } + + props := make(map[string]mcpcore.Prop, len(spec.props)+1) + for k, v := range spec.props { + props[k] = v + } + props["action"] = mcpcore.Prop{ + Type: "string", + Desc: "Action: " + available.summary(), + Enum: available.names(), + } + + description := spec.summary + " Actions: " + available.summary() + "." + if spec.actions.hasWrite() && !srv.WriteEnabled() { + description += " This server is running in read-only mode, so only the actions listed above are available; see outpost_help for how to enable the rest." + } + + destructive := available.hasDestructive() + return mcpcore.ToolDef{ + Tool: &mcpsdk.Tool{ + Name: srv.ToolName(spec.resource), + Description: description, + InputSchema: mcpcore.Schema(props, append([]string{"action"}, spec.required...)...), + Annotations: &mcpsdk.ToolAnnotations{ + ReadOnlyHint: !available.hasWrite(), + DestructiveHint: &destructive, + }, + }, + Handler: spec.handler(srv), + }, true +} + +// dispatch validates and gates an action before a handler runs it. +// +// The schema already hides write actions in read-only mode; this is the second +// line of defence, for a client that calls one anyway. +func dispatch(srv *mcpcore.Server, actions actionSet, name string) (string, *mcpsdk.CallToolResult) { + a, ok := actions.find(name) + if !ok { + available := actions.available(srv.WriteEnabled()) + return "", mcpcore.ErrorResult(fmt.Sprintf( + "unknown action %q; expected one of: %s", + name, strings.Join(available.names(), ", "), + )) + } + if a.write { + if r := mcpcore.RequireWrite(srv.WriteEnabled(), name); r != nil { + return "", r + } + } + return a.name, nil +} + +// toolDefs lists every tool the Outpost MCP server exposes. +func toolDefs(srv *mcpcore.Server, opts ServerOptions) []mcpcore.ToolDef { + specs := []toolSpec{ + tenantsSpec, + destinationsSpec, + eventsSpec, + attemptsSpec, + topicsSpec, + destinationTypesSpec, + metricsSpec, + configSpec, + statusSpec, + } + + defs := []mcpcore.ToolDef{srv.ProjectsToolDef(projectsToolDesc)} + for _, spec := range specs { + if def, ok := spec.define(srv); ok { + defs = append(defs, def) + } + } + + // Publishing needs both write mode and a Project API key, so the tool is + // only offered when it can actually work. outpost_help explains its absence. + if srv.WriteEnabled() && opts.PublishAPIKey != "" { + if def, ok := publishSpec(opts.PublishAPIKey).define(srv); ok { + defs = append(defs, def) + } + } + + defs = append(defs, + mcpcore.ToolDef{ + Tool: &mcpsdk.Tool{ + Name: helpToolName, + Description: "Get an overview of all available Outpost tools or detailed help for a specific tool. Use this when unsure which tool to use for a task, or to find out which actions this session is allowed to perform. The overview reports the current mode (read-only or write) and documents the common JSON response shape (data + meta).", + InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ + "topic": {Type: "string", Desc: "Tool name for detailed help (e.g. outpost_events). Omit for overview."}, + }), + Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true}, + }, + Handler: handleHelp(srv, opts), + }, + srv.LoginToolDef(loginToolDesc), + ) + + return defs +} + +// Shared property descriptions. +const ( + descTimeAfter = "Only records at or after this ISO 8601 datetime." + descTimeBefore = "Only records at or before this ISO 8601 datetime." + descListValue = "Accepts an array of strings or a comma-separated string." +) diff --git a/pkg/outpost/mcp/tools_test.go b/pkg/outpost/mcp/tools_test.go new file mode 100644 index 00000000..c1a8917c --- /dev/null +++ b/pkg/outpost/mcp/tools_test.go @@ -0,0 +1,766 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// mockAPI serves the given Outpost API paths and 404s anything else, so an +// unexpected call fails the test loudly rather than hanging. +func mockAPI(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + for pattern, handler := range handlers { + mux.HandleFunc(pattern, handler) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + t.Logf("unhandled request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found: " + r.URL.Path}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newTestClient(t *testing.T, baseURL string) *hookdeck.Client { + t.Helper() + u, err := url.Parse(baseURL) + require.NoError(t, err) + return &hookdeck.Client{ + BaseURL: u, + APIKey: "test-key", + ProjectID: "proj_outpost", + // Set so the server does not go looking up the display name, which is + // not what these tests are about. + ProjectName: "outpost-test", + AcceptAnySuccessStatus: true, + } +} + +// connect starts the server over an in-memory transport and returns a client +// session, exercising the same registration path as the real stdio server. +func connect(t *testing.T, opts ServerOptions) *mcpsdk.ClientSession { + t.Helper() + if opts.Config == nil { + opts.Config = &config.Config{} + } + srv := NewServer(opts) + + serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = srv.Run(ctx, serverTransport) }() + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func listTools(t *testing.T, session *mcpsdk.ClientSession) map[string]*mcpsdk.Tool { + t.Helper() + result, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + tools := make(map[string]*mcpsdk.Tool, len(result.Tools)) + for _, tool := range result.Tools { + tools[tool.Name] = tool + } + return tools +} + +func callTool(t *testing.T, session *mcpsdk.ClientSession, name string, args map[string]any) *mcpsdk.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + return result +} + +func resultText(t *testing.T, result *mcpsdk.CallToolResult) string { + t.Helper() + require.NotEmpty(t, result.Content) + tc, ok := result.Content[0].(*mcpsdk.TextContent) + require.True(t, ok, "expected TextContent, got %T", result.Content[0]) + return tc.Text +} + +// actionEnum returns the action enum a tool advertises. +func actionEnum(t *testing.T, tool *mcpsdk.Tool) []string { + t.Helper() + // The SDK reports the schema back as decoded JSON, so re-encode it rather + // than assuming a concrete type. + raw, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + + var schema struct { + Properties struct { + Action struct { + Enum []string `json:"enum"` + } `json:"action"` + } `json:"properties"` + } + require.NoError(t, json.Unmarshal(raw, &schema)) + return schema.Properties.Action.Enum +} + +// --------------------------------------------------------------------------- +// Tool registration and the write-mode gate +// --------------------------------------------------------------------------- + +func TestListTools_ReadOnlyMode(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + tools := listTools(t, session) + + t.Run("registers every read tool", func(t *testing.T) { + for _, name := range []string{ + "hookdeck_projects", "hookdeck_login", "outpost_help", + "outpost_tenants", "outpost_destinations", "outpost_events", + "outpost_attempts", "outpost_topics", "outpost_destination_types", + "outpost_metrics", "outpost_config", "outpost_status", + } { + assert.Contains(t, tools, name) + } + }) + + t.Run("omits the publish tool entirely", func(t *testing.T) { + assert.NotContains(t, tools, "outpost_publish", + "a tool that could only ever fail must not be advertised") + }) + + t.Run("write actions are absent from the action enum", func(t *testing.T) { + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["outpost_tenants"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["outpost_destinations"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["outpost_events"])) + assert.Equal(t, []string{"get", "custom_domain_get"}, actionEnum(t, tools["outpost_config"])) + }) + + t.Run("write actions are absent from the description", func(t *testing.T) { + for _, name := range []string{"outpost_tenants", "outpost_destinations", "outpost_events", "outpost_config"} { + description := tools[name].Description + for _, action := range []string{"upsert", "delete", "create", "retry", "set"} { + assert.NotContains(t, description, " "+action+" (", "%s should not describe the %s action", name, action) + } + } + }) + + t.Run("credential-returning reads are treated as writes", func(t *testing.T) { + enum := actionEnum(t, tools["outpost_tenants"]) + assert.NotContains(t, enum, "token", "a tenant token is a reusable credential") + assert.NotContains(t, enum, "portal", "a portal URL grants access to tenant data") + }) + + t.Run("read tools are annotated as read-only", func(t *testing.T) { + for _, name := range []string{"outpost_tenants", "outpost_events", "outpost_attempts", "outpost_status"} { + require.NotNil(t, tools[name].Annotations, name) + assert.True(t, tools[name].Annotations.ReadOnlyHint, "%s should be annotated read-only", name) + } + }) +} + +func TestListTools_WriteMode(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), + WriteEnabled: true, + PublishAPIKey: "project-api-key", + }) + tools := listTools(t, session) + + t.Run("write actions appear in the enum", func(t *testing.T) { + assert.Equal(t, []string{"list", "get", "upsert", "delete", "token", "portal"}, actionEnum(t, tools["outpost_tenants"])) + assert.Equal(t, []string{"list", "get", "create", "update", "delete", "enable", "disable"}, actionEnum(t, tools["outpost_destinations"])) + assert.Equal(t, []string{"list", "get", "retry"}, actionEnum(t, tools["outpost_events"])) + }) + + t.Run("publish is registered when a Project API key is available", func(t *testing.T) { + assert.Contains(t, tools, "outpost_publish") + }) + + t.Run("tools with writes are no longer annotated read-only", func(t *testing.T) { + assert.False(t, tools["outpost_tenants"].Annotations.ReadOnlyHint) + assert.True(t, tools["outpost_attempts"].Annotations.ReadOnlyHint, "attempts has no write actions in any mode") + }) + + t.Run("destructive tools carry the destructive hint", func(t *testing.T) { + for _, name := range []string{"outpost_tenants", "outpost_destinations", "outpost_config", "outpost_publish"} { + require.NotNil(t, tools[name].Annotations.DestructiveHint, name) + assert.True(t, *tools[name].Annotations.DestructiveHint, "%s should be flagged destructive", name) + } + require.NotNil(t, tools["outpost_events"].Annotations.DestructiveHint) + assert.False(t, *tools["outpost_events"].Annotations.DestructiveHint, "a retry does not destroy anything") + }) +} + +func TestListTools_WriteModeWithoutPublishKey(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + tools := listTools(t, session) + + assert.NotContains(t, tools, "outpost_publish", + "publishing needs a Project API key, which write mode alone does not supply") + assert.Contains(t, tools, "outpost_tenants") +} + +// --------------------------------------------------------------------------- +// The handler-level guard (defence in depth) +// --------------------------------------------------------------------------- + +func TestWriteGuard_BlocksWriteActionsInReadOnlyMode(t *testing.T) { + // The API is left unstubbed: a request reaching it would mean the guard + // failed to stop the call. + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/tenants/acme": func(w http.ResponseWriter, r *http.Request) { + t.Errorf("read-only server called the API: %s %s", r.Method, r.URL.Path) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + cases := []struct { + tool string + args map[string]any + }{ + {"outpost_tenants", map[string]any{"action": "upsert", "id": "acme"}}, + {"outpost_tenants", map[string]any{"action": "delete", "id": "acme"}}, + {"outpost_tenants", map[string]any{"action": "token", "id": "acme"}}, + {"outpost_tenants", map[string]any{"action": "portal", "id": "acme"}}, + {"outpost_destinations", map[string]any{"action": "delete", "tenant_id": "acme", "id": "des_1"}}, + {"outpost_events", map[string]any{"action": "retry", "id": "evt_1", "destination_id": "des_1"}}, + {"outpost_config", map[string]any{"action": "set", "values": map[string]any{"TOPICS": "a"}}}, + } + + for _, tc := range cases { + t.Run(tc.tool+"/"+tc.args["action"].(string), func(t *testing.T) { + result := callTool(t, session, tc.tool, tc.args) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "read-only mode") + assert.Contains(t, text, "--allow-write") + }) + } +} + +func TestWriteGuard_AllowsWriteActionsInWriteMode(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/tenants/acme": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"id": "acme", "topics": []string{}}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_tenants", map[string]any{ + "action": "upsert", + "id": "acme", + "metadata": map[string]any{"plan": "pro"}, + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, resultText(t, result), `"acme"`) +} + +func TestUnknownAction(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "explode"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, `unknown action "explode"`) + assert.Contains(t, text, "list, get") + assert.NotContains(t, text, "delete", "the error must not advertise actions this session cannot use") +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +// TestUnauthenticated_PointsAtALoginToolThatExists guards the platform/product +// prefix split: login is a Hookdeck operation, not an Outpost one, so it keeps +// the platform prefix here and in the Gateway server. An unauthenticated tool +// must name a tool this session actually registers. +func TestUnauthenticated_PointsAtALoginToolThatExists(t *testing.T) { + api := mockAPI(t, nil) + client := newTestClient(t, api.URL) + client.APIKey = "" + session := connect(t, ServerOptions{Client: client}) + + registered := map[string]bool{} + for name := range listTools(t, session) { + registered[name] = true + } + require.True(t, registered["hookdeck_login"], "login is platform-level, so it is hookdeck_login in every server") + require.False(t, registered["outpost_login"], "the product prefix must not be used for a platform tool") + + for _, name := range []string{"outpost_tenants", "outpost_events", "outpost_status", "hookdeck_projects"} { + t.Run(name, func(t *testing.T) { + result := callTool(t, session, name, map[string]any{"action": "list"}) + require.True(t, result.IsError) + + text := resultText(t, result) + assert.Contains(t, text, "hookdeck_login") + // Naming a tool the session does not expose would send an agent + // chasing something that cannot be called. + assert.True(t, registered["hookdeck_login"]) + }) + } +} + +// --------------------------------------------------------------------------- +// Action set construction +// --------------------------------------------------------------------------- + +func TestActionSet(t *testing.T) { + actions := actionSet{ + {name: "list"}, + {name: "delete", write: true, destructive: true}, + } + + t.Run("read-only mode drops writes", func(t *testing.T) { + assert.Equal(t, []string{"list"}, actions.available(false).names()) + assert.False(t, actions.available(false).hasWrite()) + assert.False(t, actions.available(false).hasDestructive()) + }) + + t.Run("write mode keeps everything", func(t *testing.T) { + assert.Equal(t, []string{"list", "delete"}, actions.available(true).names()) + assert.True(t, actions.available(true).hasWrite()) + assert.True(t, actions.available(true).hasDestructive()) + }) +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +func TestTenantsList(t *testing.T) { + var gotQuery string + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/tenants": func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{{"id": "acme"}}, + "pagination": map[string]any{"limit": 10}, + "count": 1, + }) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_tenants", map[string]any{ + "action": "list", + "id": "acme,globex", + "limit": 10, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Contains(t, gotQuery, "id%5B0%5D=acme") + assert.Contains(t, gotQuery, "id%5B1%5D=globex") + assert.Contains(t, gotQuery, "limit=10") + + // Successful JSON responses use the shared data/meta envelope. + var envelope struct { + Data json.RawMessage `json:"data"` + Meta struct { + ActiveProjectID string `json:"active_project_id"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal([]byte(resultText(t, result)), &envelope)) + assert.Equal(t, "proj_outpost", envelope.Meta.ActiveProjectID) + assert.Contains(t, string(envelope.Data), "acme") +} + +func TestDestinationsRequireTenantID(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_destinations", map[string]any{"action": "list"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "tenant_id is required") +} + +func TestDestinationsList(t *testing.T) { + var gotQuery string + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/tenants/acme/destinations": func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": "des_1", "type": "webhook", "topics": "*"}}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "list", + "tenant_id": "acme", + "type": "webhook", + "topics": []any{"user.created"}, + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, gotQuery, "type%5B0%5D=webhook") + assert.Contains(t, gotQuery, "topics%5B0%5D=user.created") + assert.Contains(t, resultText(t, result), "des_1") +} + +func TestEventsRetryReportsQueued(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/retry": func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "evt_1", body["event_id"]) + assert.Equal(t, "des_1", body["destination_id"]) + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_events", map[string]any{ + "action": "retry", + "id": "evt_1", + "destination_id": "des_1", + }) + require.False(t, result.IsError, resultText(t, result)) + // A retry is queued, not delivered; the response must not imply otherwise. + assert.Contains(t, resultText(t, result), `"status":"queued"`) +} + +func TestEventsRetryRequiresDestination(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_events", map[string]any{"action": "retry", "id": "evt_1"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "destination_id is required") +} + +func TestDestinationTypesOmitSetupDocsByDefault(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/destination-types": func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "type": "webhook", + "label": "Webhook", + "icon": "a very long icon", + "instructions": "a very long setup guide", + }}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_destination_types", map[string]any{"action": "list"}) + require.False(t, result.IsError, resultText(t, result)) + assert.NotContains(t, resultText(t, result), "a very long setup guide") + + verbose := callTool(t, session, "outpost_destination_types", map[string]any{ + "action": "list", + "include_setup_docs": true, + }) + assert.Contains(t, resultText(t, verbose), "a very long setup guide") +} + +func TestMetricsRequiresStartEndAndMeasures(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + t.Run("missing range", func(t *testing.T) { + result := callTool(t, session, "outpost_metrics", map[string]any{ + "action": "events", "measures": []any{"count"}, + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "start and end are required") + }) + + t.Run("missing measures", func(t *testing.T) { + result := callTool(t, session, "outpost_metrics", map[string]any{ + "action": "events", + "start": "2026-08-01T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "measures is required") + }) +} + +func TestMetricsFilters(t *testing.T) { + var gotQuery string + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/metrics/attempts": func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}, "metadata": map[string]any{}}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_metrics", map[string]any{ + "action": "attempts", + "start": "2026-08-01T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + "measures": []any{"count"}, + "filters": map[string]any{"status": "failed", "topic": []any{"user.created"}}, + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, gotQuery, "filters%5Bstatus%5D%5B0%5D=failed") + assert.Contains(t, gotQuery, "filters%5Btopic%5D%5B0%5D=user.created") +} + +func TestConfigSetRejectsAnEmptyChange(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_config", map[string]any{"action": "set"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "nothing to change") +} + +func TestConfigSetSendsValuesAndUnsets(t *testing.T) { + var body map[string]*string + api := mockAPI(t, map[string]http.HandlerFunc{ + "PATCH /2025-07-01/config": func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + _ = json.NewEncoder(w).Encode(map[string]any{"TOPICS": "user.created"}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_config", map[string]any{ + "action": "set", + "values": map[string]any{"TOPICS": "user.created"}, + "unset": []any{"MAX_RETRY_LIMIT"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + require.Contains(t, body, "TOPICS") + require.NotNil(t, body["TOPICS"]) + assert.Equal(t, "user.created", *body["TOPICS"]) + require.Contains(t, body, "MAX_RETRY_LIMIT") + assert.Nil(t, body["MAX_RETRY_LIMIT"], "an unset key is sent as null to clear it") +} + +func TestPublishUsesTheProjectAPIKeyAsBearer(t *testing.T) { + var authHeader string + api := mockAPI(t, map[string]http.HandlerFunc{ + // Publishing first checks the tenant exists in the project the publish + // credential routes to. + "GET /2025-07-01/tenants/acme": func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "acme"}) + }, + "POST /2025-07-01/publish": func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"id": "evt_1", "destination_ids": []string{"des_1"}}) + }, + }) + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), + WriteEnabled: true, + PublishAPIKey: "project-api-key", + }) + + result := callTool(t, session, "outpost_publish", map[string]any{ + "action": "publish", + "tenant_id": "acme", + "topic": "user.created", + "data": map[string]any{"user_id": "123"}, + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "Bearer project-api-key", authHeader) +} + +// --------------------------------------------------------------------------- +// API error translation +// --------------------------------------------------------------------------- + +func TestScopeFailureIsReportedAsNotPermitted(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/tenants": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "insufficient scope"}) + }, + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "list"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "Not permitted") + assert.NotContains(t, text, "Check your API key", "a 403 is not a bad-key problem") +} + +// --------------------------------------------------------------------------- +// Help +// --------------------------------------------------------------------------- + +func TestHelpOverview_ReadOnlyMode(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{})) + + assert.Contains(t, text, "Mode: read-only") + assert.Contains(t, text, "--allow-write") + assert.Contains(t, text, "HOOKDECK_MCP_ALLOW_WRITE") + // The credential-returning reads need explaining, or their absence looks + // like a bug. + assert.Contains(t, text, "token") + assert.Contains(t, text, "portal") + assert.Contains(t, text, "outpost_publish is not registered") + assert.Contains(t, text, "proj_outpost") +} + +func TestHelpOverview_WriteMode(t *testing.T) { + api := mockAPI(t, nil) + + t.Run("with a publish key", func(t *testing.T) { + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), WriteEnabled: true, PublishAPIKey: "k", + }) + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{})) + assert.Contains(t, text, "Mode: write enabled") + assert.Contains(t, text, "outpost_publish") + }) + + t.Run("without a publish key", func(t *testing.T) { + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{})) + assert.Contains(t, text, "Mode: write enabled") + assert.Contains(t, text, "outpost_publish is not registered") + assert.Contains(t, text, "HOOKDECK_OUTPOST_PUBLISH_API_KEY") + // HOOKDECK_API_KEY means "exchange this for CLI credentials" elsewhere in + // the CLI and is commonly exported for CI. Naming it here would suggest an + // ambient variable is enough to start sending real events. + assert.NotContains(t, text, "set HOOKDECK_API_KEY") + }) +} + +func TestHelpTopic(t *testing.T) { + api := mockAPI(t, nil) + + t.Run("read-only topics document only the available actions", func(t *testing.T) { + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{"topic": "outpost_tenants"})) + assert.Contains(t, text, "list") + assert.NotContains(t, text, "\n delete ") + assert.Contains(t, text, "read-only mode") + }) + + t.Run("write topics document the write actions", func(t *testing.T) { + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{"topic": "outpost_tenants"})) + assert.Contains(t, text, "delete") + assert.Contains(t, text, "token") + }) + + t.Run("bare topic names resolve", func(t *testing.T) { + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + text := resultText(t, callTool(t, session, "outpost_help", map[string]any{"topic": "events"})) + assert.Contains(t, text, "outpost_events") + }) + + t.Run("an unknown topic lists the available ones", func(t *testing.T) { + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + result := callTool(t, session, "outpost_help", map[string]any{"topic": "how do I retry an event"}) + assert.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "No help found") + }) + + t.Run("every registered tool has a help topic", func(t *testing.T) { + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), WriteEnabled: true, PublishAPIKey: "k", + }) + for name := range listTools(t, session) { + result := callTool(t, session, "outpost_help", map[string]any{"topic": name}) + assert.False(t, result.IsError, "no help topic for %s", name) + } + }) +} + +// --------------------------------------------------------------------------- +// Server identity +// --------------------------------------------------------------------------- + +func TestServerIdentity(t *testing.T) { + api := mockAPI(t, nil) + srv := NewServer(ServerOptions{Client: newTestClient(t, api.URL), Config: &config.Config{}}) + require.NotNil(t, srv) + + // The Outpost server must only ever serve Outpost projects. + assert.Equal(t, config.ProjectTypeOutpost, srv.ProjectFilter()) + assert.Equal(t, "hookdeck_projects", srv.ProjectsToolName()) + assert.Equal(t, "hookdeck_login", srv.LoginToolName()) + + var _ *mcpcore.Server = srv +} + +// TestPublishRefusesATenantTheCredentialCannotSee covers the failure that +// prompted this guard: the publish credential and the active project disagreed, +// so events were accepted, delivered nowhere, and left no trace. +func TestPublishRefusesATenantTheCredentialCannotSee(t *testing.T) { + var published bool + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/ghost": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "tenant not found"}) + }, + "POST /2025-07-01/publish": func(w http.ResponseWriter, r *http.Request) { + published = true + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"id": "evt_1", "destination_ids": []string{}}) + }, + }) + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), + WriteEnabled: true, + PublishAPIKey: "project-api-key", + }) + + result := callTool(t, session, "outpost_publish", map[string]any{ + "action": "publish", "tenant_id": "ghost", "topic": "user.created", + }) + + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "does not exist in the project the publish credential belongs to") + assert.False(t, published, "nothing should be published once the tenant is known to be missing") +} + +// TestPublishWarnsWhenNothingMatched covers the other half: the tenant exists, +// but no destination subscribes to the topic. The API accepts it and the event +// is never delivered or recorded, so a bare success would be misleading. +func TestPublishWarnsWhenNothingMatched(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme": func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "acme"}) + }, + "POST /2025-07-01/publish": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"id": "evt_1", "destination_ids": []string{}}) + }, + }) + session := connect(t, ServerOptions{ + Client: newTestClient(t, api.URL), + WriteEnabled: true, + PublishAPIKey: "project-api-key", + }) + + result := callTool(t, session, "outpost_publish", map[string]any{ + "action": "publish", "tenant_id": "acme", "topic": "user.created", + }) + + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, resultText(t, result), "matched no destinations") +} diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index 61db3690..37d6b11a 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -619,11 +619,24 @@ func (r *CLIRunner) RunListenWithTimeout(args []string, runDuration time.Duratio return stdoutBuf.String(), stderrBuf.String(), waitErr } -// RunGatewayMCPSubprocess builds the CLI binary, runs `gateway mcp` with optional stdin, +// RunGatewayMCPSubprocess runs `gateway mcp`. See RunMCPSubprocess. +func RunGatewayMCPSubprocess(t *testing.T, projectRoot, configPath string, extraEnv map[string]string, stdin string, runDuration time.Duration) (stdout, stderr string, err error) { + t.Helper() + return RunMCPSubprocess(t, projectRoot, configPath, []string{"gateway", "mcp"}, extraEnv, stdin, runDuration) +} + +// RunOutpostMCPSubprocess runs `outpost mcp` with the given extra arguments +// (e.g. --allow-write). See RunMCPSubprocess. +func RunOutpostMCPSubprocess(t *testing.T, projectRoot, configPath string, args []string, extraEnv map[string]string, stdin string, runDuration time.Duration) (stdout, stderr string, err error) { + t.Helper() + return RunMCPSubprocess(t, projectRoot, configPath, append([]string{"outpost", "mcp"}, args...), extraEnv, stdin, runDuration) +} + +// RunMCPSubprocess builds the CLI binary, runs the given MCP command with optional stdin, // lets it run for runDuration, then kills the process. Returns stdout, stderr, and the // error from Wait (often non-nil because the process was killed). configPath, when non-empty, // is passed as HOOKDECK_CONFIG_FILE. extraEnv entries override the process environment. -func RunGatewayMCPSubprocess(t *testing.T, projectRoot, configPath string, extraEnv map[string]string, stdin string, runDuration time.Duration) (stdout, stderr string, err error) { +func RunMCPSubprocess(t *testing.T, projectRoot, configPath string, args []string, extraEnv map[string]string, stdin string, runDuration time.Duration) (stdout, stderr string, err error) { t.Helper() tmpBinary := filepath.Join(projectRoot, "hookdeck-mcp-test-"+generateTimestamp()) defer os.Remove(tmpBinary) @@ -631,10 +644,10 @@ func RunGatewayMCPSubprocess(t *testing.T, projectRoot, configPath string, extra buildCmd := exec.Command("go", "build", "-o", tmpBinary, ".") buildCmd.Dir = projectRoot if buildErr := buildCmd.Run(); buildErr != nil { - return "", "", fmt.Errorf("build CLI for gateway mcp test: %w", buildErr) + return "", "", fmt.Errorf("build CLI for mcp test: %w", buildErr) } - cmd := exec.Command(tmpBinary, "gateway", "mcp") + cmd := exec.Command(tmpBinary, args...) cmd.Dir = projectRoot env := os.Environ() if configPath != "" { @@ -724,24 +737,67 @@ func findJSONRPCResponseByID(t *testing.T, stdout string, id int) map[string]any return nil } +// mcpInitializeJSON is a minimal initialize request, for tests that only need +// the server to answer one. +const mcpInitializeJSON = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}` + +// firstJSONRPCMessageLine returns the first JSON-RPC message on stdout. +func firstJSONRPCMessageLine(t *testing.T, stdout string) map[string]any { + t.Helper() + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + if _, ok := msg["jsonrpc"]; ok { + return msg + } + } + t.Fatalf("no JSON-RPC line in stdout: %q", stdout) + return nil +} + +// mcpHandshake is the initialize + initialized prelude every session needs +// before it can issue requests. +var mcpHandshake = []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"acceptance-test","version":"1.0"},"capabilities":{}}}`, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, +} + // CallGatewayMCPTool runs initialize + notifications/initialized + tools/call over gateway mcp stdio. func CallGatewayMCPTool(t *testing.T, projectRoot, configPath, toolName string, arguments map[string]any, runDuration time.Duration) MCPToolCallResult { + t.Helper() + return CallMCPTool(t, projectRoot, configPath, []string{"gateway", "mcp"}, toolName, arguments, runDuration) +} + +// CallOutpostMCPTool runs a tools/call over outpost mcp stdio. args carries the +// command's own flags (e.g. --allow-write). +func CallOutpostMCPTool(t *testing.T, projectRoot, configPath string, args []string, toolName string, arguments map[string]any, runDuration time.Duration) MCPToolCallResult { + t.Helper() + return CallMCPTool(t, projectRoot, configPath, append([]string{"outpost", "mcp"}, args...), toolName, arguments, runDuration) +} + +// CallMCPTool runs initialize + notifications/initialized + tools/call over the +// given MCP command's stdio. +func CallMCPTool(t *testing.T, projectRoot, configPath string, command []string, toolName string, arguments map[string]any, runDuration time.Duration) MCPToolCallResult { t.Helper() argsJSON, err := json.Marshal(arguments) require.NoError(t, err) - stdin := strings.Join([]string{ - `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"acceptance-test","version":"1.0"},"capabilities":{}}}`, - `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + stdin := strings.Join(append(append([]string{}, mcpHandshake...), fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, toolName, string(argsJSON)), - }, "\n") + "\n" + ), "\n") + "\n" extra := map[string]string{} if configPath != "" { extra["HOOKDECK_CONFIG_FILE"] = configPath } - stdout, stderr, waitErr := RunGatewayMCPSubprocess(t, projectRoot, configPath, extra, stdin, runDuration) + stdout, stderr, waitErr := RunMCPSubprocess(t, projectRoot, configPath, command, extra, stdin, runDuration) if waitErr != nil { - t.Logf("gateway mcp subprocess wait: %v (stderr=%q)", waitErr, stderr) + t.Logf("%v subprocess wait: %v (stderr=%q)", command, waitErr, stderr) } resp := findJSONRPCResponseByID(t, stdout, 2) @@ -762,6 +818,66 @@ func CallGatewayMCPTool(t *testing.T, projectRoot, configPath, toolName string, return out } +// ListMCPTools runs initialize + notifications/initialized + tools/list over the +// given MCP command's stdio, and returns the tools by name along with the raw +// stdout and stderr so callers can also assert on stream hygiene. +func ListMCPTools(t *testing.T, projectRoot, configPath string, command []string, runDuration time.Duration) (tools map[string]map[string]any, stdout, stderr string) { + t.Helper() + stdin := strings.Join(append(append([]string{}, mcpHandshake...), + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + ), "\n") + "\n" + + extra := map[string]string{} + if configPath != "" { + extra["HOOKDECK_CONFIG_FILE"] = configPath + } + stdout, stderr, waitErr := RunMCPSubprocess(t, projectRoot, configPath, command, extra, stdin, runDuration) + if waitErr != nil { + t.Logf("%v subprocess wait: %v (stderr=%q)", command, waitErr, stderr) + } + + resp := findJSONRPCResponseByID(t, stdout, 2) + result, ok := resp["result"].(map[string]any) + require.True(t, ok, "tools/list result missing in %v", resp) + + list, ok := result["tools"].([]any) + require.True(t, ok, "tools/list returned no tools array: %v", result) + + tools = make(map[string]map[string]any, len(list)) + for _, entry := range list { + tool, ok := entry.(map[string]any) + require.True(t, ok) + name, _ := tool["name"].(string) + tools[name] = tool + } + return tools, stdout, stderr +} + +// MCPToolActionEnum returns the action enum a tool advertises, which is how an +// MCP server tells a client which actions it may call. +func MCPToolActionEnum(t *testing.T, tool map[string]any) []string { + t.Helper() + schema, ok := tool["inputSchema"].(map[string]any) + require.True(t, ok, "tool has no inputSchema: %v", tool) + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok, "schema has no properties: %v", schema) + action, ok := properties["action"].(map[string]any) + if !ok { + return nil + } + rawEnum, ok := action["enum"].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(rawEnum)) + for _, v := range rawEnum { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + // RunFromCwd executes the CLI from the current working directory. // This is useful for tests that need to test --local flag behavior, // which creates config in the current directory. diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index 259ddaf7..edf1aa8c 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -3,7 +3,6 @@ package acceptance import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -15,27 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -const mcpInitializeJSON = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}}}` - -func firstJSONRPCMessageLine(t *testing.T, stdout string) map[string]any { - t.Helper() - for _, line := range strings.Split(stdout, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var msg map[string]any - if err := json.Unmarshal([]byte(line), &msg); err != nil { - continue - } - if _, ok := msg["jsonrpc"]; ok { - return msg - } - } - t.Fatalf("no JSON-RPC line in stdout: %q", stdout) - return nil -} - func assertGatewayMCPStdioHygiene(t *testing.T, stdout, stderr string) { t.Helper() assert.NotContains(t, stdout, "Running `hookdeck login`") diff --git a/test/acceptance/outpost_mcp_test.go b/test/acceptance/outpost_mcp_test.go new file mode 100644 index 00000000..11f105b0 --- /dev/null +++ b/test/acceptance/outpost_mcp_test.go @@ -0,0 +1,192 @@ +//go:build outpost + +package acceptance + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var outpostMCPCommand = []string{"outpost", "mcp"} + +// assertMCPStdoutIsJSONRPCOnly checks that nothing but protocol traffic reached +// stdout. Anything else corrupts the stream and breaks the client session. +func assertMCPStdoutIsJSONRPCOnly(t *testing.T, stdout string) { + t.Helper() + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &msg), + "non-JSON line on stdout: %q", line) + require.Contains(t, msg, "jsonrpc", "non-JSON-RPC object on stdout: %q", line) + } +} + +func TestOutpostMCPHelp(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + stdout := cli.RunExpectSuccess("outpost", "mcp", "--help") + assert.Contains(t, stdout, "Model Context Protocol") + assert.Contains(t, stdout, "stdio") + assert.Contains(t, stdout, "--allow-write") + assert.Contains(t, stdout, "read-only") +} + +func TestOutpostHelpListsMCP(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + stdout := cli.RunExpectSuccess("outpost", "--help") + assert.Contains(t, stdout, "mcp", "outpost --help should list the 'mcp' subcommand") +} + +func TestOutpostMCPStdio_InitializeIsJSONRPCOnly(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + + stdout, stderr, _ := RunOutpostMCPSubprocess(t, cli.projectRoot, cli.configPath, nil, nil, + mcpInitializeJSON+"\n", 10*time.Second) + + msg := firstJSONRPCMessageLine(t, stdout) + assert.Equal(t, "2.0", msg["jsonrpc"]) + assertMCPStdoutIsJSONRPCOnly(t, stdout) + assert.NotContains(t, stdout, "Running `hookdeck login`") + assert.NotContains(t, stderr, "Running `hookdeck login`") + + result, _ := msg["result"].(map[string]any) + require.NotNil(t, result, "initialize returned no result: %v", msg) + serverInfo, _ := result["serverInfo"].(map[string]any) + require.NotNil(t, serverInfo) + assert.Equal(t, "hookdeck-outpost", serverInfo["name"]) +} + +func TestOutpostMCPStdio_ReadOnlyByDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + + tools, stdout, _ := ListMCPTools(t, cli.projectRoot, cli.configPath, outpostMCPCommand, 10*time.Second) + assertMCPStdoutIsJSONRPCOnly(t, stdout) + + for _, name := range []string{ + "outpost_projects", "outpost_login", "outpost_help", "outpost_tenants", + "outpost_destinations", "outpost_events", "outpost_attempts", + "outpost_topics", "outpost_destination_types", "outpost_metrics", + "outpost_config", "outpost_status", + } { + assert.Contains(t, tools, name) + } + + // Nothing that changes data, and nothing that hands back a credential. + assert.NotContains(t, tools, "outpost_publish") + assert.Equal(t, []string{"list", "get"}, MCPToolActionEnum(t, tools["outpost_tenants"])) + assert.Equal(t, []string{"list", "get"}, MCPToolActionEnum(t, tools["outpost_destinations"])) + assert.Equal(t, []string{"get", "custom_domain_get"}, MCPToolActionEnum(t, tools["outpost_config"])) +} + +func TestOutpostMCPStdio_AllowWriteAddsWriteActions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + + command := append(append([]string{}, outpostMCPCommand...), "--allow-write") + tools, stdout, _ := ListMCPTools(t, cli.projectRoot, cli.configPath, command, 10*time.Second) + assertMCPStdoutIsJSONRPCOnly(t, stdout) + + tenantActions := MCPToolActionEnum(t, tools["outpost_tenants"]) + for _, want := range []string{"upsert", "delete", "token", "portal"} { + assert.Contains(t, tenantActions, want) + } + assert.Contains(t, MCPToolActionEnum(t, tools["outpost_events"]), "retry") + assert.Contains(t, MCPToolActionEnum(t, tools["outpost_config"]), "set") +} + +func TestOutpostMCPStdio_ReadOnlyRefusesWriteAction(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + tenantID := uniqueTenantID(t) + + result := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, nil, "outpost_tenants", map[string]any{ + "action": "upsert", + "id": tenantID, + }, 20*time.Second) + + require.True(t, result.IsError, "a read-only server must refuse upsert: %s", result.Text) + assert.Contains(t, result.Text, "read-only mode") + assert.Contains(t, result.Text, "--allow-write") + + // The refusal must be real: the tenant must not exist. + stdout, _, err := cli.Run("outpost", "tenant", "get", tenantID) + assert.Error(t, err, "the tenant should not have been created: %s", stdout) +} + +func TestOutpostMCPTool_TenantsList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + tenantID := createTestTenant(t, cli) + + result := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, nil, "outpost_tenants", map[string]any{ + "action": "list", + "limit": 50, + }, 20*time.Second) + + require.False(t, result.IsError, "tool error: %s", result.Text) + assert.Contains(t, result.Text, `"data"`) + assert.Contains(t, result.Text, `"meta"`) + assert.Contains(t, result.Text, tenantID) +} + +func TestOutpostMCPTool_TopicsAndStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + + topics := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, nil, "outpost_topics", map[string]any{ + "action": "list", + }, 20*time.Second) + require.False(t, topics.IsError, "tool error: %s", topics.Text) + assert.Contains(t, topics.Text, `"topics"`) + + status := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, nil, "outpost_status", map[string]any{ + "action": "get", + }, 20*time.Second) + require.False(t, status.IsError, "tool error: %s", status.Text) + assert.Contains(t, status.Text, `"status"`) +} + +func TestOutpostMCPTool_HelpReportsMode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewOutpostCLIRunner(t) + + readOnly := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, nil, "outpost_help", map[string]any{}, 20*time.Second) + require.False(t, readOnly.IsError, "tool error: %s", readOnly.Text) + assert.Contains(t, readOnly.Text, "Mode: read-only") + assert.Contains(t, readOnly.Text, "--allow-write") + + write := CallOutpostMCPTool(t, cli.projectRoot, cli.configPath, []string{"--allow-write"}, + "outpost_help", map[string]any{}, 20*time.Second) + require.False(t, write.IsError, "tool error: %s", write.Text) + assert.Contains(t, write.Text, "Mode: write enabled") +}