From 2d8f9d92ec58a6a970e151cb16eebcda2c77a943 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 14 Aug 2026 15:42:28 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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",