From 2d8f9d92ec58a6a970e151cb16eebcda2c77a943 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 15:42:28 +0100 Subject: [PATCH 01/49] feat(outpost): add Outpost API client, schema cache and live tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of Outpost support (#346): the API client layer that the `hookdeck outpost` commands and MCP server will be built on. No user-facing commands yet. Client: - Outpost API base URL, a separate client instance, and config resolution including a hidden --outpost-api-base for dev - IsOutpostProject alongside IsGatewayProject - Per-resource methods for tenants, destinations, events, attempts, retry, publish, topics, destination types, metrics, managed config, custom domain and status - Destination type schemas fetched and cached per API host and project, so --type validation follows the API rather than a hardcoded list Two shapes worth calling out. The `topics` field is a union — either "*" or an array — so it decodes through a dedicated type rather than []string. Publish takes a Project API key as a bearer token, which the stored CLI key cannot satisfy, so it sends through a clone with no stored credential. Live tests (build tag `outpostlive`) exercise the client against a real project and found two bugs that the stub-based unit tests could not: - destination-type `options` is [{label, value}], not []string; the stub fixture had encoded the wrong shape, which is why the unit tests passed - only HTTP 200 was treated as success. The Event Gateway API answers 200 to everything, so this never surfaced, but Outpost uses 201 on create and 202 on publish/retry, so every write failed. Fixed with an opt-in Client.AcceptAnySuccessStatus, set on the Outpost client only Docs: README gains a key capability matrix and a way to tell which credential you hold; AGENTS.md gains the same diagnosis for agents plus the acceptance key table. The config field named api_key holds a CLI client key regardless of origin, which is easy to misread. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- AGENTS.md | 18 + README.md | 25 ++ pkg/cmd/outposttypes/types.go | 210 ++++++++++ pkg/cmd/outposttypes/types_test.go | 247 ++++++++++++ pkg/cmd/root.go | 28 +- pkg/config/apiclient.go | 68 +++- pkg/config/config.go | 12 +- pkg/config/outpost_apiclient_test.go | 142 +++++++ pkg/config/project_type.go | 11 + pkg/hookdeck/attempts.go | 36 +- pkg/hookdeck/client.go | 27 +- pkg/hookdeck/destinations.go | 4 +- pkg/hookdeck/events.go | 4 +- pkg/hookdeck/outpost.go | 103 +++++ pkg/hookdeck/outpost_attempts.go | 153 ++++++++ pkg/hookdeck/outpost_config.go | 148 +++++++ pkg/hookdeck/outpost_destination_types.go | 111 ++++++ pkg/hookdeck/outpost_destinations.go | 184 +++++++++ pkg/hookdeck/outpost_events.go | 129 ++++++ pkg/hookdeck/outpost_metrics.go | 95 +++++ pkg/hookdeck/outpost_publish.go | 84 ++++ pkg/hookdeck/outpost_tenants.go | 166 ++++++++ pkg/hookdeck/outpost_test.go | 386 ++++++++++++++++++ pkg/hookdeck/projects_test.go | 86 ++-- pkg/hookdeck/request_log_redact.go | 80 ++-- pkg/hookdeck/requests.go | 30 +- pkg/hookdeck/sources.go | 4 +- pkg/hookdeck/transformations.go | 34 +- test/acceptance/outpost_live_test.go | 456 ++++++++++++++++++++++ 29 files changed, 2914 insertions(+), 167 deletions(-) create mode 100644 pkg/cmd/outposttypes/types.go create mode 100644 pkg/cmd/outposttypes/types_test.go create mode 100644 pkg/config/outpost_apiclient_test.go create mode 100644 pkg/hookdeck/outpost.go create mode 100644 pkg/hookdeck/outpost_attempts.go create mode 100644 pkg/hookdeck/outpost_config.go create mode 100644 pkg/hookdeck/outpost_destination_types.go create mode 100644 pkg/hookdeck/outpost_destinations.go create mode 100644 pkg/hookdeck/outpost_events.go create mode 100644 pkg/hookdeck/outpost_metrics.go create mode 100644 pkg/hookdeck/outpost_publish.go create mode 100644 pkg/hookdeck/outpost_tenants.go create mode 100644 pkg/hookdeck/outpost_test.go create mode 100644 test/acceptance/outpost_live_test.go diff --git a/AGENTS.md b/AGENTS.md index b48262b5..9d4819ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -579,6 +579,24 @@ Summary for code and docs work: - **Guest** — `listen` without login may call `POST /cli/guest`; separate from `--cli-key` onboarding. - **`project list`** — Requires a user-associated CLI client key (`hookdeck login` or `hookdeck login --cli-key`). CI keys from `hookdeck ci` and raw Project API keys cannot list or switch projects (acceptance: `HOOKDECK_CLI_TESTING_CLI_KEY`). +### Diagnosing a key before you debug anything else + +The config file cannot tell you which credential you hold — `api_key` is the field name for every CLI client key regardless of origin. When a command fails with a permission or project error, establish the key's scope first: + +- `hookdeck whoami` — the active project and its type. Does **not** reveal the key's scope. +- `hookdeck project list` — succeeds only with a user-associated key. A "scoped to a single project" error means the key came from `hookdeck ci`. + +A project-scoped key is bound to one project, so it also ignores any attempt to target another project. Do not chase a project-selection bug before ruling this out. + +### Keys used by acceptance tests + +| Env var | Kind | Used for | +|---|---|---| +| `HOOKDECK_CLI_TESTING_API_KEY` (`_2`, `_3`) | Project API key, one per slice | The default runner; exchanged via `hookdeck ci` (`getAcceptanceAPIKey` in `test/acceptance/helpers.go`) | +| `HOOKDECK_CLI_TESTING_CLI_KEY` | User-associated CLI key | Only `project list` / `project use` tests, via `NewCLIRunnerWithKey` | + +Each slice's key belongs to a **different project**, which is why tests must use unique resource names rather than assuming an empty project. + --- ## Agent skills diff --git a/README.md b/README.md index 9feb97b2..dc75d721 100644 --- a/README.md +++ b/README.md @@ -1622,6 +1622,31 @@ These settings ensure that all changes to `main` go through proper review and te Reference for how Hookdeck credentials relate to CLI commands. After any successful login or `hookdeck ci`, the CLI stores a **CLI client key** in your config file as `api_key` (see [Configuration files](#configuration-files)). The same field name is used regardless of how the key was obtained. +> **The `api_key` field in your config is not a Project API key.** It holds whichever CLI client key the last login produced. The field name is historical, so you cannot tell from the config file alone which kind of credential you have, or what it is allowed to do. + +### Which key can do what + +| | `hookdeck login`
`hookdeck login --cli-key` | `hookdeck ci --api-key` | Project API key
(dashboard) | +|---|---|---|---| +| What it is | CLI client key, tied to your user | CLI client key, tied to one project | Long-lived key from project settings | +| Stored in config as `api_key` | Yes | Yes | No — exchanged, never stored | +| `hookdeck listen`, `hookdeck gateway …` | Yes | Yes | No | +| `hookdeck project list` / `project use` | **Yes** | **No** — single project, no user | No | +| Accepted by `hookdeck ci --api-key` | No | No | **Yes** | + +The distinction that catches people out is the middle column: a key from `hookdeck ci` works fine for everyday commands but is pinned to one project, so anything that spans projects fails. + +### Check which key you have + +`hookdeck whoami` shows the active project but not the key's scope. To tell the two CLI client keys apart, ask for something only a user-associated key can do: + +```sh +hookdeck project list +``` + +- **A list of projects** — you have a user-associated key and can switch projects. +- **An error saying the credential is scoped to a single project** — you have a project-scoped key from `hookdeck ci`. Run `hookdeck login` (or `hookdeck login --cli-key `) for account-wide access. + ### CLI client keys (what the CLI runs as) A **CLI client key** identifies the Hookdeck CLI to the API (`cli` authentication). It powers `hookdeck listen`, `hookdeck gateway …`, and most other commands after you are configured. diff --git a/pkg/cmd/outposttypes/types.go b/pkg/cmd/outposttypes/types.go new file mode 100644 index 00000000..5c65d06a --- /dev/null +++ b/pkg/cmd/outposttypes/types.go @@ -0,0 +1,210 @@ +// Package outposttypes fetches and caches Outpost destination type schemas. +// +// Destination config and credential fields differ per type, and the set of +// types grows over time, so the CLI reads the schemas from the API rather than +// hardcoding them. Results are cached on disk for a short period to keep +// per-command latency down. +package outposttypes + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +var ( + cacheFilePrefix = "hookdeck_outpost_destination_types_" + cacheTTL = 24 * time.Hour +) + +// Schema is one destination type's schema. +type Schema = hookdeck.OutpostDestinationTypeSchema + +// Field is one configurable field within a schema. +type Field = hookdeck.OutpostDestinationTypeField + +// FetchDestinationTypes returns the destination type schemas available to the +// active project, preferring a fresh on-disk cache. +// +// Callers should treat an error as non-fatal: warn and continue, letting the +// API validate the request instead. A stale local schema must never be the +// reason a valid command is rejected. +func FetchDestinationTypes(ctx context.Context, client *hookdeck.Client) ([]Schema, error) { + cachePath := cachePathFor(client) + + if schemas, ok := readCache(cachePath); ok { + return schemas, nil + } + + schemas, err := client.ListOutpostDestinationTypes(ctx) + if err != nil { + return nil, err + } + + writeCache(cachePath, schemas) + + return schemas, nil +} + +// Find returns the schema for a destination type, matching case-insensitively. +func Find(schemas []Schema, destinationType string) (Schema, bool) { + for _, schema := range schemas { + if strings.EqualFold(schema.Type, destinationType) { + return schema, true + } + } + return Schema{}, false +} + +// TypeNames returns the available type names, sorted, for help text and error +// messages. +func TypeNames(schemas []Schema) []string { + names := make([]string, 0, len(schemas)) + for _, schema := range schemas { + names = append(names, schema.Type) + } + sort.Strings(names) + return names +} + +// ValidateFields checks supplied values against a schema's field definitions. +// +// kind names the group being checked ("config" or "credential") so errors can +// point at the right flags. Only rules the schema states are enforced: missing +// required fields, unknown fields, values outside a declared option set, and +// values failing a declared pattern. Anything else is left to the API. +func ValidateFields(fields []Field, values map[string]interface{}, kind string) error { + known := make(map[string]Field, len(fields)) + for _, field := range fields { + known[field.Key] = field + } + + var problems []string + + for _, field := range fields { + if !field.Required { + continue + } + value, present := values[field.Key] + if !present || isEmptyValue(value) { + problems = append(problems, fmt.Sprintf("--%s-%s is required", kind, flagName(field.Key))) + } + } + + for key, value := range values { + field, ok := known[key] + if !ok { + problems = append(problems, fmt.Sprintf("--%s-%s is not a valid %s field", kind, flagName(key), kind)) + continue + } + + text, isText := value.(string) + if !isText || text == "" { + continue + } + + if options := field.OptionValues(); len(options) > 0 && !containsFold(options, text) { + problems = append(problems, fmt.Sprintf("--%s-%s must be one of: %s", + kind, flagName(key), strings.Join(options, ", "))) + continue + } + + if field.Pattern != "" { + // A schema pattern the CLI cannot compile is a problem with the + // schema, not the user's input, so it is ignored rather than + // reported as a validation failure. + if re, err := regexp.Compile(field.Pattern); err == nil && !re.MatchString(text) { + problems = append(problems, fmt.Sprintf("--%s-%s does not match the expected format (%s)", + kind, flagName(key), field.Pattern)) + } + } + } + + if len(problems) == 0 { + return nil + } + + sort.Strings(problems) + return fmt.Errorf("%s", strings.Join(problems, "\n")) +} + +// flagName converts a schema field key to the CLI flag spelling. +func flagName(key string) string { + return strings.ReplaceAll(key, "_", "-") +} + +func containsFold(options []string, value string) bool { + for _, option := range options { + if strings.EqualFold(option, value) { + return true + } + } + return false +} + +func isEmptyValue(value interface{}) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + default: + return false + } +} + +// cachePathFor derives a cache file per API host and project. Destination types +// come from the project's own deployment, so a single shared cache file would +// serve one project's schemas to another. +func cachePathFor(client *hookdeck.Client) string { + var key string + if client != nil { + if client.BaseURL != nil { + key = client.BaseURL.Host + } + key += "|" + client.ProjectID + } + + hash := fnv.New64a() + _, _ = hash.Write([]byte(key)) + + return filepath.Join(os.TempDir(), fmt.Sprintf("%s%x.json", cacheFilePrefix, hash.Sum64())) +} + +func readCache(path string) ([]Schema, bool) { + info, err := os.Stat(path) + if err != nil || time.Since(info.ModTime()) >= cacheTTL { + return nil, false + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + + var schemas []Schema + if err := json.Unmarshal(data, &schemas); err != nil || len(schemas) == 0 { + return nil, false + } + + return schemas, true +} + +// writeCache stores schemas for later runs. Failures are ignored: the cache is +// an optimisation, and a read-only or full temp dir must not break the command. +func writeCache(path string, schemas []Schema) { + data, err := json.Marshal(schemas) + if err != nil { + return + } + _ = os.WriteFile(path, data, 0o600) +} diff --git a/pkg/cmd/outposttypes/types_test.go b/pkg/cmd/outposttypes/types_test.go new file mode 100644 index 00000000..c39c0cac --- /dev/null +++ b/pkg/cmd/outposttypes/types_test.go @@ -0,0 +1,247 @@ +package outposttypes + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +const schemaBody = `[ + { + "type": "webhook", + "label": "Webhook", + "config_fields": [ + {"key": "url", "type": "text", "label": "URL", "required": true, "pattern": "^https?://"} + ], + "credential_fields": [ + {"key": "secret", "type": "text", "label": "Secret", "sensitive": true} + ] + }, + { + "type": "aws_sqs", + "label": "AWS SQS", + "config_fields": [ + {"key": "queue_url", "type": "text", "label": "Queue URL", "required": true}, + {"key": "region", "type": "select", "label": "Region", "options": [ + {"label": "US East 1", "value": "us-east-1"}, + {"label": "EU West 2", "value": "eu-west-2"} + ]} + ], + "credential_fields": [] + } +]` + +// newTestClient points a client at a stub server and isolates the on-disk cache +// so tests never read or write a real user's temp files. +func newTestClient(t *testing.T, handler http.HandlerFunc) *hookdeck.Client { + t.Helper() + + t.Setenv("TMPDIR", t.TempDir()) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + + return &hookdeck.Client{BaseURL: baseURL, APIKey: "test-key", ProjectID: "tm_test"} +} + +func TestFetchDestinationTypes(t *testing.T) { + t.Run("fetches and returns schemas", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, hookdeck.APIPathPrefix+"/destination-types", r.URL.Path) + _, _ = w.Write([]byte(schemaBody)) + }) + + schemas, err := FetchDestinationTypes(context.Background(), client) + require.NoError(t, err) + require.Len(t, schemas, 2) + assert.Equal(t, []string{"aws_sqs", "webhook"}, TypeNames(schemas)) + + // A select field's options are {label, value} objects, not bare strings. + // The original stub encoded them as strings, so the unit tests passed + // while decoding the real API failed — keep this assertion faithful. + sqs, ok := Find(schemas, "aws_sqs") + require.True(t, ok) + var region Field + for _, f := range sqs.ConfigFields { + if f.Key == "region" { + region = f + } + } + require.Len(t, region.Options, 2) + assert.Equal(t, "US East 1", region.Options[0].Label) + assert.Equal(t, []string{"us-east-1", "eu-west-2"}, region.OptionValues()) + }) + + t.Run("serves a second call from cache without hitting the API", func(t *testing.T) { + var calls int + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = w.Write([]byte(schemaBody)) + }) + + _, err := FetchDestinationTypes(context.Background(), client) + require.NoError(t, err) + _, err = FetchDestinationTypes(context.Background(), client) + require.NoError(t, err) + + assert.Equal(t, 1, calls, "the second call should come from the cache") + }) + + t.Run("refetches once the cache has expired", func(t *testing.T) { + var calls int + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = w.Write([]byte(schemaBody)) + }) + + _, err := FetchDestinationTypes(context.Background(), client) + require.NoError(t, err) + + // Backdate the cache past its TTL rather than waiting it out. + stale := time.Now().Add(-2 * cacheTTL) + require.NoError(t, os.Chtimes(cachePathFor(client), stale, stale)) + + _, err = FetchDestinationTypes(context.Background(), client) + require.NoError(t, err) + assert.Equal(t, 2, calls) + }) + + t.Run("returns an error the caller can warn on rather than caching a failure", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + }) + + schemas, err := FetchDestinationTypes(context.Background(), client) + require.Error(t, err) + assert.Nil(t, schemas) + + _, cached := readCache(cachePathFor(client)) + assert.False(t, cached, "a failed fetch must not populate the cache") + }) + + t.Run("caches separately per project", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(schemaBody)) + }) + + first := cachePathFor(client) + client.ProjectID = "tm_other" + second := cachePathFor(client) + + assert.NotEqual(t, first, second, "one project's schemas must not be served to another") + }) + + t.Run("caches separately per API host", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {}) + + first := cachePathFor(client) + other, err := url.Parse("https://outpost.elsewhere.test") + require.NoError(t, err) + client.BaseURL = other + + assert.NotEqual(t, first, cachePathFor(client), "a different host must not reuse the cache") + }) +} + +func TestFind(t *testing.T) { + t.Parallel() + + schemas := []Schema{{Type: "webhook"}, {Type: "aws_sqs"}} + + found, ok := Find(schemas, "WEBHOOK") + assert.True(t, ok, "type matching should be case-insensitive") + assert.Equal(t, "webhook", found.Type) + + _, ok = Find(schemas, "kafka") + assert.False(t, ok) +} + +func TestValidateFields(t *testing.T) { + t.Parallel() + + configFields := []Field{ + {Key: "url", Required: true, Pattern: "^https?://"}, + {Key: "region", Options: []hookdeck.OutpostDestinationTypeOption{ + {Label: "US East 1", Value: "us-east-1"}, + {Label: "EU West 2", Value: "eu-west-2"}, + }}, + {Key: "note"}, + } + + t.Run("accepts valid values", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{ + "url": "https://example.com/hook", + "region": "eu-west-2", + }, "config") + assert.NoError(t, err) + }) + + t.Run("reports a missing required field using its flag name", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{}, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-url is required") + }) + + t.Run("treats a blank required value as missing", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{"url": " "}, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-url is required") + }) + + t.Run("rejects an unknown field", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{ + "url": "https://example.com", + "unknown": "x", + }, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-unknown is not a valid config field") + }) + + t.Run("converts underscores in keys to dashes in flag names", func(t *testing.T) { + err := ValidateFields([]Field{{Key: "queue_url", Required: true}}, map[string]interface{}{}, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-queue-url is required") + }) + + t.Run("rejects a value outside the declared options", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{ + "url": "https://example.com", + "region": "mars-1", + }, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-region must be one of: us-east-1, eu-west-2") + }) + + t.Run("rejects a value failing the declared pattern", func(t *testing.T) { + err := ValidateFields(configFields, map[string]interface{}{"url": "ftp://example.com"}, "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-url does not match the expected format") + }) + + t.Run("ignores a pattern the schema declares but Go cannot compile", func(t *testing.T) { + // A broken schema is not the user's fault, so it must not block a command. + err := ValidateFields([]Field{{Key: "url", Pattern: "(unclosed"}}, map[string]interface{}{ + "url": "anything", + }, "config") + assert.NoError(t, err) + }) + + t.Run("names the credential group when validating credentials", func(t *testing.T) { + err := ValidateFields([]Field{{Key: "secret", Required: true}}, map[string]interface{}{}, "credential") + require.Error(t, err) + assert.Contains(t, err.Error(), "--credential-secret is required") + }) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 442a75c6..778a29fb 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -245,18 +245,19 @@ func argvContainsGatewayMCP(argv []string) bool { // flagNeedsNextArg lists global flags that consume the next argv token as their value. // Keep in sync with the PersistentFlags registered in init() below. var flagNeedsNextArg = map[string]bool{ - "profile": true, - "p": true, - "cli-key": true, - "api-key": true, - "hookdeck-config": true, - "device-name": true, - "log-level": true, - "color": true, - "api-base": true, - "dashboard-base": true, - "console-base": true, - "ws-base": true, + "profile": true, + "p": true, + "cli-key": true, + "api-key": true, + "hookdeck-config": true, + "device-name": true, + "log-level": true, + "color": true, + "api-base": true, + "outpost-api-base": true, + "dashboard-base": true, + "console-base": true, + "ws-base": true, } // globalPositionalArgs returns argv arguments that are not global flags or flag values, @@ -332,6 +333,9 @@ func init() { rootCmd.PersistentFlags().StringVar(&Config.APIBaseURL, "api-base", "", fmt.Sprintf("Sets the API base URL (default \"%s\")", hookdeck.DefaultAPIBaseURL)) rootCmd.PersistentFlags().MarkHidden("api-base") + rootCmd.PersistentFlags().StringVar(&Config.OutpostAPIBaseURL, "outpost-api-base", "", fmt.Sprintf("Sets the Outpost API base URL (default \"%s\")", hookdeck.DefaultOutpostAPIBaseURL)) + rootCmd.PersistentFlags().MarkHidden("outpost-api-base") + rootCmd.PersistentFlags().StringVar(&Config.DashboardBaseURL, "dashboard-base", "", fmt.Sprintf("Sets the web dashboard base URL (default \"%s\")", hookdeck.DefaultDashboardBaseURL)) rootCmd.PersistentFlags().MarkHidden("dashboard-base") diff --git a/pkg/config/apiclient.go b/pkg/config/apiclient.go index e6d64cee..334ed2ee 100644 --- a/pkg/config/apiclient.go +++ b/pkg/config/apiclient.go @@ -10,9 +10,18 @@ import ( var apiClient *hookdeck.Client var apiClientOnce sync.Once +// The Outpost API lives on its own host, so it needs its own client instance. +// It is kept separate rather than derived on demand because MCP tool handlers +// mutate the client in place (e.g. ProjectID on a project switch), and those +// mutations must not leak between the two products. +var outpostAPIClient *hookdeck.Client +var outpostAPIClientOnce sync.Once + func resetAPIClient() { apiClient = nil apiClientOnce = sync.Once{} + outpostAPIClient = nil + outpostAPIClientOnce = sync.Once{} } // ResetAPIClientForTesting resets the global API client singleton so that @@ -28,18 +37,32 @@ func ResetAPIClientForTesting() { // If GetAPIClient has never been called, this is a no-op (the next GetAPIClient // will construct from Config). func (c *Config) RefreshCachedAPIClient() { - if apiClient == nil { - return + if apiClient != nil { + baseURL, err := url.Parse(c.APIBaseURL) + if err != nil { + panic("Invalid API base URL: " + err.Error()) + } + apiClient.BaseURL = baseURL + apiClient.APIKey = c.Profile.APIKey + apiClient.ProjectID = c.Profile.ProjectId + apiClient.Verbose = c.LogLevel == "debug" + apiClient.TelemetryDisabled = c.TelemetryDisabled } - baseURL, err := url.Parse(c.APIBaseURL) - if err != nil { - panic("Invalid API base URL: " + err.Error()) + + // The Outpost client shares the profile's credentials and project, so it + // has to be refreshed too. Skipping it would leave it holding the key from + // before a login or project switch. + if outpostAPIClient != nil { + outpostBaseURL, err := url.Parse(c.OutpostAPIBaseURL) + if err != nil { + panic("Invalid Outpost API base URL: " + err.Error()) + } + outpostAPIClient.BaseURL = outpostBaseURL + outpostAPIClient.APIKey = c.Profile.APIKey + outpostAPIClient.ProjectID = c.Profile.ProjectId + outpostAPIClient.Verbose = c.LogLevel == "debug" + outpostAPIClient.TelemetryDisabled = c.TelemetryDisabled } - apiClient.BaseURL = baseURL - apiClient.APIKey = c.Profile.APIKey - apiClient.ProjectID = c.Profile.ProjectId - apiClient.Verbose = c.LogLevel == "debug" - apiClient.TelemetryDisabled = c.TelemetryDisabled } // GetAPIClient returns the internal API client instance @@ -61,3 +84,28 @@ func (c *Config) GetAPIClient() *hookdeck.Client { return apiClient } + +// GetOutpostAPIClient returns the API client instance for the Hookdeck Outpost +// API. It is the same client type as GetAPIClient, pointed at the Outpost host: +// authentication, project scoping and telemetry all behave identically. +func (c *Config) GetOutpostAPIClient() *hookdeck.Client { + outpostAPIClientOnce.Do(func() { + baseURL, err := url.Parse(c.OutpostAPIBaseURL) + if err != nil { + panic("Invalid Outpost API base URL: " + err.Error()) + } + + outpostAPIClient = &hookdeck.Client{ + BaseURL: baseURL, + APIKey: c.Profile.APIKey, + ProjectID: c.Profile.ProjectId, + Verbose: c.LogLevel == "debug", + TelemetryDisabled: c.TelemetryDisabled, + // Outpost answers 201 on create and 202 on publish/retry, so + // restricting success to 200 would fail every write. + AcceptAnySuccessStatus: true, + } + }) + + return outpostAPIClient +} diff --git a/pkg/config/config.go b/pkg/config/config.go index fdc95603..82fd34ae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,11 +31,12 @@ type Config struct { DeviceName string // Helpers - APIBaseURL string - DashboardBaseURL string - ConsoleBaseURL string - WSBaseURL string - Insecure bool + APIBaseURL string + OutpostAPIBaseURL string + DashboardBaseURL string + ConsoleBaseURL string + WSBaseURL string + Insecure bool // Config ConfigFileFlag string // flag -- should NOT use this directly @@ -355,6 +356,7 @@ func (c *Config) constructConfig() { c.Color = stringCoalesce(c.Color, c.viper.GetString(("color")), "auto") c.LogLevel = stringCoalesce(c.LogLevel, c.viper.GetString(("log")), "info") c.APIBaseURL = stringCoalesce(c.APIBaseURL, c.viper.GetString(("api_base")), hookdeck.DefaultAPIBaseURL) + c.OutpostAPIBaseURL = stringCoalesce(c.OutpostAPIBaseURL, c.viper.GetString(("outpost_api_base")), hookdeck.DefaultOutpostAPIBaseURL) c.DashboardBaseURL = stringCoalesce(c.DashboardBaseURL, c.viper.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL) c.ConsoleBaseURL = stringCoalesce(c.ConsoleBaseURL, c.viper.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL) c.WSBaseURL = stringCoalesce(c.WSBaseURL, c.viper.GetString(("ws_base")), hookdeck.DefaultWebsocektURL) diff --git a/pkg/config/outpost_apiclient_test.go b/pkg/config/outpost_apiclient_test.go new file mode 100644 index 00000000..3c09e031 --- /dev/null +++ b/pkg/config/outpost_apiclient_test.go @@ -0,0 +1,142 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +func TestIsOutpostProject(t *testing.T) { + t.Parallel() + + for _, value := range []string{ProjectTypeOutpost, "outpost"} { + assert.True(t, IsOutpostProject(value), "expected %q to be an Outpost project", value) + } + + // Gateway and Console types must not satisfy the Outpost gate, and nor must + // an unset type — an unknown project should be resolved, not assumed. + for _, value := range []string{ProjectTypeGateway, ProjectTypeConsole, "inbound", "outbound", "console", ""} { + assert.False(t, IsOutpostProject(value), "expected %q not to be an Outpost project", value) + } +} + +func TestOutpostAPIClientIsSeparateFromGatewayClient(t *testing.T) { + ResetAPIClientForTesting() + t.Cleanup(ResetAPIClientForTesting) + + cfg := &Config{ + APIBaseURL: "https://api.example.test", + OutpostAPIBaseURL: "https://outpost.example.test", + } + cfg.Profile.APIKey = "key-1" + cfg.Profile.ProjectId = "tm_1" + + gateway := cfg.GetAPIClient() + outpost := cfg.GetOutpostAPIClient() + + require.NotSame(t, gateway, outpost, "the two clients must be distinct instances") + assert.Equal(t, "api.example.test", gateway.BaseURL.Host) + assert.Equal(t, "outpost.example.test", outpost.BaseURL.Host) + assert.Equal(t, "key-1", outpost.APIKey) + assert.Equal(t, "tm_1", outpost.ProjectID) + + // Tool handlers switch projects by mutating the client in place, so a change + // to one product's client must not move the other. + outpost.ProjectID = "tm_2" + assert.Equal(t, "tm_1", gateway.ProjectID) +} + +func TestRefreshCachedAPIClientRefreshesOutpostClient(t *testing.T) { + ResetAPIClientForTesting() + t.Cleanup(ResetAPIClientForTesting) + + cfg := &Config{ + APIBaseURL: "https://api.example.test", + OutpostAPIBaseURL: "https://outpost.example.test", + } + cfg.Profile.APIKey = "old-key" + cfg.Profile.ProjectId = "tm_1" + + outpost := cfg.GetOutpostAPIClient() + require.Equal(t, "old-key", outpost.APIKey) + + // Simulates signing in, or switching project, after the client was built. + cfg.Profile.APIKey = "new-key" + cfg.Profile.ProjectId = "tm_2" + cfg.RefreshCachedAPIClient() + + assert.Equal(t, "new-key", outpost.APIKey, "a stale key here would fail every Outpost call after login") + assert.Equal(t, "tm_2", outpost.ProjectID) +} + +func TestRefreshCachedAPIClientHandlesUnbuiltClients(t *testing.T) { + ResetAPIClientForTesting() + t.Cleanup(ResetAPIClientForTesting) + + cfg := &Config{ + APIBaseURL: "https://api.example.test", + OutpostAPIBaseURL: "https://outpost.example.test", + } + + // Only the gateway client has been built; refreshing must not panic on the + // Outpost one, which is nil until a command asks for it. + _ = cfg.GetAPIClient() + assert.NotPanics(t, cfg.RefreshCachedAPIClient) +} + +func TestResetAPIClientForTestingClearsOutpostClient(t *testing.T) { + ResetAPIClientForTesting() + t.Cleanup(ResetAPIClientForTesting) + + cfg := &Config{OutpostAPIBaseURL: "https://outpost.example.test"} + first := cfg.GetOutpostAPIClient() + + ResetAPIClientForTesting() + + second := cfg.GetOutpostAPIClient() + assert.NotSame(t, first, second, "reset must clear the Outpost singleton, not just the gateway one") +} + +func TestOutpostAPIBaseURLDefaultsAndOverrides(t *testing.T) { + t.Parallel() + + t.Run("falls back to the published Outpost host", func(t *testing.T) { + cfg := newTestConfigForOutpost(t, "", "") + assert.Equal(t, hookdeck.DefaultOutpostAPIBaseURL, cfg.OutpostAPIBaseURL) + }) + + t.Run("a config file value overrides the default", func(t *testing.T) { + cfg := newTestConfigForOutpost(t, "", `outpost_api_base = "https://outpost.from-config.test"`) + assert.Equal(t, "https://outpost.from-config.test", cfg.OutpostAPIBaseURL) + }) + + t.Run("an explicit flag value beats the config file", func(t *testing.T) { + cfg := newTestConfigForOutpost(t, "https://outpost.from-flag.test", `outpost_api_base = "https://outpost.from-config.test"`) + assert.Equal(t, "https://outpost.from-flag.test", cfg.OutpostAPIBaseURL) + }) +} + +// newTestConfigForOutpost resolves a Config through constructConfig, the same +// flags > config file > default chain the real CLI uses. InitConfig is avoided +// here because it terminates the process when LogLevel is unset. +func newTestConfigForOutpost(t *testing.T, outpostBaseFlag, configContents string) *Config { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(configContents+"\n"), 0o600)) + + cfg, err := LoadConfigFromFile(path) + require.NoError(t, err) + + if outpostBaseFlag != "" { + cfg.OutpostAPIBaseURL = outpostBaseFlag + cfg.constructConfig() + } + + return cfg +} diff --git a/pkg/config/project_type.go b/pkg/config/project_type.go index f7838e27..2228a248 100644 --- a/pkg/config/project_type.go +++ b/pkg/config/project_type.go @@ -53,6 +53,17 @@ func IsGatewayProject(typeOrMode string) bool { } } +// IsOutpostProject returns true if the given type or mode represents an Outpost project. +// Unlike IsGatewayProject, Outpost has a single type and mode, so there are no aliases. +func IsOutpostProject(typeOrMode string) bool { + switch typeOrMode { + case ProjectTypeOutpost, "outpost": + return true + default: + return false + } +} + // ProjectTypeToJSON returns the lowercase type for JSON output (gateway, outpost, console). func ProjectTypeToJSON(projectType string) string { switch projectType { diff --git a/pkg/hookdeck/attempts.go b/pkg/hookdeck/attempts.go index 5d50f2c2..3e5b96c0 100644 --- a/pkg/hookdeck/attempts.go +++ b/pkg/hookdeck/attempts.go @@ -9,28 +9,28 @@ import ( // EventAttempt represents a single delivery attempt for an event type EventAttempt struct { - ID string `json:"id"` - TeamID string `json:"team_id"` - EventID string `json:"event_id"` - DestinationID string `json:"destination_id"` - ResponseStatus *int `json:"response_status,omitempty"` - AttemptNumber int `json:"attempt_number"` - Trigger string `json:"trigger"` - ErrorCode *string `json:"error_code,omitempty"` - Body interface{} `json:"body,omitempty"` // API may return string or object - RequestedURL string `json:"requested_url"` - HTTPMethod string `json:"http_method"` - BulkRetryID *string `json:"bulk_retry_id,omitempty"` - Status string `json:"status"` - SuccessfulAt *time.Time `json:"successful_at,omitempty"` - DeliveredAt *time.Time `json:"delivered_at,omitempty"` + ID string `json:"id"` + TeamID string `json:"team_id"` + EventID string `json:"event_id"` + DestinationID string `json:"destination_id"` + ResponseStatus *int `json:"response_status,omitempty"` + AttemptNumber int `json:"attempt_number"` + Trigger string `json:"trigger"` + ErrorCode *string `json:"error_code,omitempty"` + Body interface{} `json:"body,omitempty"` // API may return string or object + RequestedURL string `json:"requested_url"` + HTTPMethod string `json:"http_method"` + BulkRetryID *string `json:"bulk_retry_id,omitempty"` + Status string `json:"status"` + SuccessfulAt *time.Time `json:"successful_at,omitempty"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` } // EventAttemptListResponse is the response from listing attempts (EventAttemptPaginatedResult) type EventAttemptListResponse struct { - Models []EventAttempt `json:"models"` - Pagination PaginationResponse `json:"pagination"` - Count *int `json:"count,omitempty"` + Models []EventAttempt `json:"models"` + Pagination PaginationResponse `json:"pagination"` + Count *int `json:"count,omitempty"` } // ListAttempts retrieves attempts for an event (params: event_id required; order_by, dir, limit, next, prev) diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 88dbdb29..e3234aa1 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -21,6 +21,12 @@ import ( // DefaultAPIBaseURL is the default base URL for API requests const DefaultAPIBaseURL = "https://api.hookdeck.com" +// DefaultOutpostAPIBaseURL is the default base URL for Hookdeck Outpost API +// requests. Outpost is served from its own host, not from DefaultAPIBaseURL, +// but shares the same calendar-versioned path prefix (APIPathPrefix) and the +// same authentication, so the same Client type serves both. +const DefaultOutpostAPIBaseURL = "https://api.outpost.hookdeck.com" + // DefaultDashboardURL is the default base URL for web links const DefaultDashboardURL = "https://dashboard.hookdeck.com" @@ -67,6 +73,14 @@ type Client struct { // rate limiting is expected. SuppressRateLimitErrors bool + // AcceptAnySuccessStatus treats any 2xx as success rather than 200 alone. + // + // The Event Gateway API answers 200 to every successful request, so the + // default keeps that stricter check. The Outpost API uses the full range — + // 201 when a resource is created, 202 when a publish or retry is accepted, + // 204 on delete — and reporting those as errors would fail every write. + AcceptAnySuccessStatus bool + // Per-request telemetry override. When non-nil, this is used instead of // the global telemetry singleton. Used by MCP tool handlers to set // per-invocation context. @@ -92,6 +106,7 @@ func (c *Client) WithTelemetry(t *CLITelemetry) *Client { ProjectName: c.ProjectName, Verbose: c.Verbose, SuppressRateLimitErrors: c.SuppressRateLimitErrors, + AcceptAnySuccessStatus: c.AcceptAnySuccessStatus, Telemetry: t, TelemetryDisabled: c.TelemetryDisabled, httpClient: c.httpClient, @@ -215,7 +230,7 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R return nil, err } - err = checkAndPrintError(resp) + err = c.checkResponseStatus(resp) if err != nil { // Allow callers to suppress rate limit error logging for polling scenarios if c.SuppressRateLimitErrors && resp.StatusCode == http.StatusTooManyRequests { @@ -310,6 +325,16 @@ func (c *Client) Put(ctx context.Context, path string, data []byte, configure fu return c.PerformRequest(ctx, req) } +// checkResponseStatus applies the client's success-status policy. It exists so +// AcceptAnySuccessStatus can widen what counts as success without changing +// checkAndPrintError, which other callers still use directly. +func (c *Client) checkResponseStatus(res *http.Response) error { + if c.AcceptAnySuccessStatus && res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + return checkAndPrintError(res) +} + func checkAndPrintError(res *http.Response) error { if res.StatusCode != http.StatusOK { if res.Body != nil { diff --git a/pkg/hookdeck/destinations.go b/pkg/hookdeck/destinations.go index 066562c3..c80a9bc1 100644 --- a/pkg/hookdeck/destinations.go +++ b/pkg/hookdeck/destinations.go @@ -266,8 +266,8 @@ type DestinationUpdateRequest struct { // DestinationListResponse represents the response from listing destinations type DestinationListResponse struct { - Models []Destination `json:"models"` - Pagination PaginationResponse `json:"pagination"` + Models []Destination `json:"models"` + Pagination PaginationResponse `json:"pagination"` } // DestinationCountResponse represents the response from counting destinations diff --git a/pkg/hookdeck/events.go b/pkg/hookdeck/events.go index 7cd31b8e..8f2c1f28 100644 --- a/pkg/hookdeck/events.go +++ b/pkg/hookdeck/events.go @@ -40,8 +40,8 @@ type EventData struct { // EventListResponse is the response from listing events type EventListResponse struct { - Models []Event `json:"models"` - Pagination PaginationResponse `json:"pagination"` + Models []Event `json:"models"` + Pagination PaginationResponse `json:"pagination"` } // ListEvents retrieves events with optional filters (params: webhook_id, status, source_id, destination_id, limit, order_by, dir, next, prev, etc.) diff --git a/pkg/hookdeck/outpost.go b/pkg/hookdeck/outpost.go new file mode 100644 index 00000000..1453c225 --- /dev/null +++ b/pkg/hookdeck/outpost.go @@ -0,0 +1,103 @@ +package hookdeck + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" +) + +// The Outpost API is served from its own host (see DefaultOutpostAPIBaseURL) +// but shares the Hookdeck API's calendar version prefix, so paths are built +// with APIPathPrefix exactly as the Event Gateway resources are. + +// OutpostTopicsWildcard is the value meaning "all topics". +const OutpostTopicsWildcard = "*" + +// OutpostTopics is a destination's `topics` field. The API represents it as +// either the bare string "*" or an array of topic strings, so decoding it into +// a plain []string fails whenever a destination subscribes to everything. +// +// Individual entries may themselves contain "*" as a wildcard (e.g. "user.*"), +// which is why the wildcard is not modelled as a separate flag. +type OutpostTopics []string + +// UnmarshalJSON accepts both representations, normalising "*" to a single-element +// slice so callers only deal with one shape. +func (t *OutpostTopics) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + *t = OutpostTopics{single} + return nil + } + + var list []string + if err := json.Unmarshal(data, &list); err != nil { + return fmt.Errorf(`topics must be "*" or an array of strings: %w`, err) + } + *t = OutpostTopics(list) + return nil +} + +// MarshalJSON emits the wildcard in the canonical bare-string form the API +// documents, and everything else as an array. +func (t OutpostTopics) MarshalJSON() ([]byte, error) { + if len(t) == 1 && t[0] == OutpostTopicsWildcard { + return json.Marshal(OutpostTopicsWildcard) + } + return json.Marshal([]string(t)) +} + +// IsWildcard reports whether the destination subscribes to every topic. +func (t OutpostTopics) IsWildcard() bool { + return len(t) == 1 && t[0] == OutpostTopicsWildcard +} + +// outpostQuery builds an Outpost API query string. +// +// Scalar params are added as-is; that includes the API's bracketed filter keys +// (e.g. "time[gte]"), which callers pass through verbatim. Repeated values use +// indexed bracket notation — id[0]=a&id[1]=b — which is what the Outpost API +// expects; repeating the bare key is not equivalent. +func outpostQuery(params map[string]string, lists map[string][]string) string { + values := url.Values{} + for k, v := range params { + if v == "" { + continue + } + values.Add(k, v) + } + for key, list := range lists { + for i, v := range list { + if v == "" { + continue + } + values.Add(key+"["+strconv.Itoa(i)+"]", v) + } + } + return values.Encode() +} + +// outpostPath escapes a caller-supplied path segment. Tenant IDs in particular +// are chosen by the operator rather than generated by the API, so they can +// contain characters that would otherwise change the path. +func outpostPath(segments ...string) string { + parts := make([]string, 0, len(segments)+1) + parts = append(parts, APIPathPrefix) + for _, s := range segments { + parts = append(parts, url.PathEscape(s)) + } + return strings.Join(parts, "/") +} + +// setOutpostTimeRange adds the API's comparison-operator filters for a time +// field. Empty bounds are skipped, so a caller can set either end or both. +func setOutpostTimeRange(params map[string]string, field, after, before string) { + if after != "" { + params[field+"[gte]"] = after + } + if before != "" { + params[field+"[lte]"] = before + } +} diff --git a/pkg/hookdeck/outpost_attempts.go b/pkg/hookdeck/outpost_attempts.go new file mode 100644 index 00000000..1b1b0161 --- /dev/null +++ b/pkg/hookdeck/outpost_attempts.go @@ -0,0 +1,153 @@ +package hookdeck + +import ( + "context" + "fmt" + "time" +) + +// Attempt status values returned by the API. +const ( + OutpostAttemptStatusSuccess = "success" + OutpostAttemptStatusFailed = "failed" +) + +// OutpostAttempt represents a single delivery attempt of an event to a +// destination. +// +// Event and Destination are only populated when requested through the API's +// include parameter; otherwise they are nil. +type OutpostAttempt struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + EventID string `json:"event_id"` + DestinationID string `json:"destination_id"` + Status string `json:"status"` + Code string `json:"code"` + AttemptNumber int `json:"attempt_number"` + Manual bool `json:"manual"` + Time time.Time `json:"time"` + ResponseData map[string]interface{} `json:"response_data,omitempty"` + Event *OutpostEvent `json:"event,omitempty"` + Destination *OutpostDestination `json:"destination,omitempty"` +} + +// Succeeded reports whether the attempt was delivered successfully. +func (a *OutpostAttempt) Succeeded() bool { + return a != nil && a.Status == OutpostAttemptStatusSuccess +} + +// OutpostAttemptListResponse is the paginated response from listing attempts. +type OutpostAttemptListResponse struct { + Models []OutpostAttempt `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// OutpostAttemptListParams are the filters accepted by ListOutpostAttempts. +// +// The API exposes attempts both globally and scoped to a tenant's destination. +// When both TenantID and DestinationID are set, the tenant-scoped endpoint is +// used; the filters and response shape are the same either way. +type OutpostAttemptListParams struct { + TenantID string + DestinationID string + TenantIDs []string + EventIDs []string + DestinationIDs []string + DestinationType []string + Topics []string + Status string + TimeAfter string + TimeBefore string + Include []string + Limit int + OrderBy string + Dir string + Next string + Prev string +} + +// usesTenantScopedPath reports whether the request can address the +// tenant-scoped attempts endpoint. +func (p OutpostAttemptListParams) usesTenantScopedPath() bool { + return p.TenantID != "" && p.DestinationID != "" +} + +// ListOutpostAttempts retrieves a page of delivery attempts. +func (c *Client) ListOutpostAttempts(ctx context.Context, params OutpostAttemptListParams) (*OutpostAttemptListResponse, error) { + scalar := map[string]string{ + "status": params.Status, + "order_by": params.OrderBy, + "dir": params.Dir, + "next": params.Next, + "prev": params.Prev, + } + if params.Limit > 0 { + scalar["limit"] = fmt.Sprintf("%d", params.Limit) + } + setOutpostTimeRange(scalar, "time", params.TimeAfter, params.TimeBefore) + + lists := map[string][]string{ + "event_id": params.EventIDs, + "topic": params.Topics, + "include": params.Include, + } + + path := APIPathPrefix + "/attempts" + if params.usesTenantScopedPath() { + path = outpostPath("tenants", params.TenantID, "destinations", params.DestinationID, "attempts") + } else { + // These filters are only meaningful on the global endpoint — the + // tenant-scoped one already constrains both dimensions via the path. + lists["tenant_id"] = params.TenantIDs + lists["destination_id"] = params.DestinationIDs + lists["destination_type"] = params.DestinationType + } + + resp, err := c.Get(ctx, path, outpostQuery(scalar, lists), nil) + if err != nil { + return nil, err + } + + var result OutpostAttemptListResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse attempt list response: %w", err) + } + + return &result, nil +} + +// OutpostAttemptGetParams are the options accepted by GetOutpostAttempt. +type OutpostAttemptGetParams struct { + TenantID string + DestinationID string + Include []string +} + +// GetOutpostAttempt retrieves a single delivery attempt. As with the list +// endpoint, supplying both TenantID and DestinationID uses the tenant-scoped +// route. +func (c *Client) GetOutpostAttempt(ctx context.Context, attemptID string, params OutpostAttemptGetParams) (*OutpostAttempt, error) { + scalar := map[string]string{} + path := outpostPath("attempts", attemptID) + + if params.TenantID != "" && params.DestinationID != "" { + path = outpostPath("tenants", params.TenantID, "destinations", params.DestinationID, "attempts", attemptID) + } else { + scalar["tenant_id"] = params.TenantID + } + + query := outpostQuery(scalar, map[string][]string{"include": params.Include}) + + resp, err := c.Get(ctx, path, query, nil) + if err != nil { + return nil, err + } + + var attempt OutpostAttempt + if _, err := postprocessJsonResponse(resp, &attempt); err != nil { + return nil, fmt.Errorf("failed to parse attempt response: %w", err) + } + + return &attempt, nil +} diff --git a/pkg/hookdeck/outpost_config.go b/pkg/hookdeck/outpost_config.go new file mode 100644 index 00000000..3f3b51ba --- /dev/null +++ b/pkg/hookdeck/outpost_config.go @@ -0,0 +1,148 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// OutpostManagedConfig is the operator configuration for a project. +// +// The API models every value as a string, and a null clears a value back to its +// default, so this is a flat map rather than a struct: the key set is large and +// evolves independently of the CLI. Using a map means a newly added key works +// without a CLI release. +type OutpostManagedConfig map[string]*string + +// OutpostDeploymentStatus reports the state of a project's Outpost deployment. +type OutpostDeploymentStatus struct { + Status string `json:"status"` + Version string `json:"version,omitempty"` + PortalHostname string `json:"portal_hostname,omitempty"` +} + +// OutpostCustomDomain describes the custom hostname serving a project's tenant +// portal. +type OutpostCustomDomain struct { + Hostname string `json:"hostname,omitempty"` + Status string `json:"status,omitempty"` + // Verification carries provider-specific DNS records to add. Its shape is + // determined by the DNS provider, so it is left untyped. + Verification []map[string]interface{} `json:"verification,omitempty"` +} + +// GetOutpostConfig retrieves the project's operator configuration. +func (c *Client) GetOutpostConfig(ctx context.Context) (OutpostManagedConfig, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/config", "", nil) + if err != nil { + return nil, err + } + + var config OutpostManagedConfig + if _, err := postprocessJsonResponse(resp, &config); err != nil { + return nil, fmt.Errorf("failed to parse config response: %w", err) + } + + return config, nil +} + +// UpdateOutpostConfig applies a partial update to the operator configuration. +// +// Only the supplied keys are changed. A nil value clears a key back to its +// default; some keys instead accept an empty string to turn a behaviour off, +// which the API documents per key. +func (c *Client) UpdateOutpostConfig(ctx context.Context, update OutpostManagedConfig) (OutpostManagedConfig, error) { + if len(update) == 0 { + return nil, fmt.Errorf("no configuration values to update") + } + + data, err := json.Marshal(update) + if err != nil { + return nil, fmt.Errorf("failed to marshal config update: %w", err) + } + + req, err := c.newRequest(ctx, http.MethodPatch, APIPathPrefix+"/config", data) + if err != nil { + return nil, err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return nil, err + } + + var config OutpostManagedConfig + if _, err := postprocessJsonResponse(resp, &config); err != nil { + return nil, fmt.Errorf("failed to parse config response: %w", err) + } + + return config, nil +} + +// GetOutpostStatus retrieves the deployment status for the project. +func (c *Client) GetOutpostStatus(ctx context.Context) (*OutpostDeploymentStatus, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/status", "", nil) + if err != nil { + return nil, err + } + + var status OutpostDeploymentStatus + if _, err := postprocessJsonResponse(resp, &status); err != nil { + return nil, fmt.Errorf("failed to parse status response: %w", err) + } + + return &status, nil +} + +// GetOutpostCustomDomain retrieves the tenant portal's custom domain, if one is +// configured. +func (c *Client) GetOutpostCustomDomain(ctx context.Context) (*OutpostCustomDomain, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/config/custom_domain", "", nil) + if err != nil { + return nil, err + } + + var domain OutpostCustomDomain + if _, err := postprocessJsonResponse(resp, &domain); err != nil { + return nil, fmt.Errorf("failed to parse custom domain response: %w", err) + } + + return &domain, nil +} + +// AddOutpostCustomDomain configures a custom hostname for the tenant portal. +func (c *Client) AddOutpostCustomDomain(ctx context.Context, hostname string) (*OutpostCustomDomain, error) { + data, err := json.Marshal(map[string]string{"hostname": hostname}) + if err != nil { + return nil, fmt.Errorf("failed to marshal custom domain request: %w", err) + } + + resp, err := c.Post(ctx, APIPathPrefix+"/config/custom_domain", data, nil) + if err != nil { + return nil, err + } + + var domain OutpostCustomDomain + if _, err := postprocessJsonResponse(resp, &domain); err != nil { + return nil, fmt.Errorf("failed to parse custom domain response: %w", err) + } + + return &domain, nil +} + +// DeleteOutpostCustomDomain removes the tenant portal's custom domain. +func (c *Client) DeleteOutpostCustomDomain(ctx context.Context) error { + req, err := c.newRequest(ctx, "DELETE", APIPathPrefix+"/config/custom_domain", nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/pkg/hookdeck/outpost_destination_types.go b/pkg/hookdeck/outpost_destination_types.go new file mode 100644 index 00000000..896b8fe4 --- /dev/null +++ b/pkg/hookdeck/outpost_destination_types.go @@ -0,0 +1,111 @@ +package hookdeck + +import ( + "context" + "fmt" +) + +// OutpostDestinationTypeOption is one choice for a select field. +// +// Label is for display; Value is what the API expects to be sent. +type OutpostDestinationTypeOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// OutpostDestinationTypeField describes one configurable field of a destination +// type. The API returns these so clients can build and validate input without +// hardcoding a schema per type. +type OutpostDestinationTypeField struct { + Key string `json:"key"` + Type string `json:"type"` // text, checkbox, key_value_map, select + Label string `json:"label"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + Default string `json:"default,omitempty"` + MinLength int `json:"minlength,omitempty"` + MaxLength int `json:"maxlength,omitempty"` + Pattern string `json:"pattern,omitempty"` + Options []OutpostDestinationTypeOption `json:"options,omitempty"` + + Description string `json:"description,omitempty"` +} + +// OptionValues returns the accepted values for a select field, for validation +// and error messages. +func (f OutpostDestinationTypeField) OptionValues() []string { + values := make([]string, 0, len(f.Options)) + for _, option := range f.Options { + values = append(values, option.Value) + } + return values +} + +// OutpostDestinationTypeSetupLink points at provider documentation for a type. +type OutpostDestinationTypeSetupLink struct { + Href string `json:"href,omitempty"` + CTA string `json:"cta,omitempty"` +} + +// OutpostDestinationTypeSchema is the full schema for one destination type. +// +// Icon and Instructions are intended for rendering a setup UI and are large, so +// CLI output should generally omit them. +type OutpostDestinationTypeSchema struct { + Type string `json:"type"` + Label string `json:"label"` + Description string `json:"description"` + Icon string `json:"icon,omitempty"` + Instructions string `json:"instructions,omitempty"` + SetupLink OutpostDestinationTypeSetupLink `json:"setup_link,omitempty"` + ConfigFields []OutpostDestinationTypeField `json:"config_fields"` + CredentialFields []OutpostDestinationTypeField `json:"credential_fields"` +} + +// ListOutpostDestinationTypes returns the schemas for every available +// destination type. The endpoint is not paginated. +func (c *Client) ListOutpostDestinationTypes(ctx context.Context) ([]OutpostDestinationTypeSchema, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/destination-types", "", nil) + if err != nil { + return nil, err + } + + var schemas []OutpostDestinationTypeSchema + if _, err := postprocessJsonResponse(resp, &schemas); err != nil { + return nil, fmt.Errorf("failed to parse destination type list response: %w", err) + } + + return schemas, nil +} + +// GetOutpostDestinationType returns the schema for a single destination type. +func (c *Client) GetOutpostDestinationType(ctx context.Context, destinationType string) (*OutpostDestinationTypeSchema, error) { + resp, err := c.Get(ctx, outpostPath("destination-types", destinationType), "", nil) + if err != nil { + return nil, err + } + + var schema OutpostDestinationTypeSchema + if _, err := postprocessJsonResponse(resp, &schema); err != nil { + return nil, fmt.Errorf("failed to parse destination type response: %w", err) + } + + return &schema, nil +} + +// ListOutpostTopics returns the topics configured for the project. Topics are +// operator configuration, so there is no create endpoint — they are set through +// the managed config. +func (c *Client) ListOutpostTopics(ctx context.Context) ([]string, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/topics", "", nil) + if err != nil { + return nil, err + } + + var topics []string + if _, err := postprocessJsonResponse(resp, &topics); err != nil { + return nil, fmt.Errorf("failed to parse topic list response: %w", err) + } + + return topics, nil +} diff --git a/pkg/hookdeck/outpost_destinations.go b/pkg/hookdeck/outpost_destinations.go new file mode 100644 index 00000000..e176908c --- /dev/null +++ b/pkg/hookdeck/outpost_destinations.go @@ -0,0 +1,184 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// OutpostDestination represents a delivery destination belonging to a tenant. +// +// Config and Credentials are type-specific, so they stay untyped here and are +// validated against the schemas returned by ListOutpostDestinationTypes rather +// than against hand-written per-type structs. +type OutpostDestination struct { + ID string `json:"id"` + Type string `json:"type"` + Topics OutpostTopics `json:"topics"` + Config map[string]interface{} `json:"config"` + Credentials map[string]interface{} `json:"credentials"` + Filter map[string]interface{} `json:"filter,omitempty"` + DeliveryMetadata map[string]string `json:"delivery_metadata,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Target string `json:"target,omitempty"` + TargetURL string `json:"target_url,omitempty"` + DisabledAt *time.Time `json:"disabled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Disabled reports whether the destination is currently disabled. +func (d *OutpostDestination) Disabled() bool { + return d != nil && d.DisabledAt != nil +} + +// OutpostDestinationCreateRequest is the body for creating a destination. +// Type and Config are required; the rest depend on the destination type. +type OutpostDestinationCreateRequest struct { + Type string `json:"type"` + Topics OutpostTopics `json:"topics,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Credentials map[string]interface{} `json:"credentials,omitempty"` + Filter map[string]interface{} `json:"filter,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// OutpostDestinationUpdateRequest is the body for updating a destination. +// +// The endpoint applies JSON merge-patch semantics, so omitted fields are left +// alone — hence omitempty on everything. Filter is the exception: the API +// replaces it wholesale rather than merging into it. +type OutpostDestinationUpdateRequest struct { + Topics OutpostTopics `json:"topics,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Credentials map[string]interface{} `json:"credentials,omitempty"` + Filter map[string]interface{} `json:"filter,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ListOutpostDestinations retrieves a tenant's destinations, optionally filtered +// by type and topic. +// +// This endpoint is not paginated: it returns a bare JSON array rather than the +// {models, pagination} envelope used elsewhere in this package. +func (c *Client) ListOutpostDestinations(ctx context.Context, tenantID string, types, topics []string) ([]OutpostDestination, error) { + query := outpostQuery(nil, map[string][]string{ + "type": types, + "topics": topics, + }) + + resp, err := c.Get(ctx, outpostPath("tenants", tenantID, "destinations"), query, nil) + if err != nil { + return nil, err + } + + var destinations []OutpostDestination + if _, err := postprocessJsonResponse(resp, &destinations); err != nil { + return nil, fmt.Errorf("failed to parse destination list response: %w", err) + } + + return destinations, nil +} + +// GetOutpostDestination retrieves a single destination. +func (c *Client) GetOutpostDestination(ctx context.Context, tenantID, destinationID string) (*OutpostDestination, error) { + resp, err := c.Get(ctx, outpostPath("tenants", tenantID, "destinations", destinationID), "", nil) + if err != nil { + return nil, err + } + + var destination OutpostDestination + if _, err := postprocessJsonResponse(resp, &destination); err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// CreateOutpostDestination creates a destination for a tenant. +func (c *Client) CreateOutpostDestination(ctx context.Context, tenantID string, req *OutpostDestinationCreateRequest) (*OutpostDestination, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal destination create request: %w", err) + } + + resp, err := c.Post(ctx, outpostPath("tenants", tenantID, "destinations"), data, nil) + if err != nil { + return nil, err + } + + var destination OutpostDestination + if _, err := postprocessJsonResponse(resp, &destination); err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// UpdateOutpostDestination applies a partial update to a destination. +func (c *Client) UpdateOutpostDestination(ctx context.Context, tenantID, destinationID string, req *OutpostDestinationUpdateRequest) (*OutpostDestination, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal destination update request: %w", err) + } + + // The API uses PATCH here rather than PUT, so this goes through newRequest + // instead of the Put helper. + httpReq, err := c.newRequest(ctx, "PATCH", outpostPath("tenants", tenantID, "destinations", destinationID), data) + if err != nil { + return nil, err + } + + resp, err := c.PerformRequest(ctx, httpReq) + if err != nil { + return nil, err + } + + var destination OutpostDestination + if _, err := postprocessJsonResponse(resp, &destination); err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// DeleteOutpostDestination deletes a destination. +func (c *Client) DeleteOutpostDestination(ctx context.Context, tenantID, destinationID string) error { + req, err := c.newRequest(ctx, "DELETE", outpostPath("tenants", tenantID, "destinations", destinationID), nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// EnableOutpostDestination re-enables a disabled destination. +func (c *Client) EnableOutpostDestination(ctx context.Context, tenantID, destinationID string) (*OutpostDestination, error) { + return c.setOutpostDestinationEnabled(ctx, tenantID, destinationID, "enable") +} + +// DisableOutpostDestination stops delivery to a destination without deleting it. +func (c *Client) DisableOutpostDestination(ctx context.Context, tenantID, destinationID string) (*OutpostDestination, error) { + return c.setOutpostDestinationEnabled(ctx, tenantID, destinationID, "disable") +} + +func (c *Client) setOutpostDestinationEnabled(ctx context.Context, tenantID, destinationID, action string) (*OutpostDestination, error) { + resp, err := c.Put(ctx, outpostPath("tenants", tenantID, "destinations", destinationID, action), []byte("{}"), nil) + if err != nil { + return nil, err + } + + var destination OutpostDestination + if _, err := postprocessJsonResponse(resp, &destination); err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} diff --git a/pkg/hookdeck/outpost_events.go b/pkg/hookdeck/outpost_events.go new file mode 100644 index 00000000..831ad4a8 --- /dev/null +++ b/pkg/hookdeck/outpost_events.go @@ -0,0 +1,129 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// OutpostEvent represents a published event. Events are created through Publish +// rather than a create endpoint, so this type is read-only. +type OutpostEvent struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Topic string `json:"topic"` + MatchedDestinationIDs []string `json:"matched_destination_ids"` + Time time.Time `json:"time"` + EligibleForRetry *bool `json:"eligible_for_retry,omitempty"` + Metadata map[string]string `json:"metadata"` + Data map[string]interface{} `json:"data"` +} + +// OutpostEventListResponse is the paginated response from listing events. +type OutpostEventListResponse struct { + Models []OutpostEvent `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// OutpostEventListParams are the filters accepted by ListOutpostEvents. +// +// TimeAfter and TimeBefore are ISO 8601 datetimes and map to the API's +// time[gte] / time[lte] comparison filters. +type OutpostEventListParams struct { + IDs []string + TenantIDs []string + DestinationIDs []string + Topics []string + TimeAfter string + TimeBefore string + Limit int + OrderBy string + Dir string + Next string + Prev string +} + +// ListOutpostEvents retrieves a page of events. +func (c *Client) ListOutpostEvents(ctx context.Context, params OutpostEventListParams) (*OutpostEventListResponse, error) { + scalar := map[string]string{ + "order_by": params.OrderBy, + "dir": params.Dir, + "next": params.Next, + "prev": params.Prev, + } + if params.Limit > 0 { + scalar["limit"] = fmt.Sprintf("%d", params.Limit) + } + setOutpostTimeRange(scalar, "time", params.TimeAfter, params.TimeBefore) + + query := outpostQuery(scalar, map[string][]string{ + "id": params.IDs, + "tenant_id": params.TenantIDs, + "destination_id": params.DestinationIDs, + "topic": params.Topics, + }) + + resp, err := c.Get(ctx, APIPathPrefix+"/events", query, nil) + if err != nil { + return nil, err + } + + var result OutpostEventListResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse event list response: %w", err) + } + + return &result, nil +} + +// GetOutpostEvent retrieves a single event. tenantID is optional. +func (c *Client) GetOutpostEvent(ctx context.Context, eventID, tenantID string) (*OutpostEvent, error) { + query := outpostQuery(map[string]string{"tenant_id": tenantID}, nil) + + resp, err := c.Get(ctx, outpostPath("events", eventID), query, nil) + if err != nil { + return nil, err + } + + var event OutpostEvent + if _, err := postprocessJsonResponse(resp, &event); err != nil { + return nil, fmt.Errorf("failed to parse event response: %w", err) + } + + return &event, nil +} + +// OutpostRetryRequest is the body for retrying delivery of an event to a +// destination. +type OutpostRetryRequest struct { + EventID string `json:"event_id"` + DestinationID string `json:"destination_id"` +} + +// OutpostRetryResponse is the acknowledgement returned by a retry. +type OutpostRetryResponse struct { + Success bool `json:"success"` +} + +// RetryOutpostEvent asks the API to deliver an event to a destination again. +// The retry is queued rather than performed inline, so a successful response +// means accepted, not delivered. +func (c *Client) RetryOutpostEvent(ctx context.Context, req *OutpostRetryRequest) (*OutpostRetryResponse, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal retry request: %w", err) + } + + resp, err := c.Post(ctx, APIPathPrefix+"/retry", data, nil) + if err != nil { + return nil, err + } + + var result OutpostRetryResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse retry response: %w", err) + } + + return &result, nil +} diff --git a/pkg/hookdeck/outpost_metrics.go b/pkg/hookdeck/outpost_metrics.go new file mode 100644 index 00000000..363e325f --- /dev/null +++ b/pkg/hookdeck/outpost_metrics.go @@ -0,0 +1,95 @@ +package hookdeck + +import ( + "context" + "fmt" + "time" +) + +// OutpostMetricsDataPoint is one aggregated row of a metrics query. +// +// TimeBucket is absent when the query specified no granularity, and Dimensions +// is empty when no grouping was requested. +type OutpostMetricsDataPoint struct { + TimeBucket *time.Time `json:"time_bucket,omitempty"` + Dimensions map[string]string `json:"dimensions"` + Metrics map[string]interface{} `json:"metrics"` +} + +// OutpostMetricsMetadata describes how a metrics query was executed. +// +// Truncated reports that the row limit was hit, meaning the data is incomplete +// and should not be presented as a full picture. +type OutpostMetricsMetadata struct { + Granularity string `json:"granularity,omitempty"` + QueryTimeMS int `json:"query_time_ms"` + RowCount int `json:"row_count"` + RowLimit int `json:"row_limit"` + Truncated bool `json:"truncated"` +} + +// OutpostMetricsResponse is the response from a metrics query. +type OutpostMetricsResponse struct { + Data []OutpostMetricsDataPoint `json:"data"` + Metadata OutpostMetricsMetadata `json:"metadata"` +} + +// OutpostMetricsParams are the inputs to a metrics query. +// +// Start, End and Measures are required by the API. Filters holds the API's +// filters[] parameters, keyed by dimension name. +type OutpostMetricsParams struct { + Start string + End string + Granularity string + Measures []string + Dimensions []string + Filters map[string][]string +} + +// GetOutpostEventMetrics returns aggregated event publish metrics. +// Supported measures are count and rate. +func (c *Client) GetOutpostEventMetrics(ctx context.Context, params OutpostMetricsParams) (*OutpostMetricsResponse, error) { + return c.getOutpostMetrics(ctx, "events", params) +} + +// GetOutpostAttemptMetrics returns aggregated delivery attempt metrics, such as +// counts, success and failure rates, and retry breakdowns. +func (c *Client) GetOutpostAttemptMetrics(ctx context.Context, params OutpostMetricsParams) (*OutpostMetricsResponse, error) { + return c.getOutpostMetrics(ctx, "attempts", params) +} + +func (c *Client) getOutpostMetrics(ctx context.Context, resource string, params OutpostMetricsParams) (*OutpostMetricsResponse, error) { + if params.Start == "" || params.End == "" { + return nil, fmt.Errorf("start and end are required for a metrics query") + } + if len(params.Measures) == 0 { + return nil, fmt.Errorf("at least one measure is required for a metrics query") + } + + scalar := map[string]string{ + "time[start]": params.Start, + "time[end]": params.End, + "granularity": params.Granularity, + } + + lists := map[string][]string{ + "measures": params.Measures, + "dimensions": params.Dimensions, + } + for dimension, values := range params.Filters { + lists["filters["+dimension+"]"] = values + } + + resp, err := c.Get(ctx, APIPathPrefix+"/metrics/"+resource, outpostQuery(scalar, lists), nil) + if err != nil { + return nil, err + } + + var result OutpostMetricsResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse %s metrics response: %w", resource, err) + } + + return &result, nil +} diff --git a/pkg/hookdeck/outpost_publish.go b/pkg/hookdeck/outpost_publish.go new file mode 100644 index 00000000..99679ea7 --- /dev/null +++ b/pkg/hookdeck/outpost_publish.go @@ -0,0 +1,84 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// withoutStoredAuth returns a shallow clone with the stored API key cleared, so +// that PerformRequest leaves the Authorization header alone. The underlying +// http.Client and its connection pool are shared. +func (c *Client) withoutStoredAuth() *Client { + clone := *c + clone.APIKey = "" + return &clone +} + +// OutpostPublishRequest is the body for publishing an event. +// +// ID is optional; supplying one makes the publish idempotent, and republishing +// the same ID reports Duplicate rather than creating a second event. +type OutpostPublishRequest struct { + ID string `json:"id,omitempty"` + TenantID string `json:"tenant_id"` + Topic string `json:"topic"` + DestinationID string `json:"destination_id,omitempty"` + EligibleForRetry *bool `json:"eligible_for_retry,omitempty"` + Time *time.Time `json:"time,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` +} + +// OutpostPublishResponse is the acknowledgement returned by a publish. +// +// Publishing is asynchronous, so DestinationIDs records which destinations the +// event matched at publish time — not which have received it. +type OutpostPublishResponse struct { + ID string `json:"id"` + Duplicate bool `json:"duplicate"` + DestinationIDs []string `json:"destination_ids"` +} + +// PublishOutpostEvent publishes an event to a topic. +// +// This endpoint requires a Hookdeck Project API key supplied as a bearer token. +// The CLI key stored by `hookdeck login` is not accepted, which is why apiKey is +// an explicit argument here rather than being taken from the client. +func (c *Client) PublishOutpostEvent(ctx context.Context, apiKey string, req *OutpostPublishRequest) (*OutpostPublishResponse, error) { + if apiKey == "" { + return nil, fmt.Errorf("a Hookdeck Project API key is required to publish") + } + + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal publish request: %w", err) + } + + // PerformRequest applies the client's stored key as basic auth whenever one + // is set, which would overwrite the Authorization header below. Publishing + // therefore goes out through a clone with no stored key, so the bearer token + // is the only credential on the request. Everything else — base URL, project + // scoping, telemetry, the shared HTTP client — is preserved. + publishClient := c.withoutStoredAuth() + + httpReq, err := publishClient.newRequest(ctx, http.MethodPost, APIPathPrefix+"/publish", data) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := publishClient.PerformRequest(ctx, httpReq) + if err != nil { + return nil, err + } + + var result OutpostPublishResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse publish response: %w", err) + } + + return &result, nil +} diff --git a/pkg/hookdeck/outpost_tenants.go b/pkg/hookdeck/outpost_tenants.go new file mode 100644 index 00000000..0f90a064 --- /dev/null +++ b/pkg/hookdeck/outpost_tenants.go @@ -0,0 +1,166 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// OutpostTenant represents a tenant — the end customer destinations belong to. +// Unlike most Hookdeck resources the ID is supplied by the operator rather than +// generated, which is why tenants are created with an idempotent upsert. +type OutpostTenant struct { + ID string `json:"id"` + DestinationsCount int `json:"destinations_count"` + Topics []string `json:"topics"` + Metadata map[string]string `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OutpostTenantListResponse is the paginated response from listing tenants. +type OutpostTenantListResponse struct { + Models []OutpostTenant `json:"models"` + Pagination PaginationResponse `json:"pagination"` + Count int `json:"count"` +} + +// OutpostTenantUpsertRequest is the body for PUT /tenants/{id}. Metadata is the +// only writable field; the ID comes from the path. +type OutpostTenantUpsertRequest struct { + Metadata map[string]string `json:"metadata,omitempty"` +} + +// OutpostTenantToken is a short-lived JWT scoped to a single tenant. +type OutpostTenantToken struct { + Token string `json:"token"` + TenantID string `json:"tenant_id"` +} + +// OutpostTenantPortalURL is a redirect URL granting access to a tenant's portal. +type OutpostTenantPortalURL struct { + RedirectURL string `json:"redirect_url"` + TenantID string `json:"tenant_id"` +} + +// OutpostTenantListParams are the filters accepted by ListOutpostTenants. +type OutpostTenantListParams struct { + IDs []string + Limit int + Dir string + Next string + Prev string +} + +// ListOutpostTenants retrieves a page of tenants. +func (c *Client) ListOutpostTenants(ctx context.Context, params OutpostTenantListParams) (*OutpostTenantListResponse, error) { + scalar := map[string]string{ + "dir": params.Dir, + "next": params.Next, + "prev": params.Prev, + } + if params.Limit > 0 { + scalar["limit"] = fmt.Sprintf("%d", params.Limit) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/tenants", outpostQuery(scalar, map[string][]string{ + "id": params.IDs, + }), nil) + if err != nil { + return nil, err + } + + var result OutpostTenantListResponse + if _, err := postprocessJsonResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse tenant list response: %w", err) + } + + return &result, nil +} + +// GetOutpostTenant retrieves a single tenant by ID. +func (c *Client) GetOutpostTenant(ctx context.Context, tenantID string) (*OutpostTenant, error) { + resp, err := c.Get(ctx, outpostPath("tenants", tenantID), "", nil) + if err != nil { + return nil, err + } + + var tenant OutpostTenant + if _, err := postprocessJsonResponse(resp, &tenant); err != nil { + return nil, fmt.Errorf("failed to parse tenant response: %w", err) + } + + return &tenant, nil +} + +// UpsertOutpostTenant creates a tenant or updates its metadata. The API is +// idempotent, returning 201 on create and 200 on update. +func (c *Client) UpsertOutpostTenant(ctx context.Context, tenantID string, req *OutpostTenantUpsertRequest) (*OutpostTenant, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal tenant upsert request: %w", err) + } + + resp, err := c.Put(ctx, outpostPath("tenants", tenantID), data, nil) + if err != nil { + return nil, err + } + + var tenant OutpostTenant + if _, err := postprocessJsonResponse(resp, &tenant); err != nil { + return nil, fmt.Errorf("failed to parse tenant response: %w", err) + } + + return &tenant, nil +} + +// DeleteOutpostTenant deletes a tenant and everything belonging to it. +func (c *Client) DeleteOutpostTenant(ctx context.Context, tenantID string) error { + req, err := c.newRequest(ctx, "DELETE", outpostPath("tenants", tenantID), nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// GetOutpostTenantToken mints a JWT scoped to the tenant. The token is a +// credential in its own right — it grants access to that tenant's data. +func (c *Client) GetOutpostTenantToken(ctx context.Context, tenantID string) (*OutpostTenantToken, error) { + resp, err := c.Get(ctx, outpostPath("tenants", tenantID, "token"), "", nil) + if err != nil { + return nil, err + } + + var token OutpostTenantToken + if _, err := postprocessJsonResponse(resp, &token); err != nil { + return nil, fmt.Errorf("failed to parse tenant token response: %w", err) + } + + return &token, nil +} + +// GetOutpostTenantPortalURL returns a redirect URL for the tenant's portal. +// theme is optional and accepts "light" or "dark". +func (c *Client) GetOutpostTenantPortalURL(ctx context.Context, tenantID, theme string) (*OutpostTenantPortalURL, error) { + query := outpostQuery(map[string]string{"theme": theme}, nil) + + resp, err := c.Get(ctx, outpostPath("tenants", tenantID, "portal"), query, nil) + if err != nil { + return nil, err + } + + var portal OutpostTenantPortalURL + if _, err := postprocessJsonResponse(resp, &portal); err != nil { + return nil, fmt.Errorf("failed to parse tenant portal response: %w", err) + } + + return &portal, nil +} diff --git a/pkg/hookdeck/outpost_test.go b/pkg/hookdeck/outpost_test.go new file mode 100644 index 00000000..3cccd0a1 --- /dev/null +++ b/pkg/hookdeck/outpost_test.go @@ -0,0 +1,386 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOutpostTopicsUnmarshal(t *testing.T) { + t.Parallel() + + t.Run("wildcard string decodes to a single element", func(t *testing.T) { + var topics OutpostTopics + require.NoError(t, json.Unmarshal([]byte(`"*"`), &topics)) + assert.Equal(t, OutpostTopics{"*"}, topics) + assert.True(t, topics.IsWildcard()) + }) + + t.Run("array decodes verbatim", func(t *testing.T) { + var topics OutpostTopics + require.NoError(t, json.Unmarshal([]byte(`["user.created","order.shipped"]`), &topics)) + assert.Equal(t, OutpostTopics{"user.created", "order.shipped"}, topics) + assert.False(t, topics.IsWildcard()) + }) + + t.Run("array containing a wildcard entry is not the wildcard form", func(t *testing.T) { + var topics OutpostTopics + require.NoError(t, json.Unmarshal([]byte(`["user.*","order.shipped"]`), &topics)) + assert.False(t, topics.IsWildcard()) + }) + + t.Run("a non-string, non-array value is rejected", func(t *testing.T) { + var topics OutpostTopics + err := json.Unmarshal([]byte(`42`), &topics) + require.Error(t, err) + assert.Contains(t, err.Error(), `topics must be "*" or an array of strings`) + }) +} + +func TestOutpostTopicsMarshal(t *testing.T) { + t.Parallel() + + t.Run("wildcard round-trips as a bare string", func(t *testing.T) { + data, err := json.Marshal(OutpostTopics{"*"}) + require.NoError(t, err) + assert.JSONEq(t, `"*"`, string(data)) + }) + + t.Run("multiple topics marshal as an array", func(t *testing.T) { + data, err := json.Marshal(OutpostTopics{"user.created", "order.shipped"}) + require.NoError(t, err) + assert.JSONEq(t, `["user.created","order.shipped"]`, string(data)) + }) + + t.Run("a single non-wildcard topic stays an array", func(t *testing.T) { + data, err := json.Marshal(OutpostTopics{"user.created"}) + require.NoError(t, err) + assert.JSONEq(t, `["user.created"]`, string(data)) + }) +} + +func TestOutpostQuery(t *testing.T) { + t.Parallel() + + t.Run("lists use indexed bracket notation", func(t *testing.T) { + got := outpostQuery(nil, map[string][]string{"id": {"a", "b"}}) + parsed, err := url.ParseQuery(got) + require.NoError(t, err) + assert.Equal(t, []string{"a"}, parsed["id[0]"]) + assert.Equal(t, []string{"b"}, parsed["id[1]"]) + // The bare key must not be used; the API does not read repeated keys. + assert.Empty(t, parsed["id"]) + }) + + t.Run("empty scalars and list entries are omitted", func(t *testing.T) { + got := outpostQuery(map[string]string{"dir": "", "limit": "10"}, map[string][]string{"topic": {"", "user.created"}}) + parsed, err := url.ParseQuery(got) + require.NoError(t, err) + assert.Equal(t, []string{"10"}, parsed["limit"]) + assert.Empty(t, parsed["dir"]) + // The empty entry is skipped, so the surviving value keeps its own index. + assert.Equal(t, []string{"user.created"}, parsed["topic[1]"]) + }) + + t.Run("bracketed scalar keys pass through", func(t *testing.T) { + params := map[string]string{} + setOutpostTimeRange(params, "time", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") + + parsed, err := url.ParseQuery(outpostQuery(params, nil)) + require.NoError(t, err) + assert.Equal(t, []string{"2026-01-01T00:00:00Z"}, parsed["time[gte]"]) + assert.Equal(t, []string{"2026-02-01T00:00:00Z"}, parsed["time[lte]"]) + }) + + t.Run("a one-sided time range only sets that bound", func(t *testing.T) { + params := map[string]string{} + setOutpostTimeRange(params, "time", "", "2026-02-01T00:00:00Z") + assert.NotContains(t, params, "time[gte]") + assert.Contains(t, params, "time[lte]") + }) +} + +func TestOutpostPathEscapesSegments(t *testing.T) { + t.Parallel() + + // Tenant IDs are chosen by the operator, so a slash or space must not be + // able to change which endpoint is addressed. + got := outpostPath("tenants", "acme/prod tenant", "destinations") + assert.Equal(t, APIPathPrefix+"/tenants/acme%2Fprod%20tenant/destinations", got) +} + +func TestListOutpostDestinationsUnpaginated(t *testing.T) { + t.Parallel() + + // This endpoint returns a bare array rather than the {models, pagination} + // envelope the other list endpoints use. + var gotPath, gotQuery string + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":"des_1","type":"webhook","topics":"*","config":{"url":"https://example.com"}}, + {"id":"des_2","type":"aws_sqs","topics":["user.created"],"config":{}} + ]`)) + }) + defer server.Close() + + destinations, err := client.ListOutpostDestinations(context.Background(), "tenant_1", []string{"webhook"}, nil) + require.NoError(t, err) + require.Len(t, destinations, 2) + + assert.Equal(t, APIPathPrefix+"/tenants/tenant_1/destinations", gotPath) + assert.Contains(t, gotQuery, "type%5B0%5D=webhook") + + assert.True(t, destinations[0].Topics.IsWildcard()) + assert.Equal(t, OutpostTopics{"user.created"}, destinations[1].Topics) + assert.False(t, destinations[0].Disabled()) +} + +func TestListOutpostAttemptsRouting(t *testing.T) { + t.Parallel() + + t.Run("uses the tenant-scoped path when tenant and destination are both set", func(t *testing.T) { + var gotPath, gotQuery string + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery + _, _ = w.Write([]byte(`{"models":[],"pagination":{}}`)) + }) + defer server.Close() + + _, err := client.ListOutpostAttempts(context.Background(), OutpostAttemptListParams{ + TenantID: "tenant_1", + DestinationID: "des_1", + EventIDs: []string{"evt_1"}, + }) + require.NoError(t, err) + + assert.Equal(t, APIPathPrefix+"/tenants/tenant_1/destinations/des_1/attempts", gotPath) + assert.Contains(t, gotQuery, "event_id%5B0%5D=evt_1") + // Path already constrains these, so they must not be sent as filters too. + assert.NotContains(t, gotQuery, "tenant_id") + assert.NotContains(t, gotQuery, "destination_id") + }) + + t.Run("uses the global path and sends filters when only one is set", func(t *testing.T) { + var gotPath, gotQuery string + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery + _, _ = w.Write([]byte(`{"models":[],"pagination":{}}`)) + }) + defer server.Close() + + _, err := client.ListOutpostAttempts(context.Background(), OutpostAttemptListParams{ + TenantIDs: []string{"tenant_1"}, + }) + require.NoError(t, err) + + assert.Equal(t, APIPathPrefix+"/attempts", gotPath) + assert.Contains(t, gotQuery, "tenant_id%5B0%5D=tenant_1") + }) +} + +func TestPublishOutpostEventUsesBearerToken(t *testing.T) { + t.Parallel() + + t.Run("sends the supplied project key and not the stored CLI key", func(t *testing.T) { + var gotAuth string + var gotBasicUser string + var hadBasic bool + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotBasicUser, _, hadBasic = r.BasicAuth() + _, _ = w.Write([]byte(`{"id":"evt_1","duplicate":false,"destination_ids":["des_1"]}`)) + }) + defer server.Close() + + // newTestClient sets APIKey, which PerformRequest would otherwise apply + // as basic auth and overwrite the bearer token with. + require.Equal(t, "test-api-key", client.APIKey) + + resp, err := client.PublishOutpostEvent(context.Background(), "project-api-key", &OutpostPublishRequest{ + TenantID: "tenant_1", + Topic: "user.created", + }) + require.NoError(t, err) + + assert.Equal(t, "Bearer project-api-key", gotAuth) + assert.False(t, hadBasic, "stored CLI key must not be sent as basic auth") + assert.Empty(t, gotBasicUser) + assert.Equal(t, "evt_1", resp.ID) + }) + + t.Run("leaves the original client's key intact", func(t *testing.T) { + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":"evt_1"}`)) + }) + defer server.Close() + + _, err := client.PublishOutpostEvent(context.Background(), "project-api-key", &OutpostPublishRequest{ + TenantID: "tenant_1", Topic: "user.created", + }) + require.NoError(t, err) + assert.Equal(t, "test-api-key", client.APIKey, "publish must not mutate the shared client") + }) + + t.Run("fails fast without a key rather than sending an unauthenticated request", func(t *testing.T) { + called := false + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + defer server.Close() + + _, err := client.PublishOutpostEvent(context.Background(), "", &OutpostPublishRequest{ + TenantID: "tenant_1", Topic: "user.created", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Project API key") + assert.False(t, called, "no request should be sent") + }) +} + +func TestOutpostMetricsRequiredParams(t *testing.T) { + t.Parallel() + + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[],"metadata":{}}`)) + }) + defer server.Close() + + t.Run("start and end are required", func(t *testing.T) { + _, err := client.GetOutpostEventMetrics(context.Background(), OutpostMetricsParams{ + Measures: []string{"count"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "start and end are required") + }) + + t.Run("at least one measure is required", func(t *testing.T) { + _, err := client.GetOutpostEventMetrics(context.Background(), OutpostMetricsParams{ + Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one measure") + }) +} + +func TestOutpostMetricsQueryShape(t *testing.T) { + t.Parallel() + + var gotQuery string + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"data":[],"metadata":{"truncated":true}}`)) + }) + defer server.Close() + + result, err := client.GetOutpostAttemptMetrics(context.Background(), OutpostMetricsParams{ + Start: "2026-01-01T00:00:00Z", + End: "2026-02-01T00:00:00Z", + Measures: []string{"count", "failed_count"}, + Dimensions: []string{"destination_id"}, + Filters: map[string][]string{"topic": {"user.created"}}, + }) + require.NoError(t, err) + + parsed, err := url.ParseQuery(gotQuery) + require.NoError(t, err) + assert.Equal(t, []string{"2026-01-01T00:00:00Z"}, parsed["time[start]"]) + assert.Equal(t, []string{"count"}, parsed["measures[0]"]) + assert.Equal(t, []string{"failed_count"}, parsed["measures[1]"]) + assert.Equal(t, []string{"destination_id"}, parsed["dimensions[0]"]) + assert.Equal(t, []string{"user.created"}, parsed["filters[topic][0]"]) + + assert.True(t, result.Metadata.Truncated) +} + +func TestUpdateOutpostConfigRejectsEmptyUpdate(t *testing.T) { + t.Parallel() + + called := false + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + defer server.Close() + + _, err := client.UpdateOutpostConfig(context.Background(), OutpostManagedConfig{}) + require.Error(t, err) + assert.False(t, called, "an empty update must not reach the API") +} + +func TestOutpostConfigSendsNullToClearAKey(t *testing.T) { + t.Parallel() + + var gotBody map[string]interface{} + var gotMethod string + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _, _ = w.Write([]byte(`{"TOPICS":"user.created"}`)) + }) + defer server.Close() + + topics := "user.created" + _, err := client.UpdateOutpostConfig(context.Background(), OutpostManagedConfig{ + "TOPICS": &topics, + "DELIVERY_TIMEOUT_SECONDS": nil, + }) + require.NoError(t, err) + + assert.Equal(t, http.MethodPatch, gotMethod) + assert.Equal(t, "user.created", gotBody["TOPICS"]) + require.Contains(t, gotBody, "DELIVERY_TIMEOUT_SECONDS") + assert.Nil(t, gotBody["DELIVERY_TIMEOUT_SECONDS"], "a nil value must serialise as null, not be dropped") +} + +func TestAcceptAnySuccessStatus(t *testing.T) { + t.Parallel() + + // The Gateway API answers 200 to everything, so the client's default treats + // anything else as an error. Outpost uses 201 on create and 202 on + // publish/retry, which made every write fail until this was opt-in-widened. + for _, status := range []int{http.StatusOK, http.StatusCreated, http.StatusAccepted} { + t.Run(http.StatusText(status), func(t *testing.T) { + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"id":"tenant_1"}`)) + }) + defer server.Close() + client.AcceptAnySuccessStatus = true + + tenant, err := client.UpsertOutpostTenant(context.Background(), "tenant_1", &OutpostTenantUpsertRequest{}) + require.NoError(t, err, "%d must be treated as success", status) + assert.Equal(t, "tenant_1", tenant.ID) + }) + } + + t.Run("errors are still errors", func(t *testing.T) { + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"invalid topic"}`)) + }) + defer server.Close() + client.AcceptAnySuccessStatus = true + + _, err := client.UpsertOutpostTenant(context.Background(), "tenant_1", &OutpostTenantUpsertRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid topic") + }) + + t.Run("default client still rejects a non-200 success", func(t *testing.T) { + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"id":"evt_1"}`)) + }) + defer server.Close() + + // Guards the opt-in: Gateway behaviour must be unchanged. + _, err := client.UpsertOutpostTenant(context.Background(), "tenant_1", &OutpostTenantUpsertRequest{}) + require.Error(t, err) + }) +} diff --git a/pkg/hookdeck/projects_test.go b/pkg/hookdeck/projects_test.go index 4e2f8d74..423c880f 100644 --- a/pkg/hookdeck/projects_test.go +++ b/pkg/hookdeck/projects_test.go @@ -1,43 +1,43 @@ -package hookdeck - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { - var sawTeamHeader bool - var sawProjectHeader bool - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawTeamHeader = r.Header.Get("X-Team-ID") != "" - sawProjectHeader = r.Header.Get("X-Project-ID") != "" - if r.URL.Path != APIPathPrefix+"/teams" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Mode: "inbound"}}) - })) - t.Cleanup(server.Close) - - baseURL, err := url.Parse(server.URL) - require.NoError(t, err) - - client := &Client{ - BaseURL: baseURL, - APIKey: "test_key", - ProjectID: "stale_team_should_not_be_sent", - } - - projects, err := client.ListProjects() - require.NoError(t, err) - require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") - require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") - require.Len(t, projects, 1) -} +package hookdeck + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { + var sawTeamHeader bool + var sawProjectHeader bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawTeamHeader = r.Header.Get("X-Team-ID") != "" + sawProjectHeader = r.Header.Get("X-Project-ID") != "" + if r.URL.Path != APIPathPrefix+"/teams" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Mode: "inbound"}}) + })) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + + client := &Client{ + BaseURL: baseURL, + APIKey: "test_key", + ProjectID: "stale_team_should_not_be_sent", + } + + projects, err := client.ListProjects() + require.NoError(t, err) + require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") + require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") + require.Len(t, projects, 1) +} diff --git a/pkg/hookdeck/request_log_redact.go b/pkg/hookdeck/request_log_redact.go index 62255b06..8b69270a 100644 --- a/pkg/hookdeck/request_log_redact.go +++ b/pkg/hookdeck/request_log_redact.go @@ -1,40 +1,40 @@ -package hookdeck - -import ( - "encoding/json" - "net/http" -) - -func redactHeadersForLog(headers http.Header) http.Header { - if headers == nil { - return nil - } - - redacted := headers.Clone() - if redacted.Get("Authorization") != "" { - redacted.Set("Authorization", "[redacted]") - } - return redacted -} - -func redactRequestBodyForLog(body string) string { - if body == "" { - return body - } - - var parsed map[string]json.RawMessage - if err := json.Unmarshal([]byte(body), &parsed); err != nil { - return body - } - - if _, ok := parsed["guest_api_key"]; !ok { - return body - } - - parsed["guest_api_key"] = json.RawMessage(`"[redacted]"`) - redacted, err := json.Marshal(parsed) - if err != nil { - return body - } - return string(redacted) -} +package hookdeck + +import ( + "encoding/json" + "net/http" +) + +func redactHeadersForLog(headers http.Header) http.Header { + if headers == nil { + return nil + } + + redacted := headers.Clone() + if redacted.Get("Authorization") != "" { + redacted.Set("Authorization", "[redacted]") + } + return redacted +} + +func redactRequestBodyForLog(body string) string { + if body == "" { + return body + } + + var parsed map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + return body + } + + if _, ok := parsed["guest_api_key"]; !ok { + return body + } + + parsed["guest_api_key"] = json.RawMessage(`"[redacted]"`) + redacted, err := json.Marshal(parsed) + if err != nil { + return body + } + return string(redacted) +} diff --git a/pkg/hookdeck/requests.go b/pkg/hookdeck/requests.go index 1a989287..2f2ec410 100644 --- a/pkg/hookdeck/requests.go +++ b/pkg/hookdeck/requests.go @@ -11,19 +11,19 @@ import ( // Request represents a raw inbound webhook received by a source type Request struct { - ID string `json:"id"` - SourceID string `json:"source_id"` - Verified bool `json:"verified"` - RejectionCause *string `json:"rejection_cause,omitempty"` - EventsCount int `json:"events_count"` - CliEventsCount int `json:"cli_events_count"` - IgnoredCount int `json:"ignored_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - IngestedAt *time.Time `json:"ingested_at,omitempty"` - OriginalEventDataID *string `json:"original_event_data_id,omitempty"` - Data *RequestData `json:"data,omitempty"` - TeamID string `json:"team_id"` + ID string `json:"id"` + SourceID string `json:"source_id"` + Verified bool `json:"verified"` + RejectionCause *string `json:"rejection_cause,omitempty"` + EventsCount int `json:"events_count"` + CliEventsCount int `json:"cli_events_count"` + IgnoredCount int `json:"ignored_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IngestedAt *time.Time `json:"ingested_at,omitempty"` + OriginalEventDataID *string `json:"original_event_data_id,omitempty"` + Data *RequestData `json:"data,omitempty"` + TeamID string `json:"team_id"` } // RequestData holds optional request snapshot @@ -36,8 +36,8 @@ type RequestData struct { // RequestListResponse is the response from listing requests type RequestListResponse struct { - Models []Request `json:"models"` - Pagination PaginationResponse `json:"pagination"` + Models []Request `json:"models"` + Pagination PaginationResponse `json:"pagination"` } // RequestRetryRequest is the body for POST /requests/{id}/retry. WebhookIDs limits retry to those connections; omit or empty for all. diff --git a/pkg/hookdeck/sources.go b/pkg/hookdeck/sources.go index 36aee79b..88154cc6 100644 --- a/pkg/hookdeck/sources.go +++ b/pkg/hookdeck/sources.go @@ -51,8 +51,8 @@ type SourceUpdateRequest struct { // SourceListResponse represents the response from listing sources type SourceListResponse struct { - Models []Source `json:"models"` - Pagination PaginationResponse `json:"pagination"` + Models []Source `json:"models"` + Pagination PaginationResponse `json:"pagination"` } // SourceCountResponse represents the response from counting sources diff --git a/pkg/hookdeck/transformations.go b/pkg/hookdeck/transformations.go index 02343d0d..d01f8c20 100644 --- a/pkg/hookdeck/transformations.go +++ b/pkg/hookdeck/transformations.go @@ -10,12 +10,12 @@ import ( // Transformation represents a Hookdeck transformation type Transformation struct { - ID string `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Env map[string]string `json:"env,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Env map[string]string `json:"env,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` } // TransformationCreateRequest is the request body for create and upsert (POST/PUT /transformations). @@ -48,28 +48,28 @@ type TransformationCountResponse struct { // TransformationRunRequest is the request body for PUT /transformations/run. // Either Code or TransformationID must be set. Request.Headers is required (can be empty object). type TransformationRunRequest struct { - Code string `json:"code,omitempty"` - TransformationID string `json:"transformation_id,omitempty"` - WebhookID string `json:"webhook_id,omitempty"` - Env map[string]string `json:"env,omitempty"` + Code string `json:"code,omitempty"` + TransformationID string `json:"transformation_id,omitempty"` + WebhookID string `json:"webhook_id,omitempty"` + Env map[string]string `json:"env,omitempty"` Request *TransformationRunRequestInput `json:"request,omitempty"` } // TransformationRunRequestInput is the "request" object for run (required headers; optional body, path, query). type TransformationRunRequestInput struct { - Headers map[string]string `json:"headers"` - Body interface{} `json:"body,omitempty"` - Path string `json:"path,omitempty"` - Query string `json:"query,omitempty"` + Headers map[string]string `json:"headers"` + Body interface{} `json:"body,omitempty"` + Path string `json:"path,omitempty"` + Query string `json:"query,omitempty"` ParsedQuery map[string]interface{} `json:"parsed_query,omitempty"` } // TransformationRunResponse is the response from PUT /transformations/run. // Matches OpenAPI schema TransformationExecutorOutput. type TransformationRunResponse struct { - RequestID string `json:"request_id,omitempty"` - TransformationID string `json:"transformation_id,omitempty"` - ExecutionID string `json:"execution_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + TransformationID string `json:"transformation_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` Request *TransformationRunRequestInput `json:"request,omitempty"` } diff --git a/test/acceptance/outpost_live_test.go b/test/acceptance/outpost_live_test.go new file mode 100644 index 00000000..fa43b5fc --- /dev/null +++ b/test/acceptance/outpost_live_test.go @@ -0,0 +1,456 @@ +//go:build outpostlive + +// Live, read-only smoke test for the Outpost API client (pkg/hookdeck/outpost_*.go). +// +// Why this exists separately from the `outpost` acceptance tag: the unit tests for +// this client run against stub servers, so they only assert the implementation's +// own assumptions back at it. Nothing there proves a real request is accepted, that +// real payloads decode, or that the credentials work against the Outpost host at +// all. This file makes real requests and asserts responses decode. +// +// It is read-only on purpose — no creates, deletes, config changes or publishes — +// so it is safe to run against any Outpost project. Write coverage belongs in the +// `outpost` acceptance slice, where cleanup is handled. +// +// Run: +// +// go test -tags=outpostlive ./test/acceptance/... -run Live -v +// +// Requires HOOKDECK_CLI_OUTPOST_TESTING_API_KEY (a Project API key for an Outpost +// project) in test/acceptance/.env or the environment. +package acceptance + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +const outpostLiveKeyEnv = "HOOKDECK_CLI_OUTPOST_TESTING_API_KEY" + +func outpostLiveAPIKey(t *testing.T) string { + t.Helper() + + key := os.Getenv(outpostLiveKeyEnv) + if key == "" { + t.Skipf("%s not set; skipping live Outpost smoke test", outpostLiveKeyEnv) + } + return key +} + +// outpostLiveBaseURL allows pointing the smoke test at a non-production Outpost +// host, matching the hidden --outpost-api-base flag. +func outpostLiveBaseURL(t *testing.T) *url.URL { + t.Helper() + + raw := os.Getenv("HOOKDECK_OUTPOST_API_BASE") + if raw == "" { + raw = hookdeck.DefaultOutpostAPIBaseURL + } + + parsed, err := url.Parse(raw) + require.NoError(t, err, "invalid Outpost API base URL %q", raw) + return parsed +} + +// newOutpostLiveClient builds a client authenticated with the Project API key +// directly. A Project API key is accepted on the Outpost resource endpoints, which +// makes this the shortest path to exercising the client. +func newOutpostLiveClient(t *testing.T) *hookdeck.Client { + t.Helper() + + return &hookdeck.Client{ + BaseURL: outpostLiveBaseURL(t), + APIKey: outpostLiveAPIKey(t), + AcceptAnySuccessStatus: true, + } +} + +// newOutpostLiveClientFromCLIKey authenticates the way a real user does — exchange +// the Project API key through `hookdeck ci`, then use the CLI client key the CLI +// stores. This is the path every `hookdeck outpost …` command will take, so it is +// tested explicitly rather than assumed to be equivalent to the key above. +func newOutpostLiveClientFromCLIKey(t *testing.T) *hookdeck.Client { + t.Helper() + + apiKey := outpostLiveAPIKey(t) + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + configPath := filepath.Join(t.TempDir(), "config.toml") + runner := NewCLIRunnerWithConfigPathNoCI(t, configPath) + runner.projectRoot = projectRoot + + stdout, stderr, err := runner.Run("ci", "--api-key", apiKey) + require.NoError(t, err, "hookdeck ci failed: stdout=%s stderr=%s", stdout, stderr) + + cfg, err := config.LoadConfigFromFile(configPath) + require.NoError(t, err, "could not read the config written by hookdeck ci") + + cliKey := cfg.Profile.APIKey + require.NotEmpty(t, cliKey, "hookdeck ci did not store a CLI key") + require.NotEqual(t, apiKey, cliKey, "config should hold the exchanged CLI key, not the Project API key") + + t.Logf("project %s resolved as type %q", cfg.Profile.ProjectId, cfg.Profile.ProjectType) + assert.True(t, config.IsOutpostProject(cfg.Profile.ProjectType), + "%s must belong to an Outpost project; got type %q", outpostLiveKeyEnv, cfg.Profile.ProjectType) + + return &hookdeck.Client{ + BaseURL: outpostLiveBaseURL(t), + APIKey: cliKey, + ProjectID: cfg.Profile.ProjectId, + AcceptAnySuccessStatus: true, + } +} + +// TestLiveOutpostReadsWithProjectAPIKey exercises every read endpoint the client +// exposes and asserts the real responses decode. +func TestLiveOutpostReadsWithProjectAPIKey(t *testing.T) { + client := newOutpostLiveClient(t) + ctx := context.Background() + + var tenantID string + + t.Run("status", func(t *testing.T) { + status, err := client.GetOutpostStatus(ctx) + require.NoError(t, err) + assert.NotEmpty(t, status.Status, "deployment status should be reported") + t.Logf("status=%s version=%s", status.Status, status.Version) + }) + + t.Run("topics", func(t *testing.T) { + topics, err := client.ListOutpostTopics(ctx) + require.NoError(t, err) + t.Logf("topics=%v", topics) + + // Decoding an empty list still proves the endpoint and auth work, but a + // project with no topics cannot host a destination or accept a publish, + // so most of the remaining coverage is unreachable until this is fixed. + // Fail loudly with the remedy rather than passing quietly on an empty list. + assert.NotEmpty(t, topics, + "the Outpost test project has no topics configured. Set TOPICS in the project's "+ + "Outpost settings (operator config); without it, tenants cannot have destinations "+ + "and events cannot be published, so tenant/destination/event decoding stays untested") + }) + + t.Run("destination types", func(t *testing.T) { + schemas, err := client.ListOutpostDestinationTypes(ctx) + require.NoError(t, err) + require.NotEmpty(t, schemas) + + var webhook *hookdeck.OutpostDestinationTypeSchema + for i := range schemas { + if schemas[i].Type == "webhook" { + webhook = &schemas[i] + } + } + require.NotNil(t, webhook, "webhook should always be an available destination type") + assert.NotEmpty(t, webhook.ConfigFields, "config_fields drives dynamic --config-* validation") + }) + + t.Run("single destination type", func(t *testing.T) { + schema, err := client.GetOutpostDestinationType(ctx, "webhook") + require.NoError(t, err) + assert.Equal(t, "webhook", schema.Type) + }) + + t.Run("tenants", func(t *testing.T) { + tenants, err := client.ListOutpostTenants(ctx, hookdeck.OutpostTenantListParams{Limit: 5}) + require.NoError(t, err) + t.Logf("tenant count=%d", len(tenants.Models)) + + if len(tenants.Models) > 0 { + tenantID = tenants.Models[0].ID + assert.NotEmpty(t, tenantID) + } + }) + + t.Run("destinations for a tenant", func(t *testing.T) { + if tenantID == "" { + t.Skip("no tenants in the test project; nothing to list destinations for") + } + + // The response here is a bare array rather than a {models, pagination} + // envelope — the decode is the point of this assertion. + destinations, err := client.ListOutpostDestinations(ctx, tenantID, nil, nil) + require.NoError(t, err) + t.Logf("destination count=%d", len(destinations)) + + for _, d := range destinations { + assert.NotEmpty(t, d.Type) + // topics is a union: "*" or an array. Either must decode. + assert.NotNil(t, d.Topics) + } + }) + + t.Run("events with a time range", func(t *testing.T) { + // Exercises the time[gte]/time[lte] deepObject encoding against the real API. + events, err := client.ListOutpostEvents(ctx, hookdeck.OutpostEventListParams{ + TimeAfter: time.Now().Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339), + TimeBefore: time.Now().UTC().Format(time.RFC3339), + Limit: 5, + }) + require.NoError(t, err) + t.Logf("event count=%d", len(events.Models)) + }) + + t.Run("attempts", func(t *testing.T) { + attempts, err := client.ListOutpostAttempts(ctx, hookdeck.OutpostAttemptListParams{Limit: 5}) + require.NoError(t, err) + t.Logf("attempt count=%d", len(attempts.Models)) + }) + + t.Run("event metrics", func(t *testing.T) { + // Exercises the time[start]/time[end] and measures[n] encoding. + metrics, err := client.GetOutpostEventMetrics(ctx, hookdeck.OutpostMetricsParams{ + Start: time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339), + End: time.Now().UTC().Format(time.RFC3339), + Measures: []string{"count"}, + }) + require.NoError(t, err) + t.Logf("metrics rows=%d truncated=%v", len(metrics.Data), metrics.Metadata.Truncated) + }) + + t.Run("managed config", func(t *testing.T) { + cfg, err := client.GetOutpostConfig(ctx) + require.NoError(t, err) + assert.NotEmpty(t, cfg, "managed config should return operator keys") + }) +} + +// TestLiveOutpostReadsWithCLIKey is the important one: it proves the credentials +// the CLI actually stores work against the Outpost host. Everything in Phase 2 +// depends on this being true. +func TestLiveOutpostReadsWithCLIKey(t *testing.T) { + client := newOutpostLiveClientFromCLIKey(t) + ctx := context.Background() + + t.Run("status", func(t *testing.T) { + status, err := client.GetOutpostStatus(ctx) + require.NoError(t, err, "a CLI client key should authenticate against the Outpost API") + assert.NotEmpty(t, status.Status) + }) + + t.Run("tenants", func(t *testing.T) { + tenants, err := client.ListOutpostTenants(ctx, hookdeck.OutpostTenantListParams{Limit: 5}) + require.NoError(t, err) + t.Logf("tenant count=%d", len(tenants.Models)) + }) + + t.Run("topics", func(t *testing.T) { + _, err := client.ListOutpostTopics(ctx) + require.NoError(t, err) + }) +} + +// TestLiveOutpostSeededReadWrite creates a tenant and destination, publishes an +// event, reads everything back, then cleans up. +// +// This exists because the read-only test above can only assert what the project +// already contains. On an empty project the tenant, destination, event and +// attempt decoders are never exercised — including the `topics` union on a +// destination, which is the field shape most likely to be wrong. Seeding is the +// only way to prove those decode. +// +// Everything it creates is removed in t.Cleanup, including on failure. +func TestLiveOutpostSeededReadWrite(t *testing.T) { + apiKey := outpostLiveAPIKey(t) + client := newOutpostLiveClient(t) + ctx := context.Background() + + topics, err := client.ListOutpostTopics(ctx) + require.NoError(t, err) + require.NotEmpty(t, topics, "the project needs at least one configured topic to seed anything") + topic := topics[0] + + // Unique per run so parallel runs and leftovers from a failed run cannot + // collide, matching how the existing acceptance suites name resources. + tenantID := fmt.Sprintf("cli-live-%d", time.Now().UnixNano()) + + t.Run("upsert tenant", func(t *testing.T) { + tenant, err := client.UpsertOutpostTenant(ctx, tenantID, &hookdeck.OutpostTenantUpsertRequest{ + Metadata: map[string]string{"created_by": "hookdeck-cli-live-test"}, + }) + require.NoError(t, err) + assert.Equal(t, tenantID, tenant.ID) + }) + + t.Cleanup(func() { + if err := client.DeleteOutpostTenant(context.Background(), tenantID); err != nil { + t.Logf("cleanup: could not delete tenant %s: %v", tenantID, err) + } + }) + + var destinationID string + + t.Run("create destination", func(t *testing.T) { + destination, err := client.CreateOutpostDestination(ctx, tenantID, &hookdeck.OutpostDestinationCreateRequest{ + Type: "webhook", + Topics: hookdeck.OutpostTopics{topic}, + Config: map[string]interface{}{"url": "https://example.com/hookdeck-cli-live-test"}, + }) + require.NoError(t, err) + require.NotEmpty(t, destination.ID) + destinationID = destination.ID + + assert.Equal(t, "webhook", destination.Type) + assert.Equal(t, hookdeck.OutpostTopics{topic}, destination.Topics) + assert.False(t, destination.Disabled()) + }) + + t.Run("create wildcard destination decodes the topics union", func(t *testing.T) { + // The wildcard comes back as the bare string "*" rather than an array, + // which a plain []string field cannot decode. This is the assertion the + // empty project could never make. + wildcard, err := client.CreateOutpostDestination(ctx, tenantID, &hookdeck.OutpostDestinationCreateRequest{ + Type: "webhook", + Topics: hookdeck.OutpostTopics{hookdeck.OutpostTopicsWildcard}, + Config: map[string]interface{}{"url": "https://example.com/hookdeck-cli-live-wildcard"}, + }) + require.NoError(t, err) + assert.True(t, wildcard.Topics.IsWildcard(), "expected the wildcard form, got %v", wildcard.Topics) + + require.NoError(t, client.DeleteOutpostDestination(ctx, tenantID, wildcard.ID)) + }) + + t.Run("list destinations", func(t *testing.T) { + // Unpaginated: a bare array, not a {models, pagination} envelope. + destinations, err := client.ListOutpostDestinations(ctx, tenantID, nil, nil) + require.NoError(t, err) + require.Len(t, destinations, 1, "only the non-wildcard destination should remain") + assert.Equal(t, destinationID, destinations[0].ID) + }) + + t.Run("get destination", func(t *testing.T) { + destination, err := client.GetOutpostDestination(ctx, tenantID, destinationID) + require.NoError(t, err) + assert.Equal(t, destinationID, destination.ID) + }) + + t.Run("disable and enable destination", func(t *testing.T) { + disabled, err := client.DisableOutpostDestination(ctx, tenantID, destinationID) + require.NoError(t, err) + assert.True(t, disabled.Disabled(), "disabled_at should be set") + + enabled, err := client.EnableOutpostDestination(ctx, tenantID, destinationID) + require.NoError(t, err) + assert.False(t, enabled.Disabled(), "disabled_at should be cleared") + }) + + t.Run("update destination", func(t *testing.T) { + updated, err := client.UpdateOutpostDestination(ctx, tenantID, destinationID, &hookdeck.OutpostDestinationUpdateRequest{ + Config: map[string]interface{}{"url": "https://example.com/hookdeck-cli-live-updated"}, + }) + require.NoError(t, err) + assert.Equal(t, "https://example.com/hookdeck-cli-live-updated", updated.Config["url"]) + }) + + t.Run("get tenant reflects the destination", func(t *testing.T) { + tenant, err := client.GetOutpostTenant(ctx, tenantID) + require.NoError(t, err) + assert.Equal(t, 1, tenant.DestinationsCount) + assert.Equal(t, "hookdeck-cli-live-test", tenant.Metadata["created_by"]) + }) + + t.Run("tenant token", func(t *testing.T) { + token, err := client.GetOutpostTenantToken(ctx, tenantID) + require.NoError(t, err) + assert.NotEmpty(t, token.Token, "this mints a real credential; it is gated behind write mode in MCP") + }) + + var eventID string + + t.Run("publish", func(t *testing.T) { + // Publish needs the Project API key as a bearer token, not the client's + // stored credential — the one command with different auth. + resp, err := client.PublishOutpostEvent(ctx, apiKey, &hookdeck.OutpostPublishRequest{ + TenantID: tenantID, + Topic: topic, + Data: map[string]interface{}{"source": "hookdeck-cli-live-test"}, + Metadata: map[string]string{"origin": "cli-test"}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.ID) + eventID = resp.ID + + assert.False(t, resp.Duplicate) + assert.Contains(t, resp.DestinationIDs, destinationID, "the event should match the destination's topic") + }) + + t.Run("list events for the tenant", func(t *testing.T) { + // Publishing is asynchronous, so poll rather than asserting immediately. + var events *hookdeck.OutpostEventListResponse + require.Eventually(t, func() bool { + var err error + events, err = client.ListOutpostEvents(ctx, hookdeck.OutpostEventListParams{ + TenantIDs: []string{tenantID}, + Limit: 10, + }) + return err == nil && len(events.Models) > 0 + }, 30*time.Second, 2*time.Second, "published event never appeared in the events list") + + event := events.Models[0] + assert.Equal(t, tenantID, event.TenantID) + assert.Equal(t, topic, event.Topic) + assert.Equal(t, "hookdeck-cli-live-test", event.Data["source"]) + assert.False(t, event.Time.IsZero(), "time should decode") + }) + + t.Run("get event", func(t *testing.T) { + if eventID == "" { + t.Skip("no event id from publish") + } + event, err := client.GetOutpostEvent(ctx, eventID, tenantID) + require.NoError(t, err) + assert.Equal(t, eventID, event.ID) + }) + + t.Run("attempts for the tenant", func(t *testing.T) { + // Delivery to example.com will fail; a failed attempt still proves the + // attempt decoder works, which is what is being tested here. + var attempts *hookdeck.OutpostAttemptListResponse + require.Eventually(t, func() bool { + var err error + attempts, err = client.ListOutpostAttempts(ctx, hookdeck.OutpostAttemptListParams{ + TenantIDs: []string{tenantID}, + Limit: 10, + }) + return err == nil && len(attempts.Models) > 0 + }, 60*time.Second, 3*time.Second, "no delivery attempt was recorded") + + attempt := attempts.Models[0] + assert.NotEmpty(t, attempt.ID) + assert.NotEmpty(t, attempt.Status) + assert.Equal(t, destinationID, attempt.DestinationID) + t.Logf("attempt status=%s code=%s number=%d", attempt.Status, attempt.Code, attempt.AttemptNumber) + + t.Run("get attempt", func(t *testing.T) { + got, err := client.GetOutpostAttempt(ctx, attempt.ID, hookdeck.OutpostAttemptGetParams{ + TenantID: tenantID, + }) + require.NoError(t, err) + assert.Equal(t, attempt.ID, got.ID) + }) + + t.Run("tenant-scoped attempts path", func(t *testing.T) { + scoped, err := client.ListOutpostAttempts(ctx, hookdeck.OutpostAttemptListParams{ + TenantID: tenantID, + DestinationID: destinationID, + Limit: 10, + }) + require.NoError(t, err) + assert.NotEmpty(t, scoped.Models, "the tenant-scoped attempts route should return the same data") + }) + }) +} From 85ba39ce7daeff12a6697e7bcf777f57071e93a9 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 16:01:25 +0100 Subject: [PATCH 02/49] feat(outpost): add outpost command group and tenant commands Adds the `hookdeck outpost` group with an Outpost-project gate mirroring the Gateway one, plus the tenant command tree: list, get, upsert, delete, token and portal. The gate matters for the error message rather than for safety. Pointing an outpost command at a Gateway project otherwise returns a 404, which reads as "no such tenant" instead of "you are on the wrong project"; it now says which type the project is and how to switch. Tenants are created through upsert because their IDs are chosen by the caller rather than generated. Delete names the destination count in its prompt, since that is the part most likely to have been forgotten. `--id` joins the empty-value guard list. It is a filter rather than an identifier, but the failure is worse: an empty value drops the filter, so `--id "$UNSET"` silently widens the query to everything rather than narrowing it. Verified against a real project, along with the no-terminal delete path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/attempt_list.go | 2 +- pkg/cmd/connection_create.go | 18 ++-- pkg/cmd/connection_disable.go | 2 +- pkg/cmd/connection_enable.go | 2 +- pkg/cmd/connection_update.go | 7 +- pkg/cmd/destination_common.go | 30 +++--- pkg/cmd/destination_get.go | 4 +- pkg/cmd/empty_flags.go | 16 ++++ pkg/cmd/event_list.go | 48 +++++----- pkg/cmd/helptext.go | 13 ++- pkg/cmd/metrics_attempts.go | 2 +- pkg/cmd/metrics_requests.go | 2 +- pkg/cmd/metrics_transformations.go | 2 +- pkg/cmd/outpost.go | 108 ++++++++++++++++++++++ pkg/cmd/outpost_tenant.go | 35 +++++++ pkg/cmd/outpost_tenant_delete.go | 79 ++++++++++++++++ pkg/cmd/outpost_tenant_get.go | 73 +++++++++++++++ pkg/cmd/outpost_tenant_list.go | 132 +++++++++++++++++++++++++++ pkg/cmd/outpost_tenant_portal.go | 91 ++++++++++++++++++ pkg/cmd/outpost_tenant_token.go | 64 +++++++++++++ pkg/cmd/outpost_tenant_upsert.go | 122 +++++++++++++++++++++++++ pkg/cmd/outpost_test.go | 106 +++++++++++++++++++++ pkg/cmd/project_list.go | 14 +-- pkg/cmd/project_use.go | 8 +- pkg/cmd/request_list.go | 38 ++++---- pkg/cmd/request_retry.go | 4 +- pkg/cmd/root.go | 1 + pkg/cmd/source_common.go | 18 ++-- pkg/cmd/source_count.go | 4 +- pkg/cmd/source_disable.go | 2 +- pkg/cmd/source_enable.go | 2 +- pkg/cmd/source_get.go | 6 +- pkg/cmd/source_list.go | 8 +- pkg/cmd/telemetry.go | 4 +- pkg/cmd/transformation_count.go | 4 +- pkg/cmd/transformation_create.go | 12 +-- pkg/cmd/transformation_executions.go | 22 ++--- pkg/cmd/transformation_get.go | 2 +- pkg/cmd/transformation_list.go | 16 ++-- pkg/cmd/transformation_run.go | 24 ++--- 40 files changed, 990 insertions(+), 157 deletions(-) create mode 100644 pkg/cmd/outpost.go create mode 100644 pkg/cmd/outpost_tenant.go create mode 100644 pkg/cmd/outpost_tenant_delete.go create mode 100644 pkg/cmd/outpost_tenant_get.go create mode 100644 pkg/cmd/outpost_tenant_list.go create mode 100644 pkg/cmd/outpost_tenant_portal.go create mode 100644 pkg/cmd/outpost_tenant_token.go create mode 100644 pkg/cmd/outpost_tenant_upsert.go create mode 100644 pkg/cmd/outpost_test.go diff --git a/pkg/cmd/attempt_list.go b/pkg/cmd/attempt_list.go index 55540651..b078b23d 100644 --- a/pkg/cmd/attempt_list.go +++ b/pkg/cmd/attempt_list.go @@ -13,7 +13,7 @@ import ( ) type attemptListCmd struct { - cmd *cobra.Command + cmd *cobra.Command eventID string orderBy string dir string diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index b0bfe5be..a8c00081 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -781,15 +781,15 @@ func (cc *connectionCreateCmd) buildSourceConfig() (map[string]interface{}, erro } // Build from individual --source-* flags using shared logic f := &sourceConfigFlags{ - WebhookSecret: cc.SourceWebhookSecret, - APIKey: cc.SourceAPIKey, - BasicAuthUser: cc.SourceBasicAuthUser, - BasicAuthPass: cc.SourceBasicAuthPass, - HMACSecret: cc.SourceHMACSecret, - HMACAlgo: cc.SourceHMACAlgo, - AllowedHTTPMethods: cc.SourceAllowedHTTPMethods, - CustomResponseBody: cc.SourceCustomResponseBody, - CustomResponseType: cc.SourceCustomResponseType, + WebhookSecret: cc.SourceWebhookSecret, + APIKey: cc.SourceAPIKey, + BasicAuthUser: cc.SourceBasicAuthUser, + BasicAuthPass: cc.SourceBasicAuthPass, + HMACSecret: cc.SourceHMACSecret, + HMACAlgo: cc.SourceHMACAlgo, + AllowedHTTPMethods: cc.SourceAllowedHTTPMethods, + CustomResponseBody: cc.SourceCustomResponseBody, + CustomResponseType: cc.SourceCustomResponseType, } config, err := buildSourceConfigFromIndividualFlags(f, "source-", cc.sourceType) if err != nil { diff --git a/pkg/cmd/connection_disable.go b/pkg/cmd/connection_disable.go index cac312a6..fe20127c 100644 --- a/pkg/cmd/connection_disable.go +++ b/pkg/cmd/connection_disable.go @@ -21,7 +21,7 @@ func newConnectionDisableCmd() *connectionDisableCmd { Args: validators.ExactArgs(1), Short: ShortDisable(ResourceConnection), Long: LongDisableIntro(ResourceConnection), - RunE: cc.runConnectionDisableCmd, + RunE: cc.runConnectionDisableCmd, } cc.cmd.Annotations = map[string]string{ "cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`, diff --git a/pkg/cmd/connection_enable.go b/pkg/cmd/connection_enable.go index 8edd58bf..0c52ef6c 100644 --- a/pkg/cmd/connection_enable.go +++ b/pkg/cmd/connection_enable.go @@ -21,7 +21,7 @@ func newConnectionEnableCmd() *connectionEnableCmd { Args: validators.ExactArgs(1), Short: ShortEnable(ResourceConnection), Long: LongEnableIntro(ResourceConnection), - RunE: cc.runConnectionEnableCmd, + RunE: cc.runConnectionEnableCmd, } cc.cmd.Annotations = map[string]string{ "cli.arguments": `[{"name":"connection-id","type":"string","description":"Connection ID","required":true}]`, diff --git a/pkg/cmd/connection_update.go b/pkg/cmd/connection_update.go index 33f3c0cc..4afc2ad2 100644 --- a/pkg/cmd/connection_update.go +++ b/pkg/cmd/connection_update.go @@ -18,9 +18,9 @@ type connectionUpdateCmd struct { output string // Connection fields (update-by-ID only; no inline source/destination) - name string - description string - sourceID string + name string + description string + sourceID string destinationID string // Rule flags shared with create/upsert @@ -187,4 +187,3 @@ func (cu *connectionUpdateCmd) displayConnection(conn *hookdeck.Connection, upda } } } - diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 049d8d02..8229ba63 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -11,21 +11,21 @@ import ( // Used by destination create, upsert, update. When both --config/--config-file and // individual flags are set, --config/--config-file take precedence. type destinationConfigFlags struct { - URL string - CliPath string - AuthMethod string - BearerToken string - BasicAuthUser string - BasicAuthPass string - APIKey string - APIKeyHeader string - APIKeyTo string - CustomSignatureSecret string - CustomSignatureKey string - RateLimit int - RateLimitPeriod string - PathForwardingDisabled *bool - HTTPMethod string + URL string + CliPath string + AuthMethod string + BearerToken string + BasicAuthUser string + BasicAuthPass string + APIKey string + APIKeyHeader string + APIKeyTo string + CustomSignatureSecret string + CustomSignatureKey string + RateLimit int + RateLimitPeriod string + PathForwardingDisabled *bool + HTTPMethod string } // hasAnyDestinationConfig returns true if any individual destination config flag is set. diff --git a/pkg/cmd/destination_get.go b/pkg/cmd/destination_get.go index 19ee87f6..f47cd819 100644 --- a/pkg/cmd/destination_get.go +++ b/pkg/cmd/destination_get.go @@ -17,8 +17,8 @@ import ( type destinationGetCmd struct { cmd *cobra.Command - output string - includeDestAuth bool + output string + includeDestAuth bool } func newDestinationGetCmd() *destinationGetCmd { diff --git a/pkg/cmd/empty_flags.go b/pkg/cmd/empty_flags.go index 96ae1558..09fe3b43 100644 --- a/pkg/cmd/empty_flags.go +++ b/pkg/cmd/empty_flags.go @@ -71,6 +71,22 @@ var flagsRejectingEmptyValues = map[string]bool{ "destination-aws-region": true, "destination-gcp-service-account-key": true, + // Outpost identity flags. A tenant or event id that expands to an empty + // string would silently address a different path rather than fail, so these + // are rejected the same way the identity flags above are. + "tenant-id": true, + "event-id": true, + "topic": true, + "topics": true, + "theme": true, + "hostname": true, + + // A filter rather than an identifier, but the failure is worse: an empty + // value drops the filter, so `--id "$UNSET"` silently widens the query to + // everything instead of narrowing it to one record. Only commands that call + // rejectEmptyFlags are affected. + "id": true, + // JSON configuration escape hatches "config": true, "config-file": true, diff --git a/pkg/cmd/event_list.go b/pkg/cmd/event_list.go index 6d4c8c0e..f45ddda7 100644 --- a/pkg/cmd/event_list.go +++ b/pkg/cmd/event_list.go @@ -15,32 +15,32 @@ import ( type eventListCmd struct { cmd *cobra.Command - id string - connectionID string - sourceID string - destinationID string - status string - attempts string - responseStatus string - errorCode string - cliID string - issueID string - createdAfter string - createdBefore string - successfulAfter string - successfulBefore string + id string + connectionID string + sourceID string + destinationID string + status string + attempts string + responseStatus string + errorCode string + cliID string + issueID string + createdAfter string + createdBefore string + successfulAfter string + successfulBefore string lastAttemptAfter string lastAttemptBefore string - headers string - body string - path string - parsedQuery string - orderBy string - dir string - limit int - next string - prev string - output string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string } func newEventListCmd() *eventListCmd { diff --git a/pkg/cmd/helptext.go b/pkg/cmd/helptext.go index 0223f7a0..a117ab12 100644 --- a/pkg/cmd/helptext.go +++ b/pkg/cmd/helptext.go @@ -11,8 +11,15 @@ const ( ResourceTransformation = "transformation" ResourceEvent = "event" ResourceRequest = "request" - ResourceAttempt = "attempt" - ResourceIssue = "issue" + ResourceAttempt = "attempt" + ResourceIssue = "issue" + + // Outpost resources. Destination and attempt names are shared with the + // Event Gateway constants above, but the Outpost resources they describe are + // different, so the help text is composed per command rather than reused. + ResourceTenant = "tenant" + ResourceTopic = "topic" + ResourceDestinationType = "destination type" ) // Short help (one line) for common commands. Use when the only difference is the resource name. @@ -22,7 +29,7 @@ func ShortDelete(resource string) string { return "Delete a " + resource } func ShortDisable(resource string) string { return "Disable a " + resource } func ShortEnable(resource string) string { return "Enable a " + resource } func ShortUpdate(resource string) string { return "Update a " + resource + " by ID" } -func ShortCreate(resource string) string { return "Create a new " + resource } +func ShortCreate(resource string) string { return "Create a new " + resource } func ShortUpsert(resource string) string { return "Create or update a " + resource + " by name" } // LongGetIntro returns the first paragraph for "get" commands: "Get detailed information about a specific {resource}.\n\nYou can specify either a {resource} ID or name." diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index 96447446..4493972d 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -10,7 +10,7 @@ import ( const metricsAttemptsMeasures = "count, successful_count, failed_count, delivered_count, error_rate, response_latency_avg, response_latency_max, response_latency_p95, response_latency_p99, delivery_latency_avg" type metricsAttemptsCmd struct { - cmd *cobra.Command + cmd *cobra.Command flags metricsCommonFlags } diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index 084dbf11..e011a276 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -10,7 +10,7 @@ import ( const metricsRequestsMeasures = "count, accepted_count, rejected_count, discarded_count, avg_events_per_request, avg_ignored_per_request" type metricsRequestsCmd struct { - cmd *cobra.Command + cmd *cobra.Command flags metricsCommonFlags } diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index a47b6e8d..56d430a4 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -10,7 +10,7 @@ import ( const metricsTransformationsMeasures = "count, successful_count, failed_count, error_rate, error_count, warn_count, info_count, debug_count" type metricsTransformationsCmd struct { - cmd *cobra.Command + cmd *cobra.Command flags metricsCommonFlags } diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go new file mode 100644 index 00000000..04fcc39c --- /dev/null +++ b/pkg/cmd/outpost.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostCmd struct { + cmd *cobra.Command +} + +// isOutpostMCPLeafCommand reports whether cmd is the outpost mcp subcommand. +// MCP speaks JSON-RPC on stdout, so when there is no API key yet the project +// check must not run — the server needs to start and expose its login tool. +func isOutpostMCPLeafCommand(cmd *cobra.Command) bool { + return cmd != nil && cmd.Name() == "mcp" && cmd.Parent() != nil && cmd.Parent().Name() == "outpost" +} + +// outpostPersistentPreRunE runs before every outpost subcommand. Cobra does not +// chain PersistentPreRun, so initTelemetry must be called here explicitly. +func outpostPersistentPreRunE(cmd *cobra.Command, args []string) error { + initTelemetry(cmd) + if isOutpostMCPLeafCommand(cmd) { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return nil + } + } + return requireOutpostProject(nil) +} + +// requireOutpostProject ensures the active project is an Outpost project. +// +// Without this the API answers 404 for a Gateway project, which reads as "the +// resource does not exist" rather than "you are pointed at the wrong project". +// cfg is optional; when nil the global Config is used. +func requireOutpostProject(cfg *config.Config) error { + if cfg == nil { + cfg = &Config + } + if err := cfg.Profile.ValidateAPIKey(); err != nil { + return err + } + if cfg.Profile.ProjectId == "" { + return fmt.Errorf("no project selected. Run 'hookdeck project use' to select a project") + } + + projectType := cfg.Profile.ProjectType + if projectType == "" && cfg.Profile.ProjectMode != "" { + projectType = config.ModeToProjectType(cfg.Profile.ProjectMode) + } + if projectType == "" { + // Resolve from the API, which is authoritative for the key. + response, err := cfg.GetAPIClient().ValidateAPIKey() + if err != nil { + return err + } + cfg.Profile.ApplyValidateAPIKeyResponse(response, false) + projectType = cfg.Profile.ProjectType + _ = cfg.Profile.SaveProfile() + } + + if !config.IsOutpostProject(projectType) { + return fmt.Errorf("this command requires an Outpost project; current project type is %s. Use 'hookdeck project use' to switch to an Outpost project", projectType) + } + return nil +} + +func newOutpostCmd() *outpostCmd { + oc := &outpostCmd{} + + oc.cmd = &cobra.Command{ + Use: "outpost", + Args: validators.NoArgs, + Short: ShortBeta("Manage your Hookdeck Outpost resources"), + Long: LongBeta(`Commands for managing Hookdeck Outpost tenants, destinations, events, +attempts, topics, metrics, and project configuration. + +Outpost delivers events to your users' destinations. Each of your users is a tenant, +and each tenant owns the destinations their events are delivered to. + +These commands require an Outpost project. Use 'hookdeck project use' to switch.`), + Example: ` # List tenants + hookdeck outpost tenant list + + # Create a webhook destination for a tenant + hookdeck outpost destination create --tenant-id acme --type webhook --config-url https://example.com/hooks + + # Inspect recent events + hookdeck outpost event list --limit 10 + + # Check the deployment status + hookdeck outpost status`, + PersistentPreRunE: outpostPersistentPreRunE, + } + + oc.cmd.AddCommand(newOutpostTenantCmd().cmd) + + return oc +} + +// addOutpostCmdTo registers the outpost command tree on the given parent. +func addOutpostCmdTo(parent *cobra.Command) { + parent.AddCommand(newOutpostCmd().cmd) +} diff --git a/pkg/cmd/outpost_tenant.go b/pkg/cmd/outpost_tenant.go new file mode 100644 index 00000000..aad07edf --- /dev/null +++ b/pkg/cmd/outpost_tenant.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantCmd struct { + cmd *cobra.Command +} + +func newOutpostTenantCmd() *outpostTenantCmd { + tc := &outpostTenantCmd{} + + tc.cmd = &cobra.Command{ + Use: "tenant", + Aliases: []string{"tenants"}, + Args: validators.NoArgs, + Short: ShortBeta("Manage your Outpost tenants"), + Long: LongBeta(`Manage tenants — the end users events are delivered on behalf of. + +Each tenant owns its own destinations. Tenant IDs are chosen by you rather than +generated, so use 'upsert' to create one: it is idempotent and safe to re-run.`), + } + + tc.cmd.AddCommand(newOutpostTenantListCmd().cmd) + tc.cmd.AddCommand(newOutpostTenantGetCmd().cmd) + tc.cmd.AddCommand(newOutpostTenantUpsertCmd().cmd) + tc.cmd.AddCommand(newOutpostTenantDeleteCmd().cmd) + tc.cmd.AddCommand(newOutpostTenantTokenCmd().cmd) + tc.cmd.AddCommand(newOutpostTenantPortalCmd().cmd) + + return tc +} diff --git a/pkg/cmd/outpost_tenant_delete.go b/pkg/cmd/outpost_tenant_delete.go new file mode 100644 index 00000000..1ffca4dc --- /dev/null +++ b/pkg/cmd/outpost_tenant_delete.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantDeleteCmd struct { + cmd *cobra.Command + + force bool +} + +func newOutpostTenantDeleteCmd() *outpostTenantDeleteCmd { + tc := &outpostTenantDeleteCmd{} + + tc.cmd = &cobra.Command{ + Use: "delete ", + Args: validators.ExactArgs(1), + Short: ShortDelete(ResourceTenant), + Long: LongDeleteIntro(ResourceTenant) + ` + +Deleting a tenant also removes its destinations, so events will stop being +delivered on its behalf. This cannot be undone.`, + RunE: tc.runOutpostTenantDeleteCmd, + Example: ` # Delete a tenant, with a confirmation prompt + hookdeck outpost tenant delete acme + + # Skip the prompt (for scripts and CI) + hookdeck outpost tenant delete acme --force`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"tenant-id","type":"string","description":"The ID of the tenant to delete.","required":true} + ]`, + }, + } + + tc.cmd.Flags().BoolVar(&tc.force, "force", false, "Delete without confirmation") + + return tc +} + +func (tc *outpostTenantDeleteCmd) runOutpostTenantDeleteCmd(cmd *cobra.Command, args []string) error { + tenantID := args[0] + client := Config.GetOutpostAPIClient() + ctx := context.Background() + + if !tc.force { + // Report the blast radius rather than just the name: the destination + // count is the part a user is most likely to have forgotten. + prompt := fmt.Sprintf("\nAre you sure you want to delete tenant '%s'?", tenantID) + if tenant, err := client.GetOutpostTenant(ctx, tenantID); err == nil && tenant.DestinationsCount > 0 { + prompt = fmt.Sprintf( + "\nAre you sure you want to delete tenant '%s' and its %d destination(s)?", + tenantID, tenant.DestinationsCount, + ) + } + + proceed, err := confirmDestructiveAction(prompt, "Deletion cancelled.", "force") + if err != nil { + return err + } + if !proceed { + return nil + } + } + + if err := client.DeleteOutpostTenant(ctx, tenantID); err != nil { + return fmt.Errorf("failed to delete tenant: %w", err) + } + + fmt.Printf("%s Tenant %s deleted\n", SuccessCheck, tenantID) + + return nil +} diff --git a/pkg/cmd/outpost_tenant_get.go b/pkg/cmd/outpost_tenant_get.go new file mode 100644 index 00000000..06a68c2b --- /dev/null +++ b/pkg/cmd/outpost_tenant_get.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantGetCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostTenantGetCmd() *outpostTenantGetCmd { + tc := &outpostTenantGetCmd{} + + tc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceTenant), + Long: `Get details for a tenant, including how many destinations it has.`, + RunE: tc.runOutpostTenantGetCmd, + Example: ` # Get a tenant + hookdeck outpost tenant get acme + + # As JSON + hookdeck outpost tenant get acme --output json`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"tenant-id","type":"string","description":"The ID of the tenant.","required":true} + ]`, + }, + } + + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTenantGetCmd) runOutpostTenantGetCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + tenant, err := client.GetOutpostTenant(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + if tc.output == "json" { + return printJSONIndented(tenant) + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(tenant.ID)) + fmt.Printf(" Destinations: %d\n", tenant.DestinationsCount) + if len(tenant.Topics) > 0 { + fmt.Printf(" Topics: %s\n", strings.Join(tenant.Topics, ", ")) + } + for key, value := range tenant.Metadata { + fmt.Printf(" Metadata %s: %s\n", key, value) + } + fmt.Printf(" Created: %s\n", tenant.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", tenant.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_tenant_list.go b/pkg/cmd/outpost_tenant_list.go new file mode 100644 index 00000000..e49de038 --- /dev/null +++ b/pkg/cmd/outpost_tenant_list.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantListCmd struct { + cmd *cobra.Command + + ids string + limit int + dir string + next string + prev string + output string +} + +func newOutpostTenantListCmd() *outpostTenantListCmd { + tc := &outpostTenantListCmd{} + + tc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceTenant), + Long: `List tenants in the current Outpost project.`, + PreRunE: tc.validateFlags, + RunE: tc.runOutpostTenantListCmd, + Example: ` # List tenants + hookdeck outpost tenant list + + # Fetch specific tenants by ID + hookdeck outpost tenant list --id acme,globex + + # Page through results + hookdeck outpost tenant list --limit 20 --next `, + } + + tc.cmd.Flags().StringVar(&tc.ids, "id", "", "Filter by tenant ID(s), comma-separated") + tc.cmd.Flags().IntVar(&tc.limit, "limit", 0, "Limit number of results (1-100)") + tc.cmd.Flags().StringVar(&tc.dir, "dir", "", "Sort direction (asc, desc)") + tc.cmd.Flags().StringVar(&tc.next, "next", "", "Next page cursor") + tc.cmd.Flags().StringVar(&tc.prev, "prev", "", "Previous page cursor") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTenantListCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (tc *outpostTenantListCmd) runOutpostTenantListCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + resp, err := client.ListOutpostTenants(context.Background(), hookdeck.OutpostTenantListParams{ + IDs: splitCommaList(tc.ids), + Limit: tc.limit, + Dir: tc.dir, + Next: tc.next, + Prev: tc.prev, + }) + if err != nil { + return fmt.Errorf("failed to list tenants: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) + if err != nil { + return fmt.Errorf("failed to marshal tenants to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No tenants found.") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d tenant(s):\n\n", len(resp.Models)) + for _, tenant := range resp.Models { + fmt.Printf("%s\n", color.Green(tenant.ID)) + fmt.Printf(" Destinations: %d\n", tenant.DestinationsCount) + if len(tenant.Topics) > 0 { + fmt.Printf(" Topics: %s\n", strings.Join(tenant.Topics, ", ")) + } + fmt.Printf(" Created: %s\n", tenant.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + } + + printPaginationInfo(resp.Pagination, "hookdeck outpost tenant list") + + return nil +} + +// splitCommaList turns a comma-separated flag value into a slice, dropping +// empty entries so a trailing comma does not produce a blank filter. +func splitCommaList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// printJSONIndented is the shared single-object JSON output path. +func printJSONIndented(v interface{}) error { + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} diff --git a/pkg/cmd/outpost_tenant_portal.go b/pkg/cmd/outpost_tenant_portal.go new file mode 100644 index 00000000..85b5896f --- /dev/null +++ b/pkg/cmd/outpost_tenant_portal.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/open" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantPortalCmd struct { + cmd *cobra.Command + + theme string + open bool + output string +} + +func newOutpostTenantPortalCmd() *outpostTenantPortalCmd { + tc := &outpostTenantPortalCmd{} + + tc.cmd = &cobra.Command{ + Use: "portal ", + Args: validators.ExactArgs(1), + Short: ShortBeta("Get a tenant's portal URL"), + Long: LongBeta(`Get a redirect URL for a tenant's portal, where they manage their own destinations. + +The URL grants access to that tenant's portal session, so treat it as a credential. + +This requires a portal custom domain to be configured for the project; see +'hookdeck outpost config custom-domain'.`), + PreRunE: tc.validateFlags, + RunE: tc.runOutpostTenantPortalCmd, + Example: ` # Print the portal URL + hookdeck outpost tenant portal acme + + # Open it in a browser + hookdeck outpost tenant portal acme --open + + # Request the dark theme + hookdeck outpost tenant portal acme --theme dark`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"tenant-id","type":"string","description":"The ID of the tenant whose portal URL to fetch.","required":true} + ]`, + }, + } + + tc.cmd.Flags().StringVar(&tc.theme, "theme", "", "Portal theme (light, dark)") + tc.cmd.Flags().BoolVar(&tc.open, "open", false, "Open the portal URL in your browser") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTenantPortalCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if tc.theme != "" && tc.theme != "light" && tc.theme != "dark" { + return fmt.Errorf("--theme must be either light or dark") + } + return nil +} + +func (tc *outpostTenantPortalCmd) runOutpostTenantPortalCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + portal, err := client.GetOutpostTenantPortalURL(context.Background(), args[0], tc.theme) + if err != nil { + return fmt.Errorf("failed to get tenant portal URL: %w", err) + } + + if tc.output == "json" { + return printJSONIndented(portal) + } + + fmt.Println(portal.RedirectURL) + + if tc.open { + if err := open.Browser(portal.RedirectURL); err != nil { + // The URL is already on stdout, so this is a degraded success rather + // than a failure: report it and let the user open it themselves. + fmt.Printf("Could not open a browser automatically: %v\n", err) + } + } + + return nil +} diff --git a/pkg/cmd/outpost_tenant_token.go b/pkg/cmd/outpost_tenant_token.go new file mode 100644 index 00000000..61159375 --- /dev/null +++ b/pkg/cmd/outpost_tenant_token.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantTokenCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostTenantTokenCmd() *outpostTenantTokenCmd { + tc := &outpostTenantTokenCmd{} + + tc.cmd = &cobra.Command{ + Use: "token ", + Args: validators.ExactArgs(1), + Short: ShortBeta("Mint a JWT for a tenant"), + Long: LongBeta(`Mint a short-lived JWT scoped to a single tenant. + +The token grants access to that tenant's data and is valid for 24 hours. Treat it +as a credential: it is intended for your own backend to hand to a tenant's session, +not to be pasted into a shell history or shared.`), + RunE: tc.runOutpostTenantTokenCmd, + Example: ` # Mint a token for a tenant + hookdeck outpost tenant token acme + + # As JSON, for piping into another tool + hookdeck outpost tenant token acme --output json`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"tenant-id","type":"string","description":"The ID of the tenant to mint a token for.","required":true} + ]`, + }, + } + + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTenantTokenCmd) runOutpostTenantTokenCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + token, err := client.GetOutpostTenantToken(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("failed to get tenant token: %w", err) + } + + if tc.output == "json" { + return printJSONIndented(token) + } + + // Print the token alone so it can be captured with $(...) without post-processing. + fmt.Println(token.Token) + + return nil +} diff --git a/pkg/cmd/outpost_tenant_upsert.go b/pkg/cmd/outpost_tenant_upsert.go new file mode 100644 index 00000000..70b06045 --- /dev/null +++ b/pkg/cmd/outpost_tenant_upsert.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTenantUpsertCmd struct { + cmd *cobra.Command + + metadata []string + metadataFile string + output string +} + +func newOutpostTenantUpsertCmd() *outpostTenantUpsertCmd { + tc := &outpostTenantUpsertCmd{} + + tc.cmd = &cobra.Command{ + Use: "upsert ", + Args: validators.ExactArgs(1), + Short: ShortUpsert(ResourceTenant), + Long: LongUpsertIntro(ResourceTenant) + ` + +Tenant IDs are chosen by you, not generated, so this is the only way to create one. +Re-running with the same ID updates the tenant's metadata rather than failing. + +Metadata is replaced wholesale, not merged: pass every key you want to keep.`, + PreRunE: tc.validateFlags, + RunE: tc.runOutpostTenantUpsertCmd, + Example: ` # Create or update a tenant + hookdeck outpost tenant upsert acme + + # With metadata + hookdeck outpost tenant upsert acme --metadata plan=pro --metadata region=eu + + # Metadata from a JSON file + hookdeck outpost tenant upsert acme --metadata-file ./tenant.json`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"tenant-id","type":"string","description":"The ID of the tenant to create or update.","required":true} + ]`, + }, + } + + tc.cmd.Flags().StringArrayVar(&tc.metadata, "metadata", nil, "Metadata as key=value (repeatable)") + tc.cmd.Flags().StringVar(&tc.metadataFile, "metadata-file", "", "Path to a JSON file of metadata key/value pairs") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTenantUpsertCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if len(tc.metadata) > 0 && tc.metadataFile != "" { + return fmt.Errorf("--metadata and --metadata-file cannot be used together") + } + return nil +} + +func (tc *outpostTenantUpsertCmd) runOutpostTenantUpsertCmd(cmd *cobra.Command, args []string) error { + metadata, err := tc.resolveMetadata() + if err != nil { + return err + } + + client := Config.GetOutpostAPIClient() + + tenant, err := client.UpsertOutpostTenant(context.Background(), args[0], &hookdeck.OutpostTenantUpsertRequest{ + Metadata: metadata, + }) + if err != nil { + return fmt.Errorf("failed to upsert tenant: %w", err) + } + + if tc.output == "json" { + return printJSONIndented(tenant) + } + + fmt.Printf("%s Tenant %s saved\n", SuccessCheck, tenant.ID) + + return nil +} + +func (tc *outpostTenantUpsertCmd) resolveMetadata() (map[string]string, error) { + if tc.metadataFile != "" { + contents, err := os.ReadFile(tc.metadataFile) + if err != nil { + return nil, fmt.Errorf("failed to read --metadata-file: %w", err) + } + var metadata map[string]string + if err := json.Unmarshal(contents, &metadata); err != nil { + return nil, fmt.Errorf("--metadata-file must contain a JSON object of string values: %w", err) + } + return metadata, nil + } + + if len(tc.metadata) == 0 { + return nil, nil + } + + metadata := make(map[string]string, len(tc.metadata)) + for _, entry := range tc.metadata { + key, value, found := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("--metadata %q must be in key=value form", entry) + } + metadata[key] = value + } + return metadata, nil +} diff --git a/pkg/cmd/outpost_test.go b/pkg/cmd/outpost_test.go new file mode 100644 index 00000000..e225b690 --- /dev/null +++ b/pkg/cmd/outpost_test.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" +) + +func TestRequireOutpostProject(t *testing.T) { + t.Run("no API key", func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectType = config.ProjectTypeOutpost + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "authenticated") + }) + + t.Run("no project selected", func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.APIKey = "sk_xxx" + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "no project selected") + }) + + t.Run("Outpost type passes", func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.APIKey = "sk_xxx" + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectType = config.ProjectTypeOutpost + assert.NoError(t, requireOutpostProject(cfg)) + }) + + t.Run("outpost mode passes when type is empty", func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.APIKey = "sk_xxx" + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectMode = "outpost" + assert.NoError(t, requireOutpostProject(cfg)) + }) + + // The point of the gate: without it these produce a 404 from the API, which + // reads as "no such resource" rather than "wrong project". + for name, projectType := range map[string]string{ + "Gateway type fails": config.ProjectTypeGateway, + "Console type fails": config.ProjectTypeConsole, + } { + t.Run(name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.APIKey = "sk_xxx" + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectType = projectType + + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires an Outpost project") + assert.Contains(t, err.Error(), "hookdeck project use", "the error should say how to fix it") + }) + } + + t.Run("inbound mode fails when type is empty", func(t *testing.T) { + cfg := &config.Config{} + cfg.Profile.APIKey = "sk_xxx" + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectMode = "inbound" + + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires an Outpost project") + }) +} + +func TestIsOutpostMCPLeafCommand(t *testing.T) { + t.Parallel() + + outpost := &cobra.Command{Use: "outpost"} + mcp := &cobra.Command{Use: "mcp"} + outpost.AddCommand(mcp) + + tenant := &cobra.Command{Use: "tenant"} + outpost.AddCommand(tenant) + + gateway := &cobra.Command{Use: "gateway"} + gatewayMCP := &cobra.Command{Use: "mcp"} + gateway.AddCommand(gatewayMCP) + + assert.True(t, isOutpostMCPLeafCommand(mcp)) + assert.False(t, isOutpostMCPLeafCommand(tenant)) + assert.False(t, isOutpostMCPLeafCommand(gatewayMCP), "the gateway MCP command has its own handling") + assert.False(t, isOutpostMCPLeafCommand(outpost)) + assert.False(t, isOutpostMCPLeafCommand(nil)) +} + +func TestOutpostCommandIsRegistered(t *testing.T) { + t.Parallel() + + cmd, _, err := RootCmd().Find([]string{"outpost"}) + require.NoError(t, err) + assert.Equal(t, "outpost", cmd.Name()) + assert.NotNil(t, cmd.PersistentPreRunE, "the project gate must run before every subcommand") +} diff --git a/pkg/cmd/project_list.go b/pkg/cmd/project_list.go index db620ee2..86177b77 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -16,19 +16,19 @@ import ( var validProjectTypes = []string{"gateway", "outpost", "console"} type projectListCmd struct { - cmd *cobra.Command - output string - typeFilter string + cmd *cobra.Command + output string + typeFilter string } func newProjectListCmd() *projectListCmd { lc := &projectListCmd{} lc.cmd = &cobra.Command{ - Use: "list [] []", - Args: validators.MaximumNArgs(2), - Short: "List and filter projects by organization and project name substrings", - RunE: lc.runProjectListCmd, + Use: "list [] []", + Args: validators.MaximumNArgs(2), + Short: "List and filter projects by organization and project name substrings", + RunE: lc.runProjectListCmd, Example: `$ hookdeck project list Acme / Ecommerce Production (current) | Gateway Acme / Ecommerce Staging | Gateway diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index 4d6f1e31..f1ce8ba5 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -24,10 +24,10 @@ func newProjectUseCmd() *projectUseCmd { lc := &projectUseCmd{} lc.cmd = &cobra.Command{ - Use: "use [ []]", - Args: validators.MaximumNArgs(2), - Short: "Set the active project for future commands", - RunE: lc.runProjectUseCmd, + Use: "use [ []]", + Args: validators.MaximumNArgs(2), + Short: "Set the active project for future commands", + RunE: lc.runProjectUseCmd, Example: `$ hookdeck project use Use the arrow keys to navigate: ↓ ↑ → ← ? Select Project: diff --git a/pkg/cmd/request_list.go b/pkg/cmd/request_list.go index 1fdd6905..0fd679a7 100644 --- a/pkg/cmd/request_list.go +++ b/pkg/cmd/request_list.go @@ -15,25 +15,25 @@ import ( type requestListCmd struct { cmd *cobra.Command - id string - sourceID string - status string - verified string - rejectionCause string - createdAfter string - createdBefore string - ingestedAfter string - ingestedBefore string - headers string - body string - path string - parsedQuery string - orderBy string - dir string - limit int - next string - prev string - output string + id string + sourceID string + status string + verified string + rejectionCause string + createdAfter string + createdBefore string + ingestedAfter string + ingestedBefore string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string } func newRequestListCmd() *requestListCmd { diff --git a/pkg/cmd/request_retry.go b/pkg/cmd/request_retry.go index dff80a6f..3ee2f924 100644 --- a/pkg/cmd/request_retry.go +++ b/pkg/cmd/request_retry.go @@ -12,8 +12,8 @@ import ( ) type requestRetryCmd struct { - cmd *cobra.Command - connectionIDs string + cmd *cobra.Command + connectionIDs string } func newRequestRetryCmd() *requestRetryCmd { diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 778a29fb..0305eb11 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -355,6 +355,7 @@ func init() { rootCmd.AddCommand(newWhoamiCmd().cmd) rootCmd.AddCommand(newProjectCmd().cmd) rootCmd.AddCommand(newGatewayCmd().cmd) + addOutpostCmdTo(rootCmd) rootCmd.AddCommand(newTelemetryCmd().cmd) // Backward compat: same connection command tree also at root (single definition in newConnectionCmd) addConnectionCmdTo(rootCmd) diff --git a/pkg/cmd/source_common.go b/pkg/cmd/source_common.go index c70668dc..e55fbf06 100644 --- a/pkg/cmd/source_common.go +++ b/pkg/cmd/source_common.go @@ -14,15 +14,15 @@ import ( // --source-* flags; when both --config/--config-file and individual flags are // set, --config/--config-file take precedence. type sourceConfigFlags struct { - WebhookSecret string - APIKey string - BasicAuthUser string - BasicAuthPass string - HMACSecret string - HMACAlgo string - AllowedHTTPMethods string - CustomResponseBody string - CustomResponseType string + WebhookSecret string + APIKey string + BasicAuthUser string + BasicAuthPass string + HMACSecret string + HMACAlgo string + AllowedHTTPMethods string + CustomResponseBody string + CustomResponseType string } // hasAny returns true if any individual config flag is set. diff --git a/pkg/cmd/source_count.go b/pkg/cmd/source_count.go index e245e5e4..da8ce02d 100644 --- a/pkg/cmd/source_count.go +++ b/pkg/cmd/source_count.go @@ -13,9 +13,9 @@ import ( type sourceCountCmd struct { cmd *cobra.Command - name string + name string sourceType string - disabled bool + disabled bool } func newSourceCountCmd() *sourceCountCmd { diff --git a/pkg/cmd/source_disable.go b/pkg/cmd/source_disable.go index de4d6d0f..c73a8485 100644 --- a/pkg/cmd/source_disable.go +++ b/pkg/cmd/source_disable.go @@ -21,7 +21,7 @@ func newSourceDisableCmd() *sourceDisableCmd { Args: validators.ExactArgs(1), Short: ShortDisable(ResourceSource), Long: LongDisableIntro(ResourceSource), - RunE: sc.runSourceDisableCmd, + RunE: sc.runSourceDisableCmd, } return sc diff --git a/pkg/cmd/source_enable.go b/pkg/cmd/source_enable.go index dc200855..0ffc6efa 100644 --- a/pkg/cmd/source_enable.go +++ b/pkg/cmd/source_enable.go @@ -21,7 +21,7 @@ func newSourceEnableCmd() *sourceEnableCmd { Args: validators.ExactArgs(1), Short: ShortEnable(ResourceSource), Long: LongEnableIntro(ResourceSource), - RunE: sc.runSourceEnableCmd, + RunE: sc.runSourceEnableCmd, } return sc diff --git a/pkg/cmd/source_get.go b/pkg/cmd/source_get.go index 7d8e8f49..5b9131d5 100644 --- a/pkg/cmd/source_get.go +++ b/pkg/cmd/source_get.go @@ -15,9 +15,9 @@ import ( ) type sourceGetCmd struct { - cmd *cobra.Command - output string - includeAuth bool + cmd *cobra.Command + output string + includeAuth bool } func newSourceGetCmd() *sourceGetCmd { diff --git a/pkg/cmd/source_list.go b/pkg/cmd/source_list.go index 72440a20..a288d9b2 100644 --- a/pkg/cmd/source_list.go +++ b/pkg/cmd/source_list.go @@ -15,11 +15,11 @@ import ( type sourceListCmd struct { cmd *cobra.Command - name string + name string sourceType string - disabled bool - limit int - output string + disabled bool + limit int + output string } func newSourceListCmd() *sourceListCmd { diff --git a/pkg/cmd/telemetry.go b/pkg/cmd/telemetry.go index 784b7481..a9aa444c 100644 --- a/pkg/cmd/telemetry.go +++ b/pkg/cmd/telemetry.go @@ -19,9 +19,9 @@ func newTelemetryCmd() *telemetryCmd { Long: "Enable or disable anonymous telemetry that helps improve the Hookdeck CLI. Telemetry is enabled by default. You can also set the HOOKDECK_CLI_TELEMETRY_DISABLED environment variable to 1 or true.", Example: ` $ hookdeck telemetry disabled $ hookdeck telemetry enabled`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), ValidArgs: []string{"enabled", "disabled"}, - RunE: tc.runTelemetryCmd, + RunE: tc.runTelemetryCmd, } return tc diff --git a/pkg/cmd/transformation_count.go b/pkg/cmd/transformation_count.go index f963108e..44dca8e2 100644 --- a/pkg/cmd/transformation_count.go +++ b/pkg/cmd/transformation_count.go @@ -11,8 +11,8 @@ import ( ) type transformationCountCmd struct { - cmd *cobra.Command - name string + cmd *cobra.Command + name string output string } diff --git a/pkg/cmd/transformation_create.go b/pkg/cmd/transformation_create.go index 5a0524d5..1cc4959a 100644 --- a/pkg/cmd/transformation_create.go +++ b/pkg/cmd/transformation_create.go @@ -14,12 +14,12 @@ import ( ) type transformationCreateCmd struct { - cmd *cobra.Command - name string - code string - codeFile string - env string - output string + cmd *cobra.Command + name string + code string + codeFile string + env string + output string } func newTransformationCreateCmd() *transformationCreateCmd { diff --git a/pkg/cmd/transformation_executions.go b/pkg/cmd/transformation_executions.go index f61af14d..9b73405c 100644 --- a/pkg/cmd/transformation_executions.go +++ b/pkg/cmd/transformation_executions.go @@ -26,18 +26,18 @@ func newTransformationExecutionsCmd() *cobra.Command { } type transformationExecutionsListCmd struct { - cmd *cobra.Command - trnID string - logLevel string + cmd *cobra.Command + trnID string + logLevel string connectionID string - issueID string - createdAt string - orderBy string - dir string - limit int - next string - prev string - output string + issueID string + createdAt string + orderBy string + dir string + limit int + next string + prev string + output string } func newTransformationExecutionsListCmd() *transformationExecutionsListCmd { diff --git a/pkg/cmd/transformation_get.go b/pkg/cmd/transformation_get.go index 747ca182..a6114ad4 100644 --- a/pkg/cmd/transformation_get.go +++ b/pkg/cmd/transformation_get.go @@ -26,7 +26,7 @@ func newTransformationGetCmd() *transformationGetCmd { Use: "get ", Args: validators.ExactArgs(1), Short: ShortGet(ResourceTransformation), - Long: LongGetIntro(ResourceTransformation) + ` + Long: LongGetIntro(ResourceTransformation) + ` Examples: hookdeck gateway transformation get trn_abc123 diff --git a/pkg/cmd/transformation_list.go b/pkg/cmd/transformation_list.go index f07a3a4f..89288c2a 100644 --- a/pkg/cmd/transformation_list.go +++ b/pkg/cmd/transformation_list.go @@ -15,14 +15,14 @@ import ( type transformationListCmd struct { cmd *cobra.Command - id string - name string - orderBy string - dir string - limit int - next string - prev string - output string + id string + name string + orderBy string + dir string + limit int + next string + prev string + output string } func newTransformationListCmd() *transformationListCmd { diff --git a/pkg/cmd/transformation_run.go b/pkg/cmd/transformation_run.go index f075ec7e..c67cd78e 100644 --- a/pkg/cmd/transformation_run.go +++ b/pkg/cmd/transformation_run.go @@ -13,15 +13,15 @@ import ( ) type transformationRunCmd struct { - cmd *cobra.Command - code string - codeFile string - transformationID string - request string - requestFile string - connectionID string - env string - output string + cmd *cobra.Command + code string + codeFile string + transformationID string + request string + requestFile string + connectionID string + env string + output string } func newTransformationRunCmd() *transformationRunCmd { @@ -115,9 +115,9 @@ func (tc *transformationRunCmd) runTransformationRunCmd(cmd *cobra.Command, args } req := &hookdeck.TransformationRunRequest{ - Request: &requestInput, - Env: envMap, - WebhookID: tc.connectionID, + Request: &requestInput, + Env: envMap, + WebhookID: tc.connectionID, } if code != "" { req.Code = code From a38bfe84955cf9d97ef2387af21a2239b9fae092 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 16:06:11 +0100 Subject: [PATCH 03/49] feat(outpost): add destination commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `hookdeck outpost destination` — list, get, create, update, delete, enable and disable — with --tenant-id persistent across the group, since every destination endpoint is tenant-scoped. Deviation from the plan worth noting. The plan called for flat per-field flags (--config-url, --credential-secret). That is not implementable here: Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema, so declaring them would mean a network call before every command could parse its own arguments. Config and credentials are repeatable key=value pairs instead (--config url=https://example.com), with --config-file and --credentials-file as escape hatches. The schema is still used, for validation rather than flag registration: unknown keys, missing required fields, values outside a declared option set and values failing a declared pattern are all rejected before the request, naming the exact flag to fix and pointing at `destination-type get ` for the field list. Per AGENTS.md, a schema that cannot be fetched warns and continues rather than blocking a valid command. Update reads the existing destination to recover its type, so callers do not have to repeat --type just to get their config validated, and refuses an update with no fields rather than silently succeeding. Verified against a real project: create, list, get, update, enable, disable, schema validation, unknown type, missing tenant, and the no-terminal delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost.go | 1 + pkg/cmd/outpost_destination.go | 54 +++++++ pkg/cmd/outpost_destination_common.go | 199 +++++++++++++++++++++++++ pkg/cmd/outpost_destination_create.go | 120 +++++++++++++++ pkg/cmd/outpost_destination_delete.go | 81 ++++++++++ pkg/cmd/outpost_destination_disable.go | 61 ++++++++ pkg/cmd/outpost_destination_enable.go | 71 +++++++++ pkg/cmd/outpost_destination_get.go | 67 +++++++++ pkg/cmd/outpost_destination_list.go | 89 +++++++++++ pkg/cmd/outpost_destination_update.go | 121 +++++++++++++++ pkg/cmd/outposttypes/types.go | 17 +-- pkg/cmd/outposttypes/types_test.go | 16 +- 12 files changed, 878 insertions(+), 19 deletions(-) create mode 100644 pkg/cmd/outpost_destination.go create mode 100644 pkg/cmd/outpost_destination_common.go create mode 100644 pkg/cmd/outpost_destination_create.go create mode 100644 pkg/cmd/outpost_destination_delete.go create mode 100644 pkg/cmd/outpost_destination_disable.go create mode 100644 pkg/cmd/outpost_destination_enable.go create mode 100644 pkg/cmd/outpost_destination_get.go create mode 100644 pkg/cmd/outpost_destination_list.go create mode 100644 pkg/cmd/outpost_destination_update.go diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go index 04fcc39c..ebb7138b 100644 --- a/pkg/cmd/outpost.go +++ b/pkg/cmd/outpost.go @@ -98,6 +98,7 @@ These commands require an Outpost project. Use 'hookdeck project use' to switch. } oc.cmd.AddCommand(newOutpostTenantCmd().cmd) + oc.cmd.AddCommand(newOutpostDestinationCmd().cmd) return oc } diff --git a/pkg/cmd/outpost_destination.go b/pkg/cmd/outpost_destination.go new file mode 100644 index 00000000..48cbe949 --- /dev/null +++ b/pkg/cmd/outpost_destination.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationCmd struct { + cmd *cobra.Command + + // tenantID is persistent across the group: every destination endpoint is + // scoped to a tenant, so requiring it once per subcommand would be noise. + tenantID string +} + +func newOutpostDestinationCmd() *outpostDestinationCmd { + dc := &outpostDestinationCmd{} + + dc.cmd = &cobra.Command{ + Use: "destination", + Aliases: []string{"destinations"}, + Args: validators.NoArgs, + Short: ShortBeta("Manage your Outpost destinations"), + Long: LongBeta(`Manage the destinations events are delivered to. + +Destinations belong to a tenant, so every command here takes --tenant-id. + +Config and credential fields depend on the destination type. Pass them as +repeatable key=value pairs — for example '--config url=https://example.com' — and +run 'hookdeck outpost destination-type get ' to see what a type accepts.`), + } + + dc.cmd.PersistentFlags().StringVar(&dc.tenantID, "tenant-id", "", "The tenant that owns the destination (required)") + + dc.cmd.AddCommand(newOutpostDestinationListCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationGetCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationCreateCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationUpdateCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationDeleteCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationEnableCmd(dc).cmd) + dc.cmd.AddCommand(newOutpostDestinationDisableCmd(dc).cmd) + + return dc +} + +// requireTenantID reports a missing --tenant-id in the same terms as the flag, +// rather than letting the request go out and fail as a 404. +func (dc *outpostDestinationCmd) requireTenantID() error { + if dc.tenantID == "" { + return errMissingTenantID + } + return nil +} diff --git a/pkg/cmd/outpost_destination_common.go b/pkg/cmd/outpost_destination_common.go new file mode 100644 index 00000000..df442ba5 --- /dev/null +++ b/pkg/cmd/outpost_destination_common.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + + "github.com/hookdeck/hookdeck-cli/pkg/cmd/outposttypes" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// outpostDestinationFieldFlags carries the type-specific parts of a destination. +// +// Destination config and credential fields differ per type and change server +// side, so they cannot be declared as individual Cobra flags: the CLI does not +// know the field set until it has fetched the schema, and doing that at flag +// registration would mean a network call before every command. They are taken +// as repeatable key=value pairs instead and validated against the fetched +// schema, so a wrong key is still rejected with the right message. +// +// Use 'hookdeck outpost destination-type get ' to see the fields a type +// accepts. +type outpostDestinationFieldFlags struct { + config []string + credential []string + configFile string + credentialsFile string + topics string + filter string + filterFile string +} + +func addOutpostDestinationFieldFlags(cmd *cobra.Command, f *outpostDestinationFieldFlags) { + cmd.Flags().StringArrayVar(&f.config, "config", nil, "Config field as key=value (repeatable), e.g. --config url=https://example.com") + cmd.Flags().StringArrayVar(&f.credential, "credential", nil, "Credential field as key=value (repeatable)") + cmd.Flags().StringVar(&f.configFile, "config-file", "", "Path to a JSON file of config fields") + cmd.Flags().StringVar(&f.credentialsFile, "credentials-file", "", "Path to a JSON file of credential fields") + cmd.Flags().StringVar(&f.topics, "topics", "", `Topics to subscribe to, comma-separated, or "*" for all`) + cmd.Flags().StringVar(&f.filter, "filter", "", "Event filter as a JSON object") + cmd.Flags().StringVar(&f.filterFile, "filter-file", "", "Path to a JSON file containing an event filter") +} + +func (f *outpostDestinationFieldFlags) validate() error { + if len(f.config) > 0 && f.configFile != "" { + return fmt.Errorf("--config and --config-file cannot be used together") + } + if len(f.credential) > 0 && f.credentialsFile != "" { + return fmt.Errorf("--credential and --credentials-file cannot be used together") + } + if f.filter != "" && f.filterFile != "" { + return fmt.Errorf("--filter and --filter-file cannot be used together") + } + return nil +} + +func (f *outpostDestinationFieldFlags) hasAny() bool { + return len(f.config) > 0 || len(f.credential) > 0 || f.configFile != "" || + f.credentialsFile != "" || f.topics != "" || f.filter != "" || f.filterFile != "" +} + +func (f *outpostDestinationFieldFlags) resolveConfig() (map[string]interface{}, error) { + return resolveOutpostFieldMap(f.config, f.configFile, "config") +} + +func (f *outpostDestinationFieldFlags) resolveCredentials() (map[string]interface{}, error) { + return resolveOutpostFieldMap(f.credential, f.credentialsFile, "credential") +} + +// resolveTopics returns the topics to send. Nil means "leave unchanged", which +// on update is the difference between not touching topics and clearing them. +func (f *outpostDestinationFieldFlags) resolveTopics() hookdeck.OutpostTopics { + if f.topics == "" { + return nil + } + if strings.TrimSpace(f.topics) == hookdeck.OutpostTopicsWildcard { + return hookdeck.OutpostTopics{hookdeck.OutpostTopicsWildcard} + } + return hookdeck.OutpostTopics(splitCommaList(f.topics)) +} + +func (f *outpostDestinationFieldFlags) resolveFilter() (map[string]interface{}, error) { + raw := f.filter + if f.filterFile != "" { + contents, err := os.ReadFile(f.filterFile) + if err != nil { + return nil, fmt.Errorf("failed to read --filter-file: %w", err) + } + raw = string(contents) + } + if strings.TrimSpace(raw) == "" { + return nil, nil + } + + var filter map[string]interface{} + if err := json.Unmarshal([]byte(raw), &filter); err != nil { + return nil, fmt.Errorf("filter must be a JSON object: %w", err) + } + return filter, nil +} + +// resolveOutpostFieldMap merges key=value pairs or a JSON file into one map. +func resolveOutpostFieldMap(pairs []string, file, kind string) (map[string]interface{}, error) { + if file != "" { + contents, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read --%s file: %w", kind, err) + } + var values map[string]interface{} + if err := json.Unmarshal(contents, &values); err != nil { + return nil, fmt.Errorf("the %s file must contain a JSON object: %w", kind, err) + } + return values, nil + } + + if len(pairs) == 0 { + return nil, nil + } + + values := make(map[string]interface{}, len(pairs)) + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("--%s %q must be in key=value form", kind, pair) + } + values[key] = value + } + return values, nil +} + +// validateOutpostDestinationFields checks config and credentials against the +// destination type's schema. +// +// Per AGENTS.md, a schema that cannot be fetched must not block the command: the +// API is the authority, so this warns and lets the request through. +func validateOutpostDestinationFields(ctx context.Context, destinationType string, config, credentials map[string]interface{}) error { + client := Config.GetOutpostAPIClient() + + schemas, err := outposttypes.FetchDestinationTypes(ctx, client) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not fetch destination type schemas (%v); continuing without local validation.\n", err) + return nil + } + + schema, found := outposttypes.Find(schemas, destinationType) + if !found { + return fmt.Errorf("unknown destination type %q. Available types: %s", + destinationType, strings.Join(outposttypes.TypeNames(schemas), ", ")) + } + + if err := outposttypes.ValidateFields(schema.ConfigFields, config, "config"); err != nil { + return fmt.Errorf("%w\n\nRun 'hookdeck outpost destination-type get %s' to see the fields this type accepts", err, destinationType) + } + if err := outposttypes.ValidateFields(schema.CredentialFields, credentials, "credential"); err != nil { + return fmt.Errorf("%w\n\nRun 'hookdeck outpost destination-type get %s' to see the fields this type accepts", err, destinationType) + } + + return nil +} + +// printOutpostDestination renders a destination for text output. +func printOutpostDestination(destination *hookdeck.OutpostDestination, indent string) { + color := ansi.Color(os.Stdout) + + fmt.Printf("%s%s\n", indent, color.Green(destination.ID)) + fmt.Printf("%s Type: %s\n", indent, destination.Type) + + if destination.Topics.IsWildcard() { + fmt.Printf("%s Topics: all\n", indent) + } else if len(destination.Topics) > 0 { + fmt.Printf("%s Topics: %s\n", indent, strings.Join(destination.Topics, ", ")) + } + + for _, key := range sortedKeys(destination.Config) { + fmt.Printf("%s %s: %v\n", indent, key, destination.Config[key]) + } + + if destination.Disabled() { + fmt.Printf("%s Status: %s\n", indent, color.Red("disabled")) + } else { + fmt.Printf("%s Status: %s\n", indent, color.Green("active")) + } +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/cmd/outpost_destination_create.go b/pkg/cmd/outpost_destination_create.go new file mode 100644 index 00000000..f25155f8 --- /dev/null +++ b/pkg/cmd/outpost_destination_create.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationCreateCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + fields outpostDestinationFieldFlags + destType string + output string +} + +func newOutpostDestinationCreateCmd(parent *outpostDestinationCmd) *outpostDestinationCreateCmd { + dc := &outpostDestinationCreateCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "create", + Args: validators.NoArgs, + Short: ShortCreate(ResourceDestination), + Long: `Create a destination for a tenant. + +Config and credential fields depend on --type. Pass them as repeatable key=value +pairs; run 'hookdeck outpost destination-type list' to see the available types and +'hookdeck outpost destination-type get ' to see the fields one accepts. + +Topics default to all ("*") when --topics is omitted.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationCreateCmd, + Example: ` # A webhook destination subscribed to everything + hookdeck outpost destination create --tenant-id acme --type webhook \ + --config url=https://example.com/hooks + + # Subscribed to specific topics + hookdeck outpost destination create --tenant-id acme --type webhook \ + --config url=https://example.com/hooks --topics user.created,user.updated + + # With credentials and a filter + hookdeck outpost destination create --tenant-id acme --type aws_sqs \ + --config queue_url=https://sqs.eu-west-2.amazonaws.com/1/q \ + --credential key=AKIA... --credential secret=... \ + --filter '{"data":{"tier":"pro"}}'`, + } + + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (required)") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + addOutpostDestinationFieldFlags(dc.cmd, &dc.fields) + dc.cmd.MarkFlagRequired("type") + + return dc +} + +func (dc *outpostDestinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if err := dc.parent.requireTenantID(); err != nil { + return err + } + return dc.fields.validate() +} + +func (dc *outpostDestinationCreateCmd) runOutpostDestinationCreateCmd(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + config, err := dc.fields.resolveConfig() + if err != nil { + return err + } + credentials, err := dc.fields.resolveCredentials() + if err != nil { + return err + } + filter, err := dc.fields.resolveFilter() + if err != nil { + return err + } + + if err := validateOutpostDestinationFields(ctx, dc.destType, config, credentials); err != nil { + return err + } + + topics := dc.fields.resolveTopics() + if topics == nil { + // The API requires topics, so default to everything rather than failing + // on an omitted flag. + topics = hookdeck.OutpostTopics{hookdeck.OutpostTopicsWildcard} + } + + client := Config.GetOutpostAPIClient() + + destination, err := client.CreateOutpostDestination(ctx, dc.parent.tenantID, &hookdeck.OutpostDestinationCreateRequest{ + Type: dc.destType, + Topics: topics, + Config: config, + Credentials: credentials, + Filter: filter, + }) + if err != nil { + return fmt.Errorf("failed to create destination: %w", err) + } + + if dc.output == "json" { + return printJSONIndented(destination) + } + + fmt.Printf("%s Destination created\n\n", SuccessCheck) + printOutpostDestination(destination, "") + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_destination_delete.go b/pkg/cmd/outpost_destination_delete.go new file mode 100644 index 00000000..bf7ef380 --- /dev/null +++ b/pkg/cmd/outpost_destination_delete.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationDeleteCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + force bool +} + +func newOutpostDestinationDeleteCmd(parent *outpostDestinationCmd) *outpostDestinationDeleteCmd { + dc := &outpostDestinationDeleteCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "delete ", + Args: validators.ExactArgs(1), + Short: ShortDelete(ResourceDestination), + Long: LongDeleteIntro(ResourceDestination) + ` + +Events will stop being delivered to it. To stop delivery temporarily and keep the +destination, use 'disable' instead.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationDeleteCmd, + Example: ` # Delete a destination, with a confirmation prompt + hookdeck outpost destination delete des_abc123 --tenant-id acme + + # Skip the prompt (for scripts and CI) + hookdeck outpost destination delete des_abc123 --tenant-id acme --force`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"destination-id","type":"string","description":"The ID of the destination to delete.","required":true} + ]`, + }, + } + + dc.cmd.Flags().BoolVar(&dc.force, "force", false, "Delete without confirmation") + + return dc +} + +func (dc *outpostDestinationDeleteCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + return dc.parent.requireTenantID() +} + +func (dc *outpostDestinationDeleteCmd) runOutpostDestinationDeleteCmd(cmd *cobra.Command, args []string) error { + destinationID := args[0] + + if !dc.force { + proceed, err := confirmDestructiveAction( + fmt.Sprintf("\nAre you sure you want to delete destination '%s' for tenant '%s'?", destinationID, dc.parent.tenantID), + "Deletion cancelled.", + "force", + ) + if err != nil { + return err + } + if !proceed { + return nil + } + } + + client := Config.GetOutpostAPIClient() + if err := client.DeleteOutpostDestination(context.Background(), dc.parent.tenantID, destinationID); err != nil { + return fmt.Errorf("failed to delete destination: %w", err) + } + + fmt.Printf("%s Destination %s deleted\n", SuccessCheck, destinationID) + + return nil +} diff --git a/pkg/cmd/outpost_destination_disable.go b/pkg/cmd/outpost_destination_disable.go new file mode 100644 index 00000000..4f13a343 --- /dev/null +++ b/pkg/cmd/outpost_destination_disable.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationDisableCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + output string +} + +func newOutpostDestinationDisableCmd(parent *outpostDestinationCmd) *outpostDestinationDisableCmd { + dc := &outpostDestinationDisableCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "disable ", + Args: validators.ExactArgs(1), + Short: ShortDisable(ResourceDestination), + Long: LongDisableIntro(ResourceDestination) + ` + +The destination and its configuration are kept, so 'enable' resumes delivery.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationDisableCmd, + Example: ` # Pause delivery to a destination + hookdeck outpost destination disable des_abc123 --tenant-id acme`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"destination-id","type":"string","description":"The ID of the destination to disable.","required":true} + ]`, + }, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationDisableCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + return dc.parent.requireTenantID() +} + +func (dc *outpostDestinationDisableCmd) runOutpostDestinationDisableCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + destination, err := client.DisableOutpostDestination(context.Background(), dc.parent.tenantID, args[0]) + if err != nil { + return fmt.Errorf("failed to disable destination: %w", err) + } + + return printOutpostDestinationStateChange(destination, dc.output, "disabled") +} diff --git a/pkg/cmd/outpost_destination_enable.go b/pkg/cmd/outpost_destination_enable.go new file mode 100644 index 00000000..10262f8f --- /dev/null +++ b/pkg/cmd/outpost_destination_enable.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationEnableCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + output string +} + +func newOutpostDestinationEnableCmd(parent *outpostDestinationCmd) *outpostDestinationEnableCmd { + dc := &outpostDestinationEnableCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "enable ", + Args: validators.ExactArgs(1), + Short: ShortEnable(ResourceDestination), + Long: LongEnableIntro(ResourceDestination), + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationEnableCmd, + Example: ` # Resume delivery to a destination + hookdeck outpost destination enable des_abc123 --tenant-id acme`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"destination-id","type":"string","description":"The ID of the destination to enable.","required":true} + ]`, + }, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationEnableCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + return dc.parent.requireTenantID() +} + +func (dc *outpostDestinationEnableCmd) runOutpostDestinationEnableCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + destination, err := client.EnableOutpostDestination(context.Background(), dc.parent.tenantID, args[0]) + if err != nil { + return fmt.Errorf("failed to enable destination: %w", err) + } + + return printOutpostDestinationStateChange(destination, dc.output, "enabled") +} + +// printOutpostDestinationStateChange is shared by enable and disable, which +// differ only in the verb they report. +func printOutpostDestinationStateChange(destination *hookdeck.OutpostDestination, output, verb string) error { + if output == "json" { + return printJSONIndented(destination) + } + + fmt.Printf("%s Destination %s %s\n", SuccessCheck, destination.ID, verb) + return nil +} diff --git a/pkg/cmd/outpost_destination_get.go b/pkg/cmd/outpost_destination_get.go new file mode 100644 index 00000000..b5225c8e --- /dev/null +++ b/pkg/cmd/outpost_destination_get.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationGetCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + output string +} + +func newOutpostDestinationGetCmd(parent *outpostDestinationCmd) *outpostDestinationGetCmd { + dc := &outpostDestinationGetCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceDestination), + Long: `Get details for a destination, including its config and topics.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationGetCmd, + Example: ` # Get a destination + hookdeck outpost destination get des_abc123 --tenant-id acme`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"destination-id","type":"string","description":"The ID of the destination.","required":true} + ]`, + }, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationGetCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + return dc.parent.requireTenantID() +} + +func (dc *outpostDestinationGetCmd) runOutpostDestinationGetCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + destination, err := client.GetOutpostDestination(context.Background(), dc.parent.tenantID, args[0]) + if err != nil { + return fmt.Errorf("failed to get destination: %w", err) + } + + if dc.output == "json" { + return printJSONIndented(destination) + } + + fmt.Println() + printOutpostDestination(destination, "") + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_destination_list.go b/pkg/cmd/outpost_destination_list.go new file mode 100644 index 00000000..5295661b --- /dev/null +++ b/pkg/cmd/outpost_destination_list.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// errMissingTenantID is shared by every destination subcommand so the guidance +// stays identical wherever it surfaces. +var errMissingTenantID = errors.New("--tenant-id is required. Run 'hookdeck outpost tenant list' to see available tenants") + +type outpostDestinationListCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + destType string + topics string + output string +} + +func newOutpostDestinationListCmd(parent *outpostDestinationCmd) *outpostDestinationListCmd { + dc := &outpostDestinationListCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceDestination), + Long: `List a tenant's destinations. + +This endpoint is not paginated: every destination for the tenant is returned.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationListCmd, + Example: ` # List a tenant's destinations + hookdeck outpost destination list --tenant-id acme + + # Filter by type or topic + hookdeck outpost destination list --tenant-id acme --type webhook + hookdeck outpost destination list --tenant-id acme --topics user.created`, + } + + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Filter by destination type(s), comma-separated") + dc.cmd.Flags().StringVar(&dc.topics, "topics", "", "Filter by topic(s), comma-separated") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationListCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + return dc.parent.requireTenantID() +} + +func (dc *outpostDestinationListCmd) runOutpostDestinationListCmd(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + destinations, err := client.ListOutpostDestinations( + context.Background(), + dc.parent.tenantID, + splitCommaList(dc.destType), + splitCommaList(dc.topics), + ) + if err != nil { + return fmt.Errorf("failed to list destinations: %w", err) + } + + if dc.output == "json" { + return printJSONIndented(destinations) + } + + if len(destinations) == 0 { + fmt.Println("No destinations found.") + return nil + } + + fmt.Printf("\nFound %d destination(s) for tenant %s:\n\n", len(destinations), dc.parent.tenantID) + for i := range destinations { + printOutpostDestination(&destinations[i], "") + fmt.Println() + } + + return nil +} diff --git a/pkg/cmd/outpost_destination_update.go b/pkg/cmd/outpost_destination_update.go new file mode 100644 index 00000000..5c0cc248 --- /dev/null +++ b/pkg/cmd/outpost_destination_update.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationUpdateCmd struct { + cmd *cobra.Command + parent *outpostDestinationCmd + + fields outpostDestinationFieldFlags + output string +} + +func newOutpostDestinationUpdateCmd(parent *outpostDestinationCmd) *outpostDestinationUpdateCmd { + dc := &outpostDestinationUpdateCmd{parent: parent} + + dc.cmd = &cobra.Command{ + Use: "update ", + Args: validators.ExactArgs(1), + Short: ShortUpdate(ResourceDestination), + Long: LongUpdateIntro(ResourceDestination) + ` + +Only the fields you pass are changed; omitted fields are left alone. + +--filter is the exception: the API replaces the filter wholesale rather than +merging into it, so pass the complete filter you want.`, + PreRunE: dc.validateFlags, + RunE: dc.runOutpostDestinationUpdateCmd, + Example: ` # Point a destination at a new URL + hookdeck outpost destination update des_abc123 --tenant-id acme \ + --config url=https://example.com/new + + # Change which topics it receives + hookdeck outpost destination update des_abc123 --tenant-id acme --topics "*"`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"destination-id","type":"string","description":"The ID of the destination to update.","required":true} + ]`, + }, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + addOutpostDestinationFieldFlags(dc.cmd, &dc.fields) + + return dc +} + +func (dc *outpostDestinationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if err := dc.parent.requireTenantID(); err != nil { + return err + } + if err := dc.fields.validate(); err != nil { + return err + } + // An update with nothing to update is a no-op that looks like a success. + if !dc.fields.hasAny() { + return fmt.Errorf("nothing to update. Pass at least one of --config, --credential, --topics or --filter") + } + return nil +} + +func (dc *outpostDestinationUpdateCmd) runOutpostDestinationUpdateCmd(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + config, err := dc.fields.resolveConfig() + if err != nil { + return err + } + credentials, err := dc.fields.resolveCredentials() + if err != nil { + return err + } + filter, err := dc.fields.resolveFilter() + if err != nil { + return err + } + + client := Config.GetOutpostAPIClient() + + // The type is fixed at creation, so it is read back to validate the fields + // being changed rather than asking the user to repeat it. + if len(config) > 0 || len(credentials) > 0 { + existing, err := client.GetOutpostDestination(ctx, dc.parent.tenantID, args[0]) + if err != nil { + return fmt.Errorf("failed to look up destination: %w", err) + } + if err := validateOutpostDestinationFields(ctx, existing.Type, config, credentials); err != nil { + return err + } + } + + destination, err := client.UpdateOutpostDestination(ctx, dc.parent.tenantID, args[0], &hookdeck.OutpostDestinationUpdateRequest{ + Topics: dc.fields.resolveTopics(), + Config: config, + Credentials: credentials, + Filter: filter, + }) + if err != nil { + return fmt.Errorf("failed to update destination: %w", err) + } + + if dc.output == "json" { + return printJSONIndented(destination) + } + + fmt.Printf("%s Destination updated\n\n", SuccessCheck) + printOutpostDestination(destination, "") + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outposttypes/types.go b/pkg/cmd/outposttypes/types.go index 5c65d06a..d1509bda 100644 --- a/pkg/cmd/outposttypes/types.go +++ b/pkg/cmd/outposttypes/types.go @@ -96,14 +96,14 @@ func ValidateFields(fields []Field, values map[string]interface{}, kind string) } value, present := values[field.Key] if !present || isEmptyValue(value) { - problems = append(problems, fmt.Sprintf("--%s-%s is required", kind, flagName(field.Key))) + problems = append(problems, fmt.Sprintf("--%s %s= is required", kind, field.Key)) } } for key, value := range values { field, ok := known[key] if !ok { - problems = append(problems, fmt.Sprintf("--%s-%s is not a valid %s field", kind, flagName(key), kind)) + problems = append(problems, fmt.Sprintf("%q is not a valid %s field", key, kind)) continue } @@ -113,8 +113,8 @@ func ValidateFields(fields []Field, values map[string]interface{}, kind string) } if options := field.OptionValues(); len(options) > 0 && !containsFold(options, text) { - problems = append(problems, fmt.Sprintf("--%s-%s must be one of: %s", - kind, flagName(key), strings.Join(options, ", "))) + problems = append(problems, fmt.Sprintf("--%s %s must be one of: %s", + kind, key, strings.Join(options, ", "))) continue } @@ -123,8 +123,8 @@ func ValidateFields(fields []Field, values map[string]interface{}, kind string) // schema, not the user's input, so it is ignored rather than // reported as a validation failure. if re, err := regexp.Compile(field.Pattern); err == nil && !re.MatchString(text) { - problems = append(problems, fmt.Sprintf("--%s-%s does not match the expected format (%s)", - kind, flagName(key), field.Pattern)) + problems = append(problems, fmt.Sprintf("--%s %s does not match the expected format (%s)", + kind, key, field.Pattern)) } } } @@ -137,11 +137,6 @@ func ValidateFields(fields []Field, values map[string]interface{}, kind string) return fmt.Errorf("%s", strings.Join(problems, "\n")) } -// flagName converts a schema field key to the CLI flag spelling. -func flagName(key string) string { - return strings.ReplaceAll(key, "_", "-") -} - func containsFold(options []string, value string) bool { for _, option := range options { if strings.EqualFold(option, value) { diff --git a/pkg/cmd/outposttypes/types_test.go b/pkg/cmd/outposttypes/types_test.go index c39c0cac..bf061a4e 100644 --- a/pkg/cmd/outposttypes/types_test.go +++ b/pkg/cmd/outposttypes/types_test.go @@ -192,13 +192,13 @@ func TestValidateFields(t *testing.T) { t.Run("reports a missing required field using its flag name", func(t *testing.T) { err := ValidateFields(configFields, map[string]interface{}{}, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-url is required") + assert.Contains(t, err.Error(), "--config url= is required") }) t.Run("treats a blank required value as missing", func(t *testing.T) { err := ValidateFields(configFields, map[string]interface{}{"url": " "}, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-url is required") + assert.Contains(t, err.Error(), "--config url= is required") }) t.Run("rejects an unknown field", func(t *testing.T) { @@ -207,13 +207,13 @@ func TestValidateFields(t *testing.T) { "unknown": "x", }, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-unknown is not a valid config field") + assert.Contains(t, err.Error(), `"unknown" is not a valid config field`) }) - t.Run("converts underscores in keys to dashes in flag names", func(t *testing.T) { + t.Run("uses the schema key verbatim, matching the key=value flag form", func(t *testing.T) { err := ValidateFields([]Field{{Key: "queue_url", Required: true}}, map[string]interface{}{}, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-queue-url is required") + assert.Contains(t, err.Error(), "--config queue_url= is required") }) t.Run("rejects a value outside the declared options", func(t *testing.T) { @@ -222,13 +222,13 @@ func TestValidateFields(t *testing.T) { "region": "mars-1", }, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-region must be one of: us-east-1, eu-west-2") + assert.Contains(t, err.Error(), "--config region must be one of: us-east-1, eu-west-2") }) t.Run("rejects a value failing the declared pattern", func(t *testing.T) { err := ValidateFields(configFields, map[string]interface{}{"url": "ftp://example.com"}, "config") require.Error(t, err) - assert.Contains(t, err.Error(), "--config-url does not match the expected format") + assert.Contains(t, err.Error(), "--config url does not match the expected format") }) t.Run("ignores a pattern the schema declares but Go cannot compile", func(t *testing.T) { @@ -242,6 +242,6 @@ func TestValidateFields(t *testing.T) { t.Run("names the credential group when validating credentials", func(t *testing.T) { err := ValidateFields([]Field{{Key: "secret", Required: true}}, map[string]interface{}{}, "credential") require.Error(t, err) - assert.Contains(t, err.Error(), "--credential-secret is required") + assert.Contains(t, err.Error(), "--credential secret= is required") }) } From 9fce42bfd51d316ccba47f247d479135b376d638 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 16:31:58 +0100 Subject: [PATCH 04/49] feat(outpost): add destination-type commands and per-type dynamic help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `hookdeck outpost destination-type list|get`, and makes `destination create --type --help` list that type's fields. Dynamic help is the answer to the discoverability cost of key=value config flags: `--config` alone cannot say which keys are valid, because the fields belong to the Outpost deployment rather than the CLI. Cobra parses flags before running the help function, so once a user has named a --type we can show exactly the fields it accepts, sourced from the same schema used for validation. Three properties this holds to: - Plain `--help` is untouched and needs no network or credentials. It only gains a line saying how to get per-type detail. - Cache first. The schema cache is already per host and project with a 24h TTL, so the warm path is a local file read. A cold cache allows one request bounded at 2s, and only when credentials exist; unauthenticated, offline and cold-cache runs all fall back to static help rather than erroring or hanging. - REFERENCE.md cannot be affected. The generator reads Long and the flag definitions directly and never invokes help, so generated docs stay identical whatever is cached locally. Verified with warm and cold caches, and pinned by a test asserting help never rewrites Long or flag usage. One non-obvious detail: Cobra returns flag.ErrHelp before running the cobra.OnInitialize hooks, so on the help path the config is not loaded yet. Without initialising it the client has no base URL or project and the cache — keyed on both — is never found. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost.go | 1 + pkg/cmd/outpost_destination_create.go | 3 + pkg/cmd/outpost_destination_type.go | 157 ++++++++++++++++ pkg/cmd/outpost_destination_type_render.go | 175 ++++++++++++++++++ .../outpost_destination_type_render_test.go | 112 +++++++++++ pkg/cmd/outposttypes/types.go | 13 ++ 6 files changed, 461 insertions(+) create mode 100644 pkg/cmd/outpost_destination_type.go create mode 100644 pkg/cmd/outpost_destination_type_render.go create mode 100644 pkg/cmd/outpost_destination_type_render_test.go diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go index ebb7138b..f80043bf 100644 --- a/pkg/cmd/outpost.go +++ b/pkg/cmd/outpost.go @@ -99,6 +99,7 @@ These commands require an Outpost project. Use 'hookdeck project use' to switch. oc.cmd.AddCommand(newOutpostTenantCmd().cmd) oc.cmd.AddCommand(newOutpostDestinationCmd().cmd) + oc.cmd.AddCommand(newOutpostDestinationTypeCmd().cmd) return oc } diff --git a/pkg/cmd/outpost_destination_create.go b/pkg/cmd/outpost_destination_create.go index f25155f8..e8e27917 100644 --- a/pkg/cmd/outpost_destination_create.go +++ b/pkg/cmd/outpost_destination_create.go @@ -55,6 +55,9 @@ Topics default to all ("*") when --topics is omitted.`, addOutpostDestinationFieldFlags(dc.cmd, &dc.fields) dc.cmd.MarkFlagRequired("type") + // `--type X --help` lists that type's fields; plain `--help` is untouched. + addOutpostDestinationTypeHelp(dc.cmd, &dc.destType) + return dc } diff --git a/pkg/cmd/outpost_destination_type.go b/pkg/cmd/outpost_destination_type.go new file mode 100644 index 00000000..502aa64e --- /dev/null +++ b/pkg/cmd/outpost_destination_type.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/cmd/outposttypes" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostDestinationTypeCmd struct { + cmd *cobra.Command +} + +func newOutpostDestinationTypeCmd() *outpostDestinationTypeCmd { + dc := &outpostDestinationTypeCmd{} + + dc.cmd = &cobra.Command{ + Use: "destination-type", + Aliases: []string{"destination-types"}, + Args: validators.NoArgs, + Short: ShortBeta("Inspect available destination types"), + Long: LongBeta(`Inspect the destination types this project can create, and the fields each accepts. + +Destination types are defined by the Outpost deployment rather than the CLI, so +this is the authoritative list — it stays correct as new types are added.`), + } + + dc.cmd.AddCommand(newOutpostDestinationTypeListCmd().cmd) + dc.cmd.AddCommand(newOutpostDestinationTypeGetCmd().cmd) + + return dc +} + +type outpostDestinationTypeListCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostDestinationTypeListCmd() *outpostDestinationTypeListCmd { + dc := &outpostDestinationTypeListCmd{} + + dc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceDestinationType), + Long: `List the destination types available in this project.`, + RunE: dc.run, + Example: ` # List available destination types + hookdeck outpost destination-type list`, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationTypeListCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + schemas, err := outposttypes.FetchDestinationTypes(context.Background(), client) + if err != nil { + return fmt.Errorf("failed to list destination types: %w", err) + } + + if dc.output == "json" { + return printJSONIndented(schemas) + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d destination type(s):\n\n", len(schemas)) + for _, schema := range schemas { + fmt.Printf("%s\n", color.Green(schema.Type)) + if schema.Label != "" { + fmt.Printf(" %s\n", schema.Label) + } + if schema.Description != "" { + fmt.Printf(" %s\n", schema.Description) + } + fmt.Println() + } + fmt.Println("Run 'hookdeck outpost destination-type get ' to see the fields a type accepts.") + + return nil +} + +type outpostDestinationTypeGetCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostDestinationTypeGetCmd() *outpostDestinationTypeGetCmd { + dc := &outpostDestinationTypeGetCmd{} + + dc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceDestinationType), + Long: `Show the config and credential fields a destination type accepts. + +Each field lists whether it is required, whether it is sensitive, and any values +or format the schema constrains it to.`, + RunE: dc.run, + Example: ` # Show the fields a webhook destination accepts + hookdeck outpost destination-type get webhook`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"type","type":"string","description":"The destination type to describe (e.g. webhook, aws_sqs).","required":true} + ]`, + }, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *outpostDestinationTypeGetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + ctx := context.Background() + + schemas, err := outposttypes.FetchDestinationTypes(ctx, client) + if err != nil { + return fmt.Errorf("failed to fetch destination types: %w", err) + } + + schema, found := outposttypes.Find(schemas, args[0]) + if !found { + return fmt.Errorf("unknown destination type %q. Available types: %s", + args[0], strings.Join(outposttypes.TypeNames(schemas), ", ")) + } + + if dc.output == "json" { + return printJSONIndented(schema) + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(schema.Type)) + if schema.Label != "" { + fmt.Printf(" %s\n", schema.Label) + } + if schema.Description != "" { + fmt.Printf(" %s\n", schema.Description) + } + + writeOutpostDestinationTypeFields(os.Stdout, schema, true) + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_destination_type_render.go b/pkg/cmd/outpost_destination_type_render.go new file mode 100644 index 00000000..f608fedd --- /dev/null +++ b/pkg/cmd/outpost_destination_type_render.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/cmd/outposttypes" +) + +// helpSchemaTimeout bounds the one network call --help is allowed to make. +// +// Help must never hang. A cold cache is worth a short fetch because the user has +// already named a --type and clearly wants its fields, but past this point it is +// better to print less than to make someone wait on `--help`. +const helpSchemaTimeout = 2 * time.Second + +// writeOutpostDestinationTypeFields renders a destination type's config and +// credential fields. +// +// Shared by 'destination-type get' and the --help augmentation so the two can +// never drift into describing the same schema differently. +func writeOutpostDestinationTypeFields(w io.Writer, schema outposttypes.Schema, showExample bool) { + for _, group := range []struct { + flag string + fields []outposttypes.Field + }{ + {"--config", schema.ConfigFields}, + {"--credential", schema.CredentialFields}, + } { + if len(group.fields) == 0 { + continue + } + + fmt.Fprintf(w, "\n%s fields:\n\n", group.flag) + for _, field := range group.fields { + label := field.Label + if label == "" { + label = field.Key + } + fmt.Fprintf(w, " %-24s %s\n", field.Key, label) + if notes := describeOutpostField(field); notes != "" { + fmt.Fprintf(w, " %-24s (%s)\n", "", notes) + } + } + } + + if showExample { + fmt.Fprintf(w, "\nExample:\n %s\n", outpostDestinationExample(schema)) + } +} + +// describeOutpostField summarises the constraints the schema states for a field. +func describeOutpostField(field outposttypes.Field) string { + notes := []string{"optional"} + if field.Required { + notes[0] = "required" + } + if field.Sensitive { + notes = append(notes, "sensitive") + } + if options := field.OptionValues(); len(options) > 0 { + notes = append(notes, "one of: "+strings.Join(options, ", ")) + } + if field.Default != "" { + notes = append(notes, "default: "+field.Default) + } + if field.Pattern != "" { + notes = append(notes, "pattern: "+field.Pattern) + } + if field.Description != "" { + notes = append(notes, field.Description) + } + return strings.Join(notes, "; ") +} + +// outpostDestinationExample builds a copy-pasteable create command containing +// exactly the fields the type requires. +func outpostDestinationExample(schema outposttypes.Schema) string { + var b strings.Builder + b.WriteString("hookdeck outpost destination create --tenant-id --type " + schema.Type) + + for _, field := range schema.ConfigFields { + if field.Required { + fmt.Fprintf(&b, " \\\n --config %s=<%s>", field.Key, field.Key) + } + } + for _, field := range schema.CredentialFields { + if field.Required { + fmt.Fprintf(&b, " \\\n --credential %s=<%s>", field.Key, field.Key) + } + } + + return b.String() +} + +// addOutpostDestinationTypeHelp augments a command's help with the fields for +// whichever --type was given on the command line. +// +// Cobra parses flags before running the help function, so `create --type kafka +// --help` can show exactly that type's fields. Plain `--help` is left untouched, +// which keeps it working offline and before login. +// +// This deliberately does not modify cmd.Long: REFERENCE.md is generated by +// reading Long and the flag definitions directly, so documentation stays +// deterministic no matter what is cached locally. +func addOutpostDestinationTypeHelp(cmd *cobra.Command, destType *string) { + defaultHelp := cmd.HelpFunc() + + cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + defaultHelp(c, args) + + if destType == nil || *destType == "" { + // No type named yet, so there is nothing specific to add. Point at + // the command that lists them instead. + fmt.Fprintf(c.OutOrStdout(), + "\nTip: run this with --type --help to list that type's fields,\nor 'hookdeck outpost destination-type list' to see the available types.\n") + return + } + + schema, found := lookupOutpostSchemaForHelp(*destType) + if !found { + fmt.Fprintf(c.OutOrStdout(), + "\nRun 'hookdeck outpost destination-type get %s' to see the fields this type accepts.\n", *destType) + return + } + + out := c.OutOrStdout() + fmt.Fprintf(out, "\nFields for --type %s", schema.Type) + if schema.Label != "" { + fmt.Fprintf(out, " (%s)", schema.Label) + } + fmt.Fprintln(out) + + writeOutpostDestinationTypeFields(out, schema, false) + }) +} + +// lookupOutpostSchemaForHelp resolves a schema for help output without ever +// blocking for long or failing loudly. +// +// The cache is tried first. Only if that misses, and there are credentials to +// use, is a short bounded request made — an unauthenticated `--help` must still +// work, so no attempt is made without a key. +func lookupOutpostSchemaForHelp(destinationType string) (outposttypes.Schema, bool) { + // Cobra returns flag.ErrHelp before it runs the cobra.OnInitialize hooks, so + // on the help path the config has not been loaded yet. Without this the + // client has no base URL or project, and the schema cache — which is keyed on + // both — would never be found. + Config.InitConfig() + + client := Config.GetOutpostAPIClient() + + if schema, found := outposttypes.LookupCached(client, destinationType); found { + return schema, true + } + + if client.APIKey == "" { + return outposttypes.Schema{}, false + } + + ctx, cancel := context.WithTimeout(context.Background(), helpSchemaTimeout) + defer cancel() + + schemas, err := outposttypes.FetchDestinationTypes(ctx, client) + if err != nil { + return outposttypes.Schema{}, false + } + + return outposttypes.Find(schemas, destinationType) +} diff --git a/pkg/cmd/outpost_destination_type_render_test.go b/pkg/cmd/outpost_destination_type_render_test.go new file mode 100644 index 00000000..3498d727 --- /dev/null +++ b/pkg/cmd/outpost_destination_type_render_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/cmd/outposttypes" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +func testKafkaSchema() outposttypes.Schema { + return outposttypes.Schema{ + Type: "kafka", + Label: "Apache Kafka", + ConfigFields: []outposttypes.Field{ + {Key: "brokers", Label: "Brokers", Required: true}, + {Key: "tls", Label: "TLS", Default: "true"}, + {Key: "sasl_mechanism", Label: "SASL Mechanism", Required: true, Options: []hookdeck.OutpostDestinationTypeOption{ + {Label: "PLAIN", Value: "plain"}, + {Label: "SCRAM 256", Value: "scram-sha-256"}, + }}, + }, + CredentialFields: []outposttypes.Field{ + {Key: "password", Label: "Password", Required: true, Sensitive: true}, + }, + } +} + +func TestWriteOutpostDestinationTypeFields(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeOutpostDestinationTypeFields(&buf, testKafkaSchema(), true) + out := buf.String() + + assert.Contains(t, out, "--config fields:") + assert.Contains(t, out, "--credential fields:") + assert.Contains(t, out, "brokers") + assert.Contains(t, out, "required") + assert.Contains(t, out, "one of: plain, scram-sha-256") + assert.Contains(t, out, "default: true") + assert.Contains(t, out, "sensitive", "a sensitive field must be flagged as such") + + // The example must contain every required field and no optional one, so it + // can be pasted and run. + assert.Contains(t, out, "--config brokers=") + assert.Contains(t, out, "--config sasl_mechanism=") + assert.Contains(t, out, "--credential password=") + assert.NotContains(t, out, "--config tls=", "optional fields should stay out of the example") +} + +func TestDescribeOutpostField(t *testing.T) { + t.Parallel() + + assert.Contains(t, describeOutpostField(outposttypes.Field{Key: "x"}), "optional") + assert.Contains(t, describeOutpostField(outposttypes.Field{Key: "x", Required: true}), "required") +} + +// TestOutpostHelpDoesNotMutateCommandMetadata is the guard for REFERENCE.md. +// +// The generator reads Long and the flag definitions directly rather than +// invoking help, so dynamic help output cannot reach it — but only for as long +// as the help function keeps its changes to the output stream. +func TestOutpostHelpDoesNotMutateCommandMetadata(t *testing.T) { + t.Parallel() + + destType := "kafka" + cmd := &cobra.Command{ + Use: "create", + Long: "Static long text.", + Run: func(cmd *cobra.Command, args []string) {}, + } + cmd.Flags().StringVar(&destType, "type", "kafka", "Destination type") + + longBefore := cmd.Long + usageBefore := cmd.Flags().Lookup("type").Usage + + addOutpostDestinationTypeHelp(cmd, &destType) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.Help() + + assert.Equal(t, longBefore, cmd.Long, "help must not rewrite Long; REFERENCE.md is generated from it") + assert.Equal(t, usageBefore, cmd.Flags().Lookup("type").Usage, "help must not rewrite flag usage strings") +} + +func TestOutpostHelpWithoutTypeShowsPointer(t *testing.T) { + t.Parallel() + + empty := "" + cmd := &cobra.Command{Use: "create", Run: func(cmd *cobra.Command, args []string) {}} + addOutpostDestinationTypeHelp(cmd, &empty) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.Help() + + out := buf.String() + require.NotEmpty(t, out) + assert.Contains(t, out, "--type --help", "plain help should say how to get per-type fields") + assert.Contains(t, out, "destination-type list") + // No schema lookup should be attempted without a type, so nothing can block. + assert.False(t, strings.Contains(out, "--config fields:")) +} diff --git a/pkg/cmd/outposttypes/types.go b/pkg/cmd/outposttypes/types.go index d1509bda..30c637ae 100644 --- a/pkg/cmd/outposttypes/types.go +++ b/pkg/cmd/outposttypes/types.go @@ -203,3 +203,16 @@ func writeCache(path string, schemas []Schema) { } _ = os.WriteFile(path, data, 0o600) } + +// LookupCached returns a destination type's schema from the on-disk cache only, +// never touching the network. +// +// It exists for paths that must not block or fail, such as augmenting --help: +// a miss simply means the caller shows less, not that anything went wrong. +func LookupCached(client *hookdeck.Client, destinationType string) (Schema, bool) { + schemas, ok := readCache(cachePathFor(client)) + if !ok { + return Schema{}, false + } + return Find(schemas, destinationType) +} From ea4ee42c4dc288acfaecbb345f9af70076eb0d66 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 17:04:39 +0100 Subject: [PATCH 05/49] feat(outpost): support dotted paths in --config and --credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--config a.b=c` now builds a nested object. Flat keys are unchanged, so this is a no-op for every destination type that exists today. It is added now because of what the key=value design is for. Outpost's destination types are defined by the deployment rather than the CLI, which is why fields are not hardcoded — but that cuts both ways: a nested type could ship server-side just as easily as a new flat one. Flat-only parsing would leave such a type impossible to create until we shipped a CLI fix, which is precisely the failure the design exists to avoid. Paths cost nothing today and remove that cliff. The syntax follows Helm's --set (a.b.c=v, with a file as the escape hatch) rather than being invented here. A literal dot can be escaped as `a\.b`; no field key in either product contains one today, so that exists to avoid a corner rather than to solve a present problem. Validation now skips nested values instead of rejecting them. The schema describes flat fields, so it cannot say whether a nested shape is valid, and per AGENTS.md a client-side guess must not block a command the API would accept. Checked against the live API while deciding this: all 9 destination types are flat and every value is a string on the wire. The /destination-types endpoint reports some fields as key_value_map or checkbox, but those are form-rendering hints — sending custom_headers as an object returns it normalised to a JSON-encoded string, identical to sending a string. Context in #347. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost_destination_common.go | 65 +++++++++++++++++- pkg/cmd/outpost_destination_nested_test.go | 80 ++++++++++++++++++++++ pkg/cmd/outposttypes/types.go | 8 +++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/outpost_destination_nested_test.go diff --git a/pkg/cmd/outpost_destination_common.go b/pkg/cmd/outpost_destination_common.go index df442ba5..ca59da49 100644 --- a/pkg/cmd/outpost_destination_common.go +++ b/pkg/cmd/outpost_destination_common.go @@ -130,11 +130,74 @@ func resolveOutpostFieldMap(pairs []string, file, kind string) (map[string]inter if !found || key == "" { return nil, fmt.Errorf("--%s %q must be in key=value form", kind, pair) } - values[key] = value + if err := setNestedValue(values, key, value, kind); err != nil { + return nil, err + } } return values, nil } +// setNestedValue assigns value at a dotted path, creating intermediate maps. +// +// Outpost's destination config is flat today — every field is a top-level string +// — so in practice this is a plain assignment. It supports paths because +// destination types are defined by the deployment rather than the CLI: if a +// nested type ships, `--config a.b=c` expresses it with no CLI release, which is +// the whole point of not hardcoding a server-owned schema. +// +// A literal dot in a key can be escaped as `\.`. No current field key in either +// product contains one, so this exists to avoid painting us into a corner rather +// than to solve a present problem. +func setNestedValue(target map[string]interface{}, key, value, kind string) error { + segments := splitDottedPath(key) + + for i, segment := range segments { + if segment == "" { + return fmt.Errorf("--%s %q has an empty path segment", kind, key) + } + + if i == len(segments)-1 { + target[segment] = value + break + } + + switch existing := target[segment].(type) { + case nil: + next := map[string]interface{}{} + target[segment] = next + target = next + case map[string]interface{}: + target = existing + default: + // e.g. --config a=1 --config a.b=2, where "a" cannot be both. + return fmt.Errorf("--%s %q conflicts with an earlier value for %q", kind, key, segment) + } + } + + return nil +} + +// splitDottedPath splits on unescaped dots, so `a\.b` stays a single segment. +func splitDottedPath(key string) []string { + var segments []string + var current strings.Builder + + for i := 0; i < len(key); i++ { + switch { + case key[i] == '\\' && i+1 < len(key) && key[i+1] == '.': + current.WriteByte('.') + i++ + case key[i] == '.': + segments = append(segments, current.String()) + current.Reset() + default: + current.WriteByte(key[i]) + } + } + + return append(segments, current.String()) +} + // validateOutpostDestinationFields checks config and credentials against the // destination type's schema. // diff --git a/pkg/cmd/outpost_destination_nested_test.go b/pkg/cmd/outpost_destination_nested_test.go new file mode 100644 index 00000000..1bbf834d --- /dev/null +++ b/pkg/cmd/outpost_destination_nested_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveOutpostFieldMapDottedPaths(t *testing.T) { + t.Parallel() + + t.Run("flat keys are unchanged", func(t *testing.T) { + got, err := resolveOutpostFieldMap([]string{"url=https://example.com"}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"url": "https://example.com"}, got) + }) + + t.Run("a dotted key builds a nested object", func(t *testing.T) { + got, err := resolveOutpostFieldMap([]string{"auth.type=BEARER", "auth.token=xyz"}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "auth": map[string]interface{}{"type": "BEARER", "token": "xyz"}, + }, got) + }) + + t.Run("paths nest arbitrarily deep", func(t *testing.T) { + got, err := resolveOutpostFieldMap([]string{"a.b.c.d=v"}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "a": map[string]interface{}{"b": map[string]interface{}{"c": map[string]interface{}{"d": "v"}}}, + }, got) + }) + + t.Run("an escaped dot stays part of the key", func(t *testing.T) { + got, err := resolveOutpostFieldMap([]string{`custom\.header=value`}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"custom.header": "value"}, got) + }) + + t.Run("a value containing dots is untouched", func(t *testing.T) { + // Only the key is a path; values routinely contain dots. + got, err := resolveOutpostFieldMap([]string{"url=https://a.b.example.com/x"}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"url": "https://a.b.example.com/x"}, got) + }) + + t.Run("a value containing = is untouched", func(t *testing.T) { + got, err := resolveOutpostFieldMap([]string{"url=https://example.com?a=1&b=2"}, "", "config") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"url": "https://example.com?a=1&b=2"}, got) + }) + + t.Run("a scalar and a path cannot claim the same key", func(t *testing.T) { + _, err := resolveOutpostFieldMap([]string{"a=1", "a.b=2"}, "", "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with an earlier value") + }) + + t.Run("an empty path segment is rejected", func(t *testing.T) { + _, err := resolveOutpostFieldMap([]string{"a..b=1"}, "", "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty path segment") + }) + + t.Run("a pair without = is rejected", func(t *testing.T) { + _, err := resolveOutpostFieldMap([]string{"justakey"}, "", "config") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be in key=value form") + }) +} + +func TestSplitDottedPath(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"a"}, splitDottedPath("a")) + assert.Equal(t, []string{"a", "b"}, splitDottedPath("a.b")) + assert.Equal(t, []string{"a.b"}, splitDottedPath(`a\.b`)) + assert.Equal(t, []string{"a.b", "c"}, splitDottedPath(`a\.b.c`)) +} diff --git a/pkg/cmd/outposttypes/types.go b/pkg/cmd/outposttypes/types.go index 30c637ae..a4e9abf1 100644 --- a/pkg/cmd/outposttypes/types.go +++ b/pkg/cmd/outposttypes/types.go @@ -101,6 +101,14 @@ func ValidateFields(fields []Field, values map[string]interface{}, kind string) } for key, value := range values { + // A nested value came from a dotted path. The schema describes flat + // fields today, so it cannot say whether a nested shape is valid, and + // rejecting one here would block a command the API would have accepted. + // Defer to the API, which is the authority. + if _, nested := value.(map[string]interface{}); nested { + continue + } + field, ok := known[key] if !ok { problems = append(problems, fmt.Sprintf("%q is not a valid %s field", key, kind)) From 000cd7fd2f14662fb397037def3ec2a4e3964e8f Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 17:12:31 +0100 Subject: [PATCH 06/49] feat(outpost): add event, attempt, publish, topic, metrics, config and status commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the outpost command tree. - event list/get/retry, attempt list/get — the debugging surface. Attempts carry the response code the destination returned, which is what you actually need when delivery is failing. - publish — the one command with different auth. The publish API takes a Project API key as a bearer token and does not accept the credentials `hookdeck login` stores, so it has its own --api-key defaulting to HOOKDECK_API_KEY. Without one it fails with an actionableError explaining why, rather than surfacing a bare 401 that the generic handler would rewrite into "your API key is invalid or expired" — true but useless, since the stored key is never valid here. - topic list — reports the fix when no topics are configured, since an empty list leaves the project unable to deliver anything. - metrics events/attempts — reports when results were truncated at the row limit, so a partial answer is not mistaken for a complete one. - config get/set and config custom-domain — set takes KEY=VALUE arguments with --unset to restore a default, and --dry-run showing before/after per key. These settings apply to every tenant in the project, so the diff matters. - status — the first thing to check when configuration changes have not taken effect yet. Attempt list uses the tenant-scoped route when exactly one tenant and one destination are given, and the general one otherwise; results are identical either way. Verified against a real project: publish end to end with matched destinations, retry recorded as a manual second attempt, dry-run confirmed not to apply, pagination, and the missing-key error path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost.go | 7 + pkg/cmd/outpost_attempt.go | 248 +++++++++++++++++++++++++++ pkg/cmd/outpost_config.go | 286 +++++++++++++++++++++++++++++++ pkg/cmd/outpost_custom_domain.go | 207 ++++++++++++++++++++++ pkg/cmd/outpost_event.go | 32 ++++ pkg/cmd/outpost_event_get.go | 86 ++++++++++ pkg/cmd/outpost_event_list.go | 125 ++++++++++++++ pkg/cmd/outpost_event_retry.go | 73 ++++++++ pkg/cmd/outpost_metrics.go | 180 +++++++++++++++++++ pkg/cmd/outpost_publish.go | 176 +++++++++++++++++++ pkg/cmd/outpost_status.go | 70 ++++++++ pkg/cmd/outpost_topic.go | 90 ++++++++++ 12 files changed, 1580 insertions(+) create mode 100644 pkg/cmd/outpost_attempt.go create mode 100644 pkg/cmd/outpost_config.go create mode 100644 pkg/cmd/outpost_custom_domain.go create mode 100644 pkg/cmd/outpost_event.go create mode 100644 pkg/cmd/outpost_event_get.go create mode 100644 pkg/cmd/outpost_event_list.go create mode 100644 pkg/cmd/outpost_event_retry.go create mode 100644 pkg/cmd/outpost_metrics.go create mode 100644 pkg/cmd/outpost_publish.go create mode 100644 pkg/cmd/outpost_status.go create mode 100644 pkg/cmd/outpost_topic.go diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go index f80043bf..d9dccf70 100644 --- a/pkg/cmd/outpost.go +++ b/pkg/cmd/outpost.go @@ -100,6 +100,13 @@ These commands require an Outpost project. Use 'hookdeck project use' to switch. oc.cmd.AddCommand(newOutpostTenantCmd().cmd) oc.cmd.AddCommand(newOutpostDestinationCmd().cmd) oc.cmd.AddCommand(newOutpostDestinationTypeCmd().cmd) + oc.cmd.AddCommand(newOutpostEventCmd().cmd) + oc.cmd.AddCommand(newOutpostAttemptCmd().cmd) + oc.cmd.AddCommand(newOutpostTopicCmd().cmd) + oc.cmd.AddCommand(newOutpostStatusCmd().cmd) + oc.cmd.AddCommand(newOutpostPublishCmd().cmd) + oc.cmd.AddCommand(newOutpostMetricsCmd().cmd) + oc.cmd.AddCommand(newOutpostConfigCmd().cmd) return oc } diff --git a/pkg/cmd/outpost_attempt.go b/pkg/cmd/outpost_attempt.go new file mode 100644 index 00000000..5df27052 --- /dev/null +++ b/pkg/cmd/outpost_attempt.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostAttemptCmd struct { + cmd *cobra.Command +} + +func newOutpostAttemptCmd() *outpostAttemptCmd { + ac := &outpostAttemptCmd{} + + ac.cmd = &cobra.Command{ + Use: "attempt", + Aliases: []string{"attempts"}, + Args: validators.NoArgs, + Short: ShortBeta("Inspect delivery attempts"), + Long: LongBeta(`Inspect delivery attempts — each try at delivering an event to a destination. + +This is where to look when a destination is not receiving events: attempts carry +the response code and body the destination returned.`), + } + + ac.cmd.AddCommand(newOutpostAttemptListCmd().cmd) + ac.cmd.AddCommand(newOutpostAttemptGetCmd().cmd) + + return ac +} + +// printOutpostAttempt renders one attempt, colouring the outcome so a failure is +// obvious in a long list. +func printOutpostAttempt(attempt *hookdeck.OutpostAttempt) { + color := ansi.Color(os.Stdout) + + fmt.Printf("%s\n", color.Green(attempt.ID)) + if attempt.Succeeded() { + fmt.Printf(" Status: %s\n", color.Green(attempt.Status)) + } else { + fmt.Printf(" Status: %s\n", color.Red(attempt.Status)) + } + if attempt.Code != "" { + fmt.Printf(" Code: %s\n", attempt.Code) + } + fmt.Printf(" Event: %s\n", attempt.EventID) + fmt.Printf(" Destination: %s\n", attempt.DestinationID) + fmt.Printf(" Attempt: %d", attempt.AttemptNumber) + if attempt.Manual { + fmt.Printf(" (manual retry)") + } + fmt.Println() + fmt.Printf(" Time: %s\n", attempt.Time.Format("2006-01-02 15:04:05")) +} + +type outpostAttemptListCmd struct { + cmd *cobra.Command + + tenantID string + destinationID string + eventIDs string + destinationType string + status string + topics string + timeAfter string + timeBefore string + include string + limit int + orderBy string + dir string + next string + prev string + output string +} + +func newOutpostAttemptListCmd() *outpostAttemptListCmd { + ac := &outpostAttemptListCmd{} + + ac.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceAttempt), + Long: `List delivery attempts, most recent first. + +Passing both --tenant-id and --destination-id narrows to that destination +specifically; the filters and results are otherwise the same.`, + PreRunE: ac.validateFlags, + RunE: ac.run, + Example: ` # Recent failures + hookdeck outpost attempt list --status failed --limit 20 + + # Every attempt for one event + hookdeck outpost attempt list --event-id evt_abc123 + + # Include the response body the destination returned + hookdeck outpost attempt list --event-id evt_abc123 --include response_data --output json`, + } + + ac.cmd.Flags().StringVar(&ac.tenantID, "tenant-id", "", "Filter by tenant ID(s), comma-separated") + ac.cmd.Flags().StringVar(&ac.destinationID, "destination-id", "", "Filter by destination ID(s), comma-separated") + ac.cmd.Flags().StringVar(&ac.eventIDs, "event-id", "", "Filter by event ID(s), comma-separated") + ac.cmd.Flags().StringVar(&ac.destinationType, "destination-type", "", "Filter by destination type(s), comma-separated") + ac.cmd.Flags().StringVar(&ac.status, "status", "", "Filter by status (success, failed)") + ac.cmd.Flags().StringVar(&ac.topics, "topic", "", "Filter by topic(s), comma-separated") + ac.cmd.Flags().StringVar(&ac.timeAfter, "time-after", "", "Only attempts at or after this ISO 8601 datetime") + ac.cmd.Flags().StringVar(&ac.timeBefore, "time-before", "", "Only attempts at or before this ISO 8601 datetime") + ac.cmd.Flags().StringVar(&ac.include, "include", "", "Include related data, comma-separated (event, event.data, response_data, destination)") + ac.cmd.Flags().IntVar(&ac.limit, "limit", 0, "Limit number of results") + ac.cmd.Flags().StringVar(&ac.orderBy, "order-by", "", "Field to sort by") + ac.cmd.Flags().StringVar(&ac.dir, "dir", "", "Sort direction (asc, desc)") + ac.cmd.Flags().StringVar(&ac.next, "next", "", "Next page cursor") + ac.cmd.Flags().StringVar(&ac.prev, "prev", "", "Previous page cursor") + ac.cmd.Flags().StringVar(&ac.output, "output", "", "Output format (json)") + + return ac +} + +func (ac *outpostAttemptListCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (ac *outpostAttemptListCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + params := hookdeck.OutpostAttemptListParams{ + EventIDs: splitCommaList(ac.eventIDs), + DestinationType: splitCommaList(ac.destinationType), + Topics: splitCommaList(ac.topics), + Status: ac.status, + TimeAfter: ac.timeAfter, + TimeBefore: ac.timeBefore, + Include: splitCommaList(ac.include), + Limit: ac.limit, + OrderBy: ac.orderBy, + Dir: ac.dir, + Next: ac.next, + Prev: ac.prev, + } + + // A single tenant and destination can use the tenant-scoped route; anything + // else has to go through the filters on the general one. + tenants := splitCommaList(ac.tenantID) + destinations := splitCommaList(ac.destinationID) + if len(tenants) == 1 && len(destinations) == 1 { + params.TenantID, params.DestinationID = tenants[0], destinations[0] + } else { + params.TenantIDs, params.DestinationIDs = tenants, destinations + } + + resp, err := client.ListOutpostAttempts(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list attempts: %w", err) + } + + if ac.output == "json" { + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) + if err != nil { + return fmt.Errorf("failed to marshal attempts to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No attempts found.") + return nil + } + + fmt.Printf("\nFound %d attempt(s):\n\n", len(resp.Models)) + for i := range resp.Models { + printOutpostAttempt(&resp.Models[i]) + fmt.Println() + } + + printPaginationInfo(resp.Pagination, "hookdeck outpost attempt list") + + return nil +} + +type outpostAttemptGetCmd struct { + cmd *cobra.Command + + tenantID string + destinationID string + include string + output string +} + +func newOutpostAttemptGetCmd() *outpostAttemptGetCmd { + ac := &outpostAttemptGetCmd{} + + ac.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceAttempt), + Long: `Get a delivery attempt, including the destination's response.`, + PreRunE: ac.validateFlags, + RunE: ac.run, + Example: ` # Get an attempt with the response body + hookdeck outpost attempt get att_abc123 --include response_data --output json`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"attempt-id","type":"string","description":"The ID of the delivery attempt.","required":true} + ]`, + }, + } + + ac.cmd.Flags().StringVar(&ac.tenantID, "tenant-id", "", "Tenant the attempt belongs to") + ac.cmd.Flags().StringVar(&ac.destinationID, "destination-id", "", "Destination the attempt targeted") + ac.cmd.Flags().StringVar(&ac.include, "include", "", "Include related data, comma-separated (event, event.data, response_data, destination)") + ac.cmd.Flags().StringVar(&ac.output, "output", "", "Output format (json)") + + return ac +} + +func (ac *outpostAttemptGetCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (ac *outpostAttemptGetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + attempt, err := client.GetOutpostAttempt(context.Background(), args[0], hookdeck.OutpostAttemptGetParams{ + TenantID: ac.tenantID, + DestinationID: ac.destinationID, + Include: splitCommaList(ac.include), + }) + if err != nil { + return fmt.Errorf("failed to get attempt: %w", err) + } + + if ac.output == "json" { + return printJSONIndented(attempt) + } + + fmt.Println() + printOutpostAttempt(attempt) + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_config.go b/pkg/cmd/outpost_config.go new file mode 100644 index 00000000..e683ec67 --- /dev/null +++ b/pkg/cmd/outpost_config.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostConfigCmd struct { + cmd *cobra.Command +} + +func newOutpostConfigCmd() *outpostConfigCmd { + cc := &outpostConfigCmd{} + + cc.cmd = &cobra.Command{ + Use: "config", + Aliases: []string{"configs"}, + Args: validators.NoArgs, + Short: ShortBeta("Manage Outpost project configuration"), + Long: LongBeta(`Read and change this project's Outpost configuration. + +These settings apply to the whole project — every tenant and destination — so a +change here affects all delivery. Changes take a short while to reach the +deployment; 'hookdeck outpost status' reports when it is still being applied.`), + } + + cc.cmd.AddCommand(newOutpostConfigGetCmd().cmd) + cc.cmd.AddCommand(newOutpostConfigSetCmd().cmd) + cc.cmd.AddCommand(newOutpostCustomDomainCmd().cmd) + + return cc +} + +type outpostConfigGetCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostConfigGetCmd() *outpostConfigGetCmd { + cc := &outpostConfigGetCmd{} + + cc.cmd = &cobra.Command{ + Use: "get [key]", + Args: validators.MaximumNArgs(1), + Short: ShortBeta("Show project configuration"), + Long: LongBeta(`Show this project's Outpost configuration. + +Pass a key to print just that value, which is convenient in scripts. Unset keys +are omitted unless you ask for one by name.`), + RunE: cc.run, + Example: ` # Show everything that is set + hookdeck outpost config get + + # Show one value + hookdeck outpost config get TOPICS`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"key","type":"string","description":"A single configuration key to print.","required":false} + ]`, + }, + } + + cc.cmd.Flags().StringVar(&cc.output, "output", "", "Output format (json)") + + return cc +} + +func (cc *outpostConfigGetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + config, err := client.GetOutpostConfig(context.Background()) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + if len(args) == 1 { + value, present := config[args[0]] + if !present { + return fmt.Errorf("no configuration key named %q", args[0]) + } + if cc.output == "json" { + return printJSONIndented(map[string]*string{args[0]: value}) + } + if value != nil { + fmt.Println(*value) + } + return nil + } + + if cc.output == "json" { + return printJSONIndented(config) + } + + keys := make([]string, 0, len(config)) + for key, value := range config { + if value != nil && *value != "" { + keys = append(keys, key) + } + } + sort.Strings(keys) + + if len(keys) == 0 { + fmt.Println("No configuration values are set; the deployment is using its defaults.") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%d configuration value(s) set:\n\n", len(keys)) + for _, key := range keys { + fmt.Printf(" %s = %s\n", color.Green(key), *config[key]) + } + fmt.Println() + + return nil +} + +type outpostConfigSetCmd struct { + cmd *cobra.Command + + unset []string + configFile string + dryRun bool + output string +} + +func newOutpostConfigSetCmd() *outpostConfigSetCmd { + cc := &outpostConfigSetCmd{} + + cc.cmd = &cobra.Command{ + Use: "set [KEY=VALUE ...]", + Short: ShortBeta("Change project configuration"), + Long: LongBeta(`Change this project's Outpost configuration. + +Only the keys you pass are changed. --unset returns a key to its default. + +This affects delivery for every tenant in the project, so use --dry-run first to +see exactly what would change. + +Some keys are managed for you and are rejected if set directly; the API says +which when that happens.`), + PreRunE: cc.validateFlags, + RunE: cc.run, + Example: ` # Set the topics destinations can subscribe to + hookdeck outpost config set TOPICS=user.created,user.updated + + # Preview a change without applying it + hookdeck outpost config set MAX_RETRY_LIMIT=5 --dry-run + + # Return a key to its default + hookdeck outpost config set --unset MAX_RETRY_LIMIT`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"KEY=VALUE","type":"string","description":"Configuration values to set. Repeatable.","required":false} + ]`, + }, + } + + cc.cmd.Flags().StringArrayVar(&cc.unset, "unset", nil, "Return a key to its default (repeatable)") + cc.cmd.Flags().StringVar(&cc.configFile, "config-file", "", "Path to a JSON file of configuration values") + cc.cmd.Flags().BoolVar(&cc.dryRun, "dry-run", false, "Show what would change without applying it") + cc.cmd.Flags().StringVar(&cc.output, "output", "", "Output format (json)") + + return cc +} + +func (cc *outpostConfigSetCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if len(args) == 0 && len(cc.unset) == 0 && cc.configFile == "" { + return fmt.Errorf("nothing to change. Pass KEY=VALUE arguments, --unset, or --config-file") + } + if len(args) > 0 && cc.configFile != "" { + return fmt.Errorf("KEY=VALUE arguments and --config-file cannot be used together") + } + return nil +} + +func (cc *outpostConfigSetCmd) run(cmd *cobra.Command, args []string) error { + update, err := cc.buildUpdate(args) + if err != nil { + return err + } + + client := Config.GetOutpostAPIClient() + ctx := context.Background() + + current, err := client.GetOutpostConfig(ctx) + if err != nil { + return fmt.Errorf("failed to read current config: %w", err) + } + + if cc.dryRun { + printOutpostConfigDiff(current, update) + return nil + } + + updated, err := client.UpdateOutpostConfig(ctx, update) + if err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + if cc.output == "json" { + return printJSONIndented(updated) + } + + fmt.Printf("%s Updated %d configuration value(s)\n", SuccessCheck, len(update)) + fmt.Println("\nChanges take a short while to reach the deployment. Check with 'hookdeck outpost status'.") + + return nil +} + +func (cc *outpostConfigSetCmd) buildUpdate(args []string) (hookdeck.OutpostManagedConfig, error) { + update := hookdeck.OutpostManagedConfig{} + + if cc.configFile != "" { + contents, err := os.ReadFile(cc.configFile) + if err != nil { + return nil, fmt.Errorf("failed to read --config-file: %w", err) + } + if err := json.Unmarshal(contents, &update); err != nil { + return nil, fmt.Errorf("--config-file must contain a JSON object: %w", err) + } + } + + for _, arg := range args { + key, value, found := strings.Cut(arg, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("%q must be in KEY=VALUE form", arg) + } + v := value + update[key] = &v + } + + // A nil value is how the API is told to clear a key. + for _, key := range cc.unset { + update[strings.TrimSpace(key)] = nil + } + + return update, nil +} + +// printOutpostConfigDiff shows before and after for each key being changed. +func printOutpostConfigDiff(current, update hookdeck.OutpostManagedConfig) { + color := ansi.Color(os.Stdout) + + keys := make([]string, 0, len(update)) + for key := range update { + keys = append(keys, key) + } + sort.Strings(keys) + + fmt.Printf("\nDry run — %d value(s) would change:\n\n", len(keys)) + for _, key := range keys { + before := "(not set)" + if v, ok := current[key]; ok && v != nil && *v != "" { + before = *v + } + + after := "(default)" + if v := update[key]; v != nil { + after = *v + } + + if before == after { + fmt.Printf(" %s: unchanged (%s)\n", key, before) + continue + } + fmt.Printf(" %s:\n", color.Green(key)) + fmt.Printf(" before: %s\n", before) + fmt.Printf(" after: %s\n", after) + } + fmt.Println("\nRe-run without --dry-run to apply.") +} diff --git a/pkg/cmd/outpost_custom_domain.go b/pkg/cmd/outpost_custom_domain.go new file mode 100644 index 00000000..f1345602 --- /dev/null +++ b/pkg/cmd/outpost_custom_domain.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostCustomDomainCmd struct { + cmd *cobra.Command +} + +func newOutpostCustomDomainCmd() *outpostCustomDomainCmd { + cc := &outpostCustomDomainCmd{} + + cc.cmd = &cobra.Command{ + Use: "custom-domain", + Args: validators.NoArgs, + Short: ShortBeta("Manage the tenant portal's custom domain"), + Long: LongBeta(`Manage the custom hostname that serves your tenants' portal. + +A custom domain is required before 'hookdeck outpost tenant portal' can return a +URL. Adding one returns the DNS records to create; the domain starts working +once they have propagated and been verified.`), + } + + cc.cmd.AddCommand(newOutpostCustomDomainGetCmd().cmd) + cc.cmd.AddCommand(newOutpostCustomDomainSetCmd().cmd) + cc.cmd.AddCommand(newOutpostCustomDomainDeleteCmd().cmd) + + return cc +} + +type outpostCustomDomainGetCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostCustomDomainGetCmd() *outpostCustomDomainGetCmd { + cc := &outpostCustomDomainGetCmd{} + + cc.cmd = &cobra.Command{ + Use: "get", + Args: validators.NoArgs, + Short: ShortBeta("Show the portal custom domain"), + Long: LongBeta(`Show the custom domain configured for the tenant portal, if any.`), + RunE: cc.run, + Example: ` # Show the configured custom domain + hookdeck outpost config custom-domain get`, + } + + cc.cmd.Flags().StringVar(&cc.output, "output", "", "Output format (json)") + + return cc +} + +func (cc *outpostCustomDomainGetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + domain, err := client.GetOutpostCustomDomain(context.Background()) + if err != nil { + return fmt.Errorf("failed to get custom domain: %w", err) + } + + if cc.output == "json" { + return printJSONIndented(domain) + } + + if domain.Hostname == "" { + fmt.Println("No custom domain is configured.") + fmt.Println("\nAdd one with 'hookdeck outpost config custom-domain set '.") + return nil + } + + fmt.Printf("\nHostname: %s\n", domain.Hostname) + if domain.Status != "" { + fmt.Printf("Status: %s\n", domain.Status) + } + printOutpostDomainVerification(domain.Verification) + fmt.Println() + + return nil +} + +type outpostCustomDomainSetCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostCustomDomainSetCmd() *outpostCustomDomainSetCmd { + cc := &outpostCustomDomainSetCmd{} + + cc.cmd = &cobra.Command{ + Use: "set ", + Args: validators.ExactArgs(1), + Short: ShortBeta("Set the portal custom domain"), + Long: LongBeta(`Configure a custom hostname for the tenant portal. + +The response includes the DNS records to create. The domain is not usable until +they have propagated and been verified.`), + RunE: cc.run, + Example: ` # Configure a custom domain + hookdeck outpost config custom-domain set portal.example.com`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"hostname","type":"string","description":"The hostname to serve the tenant portal from.","required":true} + ]`, + }, + } + + cc.cmd.Flags().StringVar(&cc.output, "output", "", "Output format (json)") + + return cc +} + +func (cc *outpostCustomDomainSetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + domain, err := client.AddOutpostCustomDomain(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("failed to set custom domain: %w", err) + } + + if cc.output == "json" { + return printJSONIndented(domain) + } + + fmt.Printf("%s Custom domain %s configured\n", SuccessCheck, args[0]) + printOutpostDomainVerification(domain.Verification) + + return nil +} + +type outpostCustomDomainDeleteCmd struct { + cmd *cobra.Command + + force bool +} + +func newOutpostCustomDomainDeleteCmd() *outpostCustomDomainDeleteCmd { + cc := &outpostCustomDomainDeleteCmd{} + + cc.cmd = &cobra.Command{ + Use: "delete", + Args: validators.NoArgs, + Short: ShortBeta("Remove the portal custom domain"), + Long: LongBeta(`Remove the tenant portal's custom domain. + +Tenant portal URLs stop working until another domain is configured.`), + RunE: cc.run, + Example: ` # Remove the custom domain, with a confirmation prompt + hookdeck outpost config custom-domain delete + + # Skip the prompt (for scripts and CI) + hookdeck outpost config custom-domain delete --force`, + } + + cc.cmd.Flags().BoolVar(&cc.force, "force", false, "Delete without confirmation") + + return cc +} + +func (cc *outpostCustomDomainDeleteCmd) run(cmd *cobra.Command, args []string) error { + if !cc.force { + proceed, err := confirmDestructiveAction( + "\nAre you sure you want to remove the portal custom domain? Tenant portal URLs will stop working.", + "Deletion cancelled.", + "force", + ) + if err != nil { + return err + } + if !proceed { + return nil + } + } + + client := Config.GetOutpostAPIClient() + if err := client.DeleteOutpostCustomDomain(context.Background()); err != nil { + return fmt.Errorf("failed to delete custom domain: %w", err) + } + + fmt.Printf("%s Custom domain removed\n", SuccessCheck) + + return nil +} + +// printOutpostDomainVerification renders the DNS records that must exist for the +// domain to verify. The shape is provider-defined, so it is printed generically. +func printOutpostDomainVerification(verification []map[string]interface{}) { + if len(verification) == 0 { + return + } + + fmt.Println("\nCreate these DNS records:") + for _, record := range verification { + fmt.Println() + for _, key := range sortedKeys(record) { + fmt.Printf(" %s: %v\n", key, record[key]) + } + } +} diff --git a/pkg/cmd/outpost_event.go b/pkg/cmd/outpost_event.go new file mode 100644 index 00000000..a51520ed --- /dev/null +++ b/pkg/cmd/outpost_event.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostEventCmd struct { + cmd *cobra.Command +} + +func newOutpostEventCmd() *outpostEventCmd { + ec := &outpostEventCmd{} + + ec.cmd = &cobra.Command{ + Use: "event", + Aliases: []string{"events"}, + Args: validators.NoArgs, + Short: ShortBeta("Inspect published events"), + Long: LongBeta(`Inspect events published to your tenants' destinations. + +Events are created by publishing, so there is no create command here. Publishing +is asynchronous, so a freshly published event can take a moment to appear.`), + } + + ec.cmd.AddCommand(newOutpostEventListCmd().cmd) + ec.cmd.AddCommand(newOutpostEventGetCmd().cmd) + ec.cmd.AddCommand(newOutpostEventRetryCmd().cmd) + + return ec +} diff --git a/pkg/cmd/outpost_event_get.go b/pkg/cmd/outpost_event_get.go new file mode 100644 index 00000000..6c370483 --- /dev/null +++ b/pkg/cmd/outpost_event_get.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostEventGetCmd struct { + cmd *cobra.Command + + tenantID string + output string +} + +func newOutpostEventGetCmd() *outpostEventGetCmd { + ec := &outpostEventGetCmd{} + + ec.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceEvent), + Long: `Get an event, including the payload that was published.`, + PreRunE: ec.validateFlags, + RunE: ec.run, + Example: ` # Get an event + hookdeck outpost event get evt_abc123 + + # Get the payload alone + hookdeck outpost event get evt_abc123 --output json | jq .data`, + Annotations: map[string]string{ + "cli.arguments": `[ + {"name":"event-id","type":"string","description":"The ID of the event.","required":true} + ]`, + }, + } + + ec.cmd.Flags().StringVar(&ec.tenantID, "tenant-id", "", "Tenant the event belongs to") + ec.cmd.Flags().StringVar(&ec.output, "output", "", "Output format (json)") + + return ec +} + +func (ec *outpostEventGetCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (ec *outpostEventGetCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + event, err := client.GetOutpostEvent(context.Background(), args[0], ec.tenantID) + if err != nil { + return fmt.Errorf("failed to get event: %w", err) + } + + if ec.output == "json" { + return printJSONIndented(event) + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(event.ID)) + fmt.Printf(" Topic: %s\n", event.Topic) + fmt.Printf(" Tenant: %s\n", event.TenantID) + if len(event.MatchedDestinationIDs) > 0 { + fmt.Printf(" Destinations: %s\n", strings.Join(event.MatchedDestinationIDs, ", ")) + } + fmt.Printf(" Time: %s\n", event.Time.Format("2006-01-02 15:04:05")) + for key, value := range event.Metadata { + fmt.Printf(" Metadata %s: %s\n", key, value) + } + if len(event.Data) > 0 { + if payload, err := json.MarshalIndent(event.Data, " ", " "); err == nil { + fmt.Printf(" Data: %s\n", string(payload)) + } + } + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_event_list.go b/pkg/cmd/outpost_event_list.go new file mode 100644 index 00000000..89d19b44 --- /dev/null +++ b/pkg/cmd/outpost_event_list.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostEventListCmd struct { + cmd *cobra.Command + + ids string + tenantIDs string + destinationIDs string + topics string + timeAfter string + timeBefore string + limit int + orderBy string + dir string + next string + prev string + output string +} + +func newOutpostEventListCmd() *outpostEventListCmd { + ec := &outpostEventListCmd{} + + ec.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceEvent), + Long: `List published events, most recent first. + +Filters are combined with AND. Time bounds are ISO 8601 datetimes.`, + PreRunE: ec.validateFlags, + RunE: ec.run, + Example: ` # Recent events + hookdeck outpost event list --limit 10 + + # For one tenant, on one topic + hookdeck outpost event list --tenant-id acme --topic user.created + + # Within a time window + hookdeck outpost event list --time-after 2026-08-01T00:00:00Z --time-before 2026-08-14T00:00:00Z`, + } + + ec.cmd.Flags().StringVar(&ec.ids, "id", "", "Filter by event ID(s), comma-separated") + ec.cmd.Flags().StringVar(&ec.tenantIDs, "tenant-id", "", "Filter by tenant ID(s), comma-separated") + ec.cmd.Flags().StringVar(&ec.destinationIDs, "destination-id", "", "Filter by matched destination ID(s), comma-separated") + ec.cmd.Flags().StringVar(&ec.topics, "topic", "", "Filter by topic(s), comma-separated") + ec.cmd.Flags().StringVar(&ec.timeAfter, "time-after", "", "Only events at or after this ISO 8601 datetime") + ec.cmd.Flags().StringVar(&ec.timeBefore, "time-before", "", "Only events at or before this ISO 8601 datetime") + ec.cmd.Flags().IntVar(&ec.limit, "limit", 0, "Limit number of results") + ec.cmd.Flags().StringVar(&ec.orderBy, "order-by", "", "Field to sort by (time)") + ec.cmd.Flags().StringVar(&ec.dir, "dir", "", "Sort direction (asc, desc)") + ec.cmd.Flags().StringVar(&ec.next, "next", "", "Next page cursor") + ec.cmd.Flags().StringVar(&ec.prev, "prev", "", "Previous page cursor") + ec.cmd.Flags().StringVar(&ec.output, "output", "", "Output format (json)") + + return ec +} + +func (ec *outpostEventListCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (ec *outpostEventListCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + resp, err := client.ListOutpostEvents(context.Background(), hookdeck.OutpostEventListParams{ + IDs: splitCommaList(ec.ids), + TenantIDs: splitCommaList(ec.tenantIDs), + DestinationIDs: splitCommaList(ec.destinationIDs), + Topics: splitCommaList(ec.topics), + TimeAfter: ec.timeAfter, + TimeBefore: ec.timeBefore, + Limit: ec.limit, + OrderBy: ec.orderBy, + Dir: ec.dir, + Next: ec.next, + Prev: ec.prev, + }) + if err != nil { + return fmt.Errorf("failed to list events: %w", err) + } + + if ec.output == "json" { + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) + if err != nil { + return fmt.Errorf("failed to marshal events to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No events found.") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d event(s):\n\n", len(resp.Models)) + for _, event := range resp.Models { + fmt.Printf("%s\n", color.Green(event.ID)) + fmt.Printf(" Topic: %s\n", event.Topic) + fmt.Printf(" Tenant: %s\n", event.TenantID) + if len(event.MatchedDestinationIDs) > 0 { + fmt.Printf(" Destinations: %s\n", strings.Join(event.MatchedDestinationIDs, ", ")) + } + fmt.Printf(" Time: %s\n", event.Time.Format("2006-01-02 15:04:05")) + fmt.Println() + } + + printPaginationInfo(resp.Pagination, "hookdeck outpost event list") + + return nil +} diff --git a/pkg/cmd/outpost_event_retry.go b/pkg/cmd/outpost_event_retry.go new file mode 100644 index 00000000..24778a98 --- /dev/null +++ b/pkg/cmd/outpost_event_retry.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostEventRetryCmd struct { + cmd *cobra.Command + + eventID string + destinationID string + output string +} + +func newOutpostEventRetryCmd() *outpostEventRetryCmd { + ec := &outpostEventRetryCmd{} + + ec.cmd = &cobra.Command{ + Use: "retry", + Args: validators.NoArgs, + Short: ShortBeta("Retry delivering an event to a destination"), + Long: LongBeta(`Deliver an event to a destination again. + +The retry is queued rather than performed inline, so a successful response means +it was accepted, not that it has been delivered. Use 'hookdeck outpost attempt +list' to see the outcome. + +The destination must be enabled and must subscribe to the event's topic.`), + PreRunE: ec.validateFlags, + RunE: ec.run, + Example: ` # Retry one delivery + hookdeck outpost event retry --event-id evt_abc123 --destination-id des_abc123`, + } + + ec.cmd.Flags().StringVar(&ec.eventID, "event-id", "", "The event to retry (required)") + ec.cmd.Flags().StringVar(&ec.destinationID, "destination-id", "", "The destination to deliver to (required)") + ec.cmd.Flags().StringVar(&ec.output, "output", "", "Output format (json)") + ec.cmd.MarkFlagRequired("event-id") + ec.cmd.MarkFlagRequired("destination-id") + + return ec +} + +func (ec *outpostEventRetryCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (ec *outpostEventRetryCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + resp, err := client.RetryOutpostEvent(context.Background(), &hookdeck.OutpostRetryRequest{ + EventID: ec.eventID, + DestinationID: ec.destinationID, + }) + if err != nil { + return fmt.Errorf("failed to retry event: %w", err) + } + + if ec.output == "json" { + return printJSONIndented(resp) + } + + fmt.Printf("%s Retry accepted for event %s to destination %s\n", SuccessCheck, ec.eventID, ec.destinationID) + fmt.Println("\nRun 'hookdeck outpost attempt list --event-id " + ec.eventID + "' to see the result.") + + return nil +} diff --git a/pkg/cmd/outpost_metrics.go b/pkg/cmd/outpost_metrics.go new file mode 100644 index 00000000..d45ee72d --- /dev/null +++ b/pkg/cmd/outpost_metrics.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostMetricsCmd struct { + cmd *cobra.Command +} + +func newOutpostMetricsCmd() *outpostMetricsCmd { + mc := &outpostMetricsCmd{} + + mc.cmd = &cobra.Command{ + Use: "metrics", + Args: validators.NoArgs, + Short: ShortBeta("Query aggregate metrics"), + Long: LongBeta(`Query aggregated metrics over a time range. + +Both subcommands require --start, --end and at least one --measures value, and +can group results with --dimensions.`), + } + + mc.cmd.AddCommand(newOutpostMetricsResourceCmd("events", + "Aggregated event publish metrics.", + "count, rate", + "tenant_id, topic, destination_id").cmd) + mc.cmd.AddCommand(newOutpostMetricsResourceCmd("attempts", + "Aggregated delivery attempt metrics.", + "count, successful_count, failed_count, error_rate, first_attempt_count, retry_count, manual_retry_count, avg_attempt_number, rate, successful_rate, failed_rate", + "tenant_id, destination_id, destination_type, topic, status, code, manual, attempt_number").cmd) + + return mc +} + +// outpostMetricsResourceCmd backs both `metrics events` and `metrics attempts`, +// which differ only in endpoint and in the measures and dimensions they accept. +type outpostMetricsResourceCmd struct { + cmd *cobra.Command + resource string + + start string + end string + granularity string + measures string + dimensions string + filters []string + output string +} + +func newOutpostMetricsResourceCmd(resource, summary, measures, dimensions string) *outpostMetricsResourceCmd { + mc := &outpostMetricsResourceCmd{resource: resource} + + mc.cmd = &cobra.Command{ + Use: resource, + Args: validators.NoArgs, + Short: ShortBeta(summary), + Long: LongBeta(fmt.Sprintf(`%s + +Measures: %s + +Dimensions: %s + +Omit --granularity for a single total over the whole range; set it (1h, 5m, 1d) +to bucket the results over time.`, summary, measures, dimensions)), + PreRunE: mc.validateFlags, + RunE: mc.run, + Example: fmt.Sprintf(` # Total over the last week + hookdeck outpost metrics %s --start 2026-08-07T00:00:00Z --end 2026-08-14T00:00:00Z --measures count + + # Bucketed hourly and grouped by topic + hookdeck outpost metrics %s --start 2026-08-13T00:00:00Z --end 2026-08-14T00:00:00Z \ + --measures count --granularity 1h --dimensions topic`, resource, resource), + } + + mc.cmd.Flags().StringVar(&mc.start, "start", "", "Start of the range, ISO 8601 (required)") + mc.cmd.Flags().StringVar(&mc.end, "end", "", "End of the range, ISO 8601 (required)") + mc.cmd.Flags().StringVar(&mc.granularity, "granularity", "", "Bucket size (e.g. 5m, 1h, 1d)") + mc.cmd.Flags().StringVar(&mc.measures, "measures", "", "Measures to compute, comma-separated (required)") + mc.cmd.Flags().StringVar(&mc.dimensions, "dimensions", "", "Dimensions to group by, comma-separated") + mc.cmd.Flags().StringArrayVar(&mc.filters, "filter", nil, "Filter as dimension=value (repeatable)") + mc.cmd.Flags().StringVar(&mc.output, "output", "", "Output format (json)") + + mc.cmd.MarkFlagRequired("start") + mc.cmd.MarkFlagRequired("end") + mc.cmd.MarkFlagRequired("measures") + + return mc +} + +func (mc *outpostMetricsResourceCmd) validateFlags(cmd *cobra.Command, args []string) error { + return rejectEmptyFlags(cmd) +} + +func (mc *outpostMetricsResourceCmd) run(cmd *cobra.Command, args []string) error { + filters := map[string][]string{} + for _, entry := range mc.filters { + key, value, found := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return fmt.Errorf("--filter %q must be in dimension=value form", entry) + } + filters[key] = append(filters[key], value) + } + + params := hookdeck.OutpostMetricsParams{ + Start: mc.start, + End: mc.end, + Granularity: mc.granularity, + Measures: splitCommaList(mc.measures), + Dimensions: splitCommaList(mc.dimensions), + Filters: filters, + } + + client := Config.GetOutpostAPIClient() + ctx := context.Background() + + var ( + resp *hookdeck.OutpostMetricsResponse + err error + ) + if mc.resource == "events" { + resp, err = client.GetOutpostEventMetrics(ctx, params) + } else { + resp, err = client.GetOutpostAttemptMetrics(ctx, params) + } + if err != nil { + return fmt.Errorf("failed to get %s metrics: %w", mc.resource, err) + } + + if mc.output == "json" { + return printJSONIndented(resp) + } + + if len(resp.Data) == 0 { + fmt.Println("No data for that range.") + return nil + } + + fmt.Println() + for _, point := range resp.Data { + var parts []string + if point.TimeBucket != nil { + parts = append(parts, point.TimeBucket.Format("2006-01-02 15:04")) + } + for _, key := range sortedStringKeys(point.Dimensions) { + parts = append(parts, fmt.Sprintf("%s=%s", key, point.Dimensions[key])) + } + if len(parts) > 0 { + fmt.Printf("%s\n", strings.Join(parts, " ")) + } + for _, key := range sortedKeys(point.Metrics) { + fmt.Printf(" %s: %v\n", key, point.Metrics[key]) + } + fmt.Println() + } + + // Silent truncation would read as a complete picture, so say so. + if resp.Metadata.Truncated { + fmt.Printf("Results were truncated at the %d row limit; narrow the range or filters for a complete picture.\n", + resp.Metadata.RowLimit) + } + + return nil +} + +func sortedStringKeys(m map[string]string) []string { + generic := make(map[string]interface{}, len(m)) + for k, v := range m { + generic[k] = v + } + return sortedKeys(generic) +} diff --git a/pkg/cmd/outpost_publish.go b/pkg/cmd/outpost_publish.go new file mode 100644 index 00000000..4525f95b --- /dev/null +++ b/pkg/cmd/outpost_publish.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostPublishCmd struct { + cmd *cobra.Command + + apiKey string + tenantID string + topic string + destinationID string + eventID string + data string + dataFile string + metadata []string + eligibleForRetry bool + output string +} + +func newOutpostPublishCmd() *outpostPublishCmd { + pc := &outpostPublishCmd{} + + pc.cmd = &cobra.Command{ + Use: "publish", + Args: validators.NoArgs, + Short: ShortBeta("Publish an event"), + Long: LongBeta(`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. + +This command needs a Hookdeck Project API key, which is different from every +other outpost command. The credentials stored by 'hookdeck login' are not +accepted by the publish API, so pass --api-key or set HOOKDECK_API_KEY. You can +create a Project API key in the Hookdeck dashboard under project settings.`), + PreRunE: pc.validateFlags, + RunE: pc.run, + Example: ` # Publish an event + hookdeck outpost publish --tenant-id acme --topic user.created \ + --data '{"user_id":"123"}' --api-key $HOOKDECK_API_KEY + + # Publish to one specific destination + hookdeck outpost publish --tenant-id acme --topic user.created \ + --data '{"user_id":"123"}' --destination-id des_abc123 + + # Idempotent publish: repeating the same --event-id will not duplicate + hookdeck outpost publish --tenant-id acme --topic user.created \ + --event-id my-unique-id --data-file ./payload.json`, + } + + pc.cmd.Flags().StringVar(&pc.apiKey, "api-key", os.Getenv("HOOKDECK_API_KEY"), "Hookdeck Project API key. Read from HOOKDECK_API_KEY when not provided.") + pc.cmd.Flags().StringVar(&pc.tenantID, "tenant-id", "", "Tenant to publish for (required)") + pc.cmd.Flags().StringVar(&pc.topic, "topic", "", "Topic to publish to (required)") + pc.cmd.Flags().StringVar(&pc.destinationID, "destination-id", "", "Deliver only to this destination") + pc.cmd.Flags().StringVar(&pc.eventID, "event-id", "", "Event ID, for idempotent publishing") + pc.cmd.Flags().StringVar(&pc.data, "data", "", "Event payload as a JSON object") + pc.cmd.Flags().StringVar(&pc.dataFile, "data-file", "", "Path to a JSON file containing the event payload") + pc.cmd.Flags().StringArrayVar(&pc.metadata, "metadata", nil, "Metadata as key=value (repeatable)") + pc.cmd.Flags().BoolVar(&pc.eligibleForRetry, "eligible-for-retry", true, "Whether failed deliveries should be retried") + pc.cmd.Flags().StringVar(&pc.output, "output", "", "Output format (json)") + + pc.cmd.MarkFlagRequired("tenant-id") + pc.cmd.MarkFlagRequired("topic") + + return pc +} + +func (pc *outpostPublishCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := rejectEmptyFlags(cmd); err != nil { + return err + } + if pc.data != "" && pc.dataFile != "" { + return fmt.Errorf("--data and --data-file cannot be used together") + } + + // Fail here with the reason rather than letting this surface as a bare 401, + // which the generic handler would rewrite into "your API key is invalid or + // expired" — true, but useless, since the stored key is never valid here. + if pc.apiKey == "" { + return newActionableError(fmt.Errorf( + "publishing requires a Hookdeck Project API key.\n\n" + + "Unlike other outpost commands, the publish API does not accept the credentials\n" + + "stored by 'hookdeck login'. Pass one explicitly:\n\n" + + " hookdeck outpost publish --api-key ...\n\n" + + "or set HOOKDECK_API_KEY. Create a Project API key in the Hookdeck dashboard\n" + + "under your project's settings.")) + } + + return nil +} + +func (pc *outpostPublishCmd) run(cmd *cobra.Command, args []string) error { + payload, err := pc.resolveData() + if err != nil { + return err + } + + metadata := make(map[string]string, len(pc.metadata)) + for _, entry := range pc.metadata { + key, value, found := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return fmt.Errorf("--metadata %q must be in key=value form", entry) + } + metadata[key] = value + } + + req := &hookdeck.OutpostPublishRequest{ + ID: pc.eventID, + TenantID: pc.tenantID, + Topic: pc.topic, + DestinationID: pc.destinationID, + Metadata: metadata, + Data: payload, + } + // Only send the flag when the caller set it, so the API default stands. + if cmd.Flags().Changed("eligible-for-retry") { + req.EligibleForRetry = &pc.eligibleForRetry + } + + client := Config.GetOutpostAPIClient() + + resp, err := client.PublishOutpostEvent(context.Background(), pc.apiKey, req) + if err != nil { + return fmt.Errorf("failed to publish event: %w", err) + } + + if pc.output == "json" { + return printJSONIndented(resp) + } + + if resp.Duplicate { + fmt.Printf("%s Event %s already existed; nothing was published again\n", SuccessCheck, resp.ID) + return nil + } + + fmt.Printf("%s Event %s accepted\n", SuccessCheck, resp.ID) + if len(resp.DestinationIDs) > 0 { + fmt.Printf(" Matched destinations: %s\n", strings.Join(resp.DestinationIDs, ", ")) + } else { + fmt.Println(" No destinations matched this topic, so it will not be delivered.") + } + + return nil +} + +func (pc *outpostPublishCmd) resolveData() (map[string]interface{}, error) { + raw := pc.data + if pc.dataFile != "" { + contents, err := os.ReadFile(pc.dataFile) + if err != nil { + return nil, fmt.Errorf("failed to read --data-file: %w", err) + } + raw = string(contents) + } + if strings.TrimSpace(raw) == "" { + return nil, nil + } + + var payload map[string]interface{} + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("the event payload must be a JSON object: %w", err) + } + return payload, nil +} diff --git a/pkg/cmd/outpost_status.go b/pkg/cmd/outpost_status.go new file mode 100644 index 00000000..e011b6e7 --- /dev/null +++ b/pkg/cmd/outpost_status.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostStatusCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostStatusCmd() *outpostStatusCmd { + sc := &outpostStatusCmd{} + + sc.cmd = &cobra.Command{ + Use: "status", + Args: validators.NoArgs, + Short: ShortBeta("Show the Outpost deployment status"), + Long: LongBeta(`Show the status of this project's Outpost deployment. + +Worth checking first when something is not behaving: configuration changes take +a short while to reach the deployment, and the status reports when it is still +being applied.`), + RunE: sc.run, + Example: ` # Check deployment status + hookdeck outpost status`, + } + + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + + return sc +} + +func (sc *outpostStatusCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + status, err := client.GetOutpostStatus(context.Background()) + if err != nil { + return fmt.Errorf("failed to get status: %w", err) + } + + if sc.output == "json" { + return printJSONIndented(status) + } + + color := ansi.Color(os.Stdout) + fmt.Println() + if status.Status == "HEALTHY" { + fmt.Printf("Status: %s\n", color.Green(status.Status)) + } else { + fmt.Printf("Status: %s\n", color.Red(status.Status)) + } + if status.Version != "" { + fmt.Printf("Version: %s\n", status.Version) + } + if status.PortalHostname != "" { + fmt.Printf("Portal hostname: %s\n", status.PortalHostname) + } + fmt.Println() + + return nil +} diff --git a/pkg/cmd/outpost_topic.go b/pkg/cmd/outpost_topic.go new file mode 100644 index 00000000..7f8e6ebb --- /dev/null +++ b/pkg/cmd/outpost_topic.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type outpostTopicCmd struct { + cmd *cobra.Command +} + +func newOutpostTopicCmd() *outpostTopicCmd { + tc := &outpostTopicCmd{} + + tc.cmd = &cobra.Command{ + Use: "topic", + Aliases: []string{"topics"}, + Args: validators.NoArgs, + Short: ShortBeta("Inspect available topics"), + Long: LongBeta(`Inspect the topics destinations can subscribe to. + +Topics are project configuration rather than a resource, so there is no create +command. Change them with 'hookdeck outpost config set TOPICS=a,b,c'.`), + } + + tc.cmd.AddCommand(newOutpostTopicListCmd().cmd) + + return tc +} + +type outpostTopicListCmd struct { + cmd *cobra.Command + + output string +} + +func newOutpostTopicListCmd() *outpostTopicListCmd { + tc := &outpostTopicListCmd{} + + tc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceTopic), + Long: `List the topics configured for this project.`, + RunE: tc.run, + Example: ` # List topics + hookdeck outpost topic list`, + } + + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *outpostTopicListCmd) run(cmd *cobra.Command, args []string) error { + client := Config.GetOutpostAPIClient() + + topics, err := client.ListOutpostTopics(context.Background()) + if err != nil { + return fmt.Errorf("failed to list topics: %w", err) + } + + if tc.output == "json" { + return printJSONIndented(topics) + } + + if len(topics) == 0 { + // An empty list is valid but leaves the project unusable, so say what to + // do rather than printing nothing. + fmt.Println("No topics configured.") + fmt.Println("\nDestinations cannot subscribe to anything until topics are set:") + fmt.Println(" hookdeck outpost config set TOPICS=user.created,user.updated") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d topic(s):\n\n", len(topics)) + for _, topic := range topics { + fmt.Printf(" %s\n", color.Green(topic)) + } + fmt.Println() + + return nil +} From c235fd55b53c323330cc2ceef4390d63f043f892 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 17:16:52 +0100 Subject: [PATCH 07/49] test(outpost): add acceptance suite and CI slice Adds test/acceptance/outpost_test.go behind the `outpost` build tag, covering tenant and destination lifecycles, destination types, publish and inspect, metrics, config, and the validation error paths. The suite needs its own project. Every `hookdeck outpost` command requires an Outpost project, so the Gateway keys the existing slices use would be rejected by the project gate before any request is made. NewOutpostCLIRunner reads HOOKDECK_CLI_OUTPOST_TESTING_API_KEY, which is a Project API key doing double duty: exchanged via `hookdeck ci` for the CLI credentials most commands use, and passed directly to `outpost publish`, which does not accept CLI credentials. The Gateway-rejection test lives in the gateway slice rather than this one, because asserting that a Gateway project is refused needs a Gateway project. Two things worth noting for anyone extending this: - Error assertions read stdout, not stderr. The CLI prints errors to stdout today (see #340, which tracks moving them); `go run` writes its own "exit status 1" to stderr, so asserting there passes vacuously. The tests are commented so this fails loudly if the contract changes rather than silently checking the wrong stream. - Tenants are uniquely named per run and removed in t.Cleanup. The project is shared between local runs and CI, and a failed run can leave data behind, so nothing assumes it starts empty. Both suites were run locally against the real project before committing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- .github/workflows/acceptance.yml | 9 + test/acceptance/README.md | 5 +- test/acceptance/gateway_test.go | 28 +++ test/acceptance/helpers.go | 28 +++ test/acceptance/mcp_test.go | 10 +- test/acceptance/outpost_test.go | 290 ++++++++++++++++++++++++++++++ test/acceptance/telemetry_test.go | 6 +- 7 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 test/acceptance/outpost_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 2922ccce..cefd262b 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -21,10 +21,19 @@ jobs: - slice: "2" api_key_secret: HOOKDECK_CLI_TESTING_API_KEY_3 tags: "attempt metrics issue transformation destination gateway" + # Outpost needs its own project: every `hookdeck outpost` command + # requires an Outpost project, which the Gateway keys above cannot + # satisfy. The key is a Project API key, used both to authenticate via + # `hookdeck ci` and directly for `outpost publish`, which does not + # accept CLI credentials. + - slice: "3" + api_key_secret: HOOKDECK_CLI_OUTPOST_TESTING_API_KEY + tags: "outpost" runs-on: ubuntu-latest env: ACCEPTANCE_SLICE: ${{ matrix.slice }} HOOKDECK_CLI_TESTING_API_KEY: ${{ secrets[matrix.api_key_secret] }} + HOOKDECK_CLI_OUTPOST_TESTING_API_KEY: ${{ secrets.HOOKDECK_CLI_OUTPOST_TESTING_API_KEY }} HOOKDECK_CLI_TELEMETRY_DISABLED: "1" steps: - name: Check out code diff --git a/test/acceptance/README.md b/test/acceptance/README.md index d0c3f163..63ca3b8b 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -69,7 +69,7 @@ No test-name list in the workflow—tests are partitioned by **feature tags** (s ### Run all automated tests (one key) Pass all feature tags so every automated test file is included: ```bash -go test -tags="basic guest connection source destination gateway mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update request event telemetry attempt metrics issue transformation" ./test/acceptance/... -v +go test -tags="basic guest connection source destination gateway mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update request event telemetry attempt metrics issue transformation outpost" ./test/acceptance/... -v ``` ### Run one slice (for CI or local) @@ -81,6 +81,9 @@ ACCEPTANCE_SLICE=0 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="basic guest # Slice 1 (same tags as CI job 1) ACCEPTANCE_SLICE=1 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="request event" ./test/acceptance/... -v -timeout 12m +# Slice 3 (same tags as CI job 3) - requires HOOKDECK_CLI_OUTPOST_TESTING_API_KEY +ACCEPTANCE_SLICE=3 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="outpost" ./test/acceptance/... -v -timeout 12m + # Slice 2 (same tags as CI job 2) ACCEPTANCE_SLICE=2 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="attempt metrics issue transformation destination gateway" ./test/acceptance/... -v -timeout 12m diff --git a/test/acceptance/gateway_test.go b/test/acceptance/gateway_test.go index 6e52c70f..fd066a0b 100644 --- a/test/acceptance/gateway_test.go +++ b/test/acceptance/gateway_test.go @@ -4,6 +4,7 @@ package acceptance import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -302,3 +303,30 @@ func TestGatewaySourcesAliasWorks(t *testing.T) { t.Logf("Gateway 'sources' alias verified") } + +// TestOutpostCommandsRejectGatewayProject belongs in a Gateway slice on purpose: +// it needs a Gateway project to point an outpost command at, which the Outpost +// slice's key cannot provide. +// +// Without the project gate the API answers 404, which reads as "no such tenant" +// rather than "you are on the wrong project". +func TestOutpostCommandsRejectGatewayProject(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + for _, args := range [][]string{ + {"outpost", "tenant", "list"}, + {"outpost", "status"}, + {"outpost", "topic", "list"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + stdout, _, err := cli.Run(args...) + require.Error(t, err, "a Gateway project must not satisfy an outpost command") + assert.Contains(t, stdout, "requires an Outpost project") + assert.Contains(t, stdout, "hookdeck project use", "the error should say how to fix it") + }) + } +} diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index 44627af1..61db3690 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -424,6 +424,34 @@ func getAcceptanceAPIKey(t *testing.T) string { return os.Getenv("HOOKDECK_CLI_TESTING_API_KEY") } +// NewOutpostCLIRunner creates a runner authenticated against the Outpost test +// project. +// +// It cannot share the keys the other slices use: every `hookdeck outpost` +// command requires an Outpost project, and those keys belong to Gateway +// projects, so the project gate would reject them before any request is made. +func NewOutpostCLIRunner(t *testing.T) *CLIRunner { + t.Helper() + + apiKey := os.Getenv("HOOKDECK_CLI_OUTPOST_TESTING_API_KEY") + require.NotEmpty(t, apiKey, "HOOKDECK_CLI_OUTPOST_TESTING_API_KEY must be set (a Project API key for an Outpost project)") + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err, "Failed to get project root path") + + runner := &CLIRunner{ + t: t, + apiKey: apiKey, + projectRoot: projectRoot, + configPath: getAcceptanceConfigPath(), + } + + stdout, stderr, err := runner.Run("ci", "--api-key", apiKey) + require.NoError(t, err, "Failed to authenticate CLI against the Outpost project: stdout=%s, stderr=%s", stdout, stderr) + + return runner +} + // NewCLIRunnerWithKey creates a new CLI runner authenticated with the given CLI key via // hookdeck login --api-key. Used only for project list/use tests (HOOKDECK_CLI_TESTING_CLI_KEY); // API and CI keys cannot list or switch projects, so those tests require a CLI key and login auth. diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index 5d531a57..259ddaf7 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -143,11 +143,11 @@ func TestMCPRequestsList_DateRangeAndBodyFilter(t *testing.T) { } cli := NewCLIRunner(t) result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "hookdeck_requests", map[string]any{ - "action": "list", - "ingested_after": "2020-01-01T00:00:00Z", - "created_before": "2030-01-01T00:00:00Z", - "body": map[string]any{}, - "limit": 5, + "action": "list", + "ingested_after": "2020-01-01T00:00:00Z", + "created_before": "2030-01-01T00:00:00Z", + "body": map[string]any{}, + "limit": 5, }, 20*time.Second) assert.False(t, result.IsError, "tool error: %s", result.Text) assert.Contains(t, result.Text, `"data"`) diff --git a/test/acceptance/outpost_test.go b/test/acceptance/outpost_test.go new file mode 100644 index 00000000..edcb81c6 --- /dev/null +++ b/test/acceptance/outpost_test.go @@ -0,0 +1,290 @@ +//go:build outpost + +package acceptance + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// uniqueTenantID keeps runs independent. The test project is shared between +// local runs and CI, and a failed run can leave data behind, so nothing may +// assume it starts empty. +func uniqueTenantID(t *testing.T) string { + t.Helper() + return fmt.Sprintf("cli-at-%d", time.Now().UnixNano()) +} + +// createTestTenant creates a tenant and removes it when the test ends, whether +// or not the test passed. +func createTestTenant(t *testing.T, cli *CLIRunner) string { + t.Helper() + + tenantID := uniqueTenantID(t) + cli.RunExpectSuccess("outpost", "tenant", "upsert", tenantID) + t.Cleanup(func() { + if _, _, err := cli.Run("outpost", "tenant", "delete", tenantID, "--force"); err != nil { + t.Logf("cleanup: could not delete tenant %s: %v", tenantID, err) + } + }) + + return tenantID +} + +func TestOutpostStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + stdout := cli.RunExpectSuccess("outpost", "status") + assert.Contains(t, stdout, "Status:") +} + +func TestOutpostTopicList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + stdout := cli.RunExpectSuccess("outpost", "topic", "list") + + // A project with no topics cannot deliver anything, so most of the coverage + // below would be meaningless. Fail here with the fix rather than further in. + require.NotContains(t, stdout, "No topics configured", + "the Outpost test project needs topics: hookdeck outpost config set TOPICS=user.created") +} + +func TestOutpostDestinationTypes(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + stdout := cli.RunExpectSuccess("outpost", "destination-type", "list") + assert.Contains(t, stdout, "webhook") + + stdout = cli.RunExpectSuccess("outpost", "destination-type", "get", "webhook") + assert.Contains(t, stdout, "--config fields:") + assert.Contains(t, stdout, "url") + assert.Contains(t, stdout, "required") +} + +func TestOutpostTenantLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + tenantID := uniqueTenantID(t) + + stdout := cli.RunExpectSuccess("outpost", "tenant", "upsert", tenantID, "--metadata", "plan=pro") + assert.Contains(t, stdout, tenantID) + + // Upsert is idempotent, so running it again must succeed rather than + // conflict — that is the only way to create a tenant. + cli.RunExpectSuccess("outpost", "tenant", "upsert", tenantID, "--metadata", "plan=enterprise") + + stdout = cli.RunExpectSuccess("outpost", "tenant", "get", tenantID, "--output", "json") + var tenant struct { + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &tenant)) + assert.Equal(t, tenantID, tenant.ID) + assert.Equal(t, "enterprise", tenant.Metadata["plan"], "the second upsert should have replaced the metadata") + + stdout = cli.RunExpectSuccess("outpost", "tenant", "list", "--id", tenantID, "--output", "json") + assert.Contains(t, stdout, tenantID) + + cli.RunExpectSuccess("outpost", "tenant", "delete", tenantID, "--force") + + _, _, err := cli.Run("outpost", "tenant", "get", tenantID) + assert.Error(t, err, "the tenant should be gone after delete") +} + +func TestOutpostDestinationLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + tenantID := createTestTenant(t, cli) + + stdout := cli.RunExpectSuccess("outpost", "destination", "create", + "--tenant-id", tenantID, + "--type", "webhook", + "--config", "url=https://example.com/acceptance", + "--topics", "*", + "--output", "json") + + var created struct { + ID string `json:"id"` + Type string `json:"type"` + Topics interface{} `json:"topics"` + Config map[string]interface{} `json:"config"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &created)) + require.NotEmpty(t, created.ID) + assert.Equal(t, "webhook", created.Type) + assert.Equal(t, "https://example.com/acceptance", created.Config["url"]) + // "*" comes back as a bare string rather than an array; decoding it is the + // point of this assertion. + assert.Equal(t, "*", created.Topics) + + stdout = cli.RunExpectSuccess("outpost", "destination", "get", created.ID, "--tenant-id", tenantID) + assert.Contains(t, stdout, created.ID) + + stdout = cli.RunExpectSuccess("outpost", "destination", "list", "--tenant-id", tenantID) + assert.Contains(t, stdout, created.ID) + + stdout = cli.RunExpectSuccess("outpost", "destination", "update", created.ID, + "--tenant-id", tenantID, "--config", "url=https://example.com/updated", "--output", "json") + assert.Contains(t, stdout, "https://example.com/updated") + + stdout = cli.RunExpectSuccess("outpost", "destination", "disable", created.ID, "--tenant-id", tenantID) + assert.Contains(t, strings.ToLower(stdout), "disabled") + + stdout = cli.RunExpectSuccess("outpost", "destination", "enable", created.ID, "--tenant-id", tenantID) + assert.Contains(t, strings.ToLower(stdout), "enabled") + + cli.RunExpectSuccess("outpost", "destination", "delete", created.ID, "--tenant-id", tenantID, "--force") +} + +func TestOutpostDestinationValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + tenantID := createTestTenant(t, cli) + + // Error text goes to stdout today, not stderr — see #340, which tracks + // moving it. These assert current behaviour so they fail loudly if it moves, + // rather than silently checking the wrong stream. + t.Run("an unknown config field is rejected with the valid ones", func(t *testing.T) { + stdout, _, err := cli.Run("outpost", "destination", "create", + "--tenant-id", tenantID, "--type", "webhook", "--config", "nope=x") + require.Error(t, err) + assert.Contains(t, stdout, "not a valid config field") + }) + + t.Run("an unknown type lists the available types", func(t *testing.T) { + stdout, _, err := cli.Run("outpost", "destination", "create", + "--tenant-id", tenantID, "--type", "banana", "--config", "url=https://example.com") + require.Error(t, err) + assert.Contains(t, stdout, "unknown destination type") + assert.Contains(t, stdout, "webhook", "the error should list the types that are valid") + }) + + t.Run("a missing tenant is reported before the request", func(t *testing.T) { + stdout, _, err := cli.Run("outpost", "destination", "list") + require.Error(t, err) + assert.Contains(t, stdout, "--tenant-id is required") + }) +} + +func TestOutpostPublishAndInspect(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + tenantID := createTestTenant(t, cli) + + topics := cli.RunExpectSuccess("outpost", "topic", "list", "--output", "json") + var available []string + require.NoError(t, json.Unmarshal([]byte(topics), &available)) + require.NotEmpty(t, available, "the test project needs at least one topic") + topic := available[0] + + cli.RunExpectSuccess("outpost", "destination", "create", + "--tenant-id", tenantID, "--type", "webhook", + "--config", "url=https://example.com/publish", "--topics", topic) + + // Publish needs the Project API key directly; the stored CLI key is not + // accepted by this endpoint. + stdout := cli.RunExpectSuccess("outpost", "publish", + "--tenant-id", tenantID, "--topic", topic, + "--data", `{"source":"acceptance"}`, + "--api-key", cli.apiKey) + assert.Contains(t, stdout, "accepted") + + // Publishing is asynchronous, so poll rather than asserting immediately. + require.Eventually(t, func() bool { + out, _, err := cli.Run("outpost", "event", "list", "--tenant-id", tenantID, "--output", "json") + if err != nil { + return false + } + var events struct { + Models []struct { + Topic string `json:"topic"` + } `json:"models"` + } + return json.Unmarshal([]byte(out), &events) == nil && len(events.Models) > 0 + }, 30*time.Second, 2*time.Second, "the published event never appeared") + + stdout = cli.RunExpectSuccess("outpost", "attempt", "list", "--tenant-id", tenantID, "--limit", "5") + assert.NotEmpty(t, stdout) +} + +func TestOutpostPublishRequiresProjectAPIKey(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + // The stored credentials are deliberately not enough here, and the error has + // to explain that rather than surfacing a bare 401. + stdout, _, err := cli.RunWithEnv(map[string]string{"HOOKDECK_API_KEY": ""}, + "outpost", "publish", "--tenant-id", "whoever", "--topic", "user.created") + require.Error(t, err) + assert.Contains(t, stdout, "Project API key") + assert.Contains(t, stdout, "--api-key", "the error should name the flag that fixes it") +} + +func TestOutpostMetrics(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + end := time.Now().UTC().Format(time.RFC3339) + start := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) + + cli.RunExpectSuccess("outpost", "metrics", "events", "--start", start, "--end", end, "--measures", "count") + cli.RunExpectSuccess("outpost", "metrics", "attempts", "--start", start, "--end", end, "--measures", "count") + + t.Run("required parameters are enforced", func(t *testing.T) { + _, _, err := cli.Run("outpost", "metrics", "events", "--start", start, "--end", end) + assert.Error(t, err, "--measures is required") + }) +} + +func TestOutpostConfig(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + stdout := cli.RunExpectSuccess("outpost", "config", "get", "TOPICS") + require.NotEmpty(t, strings.TrimSpace(stdout)) + + // Dry run must not change anything — this config is shared with every other + // test in this file, so an accidental write would be disruptive. + before := cli.RunExpectSuccess("outpost", "config", "get", "TOPICS") + stdout = cli.RunExpectSuccess("outpost", "config", "set", "TOPICS=should.not.apply", "--dry-run") + assert.Contains(t, stdout, "Dry run") + after := cli.RunExpectSuccess("outpost", "config", "get", "TOPICS") + assert.Equal(t, before, after, "--dry-run must not apply the change") +} diff --git a/test/acceptance/telemetry_test.go b/test/acceptance/telemetry_test.go index ed1a562d..225d462d 100644 --- a/test/acceptance/telemetry_test.go +++ b/test/acceptance/telemetry_test.go @@ -761,7 +761,7 @@ func TestTelemetryGatewaySourceUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "source", "upsert", "telemetry-src-upsert-"+generateTimestamp(), "--type", "WEBHOOK"}, + []string{"gateway", "source", "upsert", "telemetry-src-upsert-" + generateTimestamp(), "--type", "WEBHOOK"}, "hookdeck gateway source upsert") } func TestTelemetryGatewaySourceCountProxy(t *testing.T) { @@ -875,7 +875,7 @@ func TestTelemetryGatewayDestinationUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "destination", "upsert", "telemetry-dst-upsert-"+generateTimestamp(), "--type", "HTTP", "--url", "https://example.com"}, + []string{"gateway", "destination", "upsert", "telemetry-dst-upsert-" + generateTimestamp(), "--type", "HTTP", "--url", "https://example.com"}, "hookdeck gateway destination upsert") } func TestTelemetryGatewayDestinationCountProxy(t *testing.T) { @@ -968,7 +968,7 @@ func TestTelemetryGatewayTransformationUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "transformation", "upsert", "telemetry-trn-upsert-"+generateTimestamp(), "--code", `addHandler("transform", (request, context) => { return request; });`}, + []string{"gateway", "transformation", "upsert", "telemetry-trn-upsert-" + generateTimestamp(), "--code", `addHandler("transform", (request, context) => { return request; });`}, "hookdeck gateway transformation upsert") } func TestTelemetryGatewayTransformationCountProxy(t *testing.T) { From 13030238034b0e19af43fbb75a8d11f954b7e467 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 17:19:02 +0100 Subject: [PATCH 08/49] docs(outpost): add REFERENCE.md and README sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the generated REFERENCE.md block for the outpost command tree, a README section, and the publish key exception to AGENTS.md. The generator's table of contents is a hand-maintained list rather than being derived from headings, so Outpost was added there — along with Metrics, which had been missing since it was introduced. Both docs lead with the two things that are genuinely surprising: config and credential fields are key=value pairs because they belong to the Outpost deployment rather than the CLI, and publish needs a Project API key because it is the one command that does not accept the credentials `hookdeck login` stores. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- AGENTS.md | 2 + README.md | 58 ++ REFERENCE.md | 1113 ++++++++++++++++++++++++++++++ tools/generate-reference/main.go | 5 +- 4 files changed, 1177 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9d4819ea..6da1829a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -579,6 +579,8 @@ Summary for code and docs work: - **Guest** — `listen` without login may call `POST /cli/guest`; separate from `--cli-key` onboarding. - **`project list`** — Requires a user-associated CLI client key (`hookdeck login` or `hookdeck login --cli-key`). CI keys from `hookdeck ci` and raw Project API keys cannot list or switch projects (acceptance: `HOOKDECK_CLI_TESTING_CLI_KEY`). +- **`outpost publish`** — The publish API requires a **Project API key** sent as a bearer token and does not accept the CLI client key stored by `hookdeck login`. It therefore has its own `--api-key` flag defaulting to `HOOKDECK_API_KEY`, in the same shape as `hookdeck ci --api-key`. Every other `hookdeck outpost` command uses the stored credentials normally. When the key is missing the command fails with its own guidance rather than a bare 401, which the generic handler would otherwise rewrite into "your API key is invalid or expired" — accurate but useless here, since the stored key is never valid for this endpoint. + ### Diagnosing a key before you debug anything else The config file cannot tell you which credential you hold — `api_key` is the field name for every CLI client key regardless of origin. When a command fails with a permission or project error, establish the key's scope first: diff --git a/README.md b/README.md index dc75d721..b82f10ec 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ For a complete reference of all commands and flags, see [REFERENCE.md](REFERENCE - [Running in CI](#running-in-ci) - [Event Gateway](#event-gateway) - [Event Gateway MCP](#event-gateway-mcp) + - [Outpost](#outpost) - [Manage connections](#manage-connections) - [Transformations](#transformations) - [Requests, events, and attempts](#requests-events-and-attempts) @@ -663,6 +664,63 @@ Once the MCP server is configured, you can ask your agent questions like: → Agent uses hookdeck_events list with status FAILED and last_attempt_after set to yesterday's ISO datetime. ``` +### Outpost + +Manage [Hookdeck Outpost](https://hookdeck.com/docs/outpost) — your users (tenants), the destinations they own, and the events delivered to them. + +These commands require an Outpost project. Switch with `hookdeck project use`; pointing them at an Event Gateway project reports which type the project is rather than failing obscurely. + +```sh +hookdeck outpost [command] + +# Available commands +hookdeck outpost tenant # Manage tenants +hookdeck outpost destination # Manage a tenant's destinations +hookdeck outpost destination-type # Inspect available destination types and their fields +hookdeck outpost event # Inspect published events, and retry delivery +hookdeck outpost attempt # Inspect delivery attempts +hookdeck outpost publish # Publish an event +hookdeck outpost topic # Inspect available topics +hookdeck outpost metrics # Query aggregate metrics +hookdeck outpost config # Manage project configuration and the portal domain +hookdeck outpost status # Show the deployment status +``` + +#### Destination config + +Config and credential fields differ per destination type, and are defined by the Outpost deployment rather than the CLI, so they are passed as repeatable `key=value` pairs: + +```sh +hookdeck outpost tenant upsert acme + +hookdeck outpost destination create --tenant-id acme --type webhook \ + --config url=https://example.com/hooks --topics user.created +``` + +To find out what a type accepts, either ask for it directly or add `--type` to `--help`: + +```sh +hookdeck outpost destination-type get kafka +hookdeck outpost destination create --type kafka --help +``` + +Both list every field with whether it is required, whether it is sensitive, and any values or format it is constrained to. `--config-file` accepts a JSON object, and nested values — should a type ever need them — use dotted paths (`--config a.b=c`). + +#### Publishing + +`hookdeck outpost publish` is the one command that does **not** use the credentials stored by `hookdeck login`. The publish API requires a Hookdeck **Project API key**, so pass `--api-key` or set `HOOKDECK_API_KEY`: + +```sh +hookdeck outpost publish --tenant-id acme --topic user.created \ + --data '{"user_id":"123"}' --api-key $HOOKDECK_API_KEY +``` + +Create a Project API key in the Hookdeck dashboard under your project's settings. See [CLI authentication keys](#cli-authentication-keys) for how the key types differ. + +Publishing is asynchronous: a successful response means the event was accepted, not delivered. Use `hookdeck outpost attempt list` to see the outcome. + +For complete command and flag reference, see [REFERENCE.md](REFERENCE.md). + ### 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 0ea9fcf0..abf94861 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -19,6 +19,8 @@ The Hookdeck CLI provides comprehensive webhook infrastructure management includ - [Events](#events) - [Requests](#requests) - [Attempts](#attempts) +- [Metrics](#metrics) +- [Outpost](#outpost) - [Utilities](#utilities) ## Global Options @@ -1914,6 +1916,1117 @@ Query Event Gateway metrics (events, requests, attempts, queue depth, pending ev **Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json). +## Outpost + +Manage Hookdeck Outpost — tenants, their destinations, and the events delivered to them. These commands require an Outpost project; use `hookdeck project use` to switch. + +Config and credential fields differ per destination type and are defined by the Outpost deployment rather than the CLI, so they are passed as repeatable `key=value` pairs rather than individual flags: + +```sh +hookdeck outpost destination create --tenant-id acme --type webhook \ + --config url=https://example.com/hooks +``` + +Run `hookdeck outpost destination-type get ` to see the fields a type accepts, or add `--type ` to `--help`: + +```sh +hookdeck outpost destination create --type kafka --help +``` + +Nested values, should a type need them, use dotted paths (`--config a.b=c`), and `--config-file` accepts a JSON object. + +**`outpost publish` needs a Hookdeck Project API key.** It is the one command that does not accept the credentials stored by `hookdeck login`; pass `--api-key` or set `HOOKDECK_API_KEY`. Create a Project API key in the Hookdeck dashboard under your project's settings. + + +- [hookdeck outpost tenant list](#hookdeck-outpost-tenant-list) +- [hookdeck outpost tenant get](#hookdeck-outpost-tenant-get) +- [hookdeck outpost tenant upsert](#hookdeck-outpost-tenant-upsert) +- [hookdeck outpost tenant delete](#hookdeck-outpost-tenant-delete) +- [hookdeck outpost tenant token](#hookdeck-outpost-tenant-token) +- [hookdeck outpost tenant portal](#hookdeck-outpost-tenant-portal) +- [hookdeck outpost destination list](#hookdeck-outpost-destination-list) +- [hookdeck outpost destination get](#hookdeck-outpost-destination-get) +- [hookdeck outpost destination create](#hookdeck-outpost-destination-create) +- [hookdeck outpost destination update](#hookdeck-outpost-destination-update) +- [hookdeck outpost destination delete](#hookdeck-outpost-destination-delete) +- [hookdeck outpost destination enable](#hookdeck-outpost-destination-enable) +- [hookdeck outpost destination disable](#hookdeck-outpost-destination-disable) +- [hookdeck outpost destination-type list](#hookdeck-outpost-destination-type-list) +- [hookdeck outpost destination-type get](#hookdeck-outpost-destination-type-get) +- [hookdeck outpost event list](#hookdeck-outpost-event-list) +- [hookdeck outpost event get](#hookdeck-outpost-event-get) +- [hookdeck outpost event retry](#hookdeck-outpost-event-retry) +- [hookdeck outpost attempt list](#hookdeck-outpost-attempt-list) +- [hookdeck outpost attempt get](#hookdeck-outpost-attempt-get) +- [hookdeck outpost publish](#hookdeck-outpost-publish) +- [hookdeck outpost topic list](#hookdeck-outpost-topic-list) +- [hookdeck outpost metrics events](#hookdeck-outpost-metrics-events) +- [hookdeck outpost metrics attempts](#hookdeck-outpost-metrics-attempts) +- [hookdeck outpost config get](#hookdeck-outpost-config-get) +- [hookdeck outpost config set](#hookdeck-outpost-config-set) +- [hookdeck outpost config custom-domain get](#hookdeck-outpost-config-custom-domain-get) +- [hookdeck outpost config custom-domain set](#hookdeck-outpost-config-custom-domain-set) +- [hookdeck outpost config custom-domain delete](#hookdeck-outpost-config-custom-domain-delete) +- [hookdeck outpost status](#hookdeck-outpost-status) + +### hookdeck outpost tenant list + +List tenants in the current Outpost project. + +**Usage:** + +```bash +hookdeck outpost tenant list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--dir` | `string` | Sort direction (asc, desc) | +| `--id` | `string` | Filter by tenant ID(s), comma-separated | +| `--limit` | `int` | Limit number of results (1-100) (default "0") | +| `--next` | `string` | Next page cursor | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Previous page cursor | + +**Examples:** + +```bash +# List tenants +hookdeck outpost tenant list + +# Fetch specific tenants by ID +hookdeck outpost tenant list --id acme,globex + +# Page through results +hookdeck outpost tenant list --limit 20 --next +``` +### hookdeck outpost tenant get + +Get details for a tenant, including how many destinations it has. + +**Usage:** + +```bash +hookdeck outpost tenant get [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `tenant-id` | `string` | **Required.** The ID of the tenant. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Get a tenant +hookdeck outpost tenant get acme + +# As JSON +hookdeck outpost tenant get acme --output json +``` +### hookdeck outpost tenant upsert + +Create a new tenant or update an existing one by name (idempotent). + +Tenant IDs are chosen by you, not generated, so this is the only way to create one. +Re-running with the same ID updates the tenant's metadata rather than failing. + +Metadata is replaced wholesale, not merged: pass every key you want to keep. + +**Usage:** + +```bash +hookdeck outpost tenant upsert [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `tenant-id` | `string` | **Required.** The ID of the tenant to create or update. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--metadata` | `stringArray` | Metadata as key=value (repeatable) (default "[]") | +| `--metadata-file` | `string` | Path to a JSON file of metadata key/value pairs | +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Create or update a tenant +hookdeck outpost tenant upsert acme + +# With metadata +hookdeck outpost tenant upsert acme --metadata plan=pro --metadata region=eu + +# Metadata from a JSON file +hookdeck outpost tenant upsert acme --metadata-file ./tenant.json +``` +### hookdeck outpost tenant delete + +Delete a tenant. + +Deleting a tenant also removes its destinations, so events will stop being +delivered on its behalf. This cannot be undone. + +**Usage:** + +```bash +hookdeck outpost tenant delete [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `tenant-id` | `string` | **Required.** The ID of the tenant to delete. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Delete without confirmation | + +**Examples:** + +```bash +# Delete a tenant, with a confirmation prompt +hookdeck outpost tenant delete acme + +# Skip the prompt (for scripts and CI) +hookdeck outpost tenant delete acme --force +``` +### hookdeck outpost tenant token + +Mint a short-lived JWT scoped to a single tenant. + +The token grants access to that tenant's data and is valid for 24 hours. Treat it +as a credential: it is intended for your own backend to hand to a tenant's session, +not to be pasted into a shell history or shared. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost tenant token [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `tenant-id` | `string` | **Required.** The ID of the tenant to mint a token for. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Mint a token for a tenant +hookdeck outpost tenant token acme + +# As JSON, for piping into another tool +hookdeck outpost tenant token acme --output json +``` +### hookdeck outpost tenant portal + +Get a redirect URL for a tenant's portal, where they manage their own destinations. + +The URL grants access to that tenant's portal session, so treat it as a credential. + +This requires a portal custom domain to be configured for the project; see +'hookdeck outpost config custom-domain'. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost tenant portal [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `tenant-id` | `string` | **Required.** The ID of the tenant whose portal URL to fetch. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--open` | `bool` | Open the portal URL in your browser | +| `--output` | `string` | Output format (json) | +| `--theme` | `string` | Portal theme (light, dark) | + +**Examples:** + +```bash +# Print the portal URL +hookdeck outpost tenant portal acme + +# Open it in a browser +hookdeck outpost tenant portal acme --open + +# Request the dark theme +hookdeck outpost tenant portal acme --theme dark +``` +### hookdeck outpost destination list + +List a tenant's destinations. + +This endpoint is not paginated: every destination for the tenant is returned. + +**Usage:** + +```bash +hookdeck outpost destination list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | +| `--topics` | `string` | Filter by topic(s), comma-separated | +| `--type` | `string` | Filter by destination type(s), comma-separated | + +**Examples:** + +```bash +# List a tenant's destinations +hookdeck outpost destination list --tenant-id acme + +# Filter by type or topic +hookdeck outpost destination list --tenant-id acme --type webhook +hookdeck outpost destination list --tenant-id acme --topics user.created +``` +### hookdeck outpost destination get + +Get details for a destination, including its config and topics. + +**Usage:** + +```bash +hookdeck outpost destination get [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `destination-id` | `string` | **Required.** The ID of the destination. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Get a destination +hookdeck outpost destination get des_abc123 --tenant-id acme +``` +### hookdeck outpost destination create + +Create a destination for a tenant. + +Config and credential fields depend on `--type`. Pass them as repeatable key=value +pairs; run 'hookdeck outpost destination-type list' to see the available types and +'hookdeck outpost destination-type get ' to see the fields one accepts. + +Topics default to all ("*") when `--topics` is omitted. + +**Usage:** + +```bash +hookdeck outpost destination create [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--config` | `stringArray` | Config field as key=value (repeatable), e.g. `--config` url=https://example.com (default "[]") | +| `--config-file` | `string` | Path to a JSON file of config fields | +| `--credential` | `stringArray` | Credential field as key=value (repeatable) (default "[]") | +| `--credentials-file` | `string` | Path to a JSON file of credential fields | +| `--filter` | `string` | Event filter as a JSON object | +| `--filter-file` | `string` | Path to a JSON file containing an event filter | +| `--output` | `string` | Output format (json) | +| `--topics` | `string` | Topics to subscribe to, comma-separated, or "*" for all | +| `--type` | `string` | Destination type (required) | + +**Examples:** + +```bash +# A webhook destination subscribed to everything +hookdeck outpost destination create --tenant-id acme --type webhook \ +--config url=https://example.com/hooks + +# Subscribed to specific topics +hookdeck outpost destination create --tenant-id acme --type webhook \ +--config url=https://example.com/hooks --topics user.created,user.updated + +# With credentials and a filter +hookdeck outpost destination create --tenant-id acme --type aws_sqs \ +--config queue_url=https://sqs.eu-west-2.amazonaws.com/1/q \ +--credential key=AKIA... --credential secret=... \ +--filter '{"data":{"tier":"pro"}}' +``` +### hookdeck outpost destination update + +Update an existing destination by its ID. + +Only the fields you pass are changed; omitted fields are left alone. + +`--filter` is the exception: the API replaces the filter wholesale rather than +merging into it, so pass the complete filter you want. + +**Usage:** + +```bash +hookdeck outpost destination update [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `destination-id` | `string` | **Required.** The ID of the destination to update. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--config` | `stringArray` | Config field as key=value (repeatable), e.g. `--config` url=https://example.com (default "[]") | +| `--config-file` | `string` | Path to a JSON file of config fields | +| `--credential` | `stringArray` | Credential field as key=value (repeatable) (default "[]") | +| `--credentials-file` | `string` | Path to a JSON file of credential fields | +| `--filter` | `string` | Event filter as a JSON object | +| `--filter-file` | `string` | Path to a JSON file containing an event filter | +| `--output` | `string` | Output format (json) | +| `--topics` | `string` | Topics to subscribe to, comma-separated, or "*" for all | + +**Examples:** + +```bash +# Point a destination at a new URL +hookdeck outpost destination update des_abc123 --tenant-id acme \ +--config url=https://example.com/new + +# Change which topics it receives +hookdeck outpost destination update des_abc123 --tenant-id acme --topics "*" +``` +### hookdeck outpost destination delete + +Delete a destination. + +Events will stop being delivered to it. To stop delivery temporarily and keep the +destination, use 'disable' instead. + +**Usage:** + +```bash +hookdeck outpost destination delete [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `destination-id` | `string` | **Required.** The ID of the destination to delete. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Delete without confirmation | + +**Examples:** + +```bash +# Delete a destination, with a confirmation prompt +hookdeck outpost destination delete des_abc123 --tenant-id acme + +# Skip the prompt (for scripts and CI) +hookdeck outpost destination delete des_abc123 --tenant-id acme --force +``` +### hookdeck outpost destination enable + +Enable a disabled destination. + +**Usage:** + +```bash +hookdeck outpost destination enable [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `destination-id` | `string` | **Required.** The ID of the destination to enable. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Resume delivery to a destination +hookdeck outpost destination enable des_abc123 --tenant-id acme +``` +### hookdeck outpost destination disable + +Disable an active destination. It will stop receiving new events until re-enabled. + +The destination and its configuration are kept, so 'enable' resumes delivery. + +**Usage:** + +```bash +hookdeck outpost destination disable [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `destination-id` | `string` | **Required.** The ID of the destination to disable. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Pause delivery to a destination +hookdeck outpost destination disable des_abc123 --tenant-id acme +``` +### hookdeck outpost destination-type list + +List the destination types available in this project. + +**Usage:** + +```bash +hookdeck outpost destination-type list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# List available destination types +hookdeck outpost destination-type list +``` +### hookdeck outpost destination-type get + +Show the config and credential fields a destination type accepts. + +Each field lists whether it is required, whether it is sensitive, and any values +or format the schema constrains it to. + +**Usage:** + +```bash +hookdeck outpost destination-type get [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `type` | `string` | **Required.** The destination type to describe (e.g. webhook, aws_sqs). | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Show the fields a webhook destination accepts +hookdeck outpost destination-type get webhook +``` +### hookdeck outpost event list + +List published events, most recent first. + +Filters are combined with AND. Time bounds are ISO 8601 datetimes. + +**Usage:** + +```bash +hookdeck outpost event list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--destination-id` | `string` | Filter by matched destination ID(s), comma-separated | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--id` | `string` | Filter by event ID(s), comma-separated | +| `--limit` | `int` | Limit number of results (default "0") | +| `--next` | `string` | Next page cursor | +| `--order-by` | `string` | Field to sort by (time) | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Previous page cursor | +| `--tenant-id` | `string` | Filter by tenant ID(s), comma-separated | +| `--time-after` | `string` | Only events at or after this ISO 8601 datetime | +| `--time-before` | `string` | Only events at or before this ISO 8601 datetime | +| `--topic` | `string` | Filter by topic(s), comma-separated | + +**Examples:** + +```bash +# Recent events +hookdeck outpost event list --limit 10 + +# For one tenant, on one topic +hookdeck outpost event list --tenant-id acme --topic user.created + +# Within a time window +hookdeck outpost event list --time-after 2026-08-01T00:00:00Z --time-before 2026-08-14T00:00:00Z +``` +### hookdeck outpost event get + +Get an event, including the payload that was published. + +**Usage:** + +```bash +hookdeck outpost event get [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `event-id` | `string` | **Required.** The ID of the event. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | +| `--tenant-id` | `string` | Tenant the event belongs to | + +**Examples:** + +```bash +# Get an event +hookdeck outpost event get evt_abc123 + +# Get the payload alone +hookdeck outpost event get evt_abc123 --output json | jq .data +``` +### hookdeck outpost event retry + +Deliver an event to a destination again. + +The retry is queued rather than performed inline, so a successful response means +it was accepted, not that it has been delivered. Use 'hookdeck outpost attempt +list' to see the outcome. + +The destination must be enabled and must subscribe to the event's topic. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost event retry [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--destination-id` | `string` | The destination to deliver to (required) | +| `--event-id` | `string` | The event to retry (required) | +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Retry one delivery +hookdeck outpost event retry --event-id evt_abc123 --destination-id des_abc123 +``` +### hookdeck outpost attempt list + +List delivery attempts, most recent first. + +Passing both `--tenant-id` and `--destination-id` narrows to that destination +specifically; the filters and results are otherwise the same. + +**Usage:** + +```bash +hookdeck outpost attempt list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--destination-id` | `string` | Filter by destination ID(s), comma-separated | +| `--destination-type` | `string` | Filter by destination type(s), comma-separated | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--event-id` | `string` | Filter by event ID(s), comma-separated | +| `--include` | `string` | Include related data, comma-separated (event, event.data, response_data, destination) | +| `--limit` | `int` | Limit number of results (default "0") | +| `--next` | `string` | Next page cursor | +| `--order-by` | `string` | Field to sort by | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Previous page cursor | +| `--status` | `string` | Filter by status (success, failed) | +| `--tenant-id` | `string` | Filter by tenant ID(s), comma-separated | +| `--time-after` | `string` | Only attempts at or after this ISO 8601 datetime | +| `--time-before` | `string` | Only attempts at or before this ISO 8601 datetime | +| `--topic` | `string` | Filter by topic(s), comma-separated | + +**Examples:** + +```bash +# Recent failures +hookdeck outpost attempt list --status failed --limit 20 + +# Every attempt for one event +hookdeck outpost attempt list --event-id evt_abc123 + +# Include the response body the destination returned +hookdeck outpost attempt list --event-id evt_abc123 --include response_data --output json +``` +### hookdeck outpost attempt get + +Get a delivery attempt, including the destination's response. + +**Usage:** + +```bash +hookdeck outpost attempt get [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `attempt-id` | `string` | **Required.** The ID of the delivery attempt. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--destination-id` | `string` | Destination the attempt targeted | +| `--include` | `string` | Include related data, comma-separated (event, event.data, response_data, destination) | +| `--output` | `string` | Output format (json) | +| `--tenant-id` | `string` | Tenant the attempt belongs to | + +**Examples:** + +```bash +# Get an attempt with the response body +hookdeck outpost attempt get att_abc123 --include response_data --output json +``` +### hookdeck outpost publish + +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. + +This command needs a Hookdeck Project API key, which is different from every +other outpost command. The credentials stored by 'hookdeck login' are not +accepted by the publish API, so pass `--api-key` or set HOOKDECK_API_KEY. You can +create a Project API key in the Hookdeck dashboard under project settings. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost publish [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--api-key` | `string` | Hookdeck Project API key. Read from HOOKDECK_API_KEY when not provided. | +| `--data` | `string` | Event payload as a JSON object | +| `--data-file` | `string` | Path to a JSON file containing the event payload | +| `--destination-id` | `string` | Deliver only to this destination | +| `--eligible-for-retry` | `bool` | Whether failed deliveries should be retried (default "true") | +| `--event-id` | `string` | Event ID, for idempotent publishing | +| `--metadata` | `stringArray` | Metadata as key=value (repeatable) (default "[]") | +| `--output` | `string` | Output format (json) | +| `--tenant-id` | `string` | Tenant to publish for (required) | +| `--topic` | `string` | Topic to publish to (required) | + +**Examples:** + +```bash +# Publish an event +hookdeck outpost publish --tenant-id acme --topic user.created \ +--data '{"user_id":"123"}' --api-key $HOOKDECK_API_KEY + +# Publish to one specific destination +hookdeck outpost publish --tenant-id acme --topic user.created \ +--data '{"user_id":"123"}' --destination-id des_abc123 + +# Idempotent publish: repeating the same --event-id will not duplicate +hookdeck outpost publish --tenant-id acme --topic user.created \ +--event-id my-unique-id --data-file ./payload.json +``` +### hookdeck outpost topic list + +List the topics configured for this project. + +**Usage:** + +```bash +hookdeck outpost topic list [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# List topics +hookdeck outpost topic list +``` +### hookdeck outpost metrics events + +Aggregated event publish metrics. + +Measures: count, rate + +Dimensions: tenant_id, topic, destination_id + +Omit `--granularity` for a single total over the whole range; set it (1h, 5m, 1d) +to bucket the results over time. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost metrics events [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--dimensions` | `string` | Dimensions to group by, comma-separated | +| `--end` | `string` | End of the range, ISO 8601 (required) | +| `--filter` | `stringArray` | Filter as dimension=value (repeatable) (default "[]") | +| `--granularity` | `string` | Bucket size (e.g. 5m, 1h, 1d) | +| `--measures` | `string` | Measures to compute, comma-separated (required) | +| `--output` | `string` | Output format (json) | +| `--start` | `string` | Start of the range, ISO 8601 (required) | + +**Examples:** + +```bash +# Total over the last week +hookdeck outpost metrics events --start 2026-08-07T00:00:00Z --end 2026-08-14T00:00:00Z --measures count + +# Bucketed hourly and grouped by topic +hookdeck outpost metrics events --start 2026-08-13T00:00:00Z --end 2026-08-14T00:00:00Z \ +--measures count --granularity 1h --dimensions topic +``` +### hookdeck outpost metrics attempts + +Aggregated delivery attempt metrics. + +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; set it (1h, 5m, 1d) +to bucket the results over time. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost metrics attempts [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--dimensions` | `string` | Dimensions to group by, comma-separated | +| `--end` | `string` | End of the range, ISO 8601 (required) | +| `--filter` | `stringArray` | Filter as dimension=value (repeatable) (default "[]") | +| `--granularity` | `string` | Bucket size (e.g. 5m, 1h, 1d) | +| `--measures` | `string` | Measures to compute, comma-separated (required) | +| `--output` | `string` | Output format (json) | +| `--start` | `string` | Start of the range, ISO 8601 (required) | + +**Examples:** + +```bash +# Total over the last week +hookdeck outpost metrics attempts --start 2026-08-07T00:00:00Z --end 2026-08-14T00:00:00Z --measures count + +# Bucketed hourly and grouped by topic +hookdeck outpost metrics attempts --start 2026-08-13T00:00:00Z --end 2026-08-14T00:00:00Z \ +--measures count --granularity 1h --dimensions topic +``` +### hookdeck outpost config get + +Show this project's Outpost configuration. + +Pass a key to print just that value, which is convenient in scripts. Unset keys +are omitted unless you ask for one by name. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost config get [key] [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `key` | `string` | **Optional.** A single configuration key to print. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Show everything that is set +hookdeck outpost config get + +# Show one value +hookdeck outpost config get TOPICS +``` +### hookdeck outpost config set + +Change this project's Outpost configuration. + +Only the keys you pass are changed. `--unset` returns a key to its default. + +This affects delivery for every tenant in the project, so use `--dry-run` first to +see exactly what would change. + +Some keys are managed for you and are rejected if set directly; the API says +which when that happens. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost config set [KEY=VALUE ...] [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `KEY=VALUE` | `string` | **Optional.** Configuration values to set. Repeatable. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--config-file` | `string` | Path to a JSON file of configuration values | +| `--dry-run` | `bool` | Show what would change without applying it | +| `--output` | `string` | Output format (json) | +| `--unset` | `stringArray` | Return a key to its default (repeatable) (default "[]") | + +**Examples:** + +```bash +# Set the topics destinations can subscribe to +hookdeck outpost config set TOPICS=user.created,user.updated + +# Preview a change without applying it +hookdeck outpost config set MAX_RETRY_LIMIT=5 --dry-run + +# Return a key to its default +hookdeck outpost config set --unset MAX_RETRY_LIMIT +``` +### hookdeck outpost config custom-domain get + +Show the custom domain configured for the tenant portal, if any. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost config custom-domain get [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Show the configured custom domain +hookdeck outpost config custom-domain get +``` +### hookdeck outpost config custom-domain set + +Configure a custom hostname for the tenant portal. + +The response includes the DNS records to create. The domain is not usable until +they have propagated and been verified. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost config custom-domain set [flags] +``` + +**Arguments:** + +| Argument | Type | Description | +|----------|------|-------------| +| `hostname` | `string` | **Required.** The hostname to serve the tenant portal from. | + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Configure a custom domain +hookdeck outpost config custom-domain set portal.example.com +``` +### hookdeck outpost config custom-domain delete + +Remove the tenant portal's custom domain. + +Tenant portal URLs stop working until another domain is configured. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost config custom-domain delete [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Delete without confirmation | + +**Examples:** + +```bash +# Remove the custom domain, with a confirmation prompt +hookdeck outpost config custom-domain delete + +# Skip the prompt (for scripts and CI) +hookdeck outpost config custom-domain delete --force +``` +### hookdeck outpost status + +Show the status of this project's Outpost deployment. + +Worth checking first when something is not behaving: configuration changes take +a short while to reach the deployment, and the status reports when it is still +being applied. + +[BETA] This feature is in beta. Please share bugs and feedback via: +https://github.com/hookdeck/hookdeck-cli/issues + +**Usage:** + +```bash +hookdeck outpost status [flags] +``` + +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + +```bash +# Check deployment status +hookdeck outpost status +``` + ## Utilities diff --git a/tools/generate-reference/main.go b/tools/generate-reference/main.go index aecb2a1f..358d352a 100644 --- a/tools/generate-reference/main.go +++ b/tools/generate-reference/main.go @@ -139,6 +139,7 @@ var generateMarkerRE = regexp.MustCompile(`(?m)^(" + var generateEndRE = regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(generateEndMarker) + `\s*$`) // findNextEndMarker returns (start, length) of the next GENERATE*:END marker, or (-1, 0). @@ -381,10 +382,12 @@ func globalFlagsTable(root *cobra.Command) string { func generateTOC(root *cobra.Command) string { // Groups only; no per-command sub-links + // Hand-maintained rather than derived from the headings, so it stays in + // reading order. Add new top-level sections here. sections := []string{ "Global Options", "Authentication", "Projects", "Local Development", "Gateway", "Connections", "Sources", "Destinations", "Transformations", "Events", "Requests", - "Attempts", "Utilities", + "Attempts", "Metrics", "Outpost", "Utilities", } var b bytes.Buffer for _, title := range sections { From 640b00f9790cf1b582f6492d7bdf324fd9275e23 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 18:00:14 +0100 Subject: [PATCH 09/49] test(outpost): cover event get, attempt get, tenant token and retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises CLI-level acceptance coverage from 22/30 to 26/30 leaf commands. All four reuse data the existing tests already create, so they add coverage without adding setup. The tenant token assertion checks shape rather than contents — three JWT segments, and that the raw tenant id is not readable in it. The token is a real credential, so a test should not print or match on its payload. The four commands still uncovered are the tenant portal and its custom domain. They are not omitted casually: `custom-domain set` configures a real DNS-verified hostname on the shared project, and `tenant portal` returns 404 until one exists. Covering them safely needs a dedicated throwaway domain. They are the least proven surface and should be called out as such in beta release notes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- test/acceptance/outpost_test.go | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/test/acceptance/outpost_test.go b/test/acceptance/outpost_test.go index edcb81c6..392e83d0 100644 --- a/test/acceptance/outpost_test.go +++ b/test/acceptance/outpost_test.go @@ -104,6 +104,13 @@ func TestOutpostTenantLifecycle(t *testing.T) { stdout = cli.RunExpectSuccess("outpost", "tenant", "list", "--id", tenantID, "--output", "json") assert.Contains(t, stdout, tenantID) + // The token is a real credential scoped to this tenant, so assert its shape + // rather than its contents: three dot-separated JWT segments, nothing logged. + stdout = cli.RunExpectSuccess("outpost", "tenant", "token", tenantID) + token := strings.TrimSpace(stdout) + assert.Len(t, strings.Split(token, "."), 3, "expected a JWT") + assert.NotContains(t, token, tenantID, "the raw tenant id should not be readable in the token") + cli.RunExpectSuccess("outpost", "tenant", "delete", tenantID, "--force") _, _, err := cli.Run("outpost", "tenant", "get", tenantID) @@ -231,8 +238,52 @@ func TestOutpostPublishAndInspect(t *testing.T) { return json.Unmarshal([]byte(out), &events) == nil && len(events.Models) > 0 }, 30*time.Second, 2*time.Second, "the published event never appeared") - stdout = cli.RunExpectSuccess("outpost", "attempt", "list", "--tenant-id", tenantID, "--limit", "5") - assert.NotEmpty(t, stdout) + // Fetch one event by id, using an id from the list above rather than + // assuming the publish response id is queryable yet. + listed := cli.RunExpectSuccess("outpost", "event", "list", "--tenant-id", tenantID, "--output", "json") + var events struct { + Models []struct { + ID string `json:"id"` + Topic string `json:"topic"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal([]byte(listed), &events)) + require.NotEmpty(t, events.Models) + + stdout = cli.RunExpectSuccess("outpost", "event", "get", events.Models[0].ID, "--tenant-id", tenantID, "--output", "json") + assert.Contains(t, stdout, events.Models[0].ID) + assert.Contains(t, stdout, "acceptance", "the published payload should come back") + + // Delivery to example.com fails, but a failed attempt still exercises the + // read path, which is what is being checked here. + var attempts struct { + Models []struct { + ID string `json:"id"` + } `json:"models"` + } + require.Eventually(t, func() bool { + out, _, err := cli.Run("outpost", "attempt", "list", "--tenant-id", tenantID, "--output", "json") + if err != nil { + return false + } + return json.Unmarshal([]byte(out), &attempts) == nil && len(attempts.Models) > 0 + }, 60*time.Second, 3*time.Second, "no delivery attempt was recorded") + + stdout = cli.RunExpectSuccess("outpost", "attempt", "get", attempts.Models[0].ID, "--tenant-id", tenantID) + assert.Contains(t, stdout, attempts.Models[0].ID) + + t.Run("retry queues another attempt", func(t *testing.T) { + destinations := cli.RunExpectSuccess("outpost", "destination", "list", "--tenant-id", tenantID, "--output", "json") + var dests []struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal([]byte(destinations), &dests)) + require.NotEmpty(t, dests) + + out := cli.RunExpectSuccess("outpost", "event", "retry", + "--event-id", events.Models[0].ID, "--destination-id", dests[0].ID) + assert.Contains(t, out, "Retry accepted") + }) } func TestOutpostPublishRequiresProjectAPIKey(t *testing.T) { From b09cda9a1b353f0483a2a3ce4b79d28ad05ff494 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:23:47 +0000 Subject: [PATCH 10/49] Update package.json version to 2.6.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 96524745..6588dba4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "2.5.0", + "version": "2.6.0-beta.1", "description": "Hookdeck CLI", "repository": { "type": "git", From 9445c8ab177b00255d5de2d8ef5f010205776ff8 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 17:54:17 +0100 Subject: [PATCH 11/49] refactor(mcp): extract product-agnostic MCP server into pkg/mcpcore The MCP server scaffolding in pkg/gateway/mcp was written for one product but almost none of it is Gateway-specific. Move the shared parts into a new pkg/mcpcore so a second Hookdeck MCP server can reuse them instead of forking them: input parsing, the data/meta response envelope, API error translation, the auth guard, the JSON Schema helpers, project display resolution, the login and projects tools, and the server/telemetry scaffolding. Each product supplies its own identity, tool-name prefix, API client and tool list through mcpcore.Options. Everything the login and projects tools say about "the login tool" or "the projects tool" now comes from that prefix, so a second server cannot tell an agent to call a tool that does not exist in its session. Help topic normalisation takes the prefix as a parameter for the same reason. Also adds two things the second server needs, kept here so there is only one implementation of each: - TranslateAPIError handles 403 distinctly from 401. "Check your API key" is the wrong advice when the credential is valid but not permitted. - RequireWrite(enabled, action) guards a write action on a server started in read-only mode. And an option the Gateway does not use: Options.ProjectFilter restricts which project types the projects tool lists and will switch to, so a server cannot be pointed at a project it has no API for. Gateway leaves it unset and keeps its current behaviour. Gateway behaviour is unchanged: same tool names, descriptions, schemas and response shapes. pkg/gateway/mcp now holds only its tool definitions and resource handlers. Unit tests for the moved code moved with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/gateway/mcp/auth.go | 17 -- pkg/gateway/mcp/input_test.go | 51 ----- pkg/gateway/mcp/server.go | 144 ------------ pkg/gateway/mcp/server_test.go | 93 +------- pkg/gateway/mcp/telemetry_test.go | 121 ---------- pkg/gateway/mcp/tool_attempts.go | 35 +-- pkg/gateway/mcp/tool_connections.go | 61 ++--- pkg/gateway/mcp/tool_destinations.go | 31 +-- pkg/gateway/mcp/tool_events.go | 78 +++---- pkg/gateway/mcp/tool_help.go | 30 +-- pkg/gateway/mcp/tool_issues.go | 40 ++-- pkg/gateway/mcp/tool_metrics.go | 43 ++-- pkg/gateway/mcp/tool_projects.go | 113 ---------- pkg/gateway/mcp/tool_requests.go | 78 +++---- pkg/gateway/mcp/tool_sources.go | 31 +-- pkg/gateway/mcp/tool_transformations.go | 31 +-- pkg/gateway/mcp/tools.go | 195 ++++++++-------- pkg/mcpcore/auth.go | 35 +++ pkg/mcpcore/auth_test.go | 39 ++++ pkg/{gateway/mcp => mcpcore}/errors.go | 10 +- pkg/mcpcore/errors_test.go | 73 ++++++ pkg/mcpcore/help.go | 41 ++++ pkg/mcpcore/help_test.go | 45 ++++ pkg/{gateway/mcp => mcpcore}/input.go | 46 ++-- pkg/mcpcore/input_test.go | 90 ++++++++ .../mcp => mcpcore}/project_display.go | 4 +- .../mcp => mcpcore}/project_display_test.go | 6 +- pkg/{gateway/mcp => mcpcore}/response.go | 2 +- pkg/{gateway/mcp => mcpcore}/response_test.go | 2 +- pkg/mcpcore/schema.go | 24 ++ pkg/mcpcore/server.go | 211 ++++++++++++++++++ pkg/mcpcore/server_test.go | 131 +++++++++++ pkg/{gateway/mcp => mcpcore}/tool_login.go | 52 +++-- pkg/mcpcore/tool_projects.go | 159 +++++++++++++ .../mcp => mcpcore}/tool_projects_errors.go | 10 +- .../tool_projects_errors_test.go | 2 +- pkg/mcpcore/tool_projects_test.go | 120 ++++++++++ 37 files changed, 1373 insertions(+), 921 deletions(-) delete mode 100644 pkg/gateway/mcp/auth.go delete mode 100644 pkg/gateway/mcp/input_test.go delete mode 100644 pkg/gateway/mcp/server.go delete mode 100644 pkg/gateway/mcp/tool_projects.go create mode 100644 pkg/mcpcore/auth.go create mode 100644 pkg/mcpcore/auth_test.go rename pkg/{gateway/mcp => mcpcore}/errors.go (69%) create mode 100644 pkg/mcpcore/errors_test.go create mode 100644 pkg/mcpcore/help.go create mode 100644 pkg/mcpcore/help_test.go rename pkg/{gateway/mcp => mcpcore}/input.go (66%) create mode 100644 pkg/mcpcore/input_test.go rename pkg/{gateway/mcp => mcpcore}/project_display.go (91%) rename pkg/{gateway/mcp => mcpcore}/project_display_test.go (91%) rename pkg/{gateway/mcp => mcpcore}/response.go (99%) rename pkg/{gateway/mcp => mcpcore}/response_test.go (99%) create mode 100644 pkg/mcpcore/schema.go create mode 100644 pkg/mcpcore/server.go create mode 100644 pkg/mcpcore/server_test.go rename pkg/{gateway/mcp => mcpcore}/tool_login.go (77%) create mode 100644 pkg/mcpcore/tool_projects.go rename pkg/{gateway/mcp => mcpcore}/tool_projects_errors.go (70%) rename pkg/{gateway/mcp => mcpcore}/tool_projects_errors_test.go (99%) create mode 100644 pkg/mcpcore/tool_projects_test.go 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/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/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..2aff678a --- /dev/null +++ b/pkg/mcpcore/help.go @@ -0,0 +1,41 @@ +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 { + if 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/gateway/mcp/project_display.go b/pkg/mcpcore/project_display.go similarity index 91% rename from pkg/gateway/mcp/project_display.go rename to pkg/mcpcore/project_display.go index c16cffd8..57ef2232 100644 --- a/pkg/gateway/mcp/project_display.go +++ b/pkg/mcpcore/project_display.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -9,7 +9,7 @@ import ( // 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) { +func FillProjectDisplayNameIfNeeded(client *hookdeck.Client) { if client == nil || client.APIKey == "" || client.ProjectID == "" { return } diff --git a/pkg/gateway/mcp/project_display_test.go b/pkg/mcpcore/project_display_test.go similarity index 91% rename from pkg/gateway/mcp/project_display_test.go rename to pkg/mcpcore/project_display_test.go index 51beac71..2245bfcd 100644 --- a/pkg/gateway/mcp/project_display_test.go +++ b/pkg/mcpcore/project_display_test.go @@ -1,4 +1,4 @@ -package mcp +package mcpcore import ( "encoding/json" @@ -30,13 +30,13 @@ func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { APIKey: "k", ProjectID: "proj_x", } - fillProjectDisplayNameIfNeeded(client) + 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) + FillProjectDisplayNameIfNeeded(client) require.Equal(t, "already", client.ProjectName) } 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..ae1cbefc --- /dev/null +++ b/pkg/mcpcore/server.go @@ -0,0 +1,211 @@ +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 + + // 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 + + // 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 + 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, cfg: opts.Config} + + 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 } + +// 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.ToolName("login") } + +// ProjectsToolName returns the name of this server's projects tool. +func (s *Server) ProjectsToolName() string { return s.ToolName("projects") } + +// 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() + + 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/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 77% rename from pkg/gateway/mcp/tool_login.go rename to pkg/mcpcore/tool_login.go index 7e9bfeb8..76177102 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,34 @@ 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 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,9 +79,10 @@ 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 { @@ -96,8 +119,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 +128,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 +170,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} }() @@ -199,9 +222,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..ca05b924 --- /dev/null +++ b/pkg/mcpcore/tool_projects.go @@ -0,0 +1,159 @@ +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 client, restricted to the +// server's project type when one is configured. +func listProjectItems(srv *Server, client *hookdeck.Client) ([]project.ProjectListItem, error) { + if err := project.EnsureUserAssociatedClient(client); err != nil { + return nil, err + } + + projects, err := client.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 + } + + 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_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..d4a6504b --- /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, "outpost_projects", srv.ProjectsToolName()) + assert.Equal(t, "outpost_login", srv.LoginToolName()) + assert.Equal(t, "outpost_events", srv.ToolName("events")) + assert.Equal(t, "outpost_", srv.ToolPrefix()) + + def := srv.ProjectsToolDef("desc") + assert.Equal(t, "outpost_projects", def.Tool.Name) + assert.Equal(t, "desc", def.Tool.Description) +} From 536935d12acf92ea74d187d34c9e07e103b3e221 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 18:26:40 +0100 Subject: [PATCH 12/49] feat(outpost): add an MCP server for AI agent access `hookdeck outpost mcp` exposes Outpost as MCP tools: tenants, their destinations, published events, delivery attempts, topics, destination type schemas, metrics, project configuration and deployment status. Tools are prefixed outpost_ so this server and `hookdeck gateway mcp` can be configured in the same client. The server starts read-only. The gate is the schema rather than a runtime check: in read-only mode the write actions are absent from each tool's action enum and from its description, so an agent is never told about an action it cannot use, and a tool whose every action is a write is not registered at all rather than registered to always fail. A guard in each handler backs that up for a client that calls one anyway. --allow-write enables the rest, and is also read from HOOKDECK_MCP_ALLOW_WRITE, with the flag winning. A bare --read-only is accepted for the many users who type it out of habit; it wins over --allow-write. Two actions that only read are gated with the writes: `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 a reusable credential, so a read/write split drawn on HTTP methods alone would leave a read-only session able to produce them at will. outpost_help says so, along with the current mode and how to change it. 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, and outpost_help explains why. Notes on wiring: - The server is built on the Outpost API client and mutates that one, so `outpost_projects use` moves the client the later calls actually go through. Listing projects and validating credentials are account-level requests that the Outpost host does not serve, so those go through a separate account client, which is kept in step on a project switch or a login. mcpcore gained an AccountClient option for this. - `outpost_projects` only lists, and only switches to, Outpost projects. A Gateway project would leave every later call failing. - The MCP stdout hygiene and authentication fallback in root.go now apply to any ` mcp` command, and name the login tool that exists in that session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- README.md | 51 ++ REFERENCE.md | 67 +++ pkg/cmd/outpost.go | 1 + pkg/cmd/outpost_mcp.go | 134 ++++++ pkg/cmd/outpost_mcp_test.go | 68 +++ pkg/cmd/root.go | 61 ++- pkg/cmd/root_argv_test.go | 23 +- pkg/mcpcore/project_display.go | 28 +- pkg/mcpcore/project_display_test.go | 38 +- pkg/mcpcore/server.go | 50 +- pkg/mcpcore/tool_login.go | 25 +- pkg/mcpcore/tool_projects.go | 22 +- pkg/outpost/mcp/input.go | 72 +++ pkg/outpost/mcp/projects_test.go | 84 ++++ pkg/outpost/mcp/tool_attempts.go | 116 +++++ pkg/outpost/mcp/tool_catalog.go | 147 ++++++ pkg/outpost/mcp/tool_config.go | 132 ++++++ pkg/outpost/mcp/tool_destinations.go | 162 +++++++ pkg/outpost/mcp/tool_events.go | 121 +++++ pkg/outpost/mcp/tool_help.go | 272 +++++++++++ pkg/outpost/mcp/tool_metrics.go | 126 +++++ pkg/outpost/mcp/tool_publish.go | 89 ++++ pkg/outpost/mcp/tool_tenants.go | 148 ++++++ pkg/outpost/mcp/tools.go | 274 +++++++++++ pkg/outpost/mcp/tools_test.go | 684 +++++++++++++++++++++++++++ test/acceptance/helpers.go | 136 +++++- test/acceptance/mcp_test.go | 22 - test/acceptance/outpost_mcp_test.go | 192 ++++++++ 28 files changed, 3256 insertions(+), 89 deletions(-) create mode 100644 pkg/cmd/outpost_mcp.go create mode 100644 pkg/cmd/outpost_mcp_test.go create mode 100644 pkg/outpost/mcp/input.go create mode 100644 pkg/outpost/mcp/projects_test.go create mode 100644 pkg/outpost/mcp/tool_attempts.go create mode 100644 pkg/outpost/mcp/tool_catalog.go create mode 100644 pkg/outpost/mcp/tool_config.go create mode 100644 pkg/outpost/mcp/tool_destinations.go create mode 100644 pkg/outpost/mcp/tool_events.go create mode 100644 pkg/outpost/mcp/tool_help.go create mode 100644 pkg/outpost/mcp/tool_metrics.go create mode 100644 pkg/outpost/mcp/tool_publish.go create mode 100644 pkg/outpost/mcp/tool_tenants.go create mode 100644 pkg/outpost/mcp/tools.go create mode 100644 pkg/outpost/mcp/tools_test.go create mode 100644 test/acceptance/outpost_mcp_test.go 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..64212386 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -3027,6 +3027,73 @@ 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 `--api-key` or set HOOKDECK_API_KEY to enable it. + +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. | +| `--api-key` | `string` | Hookdeck Project API key, required by the publish tool. Read from HOOKDECK_API_KEY when not provided. | +| `--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 --api-key $HOOKDECK_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..4596c018 --- /dev/null +++ b/pkg/cmd/outpost_mcp.go @@ -0,0 +1,134 @@ +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" + +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 --api-key or set HOOKDECK_API_KEY to enable it. + +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 --api-key $HOOKDECK_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, "api-key", "", "Hookdeck Project API key, required by the publish tool. Read from HOOKDECK_API_KEY when not provided.") + + 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() + + publishAPIKey := mc.apiKey + if publishAPIKey == "" { + publishAPIKey = os.Getenv("HOOKDECK_API_KEY") + } + + 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..13636ed2 --- /dev/null +++ b/pkg/cmd/outpost_mcp_test.go @@ -0,0 +1,68 @@ +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", "api-key"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing --%s", name) + } + + // 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/mcpcore/project_display.go b/pkg/mcpcore/project_display.go index 57ef2232..33baa7f4 100644 --- a/pkg/mcpcore/project_display.go +++ b/pkg/mcpcore/project_display.go @@ -5,28 +5,32 @@ import ( "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 == "" { +// FillProjectDisplayNameIfNeeded sets target.ProjectOrg and target.ProjectName +// from the project list 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 the project list is fetched from, which is not always +// target: a product API served from its own host does not answer account-level +// requests, so the lookup has to go to the account API. +func FillProjectDisplayNameIfNeeded(lookup, target *hookdeck.Client) { + if lookup == nil || target == nil || target.APIKey == "" || target.ProjectID == "" { return } - if client.ProjectName != "" || client.ProjectOrg != "" { + if target.ProjectName != "" || target.ProjectOrg != "" { return } - projects, err := client.ListProjects() + projects, err := lookup.ListProjects() if err != nil { return } - items := project.NormalizeProjects(projects, client.ProjectID) + items := project.NormalizeProjects(projects, target.ProjectID) for i := range items { - if items[i].Id != client.ProjectID { + if items[i].Id != target.ProjectID { continue } - client.ProjectOrg = items[i].Org - client.ProjectName = items[i].Project + 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 index 2245bfcd..3c94c794 100644 --- a/pkg/mcpcore/project_display_test.go +++ b/pkg/mcpcore/project_display_test.go @@ -30,13 +30,47 @@ func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { APIKey: "k", ProjectID: "proj_x", } - FillProjectDisplayNameIfNeeded(client) + 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) + 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) +} diff --git a/pkg/mcpcore/server.go b/pkg/mcpcore/server.go index ae1cbefc..0c901931 100644 --- a/pkg/mcpcore/server.go +++ b/pkg/mcpcore/server.go @@ -36,6 +36,12 @@ type Options struct { // 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 @@ -54,10 +60,11 @@ type Options struct { // Server wraps the MCP SDK server and the Hookdeck API client. type Server struct { - opts Options - client *hookdeck.Client - cfg *config.Config - mcpServer *mcpsdk.Server + 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 @@ -73,7 +80,10 @@ type Server struct { // 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, cfg: opts.Config} + 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{ @@ -95,6 +105,20 @@ func NewServer(opts Options) *Server { // 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 } @@ -165,7 +189,7 @@ func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler) deviceName, _ := os.Hostname() - s.client.Telemetry = &hookdeck.CLITelemetry{ + telemetry := &hookdeck.CLITelemetry{ Source: "mcp", Environment: hookdeck.DetectEnvironment(), CommandPath: commandPath, @@ -173,9 +197,19 @@ func (s *Server) wrapWithTelemetry(toolName string, handler mcpsdk.ToolHandler) DeviceName: deviceName, MCPClient: mcpClientInfo(req), } - defer func() { s.client.Telemetry = nil }() + // 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.client) + FillProjectDisplayNameIfNeeded(s.accountClient, s.client) return handler(ctx, req) } diff --git a/pkg/mcpcore/tool_login.go b/pkg/mcpcore/tool_login.go index 76177102..40145f23 100644 --- a/pkg/mcpcore/tool_login.go +++ b/pkg/mcpcore/tool_login.go @@ -59,6 +59,9 @@ func (s *Server) LoginToolDef(description string) ToolDef { 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 @@ -88,16 +91,18 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { 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 } @@ -202,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 @@ -211,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, diff --git a/pkg/mcpcore/tool_projects.go b/pkg/mcpcore/tool_projects.go index ca05b924..9ed1dd38 100644 --- a/pkg/mcpcore/tool_projects.go +++ b/pkg/mcpcore/tool_projects.go @@ -64,14 +64,18 @@ type projectEntry struct { Current bool `json:"current"` } -// listProjectItems fetches the projects visible to the client, restricted to the -// server's project type when one is configured. +// 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) { - if err := project.EnsureUserAssociatedClient(client); err != nil { + accountClient := srv.AccountClient() + if err := project.EnsureUserAssociatedClient(accountClient); err != nil { return nil, err } - projects, err := client.ListProjects() + projects, err := accountClient.ListProjects() if err != nil { return nil, err } @@ -142,9 +146,13 @@ func projectsUse(srv *Server, client *hookdeck.Client, in Input) (*mcpsdk.CallTo return ErrorResult(fmt.Sprintf("project %q not found", id)), nil } - client.ProjectID = id - client.ProjectOrg = found.Org - client.ProjectName = found.Project + // 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, 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..c7243419 --- /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, "outpost_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, "outpost_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, "outpost_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..69b5e83b --- /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.", + 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..458bcfea --- /dev/null +++ b/pkg/outpost/mcp/tool_help.go @@ -0,0 +1,272 @@ +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 {}. + +Plain text (not this shape): outpost_help text, outpost_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" + + "--api-key , or set HOOKDECK_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_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 outpost_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(): `outpost_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 (in-memory only) + +Parameters: + action (string, required) — "list" or "use" + project_id (string) — Required for "use"`, + + srv.LoginToolName(): `outpost_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 + outpost_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..864b73ec --- /dev/null +++ b/pkg/outpost/mcp/tool_publish.go @@ -0,0 +1,89 @@ +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 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 + } + + 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 + } + return mcpcore.JSONResultEnvelopeForClient(result, client) + } +} diff --git a/pkg/outpost/mcp/tool_tenants.go b/pkg/outpost/mcp/tool_tenants.go new file mode 100644 index 00000000..ff7e60ae --- /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.", + 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..6e48fc5d --- /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 outpost_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 outpost_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..65484f4e --- /dev/null +++ b/pkg/outpost/mcp/tools_test.go @@ -0,0 +1,684 @@ +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{ + "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) + } + }) + + 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 +// --------------------------------------------------------------------------- + +func TestUnauthenticated_PointsAtTheOutpostLoginTool(t *testing.T) { + api := mockAPI(t, nil) + client := newTestClient(t, api.URL) + client.APIKey = "" + session := connect(t, ServerOptions{Client: client}) + + for _, name := range []string{"outpost_tenants", "outpost_events", "outpost_status", "outpost_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, "outpost_login") + assert.NotContains(t, text, "hookdeck_login", "the gateway tool does not exist in this session") + }) + } +} + +// --------------------------------------------------------------------------- +// 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{ + "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_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, "outpost_projects", srv.ProjectsToolName()) + assert.Equal(t, "outpost_login", srv.LoginToolName()) + + var _ *mcpcore.Server = srv +} 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") +} From 29145267c91dd0a1685939ee4e1576c83fdc8595 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 19:28:47 +0100 Subject: [PATCH 13/49] refactor(mcp): keep login and projects on the hookdeck_ prefix in every server Login and project switching are Hookdeck platform operations, not Gateway or Outpost ones. You log in to Hookdeck; you switch a Hookdeck project. So both servers now expose hookdeck_login and hookdeck_projects, while product tools keep their own prefix: outpost_tenants, hookdeck_connections. Outpost previously named these outpost_login and outpost_projects. The original reasoning was collision avoidance when both servers are configured in one client, which does not hold up: it is the same operation, clients namespace by server, and one consistent name for it is a feature rather than a clash. Gateway is unchanged, verified over stdio. Outpost is unreleased, so this costs nothing now and would be a breaking rename later. Two things this surfaced: - HelpTopic prepended the product prefix unconditionally, so a platform topic became outpost_hookdeck_projects and missed. It now tries the exact tool name first, which is what a caller passing a name from tools/list will send. - A test asserted the Outpost error must not mention hookdeck_login, on the grounds that the gateway tool does not exist in that session. That premise is now deliberately false. Rewritten to assert the error names a tool the session actually registers, which is the property worth holding. Note this does not address Gateway's own inconsistency: its product tools are also hookdeck_-prefixed, which needs a rename and a major bump (#352). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/login/claimed_cli_key.go | 108 +++++++++++++++--------------- pkg/mcpcore/help.go | 8 ++- pkg/mcpcore/server.go | 30 ++++++++- pkg/mcpcore/tool_projects_test.go | 6 +- pkg/outpost/mcp/projects_test.go | 6 +- pkg/outpost/mcp/tool_help.go | 10 +-- pkg/outpost/mcp/tools.go | 4 +- pkg/outpost/mcp/tools_test.go | 28 ++++++-- 8 files changed, 122 insertions(+), 78 deletions(-) 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/help.go b/pkg/mcpcore/help.go index 2aff678a..63cb727d 100644 --- a/pkg/mcpcore/help.go +++ b/pkg/mcpcore/help.go @@ -16,10 +16,14 @@ import ( // 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 { - if prefix != "" && !strings.HasPrefix(topic, prefix) { + // 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] } - text, ok := topics[topic] if ok { if suffix != "" { return TextResult(text + "\n\n" + suffix) diff --git a/pkg/mcpcore/server.go b/pkg/mcpcore/server.go index 0c901931..4dbf7d42 100644 --- a/pkg/mcpcore/server.go +++ b/pkg/mcpcore/server.go @@ -31,6 +31,11 @@ type Options struct { // 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. @@ -146,10 +151,31 @@ func (s *Server) ToolPrefix() string { } // LoginToolName returns the name of this server's login tool. -func (s *Server) LoginToolName() string { return s.ToolName("login") } +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.ToolName("projects") } +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. diff --git a/pkg/mcpcore/tool_projects_test.go b/pkg/mcpcore/tool_projects_test.go index d4a6504b..7913756d 100644 --- a/pkg/mcpcore/tool_projects_test.go +++ b/pkg/mcpcore/tool_projects_test.go @@ -109,12 +109,12 @@ func TestProjectsTool_ToolNamesFollowThePrefix(t *testing.T) { api := projectsAPI(t) srv, _ := newProjectsServer(t, api, config.ProjectTypeOutpost) - assert.Equal(t, "outpost_projects", srv.ProjectsToolName()) - assert.Equal(t, "outpost_login", srv.LoginToolName()) + 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, "outpost_projects", def.Tool.Name) + assert.Equal(t, "hookdeck_projects", def.Tool.Name) assert.Equal(t, "desc", def.Tool.Description) } diff --git a/pkg/outpost/mcp/projects_test.go b/pkg/outpost/mcp/projects_test.go index c7243419..6c54f310 100644 --- a/pkg/outpost/mcp/projects_test.go +++ b/pkg/outpost/mcp/projects_test.go @@ -52,7 +52,7 @@ func TestProjectsTool_UsesTheAccountAPIAndSwitchesTheOutpostClient(t *testing.T) session := connect(t, ServerOptions{Client: outpostClient, AccountClient: accountClient}) t.Run("list returns only Outpost projects", func(t *testing.T) { - result := callTool(t, session, "outpost_projects", map[string]any{"action": "list"}) + 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") @@ -61,7 +61,7 @@ func TestProjectsTool_UsesTheAccountAPIAndSwitchesTheOutpostClient(t *testing.T) }) t.Run("use switches the Outpost client, not just the account one", func(t *testing.T) { - result := callTool(t, session, "outpost_projects", map[string]any{ + result := callTool(t, session, "hookdeck_projects", map[string]any{ "action": "use", "project_id": "proj_other", }) @@ -73,7 +73,7 @@ func TestProjectsTool_UsesTheAccountAPIAndSwitchesTheOutpostClient(t *testing.T) }) t.Run("use refuses a Gateway project", func(t *testing.T) { - result := callTool(t, session, "outpost_projects", map[string]any{ + result := callTool(t, session, "hookdeck_projects", map[string]any{ "action": "use", "project_id": "proj_gateway", }) diff --git a/pkg/outpost/mcp/tool_help.go b/pkg/outpost/mcp/tool_help.go index 458bcfea..75f2329a 100644 --- a/pkg/outpost/mcp/tool_help.go +++ b/pkg/outpost/mcp/tool_help.go @@ -40,7 +40,7 @@ Successful tool calls that return JSON share one envelope. Parse the tool result unresolved. "active_project_org" (string) is included when known; omitted when empty. If no project id is set, "meta" is {}. -Plain text (not this shape): outpost_help text, outpost_login prompts, and error messages. +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 @@ -107,7 +107,7 @@ Current project: %s %s -All tools operate on the active project, which must be an Outpost project. Call outpost_projects +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 @@ -171,7 +171,7 @@ func toolSummaryLines(srv *mcpcore.Server, opts ServerOptions) []string { // never documents an action this session cannot perform. func toolHelp(srv *mcpcore.Server) map[string]string { topics := map[string]string{ - srv.ProjectsToolName(): `outpost_projects — List or switch the active project + 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 @@ -185,7 +185,7 @@ Parameters: action (string, required) — "list" or "use" project_id (string) — Required for "use"`, - srv.LoginToolName(): `outpost_login — Browser sign-in for the Hookdeck CLI inside MCP + 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. @@ -194,7 +194,7 @@ Note: signing in here does not supply a Project API key, which outpost_publish n Parameters: reauth (boolean) — If true, clears stored credentials and starts a new browser login. Use when - outpost_projects list fails and the key may be a single-project or dashboard + 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 diff --git a/pkg/outpost/mcp/tools.go b/pkg/outpost/mcp/tools.go index 6e48fc5d..f617417e 100644 --- a/pkg/outpost/mcp/tools.go +++ b/pkg/outpost/mcp/tools.go @@ -18,8 +18,8 @@ const ( 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 outpost_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 outpost_login with reauth: true. JSON successes use a standard data/meta envelope; see outpost_help." + 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. diff --git a/pkg/outpost/mcp/tools_test.go b/pkg/outpost/mcp/tools_test.go index 65484f4e..caaff4e4 100644 --- a/pkg/outpost/mcp/tools_test.go +++ b/pkg/outpost/mcp/tools_test.go @@ -131,7 +131,7 @@ func TestListTools_ReadOnlyMode(t *testing.T) { t.Run("registers every read tool", func(t *testing.T) { for _, name := range []string{ - "outpost_projects", "outpost_login", "outpost_help", + "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", @@ -291,19 +291,33 @@ func TestUnknownAction(t *testing.T) { // Authentication // --------------------------------------------------------------------------- -func TestUnauthenticated_PointsAtTheOutpostLoginTool(t *testing.T) { +// 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}) - for _, name := range []string{"outpost_tenants", "outpost_events", "outpost_status", "outpost_projects"} { + 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, "outpost_login") - assert.NotContains(t, text, "hookdeck_login", "the gateway tool does not exist in this session") + 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"]) }) } } @@ -677,8 +691,8 @@ func TestServerIdentity(t *testing.T) { // The Outpost server must only ever serve Outpost projects. assert.Equal(t, config.ProjectTypeOutpost, srv.ProjectFilter()) - assert.Equal(t, "outpost_projects", srv.ProjectsToolName()) - assert.Equal(t, "outpost_login", srv.LoginToolName()) + assert.Equal(t, "hookdeck_projects", srv.ProjectsToolName()) + assert.Equal(t, "hookdeck_login", srv.LoginToolName()) var _ *mcpcore.Server = srv } From e7cf41a1667bc869b2f62f653f9228379785ba26 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 20:23:17 +0100 Subject: [PATCH 14/49] fix(mcp): resolve project names, and scope the publish credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes from driving the Outpost MCP for real. **Project name and org were always empty.** Every MCP response carries active_project_name and active_project_org, but resolution went through ListProjects, which a project-scoped key from `hookdeck ci` cannot call. It failed, returned early, and left callers with a bare project id to show. Now it validates the key first, which works for any credential and returns the name of the key's own project, and only lists projects when the active one differs. `hookdeck whoami` has always done it this way. Fixes the Gateway server too, which had the identical hole. **The publish credential is now publish-specific**: --publish-api-key and HOOKDECK_OUTPOST_PUBLISH_API_KEY, and the MCP server no longer reads HOOKDECK_API_KEY. That variable means "exchange this for CLI credentials" for `hookdeck ci` and `listen`, and the CLI encourages exporting it for CI. Reading it here gave one name two meanings, and worse, let an ambient variable exported for something else silently register the one tool whose effects cannot be undone: publishing sends real events to real customer destinations. Enabling that should be something you typed. The `outpost publish` CLI command is unchanged and still accepts --api-key / HOOKDECK_API_KEY, because that is an explicit one-shot action rather than an unattended server. **Help text** now says switching project affects the session only, unlike `hookdeck project use`, so an agent can answer honestly when asked whether the user's CLI was repointed. Signing in does persist, because the user asked for it. Tool descriptions also tell the model to identify destinations by type and target rather than by id — Outpost destinations have no name field, so an id is all a model has unless told otherwise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost_mcp.go | 25 +++++++++++-- pkg/cmd/outpost_mcp_test.go | 7 +++- pkg/mcpcore/project_display.go | 30 ++++++++++++--- pkg/mcpcore/project_display_test.go | 56 ++++++++++++++++++++++++++++ pkg/outpost/mcp/tool_destinations.go | 2 +- pkg/outpost/mcp/tool_help.go | 13 +++++-- pkg/outpost/mcp/tool_tenants.go | 2 +- pkg/outpost/mcp/tools_test.go | 6 ++- 8 files changed, 124 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/outpost_mcp.go b/pkg/cmd/outpost_mcp.go index 4596c018..8a286d87 100644 --- a/pkg/cmd/outpost_mcp.go +++ b/pkg/cmd/outpost_mcp.go @@ -15,6 +15,14 @@ import ( // 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 @@ -45,7 +53,12 @@ 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 --api-key or set HOOKDECK_API_KEY to enable it. +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. @@ -58,7 +71,7 @@ before the server runs go to stderr.`), hookdeck outpost mcp --allow-write # Allow writes, including publishing events - hookdeck outpost mcp --allow-write --api-key $HOOKDECK_API_KEY + 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`, @@ -71,7 +84,7 @@ before the server runs go to stderr.`), 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, "api-key", "", "Hookdeck Project API key, required by the publish tool. Read from HOOKDECK_API_KEY when not provided.") + 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 } @@ -109,9 +122,13 @@ func (mc *outpostMCPCmd) runOutpostMCPCmd(cmd *cobra.Command, args []string) err // 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("HOOKDECK_API_KEY") + publishAPIKey = os.Getenv(publishAPIKeyEnvVar) } writeEnabled := resolveAllowWrite( diff --git a/pkg/cmd/outpost_mcp_test.go b/pkg/cmd/outpost_mcp_test.go index 13636ed2..e64997ff 100644 --- a/pkg/cmd/outpost_mcp_test.go +++ b/pkg/cmd/outpost_mcp_test.go @@ -58,10 +58,15 @@ func TestOutpostMCPCommandIsRegistered(t *testing.T) { 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", "api-key"} { + 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/mcpcore/project_display.go b/pkg/mcpcore/project_display.go index 33baa7f4..6de8767a 100644 --- a/pkg/mcpcore/project_display.go +++ b/pkg/mcpcore/project_display.go @@ -6,13 +6,19 @@ import ( ) // FillProjectDisplayNameIfNeeded sets target.ProjectOrg and target.ProjectName -// from the project list 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. +// 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 the project list is fetched from, which is not always -// target: a product API served from its own host does not answer account-level -// requests, so the lookup has to go to the account API. +// 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 @@ -20,6 +26,18 @@ func FillProjectDisplayNameIfNeeded(lookup, target *hookdeck.Client) { 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 diff --git a/pkg/mcpcore/project_display_test.go b/pkg/mcpcore/project_display_test.go index 3c94c794..66fa8848 100644 --- a/pkg/mcpcore/project_display_test.go +++ b/pkg/mcpcore/project_display_test.go @@ -5,9 +5,11 @@ import ( "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" ) @@ -74,3 +76,57 @@ func TestFillProjectDisplayNameIfNeeded_LooksUpThroughTheAccountClient(t *testin 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/outpost/mcp/tool_destinations.go b/pkg/outpost/mcp/tool_destinations.go index 69b5e83b..8e39332d 100644 --- a/pkg/outpost/mcp/tool_destinations.go +++ b/pkg/outpost/mcp/tool_destinations.go @@ -21,7 +21,7 @@ var destinationsActions = actionSet{ 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.", + 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{ diff --git a/pkg/outpost/mcp/tool_help.go b/pkg/outpost/mcp/tool_help.go index 75f2329a..b4b14e1c 100644 --- a/pkg/outpost/mcp/tool_help.go +++ b/pkg/outpost/mcp/tool_help.go @@ -39,6 +39,8 @@ Successful tool calls that return JSON share one envelope. Parse the tool result "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.` @@ -76,7 +78,7 @@ change or delete data. Destructive actions (delete, config set, publish) are rea 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" + - "--api-key , or set HOOKDECK_API_KEY, to publish." + "--publish-api-key , or set HOOKDECK_OUTPOST_PUBLISH_API_KEY, to publish." } return text } @@ -89,7 +91,7 @@ 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_API_KEY.` +HOOKDECK_OUTPOST_PUBLISH_API_KEY.` } func helpOverview(srv *mcpcore.Server, opts ServerOptions, client *hookdeck.Client) *mcpsdk.CallToolResult { @@ -179,7 +181,12 @@ switched to: this server talks to the Outpost API and has no access to Event Gat Actions: list — List the Outpost projects available to your credentials - use — Switch the active project for this session (in-memory only) + 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" diff --git a/pkg/outpost/mcp/tool_tenants.go b/pkg/outpost/mcp/tool_tenants.go index ff7e60ae..4f96eeb2 100644 --- a/pkg/outpost/mcp/tool_tenants.go +++ b/pkg/outpost/mcp/tool_tenants.go @@ -20,7 +20,7 @@ var tenantsActions = actionSet{ 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.", + 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}, diff --git a/pkg/outpost/mcp/tools_test.go b/pkg/outpost/mcp/tools_test.go index caaff4e4..2b647bc3 100644 --- a/pkg/outpost/mcp/tools_test.go +++ b/pkg/outpost/mcp/tools_test.go @@ -634,7 +634,11 @@ func TestHelpOverview_WriteMode(t *testing.T) { 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_API_KEY") + 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") }) } From eb6ca4294f75e3549d2f6c39f2b0909af1289479 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 20:23:40 +0100 Subject: [PATCH 15/49] docs: regenerate REFERENCE.md for the publish-api-key rename Missed in the previous commit. Caught by generate-reference --check, which is the point of the check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- REFERENCE.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 64212386..36d1a7be 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -3054,7 +3054,12 @@ 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 `--api-key` or set HOOKDECK_API_KEY to enable it. +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. @@ -3075,7 +3080,7 @@ hookdeck outpost mcp [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. | -| `--api-key` | `string` | Hookdeck Project API key, required by the publish tool. Read from HOOKDECK_API_KEY when not provided. | +| `--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:** @@ -3088,7 +3093,7 @@ hookdeck outpost mcp hookdeck outpost mcp --allow-write # Allow writes, including publishing events -hookdeck outpost mcp --allow-write --api-key $HOOKDECK_API_KEY +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 From 1868d69f6e11f1934009e7c335a3b89c113030d4 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 20:40:20 +0100 Subject: [PATCH 16/49] fix(outpost): stop publish silently going to the wrong project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by driving the MCP server against real projects. **Publish followed the credential, not the active project, and said nothing.** The publish credential is fixed when the server starts; the active project moves with hookdeck_projects use. When they disagreed, publishing for a tenant that existed in the active project was accepted with a 202 and an event id, matched nothing, was never delivered, and did not appear in any event list. The response looked like a success and reported the active project in its meta, which read as confirmation the event landed where the caller was looking. It had not. Publishing now checks the tenant first, using the publish credential, so the lookup resolves to the same project the event would go to. That also catches a mistyped or unprovisioned tenant, which the API otherwise accepts rather than rejects. One subtlety worth recording: the check must not send the project header. Publishing resolves the project from the credential alone, but resource reads also honour the header — so leaving it set checks a different project from the one being published to, and returns a 401 that hides the answer entirely. **Validation errors carried no detail.** The API returns {"message":"validation error","data":["topic is invalid"]}, but ErrorResponse parsed only the message, so every 422 surfaced as a bare "validation error" with nothing to act on. The data array is now appended, which improves every command, not just publish. **A publish that matches nothing now says so.** Zero matched destinations means the event is not delivered and never appears in the events list, so there is no artifact to inspect afterwards. The result now carries a warning rather than looking like an ordinary success. Not addressed here, both API-side rather than CLI: publishing for a non-existent tenant returns 202 rather than an error, and an event matching no destinations is not persisted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/hookdeck/client.go | 21 +++++++++-- pkg/hookdeck/outpost_publish.go | 41 +++++++++++++++++++++ pkg/outpost/mcp/tool_publish.go | 35 +++++++++++++++++- pkg/outpost/mcp/tools_test.go | 64 +++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) 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/outpost/mcp/tool_publish.go b/pkg/outpost/mcp/tool_publish.go index 864b73ec..b04e6e6c 100644 --- a/pkg/outpost/mcp/tool_publish.go +++ b/pkg/outpost/mcp/tool_publish.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -72,6 +73,26 @@ func handlePublish(srv *mcpcore.Server, apiKey string) mcpsdk.ToolHandler { 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, @@ -84,6 +105,18 @@ func handlePublish(srv *mcpcore.Server, apiKey string) mcpsdk.ToolHandler { if err != nil { return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } - return mcpcore.JSONResultEnvelopeForClient(result, client) + + // 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/tools_test.go b/pkg/outpost/mcp/tools_test.go index 2b647bc3..c1a8917c 100644 --- a/pkg/outpost/mcp/tools_test.go +++ b/pkg/outpost/mcp/tools_test.go @@ -554,6 +554,11 @@ func TestConfigSetSendsValuesAndUnsets(t *testing.T) { 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) @@ -700,3 +705,62 @@ func TestServerIdentity(t *testing.T) { 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") +} From 3b48ac63ea6f827f26c65905feae3be8c56abed0 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 17 Aug 2026 12:48:26 +0100 Subject: [PATCH 17/49] test(outpost): update MCP acceptance test for the platform tool prefix The unit tests were updated when login and projects moved to the hookdeck_ prefix; this acceptance test was missed and still asserted outpost_login. It now also asserts the product-prefixed names are absent, so the rule is pinned from both directions rather than only one. Caught by running the tagged suite locally, which is the point of doing so before pushing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- test/acceptance/outpost_mcp_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/acceptance/outpost_mcp_test.go b/test/acceptance/outpost_mcp_test.go index 11f105b0..3df126d3 100644 --- a/test/acceptance/outpost_mcp_test.go +++ b/test/acceptance/outpost_mcp_test.go @@ -82,14 +82,20 @@ func TestOutpostMCPStdio_ReadOnlyByDefault(t *testing.T) { tools, stdout, _ := ListMCPTools(t, cli.projectRoot, cli.configPath, outpostMCPCommand, 10*time.Second) assertMCPStdoutIsJSONRPCOnly(t, stdout) + // Platform tools keep the hookdeck_ prefix in every server: you log in to + // Hookdeck and switch a Hookdeck project, whichever product you are using. for _, name := range []string{ - "outpost_projects", "outpost_login", "outpost_help", "outpost_tenants", + "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) } + for _, name := range []string{"outpost_login", "outpost_projects"} { + assert.NotContains(t, tools, name, "platform tools must not carry the product prefix") + } // Nothing that changes data, and nothing that hands back a credential. assert.NotContains(t, tools, "outpost_publish") From da683e3cc4f629fc536fc09d5847e4b5b851b435 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:19:09 +0000 Subject: [PATCH 18/49] Update package.json version to 2.6.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6588dba4..d7525984 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "2.6.0-beta.1", + "version": "2.6.0-beta.2", "description": "Hookdeck CLI", "repository": { "type": "git", From f16f1a059b27e76b2944586bf734235b860d52b8 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:07:01 +0100 Subject: [PATCH 19/49] refactor(mcp): lift the write-gating machinery into mcpcore The action / actionSet / toolSpec / dispatch pattern was package-private in pkg/outpost/mcp, so a second server could not reuse it. Move it to pkg/mcpcore/toolspec.go as exported Action, ActionSet, ToolSpec and Dispatch, and port the Outpost server onto the exported versions. The read-only description suffix hardcoded a reference to outpost_help. It now comes from Server.HelpToolName(), so each product points at its own help tool. Outpost behaviour is unchanged: pkg/outpost/mcp/tools_test.go passes with only identifier renames. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/mcpcore/server.go | 4 + pkg/mcpcore/toolspec.go | 185 +++++++++++++++++++++++++++ pkg/outpost/mcp/tool_attempts.go | 20 +-- pkg/outpost/mcp/tool_catalog.go | 52 ++++---- pkg/outpost/mcp/tool_config.go | 26 ++-- pkg/outpost/mcp/tool_destinations.go | 32 ++--- pkg/outpost/mcp/tool_events.go | 22 ++-- pkg/outpost/mcp/tool_help.go | 36 +++--- pkg/outpost/mcp/tool_metrics.go | 22 ++-- pkg/outpost/mcp/tool_publish.go | 22 ++-- pkg/outpost/mcp/tool_tenants.go | 28 ++-- pkg/outpost/mcp/tools.go | 166 +----------------------- pkg/outpost/mcp/tools_test.go | 18 +-- 13 files changed, 331 insertions(+), 302 deletions(-) create mode 100644 pkg/mcpcore/toolspec.go diff --git a/pkg/mcpcore/server.go b/pkg/mcpcore/server.go index 4dbf7d42..1339370e 100644 --- a/pkg/mcpcore/server.go +++ b/pkg/mcpcore/server.go @@ -153,6 +153,10 @@ func (s *Server) ToolPrefix() string { // LoginToolName returns the name of this server's login tool. func (s *Server) LoginToolName() string { return s.platformToolName("login") } +// HelpToolName returns the name of this server's help tool. Help is a product +// tool — it documents that product's tools — so it takes the product prefix. +func (s *Server) HelpToolName() string { return s.ToolName("help") } + // 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" diff --git a/pkg/mcpcore/toolspec.go b/pkg/mcpcore/toolspec.go new file mode 100644 index 00000000..ee218707 --- /dev/null +++ b/pkg/mcpcore/toolspec.go @@ -0,0 +1,185 @@ +package mcpcore + +import ( + "fmt" + "strings" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// 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 product tool before write mode is applied. +type ToolSpec struct { + Resource string // e.g. "tenants" — the tool is named "_" + Summary string // what the tool is for, without listing actions + Actions ActionSet // every action, including the write-only ones + Props map[string]Prop + Required []string + Handler func(*Server) mcpsdk.ToolHandler + + // DefaultAction, when set, is the action used if the caller omits one. It + // exists for tools that shipped before "action" was mandatory in practice + // and whose callers still send bare list requests. + DefaultAction string +} + +// Define builds the tool definition for the current write mode. The bool is +// false when the tool should not be registered at all. +// +// 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 *Server) (ToolDef, bool) { + available := spec.Actions.Available(srv.WriteEnabled()) + if len(available) == 0 { + return ToolDef{}, false + } + + props := make(map[string]Prop, len(spec.Props)+1) + for k, v := range spec.Props { + props[k] = v + } + props["action"] = Prop{ + Type: "string", + Desc: "Action: " + available.Summary(), + Enum: available.Names(), + } + + description := spec.Summary + " Actions: " + available.Summary() + "." + if spec.Actions.HasWrite() && !srv.WriteEnabled() { + // The help tool name is sourced from the server rather than hardcoded, + // so each product points at its own help tool. + description += fmt.Sprintf( + " This server is running in read-only mode, so only the actions listed above are available; see %s for how to enable the rest.", + srv.HelpToolName(), + ) + } + + destructive := available.HasDestructive() + return ToolDef{ + Tool: &mcpsdk.Tool{ + Name: srv.ToolName(spec.Resource), + Description: description, + InputSchema: 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 *Server, actions ActionSet, name string) (string, *mcpsdk.CallToolResult) { + a, ok := actions.Find(name) + if !ok { + available := actions.Available(srv.WriteEnabled()) + return "", ErrorResult(fmt.Sprintf( + "unknown action %q; expected one of: %s", + name, strings.Join(available.Names(), ", "), + )) + } + if a.Write { + if r := RequireWrite(srv.WriteEnabled(), name); r != nil { + return "", r + } + } + return a.Name, nil +} + +// DispatchWithDefault is Dispatch, with an empty action name resolving to +// fallback instead of erroring. +func DispatchWithDefault(srv *Server, actions ActionSet, name, fallback string) (string, *mcpsdk.CallToolResult) { + if name == "" { + name = fallback + } + return Dispatch(srv, actions, name) +} diff --git a/pkg/outpost/mcp/tool_attempts.go b/pkg/outpost/mcp/tool_attempts.go index d68dd2ea..bc4559f1 100644 --- a/pkg/outpost/mcp/tool_attempts.go +++ b/pkg/outpost/mcp/tool_attempts.go @@ -9,16 +9,16 @@ import ( "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 attemptsActions = mcpcore.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{ +var attemptsSpec = mcpcore.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}, @@ -35,7 +35,7 @@ var attemptsSpec = toolSpec{ "next": {Type: "string", Desc: "Next page cursor (list)"}, "prev": {Type: "string", Desc: "Previous page cursor (list)"}, }, - handler: handleAttempts, + Handler: handleAttempts, } func handleAttempts(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -49,7 +49,7 @@ func handleAttempts(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, attemptsActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, attemptsActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_catalog.go b/pkg/outpost/mcp/tool_catalog.go index d344580c..abfb944f 100644 --- a/pkg/outpost/mcp/tool_catalog.go +++ b/pkg/outpost/mcp/tool_catalog.go @@ -12,15 +12,15 @@ import ( // 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 topicsActions = mcpcore.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, +var topicsSpec = mcpcore.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 { @@ -33,7 +33,7 @@ func handleTopics(srv *mcpcore.Server) mcpsdk.ToolHandler { if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - if _, blocked := dispatch(srv, topicsActions, in.String("action")); blocked != nil { + if _, blocked := mcpcore.Dispatch(srv, topicsActions, in.String("action")); blocked != nil { return blocked, nil } @@ -45,20 +45,20 @@ func handleTopics(srv *mcpcore.Server) mcpsdk.ToolHandler { } } -var destinationTypesActions = actionSet{ - {name: "list", desc: "list the available destination types"}, - {name: "get", desc: "get one type's full field schema"}, +var destinationTypesActions = mcpcore.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{ +var destinationTypesSpec = mcpcore.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, + Handler: handleDestinationTypes, } func handleDestinationTypes(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -72,7 +72,7 @@ func handleDestinationTypes(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, destinationTypesActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, destinationTypesActions, in.String("action")) if blocked != nil { return blocked, nil } @@ -113,15 +113,15 @@ func trimSetupDocs(schema hookdeck.OutpostDestinationTypeSchema, verbose bool) h return schema } -var statusActions = actionSet{ - {name: "get", desc: "report the deployment status for this project"}, +var statusActions = mcpcore.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, +var statusSpec = mcpcore.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 { @@ -134,7 +134,7 @@ func handleStatus(srv *mcpcore.Server) mcpsdk.ToolHandler { if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - if _, blocked := dispatch(srv, statusActions, in.String("action")); blocked != nil { + if _, blocked := mcpcore.Dispatch(srv, statusActions, in.String("action")); blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_config.go b/pkg/outpost/mcp/tool_config.go index 205c5d9b..741f890e 100644 --- a/pkg/outpost/mcp/tool_config.go +++ b/pkg/outpost/mcp/tool_config.go @@ -10,25 +10,25 @@ import ( "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 configActions = mcpcore.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{ +var configSpec = mcpcore.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, + Handler: handleConfig, } func handleConfig(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -42,7 +42,7 @@ func handleConfig(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, configActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, configActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_destinations.go b/pkg/outpost/mcp/tool_destinations.go index 8e39332d..38d98416 100644 --- a/pkg/outpost/mcp/tool_destinations.go +++ b/pkg/outpost/mcp/tool_destinations.go @@ -9,22 +9,22 @@ import ( "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 destinationsActions = mcpcore.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{ +var destinationsSpec = mcpcore.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}, @@ -34,7 +34,7 @@ var destinationsSpec = toolSpec{ "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, + Handler: handleDestinations, } func handleDestinations(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -48,7 +48,7 @@ func handleDestinations(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, destinationsActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, destinationsActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_events.go b/pkg/outpost/mcp/tool_events.go index a51d7da1..4e7a4018 100644 --- a/pkg/outpost/mcp/tool_events.go +++ b/pkg/outpost/mcp/tool_events.go @@ -9,17 +9,17 @@ import ( "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 eventsActions = mcpcore.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{ +var eventsSpec = mcpcore.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}, @@ -32,7 +32,7 @@ var eventsSpec = toolSpec{ "next": {Type: "string", Desc: "Next page cursor (list)"}, "prev": {Type: "string", Desc: "Previous page cursor (list)"}, }, - handler: handleEvents, + Handler: handleEvents, } func handleEvents(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -46,7 +46,7 @@ func handleEvents(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, eventsActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, eventsActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_help.go b/pkg/outpost/mcp/tool_help.go index b4b14e1c..9b2db99a 100644 --- a/pkg/outpost/mcp/tool_help.go +++ b/pkg/outpost/mcp/tool_help.go @@ -136,18 +136,18 @@ func toolSummaryLines(srv *mcpcore.Server, opts ServerOptions) []string { {srv.LoginToolName(), "Sign in, or reauth: true for a fresh browser session when listing projects fails"}, } - specs := []toolSpec{ + specs := []mcpcore.ToolSpec{ tenantsSpec, destinationsSpec, eventsSpec, attemptsSpec, topicsSpec, destinationTypesSpec, metricsSpec, configSpec, statusSpec, } for _, spec := range specs { - available := spec.actions.available(srv.WriteEnabled()) + 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(), ", "), + name: srv.ToolName(spec.Resource), + summary: "Actions: " + strings.Join(available.Names(), ", "), }) } if srv.WriteEnabled() && opts.PublishAPIKey != "" { @@ -212,17 +212,17 @@ Parameters: topic (string) — Tool name for detailed help (e.g. "outpost_events"). Omit for the overview.`, } - specs := []toolSpec{ + specs := []mcpcore.ToolSpec{ tenantsSpec, destinationsSpec, eventsSpec, attemptsSpec, topicsSpec, destinationTypesSpec, metricsSpec, configSpec, statusSpec, publishSpec(""), } for _, spec := range specs { - available := spec.actions.available(srv.WriteEnabled()) + available := spec.Actions.Available(srv.WriteEnabled()) if len(available) == 0 { continue } - topics[srv.ToolName(spec.resource)] = specHelp(srv, spec, available) + topics[srv.ToolName(spec.Resource)] = specHelp(srv, spec, available) } return topics @@ -230,28 +230,28 @@ Parameters: // 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 { +func specHelp(srv *mcpcore.Server, spec mcpcore.ToolSpec, available mcpcore.ActionSet) string { var b strings.Builder - fmt.Fprintf(&b, "%s\n\n%s\n\nActions:\n", srv.ToolName(spec.resource), spec.summary) + 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) + if len(a.Name) > width { + width = len(a.Name) } } for _, a := range available { - fmt.Fprintf(&b, " %-*s — %s\n", width, a.name, a.desc) + fmt.Fprintf(&b, " %-*s — %s\n", width, a.Name, a.Desc) } - if hidden := spec.actions.hasWrite() && !srv.WriteEnabled(); hidden { + 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 { + if len(spec.Props) > 0 { b.WriteString("\nParameters:\n") - names := make([]string, 0, len(spec.props)) - for name := range spec.props { + names := make([]string, 0, len(spec.Props)) + for name := range spec.Props { names = append(names, name) } sort.Strings(names) @@ -263,9 +263,9 @@ func specHelp(srv *mcpcore.Server, spec toolSpec, available actionSet) string { } } for _, name := range names { - prop := spec.props[name] + prop := spec.Props[name] required := "" - for _, r := range spec.required { + for _, r := range spec.Required { if r == name { required = ", required" break diff --git a/pkg/outpost/mcp/tool_metrics.go b/pkg/outpost/mcp/tool_metrics.go index 8ed28b8e..7f9adbdc 100644 --- a/pkg/outpost/mcp/tool_metrics.go +++ b/pkg/outpost/mcp/tool_metrics.go @@ -10,20 +10,20 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -var metricsActions = actionSet{ - {name: "events", desc: "aggregated publish metrics"}, - {name: "attempts", desc: "aggregated delivery metrics"}, +var metricsActions = mcpcore.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. " + +var metricsSpec = mcpcore.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{ + 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."}, @@ -31,7 +31,7 @@ var metricsSpec = toolSpec{ "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, + Handler: handleMetrics, } func handleMetrics(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -45,7 +45,7 @@ func handleMetrics(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, metricsActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, metricsActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_publish.go b/pkg/outpost/mcp/tool_publish.go index b04e6e6c..63b7eb88 100644 --- a/pkg/outpost/mcp/tool_publish.go +++ b/pkg/outpost/mcp/tool_publish.go @@ -10,8 +10,8 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -var publishActions = actionSet{ - {name: "publish", desc: "publish an event to a topic", write: true, destructive: true}, +var publishActions = mcpcore.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. @@ -20,13 +20,13 @@ var publishActions = actionSet{ // 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{ +func publishSpec(apiKey string) mcpcore.ToolSpec { + return mcpcore.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."}, @@ -35,7 +35,7 @@ func publishSpec(apiKey string) toolSpec { "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 { + Handler: func(srv *mcpcore.Server) mcpsdk.ToolHandler { return handlePublish(srv, apiKey) }, } @@ -52,7 +52,7 @@ func handlePublish(srv *mcpcore.Server, apiKey string) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - if _, blocked := dispatch(srv, publishActions, in.String("action")); blocked != nil { + if _, blocked := mcpcore.Dispatch(srv, publishActions, in.String("action")); blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tool_tenants.go b/pkg/outpost/mcp/tool_tenants.go index 4f96eeb2..db484863 100644 --- a/pkg/outpost/mcp/tool_tenants.go +++ b/pkg/outpost/mcp/tool_tenants.go @@ -9,20 +9,20 @@ import ( "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 tenantsActions = mcpcore.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{ +var tenantsSpec = mcpcore.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)."}, @@ -31,7 +31,7 @@ var tenantsSpec = toolSpec{ "next": {Type: "string", Desc: "Next page cursor (list)"}, "prev": {Type: "string", Desc: "Previous page cursor (list)"}, }, - handler: handleTenants, + Handler: handleTenants, } func handleTenants(srv *mcpcore.Server) mcpsdk.ToolHandler { @@ -45,7 +45,7 @@ func handleTenants(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := dispatch(srv, tenantsActions, in.String("action")) + action, blocked := mcpcore.Dispatch(srv, tenantsActions, in.String("action")) if blocked != nil { return blocked, nil } diff --git a/pkg/outpost/mcp/tools.go b/pkg/outpost/mcp/tools.go index f617417e..e93fcd26 100644 --- a/pkg/outpost/mcp/tools.go +++ b/pkg/outpost/mcp/tools.go @@ -1,9 +1,6 @@ package mcp import ( - "fmt" - "strings" - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/hookdeck/hookdeck-cli/pkg/config" @@ -62,166 +59,9 @@ func NewServer(opts ServerOptions) *mcpcore.Server { }) } -// 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{ + specs := []mcpcore.ToolSpec{ tenantsSpec, destinationsSpec, eventsSpec, @@ -235,7 +75,7 @@ func toolDefs(srv *mcpcore.Server, opts ServerOptions) []mcpcore.ToolDef { defs := []mcpcore.ToolDef{srv.ProjectsToolDef(projectsToolDesc)} for _, spec := range specs { - if def, ok := spec.define(srv); ok { + if def, ok := spec.Define(srv); ok { defs = append(defs, def) } } @@ -243,7 +83,7 @@ func toolDefs(srv *mcpcore.Server, opts ServerOptions) []mcpcore.ToolDef { // 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 { + if def, ok := publishSpec(opts.PublishAPIKey).Define(srv); ok { defs = append(defs, def) } } diff --git a/pkg/outpost/mcp/tools_test.go b/pkg/outpost/mcp/tools_test.go index c1a8917c..921aa94d 100644 --- a/pkg/outpost/mcp/tools_test.go +++ b/pkg/outpost/mcp/tools_test.go @@ -327,21 +327,21 @@ func TestUnauthenticated_PointsAtALoginToolThatExists(t *testing.T) { // --------------------------------------------------------------------------- func TestActionSet(t *testing.T) { - actions := actionSet{ - {name: "list"}, - {name: "delete", write: true, destructive: true}, + actions := mcpcore.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()) + 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()) + assert.Equal(t, []string{"list", "delete"}, actions.Available(true).Names()) + assert.True(t, actions.Available(true).HasWrite()) + assert.True(t, actions.Available(true).HasDestructive()) }) } From 29ee04dbd58d8fb45d435f30477c1d93fa6a1d44 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:18:15 +0100 Subject: [PATCH 20/49] feat(gateway mcp): add write mode behind --allow-write, rename tools to gateway_ Port the Event Gateway MCP tools onto mcpcore.ToolSpec, so their schemas are built from an action set rather than hand-written, and add the write actions every one of them was missing. The API client already had every method; this is tool-layer work only. Write mode is off by default. In read-only mode the write actions are absent from the action enum and from the tool description, so an agent is never offered something it cannot do; mcpcore.RequireWrite sits behind that as defence in depth. Enable with --allow-write or HOOKDECK_MCP_ALLOW_WRITE=true; --read-only is accepted and wins if both are passed. resolveAllowWrite is now shared with the Outpost server rather than duplicated. Actions added: connections create, upsert, update, delete, enable, disable sources create, upsert, update, delete, enable, disable destinations create, upsert, update, delete, enable, disable transformations create, upsert, update, delete, run events retry, cancel, mute requests retry issues update, dismiss pause and unpause deliberately stay read-mode actions. Read-only is the mode people investigate incidents in, and stopping a misbehaving connection is the natural end of an investigation; both are reversible and drop nothing. The rationale is recorded at the action definition. transformations run is gated as a write even though it stores nothing: it executes caller-supplied code, and a read-only session should not be able to cause that. BREAKING CHANGE: the nine product tools and the help tool are renamed from hookdeck_* to gateway_*. Per-tool permission grants and allowedTools config do not survive a rename, so every user must re-grant them. hookdeck_connections -> gateway_connections hookdeck_sources -> gateway_sources hookdeck_destinations -> gateway_destinations hookdeck_transformations -> gateway_transformations hookdeck_requests -> gateway_requests hookdeck_events -> gateway_events hookdeck_attempts -> gateway_attempts hookdeck_issues -> gateway_issues hookdeck_metrics -> gateway_metrics hookdeck_help -> gateway_help hookdeck_login and hookdeck_projects are unchanged: signing in and switching project are Hookdeck operations whichever product's server you are in. gateway_help is now generated from the tool specs, so it reports the current mode and can no longer advertise an action the session cannot perform. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- README.md | 42 +-- pkg/cmd/mcp.go | 40 ++- pkg/cmd/mcp_write_mode.go | 41 +++ pkg/cmd/outpost_mcp.go | 30 +- pkg/gateway/mcp/server_test.go | 272 +++++++++--------- pkg/gateway/mcp/telemetry_test.go | 18 +- pkg/gateway/mcp/tool_attempts.go | 43 ++- pkg/gateway/mcp/tool_connections.go | 193 ++++++++++++- pkg/gateway/mcp/tool_destinations.go | 132 ++++++++- pkg/gateway/mcp/tool_events.go | 120 +++++++- pkg/gateway/mcp/tool_help.go | 350 ++++++++---------------- pkg/gateway/mcp/tool_issues.go | 74 ++++- pkg/gateway/mcp/tool_metrics.go | 44 ++- pkg/gateway/mcp/tool_requests.go | 90 +++++- pkg/gateway/mcp/tool_sources.go | 132 ++++++++- pkg/gateway/mcp/tool_transformations.go | 178 +++++++++++- pkg/gateway/mcp/tools.go | 268 +++++------------- pkg/hookdeck/client_telemetry_test.go | 6 +- pkg/hookdeck/telemetry_test.go | 4 +- pkg/mcpcore/help.go | 20 +- pkg/mcpcore/input.go | 80 ++++++ pkg/mcpcore/server_test.go | 14 +- pkg/mcpcore/toolspec.go | 63 +++++ pkg/outpost/mcp/input.go | 72 ----- pkg/outpost/mcp/tool_attempts.go | 20 +- pkg/outpost/mcp/tool_catalog.go | 2 +- pkg/outpost/mcp/tool_config.go | 6 +- pkg/outpost/mcp/tool_destinations.go | 20 +- pkg/outpost/mcp/tool_events.go | 14 +- pkg/outpost/mcp/tool_help.go | 53 +--- pkg/outpost/mcp/tool_metrics.go | 6 +- pkg/outpost/mcp/tool_publish.go | 8 +- pkg/outpost/mcp/tool_tenants.go | 14 +- test/acceptance/mcp_test.go | 4 +- 34 files changed, 1603 insertions(+), 870 deletions(-) create mode 100644 pkg/cmd/mcp_write_mode.go delete mode 100644 pkg/outpost/mcp/input.go diff --git a/README.md b/README.md index e0de3e9e..ea975826 100644 --- a/README.md +++ b/README.md @@ -619,18 +619,18 @@ The client starts `hookdeck gateway mcp` as a stdio subprocess. If you haven't a | Tool | Description | |------|-------------| | `hookdeck_projects` | List projects or switch the active project for this session | -| `hookdeck_connections` | Inspect connections and control delivery flow (list, get, pause, unpause) | -| `hookdeck_sources` | Inspect inbound sources (HTTP endpoints that receive events) | -| `hookdeck_destinations` | Inspect delivery destinations (HTTP endpoints where events are sent) | -| `hookdeck_transformations` | Inspect JavaScript transformations applied to event payloads | -| `hookdeck_requests` | Query inbound requests — list, get details, raw body, linked events | -| `hookdeck_events` | Query processed events — list, get details, raw payload body | -| `hookdeck_attempts` | Query delivery attempts — retry history, response codes, errors | -| `hookdeck_issues` | Inspect aggregated failure signals (delivery failures, transform errors, backpressure) | -| `hookdeck_metrics` | Query aggregate metrics — counts, failure rates, queue depth over time | -| `hookdeck_help` | Discover available tools and their actions | - -`hookdeck_events` and `hookdeck_requests` **list** actions support the same filters as `hookdeck gateway event list` and `hookdeck gateway request list` — including payload search (`body`, `headers`, `parsed_query`, `path`) and date windows via `*_after` / `*_before` (ISO 8601; maps to API `field[gte]` / `field[lte]`). See `hookdeck_help` with topic `hookdeck_events` or `hookdeck_requests` for the full parameter list. +| `gateway_connections` | Inspect connections and control delivery flow (list, get, pause, unpause) | +| `gateway_sources` | Inspect inbound sources (HTTP endpoints that receive events) | +| `gateway_destinations` | Inspect delivery destinations (HTTP endpoints where events are sent) | +| `gateway_transformations` | Inspect JavaScript transformations applied to event payloads | +| `gateway_requests` | Query inbound requests — list, get details, raw body, linked events | +| `gateway_events` | Query processed events — list, get details, raw payload body | +| `gateway_attempts` | Query delivery attempts — retry history, response codes, errors | +| `gateway_issues` | Inspect aggregated failure signals (delivery failures, transform errors, backpressure) | +| `gateway_metrics` | Query aggregate metrics — counts, failure rates, queue depth over time | +| `gateway_help` | Discover available tools and their actions | + +`gateway_events` and `gateway_requests` **list** actions support the same filters as `hookdeck gateway event list` and `hookdeck gateway request list` — including payload search (`body`, `headers`, `parsed_query`, `path`) and date windows via `*_after` / `*_before` (ISO 8601; maps to API `field[gte]` / `field[lte]`). See `gateway_help` with topic `gateway_events` or `gateway_requests` for the full parameter list. #### Example prompts @@ -638,31 +638,31 @@ Once the MCP server is configured, you can ask your agent questions like: ``` "Are any of my events failing right now?" -→ Agent uses hookdeck_issues to list open issues, then hookdeck_events to inspect recent failures. +→ Agent uses gateway_issues to list open issues, then gateway_events to inspect recent failures. "Show me the last 10 events for my Stripe source and check if any failed." -→ Agent uses hookdeck_sources to find the Stripe source, then hookdeck_events filtered by source and status. +→ Agent uses gateway_sources to find the Stripe source, then gateway_events filtered by source and status. "What's the error rate for my API destination over the last 24 hours?" -→ Agent uses hookdeck_metrics with measures like failed_count and count, grouped by destination. +→ Agent uses gateway_metrics with measures like failed_count and count, grouped by destination. "Trace request req_abc123 — what events did it produce, and did they all deliver successfully?" -→ Agent uses hookdeck_requests to get the request, then the events action to list generated events. +→ Agent uses gateway_requests to get the request, then the events action to list generated events. "Why is my checkout endpoint returning 500s? Show me the latest attempt details." -→ Agent uses hookdeck_events filtered by status FAILED, then hookdeck_attempts to inspect delivery details. +→ Agent uses gateway_events filtered by status FAILED, then gateway_attempts to inspect delivery details. "Pause the connection between Stripe and my staging endpoint while I debug." -→ Agent uses hookdeck_connections to find and pause the connection. +→ Agent uses gateway_connections to find and pause the connection. "Compare failure rates across all my destinations this week." -→ Agent uses hookdeck_metrics with dimensions set to destination_id and measures like error_rate. +→ Agent uses gateway_metrics with dimensions set to destination_id and measures like error_rate. "Find Stripe charge.succeeded events from the last week." -→ Agent uses hookdeck_events list with body filter {"type":"charge.succeeded"} and created_after / created_before ISO datetimes. +→ Agent uses gateway_events list with body filter {"type":"charge.succeeded"} and created_after / created_before ISO datetimes. "Show failed events that had delivery attempts in the last 24 hours." -→ Agent uses hookdeck_events list with status FAILED and last_attempt_after set to yesterday's ISO datetime. +→ Agent uses gateway_events list with status FAILED and last_attempt_after set to yesterday's ISO datetime. ``` ### Outpost diff --git a/pkg/cmd/mcp.go b/pkg/cmd/mcp.go index 7e9d12d5..e9603f09 100644 --- a/pkg/cmd/mcp.go +++ b/pkg/cmd/mcp.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "os" gatewaymcp "github.com/hookdeck/hookdeck-cli/pkg/gateway/mcp" "github.com/hookdeck/hookdeck-cli/pkg/validators" @@ -10,6 +11,9 @@ import ( type mcpCmd struct { cmd *cobra.Command + + allowWrite bool + readOnly bool } func newMCPCmd() *mcpCmd { @@ -24,6 +28,19 @@ The server exposes Hookdeck Event Gateway resources — connections, sources, destinations, events, requests, and more — as MCP tools that AI agents and LLM-based clients can invoke. +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. + +Pausing and unpausing a connection are available in both modes. Stopping a +misbehaving connection is the natural end of an investigation, and both are +reversible: pausing buffers delivery rather than dropping events. + +Product tools are prefixed gateway_, so this server and 'hookdeck outpost mcp' +can be configured in the same client. Signing in and switching project are +Hookdeck operations rather than Event Gateway ones, so they keep the platform +prefix: hookdeck_login and hookdeck_projects. + If the CLI is already authenticated, all tools are available immediately. If not, gateway MCP still starts: project selection is skipped until you authenticate, and hookdeck_login initiates browser-based sign-in. Protocol @@ -32,13 +49,20 @@ the server runs go to stderr. hookdeck_login stays registered after sign-in so you can call it with reauth: true to replace credentials (e.g. when project listing fails with a narrow API key).`), - Example: ` # Start the MCP server (stdio transport) + Example: ` # Start the MCP server, read-only (stdio transport) hookdeck gateway mcp + # Allow tools that change data + hookdeck gateway mcp --allow-write + # 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 gateway mcp`, RunE: mc.runMCPCmd, } + + addWriteModeFlags(mc.cmd, &mc.allowWrite, &mc.readOnly, + "Enable tools that create, change or delete data.") + return mc } @@ -51,6 +75,18 @@ func (mc *mcpCmd) runMCPCmd(cmd *cobra.Command, args []string) error { // not yet authenticated. The MCP server handles this gracefully by // registering a hookdeck_login tool instead of crashing. client := Config.GetAPIClient() - srv := gatewaymcp.NewServer(client, &Config) + + writeEnabled := resolveAllowWrite( + mc.allowWrite, + cmd.Flags().Changed("allow-write"), + mc.readOnly, + os.Getenv(allowWriteEnvVar), + ) + + srv := gatewaymcp.NewServer(gatewaymcp.ServerOptions{ + Client: client, + Config: &Config, + WriteEnabled: writeEnabled, + }) return srv.RunStdio(context.Background()) } diff --git a/pkg/cmd/mcp_write_mode.go b/pkg/cmd/mcp_write_mode.go new file mode 100644 index 00000000..70b63315 --- /dev/null +++ b/pkg/cmd/mcp_write_mode.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "strconv" + + "github.com/spf13/cobra" +) + +// allowWriteEnvVar enables write actions without a flag, for MCP clients whose +// config makes environment variables easier to set than arguments. +// +// It is shared by every MCP server the CLI starts: a user who wants write mode +// should not have to learn a different variable per product. +const allowWriteEnvVar = "HOOKDECK_MCP_ALLOW_WRITE" + +// addWriteModeFlags registers the flags that select read-only or write mode. +func addWriteModeFlags(cmd *cobra.Command, allowWrite, readOnly *bool, allowWriteUsage string) { + cmd.Flags().BoolVar(allowWrite, "allow-write", false, allowWriteUsage+" 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. + cmd.Flags().BoolVar(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.") +} + +// 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 +} diff --git a/pkg/cmd/outpost_mcp.go b/pkg/cmd/outpost_mcp.go index 8a286d87..19bdc589 100644 --- a/pkg/cmd/outpost_mcp.go +++ b/pkg/cmd/outpost_mcp.go @@ -3,7 +3,6 @@ package cmd import ( "context" "os" - "strconv" "github.com/spf13/cobra" @@ -11,10 +10,6 @@ import ( "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 @@ -78,10 +73,8 @@ before the server runs go to stderr.`), 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.") + addWriteModeFlags(mc.cmd, &mc.allowWrite, &mc.readOnly, + "Enable tools that create, change or delete data, and that return tenant credentials.") // 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.") @@ -93,25 +86,6 @@ 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 diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 384e047a..0fd11e44 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -37,12 +37,24 @@ func newTestClient(baseURL string, apiKey string) *hookdeck.Client { // transport and returns the client session. The server runs in a background // goroutine and is torn down when the test ends. func connectInMemory(t *testing.T, client *hookdeck.Client) *mcpsdk.ClientSession { + t.Helper() + return connectInMemoryWithMode(t, client, false) +} + +// connectInMemoryWriteEnabled is connectInMemory with --allow-write, for the +// tests that exercise the actions a read-only server does not offer. +func connectInMemoryWriteEnabled(t *testing.T, client *hookdeck.Client) *mcpsdk.ClientSession { + t.Helper() + return connectInMemoryWithMode(t, client, true) +} + +func connectInMemoryWithMode(t *testing.T, client *hookdeck.Client, writeEnabled bool) *mcpsdk.ClientSession { t.Helper() cfg := &config.Config{} if client != nil && client.BaseURL != nil { cfg.APIBaseURL = client.BaseURL.String() } - srv := NewServer(client, cfg) + srv := NewServer(ServerOptions{Client: client, Config: cfg, WriteEnabled: writeEnabled}) serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() @@ -154,10 +166,10 @@ func TestListTools_Authenticated(t *testing.T) { assert.Contains(t, toolNames, "hookdeck_login") expectedTools := []string{ - "hookdeck_projects", "hookdeck_connections", "hookdeck_sources", - "hookdeck_destinations", "hookdeck_transformations", "hookdeck_requests", - "hookdeck_events", "hookdeck_attempts", "hookdeck_issues", - "hookdeck_metrics", "hookdeck_help", + "hookdeck_projects", "gateway_connections", "gateway_sources", + "gateway_destinations", "gateway_transformations", "gateway_requests", + "gateway_events", "gateway_attempts", "gateway_issues", + "gateway_metrics", "gateway_help", } for _, name := range expectedTools { assert.Contains(t, toolNames, name) @@ -177,8 +189,8 @@ func TestListTools_Unauthenticated(t *testing.T) { } assert.Contains(t, toolNames, "hookdeck_login") - assert.Contains(t, toolNames, "hookdeck_help") - assert.Contains(t, toolNames, "hookdeck_events") + assert.Contains(t, toolNames, "gateway_help") + assert.Contains(t, toolNames, "gateway_events") } // --------------------------------------------------------------------------- @@ -189,13 +201,13 @@ func TestHelpTool_Overview(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{}) + result := callTool(t, session, "gateway_help", map[string]any{}) assert.False(t, result.IsError) text := textContent(t, result) - assert.Contains(t, text, "hookdeck_events") - assert.Contains(t, text, "hookdeck_connections") - assert.Contains(t, text, "hookdeck_sources") + assert.Contains(t, text, "gateway_events") + assert.Contains(t, text, "gateway_connections") + assert.Contains(t, text, "gateway_sources") assert.Contains(t, text, "proj_test123") // current project } @@ -203,7 +215,7 @@ func TestHelpTool_SpecificTopic(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_events"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_events"}) assert.False(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "list") @@ -215,7 +227,7 @@ func TestHelpEventsTopic_DocumentsDateRangeFilters(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_events"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_events"}) assert.False(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "Date range filters") @@ -230,7 +242,7 @@ func TestHelpRequestsTopic_DocumentsDateRangeFilters(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "hookdeck_requests"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_requests"}) assert.False(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "Date range filters") @@ -241,20 +253,20 @@ func TestHelpRequestsTopic_DocumentsDateRangeFilters(t *testing.T) { } func TestHelpTool_ShortTopicName(t *testing.T) { - // "events" should resolve to "hookdeck_events" + // "events" should resolve to "gateway_events" client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "events"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "events"}) assert.False(t, result.IsError) - assert.Contains(t, textContent(t, result), "hookdeck_events") + assert.Contains(t, textContent(t, result), "gateway_events") } func TestHelpTool_UnknownTopic(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "nonexistent_tool"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "nonexistent_tool"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "No help found") } @@ -268,9 +280,9 @@ func TestAuthGuard_UnauthenticatedReturnsError(t *testing.T) { session := connectInMemory(t, client) resourceTools := []string{ - "hookdeck_sources", "hookdeck_destinations", "hookdeck_connections", - "hookdeck_events", "hookdeck_requests", "hookdeck_attempts", - "hookdeck_issues", "hookdeck_transformations", "hookdeck_metrics", + "gateway_sources", "gateway_destinations", "gateway_connections", + "gateway_events", "gateway_requests", "gateway_attempts", + "gateway_issues", "gateway_transformations", "gateway_metrics", "hookdeck_projects", } @@ -294,7 +306,7 @@ func TestSourcesList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) assert.False(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "src_123") @@ -308,7 +320,7 @@ func TestSourcesGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "get", "id": "src_123"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "get", "id": "src_123"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "github-webhooks") } @@ -316,7 +328,7 @@ func TestSourcesGet_Success(t *testing.T) { func TestSourcesGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -324,7 +336,7 @@ func TestSourcesGet_MissingID(t *testing.T) { func TestSourcesTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "delete"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "frobnicate"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -340,7 +352,7 @@ func TestDestinationsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_destinations", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_destinations", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "des_456") } @@ -352,7 +364,7 @@ func TestDestinationsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_destinations", map[string]any{"action": "get", "id": "des_456"}) + result := callTool(t, session, "gateway_destinations", map[string]any{"action": "get", "id": "des_456"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "des_456") } @@ -360,7 +372,7 @@ func TestDestinationsGet_Success(t *testing.T) { func TestDestinationsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_destinations", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_destinations", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -368,7 +380,7 @@ func TestDestinationsGet_MissingID(t *testing.T) { func TestDestinationsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_destinations", map[string]any{"action": "create"}) + result := callTool(t, session, "gateway_destinations", map[string]any{"action": "frobnicate"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -384,7 +396,7 @@ func TestConnectionsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "stripe-to-backend") } @@ -396,7 +408,7 @@ func TestConnectionsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "web_conn1"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "get", "id": "web_conn1"}) assert.False(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "web_conn1") @@ -418,7 +430,7 @@ func TestConnectionsGet_ByName(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "stripe-to-backend"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "get", "id": "stripe-to-backend"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "web_conn1") } @@ -426,7 +438,7 @@ func TestConnectionsGet_ByName(t *testing.T) { func TestConnectionsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id or name is required") } @@ -443,7 +455,7 @@ func TestConnectionsPause_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause", "id": "web_conn1"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "pause", "id": "web_conn1"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "web_conn1") } @@ -461,7 +473,7 @@ func TestConnectionsPause_ByName(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause", "id": "stripe-to-backend"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "pause", "id": "stripe-to-backend"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "web_conn1") } @@ -469,7 +481,7 @@ func TestConnectionsPause_ByName(t *testing.T) { func TestConnectionsPause_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "pause"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "pause"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id or name is required") } @@ -486,7 +498,7 @@ func TestConnectionsUnpause_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause", "id": "web_conn1"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "unpause", "id": "web_conn1"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "web_conn1") } @@ -504,7 +516,7 @@ func TestConnectionsUnpause_ByName(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause", "id": "stripe-to-backend"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "unpause", "id": "stripe-to-backend"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "web_conn1") } @@ -512,7 +524,7 @@ func TestConnectionsUnpause_ByName(t *testing.T) { func TestConnectionsUnpause_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "unpause"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "unpause"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id or name is required") } @@ -520,7 +532,7 @@ func TestConnectionsUnpause_MissingID(t *testing.T) { func TestConnectionsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "delete"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "frobnicate"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -534,7 +546,7 @@ func TestConnectionsList_DisabledFilter(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "list", "disabled": true}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "list", "disabled": true}) assert.False(t, result.IsError) } @@ -549,7 +561,7 @@ func TestTransformationsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_transformations", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_transformations", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "trn_789") } @@ -561,7 +573,7 @@ func TestTransformationsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_transformations", map[string]any{"action": "get", "id": "trn_789"}) + result := callTool(t, session, "gateway_transformations", map[string]any{"action": "get", "id": "trn_789"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "enrich-payload") } @@ -569,7 +581,7 @@ func TestTransformationsGet_Success(t *testing.T) { func TestTransformationsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_transformations", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_transformations", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -577,7 +589,7 @@ func TestTransformationsGet_MissingID(t *testing.T) { func TestTransformationsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_transformations", map[string]any{"action": "run"}) + result := callTool(t, session, "gateway_transformations", map[string]any{"action": "frobnicate"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -593,7 +605,7 @@ func TestAttemptsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_attempts", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_attempts", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "atm_001") } @@ -605,7 +617,7 @@ func TestAttemptsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_attempts", map[string]any{"action": "get", "id": "atm_001"}) + result := callTool(t, session, "gateway_attempts", map[string]any{"action": "get", "id": "atm_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "atm_001") } @@ -613,7 +625,7 @@ func TestAttemptsGet_Success(t *testing.T) { func TestAttemptsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_attempts", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_attempts", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -621,7 +633,7 @@ func TestAttemptsGet_MissingID(t *testing.T) { func TestAttemptsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_attempts", map[string]any{"action": "retry"}) + result := callTool(t, session, "gateway_attempts", map[string]any{"action": "retry"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -637,7 +649,7 @@ func TestEventsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_abc") } @@ -649,7 +661,7 @@ func TestEventsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "get", "id": "evt_abc"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_abc"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_abc") } @@ -657,7 +669,7 @@ func TestEventsGet_Success(t *testing.T) { func TestEventsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -669,7 +681,7 @@ func TestEventsRawBody_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "raw_body", "id": "evt_abc"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_abc"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "raw_body") } @@ -677,7 +689,7 @@ func TestEventsRawBody_Success(t *testing.T) { func TestEventsRawBody_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "raw_body"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -691,7 +703,7 @@ func TestEventsRawBody_Truncation(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "raw_body", "id": "evt_big"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_big"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "truncated") } @@ -699,7 +711,7 @@ func TestEventsRawBody_Truncation(t *testing.T) { func TestEventsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "delete"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "delete"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -713,7 +725,7 @@ func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "list", "connection_id": "web_123"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "list", "connection_id": "web_123"}) assert.False(t, result.IsError) } @@ -725,7 +737,7 @@ func TestEventsList_BodyFilter(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "body": map[string]any{"type": "payment"}, }) @@ -742,7 +754,7 @@ func TestEventsList_PayloadFilters(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "headers": `{"x-test":"1"}`, "parsed_query": map[string]any{"q": "search"}, @@ -761,7 +773,7 @@ func TestEventsList_MetadataFilters(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "id": "evt_1,evt_2", "attempts": "3", @@ -773,7 +785,7 @@ func TestEventsList_MetadataFilters(t *testing.T) { func TestEventsList_InvalidBodyFilter(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "list", "body": 42}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "list", "body": 42}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "body must be a JSON string or object") } @@ -787,7 +799,7 @@ func TestEventsList_CreatedAtDateRange(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "created_after": "2026-06-01T00:00:00Z", "created_before": "2026-06-09T23:59:59Z", @@ -804,7 +816,7 @@ func TestEventsList_SuccessfulAtDateRange(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "successful_after": "2026-06-01T00:00:00Z", "successful_before": "2026-06-09T23:59:59Z", @@ -821,7 +833,7 @@ func TestEventsList_LastAttemptAtDateRange(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{ + result := callTool(t, session, "gateway_events", map[string]any{ "action": "list", "last_attempt_after": "2026-06-01T00:00:00Z", "last_attempt_before": "2026-06-09T23:59:59Z", @@ -840,7 +852,7 @@ func TestRequestsList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "req_001") } @@ -852,7 +864,7 @@ func TestRequestsGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "get", "id": "req_001"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "get", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "req_001") } @@ -860,7 +872,7 @@ func TestRequestsGet_Success(t *testing.T) { func TestRequestsGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -872,7 +884,7 @@ func TestRequestsRawBody_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "raw_body", "id": "req_001"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "raw_body") } @@ -880,7 +892,7 @@ func TestRequestsRawBody_Success(t *testing.T) { func TestRequestsRawBody_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "raw_body"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -893,7 +905,7 @@ func TestRequestsRawBody_Truncation(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "raw_body", "id": "req_big"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_big"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "truncated") } @@ -905,7 +917,7 @@ func TestRequestsEvents_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "events", "id": "req_001"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "events", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_from_req") } @@ -913,7 +925,7 @@ func TestRequestsEvents_Success(t *testing.T) { func TestRequestsEvents_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "events"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "events"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -925,7 +937,7 @@ func TestRequestsIgnoredEvents_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "ignored_events", "id": "req_001"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "ignored_events", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "ign_evt_001") } @@ -933,7 +945,7 @@ func TestRequestsIgnoredEvents_Success(t *testing.T) { func TestRequestsIgnoredEvents_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "ignored_events"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "ignored_events"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -941,7 +953,7 @@ func TestRequestsIgnoredEvents_MissingID(t *testing.T) { func TestRequestsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "delete"}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "delete"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -954,7 +966,7 @@ func TestRequestsList_VerifiedFilter(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "list", "verified": true}) + result := callTool(t, session, "gateway_requests", map[string]any{"action": "list", "verified": true}) assert.False(t, result.IsError) } @@ -966,7 +978,7 @@ func TestRequestsList_BodyFilter(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{ + result := callTool(t, session, "gateway_requests", map[string]any{ "action": "list", "body": map[string]any{"event": "test"}, }) @@ -982,7 +994,7 @@ func TestRequestsList_CreatedAtDateRange(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{ + result := callTool(t, session, "gateway_requests", map[string]any{ "action": "list", "created_after": "2026-06-01T00:00:00Z", "created_before": "2026-06-09T23:59:59Z", @@ -999,7 +1011,7 @@ func TestRequestsList_IngestedAtDateRange(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{ + result := callTool(t, session, "gateway_requests", map[string]any{ "action": "list", "ingested_after": "2026-06-01T00:00:00Z", "ingested_before": "2026-06-09T23:59:59Z", @@ -1016,7 +1028,7 @@ func TestRequestsList_OrderByAndDir(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{ + result := callTool(t, session, "gateway_requests", map[string]any{ "action": "list", "order_by": "created_at", "dir": "desc", @@ -1035,7 +1047,7 @@ func TestIssuesList_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_issues", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_issues", map[string]any{"action": "list"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "iss_001") } @@ -1047,7 +1059,7 @@ func TestIssuesGet_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_issues", map[string]any{"action": "get", "id": "iss_001"}) + result := callTool(t, session, "gateway_issues", map[string]any{"action": "get", "id": "iss_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "iss_001") } @@ -1055,7 +1067,7 @@ func TestIssuesGet_Success(t *testing.T) { func TestIssuesGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_issues", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_issues", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -1063,7 +1075,7 @@ func TestIssuesGet_MissingID(t *testing.T) { func TestIssuesTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_issues", map[string]any{"action": "close"}) + result := callTool(t, session, "gateway_issues", map[string]any{"action": "close"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "unknown action") } @@ -1167,7 +1179,7 @@ func TestProjectsTool_UnknownAction(t *testing.T) { func TestMetricsTool_MissingStartEnd(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_metrics", map[string]any{"action": "events"}) + result := callTool(t, session, "gateway_metrics", map[string]any{"action": "events"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "required") } @@ -1175,7 +1187,7 @@ func TestMetricsTool_MissingStartEnd(t *testing.T) { func TestMetricsTool_MissingMeasures(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "events", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1191,7 +1203,7 @@ func TestMetricsEvents_DefaultRoute(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "events", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1207,7 +1219,7 @@ func TestMetricsEvents_QueueDepthRoute(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "events", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1223,7 +1235,7 @@ func TestMetricsEvents_PendingTimeseriesRoute(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "events", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1240,7 +1252,7 @@ func TestMetricsEvents_ByIssueRoute(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "events", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1257,7 +1269,7 @@ func TestMetricsRequests_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "requests", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1273,7 +1285,7 @@ func TestMetricsAttempts_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "attempts", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1289,7 +1301,7 @@ func TestMetricsTransformations_Success(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "transformations", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1301,7 +1313,7 @@ func TestMetricsTransformations_Success(t *testing.T) { func TestMetricsTool_UnknownAction(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + result := callTool(t, session, "gateway_metrics", map[string]any{ "action": "invalid", "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", @@ -1411,7 +1423,7 @@ func TestLoginTool_ReauthStartsFreshLogin(t *testing.T) { client := newTestClient(api.URL, "sk_test_123456789012") cfg := &config.Config{APIBaseURL: api.URL} - srv := NewServer(client, cfg) + srv := NewServer(ServerOptions{Client: client, Config: cfg}) serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() ctx, cancel := context.WithCancel(context.Background()) @@ -1450,7 +1462,7 @@ func TestLoginTool_ReturnsURLImmediately(t *testing.T) { unauthClient := newTestClient(api.URL, "") cfg := &config.Config{APIBaseURL: api.URL} - srv := NewServer(unauthClient, cfg) + srv := NewServer(ServerOptions{Client: unauthClient, Config: cfg}) serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() ctx, cancel := context.WithCancel(context.Background()) @@ -1486,7 +1498,7 @@ func TestLoginTool_InProgressShowsURL(t *testing.T) { unauthClient := newTestClient(api.URL, "") cfg := &config.Config{APIBaseURL: api.URL} - srv := NewServer(unauthClient, cfg) + srv := NewServer(ServerOptions{Client: unauthClient, Config: cfg}) serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() ctx, cancel := context.WithCancel(context.Background()) @@ -1544,7 +1556,7 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { unauthClient := newTestClient(api.URL, "") cfg := &config.Config{APIBaseURL: api.URL} - srv := NewServer(unauthClient, cfg) + srv := NewServer(ServerOptions{Client: unauthClient, Config: cfg}) serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() ctx, cancel := context.WithCancel(context.Background()) @@ -1585,7 +1597,7 @@ func TestSourcesList_404Error(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "not found") } @@ -1598,7 +1610,7 @@ func TestSourcesList_422ValidationError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "invalid parameter") } @@ -1611,7 +1623,7 @@ func TestSourcesList_429RateLimitError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "Rate limited") } @@ -1624,7 +1636,7 @@ func TestEventsGet_APIError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_events", map[string]any{"action": "get", "id": "evt_nope"}) + result := callTool(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_nope"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "not found") } @@ -1657,16 +1669,16 @@ func TestHelpTool_AllTopics(t *testing.T) { expectContains string }{ {"hookdeck_projects", "list"}, - {"hookdeck_connections", "pause"}, - {"hookdeck_sources", "list"}, - {"hookdeck_destinations", "HTTP"}, - {"hookdeck_transformations", "JavaScript"}, - {"hookdeck_requests", "raw_body"}, - {"hookdeck_events", "raw_body"}, - {"hookdeck_attempts", "event_id"}, - {"hookdeck_issues", "delivery"}, - {"hookdeck_metrics", "granularity"}, - {"hookdeck_help", "topic"}, + {"gateway_connections", "pause"}, + {"gateway_sources", "list"}, + {"gateway_destinations", "HTTP"}, + {"gateway_transformations", "JavaScript"}, + {"gateway_requests", "raw_body"}, + {"gateway_events", "raw_body"}, + {"gateway_attempts", "event_id"}, + {"gateway_issues", "delivery"}, + {"gateway_metrics", "granularity"}, + {"gateway_help", "topic"}, } client := newTestClient("https://api.hookdeck.com", "test-key") @@ -1674,7 +1686,7 @@ func TestHelpTool_AllTopics(t *testing.T) { for _, tt := range topics { t.Run(tt.name, func(t *testing.T) { - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": tt.name}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": tt.name}) assert.False(t, result.IsError, "help for %s should not be an error", tt.name) text := textContent(t, result) assert.Contains(t, text, tt.expectContains, @@ -1695,9 +1707,15 @@ func TestHelpTool_ShortNames(t *testing.T) { for _, name := range shortNames { t.Run(name, func(t *testing.T) { - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": name}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": name}) assert.False(t, result.IsError, "short name %q should resolve", name) - assert.Contains(t, textContent(t, result), "hookdeck_"+name) + // Logging in and switching project are platform operations and keep + // the hookdeck_ prefix; every product tool takes gateway_. + want := "gateway_" + name + if name == "projects" || name == "login" { + want = "hookdeck_" + name + } + assert.Contains(t, textContent(t, result), want) }) } } @@ -1706,15 +1724,15 @@ func TestHelpTool_OverviewListsAllTools(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{}) + result := callTool(t, session, "gateway_help", map[string]any{}) assert.False(t, result.IsError) text := textContent(t, result) expectedTools := []string{ - "hookdeck_projects", "hookdeck_connections", "hookdeck_sources", - "hookdeck_destinations", "hookdeck_transformations", "hookdeck_requests", - "hookdeck_events", "hookdeck_attempts", "hookdeck_issues", - "hookdeck_metrics", "hookdeck_help", + "hookdeck_projects", "gateway_connections", "gateway_sources", + "gateway_destinations", "gateway_transformations", "gateway_requests", + "gateway_events", "gateway_attempts", "gateway_issues", + "gateway_metrics", "gateway_help", } for _, tool := range expectedTools { assert.Contains(t, text, tool, "overview should list %s", tool) @@ -1726,7 +1744,7 @@ func TestHelpTool_OverviewShowsProjectNotSet(t *testing.T) { client.ProjectID = "" // no project set session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{}) + result := callTool(t, session, "gateway_help", map[string]any{}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "not set") } @@ -1735,11 +1753,11 @@ func TestHelpTool_UnknownTopicListsAvailable(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_help", map[string]any{"topic": "bogus"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "bogus"}) assert.True(t, result.IsError) text := textContent(t, result) assert.Contains(t, text, "No help found") - assert.Contains(t, text, "hookdeck_events") // lists available tools + assert.Contains(t, text, "gateway_events") // lists available tools } // --------------------------------------------------------------------------- @@ -1754,7 +1772,7 @@ func TestDestinationsGet_500ServerError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_destinations", map[string]any{"action": "get", "id": "des_fail"}) + result := callTool(t, session, "gateway_destinations", map[string]any{"action": "get", "id": "des_fail"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "Hookdeck API error") } @@ -1767,7 +1785,7 @@ func TestConnectionsGet_401UnauthorizedError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_connections", map[string]any{"action": "get", "id": "web_bad"}) + result := callTool(t, session, "gateway_connections", map[string]any{"action": "get", "id": "web_bad"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "Authentication failed") } @@ -1780,7 +1798,7 @@ func TestIssuesList_422ValidationError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_issues", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_issues", map[string]any{"action": "list"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "invalid filter") } @@ -1793,7 +1811,7 @@ func TestAttemptsList_429RateLimitError(t *testing.T) { }, }) - result := callTool(t, session, "hookdeck_attempts", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_attempts", map[string]any{"action": "list"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "Rate limited") } diff --git a/pkg/gateway/mcp/telemetry_test.go b/pkg/gateway/mcp/telemetry_test.go index dcce7a85..6b7794ad 100644 --- a/pkg/gateway/mcp/telemetry_test.go +++ b/pkg/gateway/mcp/telemetry_test.go @@ -74,7 +74,7 @@ func TestMCPToolCall_TelemetryHeaderSentToAPI(t *testing.T) { }), }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) require.False(t, result.IsError, "tool call should succeed") // Verify the telemetry header was sent. @@ -83,7 +83,7 @@ func TestMCPToolCall_TelemetryHeaderSentToAPI(t *testing.T) { tel := parseTelemetryHeader(t, raw) require.Equal(t, "mcp", tel.Source) - require.Equal(t, "hookdeck_sources/list", tel.CommandPath) + require.Equal(t, "gateway_sources/list", tel.CommandPath) require.True(t, strings.HasPrefix(tel.InvocationID, "inv_"), "invocation ID must start with inv_") require.NotEmpty(t, tel.DeviceName) require.Contains(t, []string{"interactive", "ci"}, tel.Environment) @@ -106,7 +106,7 @@ func TestMCPToolCall_EachCallGetsUniqueInvocationID(t *testing.T) { // Make three separate tool calls. for i := 0; i < 3; i++ { - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) require.False(t, result.IsError) } @@ -138,18 +138,18 @@ func TestMCPToolCall_TelemetryHeaderReflectsAction(t *testing.T) { }) // Call "list" action. - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) require.False(t, result.IsError) listTel := parseTelemetryHeader(t, capture.all()[0]) - require.Equal(t, "hookdeck_sources/list", listTel.CommandPath) + require.Equal(t, "gateway_sources/list", listTel.CommandPath) // Call "get" action. - result = callTool(t, session, "hookdeck_sources", map[string]any{"action": "get", "id": "src_1"}) + result = callTool(t, session, "gateway_sources", map[string]any{"action": "get", "id": "src_1"}) require.False(t, result.IsError) getTel := parseTelemetryHeader(t, capture.all()[1]) - require.Equal(t, "hookdeck_sources/get", getTel.CommandPath) + require.Equal(t, "gateway_sources/get", getTel.CommandPath) } func TestMCPToolCall_TelemetryDisabledByConfig(t *testing.T) { @@ -169,7 +169,7 @@ func TestMCPToolCall_TelemetryDisabledByConfig(t *testing.T) { client.TelemetryDisabled = true session := connectInMemory(t, client) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) require.False(t, result.IsError) raw := capture.last() @@ -189,7 +189,7 @@ func TestMCPToolCall_TelemetryDisabledByEnvVar(t *testing.T) { }), }) - result := callTool(t, session, "hookdeck_sources", map[string]any{"action": "list"}) + result := callTool(t, session, "gateway_sources", map[string]any{"action": "list"}) require.False(t, result.IsError) raw := capture.last() diff --git a/pkg/gateway/mcp/tool_attempts.go b/pkg/gateway/mcp/tool_attempts.go index 74d736b2..0c2bf0c6 100644 --- a/pkg/gateway/mcp/tool_attempts.go +++ b/pkg/gateway/mcp/tool_attempts.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,33 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleAttempts(client *hookdeck.Client) mcpsdk.ToolHandler { +// attempts is read-only: a delivery attempt is a record of something that +// already happened. Retrying is an action on the event, not on the attempt. +var attemptsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list delivery attempts"}, + {Name: "get", Desc: "get one attempt, including the response data"}, +} + +var attemptsSpec = mcpcore.ToolSpec{ + Resource: "attempts", + Summary: "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.", + Actions: attemptsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Attempt ID (required for get)"}, + "event_id": {Type: "string", Desc: "Filter by event (list)"}, + "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"}, + }, + 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 := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,15 +44,15 @@ func handleAttempts(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") - switch action { - case "list", "": + action, blocked := mcpcore.DispatchWithDefault(srv, attemptsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + + if action == "list" { return attemptsList(ctx, client, in) - case "get": - return attemptsGet(ctx, client, in) - default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil } + return attemptsGet(ctx, client, in) } } diff --git a/pkg/gateway/mcp/tool_connections.go b/pkg/gateway/mcp/tool_connections.go index 40b7a187..84eeaa3f 100644 --- a/pkg/gateway/mcp/tool_connections.go +++ b/pkg/gateway/mcp/tool_connections.go @@ -12,9 +12,55 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleConnections(client *hookdeck.Client) mcpsdk.ToolHandler { +var connectionsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list connections"}, + {Name: "get", Desc: "get one connection by ID or name"}, + + // pause and unpause deliberately stay Write: false, so they remain + // available in read-only mode. + // + // Read-only is the mode people investigate incidents in, and pausing a + // misbehaving connection is the natural end of an investigation: you find + // the connection flooding a destination and you stop it. Requiring a server + // restart with --allow-write at that moment would be the wrong trade. Both + // are also fully reversible and destroy nothing — pausing buffers delivery + // rather than dropping events. + // + // This is a decision, not an oversight. Every other mutation below is gated. + {Name: "pause", Desc: "pause delivery on a connection; events are buffered, not dropped"}, + {Name: "unpause", Desc: "resume delivery on a paused connection"}, + + {Name: "create", Desc: "create a connection between a source and a destination", Write: true}, + {Name: "upsert", Desc: "create a connection or update the existing one with the same name", Write: true}, + {Name: "update", Desc: "update a connection", Write: true}, + {Name: "delete", Desc: "delete a connection", Write: true, Destructive: true}, + {Name: "enable", Desc: "enable a disabled connection", Write: true}, + {Name: "disable", Desc: "disable a connection", Write: true}, +} + +var connectionsSpec = mcpcore.ToolSpec{ + Resource: "connections", + Summary: "Inspect and manage connections (routes linking sources to destinations). Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: connectionsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Connection ID or name. Required for get/pause/unpause/update/delete/enable/disable."}, + "name": {Type: "string", Desc: "Connection name. Filters on list; names the connection on create/upsert/update."}, + "description": {Type: "string", Desc: "Connection description (create/upsert/update)"}, + "source_id": {Type: "string", Desc: "Source ID. Filters on list; links the source on create/upsert/update."}, + "destination_id": {Type: "string", Desc: "Destination ID. Filters on list; links the destination on create/upsert/update."}, + "rules": {Type: "array", Desc: "Ruleset applied to the connection (create/upsert/update). Array of rule objects; replaces the stored ruleset.", Items: &mcpcore.Prop{Type: "object"}}, + "disabled": {Type: "boolean", Desc: "Filter disabled connections (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + Handler: handleConnections, +} + +func handleConnections(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -23,9 +69,13 @@ func handleConnections(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, connectionsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return connectionsList(ctx, client, in) case "get": return connectionsGet(ctx, client, in) @@ -33,10 +83,143 @@ func handleConnections(client *hookdeck.Client) mcpsdk.ToolHandler { return connectionsPause(ctx, client, in) case "unpause": return connectionsUnpause(ctx, client, in) + case "create": + return connectionsCreate(ctx, client, in) + case "upsert": + return connectionsUpsert(ctx, client, in) + case "update": + return connectionsUpdate(ctx, client, in) + case "delete": + return connectionsDelete(ctx, client, in) + case "enable": + return connectionsSetEnabled(ctx, client, in, "enable") default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, pause, or unpause", action)), nil + return connectionsSetEnabled(ctx, client, in, "disable") + } + } +} + +// connectionRequest builds the create/upsert/update body from the tool input. +func connectionRequest(in mcpcore.Input) (*hookdeck.ConnectionCreateRequest, error) { + req := &hookdeck.ConnectionCreateRequest{ + Name: mcpcore.OptionalStringPtr(in, "name"), + Description: mcpcore.OptionalStringPtr(in, "description"), + SourceID: mcpcore.OptionalStringPtr(in, "source_id"), + DestinationID: mcpcore.OptionalStringPtr(in, "destination_id"), + } + rules, err := ruleList(in, "rules") + if err != nil { + return nil, err + } + req.Rules = rules + return req, nil +} + +// ruleList reads the connection ruleset, which the API models as an ordered +// array of rule objects. +func ruleList(in mcpcore.Input, key string) ([]hookdeck.Rule, error) { + v, ok := in[key] + if !ok || v == nil { + return nil, nil + } + arr, ok := v.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an array of rule objects", key) + } + rules := make([]hookdeck.Rule, 0, len(arr)) + for i, item := range arr { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s[%d] must be a rule object", key, i) } + rules = append(rules, hookdeck.Rule(m)) + } + return rules, nil +} + +func connectionsCreate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + req, err := connectionRequest(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + conn, err := client.CreateConnection(ctx, req) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(conn, client) +} + +func connectionsUpsert(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + // Upsert matches on name, so without one the API cannot tell which + // connection to update and would create a new one on every call. + if _, err := mcpcore.RequireString(in, "name", "upsert"); err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + req, err := connectionRequest(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + conn, err := client.UpsertConnection(ctx, req) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(conn, client) +} + +func connectionsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := connectionIDFromInput(ctx, client, in, "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + req, err := connectionRequest(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + conn, err := client.UpdateConnection(ctx, id, req) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(conn, client) +} + +func connectionsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := connectionIDFromInput(ctx, client, in, "delete") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := client.DeleteConnection(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "connection_id": id, + "status": "deleted", + }, client) +} + +func connectionsSetEnabled(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + id, err := connectionIDFromInput(ctx, client, in, action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + call := client.EnableConnection + if action == "disable" { + call = client.DisableConnection + } + conn, err := call(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(conn, client) +} + +// connectionIDFromInput resolves the id argument, which may be a name, for the +// actions that address one existing connection. +func connectionIDFromInput(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (string, error) { + idOrName := in.String("id") + if idOrName == "" { + return "", fmt.Errorf("id or name is required for the %s action", action) } + return resolveMCPConnectionID(ctx, client, idOrName) } func connectionsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { diff --git a/pkg/gateway/mcp/tool_destinations.go b/pkg/gateway/mcp/tool_destinations.go index c2a59053..401f43b6 100644 --- a/pkg/gateway/mcp/tool_destinations.go +++ b/pkg/gateway/mcp/tool_destinations.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,38 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleDestinations(client *hookdeck.Client) mcpsdk.ToolHandler { +var destinationsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list destinations"}, + {Name: "get", Desc: "get one destination"}, + {Name: "create", Desc: "create a destination", Write: true}, + {Name: "upsert", Desc: "create a destination or update the existing one with the same name", Write: true}, + {Name: "update", Desc: "update a destination", Write: true}, + {Name: "delete", Desc: "delete a destination", Write: true, Destructive: true}, + {Name: "enable", Desc: "enable a disabled destination", Write: true}, + {Name: "disable", Desc: "disable a destination so delivery stops", Write: true}, +} + +var destinationsSpec = mcpcore.ToolSpec{ + Resource: "destinations", + Summary: "Inspect and manage delivery destinations where events are sent. Destination types include HTTP endpoints, CLI (local development), and MOCK (testing). Configuration covers the URL, authentication, and rate limiting.", + Actions: destinationsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Destination ID. Required for get/update/delete/enable/disable."}, + "name": {Type: "string", Desc: "Destination name. Filters on list; required on create/upsert."}, + "type": {Type: "string", Desc: "Destination type, e.g. HTTP, CLI, MOCK_API (create/upsert/update)"}, + "description": {Type: "string", Desc: "Destination description (create/upsert/update)"}, + "config": {Type: "object", Desc: "Type-specific configuration: url, auth, rate limiting (create/upsert/update). Replaces the stored config."}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + 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 := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,18 +49,110 @@ func handleDestinations(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, destinationsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return destinationsList(ctx, client, in) case "get": return destinationsGet(ctx, client, in) + case "create", "upsert": + return destinationsWrite(ctx, client, in, action) + case "update": + return destinationsUpdate(ctx, client, in) + case "delete": + return destinationsDelete(ctx, client, in) + case "enable": + return destinationsSetEnabled(ctx, client, in, "enable") default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return destinationsSetEnabled(ctx, client, in, "disable") } } } +// destinationsWrite handles create and upsert, which share a request body. Both +// require a name: the API keys upsert on it. +func destinationsWrite(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + name, err := mcpcore.RequireString(in, "name", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + config, err := mcpcore.Object(in, "config") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + req := &hookdeck.DestinationCreateRequest{ + Name: name, + Type: in.String("type"), + Description: mcpcore.OptionalStringPtr(in, "description"), + Config: config, + } + + call := client.CreateDestination + if action == "upsert" { + call = client.UpsertDestination + } + dest, err := call(ctx, req) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(dest, client) +} + +func destinationsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + config, err := mcpcore.Object(in, "config") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + dest, err := client.UpdateDestination(ctx, id, &hookdeck.DestinationUpdateRequest{ + Name: in.String("name"), + Type: in.String("type"), + Description: mcpcore.OptionalStringPtr(in, "description"), + Config: config, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(dest, client) +} + +func destinationsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "delete") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := client.DeleteDestination(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "destination_id": id, + "status": "deleted", + }, client) +} + +func destinationsSetEnabled(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + call := client.EnableDestination + if action == "disable" { + call = client.DisableDestination + } + dest, err := call(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(dest, client) +} + func destinationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "name", in.String("name")) diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 07eef1e4..28b96ce1 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,76 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { +var eventsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list events, most recent first"}, + {Name: "get", Desc: "get one event's metadata and headers"}, + {Name: "raw_body", Desc: "get one event's payload"}, + {Name: "retry", Desc: "queue another delivery attempt for an event", Write: true}, + {Name: "cancel", Desc: "stop a scheduled event from being delivered", Write: true, Destructive: true}, + {Name: "mute", Desc: "mute a failed event so it stops raising issues", Write: true, Destructive: true}, +} + +var eventsSpec = mcpcore.ToolSpec{ + Resource: "events", + Summary: "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). Use action raw_body with the event id to get the payload directly — do not use the requests tool for the payload when you already have an event id. Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: eventsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Event ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/retry/cancel/mute"}, + "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"}, + }, + Notes: `Date range filters (list): + Use *_after / *_before with ISO 8601 datetimes. Do not pass API bracket keys in MCP args. + created_after → created_at[gte] + created_before → created_at[lte] + successful_after → successful_at[gte] + successful_before → successful_at[lte] + last_attempt_after → last_attempt_at[gte] + last_attempt_before → last_attempt_at[lte] + Example: {"action":"list","status":"FAILED","last_attempt_after":"2026-06-08T00:00:00Z"} + +Payload search (list): + body, headers, parsed_query — Hookdeck JSON filter syntax (object or string) + path — partial URL path match + Example: {"action":"list","body":{"type":"charge.succeeded"}} + +Getting the payload: + get returns metadata and headers only. Use raw_body with the event id for the payload — there is + no need to go via the requests tool when you already have an event id. + +Acting on a failure (write mode): + retry queues another delivery attempt and is the usual follow-up to investigating a failed event. + cancel stops a scheduled event from ever being delivered; mute stops a failed event raising + further issues without retrying it.`, + 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 := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,20 +87,62 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, eventsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return eventsList(ctx, client, in) case "get": return eventsGet(ctx, client, in) case "raw_body": return eventsRawBody(ctx, client, in) + case "retry": + return eventsRetry(ctx, client, in) + case "cancel": + return eventsCancel(ctx, client, in) default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, or raw_body", action)), nil + return eventsMute(ctx, client, in) } } } +func eventsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "retry", "retried", client.RetryEvent) +} + +func eventsCancel(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "cancel", "cancelled", client.CancelEvent) +} + +func eventsMute(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "mute", "muted", client.MuteEvent) +} + +// eventAction runs one of the by-id event mutations, which all take an event id +// and return no body, and reports the outcome in a consistent shape. +func eventAction( + ctx context.Context, + client *hookdeck.Client, + in mcpcore.Input, + action, status string, + call func(context.Context, string) error, +) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := call(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "event_id": id, + "status": status, + }, client) +} + func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "id", in.String("id")) diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 3e529737..5f15326d 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,7 +11,8 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleHelp(client *hookdeck.Client) mcpsdk.ToolHandler { +func handleHelp(srv *mcpcore.Server) 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 { @@ -19,9 +21,9 @@ func handleHelp(client *hookdeck.Client) mcpsdk.ToolHandler { topic := in.String("topic") if topic == "" { - return helpOverview(client), nil + return helpOverview(srv, client), nil } - return helpTopic(topic), nil + return mcpcore.HelpTopic(helpTopicPrefix, toolHelp(srv), topic, mcpJSONSuccessResponseHelp), nil } } @@ -50,7 +52,7 @@ func formatCurrentProject(client *hookdeck.Client) string { } // mcpJSONSuccessResponseHelp documents the envelope returned by every resource tool. -// Keep in sync with JSONResultEnvelope in response.go. +// Keep in sync with JSONResultEnvelope in mcpcore/response.go. const mcpJSONSuccessResponseHelp = `Common JSON response shape (all resource tools) Successful tool calls that return JSON share one envelope. Parse the tool result body as JSON: @@ -61,42 +63,103 @@ Successful tool calls that return JSON share one envelope. Parse the tool result unresolved. "active_project_org" (string) is included when known; omitted when empty. If no project id is set, "meta" is {}. -Plain text (not this shape): hookdeck_help text, hookdeck_login prompts, and error messages. +Plain text (not this shape): gateway_help text, hookdeck_login prompts, and error messages. Errors use the host error flag; bodies are plain text, not JSON envelopes.` -func helpOverview(client *hookdeck.Client) *mcpsdk.CallToolResult { - projectInfo := formatCurrentProject(client) +// modeHelp explains what this session may do and, in read-only mode, how to +// change that. +func modeHelp(srv *mcpcore.Server) string { + if srv.WriteEnabled() { + return `Mode: write enabled. Every action listed above is available, including the ones that +create, change and delete data. Destructive actions (delete, cancel, mute, dismiss) are real and +immediate.` + } + + return `Mode: read-only. Actions that change data are not offered, and the tools above list only +the actions this session can perform. Pausing and unpausing a connection are the exception: they +stay available because stopping a misbehaving connection is the natural end of an investigation, +and both are reversible and drop nothing. + +To enable the rest, restart the server with --allow-write, or set HOOKDECK_MCP_ALLOW_WRITE=true +(the flag wins).` +} + +// toolSummaryLines renders one line per registered tool, listing only the +// actions this session can perform. +func toolSummaryLines(srv *mcpcore.Server) []string { + type entry struct { + name string + summary string + } + + entries := []entry{ + {srv.ProjectsToolName(), "List or switch the active project (actions: list, use)"}, + {srv.LoginToolName(), "Sign in, or reauth: true for a fresh browser session when listing projects fails"}, + } + + for _, spec := range resourceSpecs() { + 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(), ", "), + }) + } + entries = append(entries, entry{srv.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 +} - text := fmt.Sprintf(`Hookdeck MCP Server — Available Tools +func helpOverview(srv *mcpcore.Server, client *hookdeck.Client) *mcpsdk.CallToolResult { + var tools strings.Builder + for _, line := range toolSummaryLines(srv) { + tools.WriteString(line) + tools.WriteString("\n") + } + + text := fmt.Sprintf(`Hookdeck Event Gateway MCP Server — Available Tools Current project: %s %s -All tools operate on the active project. Call hookdeck_projects first when the user -references a project by name, or when unsure which project is active. - -hookdeck_projects — List or switch projects (actions: list, use) -hookdeck_login — Sign in or reauth: true for a fresh browser session when listing projects fails -hookdeck_connections — Inspect connections and control delivery flow (actions: list, get, pause, unpause) -hookdeck_sources — Inspect inbound sources (actions: list, get) -hookdeck_destinations — Inspect delivery destinations: HTTP, CLI, MOCK (actions: list, get) -hookdeck_transformations — Inspect JavaScript transformations (actions: list, get) -hookdeck_requests — Query inbound requests (actions: list, get, raw_body, events, ignored_events) -hookdeck_events — Query processed events (actions: list, get, raw_body) -hookdeck_attempts — Query delivery attempts (actions: list, get) -hookdeck_issues — Inspect aggregated failure signals (actions: list, get) -hookdeck_metrics — Query aggregate metrics (actions: events, requests, attempts, transformations) -hookdeck_help — This help text - -Use hookdeck_help with topic="" for detailed help on a specific tool; each topic -repeats the common JSON response shape above for convenience.`, projectInfo, mcpJSONSuccessResponseHelp) +%s + +All tools operate on the active project. Call %s first when the user references a project by +name, or when unsure which project is active. + +%s +Use %s with topic="" for detailed help on a specific tool; each topic +repeats the common JSON response shape above for convenience.`, + formatCurrentProject(client), + modeHelp(srv), + mcpJSONSuccessResponseHelp, + srv.ProjectsToolName(), + tools.String(), + srv.HelpToolName(), + ) return mcpcore.TextResult(text) } -var toolHelp = map[string]string{ - "hookdeck_projects": `hookdeck_projects — List or switch the active project +// 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. List available projects to find the matching project ID, then use the "use" action to switch to it before @@ -105,229 +168,46 @@ scoped to the active project — if the wrong project is active, all results wil Also use this when unsure which project is currently active. Actions: - list — List all projects. data.projects is the array (id, org, project, type gateway/outpost/console, current). meta includes active_project_id, active_project_name (short), and active_project_org when known. Outbound projects are excluded. + list — List all projects. data.projects is the array (id, org, project, type gateway/outpost/console, current). meta includes active_project_id, active_project_name (short), and active_project_org when known. use — Switch the active project for this session (in-memory only). -If list or use fails with 401/403 (or similar), the error may mention hookdeck_login with reauth: true — the stored key may be a narrow dashboard API key. +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. Parameters: action (string, required) — "list" or "use" - project_id (string) — Required for "use" action`, + project_id (string) — Required for "use"`, - "hookdeck_login": `hookdeck_login — Browser sign-in for the Hookdeck CLI inside MCP + 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. Parameters: - reauth (boolean, optional) — 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 teams.`, - - "hookdeck_connections": `hookdeck_connections — Inspect connections and control delivery flow - -Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. - -Actions: - list — List connections with optional filters - get — Get a single connection by ID or name - pause — Pause a connection (stops event delivery) - unpause — Resume a paused connection - -Parameters: - action (string, required) — list, get, pause, or unpause - id (string) — Connection ID or name (required for get/pause/unpause) - name (string) — Filter by name (list) - source_id (string) — Filter by source (list) - destination_id (string) — Filter by destination (list) - disabled (boolean) — Filter disabled connections (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, + 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 teams.`, - "hookdeck_sources": `hookdeck_sources — Inspect inbound sources + srv.HelpToolName(): fmt.Sprintf(`%s — Overview of the Event Gateway tools, or detailed help for one -Actions: - list — List sources with optional filters - get — Get a single source by ID +The overview reports the current mode (read-only or write) and which actions are registered. -Parameters: - action (string, required) — list or get - id (string) — Required for get - name (string) — Filter by name (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, - - "hookdeck_destinations": `hookdeck_destinations — Inspect delivery destinations (types: HTTP, CLI, MOCK) - -Actions: - list — List destinations with optional filters - get — Get a single destination by ID - -Parameters: - action (string, required) — list or get - id (string) — Required for get - name (string) — Filter by name (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, - - "hookdeck_transformations": `hookdeck_transformations — Inspect JavaScript transformations - -Actions: - list — List transformations with optional filters - get — Get a single transformation by ID +Note: all tools operate on the active project — use %s to verify or switch project +context before querying. Parameters: - action (string, required) — list or get - id (string) — Required for get - name (string) — Filter by name (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, - - "hookdeck_requests": `hookdeck_requests — Query inbound requests - -Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. - -List supports the same filters as hookdeck gateway request list. - -Actions: - list — List requests with optional filters - get — Get a single request by ID - raw_body — Get the raw body of a request - events — List events generated from a request - ignored_events — List ignored events for a request - -Parameters: - action (string, required) — list, get, raw_body, events, or ignored_events - id (string) — List: filter by request ID(s), comma-separated. Get/raw_body/events/ignored_events: required. - source_id (string) — Filter by source (list) - status (string) — accepted or rejected (list) - rejection_cause (string) — Filter by rejection cause (list) - verified (boolean) — Filter by verification status (list) - -Date range filters (list): - Use *_after / *_before with ISO 8601 datetimes (e.g. 2026-06-01T00:00:00Z). Do not pass API bracket keys like created_at[gte] in MCP args. - created_after → created_at[gte] (inclusive lower bound) - created_before → created_at[lte] (inclusive upper bound) - ingested_after → ingested_at[gte] - ingested_before → ingested_at[lte] - Example: {"action":"list","ingested_after":"2026-06-09T12:00:00Z","source_id":"src_abc"} - -Payload search (list): - body, headers, parsed_query — Hookdeck JSON filter syntax (object or string). Same as hookdeck listen --filter-body. - path — partial URL path match (string) - Example: {"action":"list","body":{"type":"charge.succeeded"}} - -Pagination and sort (list): - order_by, dir (asc/desc), limit (default 100), next, prev`, - - "hookdeck_events": `hookdeck_events — Query events (processed deliveries) - -Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. - -List supports the same filters as hookdeck gateway event list. - -Actions: - list — List events with optional filters - get — Get a single event by ID (metadata and headers only; no payload) - raw_body — Get the event payload (body) directly by event ID. Use this when you need the payload; no need to call hookdeck_requests. - -Parameters: - action (string, required) — list, get, or raw_body - id (string) — List: filter by event ID(s), comma-separated. Get/raw_body: required. - connection_id (string) — Filter by connection (list, maps to webhook_id) - source_id (string) — Filter by source (list) - destination_id (string) — Filter by destination (list) - status (string) — SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED - attempts (string) — Filter by attempt count (list); integer or API operator syntax - issue_id (string) — Filter by issue (list) - error_code (string) — Filter by error code (list) - response_status (string) — Filter by HTTP response status (list) - cli_id (string) — Filter by CLI listen session (list) - -Date range filters (list): - Use *_after / *_before with ISO 8601 datetimes. Do not pass API bracket keys in MCP args. - created_after → created_at[gte] - created_before → created_at[lte] - successful_after → successful_at[gte] - successful_before → successful_at[lte] - last_attempt_after → last_attempt_at[gte] - last_attempt_before → last_attempt_at[lte] - Example: {"action":"list","status":"FAILED","last_attempt_after":"2026-06-08T00:00:00Z"} - -Payload search (list): - body, headers, parsed_query — Hookdeck JSON filter syntax (object or string) - path — partial URL path match - Example: {"action":"list","body":{"type":"charge.succeeded"}} - -Pagination and sort (list): - order_by, dir (asc/desc), limit (default 100), next, prev`, - - "hookdeck_attempts": `hookdeck_attempts — Query delivery attempts - -Actions: - list — List attempts (typically filtered by event_id) - get — Get a single attempt by ID - -Parameters: - action (string, required) — list or get - id (string) — Required for get - event_id (string) — Filter by event (list) - limit (integer) — Max results (list, default 100) - order_by (string) — Sort field (list) - dir (string) — "asc" or "desc" (list) - next/prev (string) — Pagination cursors (list)`, - - "hookdeck_issues": `hookdeck_issues — Inspect aggregated failure signals - -Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. - -Actions: - list — List issues with optional filters - get — Get a single issue by ID - -Parameters: - action (string, required) — list or get - id (string) — Required for get - type (string) — Filter: delivery, transformation, backpressure (list) - filter_status (string) — Filter by status (list) - issue_trigger_id (string) — Filter by trigger (list) - order_by (string) — Sort: created_at, first_seen_at, last_seen_at, opened_at, status (list) - dir (string) — "asc" or "desc" (list) - limit (integer) — Max results (list, default 100) - next/prev (string) — Pagination cursors (list)`, - - "hookdeck_metrics": `hookdeck_metrics — Query aggregate metrics - -Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. - -Actions: - events — Event metrics (auto-routes to queue-depth, pending, or by-issue as needed) - requests — Request metrics - attempts — Attempt metrics - transformations — Transformation metrics - -Parameters: - action (string, required) — events, requests, attempts, or transformations - start (string, required) — ISO 8601 datetime - end (string, required) — ISO 8601 datetime - granularity (string) — e.g. "1h", "5m", "1d" - measures (string[], required) — Metrics to retrieve. Common: count, successful_count, failed_count, error_count - dimensions (string[]) — Grouping dimensions (varies by action) - source_id (string) — Filter by source - destination_id (string) — Filter by destination - connection_id (string) — Filter by connection (maps to webhook_id) - status (string) — Filter by status - issue_id (string) — Filter by issue (events only)`, - - "hookdeck_help": `hookdeck_help — Get an overview of available tools or detailed help for a specific tool - -Note: all tools operate on the active project — use hookdeck_projects to verify or switch -project context before querying. + topic (string) — Tool name for detailed help (e.g. "%s"). Omit for the overview.`, + srv.HelpToolName(), srv.ProjectsToolName(), srv.ToolName("events")), + } -Parameters: - topic (string) — Tool name for detailed help (e.g. "hookdeck_events"). Omit for overview.`, -} + for _, spec := range resourceSpecs() { + available := spec.Actions.Available(srv.WriteEnabled()) + if len(available) == 0 { + continue + } + topics[srv.ToolName(spec.Resource)] = spec.Help(srv, available) + } -// helpTopic resolves a topic name, accepting both the "hookdeck_events" and -// "events" forms. -func helpTopic(topic string) *mcpsdk.CallToolResult { - return mcpcore.HelpTopic(helpTopicPrefix, toolHelp, topic, mcpJSONSuccessResponseHelp) + return topics } diff --git a/pkg/gateway/mcp/tool_issues.go b/pkg/gateway/mcp/tool_issues.go index 9417b12c..975ea91d 100644 --- a/pkg/gateway/mcp/tool_issues.go +++ b/pkg/gateway/mcp/tool_issues.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,36 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleIssues(client *hookdeck.Client) mcpsdk.ToolHandler { +var issuesActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list issues"}, + {Name: "get", Desc: "get one issue"}, + {Name: "update", Desc: "set an issue's status", Write: true}, + {Name: "dismiss", Desc: "dismiss an issue, closing it without resolving the cause", Write: true, Destructive: true}, +} + +var issuesSpec = mcpcore.ToolSpec{ + Resource: "issues", + Summary: "Inspect and triage 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 the projects tool first if the user has specified a project.", + Actions: issuesActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Issue ID. Required for get/update/dismiss."}, + "status": {Type: "string", Desc: "New status for update: OPENED, IGNORED, ACKNOWLEDGED or RESOLVED", Enum: []string{"OPENED", "IGNORED", "ACKNOWLEDGED", "RESOLVED"}}, + "type": {Type: "string", Desc: "Filter: delivery, transformation, or backpressure (list)"}, + "filter_status": {Type: "string", Desc: "Filter by status (list)"}, + "issue_trigger_id": {Type: "string", Desc: "Filter by trigger (list)"}, + "order_by": {Type: "string", Desc: "Sort field (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + Handler: handleIssues, +} + +func handleIssues(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,18 +47,54 @@ func handleIssues(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, issuesActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return issuesList(ctx, client, in) case "get": return issuesGet(ctx, client, in) + case "update": + return issuesUpdate(ctx, client, in) default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return issuesDismiss(ctx, client, in) } } } +func issuesUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + status, err := mcpcore.RequireString(in, "status", "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + issue, err := client.UpdateIssue(ctx, id, &hookdeck.IssueUpdateRequest{ + Status: hookdeck.IssueStatus(status), + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(issue, client) +} + +func issuesDismiss(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "dismiss") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + issue, err := client.DismissIssue(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(issue, client) +} + func issuesList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "type", in.String("type")) diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index dc45363c..d6906321 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -10,9 +10,39 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { +// metrics is read-only: every action is an aggregate query. The action here +// names the metric family rather than a verb. +var metricsActions = mcpcore.ActionSet{ + {Name: "events", Desc: "aggregated event metrics"}, + {Name: "requests", Desc: "aggregated inbound request metrics"}, + {Name: "attempts", Desc: "aggregated delivery attempt metrics"}, + {Name: "transformations", Desc: "aggregated transformation execution metrics"}, +} + +var metricsSpec = mcpcore.ToolSpec{ + Resource: "metrics", + Summary: "Query aggregate metrics over a time range: counts, failure rates, error rates, queue depth and pending event data. Supports grouping by dimensions such as source, destination or connection. Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: metricsActions, + Props: map[string]mcpcore.Prop{ + "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: &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)"}, + "status": {Type: "string", Desc: "Filter by status"}, + "issue_id": {Type: "string", Desc: "Filter by issue (events only)"}, + }, + Required: []string{"start", "end", "measures"}, + 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 := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,7 +51,11 @@ func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.Dispatch(srv, metricsActions, in.String("action")) + if blocked != nil { + return blocked, nil + } + switch action { case "events": return metricsEvents(ctx, client, in) @@ -29,10 +63,8 @@ func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { return metricsRequests(ctx, client, in) case "attempts": return metricsAttempts(ctx, client, in) - case "transformations": - return metricsTransformations(ctx, client, in) default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected events, requests, attempts, or transformations", action)), nil + return metricsTransformations(ctx, client, in) } } } diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 6702d069..85269788 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -12,9 +11,64 @@ import ( const maxRawBodyBytes = 100 * 1024 // 100 KB -func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { +var requestsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list inbound requests"}, + {Name: "get", Desc: "get one request"}, + {Name: "raw_body", Desc: "get one request's raw body"}, + {Name: "events", Desc: "list the events a request produced"}, + {Name: "ignored_events", Desc: "list the events a request produced that were filtered out"}, + {Name: "retry", Desc: "route a request through its connections again, creating new events", Write: true}, +} + +var requestsSpec = mcpcore.ToolSpec{ + Resource: "requests", + Summary: "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). Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: requestsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events/retry"}, + "connection_ids": {Type: "array", Desc: "Connections to re-route the request through (retry). Omit to retry every connection the request matched.", Items: &mcpcore.Prop{Type: "string"}}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, + "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, + "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, + "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + Notes: `Date range filters (list): + Use *_after / *_before with ISO 8601 datetimes (e.g. 2026-06-01T00:00:00Z). Do not pass API bracket keys like created_at[gte] in MCP args. + created_after → created_at[gte] (inclusive lower bound) + created_before → created_at[lte] (inclusive upper bound) + ingested_after → ingested_at[gte] + ingested_before → ingested_at[lte] + Example: {"action":"list","ingested_after":"2026-06-09T12:00:00Z","source_id":"src_abc"} + +Payload search (list): + body, headers, parsed_query — Hookdeck JSON filter syntax (object or string). Same as hookdeck listen --filter-body. + path — partial URL path match (string) + Example: {"action":"list","body":{"type":"charge.succeeded"}} + +Retrying (write mode): + retry re-routes the stored request through its connections, creating new events. It does not + modify the original request. Omit connection_ids to retry every connection the request matched. + Example: {"action":"retry","id":"req_abc","connection_ids":["web_123"]}`, + Handler: handleRequests, +} + +func handleRequests(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -23,9 +77,13 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, requestsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return requestsList(ctx, client, in) case "get": return requestsGet(ctx, client, in) @@ -36,11 +94,31 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { case "ignored_events": return requestsIgnoredEvents(ctx, client, in) default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list, get, raw_body, events, or ignored_events", action)), nil + return requestsRetry(ctx, client, in) } } } +func requestsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "retry") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + // An empty body retries every connection the request matched, which is what + // the API does when webhook_ids is omitted. + var body *hookdeck.RequestRetryRequest + if ids := mcpcore.StringList(in, "connection_ids"); len(ids) > 0 { + body = &hookdeck.RequestRetryRequest{WebhookIDs: ids} + } + if err := client.RetryRequest(ctx, id, body); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "request_id": id, + "status": "retried", + }, client) +} + func requestsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "id", in.String("id")) diff --git a/pkg/gateway/mcp/tool_sources.go b/pkg/gateway/mcp/tool_sources.go index 8a257679..c2d96fd7 100644 --- a/pkg/gateway/mcp/tool_sources.go +++ b/pkg/gateway/mcp/tool_sources.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,38 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleSources(client *hookdeck.Client) mcpsdk.ToolHandler { +var sourcesActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list sources"}, + {Name: "get", Desc: "get one source"}, + {Name: "create", Desc: "create a source", Write: true}, + {Name: "upsert", Desc: "create a source or update the existing one with the same name", Write: true}, + {Name: "update", Desc: "update a source", Write: true}, + {Name: "delete", Desc: "delete a source", Write: true, Destructive: true}, + {Name: "enable", Desc: "enable a disabled source", Write: true}, + {Name: "disable", Desc: "disable a source so it stops accepting requests", Write: true}, +} + +var sourcesSpec = mcpcore.ToolSpec{ + Resource: "sources", + Summary: "Inspect and manage inbound sources (HTTP endpoints that receive events). Source configuration covers the URL, verification settings, and allowed HTTP methods.", + Actions: sourcesActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Source ID. Required for get/update/delete/enable/disable."}, + "name": {Type: "string", Desc: "Source name. Filters on list; required on create/upsert."}, + "type": {Type: "string", Desc: "Source type, e.g. STRIPE, GITHUB, HTTP (create/upsert/update)"}, + "description": {Type: "string", Desc: "Source description (create/upsert/update)"}, + "config": {Type: "object", Desc: "Type-specific configuration, including verification settings (create/upsert/update). Replaces the stored config."}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + Handler: handleSources, +} + +func handleSources(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,18 +49,110 @@ func handleSources(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, sourcesActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return sourcesList(ctx, client, in) case "get": return sourcesGet(ctx, client, in) + case "create", "upsert": + return sourcesWrite(ctx, client, in, action) + case "update": + return sourcesUpdate(ctx, client, in) + case "delete": + return sourcesDelete(ctx, client, in) + case "enable": + return sourcesSetEnabled(ctx, client, in, "enable") default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return sourcesSetEnabled(ctx, client, in, "disable") } } } +// sourcesWrite handles create and upsert, which share a request body. Both +// require a name: the API keys upsert on it. +func sourcesWrite(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + name, err := mcpcore.RequireString(in, "name", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + config, err := mcpcore.Object(in, "config") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + req := &hookdeck.SourceCreateRequest{ + Name: name, + Type: in.String("type"), + Description: mcpcore.OptionalStringPtr(in, "description"), + Config: config, + } + + call := client.CreateSource + if action == "upsert" { + call = client.UpsertSource + } + source, err := call(ctx, req) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(source, client) +} + +func sourcesUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + config, err := mcpcore.Object(in, "config") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + source, err := client.UpdateSource(ctx, id, &hookdeck.SourceUpdateRequest{ + Name: in.String("name"), + Type: in.String("type"), + Description: mcpcore.OptionalStringPtr(in, "description"), + Config: config, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(source, client) +} + +func sourcesDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "delete") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := client.DeleteSource(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "source_id": id, + "status": "deleted", + }, client) +} + +func sourcesSetEnabled(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + call := client.EnableSource + if action == "disable" { + call = client.DisableSource + } + source, err := call(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(source, client) +} + func sourcesList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "name", in.String("name")) diff --git a/pkg/gateway/mcp/tool_transformations.go b/pkg/gateway/mcp/tool_transformations.go index 097d2fe3..82d3b5a3 100644 --- a/pkg/gateway/mcp/tool_transformations.go +++ b/pkg/gateway/mcp/tool_transformations.go @@ -2,7 +2,6 @@ package mcp import ( "context" - "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,9 +9,46 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -func handleTransformations(client *hookdeck.Client) mcpsdk.ToolHandler { +var transformationsActions = mcpcore.ActionSet{ + {Name: "list", Desc: "list transformations"}, + {Name: "get", Desc: "get one transformation, including its code"}, + {Name: "create", Desc: "create a transformation", Write: true}, + {Name: "upsert", Desc: "create a transformation or update the existing one with the same name", Write: true}, + {Name: "update", Desc: "update a transformation's code or environment", Write: true}, + {Name: "delete", Desc: "delete a transformation", Write: true, Destructive: true}, + + // run is marked Write even though it stores nothing. + // + // It executes caller-supplied JavaScript on Hookdeck's transformation + // runtime. Treating "changes no records" as "is a read" would let a + // read-only session run arbitrary code, which is not what a user asking for + // read-only is asking for. The gate is about what the session can cause to + // happen, not only about what it persists. + {Name: "run", Desc: "execute transformation code against a sample request and return the result", Write: true}, +} + +var transformationsSpec = mcpcore.ToolSpec{ + Resource: "transformations", + Summary: "Inspect and manage JavaScript transformations applied to event payloads, and try code out against a sample request before saving it.", + Actions: transformationsActions, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Transformation ID. Required for get/update/delete; optional on run to execute a stored transformation."}, + "name": {Type: "string", Desc: "Transformation name. Filters on list; required on create/upsert."}, + "code": {Type: "string", Desc: "JavaScript source (create/upsert/update, or run to execute unsaved code)"}, + "env": {Type: "object", Desc: "Environment variables as a JSON object of string values (create/upsert/update/run)"}, + "connection_id": {Type: "string", Desc: "Connection to run against (run, maps to webhook_id)"}, + "request": {Type: "object", Desc: "Sample request for run: { headers, body, path, query, parsed_query }. headers is required by the API and may be an empty object."}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "next": {Type: "string", Desc: "Next page cursor"}, + "prev": {Type: "string", Desc: "Previous page cursor"}, + }, + Handler: handleTransformations, +} + +func handleTransformations(srv *mcpcore.Server) mcpsdk.ToolHandler { + client := srv.Client() return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { - if r := mcpcore.RequireAuth(client, loginToolName); r != nil { + if r := srv.RequireAuth(); r != nil { return r, nil } @@ -21,18 +57,148 @@ func handleTransformations(client *hookdeck.Client) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action := in.String("action") + action, blocked := mcpcore.DispatchWithDefault(srv, transformationsActions, in.String("action"), "list") + if blocked != nil { + return blocked, nil + } + switch action { - case "list", "": + case "list": return transformationsList(ctx, client, in) case "get": return transformationsGet(ctx, client, in) + case "create", "upsert": + return transformationsWrite(ctx, client, in, action) + case "update": + return transformationsUpdate(ctx, client, in) + case "delete": + return transformationsDelete(ctx, client, in) default: - return mcpcore.ErrorResult(fmt.Sprintf("unknown action %q; expected list or get", action)), nil + return transformationsRun(ctx, client, in) } } } +// transformationsWrite handles create and upsert, which share a request body. +func transformationsWrite(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, action string) (*mcpsdk.CallToolResult, error) { + name, err := mcpcore.RequireString(in, "name", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + code, err := mcpcore.RequireString(in, "code", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + env, err := mcpcore.StringMap(in, "env") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + call := client.CreateTransformation + if action == "upsert" { + call = client.UpsertTransformation + } + t, err := call(ctx, &hookdeck.TransformationCreateRequest{Name: name, Code: code, Env: env}) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(t, client) +} + +func transformationsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "update") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + env, err := mcpcore.StringMap(in, "env") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + t, err := client.UpdateTransformation(ctx, id, &hookdeck.TransformationUpdateRequest{ + Name: in.String("name"), + Code: in.String("code"), + Env: env, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(t, client) +} + +func transformationsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "delete") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := client.DeleteTransformation(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "transformation_id": id, + "status": "deleted", + }, client) +} + +func transformationsRun(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + code := in.String("code") + id := in.String("id") + if code == "" && id == "" { + return mcpcore.ErrorResult("code or id is required for the run action: supply code to try unsaved source, or id to run a stored transformation"), nil + } + env, err := mcpcore.StringMap(in, "env") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + request, err := transformationRunInput(in) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + + result, err := client.RunTransformation(ctx, &hookdeck.TransformationRunRequest{ + Code: code, + TransformationID: id, + WebhookID: in.String("connection_id"), + Env: env, + Request: request, + }) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +// transformationRunInput builds the sample request the run action executes +// against. The API requires a headers object, so an omitted request still +// produces one rather than being sent as null. +func transformationRunInput(in mcpcore.Input) (*hookdeck.TransformationRunRequestInput, error) { + raw, err := mcpcore.Object(in, "request") + if err != nil { + return nil, err + } + out := &hookdeck.TransformationRunRequestInput{Headers: map[string]string{}} + if raw == nil { + return out, nil + } + + nested := mcpcore.Input(raw) + headers, err := mcpcore.StringMap(nested, "headers") + if err != nil { + return nil, err + } + if headers != nil { + out.Headers = headers + } + parsedQuery, err := mcpcore.Object(nested, "parsed_query") + if err != nil { + return nil, err + } + out.Body = raw["body"] + out.Path = nested.String("path") + out.Query = nested.String("query") + out.ParsedQuery = parsedQuery + return out, nil +} + func transformationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "name", in.String("name")) diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index af721a16..f21b8b66 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -8,16 +8,33 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -// Tool names. The gateway server namespaces its tools with "hookdeck_". +// Tool names. The Event Gateway server namespaces its product tools with +// "gateway_" so it can be configured alongside the Outpost server without +// colliding, and so a tool name says which product it acts on. +// +// The platform tools (hookdeck_login, hookdeck_projects) deliberately do not +// take this prefix — see mcpcore.DefaultPlatformPrefix. const ( - toolPrefix = "hookdeck" - loginToolName = toolPrefix + "_login" + toolPrefix = "gateway" 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)." + 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 gateway_help (overview or any tool topic)." ) +// ServerOptions configure the Event Gateway MCP server. +type ServerOptions struct { + // Client is the Hookdeck API client shared by every tool handler. Handlers + // mutate it in place (the projects and login tools set ProjectID). + Client *hookdeck.Client + + // Config is the CLI configuration, used by the login tool. + Config *config.Config + + // WriteEnabled turns on the actions that create, change or delete data. + WriteEnabled bool +} + // NewServer creates an MCP server exposing the Event Gateway tools. // // The supplied client is shared across all tool handlers; changing its @@ -26,217 +43,60 @@ const ( // // 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 { +func NewServer(opts ServerOptions) *mcpcore.Server { return mcpcore.NewServer(mcpcore.Options{ - Name: "hookdeck-gateway", - ToolPrefix: toolPrefix, - Client: client, - Config: cfg, - ToolDefs: toolDefs, + Name: "hookdeck-gateway", + ToolPrefix: toolPrefix, + Client: opts.Client, + Config: opts.Config, + WriteEnabled: opts.WriteEnabled, + 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. +// resourceSpecs lists every product tool the Event Gateway server exposes. +// Registration order is the order tools are advertised in. +func resourceSpecs() []mcpcore.ToolSpec { + return []mcpcore.ToolSpec{ + connectionsSpec, + sourcesSpec, + destinationsSpec, + transformationsSpec, + requestsSpec, + eventsSpec, + attemptsSpec, + issuesSpec, + metricsSpec, + } +} + +// toolDefs builds the tool definitions for the current write mode. Each spec +// renders its own schema, so read-only sessions never advertise a write action. func toolDefs(srv *mcpcore.Server) []mcpcore.ToolDef { - client := srv.Client() - return []mcpcore.ToolDef{ - srv.ProjectsToolDef(projectsToolDesc), - { - 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: 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)"}, - "source_id": {Type: "string", Desc: "Filter by source ID (list)"}, - "destination_id": {Type: "string", Desc: "Filter by destination ID (list)"}, - "disabled": {Type: "boolean", Desc: "Filter disabled connections (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleConnections(client), - }, - { - 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: 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)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleSources(client), - }, - { - 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: 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)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleDestinations(client), - }, - { - 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: 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)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleTransformations(client), - }, - { - 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: 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)"}, - "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, - "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, - "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, - "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, - "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, - "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, - "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, - "path": {Type: "string", Desc: descPathFilter}, - "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleRequests(client), - }, - { - 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: 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"}, - }, "action"), - }, - Handler: handleEvents(client), - }, - { - 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: 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)"}, - "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: handleAttempts(client), - }, - { - 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: 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)"}, - "filter_status": {Type: "string", Desc: "Filter by status (list)"}, - "issue_trigger_id": {Type: "string", Desc: "Filter by trigger (list)"}, - "order_by": {Type: "string", Desc: "Sort field (list)"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), - }, - Handler: handleIssues(client), - }, - { - 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: 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: &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)"}, - "status": {Type: "string", Desc: "Filter by status"}, - "issue_id": {Type: "string", Desc: "Filter by issue (events only)"}, - }, "action", "start", "end", "measures"), - }, - Handler: handleMetrics(client), - }, - { + defs := []mcpcore.ToolDef{srv.ProjectsToolDef(projectsToolDesc)} + + for _, spec := range resourceSpecs() { + if def, ok := spec.Define(srv); ok { + defs = append(defs, def) + } + } + + defs = append(defs, + mcpcore.ToolDef{ 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.", + Name: srv.HelpToolName(), + Description: "Get an overview of all available Event Gateway 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). Note: all tools operate on the active project — use `hookdeck_projects` to verify or switch project context before querying.", InputSchema: mcpcore.Schema(map[string]mcpcore.Prop{ - "topic": {Type: "string", Desc: "Tool name for detailed help (e.g. hookdeck_events). Omit for overview."}, + "topic": {Type: "string", Desc: "Tool name for detailed help (e.g. gateway_events). Omit for overview."}, }), + Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true}, }, - Handler: handleHelp(client), + Handler: handleHelp(srv), }, srv.LoginToolDef(loginToolDesc), - } + ) + + return defs } const ( diff --git a/pkg/hookdeck/client_telemetry_test.go b/pkg/hookdeck/client_telemetry_test.go index e2d96ff1..151a8e9c 100644 --- a/pkg/hookdeck/client_telemetry_test.go +++ b/pkg/hookdeck/client_telemetry_test.go @@ -27,7 +27,7 @@ func TestWithTelemetry(t *testing.T) { tel := &CLITelemetry{ Source: "mcp", Environment: "interactive", - CommandPath: "hookdeck_events/list", + CommandPath: "gateway_events/list", InvocationID: "inv_test123", DeviceName: "test-machine", MCPClient: "test-client/1.0", @@ -69,7 +69,7 @@ func TestPerformRequestUsesTelemetryOverride(t *testing.T) { tel := &CLITelemetry{ Source: "mcp", Environment: "ci", - CommandPath: "hookdeck_events/list", + CommandPath: "gateway_events/list", InvocationID: "inv_abcdef0123456789", DeviceName: "test-device", MCPClient: "claude-desktop/1.0", @@ -91,7 +91,7 @@ func TestPerformRequestUsesTelemetryOverride(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(receivedHeader), &parsed)) require.Equal(t, "mcp", parsed.Source) require.Equal(t, "ci", parsed.Environment) - require.Equal(t, "hookdeck_events/list", parsed.CommandPath) + require.Equal(t, "gateway_events/list", parsed.CommandPath) require.Equal(t, "inv_abcdef0123456789", parsed.InvocationID) require.Equal(t, "claude-desktop/1.0", parsed.MCPClient) } diff --git a/pkg/hookdeck/telemetry_test.go b/pkg/hookdeck/telemetry_test.go index f8c66776..4835ca2e 100644 --- a/pkg/hookdeck/telemetry_test.go +++ b/pkg/hookdeck/telemetry_test.go @@ -134,7 +134,7 @@ func TestTelemetryJSONSerialization(t *testing.T) { mcpTel := &CLITelemetry{ Source: "mcp", Environment: "interactive", - CommandPath: "hookdeck_events/list", + CommandPath: "gateway_events/list", InvocationID: "inv_1234567890abcdef", DeviceName: "macbook-pro", MCPClient: "claude-desktop/1.2.0", @@ -146,7 +146,7 @@ func TestTelemetryJSONSerialization(t *testing.T) { var parsedMCP map[string]interface{} require.NoError(t, json.Unmarshal(b, &parsedMCP)) require.Equal(t, "mcp", parsedMCP["source"]) - require.Equal(t, "hookdeck_events/list", parsedMCP["command_path"]) + require.Equal(t, "gateway_events/list", parsedMCP["command_path"]) require.Equal(t, "claude-desktop/1.2.0", parsedMCP["mcp_client"]) } diff --git a/pkg/mcpcore/help.go b/pkg/mcpcore/help.go index 63cb727d..ff92a369 100644 --- a/pkg/mcpcore/help.go +++ b/pkg/mcpcore/help.go @@ -12,17 +12,29 @@ import ( // 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. +// qualified name ("gateway_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. + // + // A bare resource ("events", "projects") is qualified by trying the + // product prefix first and the platform prefix second — otherwise "projects" + // would resolve on a server whose product prefix happens to be "hookdeck" + // and fail on every other one. text, ok := topics[topic] - if !ok && prefix != "" && !strings.HasPrefix(topic, prefix) { - topic = prefix + topic - text, ok = topics[topic] + if !ok { + for _, p := range []string{prefix, DefaultPlatformPrefix + "_"} { + if p == "" || strings.HasPrefix(topic, p) { + continue + } + if text, ok = topics[p+topic]; ok { + topic = p + topic + break + } + } } if ok { if suffix != "" { diff --git a/pkg/mcpcore/input.go b/pkg/mcpcore/input.go index 9f359f26..4475a95d 100644 --- a/pkg/mcpcore/input.go +++ b/pkg/mcpcore/input.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" ) // Input is a thin wrapper around the raw JSON arguments from an MCP tool call. @@ -154,3 +155,82 @@ func SetPayloadSearchFilters(params map[string]string, in Input) error { } return nil } + +// 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 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 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 or transformation environment variables. +func StringMap(in 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 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 +} + +// OptionalStringPtr returns a pointer to the value for key, or nil when the key +// is absent. Update requests use this to distinguish "not supplied" from +// "set to empty". +func OptionalStringPtr(in Input, key string) *string { + v, ok := in[key] + if !ok || v == nil { + return nil + } + s, ok := v.(string) + if !ok { + return nil + } + return &s +} diff --git a/pkg/mcpcore/server_test.go b/pkg/mcpcore/server_test.go index c73160e7..169bc92e 100644 --- a/pkg/mcpcore/server_test.go +++ b/pkg/mcpcore/server_test.go @@ -57,7 +57,7 @@ func TestWrapWithTelemetrySetsAndClears(t *testing.T) { 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.Equal(t, "gateway_events/list", s.client.Telemetry.CommandPath) require.NotEmpty(t, s.client.Telemetry.InvocationID) require.NotEmpty(t, s.client.Telemetry.DeviceName) // Capture a copy @@ -66,7 +66,7 @@ func TestWrapWithTelemetrySetsAndClears(t *testing.T) { return &mcpsdk.CallToolResult{}, nil }) - wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) + wrapped := s.wrapWithTelemetry("gateway_events", innerHandler) req := newCallToolRequest(`{"action":"list"}`) result, err := wrapped(context.Background(), req) @@ -76,7 +76,7 @@ func TestWrapWithTelemetrySetsAndClears(t *testing.T) { // 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) + require.Equal(t, "gateway_events/list", capturedTelemetry.CommandPath) // After the wrapper returns, telemetry should be cleared on the shared client require.Nil(t, s.client.Telemetry) @@ -93,14 +93,14 @@ func TestWrapWithTelemetryNoAction(t *testing.T) { return &mcpsdk.CallToolResult{}, nil }) - wrapped := s.wrapWithTelemetry("hookdeck_help", innerHandler) + wrapped := s.wrapWithTelemetry("gateway_help", innerHandler) - req := newCallToolRequest(`{"topic":"hookdeck_events"}`) + req := newCallToolRequest(`{"topic":"gateway_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) + require.Equal(t, "gateway_help", capturedPath) } func TestWrapWithTelemetryUniqueInvocationIDs(t *testing.T) { @@ -114,7 +114,7 @@ func TestWrapWithTelemetryUniqueInvocationIDs(t *testing.T) { return &mcpsdk.CallToolResult{}, nil }) - wrapped := s.wrapWithTelemetry("hookdeck_events", innerHandler) + wrapped := s.wrapWithTelemetry("gateway_events", innerHandler) for i := 0; i < 5; i++ { req := newCallToolRequest(`{"action":"list"}`) diff --git a/pkg/mcpcore/toolspec.go b/pkg/mcpcore/toolspec.go index ee218707..ef3c5918 100644 --- a/pkg/mcpcore/toolspec.go +++ b/pkg/mcpcore/toolspec.go @@ -2,6 +2,7 @@ package mcpcore import ( "fmt" + "sort" "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -104,6 +105,68 @@ type ToolSpec struct { // exists for tools that shipped before "action" was mandatory in practice // and whose callers still send bare list requests. DefaultAction string + + // Notes is hand-written guidance appended to the generated help topic: + // worked examples, filter-syntax mappings, anything that cannot be derived + // from the actions and props. Optional. + Notes string +} + +// Help renders a tool's help topic from its definition, so help cannot drift +// from the schema the agent is actually given. available is the action set for +// the current mode. +func (spec ToolSpec) Help(srv *Server, 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 { + fmt.Fprintf(&b, "\nFurther actions exist but are unavailable in read-only mode. See %s for how to enable them.\n", srv.HelpToolName()) + } + + 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) + } + } + + if spec.Notes != "" { + b.WriteString("\n") + b.WriteString(spec.Notes) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") } // Define builds the tool definition for the current write mode. The bool is diff --git a/pkg/outpost/mcp/input.go b/pkg/outpost/mcp/input.go deleted file mode 100644 index 63410ce1..00000000 --- a/pkg/outpost/mcp/input.go +++ /dev/null @@ -1,72 +0,0 @@ -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/tool_attempts.go b/pkg/outpost/mcp/tool_attempts.go index bc4559f1..dec77167 100644 --- a/pkg/outpost/mcp/tool_attempts.go +++ b/pkg/outpost/mcp/tool_attempts.go @@ -72,21 +72,21 @@ func singleOrEmpty(values []string) string { } func attemptsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - tenantIDs := stringList(in, "tenant_id") - destinationIDs := stringList(in, "destination_id") + tenantIDs := mcpcore.StringList(in, "tenant_id") + destinationIDs := mcpcore.StringList(in, "destination_id") result, err := client.ListOutpostAttempts(ctx, hookdeck.OutpostAttemptListParams{ TenantID: singleOrEmpty(tenantIDs), DestinationID: singleOrEmpty(destinationIDs), TenantIDs: tenantIDs, - EventIDs: stringList(in, "event_id"), + EventIDs: mcpcore.StringList(in, "event_id"), DestinationIDs: destinationIDs, - DestinationType: stringList(in, "destination_type"), - Topics: stringList(in, "topic"), + DestinationType: mcpcore.StringList(in, "destination_type"), + Topics: mcpcore.StringList(in, "topic"), Status: in.String("status"), TimeAfter: in.String("time_after"), TimeBefore: in.String("time_before"), - Include: stringList(in, "include"), + Include: mcpcore.StringList(in, "include"), Limit: in.Int("limit", 0), OrderBy: in.String("order_by"), Dir: in.String("dir"), @@ -100,14 +100,14 @@ func attemptsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input } func attemptsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "get") + id, err := mcpcore.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"), + TenantID: singleOrEmpty(mcpcore.StringList(in, "tenant_id")), + DestinationID: singleOrEmpty(mcpcore.StringList(in, "destination_id")), + Include: mcpcore.StringList(in, "include"), }) if err != nil { return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil diff --git a/pkg/outpost/mcp/tool_catalog.go b/pkg/outpost/mcp/tool_catalog.go index abfb944f..05ed9869 100644 --- a/pkg/outpost/mcp/tool_catalog.go +++ b/pkg/outpost/mcp/tool_catalog.go @@ -79,7 +79,7 @@ func handleDestinationTypes(srv *mcpcore.Server) mcpsdk.ToolHandler { verbose := in.Bool("include_setup_docs") if action == "get" { - destinationType, err := requireString(in, "type", "get") + destinationType, err := mcpcore.RequireString(in, "type", "get") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } diff --git a/pkg/outpost/mcp/tool_config.go b/pkg/outpost/mcp/tool_config.go index 741f890e..3d191a56 100644 --- a/pkg/outpost/mcp/tool_config.go +++ b/pkg/outpost/mcp/tool_config.go @@ -59,7 +59,7 @@ func handleConfig(srv *mcpcore.Server) mcpsdk.ToolHandler { } return mcpcore.JSONResultEnvelopeForClient(domain, client) case "custom_domain_set": - hostname, err := requireString(in, "hostname", "custom_domain_set") + hostname, err := mcpcore.RequireString(in, "hostname", "custom_domain_set") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -95,7 +95,7 @@ func configGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) ( func configSet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { update := hookdeck.OutpostManagedConfig{} - values, err := object(in, "values") + values, err := mcpcore.Object(in, "values") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -112,7 +112,7 @@ func configSet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) ( } } - for _, key := range stringList(in, "unset") { + for _, key := range mcpcore.StringList(in, "unset") { update[key] = nil } diff --git a/pkg/outpost/mcp/tool_destinations.go b/pkg/outpost/mcp/tool_destinations.go index 38d98416..dfc9d724 100644 --- a/pkg/outpost/mcp/tool_destinations.go +++ b/pkg/outpost/mcp/tool_destinations.go @@ -53,7 +53,7 @@ func handleDestinations(srv *mcpcore.Server) mcpsdk.ToolHandler { return blocked, nil } - tenantID, err := requireString(in, "tenant_id", action) + tenantID, err := mcpcore.RequireString(in, "tenant_id", action) if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -65,7 +65,7 @@ func handleDestinations(srv *mcpcore.Server) mcpsdk.ToolHandler { return destinationsCreate(ctx, client, in, tenantID) } - id, err := requireString(in, "id", action) + id, err := mcpcore.RequireString(in, "id", action) if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -104,7 +104,7 @@ func destinationResult(client *hookdeck.Client) func(*hookdeck.OutpostDestinatio } 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")) + destinations, err := client.ListOutpostDestinations(ctx, tenantID, mcpcore.StringList(in, "type"), mcpcore.StringList(in, "topics")) if err != nil { return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil } @@ -112,7 +112,7 @@ func destinationsList(ctx context.Context, client *hookdeck.Client, in mcpcore.I } func destinationsCreate(ctx context.Context, client *hookdeck.Client, in mcpcore.Input, tenantID string) (*mcpsdk.CallToolResult, error) { - destinationType, err := requireString(in, "type", "create") + destinationType, err := mcpcore.RequireString(in, "type", "create") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -122,7 +122,7 @@ func destinationsCreate(ctx context.Context, client *hookdeck.Client, in mcpcore } return destinationResult(client)(client.CreateOutpostDestination(ctx, tenantID, &hookdeck.OutpostDestinationCreateRequest{ Type: destinationType, - Topics: hookdeck.OutpostTopics(stringList(in, "topics")), + Topics: hookdeck.OutpostTopics(mcpcore.StringList(in, "topics")), Config: cfg, Credentials: credentials, Filter: filter, @@ -136,7 +136,7 @@ func destinationsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore return mcpcore.ErrorResult(err.Error()), nil } return destinationResult(client)(client.UpdateOutpostDestination(ctx, tenantID, id, &hookdeck.OutpostDestinationUpdateRequest{ - Topics: hookdeck.OutpostTopics(stringList(in, "topics")), + Topics: hookdeck.OutpostTopics(mcpcore.StringList(in, "topics")), Config: cfg, Credentials: credentials, Filter: filter, @@ -146,16 +146,16 @@ func destinationsUpdate(ctx context.Context, client *hookdeck.Client, in mcpcore // 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 { + if cfg, err = mcpcore.Object(in, "config"); err != nil { return nil, nil, nil, nil, err } - if credentials, err = object(in, "credentials"); err != nil { + if credentials, err = mcpcore.Object(in, "credentials"); err != nil { return nil, nil, nil, nil, err } - if filter, err = object(in, "filter"); err != nil { + if filter, err = mcpcore.Object(in, "filter"); err != nil { return nil, nil, nil, nil, err } - if metadata, err = stringMap(in, "metadata"); err != nil { + if metadata, err = mcpcore.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 index 4e7a4018..a4f303ba 100644 --- a/pkg/outpost/mcp/tool_events.go +++ b/pkg/outpost/mcp/tool_events.go @@ -64,10 +64,10 @@ func handleEvents(srv *mcpcore.Server) mcpsdk.ToolHandler { 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"), + IDs: mcpcore.StringList(in, "id"), + TenantIDs: mcpcore.StringList(in, "tenant_id"), + DestinationIDs: mcpcore.StringList(in, "destination_id"), + Topics: mcpcore.StringList(in, "topic"), TimeAfter: in.String("time_after"), TimeBefore: in.String("time_before"), Limit: in.Int("limit", 0), @@ -83,7 +83,7 @@ func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) } func eventsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "get") + id, err := mcpcore.RequireString(in, "id", "get") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -95,11 +95,11 @@ func eventsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) ( } func eventsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "retry") + id, err := mcpcore.RequireString(in, "id", "retry") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - destinationID, err := requireString(in, "destination_id", "retry") + destinationID, err := mcpcore.RequireString(in, "destination_id", "retry") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } diff --git a/pkg/outpost/mcp/tool_help.go b/pkg/outpost/mcp/tool_help.go index 9b2db99a..3f79287a 100644 --- a/pkg/outpost/mcp/tool_help.go +++ b/pkg/outpost/mcp/tool_help.go @@ -3,7 +3,6 @@ package mcp import ( "context" "fmt" - "sort" "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -222,58 +221,8 @@ Parameters: if len(available) == 0 { continue } - topics[srv.ToolName(spec.Resource)] = specHelp(srv, spec, available) + topics[srv.ToolName(spec.Resource)] = spec.Help(srv, 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 mcpcore.ToolSpec, available mcpcore.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 index 7f9adbdc..95137572 100644 --- a/pkg/outpost/mcp/tool_metrics.go +++ b/pkg/outpost/mcp/tool_metrics.go @@ -74,7 +74,7 @@ func metricsParams(in mcpcore.Input) (hookdeck.OutpostMetricsParams, error) { if start == "" || end == "" { return hookdeck.OutpostMetricsParams{}, fmt.Errorf("start and end are required (ISO 8601 datetimes)") } - measures := stringList(in, "measures") + measures := mcpcore.StringList(in, "measures") if len(measures) == 0 { return hookdeck.OutpostMetricsParams{}, fmt.Errorf(`measures is required, e.g. ["count"]`) } @@ -89,7 +89,7 @@ func metricsParams(in mcpcore.Input) (hookdeck.OutpostMetricsParams, error) { End: end, Granularity: in.String("granularity"), Measures: measures, - Dimensions: stringList(in, "dimensions"), + Dimensions: mcpcore.StringList(in, "dimensions"), Filters: filters, }, nil } @@ -97,7 +97,7 @@ func metricsParams(in mcpcore.Input) (hookdeck.OutpostMetricsParams, error) { // 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") + raw, err := mcpcore.Object(in, "filters") if err != nil { return nil, err } diff --git a/pkg/outpost/mcp/tool_publish.go b/pkg/outpost/mcp/tool_publish.go index 63b7eb88..23f89f5b 100644 --- a/pkg/outpost/mcp/tool_publish.go +++ b/pkg/outpost/mcp/tool_publish.go @@ -56,19 +56,19 @@ func handlePublish(srv *mcpcore.Server, apiKey string) mcpsdk.ToolHandler { return blocked, nil } - tenantID, err := requireString(in, "tenant_id", "publish") + tenantID, err := mcpcore.RequireString(in, "tenant_id", "publish") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - topic, err := requireString(in, "topic", "publish") + topic, err := mcpcore.RequireString(in, "topic", "publish") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - data, err := object(in, "data") + data, err := mcpcore.Object(in, "data") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - metadata, err := stringMap(in, "metadata") + metadata, err := mcpcore.StringMap(in, "metadata") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } diff --git a/pkg/outpost/mcp/tool_tenants.go b/pkg/outpost/mcp/tool_tenants.go index db484863..e87b547b 100644 --- a/pkg/outpost/mcp/tool_tenants.go +++ b/pkg/outpost/mcp/tool_tenants.go @@ -69,7 +69,7 @@ func handleTenants(srv *mcpcore.Server) mcpsdk.ToolHandler { 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"), + IDs: mcpcore.StringList(in, "id"), Limit: in.Int("limit", 0), Dir: in.String("dir"), Next: in.String("next"), @@ -82,7 +82,7 @@ func tenantsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) } func tenantsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "get") + id, err := mcpcore.RequireString(in, "id", "get") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -94,11 +94,11 @@ func tenantsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) } func tenantsUpsert(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "upsert") + id, err := mcpcore.RequireString(in, "id", "upsert") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } - metadata, err := stringMap(in, "metadata") + metadata, err := mcpcore.StringMap(in, "metadata") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -110,7 +110,7 @@ func tenantsUpsert(ctx context.Context, client *hookdeck.Client, in mcpcore.Inpu } func tenantsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "delete") + id, err := mcpcore.RequireString(in, "id", "delete") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -124,7 +124,7 @@ func tenantsDelete(ctx context.Context, client *hookdeck.Client, in mcpcore.Inpu } func tenantsToken(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "token") + id, err := mcpcore.RequireString(in, "id", "token") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } @@ -136,7 +136,7 @@ func tenantsToken(ctx context.Context, client *hookdeck.Client, in mcpcore.Input } func tenantsPortal(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := requireString(in, "id", "portal") + id, err := mcpcore.RequireString(in, "id", "portal") if err != nil { return mcpcore.ErrorResult(err.Error()), nil } diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index edf1aa8c..20dc34dd 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -102,7 +102,7 @@ func TestMCPEventsList_DateRangeAndBodyFilter(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } cli := NewCLIRunner(t) - result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "hookdeck_events", map[string]any{ + result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "gateway_events", map[string]any{ "action": "list", "created_after": "2020-01-01T00:00:00Z", "created_before": "2030-01-01T00:00:00Z", @@ -120,7 +120,7 @@ func TestMCPRequestsList_DateRangeAndBodyFilter(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } cli := NewCLIRunner(t) - result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "hookdeck_requests", map[string]any{ + result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, "gateway_requests", map[string]any{ "action": "list", "ingested_after": "2020-01-01T00:00:00Z", "created_before": "2030-01-01T00:00:00Z", From 3a2b4a5977b7735e1c34ccb9cf9fbbb043587d07 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:29:34 +0100 Subject: [PATCH 21/49] test(gateway mcp): cover the write-mode gate, and document it Unit coverage in pkg/gateway/mcp/write_mode_test.go, mirroring the Outpost suite: the action enum and tool description in each mode, the read-only and destructive annotations, the handler-level guard refusing every write action without --allow-write, and successful write calls asserted on the request the handler sends rather than on "did not error". Two tests exist specifically to hold the pause/unpause decision in place: pause and unpause stay in the read-only action enum, and calling them against a read-only server is not refused. If someone later gates them, these fail. Acceptance coverage under the existing mcp tag: tools/list omits the write actions without the flag and includes them with it, the renamed tools are advertised and the old hookdeck_ product names are not, and gateway_help reports the current mode. README documents read-only-by-default, --allow-write, the pause/unpause exception, and the full per-tool action table. Both tagged suites pass locally: -tags=mcp and -tags=outpost. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- README.md | 52 +++- pkg/gateway/mcp/write_mode_test.go | 381 +++++++++++++++++++++++++++++ test/acceptance/mcp_test.go | 116 +++++++++ 3 files changed, 536 insertions(+), 13 deletions(-) create mode 100644 pkg/gateway/mcp/write_mode_test.go diff --git a/README.md b/README.md index ea975826..117b7040 100644 --- a/README.md +++ b/README.md @@ -614,24 +614,50 @@ Claude Desktop (`claude_desktop_config.json`): The client starts `hookdeck gateway mcp` as a stdio subprocess. If you haven't authenticated yet, the `hookdeck_login` tool is available to log in via the browser. +#### Read-only by default + +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` (or set `HOOKDECK_MCP_ALLOW_WRITE=true`) to enable creating, changing and deleting: + +```json +{ + "mcpServers": { + "hookdeck": { + "command": "hookdeck", + "args": ["gateway", "mcp", "--allow-write"] + } + } +} +``` + +`--read-only` is accepted explicitly and wins if both are passed. + +Pausing and unpausing a connection are available in **both** modes. Read-only is the mode incidents get investigated in, and stopping a misbehaving connection is the natural end of an investigation; both are reversible, and pausing buffers delivery rather than dropping events. + #### Available tools -| Tool | Description | -|------|-------------| -| `hookdeck_projects` | List projects or switch the active project for this session | -| `gateway_connections` | Inspect connections and control delivery flow (list, get, pause, unpause) | -| `gateway_sources` | Inspect inbound sources (HTTP endpoints that receive events) | -| `gateway_destinations` | Inspect delivery destinations (HTTP endpoints where events are sent) | -| `gateway_transformations` | Inspect JavaScript transformations applied to event payloads | -| `gateway_requests` | Query inbound requests — list, get details, raw body, linked events | -| `gateway_events` | Query processed events — list, get details, raw payload body | -| `gateway_attempts` | Query delivery attempts — retry history, response codes, errors | -| `gateway_issues` | Inspect aggregated failure signals (delivery failures, transform errors, backpressure) | -| `gateway_metrics` | Query aggregate metrics — counts, failure rates, queue depth over time | -| `gateway_help` | Discover available tools and their actions | +Product tools are prefixed `gateway_`. Signing in and switching project are Hookdeck operations rather than Event Gateway ones, so they keep the platform `hookdeck_` prefix and are shared with `hookdeck outpost mcp`. + +| Tool | Read actions | Added by `--allow-write` | +|------|--------------|--------------------------| +| `hookdeck_projects` | list, use | — | +| `hookdeck_login` | (sign in) | — | +| `gateway_connections` | list, get, pause, unpause | create, upsert, update, delete, enable, disable | +| `gateway_sources` | list, get | create, upsert, update, delete, enable, disable | +| `gateway_destinations` | list, get | create, upsert, update, delete, enable, disable | +| `gateway_transformations` | list, get | create, upsert, update, delete, run | +| `gateway_requests` | list, get, raw_body, events, ignored_events | retry | +| `gateway_events` | list, get, raw_body | retry, cancel, mute | +| `gateway_attempts` | list, get | — | +| `gateway_issues` | list, get | update, dismiss | +| `gateway_metrics` | events, requests, attempts, transformations | — | +| `gateway_help` | overview, per-tool topics | — | + +`transformations run` executes code without storing anything, but it is gated as a write: a read-only session should not be able to run caller-supplied code. `gateway_events` and `gateway_requests` **list** actions support the same filters as `hookdeck gateway event list` and `hookdeck gateway request list` — including payload search (`body`, `headers`, `parsed_query`, `path`) and date windows via `*_after` / `*_before` (ISO 8601; maps to API `field[gte]` / `field[lte]`). See `gateway_help` with topic `gateway_events` or `gateway_requests` for the full parameter list. +`gateway_help` reports which mode the session is in and lists only the actions it can perform. + #### Example prompts Once the MCP server is configured, you can ask your agent questions like: diff --git a/pkg/gateway/mcp/write_mode_test.go b/pkg/gateway/mcp/write_mode_test.go new file mode 100644 index 00000000..4eeaed20 --- /dev/null +++ b/pkg/gateway/mcp/write_mode_test.go @@ -0,0 +1,381 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// listTools returns the advertised tools keyed by name. +func listTools(t *testing.T, session *mcpsdk.ClientSession) map[string]*mcpsdk.Tool { + t.Helper() + result, err := session.ListTools(t.Context(), 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 +} + +// actionEnum returns the action enum a tool advertises. +func actionEnum(t *testing.T, tool *mcpsdk.Tool) []string { + t.Helper() + require.NotNil(t, tool) + // 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 := connectInMemory(t, newTestClient(api.URL, "test-key")) + tools := listTools(t, session) + + t.Run("registers every tool", func(t *testing.T) { + for _, name := range []string{ + "hookdeck_projects", "hookdeck_login", "gateway_help", + "gateway_connections", "gateway_sources", "gateway_destinations", + "gateway_transformations", "gateway_requests", "gateway_events", + "gateway_attempts", "gateway_issues", "gateway_metrics", + } { + assert.Contains(t, tools, name) + } + }) + + t.Run("the old hookdeck_ product names are gone", func(t *testing.T) { + for _, name := range []string{ + "hookdeck_connections", "hookdeck_sources", "hookdeck_destinations", + "hookdeck_transformations", "hookdeck_requests", "hookdeck_events", + "hookdeck_attempts", "hookdeck_issues", "hookdeck_metrics", "hookdeck_help", + } { + assert.NotContains(t, tools, name) + } + }) + + t.Run("write actions are absent from the action enum", func(t *testing.T) { + assert.Equal(t, []string{"list", "get", "pause", "unpause"}, actionEnum(t, tools["gateway_connections"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_sources"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_destinations"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_transformations"])) + assert.Equal(t, []string{"list", "get", "raw_body"}, actionEnum(t, tools["gateway_events"])) + assert.Equal(t, []string{"list", "get", "raw_body", "events", "ignored_events"}, actionEnum(t, tools["gateway_requests"])) + assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_issues"])) + }) + + // This is the regression gate for the pause/unpause decision: they are + // mutations, and they stay offered in the mode people investigate in. + t.Run("pause and unpause remain available", func(t *testing.T) { + enum := actionEnum(t, tools["gateway_connections"]) + assert.Contains(t, enum, "pause") + assert.Contains(t, enum, "unpause") + }) + + t.Run("write actions are absent from the description", func(t *testing.T) { + for _, name := range []string{ + "gateway_connections", "gateway_sources", "gateway_destinations", + "gateway_transformations", "gateway_events", "gateway_requests", "gateway_issues", + } { + description := tools[name].Description + for _, action := range []string{"create", "upsert", "update", "delete", "retry", "cancel", "mute", "dismiss", "run"} { + assert.NotContains(t, description, " "+action+" (", "%s should not describe the %s action", name, action) + } + } + }) + + t.Run("read-only mode is stated in the description", func(t *testing.T) { + assert.Contains(t, tools["gateway_events"].Description, "read-only mode") + assert.Contains(t, tools["gateway_events"].Description, "gateway_help") + }) + + t.Run("tools are annotated read-only", func(t *testing.T) { + for _, name := range []string{ + "gateway_connections", "gateway_sources", "gateway_events", + "gateway_attempts", "gateway_metrics", + } { + 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 := connectInMemoryWriteEnabled(t, newTestClient(api.URL, "test-key")) + tools := listTools(t, session) + + t.Run("write actions appear in the enum", func(t *testing.T) { + assert.Equal(t, + []string{"list", "get", "pause", "unpause", "create", "upsert", "update", "delete", "enable", "disable"}, + actionEnum(t, tools["gateway_connections"])) + assert.Equal(t, + []string{"list", "get", "create", "upsert", "update", "delete", "enable", "disable"}, + actionEnum(t, tools["gateway_sources"])) + assert.Equal(t, + []string{"list", "get", "create", "upsert", "update", "delete", "enable", "disable"}, + actionEnum(t, tools["gateway_destinations"])) + assert.Equal(t, + []string{"list", "get", "create", "upsert", "update", "delete", "run"}, + actionEnum(t, tools["gateway_transformations"])) + assert.Equal(t, + []string{"list", "get", "raw_body", "retry", "cancel", "mute"}, + actionEnum(t, tools["gateway_events"])) + assert.Equal(t, + []string{"list", "get", "raw_body", "events", "ignored_events", "retry"}, + actionEnum(t, tools["gateway_requests"])) + assert.Equal(t, + []string{"list", "get", "update", "dismiss"}, + actionEnum(t, tools["gateway_issues"])) + }) + + t.Run("tools with writes are no longer annotated read-only", func(t *testing.T) { + assert.False(t, tools["gateway_connections"].Annotations.ReadOnlyHint) + assert.False(t, tools["gateway_events"].Annotations.ReadOnlyHint) + assert.True(t, tools["gateway_attempts"].Annotations.ReadOnlyHint, + "attempts has no write actions in any mode") + assert.True(t, tools["gateway_metrics"].Annotations.ReadOnlyHint, + "metrics has no write actions in any mode") + }) + + t.Run("destructive tools carry the destructive hint", func(t *testing.T) { + for _, name := range []string{ + "gateway_connections", "gateway_sources", "gateway_destinations", + "gateway_transformations", "gateway_events", "gateway_issues", + } { + 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["gateway_requests"].Annotations.DestructiveHint) + assert.False(t, *tools["gateway_requests"].Annotations.DestructiveHint, + "retrying a request destroys nothing") + }) + + t.Run("the read-only notice is gone", func(t *testing.T) { + assert.NotContains(t, tools["gateway_events"].Description, "read-only mode") + }) +} + +// --------------------------------------------------------------------------- +// The handler-level guard (defence in depth) +// --------------------------------------------------------------------------- + +func TestWriteGuard_BlocksWriteActionsInReadOnlyMode(t *testing.T) { + // Every path a blocked action would reach fails the test: a request + // arriving here means the guard did not stop the call. + fail := func(w http.ResponseWriter, r *http.Request) { + t.Errorf("read-only server called the API: %s %s", r.Method, r.URL.Path) + } + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/connections": fail, + "/2025-07-01/connections/": fail, + "/2025-07-01/sources": fail, + "/2025-07-01/sources/": fail, + "/2025-07-01/destinations": fail, + "/2025-07-01/destinations/": fail, + "/2025-07-01/transformations": fail, + "/2025-07-01/transformations/": fail, + "/2025-07-01/events/": fail, + "/2025-07-01/requests/": fail, + "/2025-07-01/issues/": fail, + }) + session := connectInMemory(t, newTestClient(api.URL, "test-key")) + + cases := []struct { + tool string + args map[string]any + }{ + {"gateway_connections", map[string]any{"action": "create", "name": "c", "source_id": "src_1", "destination_id": "des_1"}}, + {"gateway_connections", map[string]any{"action": "upsert", "name": "c"}}, + {"gateway_connections", map[string]any{"action": "update", "id": "web_1"}}, + {"gateway_connections", map[string]any{"action": "delete", "id": "web_1"}}, + {"gateway_connections", map[string]any{"action": "enable", "id": "web_1"}}, + {"gateway_connections", map[string]any{"action": "disable", "id": "web_1"}}, + {"gateway_sources", map[string]any{"action": "create", "name": "s", "type": "HTTP"}}, + {"gateway_sources", map[string]any{"action": "delete", "id": "src_1"}}, + {"gateway_destinations", map[string]any{"action": "create", "name": "d", "type": "HTTP"}}, + {"gateway_destinations", map[string]any{"action": "delete", "id": "des_1"}}, + {"gateway_transformations", map[string]any{"action": "create", "name": "t", "code": "return"}}, + {"gateway_transformations", map[string]any{"action": "delete", "id": "trs_1"}}, + {"gateway_transformations", map[string]any{"action": "run", "code": "return request"}}, + {"gateway_events", map[string]any{"action": "retry", "id": "evt_1"}}, + {"gateway_events", map[string]any{"action": "cancel", "id": "evt_1"}}, + {"gateway_events", map[string]any{"action": "mute", "id": "evt_1"}}, + {"gateway_requests", map[string]any{"action": "retry", "id": "req_1"}}, + {"gateway_issues", map[string]any{"action": "update", "id": "iss_1", "status": "RESOLVED"}}, + {"gateway_issues", map[string]any{"action": "dismiss", "id": "iss_1"}}, + } + + 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 := textContent(t, result) + assert.Contains(t, text, "read-only mode") + assert.Contains(t, text, "--allow-write") + }) + } +} + +// TestWriteGuard_PauseIsNotGated is the counterpart to the test above: the two +// mutations that stay available must not be blocked in read-only mode. +func TestWriteGuard_PauseIsNotGated(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/connections/web_1/pause": func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "web_1", "paused_at": "2026-01-01T00:00:00Z"}) + }, + "/2025-07-01/connections/web_1/unpause": func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "web_1"}) + }, + "/2025-07-01/connections/web_1": func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "web_1"}) + }, + }) + session := connectInMemory(t, newTestClient(api.URL, "test-key")) + + for _, action := range []string{"pause", "unpause"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_connections", map[string]any{ + "action": action, "id": "web_1", + }) + assert.False(t, result.IsError, "%s must stay available in read-only mode: %s", + action, textContent(t, result)) + }) + } +} + +func TestWriteGuard_AllowsWriteActionsInWriteMode(t *testing.T) { + var seen []string + record := func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"id": "res_1"}) + } + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/events/evt_1/retry": record, + "/2025-07-01/requests/req_1/retry": record, + "/2025-07-01/sources": record, + "/2025-07-01/destinations": record, + "/2025-07-01/transformations": record, + "/2025-07-01/issues/iss_1": record, + }) + session := connectInMemoryWriteEnabled(t, newTestClient(api.URL, "test-key")) + + cases := []struct { + name string + tool string + args map[string]any + want string + }{ + {"events retry", "gateway_events", map[string]any{"action": "retry", "id": "evt_1"}, "POST /2025-07-01/events/evt_1/retry"}, + {"requests retry", "gateway_requests", map[string]any{"action": "retry", "id": "req_1"}, "POST /2025-07-01/requests/req_1/retry"}, + {"sources create", "gateway_sources", map[string]any{"action": "create", "name": "s", "type": "HTTP"}, "POST /2025-07-01/sources"}, + {"sources upsert", "gateway_sources", map[string]any{"action": "upsert", "name": "s", "type": "HTTP"}, "PUT /2025-07-01/sources"}, + {"destinations create", "gateway_destinations", map[string]any{"action": "create", "name": "d", "type": "HTTP"}, "POST /2025-07-01/destinations"}, + {"transformations create", "gateway_transformations", map[string]any{"action": "create", "name": "t", "code": "return request"}, "POST /2025-07-01/transformations"}, + {"issues update", "gateway_issues", map[string]any{"action": "update", "id": "iss_1", "status": "RESOLVED"}, "PUT /2025-07-01/issues/iss_1"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + seen = nil + result := callTool(t, session, tc.tool, tc.args) + assert.False(t, result.IsError, "unexpected error: %s", textContent(t, result)) + // Assert the request the handler sends, not just that it did not + // error: a wire-shape bug passes a "no error" assertion. + assert.Contains(t, seen, tc.want) + }) + } +} + +// TestWriteActions_RequireAnID checks the argument validation on the write +// actions that address one existing record. +func TestWriteActions_RequireAnID(t *testing.T) { + api := mockAPI(t, nil) + session := connectInMemoryWriteEnabled(t, newTestClient(api.URL, "test-key")) + + cases := []struct { + tool string + args map[string]any + }{ + {"gateway_events", map[string]any{"action": "retry"}}, + {"gateway_events", map[string]any{"action": "cancel"}}, + {"gateway_events", map[string]any{"action": "mute"}}, + {"gateway_requests", map[string]any{"action": "retry"}}, + {"gateway_sources", map[string]any{"action": "delete"}}, + {"gateway_destinations", map[string]any{"action": "delete"}}, + {"gateway_transformations", map[string]any{"action": "delete"}}, + {"gateway_issues", map[string]any{"action": "dismiss"}}, + } + + 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) + assert.Contains(t, textContent(t, result), "id is required") + }) + } +} + +// TestHelpReportsMode checks that gateway_help states which mode the session is +// in, so an agent can find out why an action it expected is missing. +func TestHelpReportsMode(t *testing.T) { + api := mockAPI(t, nil) + + t.Run("read-only", func(t *testing.T) { + session := connectInMemory(t, newTestClient(api.URL, "test-key")) + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{})) + assert.Contains(t, text, "Mode: read-only") + assert.Contains(t, text, "--allow-write") + assert.Contains(t, text, "Pausing and unpausing") + }) + + t.Run("write enabled", func(t *testing.T) { + session := connectInMemoryWriteEnabled(t, newTestClient(api.URL, "test-key")) + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{})) + assert.Contains(t, text, "Mode: write enabled") + }) + + t.Run("a topic only documents the available actions", func(t *testing.T) { + session := connectInMemory(t, newTestClient(api.URL, "test-key")) + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_events"})) + + // Scope the assertion to the generated Actions list. Prose further down + // may legitimately mention what the gated actions do. + _, rest, ok := strings.Cut(text, "Actions:\n") + require.True(t, ok, "help topic should have an Actions section") + actions, _, ok := strings.Cut(rest, "\n\n") + require.True(t, ok) + + assert.Contains(t, actions, "list") + for _, gated := range []string{"retry", "cancel", "mute"} { + assert.NotContains(t, actions, gated, + "read-only help must not list the %s action", gated) + } + assert.Contains(t, text, "unavailable in read-only mode") + }) +} diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index 20dc34dd..e99e53e3 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -32,6 +32,8 @@ func TestMCPHelp(t *testing.T) { assert.Contains(t, stdout, "Model Context Protocol") assert.Contains(t, stdout, "stdio") assert.Contains(t, stdout, "hookdeck gateway mcp") + assert.Contains(t, stdout, "--allow-write") + assert.Contains(t, stdout, "read-only") } func TestGatewayHelpListsMCP(t *testing.T) { @@ -152,3 +154,117 @@ func TestGatewayMCPStdio_OutpostProjectRejected(t *testing.T) { assertGatewayMCPStdioHygiene(t, stdout, stderr) } } + +// --- Write mode (--allow-write) --- + +var gatewayMCPCommand = []string{"gateway", "mcp"} + +func TestGatewayMCPStdio_ReadOnlyByDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + tools, stdout, stderr := ListMCPTools(t, cli.projectRoot, cli.configPath, gatewayMCPCommand, 10*time.Second) + assertGatewayMCPStdioHygiene(t, stdout, stderr) + + // Product tools take the gateway_ prefix; the platform tools keep + // hookdeck_, because you log in to Hookdeck and switch a Hookdeck project + // whichever product's server you are in. + for _, name := range []string{ + "hookdeck_projects", "hookdeck_login", + "gateway_help", "gateway_connections", "gateway_sources", + "gateway_destinations", "gateway_transformations", "gateway_requests", + "gateway_events", "gateway_attempts", "gateway_issues", "gateway_metrics", + } { + assert.Contains(t, tools, name) + } + for _, name := range []string{"gateway_login", "gateway_projects"} { + assert.NotContains(t, tools, name, "platform tools must not carry the product prefix") + } + for _, name := range []string{"hookdeck_connections", "hookdeck_events", "hookdeck_help"} { + assert.NotContains(t, tools, name, "product tools were renamed to gateway_ in v3") + } + + // Nothing that creates, changes or deletes. + assert.Equal(t, []string{"list", "get", "pause", "unpause"}, + MCPToolActionEnum(t, tools["gateway_connections"])) + assert.Equal(t, []string{"list", "get"}, MCPToolActionEnum(t, tools["gateway_sources"])) + assert.Equal(t, []string{"list", "get", "raw_body"}, MCPToolActionEnum(t, tools["gateway_events"])) +} + +func TestGatewayMCPStdio_AllowWriteAddsWriteActions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + command := append(append([]string{}, gatewayMCPCommand...), "--allow-write") + tools, stdout, stderr := ListMCPTools(t, cli.projectRoot, cli.configPath, command, 10*time.Second) + assertGatewayMCPStdioHygiene(t, stdout, stderr) + + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_events"]), "retry") + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_requests"]), "retry") + for _, want := range []string{"create", "upsert", "update", "delete", "enable", "disable"} { + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_connections"]), want) + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_sources"]), want) + } + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_issues"]), "dismiss") + + // Read-only tools stay read-only in write mode. + assert.Equal(t, []string{"list", "get"}, MCPToolActionEnum(t, tools["gateway_attempts"])) +} + +func TestGatewayMCPStdio_ReadOnlyRefusesWriteAction(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + result := CallMCPTool(t, cli.projectRoot, cli.configPath, gatewayMCPCommand, "gateway_sources", map[string]any{ + "action": "delete", + "id": "src_does_not_exist", + }, 20*time.Second) + + require.True(t, result.IsError, "a read-only server must refuse delete: %s", result.Text) + assert.Contains(t, result.Text, "read-only mode") + assert.Contains(t, result.Text, "--allow-write") +} + +// TestGatewayMCPStdio_PauseStaysAvailableReadOnly is the acceptance-level +// counterpart to the unit test: pause is a mutation that deliberately remains +// offered in read-only mode, because read-only is the mode incidents get +// investigated in. +func TestGatewayMCPStdio_PauseStaysAvailableReadOnly(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + result := CallMCPTool(t, cli.projectRoot, cli.configPath, gatewayMCPCommand, "gateway_connections", map[string]any{ + "action": "pause", + "id": "conn_does_not_exist", + }, 20*time.Second) + + // It fails because the connection does not exist, not because the action + // was gated. The distinction is the whole point of the test. + assert.NotContains(t, result.Text, "read-only mode") + assert.NotContains(t, result.Text, "--allow-write") +} + +func TestGatewayMCPTool_HelpReportsMode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + readOnly := CallMCPTool(t, cli.projectRoot, cli.configPath, gatewayMCPCommand, + "gateway_help", map[string]any{}, 20*time.Second) + assert.Contains(t, readOnly.Text, "Mode: read-only") + assert.Contains(t, readOnly.Text, "--allow-write") + + write := CallMCPTool(t, cli.projectRoot, cli.configPath, + append(append([]string{}, gatewayMCPCommand...), "--allow-write"), + "gateway_help", map[string]any{}, 20*time.Second) + assert.Contains(t, write.Text, "Mode: write enabled") +} From 0317f119cf0e59fe899b6f5e95a979ec8af1d365 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:06:43 +0100 Subject: [PATCH 22/49] test(outpost): prove every MCP action with a successful call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten of the Outpost MCP write actions had only their read-only refusal covered — tenants delete/token/portal, destinations create/update/delete/ enable/disable and the two custom-domain writes. Four are annotated destructive. Nothing proved any of them worked, so an agent running with --allow-write would have been the first caller. Several reads were never called at all: outpost_attempts with any action, outpost_config get and custom_domain_get, destinations get, events list/get, destination_types get, metrics events, topics and status. The new tests assert the request that goes on the wire — method, path, query and body — rather than only that the call did not error, because a stub server answers whatever it is asked and would hide a wire-shape bug. TestEveryActionHasBeenCalledSuccessfully is a checklist that fails when a new action lands without a successful call written for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/outpost/mcp/tool_actions_test.go | 741 +++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 pkg/outpost/mcp/tool_actions_test.go diff --git a/pkg/outpost/mcp/tool_actions_test.go b/pkg/outpost/mcp/tool_actions_test.go new file mode 100644 index 00000000..73b5025a --- /dev/null +++ b/pkg/outpost/mcp/tool_actions_test.go @@ -0,0 +1,741 @@ +package mcp + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every action of every Outpost tool, called successfully at least once. +// +// The write actions previously had only their read-only refusal covered, so the +// first successful invocation of a delete or a create would have happened in a +// user's project. The reads that no test called at all are here for the same +// reason. +// +// These assert the request that goes on the wire — method, path, query and body +// — rather than only that the call did not error. Every Outpost defect found +// during development was a wire-shape bug, which a "no error" assertion misses +// entirely because the stub server answers whatever it is asked. + +// captured records the request a handler actually sent. +type captured struct { + method string + path string + query string + body []byte +} + +// recordJSON captures the incoming request and replies with response. +func recordJSON(into *captured, status int, response any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *into = captured{method: r.Method, path: r.URL.Path, query: r.URL.RawQuery, body: body} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if response != nil { + _ = json.NewEncoder(w).Encode(response) + } + } +} + +// decodeBody unmarshals the captured request body. +func (c captured) decodeBody(t *testing.T) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal(c.body, &body), "request body was not a JSON object: %s", c.body) + return body +} + +// envelopeData returns the data half of the standard result envelope. +func envelopeData(t *testing.T, text string) json.RawMessage { + t.Helper() + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(text), &envelope), "result was not an envelope: %s", text) + return envelope.Data +} + +// --------------------------------------------------------------------------- +// outpost_tenants — the write actions +// --------------------------------------------------------------------------- + +func TestTenantsGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme": recordJSON(&got, http.StatusOK, map[string]any{ + "id": "acme", "destinations_count": 2, "topics": []string{"user.created"}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "get", "id": "acme"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/tenants/acme", got.path) + assert.Contains(t, string(envelopeData(t, resultText(t, result))), `"acme"`) +} + +func TestTenantsUpsertSendsMetadata(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/tenants/acme": recordJSON(&got, http.StatusOK, map[string]any{"id": "acme"}), + }) + 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", "region": "eu"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme", got.path) + assert.Equal(t, map[string]any{"plan": "pro", "region": "eu"}, got.decodeBody(t)["metadata"]) +} + +func TestTenantsDelete(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/tenants/acme": recordJSON(&got, http.StatusOK, map[string]any{"success": true}), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "delete", "id": "acme"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme", got.path) + // The API returns no useful body, so the tool has to say what happened. + assert.JSONEq(t, `{"tenant_id":"acme","status":"deleted"}`, string(envelopeData(t, resultText(t, result)))) +} + +func TestTenantsDeleteRequiresAnID(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "delete"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "id is required") +} + +func TestTenantsToken(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/token": recordJSON(&got, http.StatusOK, map[string]any{ + "token": "header.payload.signature", "tenant_id": "acme", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_tenants", map[string]any{"action": "token", "id": "acme"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/tenants/acme/token", got.path) + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "header.payload.signature") +} + +func TestTenantsPortal(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/portal": recordJSON(&got, http.StatusOK, map[string]any{ + "redirect_url": "https://portal.example.com/s/abc", "tenant_id": "acme", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_tenants", map[string]any{ + "action": "portal", "id": "acme", "theme": "dark", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/tenants/acme/portal", got.path) + assert.Equal(t, "theme=dark", got.query, "the theme has to reach the API or the flag does nothing") + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "https://portal.example.com/s/abc") +} + +// --------------------------------------------------------------------------- +// outpost_destinations — every action +// --------------------------------------------------------------------------- + +func TestDestinationsGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/destinations/des_1": recordJSON(&got, http.StatusOK, 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": "get", "tenant_id": "acme", "id": "des_1", + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations/des_1", got.path) +} + +func TestDestinationsCreate(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/tenants/acme/destinations": recordJSON(&got, http.StatusCreated, map[string]any{ + "id": "des_1", "type": "webhook", "topics": []string{"user.created"}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "create", + "tenant_id": "acme", + "type": "webhook", + "topics": []any{"user.created"}, + "config": map[string]any{"url": "https://example.com/hooks"}, + "credentials": map[string]any{"secret": "shh"}, + "filter": map[string]any{"data": map[string]any{"tier": "pro"}}, + "metadata": map[string]any{"owner": "platform"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodPost, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "webhook", body["type"]) + assert.Equal(t, []any{"user.created"}, body["topics"]) + assert.Equal(t, map[string]any{"url": "https://example.com/hooks"}, body["config"]) + assert.Equal(t, map[string]any{"secret": "shh"}, body["credentials"]) + assert.Equal(t, map[string]any{"data": map[string]any{"tier": "pro"}}, body["filter"]) + assert.Equal(t, map[string]any{"owner": "platform"}, body["metadata"]) +} + +// The API documents subscribing to everything as the bare string "*", not +// ["*"], and rejects the array form. The encoding lives in OutpostTopics, so +// this asserts it survives the round trip from an MCP argument. +func TestDestinationsCreateSendsTheWildcardAsAString(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/tenants/acme/destinations": recordJSON(&got, http.StatusCreated, map[string]any{ + "id": "des_1", "type": "webhook", "topics": "*", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "create", "tenant_id": "acme", "type": "webhook", + "topics": []any{"*"}, + "config": map[string]any{"url": "https://example.com/hooks"}, + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "*", got.decodeBody(t)["topics"]) +} + +func TestDestinationsCreateRequiresAType(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "create", "tenant_id": "acme", + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "type is required") +} + +func TestDestinationsUpdateIsAPatch(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "PATCH /2025-07-01/tenants/acme/destinations/des_1": recordJSON(&got, http.StatusOK, map[string]any{ + "id": "des_1", "type": "webhook", "topics": "*", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "update", "tenant_id": "acme", "id": "des_1", + "config": map[string]any{"url": "https://example.com/new"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + // A PUT here would replace the destination rather than merge into it. + assert.Equal(t, http.MethodPatch, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations/des_1", got.path) + + body := got.decodeBody(t) + assert.Equal(t, map[string]any{"url": "https://example.com/new"}, body["config"]) + // Fields the caller did not mention must not be sent, or the merge-patch + // clears them. + assert.NotContains(t, body, "topics") + assert.NotContains(t, body, "credentials") + assert.NotContains(t, body, "metadata") +} + +func TestDestinationsDelete(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/tenants/acme/destinations/des_1": recordJSON(&got, http.StatusOK, nil), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": "delete", "tenant_id": "acme", "id": "des_1", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations/des_1", got.path) + assert.JSONEq(t, `{"tenant_id":"acme","destination_id":"des_1","status":"deleted"}`, + string(envelopeData(t, resultText(t, result)))) +} + +func TestDestinationsEnableAndDisable(t *testing.T) { + for _, action := range []string{"enable", "disable"} { + t.Run(action, func(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/tenants/acme/destinations/des_1/" + action: recordJSON(&got, http.StatusOK, map[string]any{ + "id": "des_1", "type": "webhook", "topics": "*", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": action, "tenant_id": "acme", "id": "des_1", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations/des_1/"+action, got.path) + }) + } +} + +func TestDestinationsWriteActionsRequireADestinationID(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + for _, action := range []string{"update", "delete", "enable", "disable"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "outpost_destinations", map[string]any{ + "action": action, "tenant_id": "acme", + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "id is required") + }) + } +} + +// --------------------------------------------------------------------------- +// outpost_config — the reads, and the custom-domain writes +// --------------------------------------------------------------------------- + +func TestConfigGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config": recordJSON(&got, http.StatusOK, map[string]any{ + "TOPICS": "user.created", "MAX_RETRY_LIMIT": "5", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + t.Run("everything", func(t *testing.T) { + result := callTool(t, session, "outpost_config", map[string]any{"action": "get"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "/2025-07-01/config", got.path) + + data := envelopeData(t, resultText(t, result)) + assert.Contains(t, string(data), "TOPICS") + assert.Contains(t, string(data), "MAX_RETRY_LIMIT") + }) + + t.Run("one key", func(t *testing.T) { + result := callTool(t, session, "outpost_config", map[string]any{"action": "get", "key": "TOPICS"}) + require.False(t, result.IsError, resultText(t, result)) + assert.JSONEq(t, `{"TOPICS":"user.created"}`, string(envelopeData(t, resultText(t, result)))) + }) + + t.Run("an unknown key is named in the error", func(t *testing.T) { + result := callTool(t, session, "outpost_config", map[string]any{"action": "get", "key": "NOPE"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), `no configuration key named "NOPE"`) + }) +} + +func TestConfigCustomDomainGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config/custom_domain": recordJSON(&got, http.StatusOK, map[string]any{ + "hostname": "portal.example.com", "status": "pending", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_config", map[string]any{"action": "custom_domain_get"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/config/custom_domain", got.path) + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "portal.example.com") +} + +func TestConfigCustomDomainSet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/config/custom_domain": recordJSON(&got, http.StatusCreated, map[string]any{ + "hostname": "portal.example.com", + "status": "pending", + "verification": []map[string]any{ + {"type": "CNAME", "name": "portal", "value": "outpost.hookdeck.com"}, + }, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_config", map[string]any{ + "action": "custom_domain_set", "hostname": "portal.example.com", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodPost, got.method) + assert.Equal(t, "/2025-07-01/config/custom_domain", got.path) + assert.Equal(t, "portal.example.com", got.decodeBody(t)["hostname"]) + // The DNS records are the only actionable part of the response; dropping + // them would leave the domain permanently unverified. + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "CNAME") +} + +func TestConfigCustomDomainSetRequiresAHostname(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": "custom_domain_set"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "hostname is required") +} + +func TestConfigCustomDomainDelete(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/config/custom_domain": recordJSON(&got, http.StatusOK, nil), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL), WriteEnabled: true}) + + result := callTool(t, session, "outpost_config", map[string]any{"action": "custom_domain_delete"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/config/custom_domain", got.path) + assert.JSONEq(t, `{"status":"deleted"}`, string(envelopeData(t, resultText(t, result)))) +} + +// --------------------------------------------------------------------------- +// outpost_attempts — no test called this tool with any action +// --------------------------------------------------------------------------- + +func TestAttemptsList(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/attempts": recordJSON(&got, http.StatusOK, map[string]any{ + "models": []map[string]any{{"id": "att_1", "status": "failed", "code": "500"}}, + "pagination": map[string]any{"limit": 10}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_attempts", map[string]any{ + "action": "list", + "event_id": "evt_1", + "status": "failed", + "topic": "user.created,user.updated", + "include": []any{"event"}, + "time_after": "2026-08-01T00:00:00Z", + "time_before": "2026-08-14T00:00:00Z", + "limit": 10, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/attempts", got.path) + // Repeated values use indexed brackets; repeating the bare key is not + // equivalent for this API. + assert.Contains(t, got.query, "event_id%5B0%5D=evt_1") + assert.Contains(t, got.query, "topic%5B0%5D=user.created") + assert.Contains(t, got.query, "topic%5B1%5D=user.updated") + assert.Contains(t, got.query, "include%5B0%5D=event") + assert.Contains(t, got.query, "status=failed") + assert.Contains(t, got.query, "time%5Bgte%5D=2026-08-01T00%3A00%3A00Z") + assert.Contains(t, got.query, "time%5Blte%5D=2026-08-14T00%3A00%3A00Z") + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "att_1") +} + +// A single tenant and destination address the nested route; anything else has +// to fall back to the global one, because the nested path cannot express two. +func TestAttemptsListUsesTheTenantScopedRoute(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/destinations/des_1/attempts": recordJSON(&got, http.StatusOK, map[string]any{ + "models": []map[string]any{{"id": "att_1"}}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_attempts", map[string]any{ + "action": "list", "tenant_id": "acme", "destination_id": "des_1", + }) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, "/2025-07-01/tenants/acme/destinations/des_1/attempts", got.path) +} + +func TestAttemptsGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/attempts/att_1": recordJSON(&got, http.StatusOK, map[string]any{ + "id": "att_1", "status": "failed", "code": "500", + "response_data": map[string]any{"body": "boom"}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_attempts", map[string]any{ + "action": "get", "id": "att_1", "include": []any{"destination"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/attempts/att_1", got.path) + assert.Contains(t, got.query, "include%5B0%5D=destination") + // The response body is why anyone looks at an attempt. + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "boom") +} + +func TestAttemptsGetRequiresAnID(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_attempts", map[string]any{"action": "get"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "id is required") +} + +// --------------------------------------------------------------------------- +// outpost_events — the reads +// --------------------------------------------------------------------------- + +func TestEventsList(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/events": recordJSON(&got, http.StatusOK, map[string]any{ + "models": []map[string]any{{"id": "evt_1", "topic": "user.created"}}, + "pagination": map[string]any{"limit": 5, "next": "cursor_2"}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_events", map[string]any{ + "action": "list", "tenant_id": "acme", "topic": "user.created", + "limit": 5, "dir": "desc", "next": "cursor_1", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/events", got.path) + assert.Contains(t, got.query, "tenant_id%5B0%5D=acme") + assert.Contains(t, got.query, "topic%5B0%5D=user.created") + assert.Contains(t, got.query, "limit=5") + assert.Contains(t, got.query, "dir=desc") + // Pagination is only usable if the cursor is actually forwarded. + assert.Contains(t, got.query, "next=cursor_1") + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "cursor_2") +} + +func TestEventsGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/events/evt_1": recordJSON(&got, http.StatusOK, map[string]any{ + "id": "evt_1", "topic": "user.created", "data": map[string]any{"user_id": "123"}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_events", map[string]any{ + "action": "get", "id": "evt_1", "tenant_id": "acme", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/events/evt_1", got.path) + assert.Equal(t, "tenant_id=acme", got.query) + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "user_id") +} + +func TestEventsGetRequiresAnID(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_events", map[string]any{"action": "get"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "id is required") +} + +// --------------------------------------------------------------------------- +// The read-only catalogues +// --------------------------------------------------------------------------- + +func TestDestinationTypesGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/destination-types/webhook": recordJSON(&got, http.StatusOK, map[string]any{ + "type": "webhook", + "label": "Webhook", + "icon": "icon", + "instructions": "a very long setup guide", + "config_fields": []map[string]any{ + {"key": "url", "type": "text", "required": true}, + }, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_destination_types", map[string]any{ + "action": "get", "type": "webhook", + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/destination-types/webhook", got.path) + text := resultText(t, result) + assert.Contains(t, text, "url", "the config fields are the reason to call this") + assert.NotContains(t, text, "a very long setup guide", "setup docs are opt-in on get too") + + verbose := callTool(t, session, "outpost_destination_types", map[string]any{ + "action": "get", "type": "webhook", "include_setup_docs": true, + }) + assert.Contains(t, resultText(t, verbose), "a very long setup guide") +} + +func TestDestinationTypesGetRequiresAType(t *testing.T) { + api := mockAPI(t, nil) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_destination_types", map[string]any{"action": "get"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "type is required") +} + +func TestTopicsList(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/topics": recordJSON(&got, http.StatusOK, []string{"user.created", "user.deleted"}), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_topics", map[string]any{"action": "list"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/topics", got.path) + assert.JSONEq(t, `{"topics":["user.created","user.deleted"]}`, + string(envelopeData(t, resultText(t, result)))) +} + +func TestStatusGet(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/status": recordJSON(&got, http.StatusOK, map[string]any{ + "status": "ready", "version": "1.2.3", "portal_hostname": "portal.example.com", + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_status", map[string]any{"action": "get"}) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/status", got.path) + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "portal.example.com") +} + +// --------------------------------------------------------------------------- +// outpost_metrics — the events action was never called +// --------------------------------------------------------------------------- + +func TestMetricsEvents(t *testing.T) { + var got captured + api := mockAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/metrics/events": recordJSON(&got, http.StatusOK, map[string]any{ + "data": []map[string]any{ + {"dimensions": map[string]string{"topic": "user.created"}, "metrics": map[string]any{"count": 42}}, + }, + "metadata": map[string]any{"row_count": 1}, + }), + }) + session := connect(t, ServerOptions{Client: newTestClient(t, api.URL)}) + + result := callTool(t, session, "outpost_metrics", map[string]any{ + "action": "events", + "start": "2026-08-01T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + "granularity": "1d", + "measures": []any{"count"}, + "dimensions": []any{"topic"}, + }) + require.False(t, result.IsError, resultText(t, result)) + + assert.Equal(t, "/2025-07-01/metrics/events", got.path) + // The range uses bracketed keys, not start/end. + assert.Contains(t, got.query, "time%5Bstart%5D=2026-08-01T00%3A00%3A00Z") + assert.Contains(t, got.query, "time%5Bend%5D=2026-08-14T00%3A00%3A00Z") + assert.Contains(t, got.query, "granularity=1d") + assert.Contains(t, got.query, "measures%5B0%5D=count") + assert.Contains(t, got.query, "dimensions%5B0%5D=topic") + assert.Contains(t, string(envelopeData(t, resultText(t, result))), "42") +} + +// --------------------------------------------------------------------------- +// Coverage gate +// --------------------------------------------------------------------------- + +// TestEveryActionHasBeenCalledSuccessfully is a checklist rather than a +// behaviour test. It fails when an action is added without a successful call +// being written for it, which is the gap this file was created to close: a +// destructive action whose only coverage is its refusal has never been proven +// to work at all. +func TestEveryActionHasBeenCalledSuccessfully(t *testing.T) { + // Actions with a test above that calls them and asserts the request sent. + covered := map[string]map[string]bool{ + "tenants": { + "list": true, "get": true, "upsert": true, + "delete": true, "token": true, "portal": true, + }, + "destinations": { + "list": true, "get": true, "create": true, "update": true, + "delete": true, "enable": true, "disable": true, + }, + "events": {"list": true, "get": true, "retry": true}, + "attempts": {"list": true, "get": true}, + "topics": {"list": true}, + "destination_types": {"list": true, "get": true}, + "metrics": {"events": true, "attempts": true}, + "config": { + "get": true, "set": true, "custom_domain_get": true, + "custom_domain_set": true, "custom_domain_delete": true, + }, + "status": {"get": true}, + } + + specs := map[string]actionSet{ + "tenants": tenantsActions, + "destinations": destinationsActions, + "events": eventsActions, + "attempts": attemptsActions, + "topics": topicsActions, + "destination_types": destinationTypesActions, + "metrics": metricsActions, + "config": configActions, + "status": statusActions, + } + + for resource, actions := range specs { + for _, a := range actions { + assert.True(t, covered[resource][a.name], + "outpost_%s action %q has no test making a successful call; "+ + "a refusal test alone does not prove the action works", + resource, a.name) + } + } +} From ff7e3b2956224ad677da172deb737c170bcdf980 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:12:06 +0100 Subject: [PATCH 23/49] feat(outpost): add --metadata to destination create and update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client request types and the outpost_destinations MCP tool both supported destination metadata, but the CLI exposed no way to set it, so the same field was reachable through an agent and not through a person. `outpost tenant upsert` already had --metadata/--metadata-file; this brings destinations to the same shape and shares one resolver between them rather than keeping a second copy. Metadata alone now counts as an update, and --filter's "replaced wholesale, not merged" note covers metadata too. Adds unit coverage driving the commands' RunE against a stub Outpost API: `outpost config set` was previously only ever run with --dry-run, because the acceptance project's config is shared with every other test in that file, which left the PATCH body — including how --unset encodes as null — with no coverage at all. Also covers config get, the custom-domain commands, tenant portal, and empty-value rejection on outpost flags. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- REFERENCE.md | 17 +- pkg/cmd/outpost_destination_common.go | 52 ++- pkg/cmd/outpost_destination_create.go | 12 +- pkg/cmd/outpost_destination_update.go | 17 +- pkg/cmd/outpost_rune_test.go | 509 ++++++++++++++++++++++++++ pkg/cmd/outpost_tenant_upsert.go | 30 +- 6 files changed, 600 insertions(+), 37 deletions(-) create mode 100644 pkg/cmd/outpost_rune_test.go diff --git a/REFERENCE.md b/REFERENCE.md index 36d1a7be..72eb1d04 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -2274,6 +2274,8 @@ hookdeck outpost destination create [flags] | `--credentials-file` | `string` | Path to a JSON file of credential fields | | `--filter` | `string` | Event filter as a JSON object | | `--filter-file` | `string` | Path to a JSON file containing an event filter | +| `--metadata` | `stringArray` | Metadata as key=value (repeatable) (default "[]") | +| `--metadata-file` | `string` | Path to a JSON file of metadata key/value pairs | | `--output` | `string` | Output format (json) | | `--topics` | `string` | Topics to subscribe to, comma-separated, or "*" for all | | `--type` | `string` | Destination type (required) | @@ -2294,6 +2296,11 @@ hookdeck outpost destination create --tenant-id acme --type aws_sqs \ --config queue_url=https://sqs.eu-west-2.amazonaws.com/1/q \ --credential key=AKIA... --credential secret=... \ --filter '{"data":{"tier":"pro"}}' + +# With metadata of your own to correlate against your systems +hookdeck outpost destination create --tenant-id acme --type webhook \ +--config url=https://example.com/hooks \ +--metadata owner=platform --metadata tier=pro ``` ### hookdeck outpost destination update @@ -2301,8 +2308,8 @@ Update an existing destination by its ID. Only the fields you pass are changed; omitted fields are left alone. -`--filter` is the exception: the API replaces the filter wholesale rather than -merging into it, so pass the complete filter you want. +`--filter` and `--metadata` are the exceptions: the API replaces each wholesale +rather than merging into it, so pass the complete value you want. **Usage:** @@ -2326,6 +2333,8 @@ hookdeck outpost destination update [flags] | `--credentials-file` | `string` | Path to a JSON file of credential fields | | `--filter` | `string` | Event filter as a JSON object | | `--filter-file` | `string` | Path to a JSON file containing an event filter | +| `--metadata` | `stringArray` | Metadata as key=value (repeatable) (default "[]") | +| `--metadata-file` | `string` | Path to a JSON file of metadata key/value pairs | | `--output` | `string` | Output format (json) | | `--topics` | `string` | Topics to subscribe to, comma-separated, or "*" for all | @@ -2338,6 +2347,10 @@ hookdeck outpost destination update des_abc123 --tenant-id acme \ # Change which topics it receives hookdeck outpost destination update des_abc123 --tenant-id acme --topics "*" + +# Replace the metadata +hookdeck outpost destination update des_abc123 --tenant-id acme \ +--metadata owner=platform --metadata tier=pro ``` ### hookdeck outpost destination delete diff --git a/pkg/cmd/outpost_destination_common.go b/pkg/cmd/outpost_destination_common.go index ca59da49..8d77006c 100644 --- a/pkg/cmd/outpost_destination_common.go +++ b/pkg/cmd/outpost_destination_common.go @@ -35,6 +35,8 @@ type outpostDestinationFieldFlags struct { topics string filter string filterFile string + metadata []string + metadataFile string } func addOutpostDestinationFieldFlags(cmd *cobra.Command, f *outpostDestinationFieldFlags) { @@ -45,6 +47,8 @@ func addOutpostDestinationFieldFlags(cmd *cobra.Command, f *outpostDestinationFi cmd.Flags().StringVar(&f.topics, "topics", "", `Topics to subscribe to, comma-separated, or "*" for all`) cmd.Flags().StringVar(&f.filter, "filter", "", "Event filter as a JSON object") cmd.Flags().StringVar(&f.filterFile, "filter-file", "", "Path to a JSON file containing an event filter") + cmd.Flags().StringArrayVar(&f.metadata, "metadata", nil, "Metadata as key=value (repeatable)") + cmd.Flags().StringVar(&f.metadataFile, "metadata-file", "", "Path to a JSON file of metadata key/value pairs") } func (f *outpostDestinationFieldFlags) validate() error { @@ -57,12 +61,16 @@ func (f *outpostDestinationFieldFlags) validate() error { if f.filter != "" && f.filterFile != "" { return fmt.Errorf("--filter and --filter-file cannot be used together") } + if len(f.metadata) > 0 && f.metadataFile != "" { + return fmt.Errorf("--metadata and --metadata-file cannot be used together") + } return nil } func (f *outpostDestinationFieldFlags) hasAny() bool { return len(f.config) > 0 || len(f.credential) > 0 || f.configFile != "" || - f.credentialsFile != "" || f.topics != "" || f.filter != "" || f.filterFile != "" + f.credentialsFile != "" || f.topics != "" || f.filter != "" || f.filterFile != "" || + len(f.metadata) > 0 || f.metadataFile != "" } func (f *outpostDestinationFieldFlags) resolveConfig() (map[string]interface{}, error) { @@ -85,6 +93,10 @@ func (f *outpostDestinationFieldFlags) resolveTopics() hookdeck.OutpostTopics { return hookdeck.OutpostTopics(splitCommaList(f.topics)) } +func (f *outpostDestinationFieldFlags) resolveMetadata() (map[string]string, error) { + return resolveOutpostMetadata(f.metadata, f.metadataFile) +} + func (f *outpostDestinationFieldFlags) resolveFilter() (map[string]interface{}, error) { raw := f.filter if f.filterFile != "" { @@ -105,6 +117,44 @@ func (f *outpostDestinationFieldFlags) resolveFilter() (map[string]interface{}, return filter, nil } +// resolveOutpostMetadata reads --metadata pairs or a --metadata-file into the +// string map the API expects. +// +// Metadata is a plain string map on every Outpost resource that has it, so +// unlike config and credentials there is no dotted-path nesting here: a dot in +// a key is part of the key. +// +// Nil means "not supplied", which on update is the difference between leaving +// metadata alone and replacing it. +func resolveOutpostMetadata(pairs []string, file string) (map[string]string, error) { + if file != "" { + contents, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read --metadata-file: %w", err) + } + var metadata map[string]string + if err := json.Unmarshal(contents, &metadata); err != nil { + return nil, fmt.Errorf("--metadata-file must contain a JSON object of string values: %w", err) + } + return metadata, nil + } + + if len(pairs) == 0 { + return nil, nil + } + + metadata := make(map[string]string, len(pairs)) + for _, entry := range pairs { + key, value, found := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("--metadata %q must be in key=value form", entry) + } + metadata[key] = value + } + return metadata, nil +} + // resolveOutpostFieldMap merges key=value pairs or a JSON file into one map. func resolveOutpostFieldMap(pairs []string, file, kind string) (map[string]interface{}, error) { if file != "" { diff --git a/pkg/cmd/outpost_destination_create.go b/pkg/cmd/outpost_destination_create.go index e8e27917..5cd58202 100644 --- a/pkg/cmd/outpost_destination_create.go +++ b/pkg/cmd/outpost_destination_create.go @@ -47,7 +47,12 @@ Topics default to all ("*") when --topics is omitted.`, hookdeck outpost destination create --tenant-id acme --type aws_sqs \ --config queue_url=https://sqs.eu-west-2.amazonaws.com/1/q \ --credential key=AKIA... --credential secret=... \ - --filter '{"data":{"tier":"pro"}}'`, + --filter '{"data":{"tier":"pro"}}' + + # With metadata of your own to correlate against your systems + hookdeck outpost destination create --tenant-id acme --type webhook \ + --config url=https://example.com/hooks \ + --metadata owner=platform --metadata tier=pro`, } dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (required)") @@ -86,6 +91,10 @@ func (dc *outpostDestinationCreateCmd) runOutpostDestinationCreateCmd(cmd *cobra if err != nil { return err } + metadata, err := dc.fields.resolveMetadata() + if err != nil { + return err + } if err := validateOutpostDestinationFields(ctx, dc.destType, config, credentials); err != nil { return err @@ -106,6 +115,7 @@ func (dc *outpostDestinationCreateCmd) runOutpostDestinationCreateCmd(cmd *cobra Config: config, Credentials: credentials, Filter: filter, + Metadata: metadata, }) if err != nil { return fmt.Errorf("failed to create destination: %w", err) diff --git a/pkg/cmd/outpost_destination_update.go b/pkg/cmd/outpost_destination_update.go index 5c0cc248..b3dc5e7f 100644 --- a/pkg/cmd/outpost_destination_update.go +++ b/pkg/cmd/outpost_destination_update.go @@ -29,8 +29,8 @@ func newOutpostDestinationUpdateCmd(parent *outpostDestinationCmd) *outpostDesti Only the fields you pass are changed; omitted fields are left alone. ---filter is the exception: the API replaces the filter wholesale rather than -merging into it, so pass the complete filter you want.`, +--filter and --metadata are the exceptions: the API replaces each wholesale +rather than merging into it, so pass the complete value you want.`, PreRunE: dc.validateFlags, RunE: dc.runOutpostDestinationUpdateCmd, Example: ` # Point a destination at a new URL @@ -38,7 +38,11 @@ merging into it, so pass the complete filter you want.`, --config url=https://example.com/new # Change which topics it receives - hookdeck outpost destination update des_abc123 --tenant-id acme --topics "*"`, + hookdeck outpost destination update des_abc123 --tenant-id acme --topics "*" + + # Replace the metadata + hookdeck outpost destination update des_abc123 --tenant-id acme \ + --metadata owner=platform --metadata tier=pro`, Annotations: map[string]string{ "cli.arguments": `[ {"name":"destination-id","type":"string","description":"The ID of the destination to update.","required":true} @@ -64,7 +68,7 @@ func (dc *outpostDestinationUpdateCmd) validateFlags(cmd *cobra.Command, args [] } // An update with nothing to update is a no-op that looks like a success. if !dc.fields.hasAny() { - return fmt.Errorf("nothing to update. Pass at least one of --config, --credential, --topics or --filter") + return fmt.Errorf("nothing to update. Pass at least one of --config, --credential, --topics, --filter or --metadata") } return nil } @@ -84,6 +88,10 @@ func (dc *outpostDestinationUpdateCmd) runOutpostDestinationUpdateCmd(cmd *cobra if err != nil { return err } + metadata, err := dc.fields.resolveMetadata() + if err != nil { + return err + } client := Config.GetOutpostAPIClient() @@ -104,6 +112,7 @@ func (dc *outpostDestinationUpdateCmd) runOutpostDestinationUpdateCmd(cmd *cobra Config: config, Credentials: credentials, Filter: filter, + Metadata: metadata, }) if err != nil { return fmt.Errorf("failed to update destination: %w", err) diff --git a/pkg/cmd/outpost_rune_test.go b/pkg/cmd/outpost_rune_test.go new file mode 100644 index 00000000..1b2c3d13 --- /dev/null +++ b/pkg/cmd/outpost_rune_test.go @@ -0,0 +1,509 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" +) + +// Unit coverage for the outpost commands' RunE against a stub Outpost API. +// +// The acceptance suite runs against a shared project, so the destructive paths +// there are limited to what is safe to do to it — `outpost config set` in +// particular is only ever exercised with --dry-run, because the config it would +// change belongs to every other test in the file. That left the code that +// builds the PATCH body untested, including how --unset is encoded, which is +// the part most likely to be wrong and the most damaging if it is: sending the +// wrong shape here changes delivery for a whole project. +// +// These drive the real cobra commands, so flag parsing, PreRunE validation and +// the request body are all covered without touching a real project. + +// stubRequest is a request the command under test sent. +type stubRequest struct { + method string + path string + query string + body []byte +} + +// stubOutpostAPI points the global Config at an httptest server, and restores +// the previous state when the test ends. +// +// It records every request, so a test can assert both what was sent and that +// nothing was sent at all — which is the whole point of --dry-run. +func stubOutpostAPI(t *testing.T, handlers map[string]http.HandlerFunc) *[]stubRequest { + t.Helper() + + var requests []stubRequest + + mux := http.NewServeMux() + for pattern, handler := range handlers { + pattern, handler := pattern, handler + mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, stubRequest{r.Method, r.URL.Path, r.URL.RawQuery, body}) + r.Body = io.NopCloser(bytes.NewReader(body)) + handler(w, r) + }) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, stubRequest{r.Method, r.URL.Path, r.URL.RawQuery, body}) + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + previous := Config + Config = config.Config{} + Config.OutpostAPIBaseURL = server.URL + Config.Profile.APIKey = "test-key" + Config.Profile.ProjectId = "proj_outpost" + Config.Profile.ProjectType = config.ProjectTypeOutpost + // Executing any cobra command runs the initializer registered in root.go, + // which reads the config file and validates the log level. Point it at a + // throwaway file so a test never reads — or creates — the developer's real + // one, and give it a level it accepts. Everything set above survives: + // InitConfig coalesces, so a value already present wins over the file. + Config.LogLevel = "info" + Config.ConfigFileFlag = filepath.Join(t.TempDir(), "config.toml") + config.ResetAPIClientForTesting() + + t.Cleanup(func() { + Config = previous + config.ResetAPIClientForTesting() + }) + + return &requests +} + +// jsonResponse replies with status and body. +func jsonResponse(status int, body any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } + } +} + +// runCommand executes a command with the given arguments, capturing stdout. +func runCommand(t *testing.T, cmd interface { + SetArgs([]string) + Execute() error +}, args ...string) (stdout string, err error) { + t.Helper() + + original := os.Stdout + r, w, pipeErr := os.Pipe() + require.NoError(t, pipeErr) + os.Stdout = w + + cmd.SetArgs(args) + err = cmd.Execute() + + require.NoError(t, w.Close()) + os.Stdout = original + + out, readErr := io.ReadAll(r) + require.NoError(t, readErr) + return string(out), err +} + +// decodeConfigBody decodes a config PATCH body, keeping nulls distinguishable +// from missing keys — the difference between "clear this" and "leave it alone". +func decodeConfigBody(t *testing.T, raw []byte) map[string]*string { + t.Helper() + var body map[string]*string + require.NoError(t, json.Unmarshal(raw, &body), "body was not a JSON object: %s", raw) + return body +} + +// --------------------------------------------------------------------------- +// outpost config set +// --------------------------------------------------------------------------- + +func TestOutpostConfigSetSendsThePatch(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config": jsonResponse(http.StatusOK, map[string]string{ + "TOPICS": "user.created", "MAX_RETRY_LIMIT": "3", + }), + "PATCH /2025-07-01/config": jsonResponse(http.StatusOK, map[string]string{ + "TOPICS": "user.created,user.updated", + }), + }) + + cmd := newOutpostConfigSetCmd().cmd + stdout, err := runCommand(t, cmd, + "TOPICS=user.created,user.updated", "--unset", "MAX_RETRY_LIMIT") + require.NoError(t, err) + + require.Len(t, *requests, 2, "the current config is read first so a diff can be shown") + patch := (*requests)[1] + assert.Equal(t, http.MethodPatch, patch.method) + assert.Equal(t, "/2025-07-01/config", patch.path) + + body := decodeConfigBody(t, patch.body) + require.Contains(t, body, "TOPICS") + require.NotNil(t, body["TOPICS"]) + assert.Equal(t, "user.created,user.updated", *body["TOPICS"]) + + // An unset key must be present with a null value. Omitting it would leave + // the key set, and sending "" would set it to an empty string — neither + // returns it to its default. + require.Contains(t, body, "MAX_RETRY_LIMIT") + assert.Nil(t, body["MAX_RETRY_LIMIT"]) + + assert.Contains(t, stdout, "Updated 2 configuration value(s)") + assert.Contains(t, stdout, "hookdeck outpost status", "the change is asynchronous, so say where to check") +} + +func TestOutpostConfigSetDryRunSendsNoPatch(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config": jsonResponse(http.StatusOK, map[string]string{ + "TOPICS": "user.created", "MAX_RETRY_LIMIT": "3", + }), + }) + + cmd := newOutpostConfigSetCmd().cmd + stdout, err := runCommand(t, cmd, "TOPICS=nope", "--unset", "MAX_RETRY_LIMIT", "--dry-run") + require.NoError(t, err) + + for _, r := range *requests { + assert.Equal(t, http.MethodGet, r.method, "--dry-run must not write anything") + } + + assert.Contains(t, stdout, "Dry run") + assert.Contains(t, stdout, "before: user.created") + assert.Contains(t, stdout, "after: nope") + // An unset shows what it reverts to, which is not the same as an empty value. + assert.Contains(t, stdout, "after: (default)") + assert.Contains(t, stdout, "Re-run without --dry-run to apply.") +} + +func TestOutpostConfigSetFromAFile(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config": jsonResponse(http.StatusOK, map[string]string{}), + "PATCH /2025-07-01/config": jsonResponse(http.StatusOK, map[string]string{"TOPICS": "a"}), + }) + + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(`{"TOPICS":"a","MAX_RETRY_LIMIT":null}`), 0o600)) + + cmd := newOutpostConfigSetCmd().cmd + _, err := runCommand(t, cmd, "--config-file", path) + require.NoError(t, err) + + body := decodeConfigBody(t, (*requests)[1].body) + require.NotNil(t, body["TOPICS"]) + assert.Equal(t, "a", *body["TOPICS"]) + // A null in the file means the same as --unset. + require.Contains(t, body, "MAX_RETRY_LIMIT") + assert.Nil(t, body["MAX_RETRY_LIMIT"]) +} + +func TestOutpostConfigSetValidation(t *testing.T) { + t.Run("nothing to change", func(t *testing.T) { + cmd := newOutpostConfigSetCmd().cmd + _, err := runCommand(t, cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "nothing to change") + }) + + t.Run("arguments and a file conflict", func(t *testing.T) { + cmd := newOutpostConfigSetCmd().cmd + _, err := runCommand(t, cmd, "TOPICS=a", "--config-file", "x.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be used together") + }) + + t.Run("an argument without = is rejected", func(t *testing.T) { + stubOutpostAPI(t, nil) + cmd := newOutpostConfigSetCmd().cmd + _, err := runCommand(t, cmd, "TOPICS") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be in KEY=VALUE form") + }) +} + +func TestOutpostConfigGet(t *testing.T) { + stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config": jsonResponse(http.StatusOK, map[string]any{ + "TOPICS": "user.created", "UNSET_KEY": nil, + }), + }) + + t.Run("one key prints just the value", func(t *testing.T) { + stdout, err := runCommand(t, newOutpostConfigGetCmd().cmd, "TOPICS") + require.NoError(t, err) + assert.Equal(t, "user.created\n", stdout, "scripts consume this, so it must be the bare value") + }) + + t.Run("an unknown key is named in the error", func(t *testing.T) { + _, err := runCommand(t, newOutpostConfigGetCmd().cmd, "NOPE") + require.Error(t, err) + assert.Contains(t, err.Error(), `no configuration key named "NOPE"`) + }) + + t.Run("keys with no value are omitted from the listing", func(t *testing.T) { + stdout, err := runCommand(t, newOutpostConfigGetCmd().cmd) + require.NoError(t, err) + assert.Contains(t, stdout, "TOPICS") + assert.NotContains(t, stdout, "UNSET_KEY") + }) +} + +// --------------------------------------------------------------------------- +// outpost config custom-domain +// --------------------------------------------------------------------------- + +func TestOutpostCustomDomain(t *testing.T) { + t.Run("get reports a configured domain and its DNS records", func(t *testing.T) { + stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config/custom_domain": jsonResponse(http.StatusOK, map[string]any{ + "hostname": "portal.example.com", + "status": "pending", + "verification": []map[string]any{ + {"type": "CNAME", "name": "portal", "value": "outpost.hookdeck.com"}, + }, + }), + }) + + stdout, err := runCommand(t, newOutpostCustomDomainGetCmd().cmd) + require.NoError(t, err) + assert.Contains(t, stdout, "portal.example.com") + assert.Contains(t, stdout, "Status: pending") + assert.Contains(t, stdout, "Create these DNS records:") + assert.Contains(t, stdout, "CNAME") + }) + + t.Run("get says how to add one when there is none", func(t *testing.T) { + stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/config/custom_domain": jsonResponse(http.StatusOK, map[string]any{}), + }) + + stdout, err := runCommand(t, newOutpostCustomDomainGetCmd().cmd) + require.NoError(t, err) + assert.Contains(t, stdout, "No custom domain is configured.") + assert.Contains(t, stdout, "custom-domain set") + }) + + t.Run("set posts the hostname and echoes the DNS records", func(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/config/custom_domain": jsonResponse(http.StatusCreated, map[string]any{ + "hostname": "portal.example.com", + "verification": []map[string]any{ + {"type": "CNAME", "name": "portal", "value": "outpost.hookdeck.com"}, + }, + }), + }) + + stdout, err := runCommand(t, newOutpostCustomDomainSetCmd().cmd, "portal.example.com") + require.NoError(t, err) + + require.Len(t, *requests, 1) + assert.Equal(t, http.MethodPost, (*requests)[0].method) + assert.JSONEq(t, `{"hostname":"portal.example.com"}`, string((*requests)[0].body)) + assert.Contains(t, stdout, "portal.example.com") + assert.Contains(t, stdout, "CNAME", "without the records the domain never verifies") + }) + + t.Run("delete --force skips the prompt", func(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/config/custom_domain": jsonResponse(http.StatusOK, nil), + }) + + stdout, err := runCommand(t, newOutpostCustomDomainDeleteCmd().cmd, "--force") + require.NoError(t, err) + + require.Len(t, *requests, 1) + assert.Equal(t, http.MethodDelete, (*requests)[0].method) + assert.Equal(t, "/2025-07-01/config/custom_domain", (*requests)[0].path) + assert.Contains(t, stdout, "Custom domain removed") + }) +} + +// A destructive command without --force must prompt. The prompt cannot be +// answered from a non-interactive test, so this asserts the command declines to +// proceed rather than deleting anything — the failure mode that matters is a +// delete going through unprompted in a script. +func TestOutpostCustomDomainDeleteWithoutForceDoesNotDelete(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/config/custom_domain": func(w http.ResponseWriter, r *http.Request) { + t.Error("the domain was deleted without confirmation") + }, + }) + + _, _ = runCommand(t, newOutpostCustomDomainDeleteCmd().cmd) + assert.Empty(t, *requests, "no request may be made until the deletion is confirmed") +} + +// --------------------------------------------------------------------------- +// outpost tenant portal +// --------------------------------------------------------------------------- + +func TestOutpostTenantPortal(t *testing.T) { + t.Run("prints the URL and passes the theme through", func(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/portal": jsonResponse(http.StatusOK, map[string]any{ + "redirect_url": "https://portal.example.com/s/abc", + "tenant_id": "acme", + }), + }) + + stdout, err := runCommand(t, newOutpostTenantPortalCmd().cmd, "acme", "--theme", "dark") + require.NoError(t, err) + + require.Len(t, *requests, 1) + assert.Equal(t, "theme=dark", (*requests)[0].query) + // The URL is a credential and scripts pipe it, so it must be the only + // thing on stdout. + assert.Equal(t, "https://portal.example.com/s/abc\n", stdout) + }) + + t.Run("an invalid theme is rejected before the request", func(t *testing.T) { + requests := stubOutpostAPI(t, nil) + _, err := runCommand(t, newOutpostTenantPortalCmd().cmd, "acme", "--theme", "neon") + require.Error(t, err) + assert.Contains(t, err.Error(), "--theme must be either light or dark") + assert.Empty(t, *requests) + }) +} + +// --------------------------------------------------------------------------- +// --metadata on destinations (parity with tenant upsert and the MCP tool) +// --------------------------------------------------------------------------- + +func TestOutpostDestinationCreateSendsMetadata(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/destination-types": jsonResponse(http.StatusOK, []map[string]any{{ + "type": "webhook", + "config_fields": []map[string]any{{"key": "url", "type": "text", "required": true}}, + }}), + "POST /2025-07-01/tenants/acme/destinations": jsonResponse(http.StatusCreated, map[string]any{ + "id": "des_1", "type": "webhook", "topics": "*", + }), + }) + + parent := newOutpostDestinationCmd() + _, err := runCommand(t, parent.cmd, "create", + "--tenant-id", "acme", "--type", "webhook", + "--config", "url=https://example.com/hooks", + "--metadata", "owner=platform", "--metadata", "tier=pro") + require.NoError(t, err) + + var body map[string]any + for _, r := range *requests { + if r.method == http.MethodPost { + require.NoError(t, json.Unmarshal(r.body, &body)) + } + } + require.NotNil(t, body, "no create request was sent") + assert.Equal(t, map[string]any{"owner": "platform", "tier": "pro"}, body["metadata"]) +} + +func TestOutpostDestinationUpdateSendsMetadataFromAFile(t *testing.T) { + requests := stubOutpostAPI(t, map[string]http.HandlerFunc{ + "PATCH /2025-07-01/tenants/acme/destinations/des_1": jsonResponse(http.StatusOK, map[string]any{ + "id": "des_1", "type": "webhook", "topics": "*", + }), + }) + + path := filepath.Join(t.TempDir(), "metadata.json") + require.NoError(t, os.WriteFile(path, []byte(`{"owner":"platform"}`), 0o600)) + + parent := newOutpostDestinationCmd() + _, err := runCommand(t, parent.cmd, "update", "des_1", + "--tenant-id", "acme", "--metadata-file", path) + require.NoError(t, err) + + require.Len(t, *requests, 1, "metadata alone needs no destination-type lookup") + var body map[string]any + require.NoError(t, json.Unmarshal((*requests)[0].body, &body)) + assert.Equal(t, map[string]any{"owner": "platform"}, body["metadata"]) + // Nothing else was passed, so nothing else may be sent: this endpoint + // merge-patches, and an empty config would clear the destination's config. + assert.NotContains(t, body, "config") + assert.NotContains(t, body, "topics") +} + +func TestOutpostDestinationMetadataValidation(t *testing.T) { + t.Run("--metadata and --metadata-file conflict", func(t *testing.T) { + stubOutpostAPI(t, nil) + parent := newOutpostDestinationCmd() + _, err := runCommand(t, parent.cmd, "update", "des_1", + "--tenant-id", "acme", "--metadata", "a=b", "--metadata-file", "x.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--metadata and --metadata-file cannot be used together") + }) + + t.Run("a pair without = is rejected", func(t *testing.T) { + got, err := resolveOutpostMetadata([]string{"justakey"}, "") + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "must be in key=value form") + }) + + t.Run("metadata alone satisfies update's has-anything check", func(t *testing.T) { + fields := outpostDestinationFieldFlags{metadata: []string{"a=b"}} + assert.True(t, fields.hasAny(), + "--metadata on its own must be a valid update, not 'nothing to update'") + }) + + t.Run("no metadata flags means leave metadata alone", func(t *testing.T) { + got, err := resolveOutpostMetadata(nil, "") + require.NoError(t, err) + assert.Nil(t, got, "nil, not an empty map, or the update would clear the metadata") + }) +} + +// --------------------------------------------------------------------------- +// Empty-value rejection on outpost flags +// --------------------------------------------------------------------------- + +// An unexported shell variable expands to an empty string, so `--tenant-id +// "$TENANT"` would otherwise address a different path rather than fail. +func TestOutpostFlagsRejectEmptyValues(t *testing.T) { + cases := []struct { + name string + args []string + flag string + }{ + {"tenant-id on destination list", []string{"list", "--tenant-id", ""}, "--tenant-id"}, + {"type on destination create", []string{"create", "--tenant-id", "acme", "--type", ""}, "--type"}, + {"config on destination create", []string{"create", "--tenant-id", "acme", "--type", "webhook", "--config", ""}, "--config"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requests := stubOutpostAPI(t, nil) + parent := newOutpostDestinationCmd() + _, err := runCommand(t, parent.cmd, tc.args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.flag) + assert.Empty(t, *requests, "an empty value must fail before any request") + }) + } + + t.Run("theme on tenant portal", func(t *testing.T) { + requests := stubOutpostAPI(t, nil) + _, err := runCommand(t, newOutpostTenantPortalCmd().cmd, "acme", "--theme", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--theme") + assert.Empty(t, *requests) + }) +} diff --git a/pkg/cmd/outpost_tenant_upsert.go b/pkg/cmd/outpost_tenant_upsert.go index 70b06045..02b04854 100644 --- a/pkg/cmd/outpost_tenant_upsert.go +++ b/pkg/cmd/outpost_tenant_upsert.go @@ -2,10 +2,7 @@ package cmd import ( "context" - "encoding/json" "fmt" - "os" - "strings" "github.com/spf13/cobra" @@ -93,30 +90,5 @@ func (tc *outpostTenantUpsertCmd) runOutpostTenantUpsertCmd(cmd *cobra.Command, } func (tc *outpostTenantUpsertCmd) resolveMetadata() (map[string]string, error) { - if tc.metadataFile != "" { - contents, err := os.ReadFile(tc.metadataFile) - if err != nil { - return nil, fmt.Errorf("failed to read --metadata-file: %w", err) - } - var metadata map[string]string - if err := json.Unmarshal(contents, &metadata); err != nil { - return nil, fmt.Errorf("--metadata-file must contain a JSON object of string values: %w", err) - } - return metadata, nil - } - - if len(tc.metadata) == 0 { - return nil, nil - } - - metadata := make(map[string]string, len(tc.metadata)) - for _, entry := range tc.metadata { - key, value, found := strings.Cut(entry, "=") - key = strings.TrimSpace(key) - if !found || key == "" { - return nil, fmt.Errorf("--metadata %q must be in key=value form", entry) - } - metadata[key] = value - } - return metadata, nil + return resolveOutpostMetadata(tc.metadata, tc.metadataFile) } From c065c770596d95dbf859afcc2ed2480ee663151c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:19:08 +0100 Subject: [PATCH 24/49] fix(outpost): make API errors readable, and cover the portal and custom domain The tenant portal and the three custom-domain commands had no automated coverage of any kind. Adding it surfaced two defects. An error body whose "data" member is an object failed to decode, so the whole envelope was passed through to the user as raw JSON with the readable sentence buried inside it. That is the shape used for not-found and for several rejected-value errors, so it affected a large share of the Outpost errors anyone would actually hit. ErrorResponse now accepts every shape the API returns. `outpost tenant portal` answers 404 whenever the project has no portal, which reads as a missing tenant. It now names the precondition its own help already documents, and the command to fix it. The new acceptance test configures a custom domain rather than being gated behind an opt-in env var, because an opt-in would not run in CI and these are the commands with the least coverage. Prior state is read first and restored in t.Cleanup, and the hostname is unique per run. Also covers tenant list pagination, which accepted --next and --prev but had never been sent one, and `destination-type get` with an unknown type. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost_rune_test.go | 18 +++ pkg/cmd/outpost_tenant_portal.go | 13 ++ pkg/hookdeck/client.go | 84 +++++++++++++ pkg/hookdeck/error_response_test.go | 92 ++++++++++++++ test/acceptance/outpost_test.go | 178 ++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 pkg/hookdeck/error_response_test.go diff --git a/pkg/cmd/outpost_rune_test.go b/pkg/cmd/outpost_rune_test.go index 1b2c3d13..abe2b64e 100644 --- a/pkg/cmd/outpost_rune_test.go +++ b/pkg/cmd/outpost_rune_test.go @@ -375,6 +375,24 @@ func TestOutpostTenantPortal(t *testing.T) { assert.Equal(t, "https://portal.example.com/s/abc\n", stdout) }) + // The endpoint answers 404 whenever the project has no portal, which reads + // as a missing tenant unless the CLI says otherwise. The precondition is + // documented in the command's help, so the error has to name it too. + t.Run("a project with no portal is explained, not dumped", func(t *testing.T) { + stubOutpostAPI(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/tenants/acme/portal": jsonResponse(http.StatusNotFound, map[string]any{ + "data": map[string]any{"message": "Portal not configured for this project"}, + "code": "NOT_FOUND", + }), + }) + + _, err := runCommand(t, newOutpostTenantPortalCmd().cmd, "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "no tenant portal") + assert.Contains(t, err.Error(), "custom-domain set", "the error has to name the fix") + assert.NotContains(t, err.Error(), `"code"`, "the raw response body is not an error message") + }) + t.Run("an invalid theme is rejected before the request", func(t *testing.T) { requests := stubOutpostAPI(t, nil) _, err := runCommand(t, newOutpostTenantPortalCmd().cmd, "acme", "--theme", "neon") diff --git a/pkg/cmd/outpost_tenant_portal.go b/pkg/cmd/outpost_tenant_portal.go index 85b5896f..91deb6ca 100644 --- a/pkg/cmd/outpost_tenant_portal.go +++ b/pkg/cmd/outpost_tenant_portal.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/open" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -70,6 +71,18 @@ func (tc *outpostTenantPortalCmd) runOutpostTenantPortalCmd(cmd *cobra.Command, portal, err := client.GetOutpostTenantPortalURL(context.Background(), args[0], tc.theme) if err != nil { + // A 404 here is the documented precondition, not a missing tenant: the + // endpoint answers this way whenever the project has no portal to + // redirect to. Passing the raw response through would leave the reader + // looking for a tenant that exists. + if hookdeck.IsNotFoundError(err) { + return fmt.Errorf(`this project has no tenant portal, so there is no URL to return. + +A portal needs a custom domain, and the change takes a short while to reach the deployment: + + hookdeck outpost config custom-domain set + hookdeck outpost status`) + } return fmt.Errorf("failed to get tenant portal URL: %w", err) } diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 276a4325..162b4bb1 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "sort" "strings" "time" @@ -124,6 +125,89 @@ type ErrorResponse struct { Data []string `json:"data,omitempty"` } +// UnmarshalJSON decodes an error body, accepting every shape "data" is returned +// in rather than only the array of strings a validation failure uses. +// +// This is not cosmetic. The caller falls back to dumping the raw response body +// whenever this decode fails, so a body whose "data" is an object — which is +// how not-found and several rejected-value errors come back — reached the user +// as a wall of JSON with the readable message buried inside it. Being liberal +// here is what turns those into a sentence. +func (e *ErrorResponse) UnmarshalJSON(data []byte) error { + // A distinct type avoids recursing into this method. + var raw struct { + Handled bool `json:"Handled"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + e.Handled = raw.Handled + e.Message = raw.Message + e.Data = flattenErrorData(raw.Data) + + return nil +} + +// flattenErrorData renders the "data" member as lines of detail. +func flattenErrorData(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var decoded interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil + } + + switch value := decoded.(type) { + case string: + return []string{value} + + case []interface{}: + lines := make([]string, 0, len(value)) + for _, item := range value { + lines = append(lines, errorDataScalar(item)) + } + return lines + + case map[string]interface{}: + // A nested message is the whole story; the surrounding keys are + // bookkeeping, so repeating them would only add noise. + if message, ok := value["message"].(string); ok && message != "" { + return []string{message} + } + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+": "+errorDataScalar(value[key])) + } + return lines + + default: + return []string{errorDataScalar(decoded)} + } +} + +// errorDataScalar renders one detail value, keeping strings unquoted. +func errorDataScalar(value interface{}) string { + if s, ok := value.(string); ok { + return s + } + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} + // Detail returns the message with any field-level detail appended. func (e *ErrorResponse) Detail() string { if len(e.Data) == 0 { diff --git a/pkg/hookdeck/error_response_test.go b/pkg/hookdeck/error_response_test.go new file mode 100644 index 00000000..e68e0091 --- /dev/null +++ b/pkg/hookdeck/error_response_test.go @@ -0,0 +1,92 @@ +package hookdeck + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The API returns "data" in several shapes. Anything this fails to decode is +// reported to the user as a raw JSON dump, so the tolerance is the feature. +func TestErrorResponseDecodesEveryDataShape(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "message only", + body: `{"message":"validation error"}`, + want: "validation error", + }, + { + name: "field detail as an array", + body: `{"message":"validation error","data":["topic is invalid"]}`, + want: "validation error: topic is invalid", + }, + { + // The shape returned when a resource or a project-level feature is + // missing. Before this decode existed the reader got the whole + // envelope instead of the sentence inside it. + name: "message nested under data", + body: `{"data":{"message":"Portal not configured for this project"},"status":404,"code":"NOT_FOUND","model":null}`, + want: "Portal not configured for this project", + }, + { + name: "rejected value named under data", + body: `{"code":"CUSTOM_DOMAIN_INVALID","status":429,"message":"Custom domain is an invalid hostname","data":{"hostname":"portal.example.com"}}`, + want: "Custom domain is an invalid hostname: hostname: portal.example.com", + }, + { + name: "data as a bare string", + body: `{"message":"nope","data":"because"}`, + want: "nope: because", + }, + { + name: "data as a null", + body: `{"message":"nope","data":null}`, + want: "nope", + }, + { + name: "no message at all", + body: `{"data":["topic is invalid"]}`, + want: "topic is invalid", + }, + { + name: "non-string entries are still rendered", + body: `{"message":"nope","data":{"limit":5}}`, + want: "nope: limit: 5", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var response ErrorResponse + require.NoError(t, json.Unmarshal([]byte(tc.body), &response)) + assert.Equal(t, tc.want, response.Detail()) + }) + } +} + +// Several object keys are rendered in a stable order, so the message does not +// change between runs. +func TestErrorResponseOrdersFieldDetail(t *testing.T) { + var response ErrorResponse + require.NoError(t, json.Unmarshal( + []byte(`{"message":"nope","data":{"b":"2","a":"1","c":"3"}}`), &response)) + assert.Equal(t, []string{"a: 1", "b: 2", "c: 3"}, response.Data) +} + +// Tests elsewhere in this package serialise ErrorResponse to build stub +// responses, so the round trip has to survive the custom decoder. +func TestErrorResponseRoundTrips(t *testing.T) { + encoded, err := json.Marshal(ErrorResponse{Message: "test error", Data: []string{"a", "b"}}) + require.NoError(t, err) + + var decoded ErrorResponse + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, "test error", decoded.Message) + assert.Equal(t, []string{"a", "b"}, decoded.Data) +} diff --git a/test/acceptance/outpost_test.go b/test/acceptance/outpost_test.go index 392e83d0..ee4e559b 100644 --- a/test/acceptance/outpost_test.go +++ b/test/acceptance/outpost_test.go @@ -75,6 +75,184 @@ func TestOutpostDestinationTypes(t *testing.T) { assert.Contains(t, stdout, "--config fields:") assert.Contains(t, stdout, "url") assert.Contains(t, stdout, "required") + + // The types are fetched from the deployment rather than hardcoded, so an + // unknown one has to be answered with the list that is actually available — + // otherwise the only way to find a valid type is to guess. + t.Run("an unknown type lists the available ones", func(t *testing.T) { + stdout, _, err := cli.Run("outpost", "destination-type", "get", "banana") + require.Error(t, err) + assert.Contains(t, stdout, `unknown destination type "banana"`) + assert.Contains(t, stdout, "webhook", "the error should list the types that are valid") + }) +} + +// TestOutpostTenantPagination walks a cursor rather than trusting that the flag +// is wired up. --next and --prev were accepted by every list command but no +// test had ever sent one, so a dropped cursor would have looked like a short +// page instead of a bug. +func TestOutpostTenantPagination(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + // Three tenants guarantee at least two pages at a limit of one, whatever + // else is already in the shared project. + created := make([]string, 0, 3) + for i := 0; i < 3; i++ { + created = append(created, createTestTenant(t, cli)) + } + + type page struct { + Models []struct { + ID string `json:"id"` + } `json:"models"` + Pagination struct { + Next *string `json:"next"` + Prev *string `json:"prev"` + } `json:"pagination"` + } + + readPage := func(args ...string) page { + t.Helper() + var p page + stdout := cli.RunExpectSuccess(append([]string{"outpost", "tenant", "list", "--limit", "1", "--output", "json"}, args...)...) + require.NoError(t, json.Unmarshal([]byte(stdout), &p)) + return p + } + + first := readPage() + require.Len(t, first.Models, 1, "--limit was not applied") + require.NotNil(t, first.Pagination.Next, "a next cursor is required to page forward") + + second := readPage("--next", *first.Pagination.Next) + require.Len(t, second.Models, 1) + assert.NotEqual(t, first.Models[0].ID, second.Models[0].ID, + "--next returned the same record, so the cursor was not sent") + + // Paging back from the second page must return the first one. This is the + // assertion that a cursor is being used rather than silently ignored. + require.NotNil(t, second.Pagination.Prev) + back := readPage("--prev", *second.Pagination.Prev) + require.Len(t, back.Models, 1) + assert.Equal(t, first.Models[0].ID, back.Models[0].ID) + + assert.Len(t, created, 3) +} + +// TestOutpostTenantPortalAndCustomDomain covers the tenant portal and the +// custom-domain commands, which between them had no automated coverage at all. +// +// They have to be tested together: a portal URL only exists once the project +// has a portal custom domain, so proving `tenant portal` works means configuring +// one. +// +// This mutates a project-wide setting rather than being gated behind an opt-in +// env var. An opt-in would never run in CI, which is the same silent +// non-execution this work set out to remove — and these are the commands most +// in need of a real run, since nothing else exercises them. The blast radius is +// contained instead: the prior state is read first and restored in t.Cleanup, +// whether the test passes or fails, and the hostname is unique per run. +// +// The hostname is a subdomain of a domain Hookdeck owns because the API rejects +// the reserved test TLDs (.test, .invalid, .example) and example.com. No DNS +// record is ever created, so the domain stays unverified and is removed at the +// end of the test. +func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewOutpostCLIRunner(t) + + readCustomDomain := func() string { + t.Helper() + stdout := cli.RunExpectSuccess("outpost", "config", "custom-domain", "get", "--output", "json") + var domain struct { + Hostname string `json:"hostname"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &domain)) + return domain.Hostname + } + + before := readCustomDomain() + t.Cleanup(func() { + // The happy path already removed it, so only delete what is still there. + if readCustomDomain() != "" { + if _, _, err := cli.Run("outpost", "config", "custom-domain", "delete", "--force"); err != nil { + t.Errorf("cleanup: could not remove the test custom domain: %v", err) + } + } + if before != "" { + if _, _, err := cli.Run("outpost", "config", "custom-domain", "set", before); err != nil { + t.Errorf("cleanup: could not restore the project's custom domain %q: %v", before, err) + } + } + }) + + if before != "" { + cli.RunExpectSuccess("outpost", "config", "custom-domain", "delete", "--force") + } + + hostname := fmt.Sprintf("cli-acceptance-%d.hookdeck.com", time.Now().UnixNano()) + + stdout := cli.RunExpectSuccess("outpost", "config", "custom-domain", "set", hostname) + assert.Contains(t, stdout, hostname) + + stdout = cli.RunExpectSuccess("outpost", "config", "custom-domain", "get") + assert.Contains(t, stdout, hostname) + assert.Contains(t, stdout, "Status:", "a domain that is not yet verified has to say so") + + tenantID := createTestTenant(t, cli) + + // Configuration changes reach the deployment asynchronously, so the portal + // 404s for a short while after the domain is set. Polling here is the + // difference between testing the command and testing the propagation delay. + var portalURL string + require.Eventually(t, func() bool { + out, _, err := cli.Run("outpost", "tenant", "portal", tenantID) + if err != nil { + return false + } + portalURL = strings.TrimSpace(out) + return strings.Contains(portalURL, hostname) + }, 90*time.Second, 5*time.Second, "the portal URL never became available after setting a custom domain") + + // The URL is a credential and scripts pipe it, so it must be the only thing + // printed. + assert.Equal(t, 1, len(strings.Split(portalURL, "\n")), "the portal URL must be printed on its own") + assert.Contains(t, portalURL, "token=", "the URL carries the tenant's portal session") + + t.Run("theme is passed through", func(t *testing.T) { + for _, theme := range []string{"light", "dark"} { + out := cli.RunExpectSuccess("outpost", "tenant", "portal", tenantID, "--theme", theme, "--output", "json") + var portal struct { + RedirectURL string `json:"redirect_url"` + TenantID string `json:"tenant_id"` + } + require.NoError(t, json.Unmarshal([]byte(out), &portal)) + assert.Equal(t, tenantID, portal.TenantID) + assert.Contains(t, portal.RedirectURL, "theme="+theme) + } + }) + + t.Run("an invalid theme is rejected", func(t *testing.T) { + stdout, _, err := cli.Run("outpost", "tenant", "portal", tenantID, "--theme", "neon") + require.Error(t, err) + assert.Contains(t, stdout, "--theme must be either light or dark") + }) + + // --open launches a browser, so it is never exercised here; this only + // checks it is still offered, since the flag is otherwise unreferenced. + t.Run("--open is offered", func(t *testing.T) { + help := cli.RunExpectSuccess("outpost", "tenant", "portal", "--help") + assert.Contains(t, help, "--open") + }) + + cli.RunExpectSuccess("outpost", "config", "custom-domain", "delete", "--force") + assert.Empty(t, readCustomDomain(), "the domain should be gone after delete") } func TestOutpostTenantLifecycle(t *testing.T) { From d2d0b9125b028cced70582c0b2608e8c1de2d9b1 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:20:33 +0100 Subject: [PATCH 25/49] ci(outpost): run the live smoke test nightly instead of never MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outpostlive build tag appeared in no workflow, so the only test proving the Outpost API client works against a real host had never run automatically — silent non-execution rather than a considered decision. It stays out of the pull-request matrix on purpose: it makes real requests to a live deployment, so a deployment problem would fail every unrelated PR. A nightly schedule plus workflow_dispatch keeps it honest without coupling it to the PR gate. The tests skip themselves when the key is absent, which is right locally and wrong in CI, so the job fails fast on a missing secret rather than reporting a green run that tested nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- .github/workflows/outpost-live.yml | 46 ++++++++++++++++++++++++++++++ test/acceptance/README.md | 16 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .github/workflows/outpost-live.yml diff --git a/.github/workflows/outpost-live.yml b/.github/workflows/outpost-live.yml new file mode 100644 index 00000000..56396bf3 --- /dev/null +++ b/.github/workflows/outpost-live.yml @@ -0,0 +1,46 @@ +name: Outpost Live Smoke Test + +# The `outpostlive` build tag is the only thing that proves the Outpost API +# client works against a real Outpost host: everything under `-tags=outpost` +# drives the CLI, and the client's own unit tests run against stub servers, +# which can only assert the implementation's assumptions back at it. +# +# It is deliberately not part of the pull-request acceptance matrix. It makes +# real requests to a live deployment, so a deployment problem would fail every +# unrelated PR. A nightly run keeps it honest without coupling it to the PR +# gate, and workflow_dispatch makes it runnable on demand — run it before +# cutting a release. +on: + schedule: + # 05:00 UTC daily. + - cron: "0 5 * * *" + workflow_dispatch: + workflow_call: + +jobs: + outpost-live: + runs-on: ubuntu-latest + env: + HOOKDECK_CLI_OUTPOST_TESTING_API_KEY: ${{ secrets.HOOKDECK_CLI_OUTPOST_TESTING_API_KEY }} + HOOKDECK_CLI_TELEMETRY_DISABLED: "1" + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: "1.26.6" + + # The tests skip themselves when the key is absent, which is right for a + # developer machine and wrong here: a missing secret would turn this job + # green while testing nothing. + - name: Require the Outpost testing key + run: | + if [ -z "$HOOKDECK_CLI_OUTPOST_TESTING_API_KEY" ]; then + echo "HOOKDECK_CLI_OUTPOST_TESTING_API_KEY is not set; the live tests would skip silently." >&2 + exit 1 + fi + + - name: Run Outpost live smoke test + run: go test -tags=outpostlive ./test/acceptance/... -v -timeout 12m diff --git a/test/acceptance/README.md b/test/acceptance/README.md index 63ca3b8b..3762118e 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -15,6 +15,22 @@ These tests run automatically in CI using API keys from `hookdeck ci`. They don' **Guest login (mock API, `guest` tag):** `guest_login_acceptance_test.go` asserts `POST /cli-auth` receives guest credentials when a guest profile is present, and omits them after logout (empty profile). +### 1b. Outpost live smoke test (`outpostlive` tag) + +`outpost_live_test.go` carries `//go:build outpostlive` and is **not** part of the pull-request acceptance matrix. It calls the Outpost API client directly against a real Outpost host, which is the only thing that proves the client's stored credentials, request shapes and response decoding work outside a stub server — everything under `-tags=outpost` drives the CLI, and the client's unit tests only assert the implementation's assumptions back at it. + +It is kept out of the PR gate because a live-deployment problem would then fail every unrelated pull request. Instead it runs: + +- **nightly**, and on demand, via `.github/workflows/outpost-live.yml` (`workflow_dispatch`); +- **before cutting a release** — run it manually if the last nightly is not recent. + +```bash +# Requires HOOKDECK_CLI_OUTPOST_TESTING_API_KEY (a Project API key for an Outpost project) +go test -tags=outpostlive ./test/acceptance/... -v -timeout 12m +``` + +The tests skip themselves when the key is absent, which is right on a developer machine and wrong in CI, so the workflow fails fast if the secret is missing rather than reporting a green job that tested nothing. + ### 2. Manual Tests (Require Human Interaction) These tests require browser-based authentication via `hookdeck login` and must be run manually by developers. From db6343440d438d5e059a8dda82ae10972a7b526f Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:24:11 +0100 Subject: [PATCH 26/49] fix(outpost): break the project-switch loop on the first-run path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An outpost command run against a non-Outpost project said to run 'hookdeck project use'. With a project-scoped credential that command refuses and says to sign in again — and signing in with the same key lands back on the original error. Three commands, no way out, on the path every new user takes. The guard now establishes whether the credential can switch projects at all before advising, and when it cannot, names the two things that do work: signing in with an account-wide key, or pointing the machine at the Outpost project with its own API key. The extra request is only made on a path that has already failed, and a failure to make it falls back to the previous advice rather than compounding one error with another. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/cmd/outpost.go | 35 ++++++++++++++++++++++++++- pkg/cmd/outpost_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/outpost.go b/pkg/cmd/outpost.go index af27e629..312d0d18 100644 --- a/pkg/cmd/outpost.go +++ b/pkg/cmd/outpost.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/project" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -64,11 +65,43 @@ func requireOutpostProject(cfg *config.Config) error { } if !config.IsOutpostProject(projectType) { - return fmt.Errorf("this command requires an Outpost project; current project type is %s. Use 'hookdeck project use' to switch to an Outpost project", projectType) + return wrongProjectTypeError(cfg, projectType) } return nil } +// wrongProjectTypeError explains how to reach an Outpost project from here. +// +// "Run 'hookdeck project use'" is the right advice only when the stored +// credential can switch projects. A project-scoped key cannot, and `project use` +// answers it by saying to log in again — which, with the same key, lands back on +// this error. Naming the real cause is what breaks that circle. +// +// Establishing the credential's scope costs a request, so it is only made here, +// on a path that has already failed. If the check itself fails, fall back to the +// general advice rather than compounding one error with another. +func wrongProjectTypeError(cfg *config.Config, projectType string) error { + described := projectType + if described == "" { + described = "unknown" + } + + scoped, err := project.CredentialsLackUserAssociation(cfg.GetAPIClient()) + if err == nil && scoped { + return fmt.Errorf(`this command requires an Outpost project; the active project is a %s project. + +The stored credential belongs to that one project, so 'hookdeck project use' cannot switch away from it. Either sign in with an account-wide key: + + hookdeck login + +or point this machine at the Outpost project directly, using that project's API key: + + hookdeck ci --api-key `, described) + } + + return fmt.Errorf("this command requires an Outpost project; current project type is %s. Use 'hookdeck project use' to switch to an Outpost project", described) +} + func newOutpostCmd() *outpostCmd { oc := &outpostCmd{} diff --git a/pkg/cmd/outpost_test.go b/pkg/cmd/outpost_test.go index e225b690..913627e0 100644 --- a/pkg/cmd/outpost_test.go +++ b/pkg/cmd/outpost_test.go @@ -1,6 +1,9 @@ package cmd import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/spf13/cobra" @@ -75,6 +78,56 @@ func TestRequireOutpostProject(t *testing.T) { }) } +// The wrong-project-type error is on the first-run path, and its advice used to +// be circular for anyone holding a project-scoped key: it sent them to +// `hookdeck project use`, which refuses a project-scoped key and tells them to +// log in, which with the same key returns them here. +func TestRequireOutpostProjectExplainsAProjectScopedKey(t *testing.T) { + validate := func(t *testing.T, response map[string]any) *config.Config { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(response) + })) + t.Cleanup(server.Close) + + cfg := &config.Config{APIBaseURL: server.URL} + cfg.Profile.APIKey = "sk_xxx" + cfg.Profile.ProjectId = "proj_1" + cfg.Profile.ProjectType = config.ProjectTypeGateway + + // GetAPIClient is a process-wide singleton, so it has to be rebuilt for + // this config or the request goes wherever an earlier test pointed it. + config.ResetAPIClientForTesting() + t.Cleanup(config.ResetAPIClientForTesting) + + return cfg + } + + t.Run("a project-scoped key is named as the reason", func(t *testing.T) { + // No user_id is how a single-project credential is recognised. + cfg := validate(t, map[string]any{"team_id": "proj_1", "team_mode": "inbound"}) + + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot switch away from it") + assert.Contains(t, err.Error(), "hookdeck login") + assert.Contains(t, err.Error(), "hookdeck ci --api-key") + // `project use` may be mentioned, but only to rule it out — offering it + // as the fix is the dead end this replaced. + assert.NotContains(t, err.Error(), "Use 'hookdeck project use' to switch") + }) + + t.Run("an account-wide key is still told to switch project", func(t *testing.T) { + cfg := validate(t, map[string]any{ + "user_id": "usr_1", "team_id": "proj_1", "team_mode": "inbound", + }) + + err := requireOutpostProject(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "hookdeck project use") + }) +} + func TestIsOutpostMCPLeafCommand(t *testing.T) { t.Parallel() From 0506c0c67cede5c918fcd34558413617ccc901e3 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:30:17 +0100 Subject: [PATCH 27/49] test(outpost): keep the custom-domain test from colliding with a parallel run A project's custom domain is a single value, so two acceptance runs against the same project can overwrite each other's. The hostname is still asserted on the fast path, immediately after set; the slow poll now only waits for a portal URL to exist, and cleanup only removes the domain this test set. Otherwise a collision between runs would be reported as a CLI defect. Documents --metadata on tenants and destinations in the README. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- README.md | 2 ++ test/acceptance/outpost_test.go | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e0de3e9e..7d44a4c8 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,8 @@ hookdeck outpost destination create --type kafka --help Both list every field with whether it is required, whether it is sensitive, and any values or format it is constrained to. `--config-file` accepts a JSON object, and nested values — should a type ever need them — use dotted paths (`--config a.b=c`). +Tenants and destinations also carry `--metadata key=value` (repeatable, or `--metadata-file` for a JSON object) for your own correlation data. It is replaced wholesale rather than merged, so pass every key you want to keep. + #### Publishing `hookdeck outpost publish` is the one command that does **not** use the credentials stored by `hookdeck login`. The publish API requires a Hookdeck **Project API key**, so pass `--api-key` or set `HOOKDECK_API_KEY`: diff --git a/test/acceptance/outpost_test.go b/test/acceptance/outpost_test.go index ee4e559b..224a9f96 100644 --- a/test/acceptance/outpost_test.go +++ b/test/acceptance/outpost_test.go @@ -177,10 +177,15 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { return domain.Hostname } + hostname := fmt.Sprintf("cli-acceptance-%d.hookdeck.com", time.Now().UnixNano()) + before := readCustomDomain() t.Cleanup(func() { - // The happy path already removed it, so only delete what is still there. - if readCustomDomain() != "" { + // Only remove the domain this test set. The happy path already deleted + // it, and a concurrent run against the same project may have configured + // its own by now — deleting that would fail the other run rather than + // this one. + if readCustomDomain() == hostname { if _, _, err := cli.Run("outpost", "config", "custom-domain", "delete", "--force"); err != nil { t.Errorf("cleanup: could not remove the test custom domain: %v", err) } @@ -196,8 +201,6 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { cli.RunExpectSuccess("outpost", "config", "custom-domain", "delete", "--force") } - hostname := fmt.Sprintf("cli-acceptance-%d.hookdeck.com", time.Now().UnixNano()) - stdout := cli.RunExpectSuccess("outpost", "config", "custom-domain", "set", hostname) assert.Contains(t, stdout, hostname) @@ -210,6 +213,11 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { // Configuration changes reach the deployment asynchronously, so the portal // 404s for a short while after the domain is set. Polling here is the // difference between testing the command and testing the propagation delay. + // + // The hostname is asserted above, on the fast path; this only waits for a + // URL to exist. Requiring our hostname here as well would make the test + // fail whenever a concurrent run against the same project has replaced the + // domain in the meantime — a collision between runs, not a CLI defect. var portalURL string require.Eventually(t, func() bool { out, _, err := cli.Run("outpost", "tenant", "portal", tenantID) @@ -217,7 +225,7 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { return false } portalURL = strings.TrimSpace(out) - return strings.Contains(portalURL, hostname) + return strings.Contains(portalURL, "token=") }, 90*time.Second, 5*time.Second, "the portal URL never became available after setting a custom domain") // The URL is a credential and scripts pipe it, so it must be the only thing From e53630883937762c1835232139f2412dc51228da Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 13:53:19 +0100 Subject: [PATCH 28/49] fix(gateway mcp): treat transformations run as a read, not a write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run was gated behind --allow-write on the reasoning that it executes caller-supplied code. Checked against the API instead of assumed: a run creates no execution record and returns no execution id, modifies no transformation, connection or event, and delivers nothing to a destination. The execution_id and request_id fields on the response are populated only when running against an already captured request, and reference that existing record rather than creating one. Gating it also worked against the mode it was meant to protect. A read-only session can already read transformation code; without run it cannot try that code against a sample payload, so it cannot debug a transformation at all — which is the investigation work read-only mode exists for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/gateway/mcp/tool_transformations.go | 20 ++++++++++------ pkg/gateway/mcp/write_mode_test.go | 32 ++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/pkg/gateway/mcp/tool_transformations.go b/pkg/gateway/mcp/tool_transformations.go index 82d3b5a3..c55d85cf 100644 --- a/pkg/gateway/mcp/tool_transformations.go +++ b/pkg/gateway/mcp/tool_transformations.go @@ -17,14 +17,20 @@ var transformationsActions = mcpcore.ActionSet{ {Name: "update", Desc: "update a transformation's code or environment", Write: true}, {Name: "delete", Desc: "delete a transformation", Write: true, Destructive: true}, - // run is marked Write even though it stores nothing. + // run is a read: it is a sandbox evaluation with no side effects. // - // It executes caller-supplied JavaScript on Hookdeck's transformation - // runtime. Treating "changes no records" as "is a read" would let a - // read-only session run arbitrary code, which is not what a user asking for - // read-only is asking for. The gate is about what the session can cause to - // happen, not only about what it persists. - {Name: "run", Desc: "execute transformation code against a sample request and return the result", Write: true}, + // Verified against the API — a run creates no execution record and returns + // no execution id. It modifies no transformation, connection or event, and + // delivers nothing to a destination. The execution_id and request_id fields + // on the response are only populated when running against an already + // captured request, and they reference that existing record rather than + // creating one. + // + // Keeping it available in read-only mode is also what makes the mode useful: + // a session that can read transformation code but cannot try it against a + // sample payload cannot actually debug a transformation, which is the + // investigation work read-only mode exists for. + {Name: "run", Desc: "execute transformation code against a sample request and return the result"}, } var transformationsSpec = mcpcore.ToolSpec{ diff --git a/pkg/gateway/mcp/write_mode_test.go b/pkg/gateway/mcp/write_mode_test.go index 4eeaed20..63cca75c 100644 --- a/pkg/gateway/mcp/write_mode_test.go +++ b/pkg/gateway/mcp/write_mode_test.go @@ -82,7 +82,9 @@ func TestListTools_ReadOnlyMode(t *testing.T) { assert.Equal(t, []string{"list", "get", "pause", "unpause"}, actionEnum(t, tools["gateway_connections"])) assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_sources"])) assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_destinations"])) - assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_transformations"])) + // run stays available: it is a sandbox evaluation that persists nothing, + // and debugging a transformation is read-only-mode work. + assert.Equal(t, []string{"list", "get", "run"}, actionEnum(t, tools["gateway_transformations"])) assert.Equal(t, []string{"list", "get", "raw_body"}, actionEnum(t, tools["gateway_events"])) assert.Equal(t, []string{"list", "get", "raw_body", "events", "ignored_events"}, actionEnum(t, tools["gateway_requests"])) assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_issues"])) @@ -102,7 +104,7 @@ func TestListTools_ReadOnlyMode(t *testing.T) { "gateway_transformations", "gateway_events", "gateway_requests", "gateway_issues", } { description := tools[name].Description - for _, action := range []string{"create", "upsert", "update", "delete", "retry", "cancel", "mute", "dismiss", "run"} { + for _, action := range []string{"create", "upsert", "update", "delete", "retry", "cancel", "mute", "dismiss"} { assert.NotContains(t, description, " "+action+" (", "%s should not describe the %s action", name, action) } } @@ -221,7 +223,6 @@ func TestWriteGuard_BlocksWriteActionsInReadOnlyMode(t *testing.T) { {"gateway_destinations", map[string]any{"action": "delete", "id": "des_1"}}, {"gateway_transformations", map[string]any{"action": "create", "name": "t", "code": "return"}}, {"gateway_transformations", map[string]any{"action": "delete", "id": "trs_1"}}, - {"gateway_transformations", map[string]any{"action": "run", "code": "return request"}}, {"gateway_events", map[string]any{"action": "retry", "id": "evt_1"}}, {"gateway_events", map[string]any{"action": "cancel", "id": "evt_1"}}, {"gateway_events", map[string]any{"action": "mute", "id": "evt_1"}}, @@ -268,6 +269,31 @@ func TestWriteGuard_PauseIsNotGated(t *testing.T) { } } +// TestWriteGuard_TransformationRunIsNotGated pins run as a read. +// +// A run is a sandbox evaluation: verified against the API, it creates no +// execution record and returns no execution id. Gating it would leave a +// read-only session able to read transformation code but unable to try it, +// which is the debugging work read-only mode is for. +func TestWriteGuard_TransformationRunIsNotGated(t *testing.T) { + api := mockAPI(t, map[string]http.HandlerFunc{ + "/2025-07-01/transformations/run": func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "request": map[string]any{"headers": map[string]any{}}, + }) + }, + }) + session := connectInMemory(t, newTestClient(api.URL, "test-key")) + + result := callTool(t, session, "gateway_transformations", map[string]any{ + "action": "run", + "code": "addHandler(\"transform\", (request, context) => { return request; });", + "request": map[string]any{"headers": map[string]any{}}, + }) + assert.False(t, result.IsError, "run must stay available in read-only mode: %s", + textContent(t, result)) +} + func TestWriteGuard_AllowsWriteActionsInWriteMode(t *testing.T) { var seen []string record := func(w http.ResponseWriter, r *http.Request) { From c18a1d7e440d0c9ee69dacbfd41a666fabd54a23 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 14:54:06 +0100 Subject: [PATCH 29/49] test(gateway mcp): call every tool action successfully at least once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-one of the twenty-eight write actions had only a read-only refusal test, so the first successful delete, upsert or disable would have run for the first time in a user's project. Several reads had no test at all. Each action now has a test that makes the call through a real MCP session and asserts the request that goes on the wire — method, path, query and body. A "no error" assertion proves little here: the stub answers whatever it is asked, so a handler sending the wrong method or path still passes. TestEveryActionHasBeenCalledSuccessfully enumerates the actions from the tool specs themselves and fails when one has no successful-call test recorded, so adding an action without covering it breaks the build. A companion test rejects checklist entries for actions that no longer exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/gateway/mcp/write_actions_test.go | 1100 +++++++++++++++++++++++++ 1 file changed, 1100 insertions(+) create mode 100644 pkg/gateway/mcp/write_actions_test.go diff --git a/pkg/gateway/mcp/write_actions_test.go b/pkg/gateway/mcp/write_actions_test.go new file mode 100644 index 00000000..b656e693 --- /dev/null +++ b/pkg/gateway/mcp/write_actions_test.go @@ -0,0 +1,1100 @@ +package mcp + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every action of every Event Gateway tool, called successfully at least once. +// +// Most write actions previously had only their read-only refusal covered, so +// the first successful invocation of a delete or an upsert would have happened +// in a user's project. The reads no test called at all are here for the same +// reason. +// +// These assert the request that goes on the wire — method, path, query and body +// — rather than only that the call did not error. A "no error" assertion proves +// almost nothing here, because the stub server answers whatever it is asked: a +// handler sending PUT to the wrong path would still pass. + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// wireRequest records the request a handler actually sent. +type wireRequest struct { + method string + path string + query string + body []byte +} + +// record captures the incoming request and replies with response. A nil +// response writes no body, which is what the delete endpoints do. +func record(into *wireRequest, status int, response any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *into = wireRequest{method: r.Method, path: r.URL.Path, query: r.URL.RawQuery, body: body} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if response != nil { + _ = json.NewEncoder(w).Encode(response) + } + } +} + +// ok is record with a 200 status, which is what these endpoints return. +func ok(into *wireRequest, response any) http.HandlerFunc { + return record(into, http.StatusOK, response) +} + +// decodeBody unmarshals the captured request body as a JSON object. +func (w wireRequest) decodeBody(t *testing.T) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal(w.body, &body), "request body was not a JSON object: %s", w.body) + return body +} + +// envelopeData returns the data half of the standard result envelope. +func envelopeData(t *testing.T, text string) json.RawMessage { + t.Helper() + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(text), &envelope), "result was not an envelope: %s", text) + return envelope.Data +} + +// writeSession connects a write-enabled session to a stub API. +func writeSession(t *testing.T, handlers map[string]http.HandlerFunc) *mcpsdk.ClientSession { + t.Helper() + api := mockAPI(t, handlers) + return connectInMemoryWriteEnabled(t, newTestClient(api.URL, "test-key")) +} + +// readSession connects a read-only session to a stub API. +func readSession(t *testing.T, handlers map[string]http.HandlerFunc) *mcpsdk.ClientSession { + t.Helper() + api := mockAPI(t, handlers) + return connectInMemory(t, newTestClient(api.URL, "test-key")) +} + +// succeeds calls a tool and fails the test if the result is an error. +func succeeds(t *testing.T, session *mcpsdk.ClientSession, tool string, args map[string]any) string { + t.Helper() + result := callTool(t, session, tool, args) + require.False(t, result.IsError, "unexpected error: %s", textContent(t, result)) + return textContent(t, result) +} + +// connectionBody is a minimal connection as the API returns it. +func connectionBody() map[string]any { + return map[string]any{"id": "web_1", "name": "stripe-to-backend"} +} + +// --------------------------------------------------------------------------- +// gateway_connections +// --------------------------------------------------------------------------- + +func TestConnectionsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections": ok(&got, listResponse(connectionBody())), + }) + + text := succeeds(t, session, "gateway_connections", map[string]any{ + "action": "list", "name": "stripe-to-backend", "source_id": "src_1", + "destination_id": "des_1", "limit": 25, "next": "cursor_1", + }) + + assert.Equal(t, "/2025-07-01/connections", got.path) + assert.Contains(t, got.query, "name=stripe-to-backend") + assert.Contains(t, got.query, "source_id=src_1") + assert.Contains(t, got.query, "destination_id=des_1") + assert.Contains(t, got.query, "limit=25") + // Pagination is only usable if the cursor is forwarded. + assert.Contains(t, got.query, "next=cursor_1") + assert.Contains(t, string(envelopeData(t, text)), "web_1") +} + +func TestConnectionsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections/web_1": ok(&got, connectionBody()), + }) + + text := succeeds(t, session, "gateway_connections", map[string]any{"action": "get", "id": "web_1"}) + + assert.Equal(t, http.MethodGet, got.method) + assert.Equal(t, "/2025-07-01/connections/web_1", got.path) + assert.Contains(t, string(envelopeData(t, text)), "stripe-to-backend") +} + +func TestConnectionsCreate(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/connections": ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{ + "action": "create", + "name": "stripe-to-backend", + "description": "routes Stripe events", + "source_id": "src_1", + "destination_id": "des_1", + "rules": []any{map[string]any{"type": "retry", "count": 3}}, + }) + + assert.Equal(t, http.MethodPost, got.method) + assert.Equal(t, "/2025-07-01/connections", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "stripe-to-backend", body["name"]) + assert.Equal(t, "routes Stripe events", body["description"]) + assert.Equal(t, "src_1", body["source_id"]) + assert.Equal(t, "des_1", body["destination_id"]) + // The ruleset is an ordered array of rule objects; flattening it would + // silently change the connection's behaviour. + assert.Equal(t, []any{map[string]any{"type": "retry", "count": float64(3)}}, body["rules"]) +} + +// Upsert keys on the name, so it goes to the collection rather than to an id, +// and a POST here would create a duplicate on every call. +func TestConnectionsUpsertIsAPutToTheCollection(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/connections": ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{ + "action": "upsert", "name": "stripe-to-backend", "source_id": "src_1", "destination_id": "des_1", + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/connections", got.path) + assert.Equal(t, "stripe-to-backend", got.decodeBody(t)["name"]) +} + +func TestConnectionsUpsertRequiresAName(t *testing.T) { + session := writeSession(t, nil) + + result := callTool(t, session, "gateway_connections", map[string]any{ + "action": "upsert", "source_id": "src_1", + }) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "name is required") +} + +func TestConnectionsUpdate(t *testing.T) { + var resolve, got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections/web_1": ok(&resolve, connectionBody()), + "PUT /2025-07-01/connections/web_1": ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{ + "action": "update", "id": "web_1", "description": "now with retries", + "rules": []any{map[string]any{"type": "retry"}}, + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/connections/web_1", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "now with retries", body["description"]) + assert.Equal(t, []any{map[string]any{"type": "retry"}}, body["rules"]) + // Fields the caller did not mention are omitted rather than sent empty, + // which would blank them on the stored connection. + assert.NotContains(t, body, "name") + assert.NotContains(t, body, "source_id") +} + +// The id argument accepts a name, so the write actions resolve it before +// addressing the connection. Sending the name in the path would 404. +func TestConnectionsUpdateResolvesANameToAnID(t *testing.T) { + var lookup, got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections": ok(&lookup, listResponse(connectionBody())), + "PUT /2025-07-01/connections/web_1": ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{ + "action": "update", "id": "stripe-to-backend", "description": "renamed target", + }) + + assert.Equal(t, "name=stripe-to-backend", lookup.query) + assert.Equal(t, "/2025-07-01/connections/web_1", got.path) +} + +func TestConnectionsDelete(t *testing.T) { + var resolve, got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections/web_1": ok(&resolve, connectionBody()), + "DELETE /2025-07-01/connections/web_1": ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_connections", map[string]any{"action": "delete", "id": "web_1"}) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/connections/web_1", got.path) + // The API returns no useful body, so the tool has to say what happened. + assert.JSONEq(t, `{"connection_id":"web_1","status":"deleted"}`, string(envelopeData(t, text))) +} + +func TestConnectionsEnableAndDisable(t *testing.T) { + for _, action := range []string{"enable", "disable"} { + t.Run(action, func(t *testing.T) { + var resolve, got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections/web_1": ok(&resolve, connectionBody()), + "PUT /2025-07-01/connections/web_1/" + action: ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{"action": action, "id": "web_1"}) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/connections/web_1/"+action, got.path) + }) + } +} + +func TestConnectionsPauseAndUnpause(t *testing.T) { + for _, action := range []string{"pause", "unpause"} { + t.Run(action, func(t *testing.T) { + var resolve, got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/connections/web_1": ok(&resolve, connectionBody()), + "PUT /2025-07-01/connections/web_1/" + action: ok(&got, connectionBody()), + }) + + succeeds(t, session, "gateway_connections", map[string]any{"action": action, "id": "web_1"}) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/connections/web_1/"+action, got.path) + }) + } +} + +func TestConnectionsWriteActionsRequireAnID(t *testing.T) { + session := writeSession(t, nil) + + for _, action := range []string{"update", "delete", "enable", "disable"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_connections", map[string]any{"action": action}) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "id or name is required") + }) + } +} + +// --------------------------------------------------------------------------- +// gateway_sources +// --------------------------------------------------------------------------- + +func sourceBody() map[string]any { + return map[string]any{"id": "src_1", "name": "stripe", "type": "STRIPE"} +} + +func TestSourcesListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/sources": ok(&got, listResponse(sourceBody())), + }) + + succeeds(t, session, "gateway_sources", map[string]any{ + "action": "list", "name": "stripe", "limit": 5, "next": "cursor_1", + }) + + assert.Equal(t, "/2025-07-01/sources", got.path) + assert.Contains(t, got.query, "name=stripe") + assert.Contains(t, got.query, "limit=5") + assert.Contains(t, got.query, "next=cursor_1") +} + +func TestSourcesGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/sources/src_1": ok(&got, sourceBody()), + }) + + succeeds(t, session, "gateway_sources", map[string]any{"action": "get", "id": "src_1"}) + assert.Equal(t, "/2025-07-01/sources/src_1", got.path) +} + +func TestSourcesCreateAndUpsert(t *testing.T) { + cases := []struct { + action string + pattern string + method string + }{ + // Create posts to the collection; upsert keys on the name and PUTs to + // the same collection, so the two differ only in method. + {"create", "POST /2025-07-01/sources", http.MethodPost}, + {"upsert", "PUT /2025-07-01/sources", http.MethodPut}, + } + + for _, tc := range cases { + t.Run(tc.action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + tc.pattern: ok(&got, sourceBody()), + }) + + succeeds(t, session, "gateway_sources", map[string]any{ + "action": tc.action, "name": "stripe", "type": "STRIPE", + "description": "Stripe webhooks", + "config": map[string]any{"auth_type": "STRIPE_SIGNATURE"}, + }) + + assert.Equal(t, tc.method, got.method) + assert.Equal(t, "/2025-07-01/sources", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "stripe", body["name"]) + assert.Equal(t, "STRIPE", body["type"]) + assert.Equal(t, "Stripe webhooks", body["description"]) + assert.Equal(t, map[string]any{"auth_type": "STRIPE_SIGNATURE"}, body["config"]) + }) + } +} + +func TestSourcesUpdate(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/sources/src_1": ok(&got, sourceBody()), + }) + + succeeds(t, session, "gateway_sources", map[string]any{ + "action": "update", "id": "src_1", + "config": map[string]any{"allowed_http_methods": []any{"POST"}}, + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/sources/src_1", got.path) + + body := got.decodeBody(t) + assert.Equal(t, map[string]any{"allowed_http_methods": []any{"POST"}}, body["config"]) + // An update that resent empty values would rename the source to "". + assert.NotContains(t, body, "name") + assert.NotContains(t, body, "type") +} + +func TestSourcesDelete(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/sources/src_1": ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_sources", map[string]any{"action": "delete", "id": "src_1"}) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/sources/src_1", got.path) + assert.JSONEq(t, `{"source_id":"src_1","status":"deleted"}`, string(envelopeData(t, text))) +} + +func TestSourcesEnableAndDisable(t *testing.T) { + for _, action := range []string{"enable", "disable"} { + t.Run(action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/sources/src_1/" + action: ok(&got, sourceBody()), + }) + + succeeds(t, session, "gateway_sources", map[string]any{"action": action, "id": "src_1"}) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/sources/src_1/"+action, got.path) + }) + } +} + +func TestSourcesWriteActionsRequireAnID(t *testing.T) { + session := writeSession(t, nil) + + for _, action := range []string{"update", "delete", "enable", "disable"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_sources", map[string]any{"action": action}) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "id is required") + }) + } +} + +// --------------------------------------------------------------------------- +// gateway_destinations +// --------------------------------------------------------------------------- + +func destinationBody() map[string]any { + return map[string]any{"id": "des_1", "name": "backend", "type": "HTTP"} +} + +func TestDestinationsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/destinations": ok(&got, listResponse(destinationBody())), + }) + + succeeds(t, session, "gateway_destinations", map[string]any{ + "action": "list", "name": "backend", "limit": 5, "prev": "cursor_0", + }) + + assert.Equal(t, "/2025-07-01/destinations", got.path) + assert.Contains(t, got.query, "name=backend") + assert.Contains(t, got.query, "limit=5") + assert.Contains(t, got.query, "prev=cursor_0") +} + +func TestDestinationsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/destinations/des_1": ok(&got, destinationBody()), + }) + + succeeds(t, session, "gateway_destinations", map[string]any{"action": "get", "id": "des_1"}) + assert.Equal(t, "/2025-07-01/destinations/des_1", got.path) +} + +func TestDestinationsCreateAndUpsert(t *testing.T) { + cases := []struct { + action string + pattern string + method string + }{ + {"create", "POST /2025-07-01/destinations", http.MethodPost}, + {"upsert", "PUT /2025-07-01/destinations", http.MethodPut}, + } + + for _, tc := range cases { + t.Run(tc.action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + tc.pattern: ok(&got, destinationBody()), + }) + + succeeds(t, session, "gateway_destinations", map[string]any{ + "action": tc.action, "name": "backend", "type": "HTTP", + "description": "the API", + "config": map[string]any{"url": "https://example.com/hooks"}, + }) + + assert.Equal(t, tc.method, got.method) + assert.Equal(t, "/2025-07-01/destinations", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "backend", body["name"]) + assert.Equal(t, "HTTP", body["type"]) + assert.Equal(t, "the API", body["description"]) + // The URL is the whole point of an HTTP destination. + assert.Equal(t, map[string]any{"url": "https://example.com/hooks"}, body["config"]) + }) + } +} + +func TestDestinationsUpdate(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/destinations/des_1": ok(&got, destinationBody()), + }) + + succeeds(t, session, "gateway_destinations", map[string]any{ + "action": "update", "id": "des_1", + "config": map[string]any{"url": "https://example.com/new"}, + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/destinations/des_1", got.path) + + body := got.decodeBody(t) + assert.Equal(t, map[string]any{"url": "https://example.com/new"}, body["config"]) + assert.NotContains(t, body, "name") + assert.NotContains(t, body, "type") +} + +func TestDestinationsDelete(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/destinations/des_1": ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_destinations", map[string]any{"action": "delete", "id": "des_1"}) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/destinations/des_1", got.path) + assert.JSONEq(t, `{"destination_id":"des_1","status":"deleted"}`, string(envelopeData(t, text))) +} + +func TestDestinationsEnableAndDisable(t *testing.T) { + for _, action := range []string{"enable", "disable"} { + t.Run(action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/destinations/des_1/" + action: ok(&got, destinationBody()), + }) + + succeeds(t, session, "gateway_destinations", map[string]any{"action": action, "id": "des_1"}) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/destinations/des_1/"+action, got.path) + }) + } +} + +func TestDestinationsWriteActionsRequireAnID(t *testing.T) { + session := writeSession(t, nil) + + for _, action := range []string{"update", "delete", "enable", "disable"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_destinations", map[string]any{"action": action}) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "id is required") + }) + } +} + +// --------------------------------------------------------------------------- +// gateway_transformations +// --------------------------------------------------------------------------- + +func transformationBody() map[string]any { + return map[string]any{"id": "trs_1", "name": "enrich", "code": "return request"} +} + +func TestTransformationsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/transformations": ok(&got, listResponse(transformationBody())), + }) + + succeeds(t, session, "gateway_transformations", map[string]any{ + "action": "list", "name": "enrich", "limit": 5, + }) + + assert.Equal(t, "/2025-07-01/transformations", got.path) + assert.Contains(t, got.query, "name=enrich") + assert.Contains(t, got.query, "limit=5") +} + +func TestTransformationsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/transformations/trs_1": ok(&got, transformationBody()), + }) + + text := succeeds(t, session, "gateway_transformations", map[string]any{"action": "get", "id": "trs_1"}) + assert.Equal(t, "/2025-07-01/transformations/trs_1", got.path) + // The code is the reason to fetch one. + assert.Contains(t, string(envelopeData(t, text)), "return request") +} + +func TestTransformationsCreateAndUpsert(t *testing.T) { + cases := []struct { + action string + pattern string + method string + }{ + {"create", "POST /2025-07-01/transformations", http.MethodPost}, + {"upsert", "PUT /2025-07-01/transformations", http.MethodPut}, + } + + for _, tc := range cases { + t.Run(tc.action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + tc.pattern: ok(&got, transformationBody()), + }) + + succeeds(t, session, "gateway_transformations", map[string]any{ + "action": tc.action, "name": "enrich", "code": "return request", + "env": map[string]any{"API_KEY": "shh"}, + }) + + assert.Equal(t, tc.method, got.method) + assert.Equal(t, "/2025-07-01/transformations", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "enrich", body["name"]) + assert.Equal(t, "return request", body["code"]) + assert.Equal(t, map[string]any{"API_KEY": "shh"}, body["env"]) + }) + } +} + +func TestTransformationsUpdate(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/transformations/trs_1": ok(&got, transformationBody()), + }) + + succeeds(t, session, "gateway_transformations", map[string]any{ + "action": "update", "id": "trs_1", "code": "return { ...request }", + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/transformations/trs_1", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "return { ...request }", body["code"]) + // Sending an empty name would rename the transformation to "". + assert.NotContains(t, body, "name") + assert.NotContains(t, body, "env") +} + +func TestTransformationsDelete(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/transformations/trs_1": ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_transformations", map[string]any{"action": "delete", "id": "trs_1"}) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/transformations/trs_1", got.path) + assert.JSONEq(t, `{"transformation_id":"trs_1","status":"deleted"}`, string(envelopeData(t, text))) +} + +func TestTransformationsRunSendsTheSampleRequest(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/transformations/run": ok(&got, map[string]any{ + "request": map[string]any{"headers": map[string]any{}, "body": map[string]any{"ok": true}}, + }), + }) + + succeeds(t, session, "gateway_transformations", map[string]any{ + "action": "run", "code": "return request", "connection_id": "web_1", + "env": map[string]any{"API_KEY": "shh"}, + "request": map[string]any{"headers": map[string]any{"x-test": "1"}, "body": map[string]any{"id": 7}, "path": "/hooks"}, + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/transformations/run", got.path) + + body := got.decodeBody(t) + assert.Equal(t, "return request", body["code"]) + // connection_id is the MCP name for the API's webhook_id. + assert.Equal(t, "web_1", body["webhook_id"]) + + request, isObject := body["request"].(map[string]any) + require.True(t, isObject, "request must be sent as an object: %v", body["request"]) + assert.Equal(t, map[string]any{"x-test": "1"}, request["headers"]) + assert.Equal(t, map[string]any{"id": float64(7)}, request["body"]) + assert.Equal(t, "/hooks", request["path"]) +} + +func TestTransformationsWriteActionsRequireAnID(t *testing.T) { + session := writeSession(t, nil) + + for _, action := range []string{"update", "delete"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_transformations", map[string]any{"action": action}) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "id is required") + }) + } +} + +// --------------------------------------------------------------------------- +// gateway_events +// --------------------------------------------------------------------------- + +func TestEventsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/events": ok(&got, listResponse(map[string]any{"id": "evt_1"})), + }) + + succeeds(t, session, "gateway_events", map[string]any{ + "action": "list", "connection_id": "web_1", "source_id": "src_1", + "destination_id": "des_1", "status": "FAILED", "limit": 5, + }) + + assert.Equal(t, "/2025-07-01/events", got.path) + // connection_id is the MCP name for the API's webhook_id. + assert.Contains(t, got.query, "webhook_id=web_1") + assert.Contains(t, got.query, "source_id=src_1") + assert.Contains(t, got.query, "destination_id=des_1") + assert.Contains(t, got.query, "status=FAILED") +} + +func TestEventsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/events/evt_1": ok(&got, map[string]any{"id": "evt_1", "status": "FAILED"}), + }) + + succeeds(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_1"}) + assert.Equal(t, "/2025-07-01/events/evt_1", got.path) +} + +func TestEventsRawBody(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/events/evt_1/raw_body": func(w http.ResponseWriter, r *http.Request) { + got = wireRequest{method: r.Method, path: r.URL.Path} + _, _ = w.Write([]byte(`{"amount":100}`)) + }, + }) + + text := succeeds(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_1"}) + + assert.Equal(t, "/2025-07-01/events/evt_1/raw_body", got.path) + assert.Contains(t, string(envelopeData(t, text)), "amount") +} + +// The three by-id event mutations differ in method and path, and the API +// returns no body, so the tool reports the outcome itself. +func TestEventsRetryCancelAndMute(t *testing.T) { + cases := []struct { + action string + pattern string + method string + status string + }{ + {"retry", "POST /2025-07-01/events/evt_1/retry", http.MethodPost, "retried"}, + {"cancel", "PUT /2025-07-01/events/evt_1/cancel", http.MethodPut, "cancelled"}, + {"mute", "PUT /2025-07-01/events/evt_1/mute", http.MethodPut, "muted"}, + } + + for _, tc := range cases { + t.Run(tc.action, func(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + tc.pattern: ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_events", map[string]any{"action": tc.action, "id": "evt_1"}) + + assert.Equal(t, tc.method, got.method) + assert.Equal(t, "/2025-07-01/events/evt_1/"+tc.action, got.path) + assert.JSONEq(t, `{"event_id":"evt_1","status":"`+tc.status+`"}`, string(envelopeData(t, text))) + }) + } +} + +// --------------------------------------------------------------------------- +// gateway_requests +// --------------------------------------------------------------------------- + +func TestRequestsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/requests": ok(&got, listResponse(map[string]any{"id": "req_1"})), + }) + + succeeds(t, session, "gateway_requests", map[string]any{ + "action": "list", "source_id": "src_1", "status": "accepted", "limit": 5, + }) + + assert.Equal(t, "/2025-07-01/requests", got.path) + assert.Contains(t, got.query, "source_id=src_1") + assert.Contains(t, got.query, "status=accepted") +} + +func TestRequestsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/requests/req_1": ok(&got, map[string]any{"id": "req_1"}), + }) + + succeeds(t, session, "gateway_requests", map[string]any{"action": "get", "id": "req_1"}) + assert.Equal(t, "/2025-07-01/requests/req_1", got.path) +} + +func TestRequestsRawBody(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/requests/req_1/raw_body": func(w http.ResponseWriter, r *http.Request) { + got = wireRequest{method: r.Method, path: r.URL.Path} + _, _ = w.Write([]byte(`{"amount":100}`)) + }, + }) + + text := succeeds(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_1"}) + + assert.Equal(t, "/2025-07-01/requests/req_1/raw_body", got.path) + assert.Contains(t, string(envelopeData(t, text)), "amount") +} + +func TestRequestsEventsAndIgnoredEvents(t *testing.T) { + for _, action := range []string{"events", "ignored_events"} { + t.Run(action, func(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/requests/req_1/" + action: ok(&got, listResponse(map[string]any{"id": "evt_1"})), + }) + + succeeds(t, session, "gateway_requests", map[string]any{"action": action, "id": "req_1"}) + assert.Equal(t, "/2025-07-01/requests/req_1/"+action, got.path) + }) + } +} + +func TestRequestsRetrySendsSelectedConnections(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/requests/req_1/retry": ok(&got, nil), + }) + + text := succeeds(t, session, "gateway_requests", map[string]any{ + "action": "retry", "id": "req_1", "connection_ids": []any{"web_1", "web_2"}, + }) + + assert.Equal(t, http.MethodPost, got.method) + assert.Equal(t, "/2025-07-01/requests/req_1/retry", got.path) + // connection_ids is the MCP name for the API's webhook_ids; dropping it + // would retry every connection instead of the two asked for. + assert.Equal(t, []any{"web_1", "web_2"}, got.decodeBody(t)["webhook_ids"]) + assert.JSONEq(t, `{"request_id":"req_1","status":"retried"}`, string(envelopeData(t, text))) +} + +// Omitting connection_ids must send an empty body, which the API reads as +// "every connection the request matched". +func TestRequestsRetryWithoutConnectionsSendsNoIDs(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "POST /2025-07-01/requests/req_1/retry": ok(&got, nil), + }) + + succeeds(t, session, "gateway_requests", map[string]any{"action": "retry", "id": "req_1"}) + assert.NotContains(t, got.decodeBody(t), "webhook_ids") +} + +// --------------------------------------------------------------------------- +// gateway_attempts +// --------------------------------------------------------------------------- + +func TestAttemptsListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/attempts": ok(&got, listResponse(map[string]any{"id": "atm_1"})), + }) + + succeeds(t, session, "gateway_attempts", map[string]any{ + "action": "list", "event_id": "evt_1", "limit": 5, "order_by": "created_at", "dir": "desc", + }) + + assert.Equal(t, "/2025-07-01/attempts", got.path) + assert.Contains(t, got.query, "event_id=evt_1") + assert.Contains(t, got.query, "order_by=created_at") + assert.Contains(t, got.query, "dir=desc") +} + +func TestAttemptsGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/attempts/atm_1": ok(&got, map[string]any{ + "id": "atm_1", "response_status": 500, "body": "boom", + }), + }) + + text := succeeds(t, session, "gateway_attempts", map[string]any{"action": "get", "id": "atm_1"}) + + assert.Equal(t, "/2025-07-01/attempts/atm_1", got.path) + assert.Contains(t, string(envelopeData(t, text)), "atm_1") +} + +// --------------------------------------------------------------------------- +// gateway_issues +// --------------------------------------------------------------------------- + +func TestIssuesListSendsFilters(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/issues": ok(&got, listResponse(map[string]any{"id": "iss_1"})), + }) + + succeeds(t, session, "gateway_issues", map[string]any{ + "action": "list", "type": "delivery", "filter_status": "OPENED", + "issue_trigger_id": "ist_1", "limit": 5, + }) + + assert.Equal(t, "/2025-07-01/issues", got.path) + assert.Contains(t, got.query, "type=delivery") + // filter_status is the MCP name for the list filter, so that `status` + // stays free for the update action's new value. + assert.Contains(t, got.query, "status=OPENED") + assert.Contains(t, got.query, "issue_trigger_id=ist_1") +} + +func TestIssuesGetByID(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/issues/iss_1": ok(&got, map[string]any{"id": "iss_1", "status": "OPENED"}), + }) + + succeeds(t, session, "gateway_issues", map[string]any{"action": "get", "id": "iss_1"}) + assert.Equal(t, "/2025-07-01/issues/iss_1", got.path) +} + +func TestIssuesUpdateSendsTheNewStatus(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "PUT /2025-07-01/issues/iss_1": ok(&got, map[string]any{"id": "iss_1", "status": "RESOLVED"}), + }) + + succeeds(t, session, "gateway_issues", map[string]any{ + "action": "update", "id": "iss_1", "status": "RESOLVED", + }) + + assert.Equal(t, http.MethodPut, got.method) + assert.Equal(t, "/2025-07-01/issues/iss_1", got.path) + assert.Equal(t, "RESOLVED", got.decodeBody(t)["status"]) +} + +func TestIssuesUpdateRequiresAStatus(t *testing.T) { + session := writeSession(t, nil) + + result := callTool(t, session, "gateway_issues", map[string]any{"action": "update", "id": "iss_1"}) + require.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "status is required") +} + +// Dismiss is a DELETE, and unlike the other deletes the API answers with the +// updated issue, which the tool returns rather than a synthesised status. +func TestIssuesDismissIsADelete(t *testing.T) { + var got wireRequest + session := writeSession(t, map[string]http.HandlerFunc{ + "DELETE /2025-07-01/issues/iss_1": ok(&got, map[string]any{"id": "iss_1", "status": "IGNORED"}), + }) + + text := succeeds(t, session, "gateway_issues", map[string]any{"action": "dismiss", "id": "iss_1"}) + + assert.Equal(t, http.MethodDelete, got.method) + assert.Equal(t, "/2025-07-01/issues/iss_1", got.path) + assert.Contains(t, string(envelopeData(t, text)), "IGNORED") +} + +// --------------------------------------------------------------------------- +// gateway_metrics +// --------------------------------------------------------------------------- + +func TestMetricsActionsHitTheirOwnEndpoints(t *testing.T) { + for _, action := range []string{"events", "requests", "attempts", "transformations"} { + t.Run(action, func(t *testing.T) { + var got wireRequest + session := readSession(t, map[string]http.HandlerFunc{ + "GET /2025-07-01/metrics/" + action: ok(&got, []map[string]any{ + { + "time_bucket": "2026-08-01T00:00:00Z", + "dimensions": map[string]any{"status": "FAILED"}, + "metrics": map[string]any{"count": 42}, + }, + }), + }) + + text := succeeds(t, session, "gateway_metrics", map[string]any{ + "action": action, + "start": "2026-08-01T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + "granularity": "1d", + "measures": []any{"count"}, + "dimensions": []any{"status"}, + "source_id": "src_1", + }) + + assert.Equal(t, "/2025-07-01/metrics/"+action, got.path) + // The range uses bracketed keys, and repeated values use "[]" + // suffixes; neither is interchangeable with a plain key. + assert.Contains(t, got.query, "date_range%5Bstart%5D=2026-08-01T00%3A00%3A00Z") + assert.Contains(t, got.query, "date_range%5Bend%5D=2026-08-14T00%3A00%3A00Z") + assert.Contains(t, got.query, "granularity=1d") + assert.Contains(t, got.query, "measures%5B%5D=count") + assert.Contains(t, got.query, "dimensions%5B%5D=status") + assert.Contains(t, got.query, "filters%5Bsource_id%5D=src_1") + assert.Contains(t, string(envelopeData(t, text)), "42") + }) + } +} + +// --------------------------------------------------------------------------- +// Coverage gate +// --------------------------------------------------------------------------- + +// TestEveryActionHasBeenCalledSuccessfully is a checklist rather than a +// behaviour test. It fails when an action — or a whole tool — is added without +// a successful call being written for it, which is the gap this file was +// created to close: a destructive action whose only coverage is its read-only +// refusal has never been proven to work at all. +// +// The action lists come from the specs themselves, so a new action shows up +// here the moment it is registered. +func TestEveryActionHasBeenCalledSuccessfully(t *testing.T) { + covered := coveredActions() + + for _, spec := range resourceSpecs() { + for _, action := range spec.Actions { + assert.True(t, covered[spec.Resource][action.Name], + "%s_%s action %q has no test making a successful call; "+ + "a refusal test alone does not prove the action works", + toolPrefix, spec.Resource, action.Name) + } + } +} + +// TestCoverageChecklistHasNoStaleEntries keeps the checklist honest in the +// other direction: an entry for an action that no longer exists would let a +// genuinely uncovered action hide behind a name that once matched. +func TestCoverageChecklistHasNoStaleEntries(t *testing.T) { + actual := map[string]map[string]bool{} + for _, spec := range resourceSpecs() { + names := map[string]bool{} + for _, action := range spec.Actions { + names[action.Name] = true + } + actual[spec.Resource] = names + } + + for resource, actions := range coveredActions() { + require.Contains(t, actual, resource, "checklist covers unknown tool %q", resource) + for name := range actions { + assert.True(t, actual[resource][name], + "checklist covers %s_%s action %q, which no longer exists", + toolPrefix, resource, name) + } + } +} + +// coveredActions lists, per tool, the actions that have a test in this file +// calling them and asserting the request that went on the wire. Adding an +// action to a spec without adding it here fails the checklist above. +func coveredActions() map[string]map[string]bool { + return map[string]map[string]bool{ + "connections": { + "list": true, "get": true, "pause": true, "unpause": true, + "create": true, "upsert": true, "update": true, "delete": true, + "enable": true, "disable": true, + }, + "sources": { + "list": true, "get": true, "create": true, "upsert": true, + "update": true, "delete": true, "enable": true, "disable": true, + }, + "destinations": { + "list": true, "get": true, "create": true, "upsert": true, + "update": true, "delete": true, "enable": true, "disable": true, + }, + "transformations": { + "list": true, "get": true, "create": true, "upsert": true, + "update": true, "delete": true, "run": true, + }, + "requests": { + "list": true, "get": true, "raw_body": true, "events": true, + "ignored_events": true, "retry": true, + }, + "events": { + "list": true, "get": true, "raw_body": true, + "retry": true, "cancel": true, "mute": true, + }, + "attempts": {"list": true, "get": true}, + "issues": {"list": true, "get": true, "update": true, "dismiss": true}, + "metrics": { + "events": true, "requests": true, "attempts": true, "transformations": true, + }, + } +} From 38e12ee274e13ed6982f1bcdfcc20a0cc45ab0b4 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 15:17:28 +0100 Subject: [PATCH 30/49] test(outpost): stop the portal test assuming propagation is stable The test waited up to 90s for the portal to appear after setting a custom domain, then treated it as available for every later assertion. Propagation is not only delayed but uneven: in a real run the portal answered the poll and then 404'd on the very next call, failing the theme subtest. Every portal call now goes through a helper that retries while the deployment still reports the portal as unconfigured, so the test measures the command rather than the propagation delay. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- test/acceptance/outpost_test.go | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/test/acceptance/outpost_test.go b/test/acceptance/outpost_test.go index 224a9f96..334df3ab 100644 --- a/test/acceptance/outpost_test.go +++ b/test/acceptance/outpost_test.go @@ -218,15 +218,29 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { // URL to exist. Requiring our hostname here as well would make the test // fail whenever a concurrent run against the same project has replaced the // domain in the meantime — a collision between runs, not a CLI defect. - var portalURL string - require.Eventually(t, func() bool { - out, _, err := cli.Run("outpost", "tenant", "portal", tenantID) - if err != nil { - return false - } - portalURL = strings.TrimSpace(out) - return strings.Contains(portalURL, "token=") - }, 90*time.Second, 5*time.Second, "the portal URL never became available after setting a custom domain") + // portalEventually runs a portal command, retrying while the deployment + // still reports the portal as unconfigured. + // + // Propagation is not only delayed but uneven: the portal can answer once and + // 404 on the very next call. Waiting for it to appear a single time and then + // treating it as available for the rest of the test is what makes this test + // flaky, so every portal call goes through here. + portalEventually := func(t *testing.T, args ...string) string { + t.Helper() + var out string + require.Eventually(t, func() bool { + stdout, _, err := cli.Run(append([]string{"outpost", "tenant", "portal"}, args...)...) + if err != nil { + return false + } + out = strings.TrimSpace(stdout) + return out != "" + }, 90*time.Second, 5*time.Second, "the portal never became available after setting a custom domain") + return out + } + + portalURL := portalEventually(t, tenantID) + require.Contains(t, portalURL, "token=") // The URL is a credential and scripts pipe it, so it must be the only thing // printed. @@ -235,7 +249,7 @@ func TestOutpostTenantPortalAndCustomDomain(t *testing.T) { t.Run("theme is passed through", func(t *testing.T) { for _, theme := range []string{"light", "dark"} { - out := cli.RunExpectSuccess("outpost", "tenant", "portal", tenantID, "--theme", theme, "--output", "json") + out := portalEventually(t, tenantID, "--theme", theme, "--output", "json") var portal struct { RedirectURL string `json:"redirect_url"` TenantID string `json:"tenant_id"` From 0eb550b2b1cc80c8617a69e1d5dddfdddb3fb46f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:32:59 +0000 Subject: [PATCH 31/49] Update package.json version to 3.0.0-pin-pre-tool-shape-review --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7525984..3c2a138c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "2.6.0-beta.2", + "version": "3.0.0-pin-pre-tool-shape-review", "description": "Hookdeck CLI", "repository": { "type": "git", From cd58f8bc20ff2d398db0c3aaf9e1898a5585b759 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 16:20:00 +0100 Subject: [PATCH 32/49] ci: pass the CLI key to acceptance runs so project tests stop skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HOOKDECK_CLI_TESTING_CLI_KEY secret has existed since March, but the acceptance job never put it in the environment. TestProjectList and the project-switch tests read it with os.Getenv, found nothing, and skipped themselves — so project list and project use have had no automated coverage anywhere, while reporting green. The tests skip rather than fail when the key is absent, which is what kept this invisible. Wiring the secret through makes CI answer whether the stored value is a user-associated key: project-scoped keys cannot list or switch projects and will now fail loudly instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- .github/workflows/acceptance.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index d7969357..90ef7e20 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -34,6 +34,11 @@ jobs: ACCEPTANCE_SLICE: ${{ matrix.slice }} HOOKDECK_CLI_TESTING_API_KEY: ${{ secrets[matrix.api_key_secret] }} HOOKDECK_CLI_OUTPOST_TESTING_API_KEY: ${{ secrets.HOOKDECK_CLI_OUTPOST_TESTING_API_KEY }} + # Only the project list/use tests use this, and only in slice 0. It must be a + # user-associated CLI key from `hookdeck login`: project-scoped keys cannot list + # or switch projects, so those tests skip themselves when it is absent — which is + # why they ran nowhere until this was wired through. + HOOKDECK_CLI_TESTING_CLI_KEY: ${{ secrets.HOOKDECK_CLI_TESTING_CLI_KEY }} HOOKDECK_CLI_TELEMETRY_DISABLED: "1" steps: - name: Check out code From e0f090d2b834c4fb539dd9597585a137d348ff26 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 16:32:47 +0100 Subject: [PATCH 33/49] feat(gateway mcp)!: split events and requests into plural search and singular by-id tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway_events had 6 actions and 25 parameters, gateway_requests 6 and 19, and in both only `id` was shared: every other parameter was a list filter, so raw_body, retry, cancel and mute each needed one argument and were shown all of them. Irrelevant parameters in a schema are the single most damaging thing you can put in front of a model, and this was our widest surface by far. Split each along the seam that already existed: gateway_events list only, keeps the filters (25 params) gateway_event get, raw_body, retry, cancel, mute (1 param: id) gateway_requests list only, keeps the filters (18 params) gateway_request get, raw_body, events, ignored_events, retry (2 params) connection_ids stays on gateway_request: it is the retry body parameter, not a list filter. Both halves keep the action enum, so the shape stays consistent with the other tools. Write semantics are unchanged: retry/cancel/mute are still writes, cancel/mute still destructive. The plural tools now carry no write actions at all, so they are annotated read-only in both modes. Plural-versus-singular is a subtle distinction for a model to hold, so the descriptions state it explicitly on both sides and the help topics name their counterpart. They also document the one relationship traversal the API offers — GET /requests/{id}/events — and say that neither a request_id filter on events nor an event_id filter on requests exists, so an agent does not hunt for one. Every action on both new tools has a test asserting the request that goes on the wire, and the coverage checklist gates them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- pkg/gateway/mcp/server_test.go | 197 ++++++++++++++++++++------ pkg/gateway/mcp/telemetry_test.go | 37 +++++ pkg/gateway/mcp/tool_event.go | 147 +++++++++++++++++++ pkg/gateway/mcp/tool_events.go | 146 +++++-------------- pkg/gateway/mcp/tool_request.go | 157 ++++++++++++++++++++ pkg/gateway/mcp/tool_requests.go | 146 +++++-------------- pkg/gateway/mcp/tools.go | 13 ++ pkg/gateway/mcp/write_actions_test.go | 73 +++++++--- pkg/gateway/mcp/write_mode_test.go | 78 ++++++---- 9 files changed, 676 insertions(+), 318 deletions(-) create mode 100644 pkg/gateway/mcp/tool_event.go create mode 100644 pkg/gateway/mcp/tool_request.go diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 0fd11e44..b150e966 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -167,8 +167,9 @@ func TestListTools_Authenticated(t *testing.T) { expectedTools := []string{ "hookdeck_projects", "gateway_connections", "gateway_sources", - "gateway_destinations", "gateway_transformations", "gateway_requests", - "gateway_events", "gateway_attempts", "gateway_issues", + "gateway_destinations", "gateway_transformations", + "gateway_requests", "gateway_request", + "gateway_events", "gateway_event", "gateway_attempts", "gateway_issues", "gateway_metrics", "gateway_help", } for _, name := range expectedTools { @@ -191,6 +192,7 @@ func TestListTools_Unauthenticated(t *testing.T) { assert.Contains(t, toolNames, "hookdeck_login") assert.Contains(t, toolNames, "gateway_help") assert.Contains(t, toolNames, "gateway_events") + assert.Contains(t, toolNames, "gateway_event") } // --------------------------------------------------------------------------- @@ -215,14 +217,56 @@ func TestHelpTool_SpecificTopic(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_events"}) + result := callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_event"}) assert.False(t, result.IsError) text := textContent(t, result) - assert.Contains(t, text, "list") assert.Contains(t, text, "get") assert.Contains(t, text, "raw_body") } +// The plural/singular split is only usable if help says which tool does what, +// so each topic has to point at its counterpart by name. +func TestHelpTopics_PointAtTheirCounterpart(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + cases := []struct{ topic, wants string }{ + {"gateway_events", "gateway_event"}, + {"gateway_event", "gateway_events"}, + {"gateway_requests", "gateway_request"}, + {"gateway_request", "gateway_requests"}, + } + + for _, tc := range cases { + t.Run(tc.topic, func(t *testing.T) { + result := callTool(t, session, "gateway_help", map[string]any{"topic": tc.topic}) + assert.False(t, result.IsError) + assert.Contains(t, textContent(t, result), tc.wants) + }) + } +} + +// The only relationship traversal the API supports is request → events. An +// agent told nothing will look for a request_id filter that does not exist, so +// both event topics have to say where the traversal lives. +func TestHelpTopics_DocumentTheOneTraversalDirection(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + for _, topic := range []string{"gateway_events", "gateway_event"} { + t.Run(topic, func(t *testing.T) { + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{"topic": topic})) + assert.Contains(t, text, "request_id") + assert.Contains(t, text, "gateway_request") + }) + } + + t.Run("gateway_requests states there is no event_id filter", func(t *testing.T) { + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_requests"})) + assert.Contains(t, text, "event_id") + }) +} + func TestHelpEventsTopic_DocumentsDateRangeFilters(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) @@ -279,16 +323,24 @@ func TestAuthGuard_UnauthenticatedReturnsError(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "") session := connectInMemory(t, client) - resourceTools := []string{ - "gateway_sources", "gateway_destinations", "gateway_connections", - "gateway_events", "gateway_requests", "gateway_attempts", - "gateway_issues", "gateway_transformations", "gateway_metrics", - "hookdeck_projects", + resourceTools := map[string]string{ + "gateway_sources": "list", + "gateway_destinations": "list", + "gateway_connections": "list", + "gateway_events": "list", + "gateway_event": "get", + "gateway_requests": "list", + "gateway_request": "get", + "gateway_attempts": "list", + "gateway_issues": "list", + "gateway_transformations": "list", + "gateway_metrics": "events", + "hookdeck_projects": "list", } - for _, toolName := range resourceTools { + for toolName, action := range resourceTools { t.Run(toolName, func(t *testing.T) { - result := callTool(t, session, toolName, map[string]any{"action": "list"}) + result := callTool(t, session, toolName, map[string]any{"action": action, "id": "res_1"}) assert.True(t, result.IsError, "expected IsError=true for unauthenticated %s", toolName) assert.Contains(t, textContent(t, result), "hookdeck_login") }) @@ -654,47 +706,47 @@ func TestEventsList_Success(t *testing.T) { assert.Contains(t, textContent(t, result), "evt_abc") } -func TestEventsGet_Success(t *testing.T) { +func TestEventGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/events/evt_abc": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "evt_abc", "status": "SUCCESSFUL"}) }, }) - result := callTool(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_abc"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "get", "id": "evt_abc"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_abc") } -func TestEventsGet_MissingID(t *testing.T) { +func TestEventGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_events", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } -func TestEventsRawBody_Success(t *testing.T) { +func TestEventRawBody_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/events/evt_abc/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"key":"value"}`)) }, }) - result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_abc"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "raw_body", "id": "evt_abc"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "raw_body") } -func TestEventsRawBody_MissingID(t *testing.T) { +func TestEventRawBody_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "raw_body"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } -func TestEventsRawBody_Truncation(t *testing.T) { +func TestEventRawBody_Truncation(t *testing.T) { // Generate a body larger than 100KB largeBody := strings.Repeat("x", 150*1024) session := mockAPIWithClient(t, map[string]http.HandlerFunc{ @@ -703,7 +755,7 @@ func TestEventsRawBody_Truncation(t *testing.T) { }, }) - result := callTool(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_big"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "raw_body", "id": "evt_big"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "truncated") } @@ -716,6 +768,31 @@ func TestEventsTool_UnknownAction(t *testing.T) { assert.Contains(t, textContent(t, result), "unknown action") } +// The by-id actions moved to gateway_event, so asking the plural tool for one +// is now a wrong-tool mistake. The error has to name the tool that can do it, +// because an agent that lands here needs redirecting, not just refusing. +func TestEventsTool_ByIDActionsAreNotOnThePluralTool(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + for _, action := range []string{"get", "raw_body", "retry", "cancel", "mute"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_events", map[string]any{"action": action, "id": "evt_abc"}) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "unknown action") + }) + } +} + +func TestEventTool_ListIsNotOnTheSingularTool(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + result := callTool(t, session, "gateway_event", map[string]any{"action": "list"}) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "unknown action") +} + func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { @@ -857,47 +934,47 @@ func TestRequestsList_Success(t *testing.T) { assert.Contains(t, textContent(t, result), "req_001") } -func TestRequestsGet_Success(t *testing.T) { +func TestRequestGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests/req_001": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "req_001"}) }, }) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "get", "id": "req_001"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "get", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "req_001") } -func TestRequestsGet_MissingID(t *testing.T) { +func TestRequestGet_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "get"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "get"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } -func TestRequestsRawBody_Success(t *testing.T) { +func TestRequestRawBody_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests/req_001/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"payload":"data"}`)) }, }) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_001"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "raw_body", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "raw_body") } -func TestRequestsRawBody_MissingID(t *testing.T) { +func TestRequestRawBody_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "raw_body"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } -func TestRequestsRawBody_Truncation(t *testing.T) { +func TestRequestRawBody_Truncation(t *testing.T) { largeBody := strings.Repeat("y", 150*1024) session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests/req_big/raw_body": func(w http.ResponseWriter, r *http.Request) { @@ -905,47 +982,47 @@ func TestRequestsRawBody_Truncation(t *testing.T) { }, }) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_big"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "raw_body", "id": "req_big"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "truncated") } -func TestRequestsEvents_Success(t *testing.T) { +func TestRequestEvents_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests/req_001/events": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_from_req"})) }, }) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "events", "id": "req_001"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "events", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_from_req") } -func TestRequestsEvents_MissingID(t *testing.T) { +func TestRequestEvents_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "events"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "events"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } -func TestRequestsIgnoredEvents_Success(t *testing.T) { +func TestRequestIgnoredEvents_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests/req_001/ignored_events": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "ign_evt_001"})) }, }) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "ignored_events", "id": "req_001"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "ignored_events", "id": "req_001"}) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "ign_evt_001") } -func TestRequestsIgnoredEvents_MissingID(t *testing.T) { +func TestRequestIgnoredEvents_MissingID(t *testing.T) { client := newTestClient("https://api.hookdeck.com", "test-key") session := connectInMemory(t, client) - result := callTool(t, session, "gateway_requests", map[string]any{"action": "ignored_events"}) + result := callTool(t, session, "gateway_request", map[string]any{"action": "ignored_events"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "id is required") } @@ -958,6 +1035,29 @@ func TestRequestsTool_UnknownAction(t *testing.T) { assert.Contains(t, textContent(t, result), "unknown action") } +// The by-id actions moved to gateway_request; the plural tool only searches. +func TestRequestsTool_ByIDActionsAreNotOnThePluralTool(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + for _, action := range []string{"get", "raw_body", "events", "ignored_events", "retry"} { + t.Run(action, func(t *testing.T) { + result := callTool(t, session, "gateway_requests", map[string]any{"action": action, "id": "req_001"}) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "unknown action") + }) + } +} + +func TestRequestTool_ListIsNotOnTheSingularTool(t *testing.T) { + client := newTestClient("https://api.hookdeck.com", "test-key") + session := connectInMemory(t, client) + + result := callTool(t, session, "gateway_request", map[string]any{"action": "list"}) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "unknown action") +} + func TestRequestsList_VerifiedFilter(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { @@ -1628,7 +1728,7 @@ func TestSourcesList_429RateLimitError(t *testing.T) { assert.Contains(t, textContent(t, result), "Rate limited") } -func TestEventsGet_APIError(t *testing.T) { +func TestEventGet_APIError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2025-07-01/events/evt_nope": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -1636,7 +1736,7 @@ func TestEventsGet_APIError(t *testing.T) { }, }) - result := callTool(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_nope"}) + result := callTool(t, session, "gateway_event", map[string]any{"action": "get", "id": "evt_nope"}) assert.True(t, result.IsError) assert.Contains(t, textContent(t, result), "not found") } @@ -1673,8 +1773,10 @@ func TestHelpTool_AllTopics(t *testing.T) { {"gateway_sources", "list"}, {"gateway_destinations", "HTTP"}, {"gateway_transformations", "JavaScript"}, - {"gateway_requests", "raw_body"}, - {"gateway_events", "raw_body"}, + {"gateway_requests", "list"}, + {"gateway_request", "raw_body"}, + {"gateway_events", "list"}, + {"gateway_event", "raw_body"}, {"gateway_attempts", "event_id"}, {"gateway_issues", "delivery"}, {"gateway_metrics", "granularity"}, @@ -1698,8 +1800,8 @@ func TestHelpTool_AllTopics(t *testing.T) { func TestHelpTool_ShortNames(t *testing.T) { shortNames := []string{ "projects", "connections", "sources", "destinations", - "transformations", "requests", "events", "attempts", - "issues", "metrics", "help", + "transformations", "requests", "request", "events", "event", + "attempts", "issues", "metrics", "help", } client := newTestClient("https://api.hookdeck.com", "test-key") @@ -1730,8 +1832,9 @@ func TestHelpTool_OverviewListsAllTools(t *testing.T) { expectedTools := []string{ "hookdeck_projects", "gateway_connections", "gateway_sources", - "gateway_destinations", "gateway_transformations", "gateway_requests", - "gateway_events", "gateway_attempts", "gateway_issues", + "gateway_destinations", "gateway_transformations", + "gateway_requests", "gateway_request", + "gateway_events", "gateway_event", "gateway_attempts", "gateway_issues", "gateway_metrics", "gateway_help", } for _, tool := range expectedTools { diff --git a/pkg/gateway/mcp/telemetry_test.go b/pkg/gateway/mcp/telemetry_test.go index 6b7794ad..295a2dc1 100644 --- a/pkg/gateway/mcp/telemetry_test.go +++ b/pkg/gateway/mcp/telemetry_test.go @@ -152,6 +152,43 @@ func TestMCPToolCall_TelemetryHeaderReflectsAction(t *testing.T) { require.Equal(t, "gateway_sources/get", getTel.CommandPath) } +// command_path is "/", so splitting a tool changes what +// telemetry reports for the actions that moved. The singular tools have to +// report their own names, not the plural ones they came from. +func TestMCPToolCall_TelemetryNamesTheSingularTools(t *testing.T) { + t.Setenv("HOOKDECK_CLI_TELEMETRY_DISABLED", "") + + cases := []struct { + tool, action, id, path, want string + }{ + {"gateway_event", "get", "evt_1", "/2025-07-01/events/evt_1", "gateway_event/get"}, + {"gateway_request", "get", "req_1", "/2025-07-01/requests/req_1", "gateway_request/get"}, + {"gateway_events", "list", "", "/2025-07-01/events", "gateway_events/list"}, + {"gateway_requests", "list", "", "/2025-07-01/requests", "gateway_requests/list"}, + } + + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { + capture := &headerCapture{} + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + tc.path: capture.handler(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "res_1"})) + }), + }) + + args := map[string]any{"action": tc.action} + if tc.id != "" { + args["id"] = tc.id + } + result := callTool(t, session, tc.tool, args) + require.False(t, result.IsError) + + tel := parseTelemetryHeader(t, capture.last()) + require.Equal(t, tc.want, tel.CommandPath) + }) + } +} + func TestMCPToolCall_TelemetryDisabledByConfig(t *testing.T) { t.Setenv("HOOKDECK_CLI_TELEMETRY_DISABLED", "") diff --git a/pkg/gateway/mcp/tool_event.go b/pkg/gateway/mcp/tool_event.go new file mode 100644 index 00000000..911932ae --- /dev/null +++ b/pkg/gateway/mcp/tool_event.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" +) + +// gateway_event is the single-record half of the events pair: every action here +// addresses one event by id and needs nothing else. Searching for events lives +// on gateway_events — see tool_events.go. +var eventActions = mcpcore.ActionSet{ + {Name: "get", Desc: "get this event's metadata and headers"}, + {Name: "raw_body", Desc: "get this event's payload"}, + {Name: "retry", Desc: "queue another delivery attempt for this event", Write: true}, + {Name: "cancel", Desc: "stop this scheduled event from being delivered", Write: true, Destructive: true}, + {Name: "mute", Desc: "mute this failed event so it stops raising issues", Write: true, Destructive: true}, +} + +var eventSpec = mcpcore.ToolSpec{ + Resource: "event", + Summary: "ONE event by ID — singular, single-record. Use this when you already have an event ID: read the event, get its payload, retry, cancel or mute it. Takes an id and nothing else. " + + "To find events in the first place — by status, source, destination, date range or payload — use " + eventsToolName + " (plural), which takes the filters and returns IDs. This tool has no filters and cannot search. " + + "Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: eventActions, + Required: []string{"id"}, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Event ID (required, one event). Get one from " + eventsToolName + " list, or from a request's events action on " + requestToolName + "."}, + }, + Notes: `Plural vs singular — which of the two event tools to use: + ` + eventToolName + ` (this tool, singular) — you already have an event ID and want to read or act on it. + ` + eventsToolName + ` (plural) — you have filters and want to find matching events. + The usual flow is ` + eventsToolName + ` to find an ID, then ` + eventToolName + ` with that ID. + +Getting the payload: + get returns metadata and headers only. Use raw_body for the payload — there is no need to go via + the request tools when you already have an event id. + Example: {"action":"raw_body","id":"evt_abc"} + +Acting on a failure (write mode): + retry queues another delivery attempt and is the usual follow-up to investigating a failed event. + cancel stops a scheduled event from ever being delivered; mute stops a failed event raising + further issues without retrying it. + +From an event to its request: + An event carries request_id. Pass that to ` + requestToolName + ` with action get to see the raw + inbound request. Events cannot be filtered by request_id — for the other direction, call + ` + requestToolName + ` with action events.`, + Handler: handleEvent, +} + +func handleEvent(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 := mcpcore.DispatchWithDefault(srv, eventActions, in.String("action"), "get") + if blocked != nil { + return blocked, nil + } + + switch action { + case "get": + return eventGet(ctx, client, in) + case "raw_body": + return eventRawBody(ctx, client, in) + case "retry": + return eventRetry(ctx, client, in) + case "cancel": + return eventCancel(ctx, client, in) + default: + return eventMute(ctx, client, in) + } + } +} + +func eventGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the get action"), nil + } + event, err := client.GetEvent(ctx, id, nil) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(event, client) +} + +func eventRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the raw_body action"), nil + } + body, err := client.GetEventRawBody(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + text := string(body) + if len(body) > maxRawBodyBytes { + text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) +} + +func eventRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "retry", "retried", client.RetryEvent) +} + +func eventCancel(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "cancel", "cancelled", client.CancelEvent) +} + +func eventMute(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + return eventAction(ctx, client, in, "mute", "muted", client.MuteEvent) +} + +// eventAction runs one of the by-id event mutations, which all take an event id +// and return no body, and reports the outcome in a consistent shape. +func eventAction( + ctx context.Context, + client *hookdeck.Client, + in mcpcore.Input, + action, status string, + call func(context.Context, string) error, +) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", action) + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + if err := call(ctx, id); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "event_id": id, + "status": status, + }, client) +} diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 28b96ce1..de1eac41 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -9,30 +9,32 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) +// gateway_events is the collection half of the events pair: it searches for +// events and hands back their ids. Everything you can do to one event lives on +// gateway_event — see tool_event.go. var eventsActions = mcpcore.ActionSet{ - {Name: "list", Desc: "list events, most recent first"}, - {Name: "get", Desc: "get one event's metadata and headers"}, - {Name: "raw_body", Desc: "get one event's payload"}, - {Name: "retry", Desc: "queue another delivery attempt for an event", Write: true}, - {Name: "cancel", Desc: "stop a scheduled event from being delivered", Write: true, Destructive: true}, - {Name: "mute", Desc: "mute a failed event so it stops raising issues", Write: true, Destructive: true}, + {Name: "list", Desc: "search events by filter, most recent first; returns event IDs"}, } var eventsSpec = mcpcore.ToolSpec{ Resource: "events", - Summary: "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). Use action raw_body with the event id to get the payload directly — do not use the requests tool for the payload when you already have an event id. Results are scoped to the active project — call the projects tool first if the user has specified a project.", - Actions: eventsActions, + Summary: "SEARCH MANY events — plural, collection only. Find events (processed deliveries routed through connections to destinations) matching filters and get back their IDs. " + + "List supports the same filters as `hookdeck gateway event list` (metadata, date range, payload search, sort). " + + "To act on a specific event you already have an ID for — read it, get its payload, retry, cancel or mute it — use " + eventToolName + " (singular). This tool cannot do any of that; it only searches. " + + "There is no request_id filter here: to see the events one request produced, call " + requestToolName + " with action events. " + + "Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: eventsActions, Props: map[string]mcpcore.Prop{ - "id": {Type: "string", Desc: "Event ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/retry/cancel/mute"}, - "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)"}, + "id": {Type: "string", Desc: "Filter by event ID(s), comma-separated. To fetch or act on one event by ID, use " + eventToolName + " instead."}, + "connection_id": {Type: "string", Desc: "Filter by connection (maps to webhook_id)"}, + "source_id": {Type: "string", Desc: "Filter by source"}, + "destination_id": {Type: "string", Desc: "Filter by destination"}, "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)"}, + "attempts": {Type: "string", Desc: "Filter by attempt count. Integer or API operator syntax; pass through as string."}, + "issue_id": {Type: "string", Desc: "Filter by issue"}, + "error_code": {Type: "string", Desc: "Filter by error code"}, + "response_status": {Type: "string", Desc: "Filter by HTTP response status"}, + "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID"}, "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}, @@ -43,13 +45,19 @@ var eventsSpec = mcpcore.ToolSpec{ "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)"}, + "limit": {Type: "integer", Desc: "Max results"}, + "order_by": {Type: "string", Desc: "Sort field"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc"}, "next": {Type: "string", Desc: "Next page cursor"}, "prev": {Type: "string", Desc: "Previous page cursor"}, }, - Notes: `Date range filters (list): + Notes: `Plural vs singular — which of the two event tools to use: + ` + eventsToolName + ` (this tool, plural) — you have filters and want to find matching events. + ` + eventToolName + ` (singular) — you already have an event ID and want to read or act on it + (get, raw_body, retry, cancel, mute). + The usual flow is ` + eventsToolName + ` to find an ID, then ` + eventToolName + ` with that ID. + +Date range filters: Use *_after / *_before with ISO 8601 datetimes. Do not pass API bracket keys in MCP args. created_after → created_at[gte] created_before → created_at[lte] @@ -59,19 +67,17 @@ var eventsSpec = mcpcore.ToolSpec{ last_attempt_before → last_attempt_at[lte] Example: {"action":"list","status":"FAILED","last_attempt_after":"2026-06-08T00:00:00Z"} -Payload search (list): +Payload search: body, headers, parsed_query — Hookdeck JSON filter syntax (object or string) path — partial URL path match Example: {"action":"list","body":{"type":"charge.succeeded"}} -Getting the payload: - get returns metadata and headers only. Use raw_body with the event id for the payload — there is - no need to go via the requests tool when you already have an event id. - -Acting on a failure (write mode): - retry queues another delivery attempt and is the usual follow-up to investigating a failed event. - cancel stops a scheduled event from ever being delivered; mute stops a failed event raising - further issues without retrying it.`, +Requests and events: + The API offers one traversal direction only. Events cannot be filtered by request_id — there is + no such filter, so do not look for one. To get the events a request produced, call + ` + requestToolName + ` with action events (or ignored_events for the ones filtered out). + Going the other way, an event carries request_id: read it from the event and pass it to + ` + requestToolName + ` with action get.`, Handler: handleEvents, } @@ -87,62 +93,14 @@ func handleEvents(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := mcpcore.DispatchWithDefault(srv, eventsActions, in.String("action"), "list") - if blocked != nil { + if _, blocked := mcpcore.DispatchWithDefault(srv, eventsActions, in.String("action"), "list"); blocked != nil { return blocked, nil } - switch action { - case "list": - return eventsList(ctx, client, in) - case "get": - return eventsGet(ctx, client, in) - case "raw_body": - return eventsRawBody(ctx, client, in) - case "retry": - return eventsRetry(ctx, client, in) - case "cancel": - return eventsCancel(ctx, client, in) - default: - return eventsMute(ctx, client, in) - } + return eventsList(ctx, client, in) } } -func eventsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - return eventAction(ctx, client, in, "retry", "retried", client.RetryEvent) -} - -func eventsCancel(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - return eventAction(ctx, client, in, "cancel", "cancelled", client.CancelEvent) -} - -func eventsMute(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - return eventAction(ctx, client, in, "mute", "muted", client.MuteEvent) -} - -// eventAction runs one of the by-id event mutations, which all take an event id -// and return no body, and reports the outcome in a consistent shape. -func eventAction( - ctx context.Context, - client *hookdeck.Client, - in mcpcore.Input, - action, status string, - call func(context.Context, string) error, -) (*mcpsdk.CallToolResult, error) { - id, err := mcpcore.RequireString(in, "id", action) - if err != nil { - return mcpcore.ErrorResult(err.Error()), nil - } - if err := call(ctx, id); err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(map[string]string{ - "event_id": id, - "status": status, - }, client) -} - func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "id", in.String("id")) @@ -177,31 +135,3 @@ func eventsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) } return mcpcore.JSONResultEnvelopeForClient(result, client) } - -func eventsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the get action"), nil - } - event, err := client.GetEvent(ctx, id, nil) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(event, client) -} - -func eventsRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the raw_body action"), nil - } - body, err := client.GetEventRawBody(ctx, id) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - text := string(body) - if len(body) > maxRawBodyBytes { - text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" - } - return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) -} diff --git a/pkg/gateway/mcp/tool_request.go b/pkg/gateway/mcp/tool_request.go new file mode 100644 index 00000000..1b9138b6 --- /dev/null +++ b/pkg/gateway/mcp/tool_request.go @@ -0,0 +1,157 @@ +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" +) + +const maxRawBodyBytes = 100 * 1024 // 100 KB + +// gateway_request is the single-record half of the requests pair: every action +// here addresses one request by id. Searching for requests lives on +// gateway_requests — see tool_requests.go. +var requestActions = mcpcore.ActionSet{ + {Name: "get", Desc: "get this request"}, + {Name: "raw_body", Desc: "get this request's raw body"}, + {Name: "events", Desc: "list the events this request produced"}, + {Name: "ignored_events", Desc: "list the events this request produced that were filtered out"}, + {Name: "retry", Desc: "route this request through its connections again, creating new events", Write: true}, +} + +var requestSpec = mcpcore.ToolSpec{ + Resource: "request", + Summary: "ONE request by ID — singular, single-record. Use this when you already have a request ID: read the request, get its raw body, list the events it produced, or retry it. Takes an id (plus connection_ids for retry) and nothing else. " + + "To find requests in the first place — by source, status, date range or payload — use " + requestsToolName + " (plural), which takes the filters and returns IDs. This tool has no filters and cannot search. " + + "Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: requestActions, + Required: []string{"id"}, + Props: map[string]mcpcore.Prop{ + "id": {Type: "string", Desc: "Request ID (required, one request). Get one from " + requestsToolName + " list, or from an event's request_id."}, + "connection_ids": {Type: "array", Desc: "Connections to re-route the request through (retry action only). Omit to retry every connection the request matched.", Items: &mcpcore.Prop{Type: "string"}}, + }, + Notes: `Plural vs singular — which of the two request tools to use: + ` + requestToolName + ` (this tool, singular) — you already have a request ID and want to read or act on it. + ` + requestsToolName + ` (plural) — you have filters and want to find matching requests. + The usual flow is ` + requestsToolName + ` to find an ID, then ` + requestToolName + ` with that ID. + +Requests and events: + events lists the events this request produced; ignored_events lists the ones a connection filter + dropped. This is the only relationship traversal the API offers — events cannot be filtered by + request_id, and requests cannot be filtered by event_id, so do not look for those filters. + Coming the other way, an event carries request_id: pass it here with action get. + Act on an individual event returned by the events action with ` + eventToolName + `. + +Retrying (write mode): + retry re-routes the stored request through its connections, creating new events. It does not + modify the original request. Omit connection_ids to retry every connection the request matched. + Example: {"action":"retry","id":"req_abc","connection_ids":["web_123"]}`, + Handler: handleRequest, +} + +func handleRequest(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 := mcpcore.DispatchWithDefault(srv, requestActions, in.String("action"), "get") + if blocked != nil { + return blocked, nil + } + + switch action { + case "get": + return requestGet(ctx, client, in) + case "raw_body": + return requestRawBody(ctx, client, in) + case "events": + return requestEvents(ctx, client, in) + case "ignored_events": + return requestIgnoredEvents(ctx, client, in) + default: + return requestRetry(ctx, client, in) + } + } +} + +func requestGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the get action"), nil + } + r, err := client.GetRequest(ctx, id, nil) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(r, client) +} + +func requestRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the raw_body action"), nil + } + body, err := client.GetRequestRawBody(ctx, id) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + text := string(body) + if len(body) > maxRawBodyBytes { + text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) +} + +func requestEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the events action"), nil + } + result, err := client.GetRequestEvents(ctx, id, nil) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +func requestIgnoredEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id := in.String("id") + if id == "" { + return mcpcore.ErrorResult("id is required for the ignored_events action"), nil + } + result, err := client.GetRequestIgnoredEvents(ctx, id, nil) + if err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(result, client) +} + +func requestRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { + id, err := mcpcore.RequireString(in, "id", "retry") + if err != nil { + return mcpcore.ErrorResult(err.Error()), nil + } + // An empty body retries every connection the request matched, which is what + // the API does when webhook_ids is omitted. + var body *hookdeck.RequestRetryRequest + if ids := mcpcore.StringList(in, "connection_ids"); len(ids) > 0 { + body = &hookdeck.RequestRetryRequest{WebhookIDs: ids} + } + if err := client.RetryRequest(ctx, id, body); err != nil { + return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil + } + return mcpcore.JSONResultEnvelopeForClient(map[string]string{ + "request_id": id, + "status": "retried", + }, client) +} diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 85269788..33f6317b 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -9,28 +9,27 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/mcpcore" ) -const maxRawBodyBytes = 100 * 1024 // 100 KB - +// gateway_requests is the collection half of the requests pair: it searches for +// requests and hands back their ids. Everything you can do to one request lives +// on gateway_request — see tool_request.go. var requestsActions = mcpcore.ActionSet{ - {Name: "list", Desc: "list inbound requests"}, - {Name: "get", Desc: "get one request"}, - {Name: "raw_body", Desc: "get one request's raw body"}, - {Name: "events", Desc: "list the events a request produced"}, - {Name: "ignored_events", Desc: "list the events a request produced that were filtered out"}, - {Name: "retry", Desc: "route a request through its connections again, creating new events", Write: true}, + {Name: "list", Desc: "search inbound requests by filter, most recent first; returns request IDs"}, } var requestsSpec = mcpcore.ToolSpec{ Resource: "requests", - Summary: "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). Results are scoped to the active project — call the projects tool first if the user has specified a project.", - Actions: requestsActions, + Summary: "SEARCH MANY requests — plural, collection only. Find inbound requests (raw HTTP data received by Hookdeck before routing) matching filters and get back their IDs. " + + "List supports the same filters as `hookdeck gateway request list` (metadata, date range, payload search, sort). " + + "To act on a specific request you already have an ID for — read it, get its raw body, list the events it produced, or retry it — use " + requestToolName + " (singular). This tool cannot do any of that; it only searches. " + + "There is no event_id filter here: to go from an event to its request, read request_id off the event and call " + requestToolName + " with action get. " + + "Results are scoped to the active project — call the projects tool first if the user has specified a project.", + Actions: requestsActions, Props: map[string]mcpcore.Prop{ - "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events/retry"}, - "connection_ids": {Type: "array", Desc: "Connections to re-route the request through (retry). Omit to retry every connection the request matched.", Items: &mcpcore.Prop{Type: "string"}}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, - "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, - "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, + "id": {Type: "string", Desc: "Filter by request ID(s), comma-separated. To fetch or act on one request by ID, use " + requestToolName + " instead."}, + "source_id": {Type: "string", Desc: "Filter by source"}, + "status": {Type: "string", Desc: "Filter by status: accepted or rejected"}, + "rejection_cause": {Type: "string", Desc: "Filter by rejection cause"}, + "verified": {Type: "boolean", Desc: "Filter by verification status"}, "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, @@ -39,13 +38,19 @@ var requestsSpec = mcpcore.ToolSpec{ "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, "path": {Type: "string", Desc: descPathFilter}, - "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field, e.g. created_at"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc"}, + "limit": {Type: "integer", Desc: "Max results"}, "next": {Type: "string", Desc: "Next page cursor"}, "prev": {Type: "string", Desc: "Previous page cursor"}, }, - Notes: `Date range filters (list): + Notes: `Plural vs singular — which of the two request tools to use: + ` + requestsToolName + ` (this tool, plural) — you have filters and want to find matching requests. + ` + requestToolName + ` (singular) — you already have a request ID and want to read or act on it + (get, raw_body, events, ignored_events, retry). + The usual flow is ` + requestsToolName + ` to find an ID, then ` + requestToolName + ` with that ID. + +Date range filters: Use *_after / *_before with ISO 8601 datetimes (e.g. 2026-06-01T00:00:00Z). Do not pass API bracket keys like created_at[gte] in MCP args. created_after → created_at[gte] (inclusive lower bound) created_before → created_at[lte] (inclusive upper bound) @@ -53,15 +58,16 @@ var requestsSpec = mcpcore.ToolSpec{ ingested_before → ingested_at[lte] Example: {"action":"list","ingested_after":"2026-06-09T12:00:00Z","source_id":"src_abc"} -Payload search (list): +Payload search: body, headers, parsed_query — Hookdeck JSON filter syntax (object or string). Same as hookdeck listen --filter-body. path — partial URL path match (string) Example: {"action":"list","body":{"type":"charge.succeeded"}} -Retrying (write mode): - retry re-routes the stored request through its connections, creating new events. It does not - modify the original request. Omit connection_ids to retry every connection the request matched. - Example: {"action":"retry","id":"req_abc","connection_ids":["web_123"]}`, +Requests and events: + The API offers one traversal direction only. Requests cannot be filtered by event_id — there is + no such filter, so do not look for one. From an event, read its request_id and call + ` + requestToolName + ` with action get. From a request, call ` + requestToolName + ` with action + events to list the events it produced.`, Handler: handleRequests, } @@ -77,48 +83,14 @@ func handleRequests(srv *mcpcore.Server) mcpsdk.ToolHandler { return mcpcore.ErrorResult(err.Error()), nil } - action, blocked := mcpcore.DispatchWithDefault(srv, requestsActions, in.String("action"), "list") - if blocked != nil { + if _, blocked := mcpcore.DispatchWithDefault(srv, requestsActions, in.String("action"), "list"); blocked != nil { return blocked, nil } - switch action { - case "list": - return requestsList(ctx, client, in) - case "get": - return requestsGet(ctx, client, in) - case "raw_body": - return requestsRawBody(ctx, client, in) - case "events": - return requestsEvents(ctx, client, in) - case "ignored_events": - return requestsIgnoredEvents(ctx, client, in) - default: - return requestsRetry(ctx, client, in) - } + return requestsList(ctx, client, in) } } -func requestsRetry(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id, err := mcpcore.RequireString(in, "id", "retry") - if err != nil { - return mcpcore.ErrorResult(err.Error()), nil - } - // An empty body retries every connection the request matched, which is what - // the API does when webhook_ids is omitted. - var body *hookdeck.RequestRetryRequest - if ids := mcpcore.StringList(in, "connection_ids"); len(ids) > 0 { - body = &hookdeck.RequestRetryRequest{WebhookIDs: ids} - } - if err := client.RetryRequest(ctx, id, body); err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(map[string]string{ - "request_id": id, - "status": "retried", - }, client) -} - func requestsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { params := make(map[string]string) mcpcore.SetIfNonEmpty(params, "id", in.String("id")) @@ -152,55 +124,3 @@ func requestsList(ctx context.Context, client *hookdeck.Client, in mcpcore.Input } return mcpcore.JSONResultEnvelopeForClient(result, client) } - -func requestsGet(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the get action"), nil - } - r, err := client.GetRequest(ctx, id, nil) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(r, client) -} - -func requestsRawBody(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the raw_body action"), nil - } - body, err := client.GetRequestRawBody(ctx, id) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - text := string(body) - if len(body) > maxRawBodyBytes { - text = string(body[:maxRawBodyBytes]) + "\n... [truncated]" - } - return mcpcore.JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) -} - -func requestsEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the events action"), nil - } - result, err := client.GetRequestEvents(ctx, id, nil) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(result, client) -} - -func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in mcpcore.Input) (*mcpsdk.CallToolResult, error) { - id := in.String("id") - if id == "" { - return mcpcore.ErrorResult("id is required for the ignored_events action"), nil - } - result, err := client.GetRequestIgnoredEvents(ctx, id, nil) - if err != nil { - return mcpcore.ErrorResult(mcpcore.TranslateAPIError(err)), nil - } - return mcpcore.JSONResultEnvelopeForClient(result, client) -} diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index f21b8b66..c384191d 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -18,6 +18,10 @@ const ( toolPrefix = "gateway" helpToolName = toolPrefix + "_help" helpTopicPrefix = toolPrefix + "_" + eventsToolName = toolPrefix + "_events" + eventToolName = toolPrefix + "_event" + requestsToolName = toolPrefix + "_requests" + requestToolName = toolPrefix + "_request" 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 gateway_help (overview or any tool topic)." ) @@ -56,6 +60,13 @@ func NewServer(opts ServerOptions) *mcpcore.Server { // resourceSpecs lists every product tool the Event Gateway server exposes. // Registration order is the order tools are advertised in. +// +// Events and requests are split into a plural collection tool and a singular +// single-record tool. Their actions share no parameters beyond an id: list +// carries ~20 filters that no by-id action can use, so a single tool would +// show every one of them to a caller that only has an id. The pairs are +// registered next to each other so the naming distinction is visible where an +// agent reads the tool list. func resourceSpecs() []mcpcore.ToolSpec { return []mcpcore.ToolSpec{ connectionsSpec, @@ -63,7 +74,9 @@ func resourceSpecs() []mcpcore.ToolSpec { destinationsSpec, transformationsSpec, requestsSpec, + requestSpec, eventsSpec, + eventSpec, attemptsSpec, issuesSpec, metricsSpec, diff --git a/pkg/gateway/mcp/write_actions_test.go b/pkg/gateway/mcp/write_actions_test.go index b656e693..9b200410 100644 --- a/pkg/gateway/mcp/write_actions_test.go +++ b/pkg/gateway/mcp/write_actions_test.go @@ -701,7 +701,7 @@ func TestTransformationsWriteActionsRequireAnID(t *testing.T) { } // --------------------------------------------------------------------------- -// gateway_events +// gateway_events (plural: search only) // --------------------------------------------------------------------------- func TestEventsListSendsFilters(t *testing.T) { @@ -723,17 +723,26 @@ func TestEventsListSendsFilters(t *testing.T) { assert.Contains(t, got.query, "status=FAILED") } -func TestEventsGetByID(t *testing.T) { +// --------------------------------------------------------------------------- +// gateway_event (singular: one event by id) +// --------------------------------------------------------------------------- + +func TestEventGetByID(t *testing.T) { var got wireRequest session := readSession(t, map[string]http.HandlerFunc{ "GET /2025-07-01/events/evt_1": ok(&got, map[string]any{"id": "evt_1", "status": "FAILED"}), }) - succeeds(t, session, "gateway_events", map[string]any{"action": "get", "id": "evt_1"}) + text := succeeds(t, session, "gateway_event", map[string]any{"action": "get", "id": "evt_1"}) + + assert.Equal(t, http.MethodGet, got.method) assert.Equal(t, "/2025-07-01/events/evt_1", got.path) + // The id addresses one record; sending it as a filter would list instead. + assert.Empty(t, got.query) + assert.Contains(t, string(envelopeData(t, text)), "evt_1") } -func TestEventsRawBody(t *testing.T) { +func TestEventRawBody(t *testing.T) { var got wireRequest session := readSession(t, map[string]http.HandlerFunc{ "GET /2025-07-01/events/evt_1/raw_body": func(w http.ResponseWriter, r *http.Request) { @@ -742,15 +751,16 @@ func TestEventsRawBody(t *testing.T) { }, }) - text := succeeds(t, session, "gateway_events", map[string]any{"action": "raw_body", "id": "evt_1"}) + text := succeeds(t, session, "gateway_event", map[string]any{"action": "raw_body", "id": "evt_1"}) + assert.Equal(t, http.MethodGet, got.method) assert.Equal(t, "/2025-07-01/events/evt_1/raw_body", got.path) assert.Contains(t, string(envelopeData(t, text)), "amount") } // The three by-id event mutations differ in method and path, and the API // returns no body, so the tool reports the outcome itself. -func TestEventsRetryCancelAndMute(t *testing.T) { +func TestEventRetryCancelAndMute(t *testing.T) { cases := []struct { action string pattern string @@ -769,7 +779,7 @@ func TestEventsRetryCancelAndMute(t *testing.T) { tc.pattern: ok(&got, nil), }) - text := succeeds(t, session, "gateway_events", map[string]any{"action": tc.action, "id": "evt_1"}) + text := succeeds(t, session, "gateway_event", map[string]any{"action": tc.action, "id": "evt_1"}) assert.Equal(t, tc.method, got.method) assert.Equal(t, "/2025-07-01/events/evt_1/"+tc.action, got.path) @@ -779,7 +789,7 @@ func TestEventsRetryCancelAndMute(t *testing.T) { } // --------------------------------------------------------------------------- -// gateway_requests +// gateway_requests (plural: search only) // --------------------------------------------------------------------------- func TestRequestsListSendsFilters(t *testing.T) { @@ -797,17 +807,26 @@ func TestRequestsListSendsFilters(t *testing.T) { assert.Contains(t, got.query, "status=accepted") } -func TestRequestsGetByID(t *testing.T) { +// --------------------------------------------------------------------------- +// gateway_request (singular: one request by id) +// --------------------------------------------------------------------------- + +func TestRequestGetByID(t *testing.T) { var got wireRequest session := readSession(t, map[string]http.HandlerFunc{ "GET /2025-07-01/requests/req_1": ok(&got, map[string]any{"id": "req_1"}), }) - succeeds(t, session, "gateway_requests", map[string]any{"action": "get", "id": "req_1"}) + text := succeeds(t, session, "gateway_request", map[string]any{"action": "get", "id": "req_1"}) + + assert.Equal(t, http.MethodGet, got.method) assert.Equal(t, "/2025-07-01/requests/req_1", got.path) + // The id addresses one record; sending it as a filter would list instead. + assert.Empty(t, got.query) + assert.Contains(t, string(envelopeData(t, text)), "req_1") } -func TestRequestsRawBody(t *testing.T) { +func TestRequestRawBody(t *testing.T) { var got wireRequest session := readSession(t, map[string]http.HandlerFunc{ "GET /2025-07-01/requests/req_1/raw_body": func(w http.ResponseWriter, r *http.Request) { @@ -816,13 +835,16 @@ func TestRequestsRawBody(t *testing.T) { }, }) - text := succeeds(t, session, "gateway_requests", map[string]any{"action": "raw_body", "id": "req_1"}) + text := succeeds(t, session, "gateway_request", map[string]any{"action": "raw_body", "id": "req_1"}) + assert.Equal(t, http.MethodGet, got.method) assert.Equal(t, "/2025-07-01/requests/req_1/raw_body", got.path) assert.Contains(t, string(envelopeData(t, text)), "amount") } -func TestRequestsEventsAndIgnoredEvents(t *testing.T) { +// events and ignored_events are the only relationship traversal the API +// offers, so they have to reach their own sub-resource paths. +func TestRequestEventsAndIgnoredEvents(t *testing.T) { for _, action := range []string{"events", "ignored_events"} { t.Run(action, func(t *testing.T) { var got wireRequest @@ -830,19 +852,22 @@ func TestRequestsEventsAndIgnoredEvents(t *testing.T) { "GET /2025-07-01/requests/req_1/" + action: ok(&got, listResponse(map[string]any{"id": "evt_1"})), }) - succeeds(t, session, "gateway_requests", map[string]any{"action": action, "id": "req_1"}) + text := succeeds(t, session, "gateway_request", map[string]any{"action": action, "id": "req_1"}) + + assert.Equal(t, http.MethodGet, got.method) assert.Equal(t, "/2025-07-01/requests/req_1/"+action, got.path) + assert.Contains(t, string(envelopeData(t, text)), "evt_1") }) } } -func TestRequestsRetrySendsSelectedConnections(t *testing.T) { +func TestRequestRetrySendsSelectedConnections(t *testing.T) { var got wireRequest session := writeSession(t, map[string]http.HandlerFunc{ "POST /2025-07-01/requests/req_1/retry": ok(&got, nil), }) - text := succeeds(t, session, "gateway_requests", map[string]any{ + text := succeeds(t, session, "gateway_request", map[string]any{ "action": "retry", "id": "req_1", "connection_ids": []any{"web_1", "web_2"}, }) @@ -856,13 +881,13 @@ func TestRequestsRetrySendsSelectedConnections(t *testing.T) { // Omitting connection_ids must send an empty body, which the API reads as // "every connection the request matched". -func TestRequestsRetryWithoutConnectionsSendsNoIDs(t *testing.T) { +func TestRequestRetryWithoutConnectionsSendsNoIDs(t *testing.T) { var got wireRequest session := writeSession(t, map[string]http.HandlerFunc{ "POST /2025-07-01/requests/req_1/retry": ok(&got, nil), }) - succeeds(t, session, "gateway_requests", map[string]any{"action": "retry", "id": "req_1"}) + succeeds(t, session, "gateway_request", map[string]any{"action": "retry", "id": "req_1"}) assert.NotContains(t, got.decodeBody(t), "webhook_ids") } @@ -1083,12 +1108,16 @@ func coveredActions() map[string]map[string]bool { "list": true, "get": true, "create": true, "upsert": true, "update": true, "delete": true, "run": true, }, - "requests": { - "list": true, "get": true, "raw_body": true, "events": true, + // requests/events are the plural search tools; request/event are the + // singular by-id tools they hand IDs to. + "requests": {"list": true}, + "request": { + "get": true, "raw_body": true, "events": true, "ignored_events": true, "retry": true, }, - "events": { - "list": true, "get": true, "raw_body": true, + "events": {"list": true}, + "event": { + "get": true, "raw_body": true, "retry": true, "cancel": true, "mute": true, }, "attempts": {"list": true, "get": true}, diff --git a/pkg/gateway/mcp/write_mode_test.go b/pkg/gateway/mcp/write_mode_test.go index 63cca75c..7c2a3583 100644 --- a/pkg/gateway/mcp/write_mode_test.go +++ b/pkg/gateway/mcp/write_mode_test.go @@ -61,7 +61,8 @@ func TestListTools_ReadOnlyMode(t *testing.T) { for _, name := range []string{ "hookdeck_projects", "hookdeck_login", "gateway_help", "gateway_connections", "gateway_sources", "gateway_destinations", - "gateway_transformations", "gateway_requests", "gateway_events", + "gateway_transformations", "gateway_requests", "gateway_request", + "gateway_events", "gateway_event", "gateway_attempts", "gateway_issues", "gateway_metrics", } { assert.Contains(t, tools, name) @@ -85,8 +86,12 @@ func TestListTools_ReadOnlyMode(t *testing.T) { // run stays available: it is a sandbox evaluation that persists nothing, // and debugging a transformation is read-only-mode work. assert.Equal(t, []string{"list", "get", "run"}, actionEnum(t, tools["gateway_transformations"])) - assert.Equal(t, []string{"list", "get", "raw_body"}, actionEnum(t, tools["gateway_events"])) - assert.Equal(t, []string{"list", "get", "raw_body", "events", "ignored_events"}, actionEnum(t, tools["gateway_requests"])) + // The plural tools search and nothing else; the singular tools are + // where the by-id actions live, write-gated as before. + assert.Equal(t, []string{"list"}, actionEnum(t, tools["gateway_events"])) + assert.Equal(t, []string{"get", "raw_body"}, actionEnum(t, tools["gateway_event"])) + assert.Equal(t, []string{"list"}, actionEnum(t, tools["gateway_requests"])) + assert.Equal(t, []string{"get", "raw_body", "events", "ignored_events"}, actionEnum(t, tools["gateway_request"])) assert.Equal(t, []string{"list", "get"}, actionEnum(t, tools["gateway_issues"])) }) @@ -101,7 +106,7 @@ func TestListTools_ReadOnlyMode(t *testing.T) { t.Run("write actions are absent from the description", func(t *testing.T) { for _, name := range []string{ "gateway_connections", "gateway_sources", "gateway_destinations", - "gateway_transformations", "gateway_events", "gateway_requests", "gateway_issues", + "gateway_transformations", "gateway_event", "gateway_request", "gateway_issues", } { description := tools[name].Description for _, action := range []string{"create", "upsert", "update", "delete", "retry", "cancel", "mute", "dismiss"} { @@ -111,13 +116,17 @@ func TestListTools_ReadOnlyMode(t *testing.T) { }) t.Run("read-only mode is stated in the description", func(t *testing.T) { - assert.Contains(t, tools["gateway_events"].Description, "read-only mode") - assert.Contains(t, tools["gateway_events"].Description, "gateway_help") + assert.Contains(t, tools["gateway_event"].Description, "read-only mode") + assert.Contains(t, tools["gateway_event"].Description, "gateway_help") + // The plural tools only search, so there is nothing being withheld + // from them and no notice to give. + assert.NotContains(t, tools["gateway_events"].Description, "read-only mode") }) t.Run("tools are annotated read-only", func(t *testing.T) { for _, name := range []string{ "gateway_connections", "gateway_sources", "gateway_events", + "gateway_event", "gateway_requests", "gateway_request", "gateway_attempts", "gateway_metrics", } { require.NotNil(t, tools[name].Annotations, name) @@ -144,12 +153,16 @@ func TestListTools_WriteMode(t *testing.T) { assert.Equal(t, []string{"list", "get", "create", "upsert", "update", "delete", "run"}, actionEnum(t, tools["gateway_transformations"])) + assert.Equal(t, []string{"list"}, actionEnum(t, tools["gateway_events"]), + "the plural tool stays search-only in write mode") assert.Equal(t, - []string{"list", "get", "raw_body", "retry", "cancel", "mute"}, - actionEnum(t, tools["gateway_events"])) + []string{"get", "raw_body", "retry", "cancel", "mute"}, + actionEnum(t, tools["gateway_event"])) + assert.Equal(t, []string{"list"}, actionEnum(t, tools["gateway_requests"]), + "the plural tool stays search-only in write mode") assert.Equal(t, - []string{"list", "get", "raw_body", "events", "ignored_events", "retry"}, - actionEnum(t, tools["gateway_requests"])) + []string{"get", "raw_body", "events", "ignored_events", "retry"}, + actionEnum(t, tools["gateway_request"])) assert.Equal(t, []string{"list", "get", "update", "dismiss"}, actionEnum(t, tools["gateway_issues"])) @@ -157,7 +170,12 @@ func TestListTools_WriteMode(t *testing.T) { t.Run("tools with writes are no longer annotated read-only", func(t *testing.T) { assert.False(t, tools["gateway_connections"].Annotations.ReadOnlyHint) - assert.False(t, tools["gateway_events"].Annotations.ReadOnlyHint) + assert.False(t, tools["gateway_event"].Annotations.ReadOnlyHint) + assert.False(t, tools["gateway_request"].Annotations.ReadOnlyHint) + assert.True(t, tools["gateway_events"].Annotations.ReadOnlyHint, + "searching for events changes nothing in any mode") + assert.True(t, tools["gateway_requests"].Annotations.ReadOnlyHint, + "searching for requests changes nothing in any mode") assert.True(t, tools["gateway_attempts"].Annotations.ReadOnlyHint, "attempts has no write actions in any mode") assert.True(t, tools["gateway_metrics"].Annotations.ReadOnlyHint, @@ -167,18 +185,22 @@ func TestListTools_WriteMode(t *testing.T) { t.Run("destructive tools carry the destructive hint", func(t *testing.T) { for _, name := range []string{ "gateway_connections", "gateway_sources", "gateway_destinations", - "gateway_transformations", "gateway_events", "gateway_issues", + "gateway_transformations", "gateway_event", "gateway_issues", } { 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["gateway_requests"].Annotations.DestructiveHint) - assert.False(t, *tools["gateway_requests"].Annotations.DestructiveHint, - "retrying a request destroys nothing") + // cancel and mute moved to the singular tool, so the plural one no + // longer claims to be destructive. + for _, name := range []string{"gateway_request", "gateway_events", "gateway_requests"} { + require.NotNil(t, tools[name].Annotations.DestructiveHint, name) + assert.False(t, *tools[name].Annotations.DestructiveHint, + "%s destroys nothing", name) + } }) t.Run("the read-only notice is gone", func(t *testing.T) { - assert.NotContains(t, tools["gateway_events"].Description, "read-only mode") + assert.NotContains(t, tools["gateway_event"].Description, "read-only mode") }) } @@ -223,10 +245,10 @@ func TestWriteGuard_BlocksWriteActionsInReadOnlyMode(t *testing.T) { {"gateway_destinations", map[string]any{"action": "delete", "id": "des_1"}}, {"gateway_transformations", map[string]any{"action": "create", "name": "t", "code": "return"}}, {"gateway_transformations", map[string]any{"action": "delete", "id": "trs_1"}}, - {"gateway_events", map[string]any{"action": "retry", "id": "evt_1"}}, - {"gateway_events", map[string]any{"action": "cancel", "id": "evt_1"}}, - {"gateway_events", map[string]any{"action": "mute", "id": "evt_1"}}, - {"gateway_requests", map[string]any{"action": "retry", "id": "req_1"}}, + {"gateway_event", map[string]any{"action": "retry", "id": "evt_1"}}, + {"gateway_event", map[string]any{"action": "cancel", "id": "evt_1"}}, + {"gateway_event", map[string]any{"action": "mute", "id": "evt_1"}}, + {"gateway_request", map[string]any{"action": "retry", "id": "req_1"}}, {"gateway_issues", map[string]any{"action": "update", "id": "iss_1", "status": "RESOLVED"}}, {"gateway_issues", map[string]any{"action": "dismiss", "id": "iss_1"}}, } @@ -317,8 +339,8 @@ func TestWriteGuard_AllowsWriteActionsInWriteMode(t *testing.T) { args map[string]any want string }{ - {"events retry", "gateway_events", map[string]any{"action": "retry", "id": "evt_1"}, "POST /2025-07-01/events/evt_1/retry"}, - {"requests retry", "gateway_requests", map[string]any{"action": "retry", "id": "req_1"}, "POST /2025-07-01/requests/req_1/retry"}, + {"event retry", "gateway_event", map[string]any{"action": "retry", "id": "evt_1"}, "POST /2025-07-01/events/evt_1/retry"}, + {"request retry", "gateway_request", map[string]any{"action": "retry", "id": "req_1"}, "POST /2025-07-01/requests/req_1/retry"}, {"sources create", "gateway_sources", map[string]any{"action": "create", "name": "s", "type": "HTTP"}, "POST /2025-07-01/sources"}, {"sources upsert", "gateway_sources", map[string]any{"action": "upsert", "name": "s", "type": "HTTP"}, "PUT /2025-07-01/sources"}, {"destinations create", "gateway_destinations", map[string]any{"action": "create", "name": "d", "type": "HTTP"}, "POST /2025-07-01/destinations"}, @@ -348,10 +370,10 @@ func TestWriteActions_RequireAnID(t *testing.T) { tool string args map[string]any }{ - {"gateway_events", map[string]any{"action": "retry"}}, - {"gateway_events", map[string]any{"action": "cancel"}}, - {"gateway_events", map[string]any{"action": "mute"}}, - {"gateway_requests", map[string]any{"action": "retry"}}, + {"gateway_event", map[string]any{"action": "retry"}}, + {"gateway_event", map[string]any{"action": "cancel"}}, + {"gateway_event", map[string]any{"action": "mute"}}, + {"gateway_request", map[string]any{"action": "retry"}}, {"gateway_sources", map[string]any{"action": "delete"}}, {"gateway_destinations", map[string]any{"action": "delete"}}, {"gateway_transformations", map[string]any{"action": "delete"}}, @@ -388,7 +410,7 @@ func TestHelpReportsMode(t *testing.T) { t.Run("a topic only documents the available actions", func(t *testing.T) { session := connectInMemory(t, newTestClient(api.URL, "test-key")) - text := textContent(t, callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_events"})) + text := textContent(t, callTool(t, session, "gateway_help", map[string]any{"topic": "gateway_event"})) // Scope the assertion to the generated Actions list. Prose further down // may legitimately mention what the gated actions do. @@ -397,7 +419,7 @@ func TestHelpReportsMode(t *testing.T) { actions, _, ok := strings.Cut(rest, "\n\n") require.True(t, ok) - assert.Contains(t, actions, "list") + assert.Contains(t, actions, "get") for _, gated := range []string{"retry", "cancel", "mute"} { assert.NotContains(t, actions, gated, "read-only help must not list the %s action", gated) From 1ef3e218f787e5e59f818b149f9fcf90b7d01776 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 19 Aug 2026 16:32:55 +0100 Subject: [PATCH 34/49] docs(mcp): document the events/requests plural-singular split The README tool table now lists all four tools, and says which one searches and which one acts on a record. Records the one traversal direction the API supports so the table is not read as implying the reverse filter exists. The acceptance suite asserts the four action enums and that both singular tools are reachable end to end. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- README.md | 17 +++++++++++--- test/acceptance/mcp_test.go | 47 +++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0a02bca2..f85f884c 100644 --- a/README.md +++ b/README.md @@ -645,8 +645,10 @@ Product tools are prefixed `gateway_`. Signing in and switching project are Hook | `gateway_sources` | list, get | create, upsert, update, delete, enable, disable | | `gateway_destinations` | list, get | create, upsert, update, delete, enable, disable | | `gateway_transformations` | list, get | create, upsert, update, delete, run | -| `gateway_requests` | list, get, raw_body, events, ignored_events | retry | -| `gateway_events` | list, get, raw_body | retry, cancel, mute | +| `gateway_requests` | list | — | +| `gateway_request` | get, raw_body, events, ignored_events | retry | +| `gateway_events` | list | — | +| `gateway_event` | get, raw_body | retry, cancel, mute | | `gateway_attempts` | list, get | — | | `gateway_issues` | list, get | update, dismiss | | `gateway_metrics` | events, requests, attempts, transformations | — | @@ -654,8 +656,17 @@ Product tools are prefixed `gateway_`. Signing in and switching project are Hook `transformations run` executes code without storing anything, but it is gated as a write: a read-only session should not be able to run caller-supplied code. +Events and requests are each split into a **plural** tool that searches and a **singular** tool that acts on one record: + +- `gateway_events` / `gateway_requests` (plural) take the filters and return IDs. They cannot fetch or change a single record. +- `gateway_event` / `gateway_request` (singular) take an `id` and nothing else (plus `connection_ids` on request retry). They cannot search. + +The usual flow is plural to find an ID, then singular with that ID. The split keeps ~20 list filters out of the schema for actions that only need an id. + `gateway_events` and `gateway_requests` **list** actions support the same filters as `hookdeck gateway event list` and `hookdeck gateway request list` — including payload search (`body`, `headers`, `parsed_query`, `path`) and date windows via `*_after` / `*_before` (ISO 8601; maps to API `field[gte]` / `field[lte]`). See `gateway_help` with topic `gateway_events` or `gateway_requests` for the full parameter list. +The only relationship traversal the API supports is request → events: `gateway_request` with action `events` (or `ignored_events`). There is no `request_id` filter on events and no `event_id` filter on requests. To go the other way, read `request_id` off an event and call `gateway_request` with action `get`. + `gateway_help` reports which mode the session is in and lists only the actions it can perform. #### Example prompts @@ -673,7 +684,7 @@ Once the MCP server is configured, you can ask your agent questions like: → Agent uses gateway_metrics with measures like failed_count and count, grouped by destination. "Trace request req_abc123 — what events did it produce, and did they all deliver successfully?" -→ Agent uses gateway_requests to get the request, then the events action to list generated events. +→ Agent uses gateway_request to get the request, then its events action to list generated events. "Why is my checkout endpoint returning 500s? Show me the latest attempt details." → Agent uses gateway_events filtered by status FAILED, then gateway_attempts to inspect delivery details. diff --git a/test/acceptance/mcp_test.go b/test/acceptance/mcp_test.go index e99e53e3..449db21e 100644 --- a/test/acceptance/mcp_test.go +++ b/test/acceptance/mcp_test.go @@ -135,6 +135,32 @@ func TestMCPRequestsList_DateRangeAndBodyFilter(t *testing.T) { "expected list payload in %s", result.Text) } +// The singular tools are the ones an agent reaches for once it has an id, so +// they have to be reachable end to end. A missing record fails at the API, +// which proves the call got that far; an unknown action or tool would not. +func TestMCPSingularToolsAreReachable(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + + cases := []struct{ tool, id string }{ + {"gateway_event", "evt_does_not_exist"}, + {"gateway_request", "req_does_not_exist"}, + } + + for _, tc := range cases { + t.Run(tc.tool, func(t *testing.T) { + result := CallGatewayMCPTool(t, cli.projectRoot, cli.configPath, tc.tool, map[string]any{ + "action": "get", + "id": tc.id, + }, 20*time.Second) + assert.NotContains(t, result.Text, "unknown action") + assert.NotContains(t, result.Text, "Unknown tool") + }) + } +} + func TestGatewayMCPStdio_OutpostProjectRejected(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") @@ -174,8 +200,10 @@ func TestGatewayMCPStdio_ReadOnlyByDefault(t *testing.T) { for _, name := range []string{ "hookdeck_projects", "hookdeck_login", "gateway_help", "gateway_connections", "gateway_sources", - "gateway_destinations", "gateway_transformations", "gateway_requests", - "gateway_events", "gateway_attempts", "gateway_issues", "gateway_metrics", + "gateway_destinations", "gateway_transformations", + "gateway_requests", "gateway_request", + "gateway_events", "gateway_event", + "gateway_attempts", "gateway_issues", "gateway_metrics", } { assert.Contains(t, tools, name) } @@ -190,7 +218,13 @@ func TestGatewayMCPStdio_ReadOnlyByDefault(t *testing.T) { assert.Equal(t, []string{"list", "get", "pause", "unpause"}, MCPToolActionEnum(t, tools["gateway_connections"])) assert.Equal(t, []string{"list", "get"}, MCPToolActionEnum(t, tools["gateway_sources"])) - assert.Equal(t, []string{"list", "get", "raw_body"}, MCPToolActionEnum(t, tools["gateway_events"])) + // Events and requests are split plural/singular: the plural tools search, + // the singular ones act on one record by id. + assert.Equal(t, []string{"list"}, MCPToolActionEnum(t, tools["gateway_events"])) + assert.Equal(t, []string{"get", "raw_body"}, MCPToolActionEnum(t, tools["gateway_event"])) + assert.Equal(t, []string{"list"}, MCPToolActionEnum(t, tools["gateway_requests"])) + assert.Equal(t, []string{"get", "raw_body", "events", "ignored_events"}, + MCPToolActionEnum(t, tools["gateway_request"])) } func TestGatewayMCPStdio_AllowWriteAddsWriteActions(t *testing.T) { @@ -203,8 +237,11 @@ func TestGatewayMCPStdio_AllowWriteAddsWriteActions(t *testing.T) { tools, stdout, stderr := ListMCPTools(t, cli.projectRoot, cli.configPath, command, 10*time.Second) assertGatewayMCPStdioHygiene(t, stdout, stderr) - assert.Contains(t, MCPToolActionEnum(t, tools["gateway_events"]), "retry") - assert.Contains(t, MCPToolActionEnum(t, tools["gateway_requests"]), "retry") + // retry lives on the singular tools, and stays off the plural ones. + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_event"]), "retry") + assert.Contains(t, MCPToolActionEnum(t, tools["gateway_request"]), "retry") + assert.Equal(t, []string{"list"}, MCPToolActionEnum(t, tools["gateway_events"])) + assert.Equal(t, []string{"list"}, MCPToolActionEnum(t, tools["gateway_requests"])) for _, want := range []string{"create", "upsert", "update", "delete", "enable", "disable"} { assert.Contains(t, MCPToolActionEnum(t, tools["gateway_connections"]), want) assert.Contains(t, MCPToolActionEnum(t, tools["gateway_sources"]), want) From eda57f996780cdde429f108d0eb9fc0a32774fbf Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 20 Aug 2026 10:01:09 +0100 Subject: [PATCH 35/49] revert: restore the package.json version after an accidental release A tag named v3.0.0-pin-pre-tool-shape-review was pushed as a marker to diff against. release.yml triggers on "push: tags: v*", so it ran the full pipeline: a GitHub pre-release, an npm publish, and this bot commit setting the version to the tag name. The release and tag are deleted and the npm dist-tag is being removed. This restores the version so the branch does not carry a bogus one into the real 3.0.0 release. A marker does not need a tag; a965637 is the commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c2a138c..d7525984 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "3.0.0-pin-pre-tool-shape-review", + "version": "2.6.0-beta.2", "description": "Hookdeck CLI", "repository": { "type": "git", From 2ca2cdba84c5888bca6319f76fdcc6981e63d417 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 20 Aug 2026 10:39:00 +0100 Subject: [PATCH 36/49] feat(gateway): add the missing documented query filters to events and requests The API documents seven public filters that neither the CLI nor the MCP tools exposed. Two of them answer questions that had no answer at all before: `--events-count 0` finds requests that produced no events, which is the usual explanation for a webhook that appears to have gone missing, and `--search-term` matches a value across body, headers, parsed query and path at once, for when you know the value but not which field carries it. Events gain search_term, delivery_group and next_attempt_at bounds; requests gain search_term and the events/ignored/cli event counts. The filters go on the plural, list-only MCP tools; the singular by-id tools keep their id and nothing else, which is the point of the pair. The spec also carries parameters marked x-docs-hide. Those are omitted deliberately, and a test on each surface pins that so they are not later added by someone reading the spec without the context. The tests assert the query string that goes on the wire, not the exit status: an unrecognised query parameter is ignored rather than rejected, so a misspelled filter key still exits zero and still prints results. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3 --- REFERENCE.md | 19 ++++ pkg/cmd/event_list.go | 27 ++++- pkg/cmd/gateway_list_filters_test.go | 153 ++++++++++++++++++++++++++ pkg/cmd/request_list.go | 28 ++++- pkg/gateway/mcp/server_test.go | 87 +++++++++++++++ pkg/gateway/mcp/tool_events.go | 21 ++++ pkg/gateway/mcp/tool_requests.go | 29 ++++- pkg/gateway/mcp/tools.go | 20 ++++ pkg/gateway/mcp/write_actions_test.go | 60 ++++++++++ test/acceptance/event_test.go | 59 ++++++++++ test/acceptance/helpers.go | 44 +++++++- test/acceptance/request_test.go | 76 +++++++++++++ 12 files changed, 613 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/gateway_list_filters_test.go diff --git a/REFERENCE.md b/REFERENCE.md index 72eb1d04..14d79544 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1560,6 +1560,9 @@ hookdeck gateway transformation executions get