v3.0.0: Outpost support, Gateway MCP write mode, and the gateway_ tool rename - #368
Open
leggetter wants to merge 76 commits into
Open
v3.0.0: Outpost support, Gateway MCP write mode, and the gateway_ tool rename#368leggetter wants to merge 76 commits into
leggetter wants to merge 76 commits into
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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 <type>` 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination-type list|get`, and makes `destination create --type <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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`--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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…d status commands 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`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 `<group> mcp` command, and name the login tool that exists in that
session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…ry 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…/outpost-api-client
The action / actionSet / toolSpec / dispatch pattern was package-private in pkg/outpost/mcp, so a second server could not reuse it. Move it to pkg/mcpcore/toolspec.go as exported Action, ActionSet, ToolSpec and Dispatch, and port the Outpost server onto the exported versions. The read-only description suffix hardcoded a reference to outpost_help. It now comes from Server.HelpToolName(), so each product points at its own help tool. Outpost behaviour is unchanged: pkg/outpost/mcp/tools_test.go passes with only identifier renames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…to gateway_ Port the Event Gateway MCP tools onto mcpcore.ToolSpec, so their schemas are built from an action set rather than hand-written, and add the write actions every one of them was missing. The API client already had every method; this is tool-layer work only. Write mode is off by default. In read-only mode the write actions are absent from the action enum and from the tool description, so an agent is never offered something it cannot do; mcpcore.RequireWrite sits behind that as defence in depth. Enable with --allow-write or HOOKDECK_MCP_ALLOW_WRITE=true; --read-only is accepted and wins if both are passed. resolveAllowWrite is now shared with the Outpost server rather than duplicated. Actions added: connections create, upsert, update, delete, enable, disable sources create, upsert, update, delete, enable, disable destinations create, upsert, update, delete, enable, disable transformations create, upsert, update, delete, run events retry, cancel, mute requests retry issues update, dismiss pause and unpause deliberately stay read-mode actions. Read-only is the mode people investigate incidents in, and stopping a misbehaving connection is the natural end of an investigation; both are reversible and drop nothing. The rationale is recorded at the action definition. transformations run is gated as a write even though it stores nothing: it executes caller-supplied code, and a read-only session should not be able to cause that. BREAKING CHANGE: the nine product tools and the help tool are renamed from hookdeck_* to gateway_*. Per-tool permission grants and allowedTools config do not survive a rename, so every user must re-grant them. hookdeck_connections -> gateway_connections hookdeck_sources -> gateway_sources hookdeck_destinations -> gateway_destinations hookdeck_transformations -> gateway_transformations hookdeck_requests -> gateway_requests hookdeck_events -> gateway_events hookdeck_attempts -> gateway_attempts hookdeck_issues -> gateway_issues hookdeck_metrics -> gateway_metrics hookdeck_help -> gateway_help hookdeck_login and hookdeck_projects are unchanged: signing in and switching project are Hookdeck operations whichever product's server you are in. gateway_help is now generated from the tool specs, so it reports the current mode and can no longer advertise an action the session cannot perform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Unit coverage in pkg/gateway/mcp/write_mode_test.go, mirroring the Outpost suite: the action enum and tool description in each mode, the read-only and destructive annotations, the handler-level guard refusing every write action without --allow-write, and successful write calls asserted on the request the handler sends rather than on "did not error". Two tests exist specifically to hold the pause/unpause decision in place: pause and unpause stay in the read-only action enum, and calling them against a read-only server is not refused. If someone later gates them, these fail. Acceptance coverage under the existing mcp tag: tools/list omits the write actions without the flag and includes them with it, the renamed tools are advertised and the old hookdeck_ product names are not, and gateway_help reports the current mode. README documents read-only-by-default, --allow-write, the pause/unpause exception, and the full per-tool action table. Both tagged suites pass locally: -tags=mcp and -tags=outpost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Ten of the Outpost MCP write actions had only their read-only refusal covered — tenants delete/token/portal, destinations create/update/delete/ enable/disable and the two custom-domain writes. Four are annotated destructive. Nothing proved any of them worked, so an agent running with --allow-write would have been the first caller. Several reads were never called at all: outpost_attempts with any action, outpost_config get and custom_domain_get, destinations get, events list/get, destination_types get, metrics events, topics and status. The new tests assert the request that goes on the wire — method, path, query and body — rather than only that the call did not error, because a stub server answers whatever it is asked and would hide a wire-shape bug. TestEveryActionHasBeenCalledSuccessfully is a checklist that fails when a new action lands without a successful call written for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The client request types and the outpost_destinations MCP tool both supported destination metadata, but the CLI exposed no way to set it, so the same field was reachable through an agent and not through a person. `outpost tenant upsert` already had --metadata/--metadata-file; this brings destinations to the same shape and shares one resolver between them rather than keeping a second copy. Metadata alone now counts as an update, and --filter's "replaced wholesale, not merged" note covers metadata too. Adds unit coverage driving the commands' RunE against a stub Outpost API: `outpost config set` was previously only ever run with --dry-run, because the acceptance project's config is shared with every other test in that file, which left the PATCH body — including how --unset encodes as null — with no coverage at all. Also covers config get, the custom-domain commands, tenant portal, and empty-value rejection on outpost flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…om domain The tenant portal and the three custom-domain commands had no automated coverage of any kind. Adding it surfaced two defects. An error body whose "data" member is an object failed to decode, so the whole envelope was passed through to the user as raw JSON with the readable sentence buried inside it. That is the shape used for not-found and for several rejected-value errors, so it affected a large share of the Outpost errors anyone would actually hit. ErrorResponse now accepts every shape the API returns. `outpost tenant portal` answers 404 whenever the project has no portal, which reads as a missing tenant. It now names the precondition its own help already documents, and the command to fix it. The new acceptance test configures a custom domain rather than being gated behind an opt-in env var, because an opt-in would not run in CI and these are the commands with the least coverage. Prior state is read first and restored in t.Cleanup, and the hostname is unique per run. Also covers tenant list pagination, which accepted --next and --prev but had never been sent one, and `destination-type get` with an unknown type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The outpostlive build tag appeared in no workflow, so the only test proving the Outpost API client works against a real host had never run automatically — silent non-execution rather than a considered decision. It stays out of the pull-request matrix on purpose: it makes real requests to a live deployment, so a deployment problem would fail every unrelated PR. A nightly schedule plus workflow_dispatch keeps it honest without coupling it to the PR gate. The tests skip themselves when the key is absent, which is right locally and wrong in CI, so the job fails fast on a missing secret rather than reporting a green run that tested nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
An outpost command run against a non-Outpost project said to run 'hookdeck project use'. With a project-scoped credential that command refuses and says to sign in again — and signing in with the same key lands back on the original error. Three commands, no way out, on the path every new user takes. The guard now establishes whether the credential can switch projects at all before advising, and when it cannot, names the two things that do work: signing in with an account-wide key, or pointing the machine at the Outpost project with its own API key. The extra request is only made on a path that has already failed, and a failure to make it falls back to the previous advice rather than compounding one error with another. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…llel run A project's custom domain is a single value, so two acceptance runs against the same project can overwrite each other's. The hostname is still asserted on the fast path, immediately after set; the slow poll now only waits for a portal URL to exist, and cleanup only removes the domain this test set. Otherwise a collision between runs would be reported as a CLI defect. Documents --metadata on tenants and destinations in the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
ReadOnlyHint was derived from whether any available action was gated. pause and unpause are deliberately ungated so they stay usable in read-only mode, so gateway_connections advertised ReadOnlyHint: true while offering them. A client that auto-approves read-only tools would have halted production delivery without asking anyone. Gating and annotation are now separate questions. Write still decides what read-only mode offers; Mutates records that an action changes something regardless. Almost every write is both and needs no extra flag — Mutates exists for the actions we choose not to gate. The gating decision is unchanged: pause and unpause remain available in read-only mode, and the tests pin both halves so the two cannot drift back together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The reauth path removed the stored credentials before opening the browser flow. A completed sign-in calls ApplyPollAPIKeyResponse, which sets every field the clear had zeroed, so removing them first gained nothing on the path that works. It only had an effect on the path that does not: an abandoned flow, a closed laptop, a failed poll, and the user is signed out of every terminal and every future session with nothing to show for it. Clearing in memory is still needed, so this process stops using the old key and the sign-in proceeds as unauthenticated. That half is unchanged. ClearActiveProfileCredentialsInMemory is a new method rather than a change to the existing one, so `hookdeck logout` — the only other caller — keeps removing stored credentials exactly as before. Tests pin both: that an abandoned reauth leaves the file byte-identical, that logout still clears it, and that the two zero the same in-memory fields. fix(login): buffer the interrupt channel so Ctrl-C is not dropped signal.Notify never blocks, so an unbuffered channel discards signals sent while nobody is receiving. A Ctrl-C between Notify and the receiving goroutine starting was dropped, leaving the terminal in the raw state the password prompt had put it in. This was the last `go vet ./...` finding in the repo; vet is now clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
TestListenCommandBasic and TestListenCommandWithContext start `listen` as a subprocess without wiring Stdout or Stderr, so the only evidence of a failure is "exit status 1". Both have been failing in CI since 26ffa5b and the logs carry nothing that identifies the cause; the same tests pass locally under the same slice configuration. The sibling tests added for #333 already capture output through startListenCapturingOutput, which is why their failures are readable. These two predate it and hand-roll the subprocess. This only makes the failure diagnosable — no behaviour is changed and no cause is assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
TestListenCommandBasic and TestListenCommandWithContext started `listen` via `go run` with output going nowhere, so a failure surfaced only as "exit status 1". Both have been failing in CI since 26ffa5b with nothing in the logs to attribute it to. Capturing the output on the `go run` form does not work: `go run` starts the compiled program as a child process, so killing it orphans that child still holding the inherited stdout and stderr, and cmd.Wait blocks until the orphan exits. Adding capture alone turned the failure into a termination timeout instead. These now use startListenCapturingOutput, the helper the #333 tests already use: it builds a binary, runs it directly, wires both streams to buffers, and passes HOOKDECK_CONFIG_FILE explicitly rather than relying on it being inherited. A kill reaches the process that is actually listening, and a failure prints what listen said. The cause of the CI failure is still unidentified. This makes the next occurrence readable rather than assuming what it will be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Declared, never set by any spec, never read. Its doc comment described a mechanism that does not exist: Define always puts "action" in the schema's required list, so a spec-level default could only ever apply to a caller that ignores the schema, and the actual defaulting is done per-handler by DispatchWithDefault. A field that documents behaviour the code does not have is worse than no field — the next tool author sets it and nothing happens. The reasoning now lives on DispatchWithDefault, where the fallback actually is. Raised in review of #348, and independently by a code review pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The branch still read 2.6.0-beta.2, restored after a tag that accidentally triggered a release. This is the 3.0.0 line now, and a 2.6 beta version on it is misleading to anyone reading the branch. The release workflow rewrites package.json from the tag, so tagging v3.0.0-beta.1 would have set this anyway. This only makes the branch say the right thing before then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…ring
Both surfaces described search_term as a partial match, copied from the
API schema, which says "match partially". Verified against the live API,
it is not: the term has to equal a complete field value.
posted {"sentence":"quixotic wombat flugelhorn","dotted":"foo.bar.baz",
"email":"pat@example.test"}
"quixotic wombat flugelhorn" -> 1 match
"wombat" -> 0
"foo.bar.baz" -> 1
"bar" -> 0
"pat@example.test" -> 1
"example" -> 0
So it is not a substring match and not word-tokenised either. Calling it
partial sends people looking for substrings that can never match, and the
filter returns an empty list rather than an error, so there is nothing to
tell them apart from "no results".
This is what made the filter look broken during testing: one pass
searched for terms that were words inside larger values and concluded it
did not work at all.
Wording corrected on the CLI flags, the MCP prop descriptions and the
help topics, with a worked example in each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…tination
The destination-type schema publishes a default per field, and the CLI
prints it in help ("tls ... default: on"). Nothing sent it. The API treats
an absent key as unset rather than applying the default itself, so a
caller who read the help and omitted the flag got the opposite of what was
advertised.
Following the CLI's own printed example for a rabbitmq destination:
outpost destination create --type rabbitmq \
--config server_url=... --credential username=... --credential password=...
stored tls: "false", and the connection carried its SASL credentials in
the clear. Same for kafka. field.Default was read in exactly one place —
to render help — so we were documenting a default we never applied.
Defaults are now filled in for config fields the caller did not supply,
and the applied values are printed to stderr: someone who wants TLS off
should have to see that they are turning it off.
Values are passed through as the schema declares them. The schema is not
self-consistent about booleans — rabbitmq's tls default is "on" and
kafka's is "true" — and the API normalises both, so passing them through
avoids inventing a mapping that could drift from what it accepts.
Create only. Update is a merge patch where an omitted key means "leave
this alone", so filling defaults there would rewrite fields nobody
mentioned.
Verified against the live API: rabbitmq and kafka now store tls "true"
when the flag is omitted, and an explicit --config tls=false is still
honoured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
RetryEvent, CancelEvent and MuteEvent discarded the response body and
reported success from the status code alone. The API answers 200 for a
no-op — cancelling an already-delivered event leaves it SUCCESSFUL — so
the MCP tool returned {"event_id":"...","status":"cancelled"} for events
that were never cancelled, and the CLI printed "Event ... cancelled."
Confirmed against the live API: PUT /events/{id}/mute on a delivered
event answers 200 with the full event, status still SUCCESSFUL. The
response said what happened; we threw it away and asserted otherwise.
The three methods now return the event, as the connection pause/unpause
actions already did. The MCP tool returns it so an agent can see the real
status, and the CLI prints it: "Event evt_x is now SUCCESSFUL."
The existing test pinned the old shape — it asserted the hardcoded
"cancelled" — so it passed throughout. Replaced with one that answers
SUCCESSFUL from the stub and fails if the tool claims otherwise.
Third instance of this shape in the codebase: a wrong answer that reads
as a right one, after silently dropped filters and the events_count: 0
case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…sult
`transformation run` answers 200 whether the code ran or threw, and
log_level is the only signal that says which. TransformationRunResponse
omitted both log_level and console, so a syntax error, a throwing handler,
a handler that returned nothing and a clean run were indistinguishable:
the MCP tool returned {"data":{}} for all four, and the CLI printed
"✔ Transformation run completed" and exited 0.
Confirmed against the live API. A throwing handler answers:
{"log_level":"fatal","console":[{"type":"error","message":"Error: ..."}]}
with no request at all. The reason was always there; we discarded it.
Both fields are now on the response, with Failed() and ConsoleText()
helpers. A failed run is an error on both surfaces and carries the
console output, so the caller sees "Error: boom-marker" rather than
nothing.
Separately, the MCP path did not supply a content-type. The engine errors
without one and the schema tells callers headers may be an empty object,
so following the documentation produced a failure nothing explained —
identical code worked from the CLI, which has always injected it. Now
both do, and an explicit content-type from the caller is left alone.
This was reported as an MCP defect. The CLI had the same bug: it just
happened to send a content-type, so it failed less often rather than
reporting better.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Define filtered the action enum by mode but nothing else that describes those actions. Two leaks followed. The hand-written summaries enumerated actions, duplicating the generated "Actions: ..." line, so they went stale as soon as the enum was filtered. gateway_event in read-only mode said "read the event, get its payload, retry, cancel or mute it" with an enum of [get, raw_body]. Prose is more persuasive to a model than a schema. The summaries no longer list actions at all — the generated line already does that, accurately per mode. What they keep is the part it cannot express: which tool to reach for, and what this one deliberately cannot do. Properties had the same problem: a read-only session was offered config, type, description, rules and status — parameters belonging to actions it had not been given, with nothing in the schema to say so. Prop.Write marks them and Define drops them alongside the actions. connection_ids on gateway_request is the clearest case: it exists only for retry. This is #363, and the prose half found by driving the servers for real. One existing test asserted gateway_request always carries connection_ids; it now asserts absent in read-only and present in write mode, which is the actual contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
… not The API masks AWS credentials on read but returns a webhook destination's signing secret in full. A security review flagged that as a leak, and it sits awkwardly next to gating tenant token and portal as writes precisely because they hand back reusable credentials. It is deliberate. A webhook secret can be generated by the platform, so withholding it would make it unretrievable and leave no way to verify signatures at the receiving end. Every other type's credentials are supplied by the caller at creation — they already have them, so returning them adds nothing and only widens exposure. The rule is: credentials the platform can produce are readable; credentials the caller provided are masked. Written down where the type is defined, because this has been raised twice and will be raised again by anyone who sees the two behaviours side by side without knowing which is an input and which is an output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
--events-count, --ignored-count, --cli-events-count and --attempts were
all documented "(integer or operators)". Only a bare integer works:
gateway request list --events-count '>0' -> 422
gateway request list --events-count '{"gt":0}' -> 422
events_count must be one of [number, object, array]
The API does accept operators — verified on the wire, events_count[gte]=1
returns only records with a count of 1 — but the CLI sends the value as a
bare scalar, so there is no way to express one. The date filters get this
right with paired --x-after/--x-before flags mapping to [gte]/[lte]; the
count filters have no equivalent.
I introduced three of these four when adding the count filters, by
copying the wording from --attempts, which had carried the false claim
since before. So one wrong description became four.
Correcting the text rather than adding operator support: this is a
pre-release fix for something we tell users that is not true, and six new
flags is a feature. Follow-up filed with the verified encoding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`outpost publish --tenant-id no-such-tenant` printed a green tick and exited 0. The API accepts the event with a 202 and an id; it then matches no destination, is never delivered, and appears in no event list. The only hint was "No destinations matched this topic", which blames the topic when the tenant is what is wrong. Publishing follows the --api-key credential rather than the active project, so this also fires when the key is for a different project than the one being worked in — the case the message now names. TenantExistsForPublish was written for exactly this and the MCP publish tool has always called it. The CLI did not, so the two surfaces answered the same mistake differently: one refused with an explanation, the other reported success. A failed lookup is deliberately not fatal. If the check itself errors the publish still goes ahead, because refusing over a transient failure would be worse than the problem being guarded against. Tested both ways. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Four papercuts, two of them the same wrong-answer shape as the filters fixed earlier in this branch. Unknown arguments were accepted and ignored. gateway_events has no request_id filter, so passing one returned every event in the project with nothing to distinguish that from a real result. additionalProperties:false is now set, and — since nothing enforces it at this layer — checked before the handler runs. Two things take precedence over that message. An unauthenticated call is handed to the handler, so the caller is told to sign in rather than corrected on an argument they cannot use yet. An argument that exists but is hidden by read-only mode is let through to the write guard, because "restart with --allow-write" is the useful answer and "unknown argument" would send them hunting for a typo that is not there. Unreadable booleans were dropped rather than rejected: verified: "yes" returned every request, verified and unverified alike, and reported them as unverified. BoolOrStringE errors instead. Applied to verified, disabled and eligible_for_retry. gateway_connections said "id or name is required" to callers who had just passed name — id accepts a name, but only id was read, and name is a declared property so reaching for it is the obvious mistake. Both are now read. gateway_destinations offered type MOCK in its summary while the property said MOCK_API and the API accepts only MOCK_API. The summary is what a model weights most. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…uesses wrong The split was validated by driving both servers — an agent picked the right tool every time — but the two ways of getting it wrong both dead ended. Reaching for the sibling's action answered "unknown action \"list\"; expected one of: get, raw_body" and stopped there, so an agent had to already know the other tool existed. The error now names it, in both directions. Passing an id of the wrong kind answered "Resource not found", so an agent reported that a request did not exist when it did. Hookdeck ids carry their type as a prefix and the split increases how often an agent holds both kinds at once, so this is now caught before the call: a req_ id given to the event tool says what it is and which tool takes it. Only prefixes that clearly belong to another tool are caught. Anything unrecognised is left to the API, so a new resource type does not start failing here the day it ships. Dispatch takes the hint as a variadic argument, so the tools without a sibling are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Three cases where our own output sent people somewhere useless. The first example in `hookdeck outpost --help` used --config-url, a flag that does not exist; the form is --config url=. Copy-pasting the first example a user sees gave "unknown flag". A bad --api-key on `outpost publish` answered with the generic 401 text: "run hookdeck login". That produces exactly the kind of credential the publish API refuses, as the command's own help says two paragraphs earlier. It now explains that a Project API key is required and that signing in again will not help. The same generic text appended "MCP: use hookdeck_login with reauth: true" in a terminal, where the reader has no way to call a tool. That line is now only printed to an actual MCP session. Also improved the type-specific help fallback. `destination create --type webhook --help` does work — it was reported as broken, but the report was taken from an unauthenticated session. What it printed there was "Run 'hookdeck outpost destination-type get webhook'", a command needing the same credentials that would fail identically. It now says the fields are read from the API and need a signed-in session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
TestEventCancel and TestEventMute asserted the output contained "cancelled" and "muted". They were pinning the bug fixed in 6c16792: the event these tests create has already been delivered, so cancelling it is a no-op, the API answers 200, and the command asserted an outcome nobody had checked. Both passed continuously while the defect shipped, which is the point — they asserted what the code did rather than what it should do. That is the third test in this branch to certify the defect it covered, after mcpLoginToolName returning its own literal and the MCP action test asserting the same hardcoded status. They now assert the event id and that a status is reported, and fail if the output claims "cancelled" or "muted" for an event that was neither. Found by running the acceptance slices locally before pushing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Several decisions in these files read as bugs on inspection: Write and
Mutates look redundant, pause and unpause are mutations reachable in
read-only mode, transformations run executes caller code and is not gated,
and the unknown-argument guard exempts two cases that look like holes.
Each was already explained in a comment. What none of them said is what
stops someone acting on the misreading. Every one is pinned by a test, so
the comments now name it — a reviewer who thinks a line is wrong can run
that test and see the intent asserted rather than inferred.
Verified rather than claimed: each decision was reverted in turn and the
named test failed, then restored.
pause given Write: true -> TestWriteGuard_PauseIsNotGated and
"a tool offering pause is not
annotated read-only" both fail
run given Write: true -> TestWriteGuard_TransformationRunIsNotGated
mode exemption removed -> TestHiddenWriteArgumentsGetTheWriteModeMessage
Define also now states the invariant it relies on and nothing enforces:
four things there depend on the mode — the action enum, the properties,
the description and the annotations — and they must agree. Each has been
wrong separately in this branch. Anyone adding a fifth needs to extend
TestReadOnlyModeHidesWriteOnlyPropsAndProse.
The ReadOnlyHint line explains why it derives from HasChanging rather than
HasWrite at the point it does so, instead of one hop away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
All three were real. Verified before fixing and after.
**Help drifted from the schema.** Define filtered Prop.Write out of the
advertised schema; Help rendered spec.Props directly. So a read-only
gateway_help topic still listed config, rules, connection_ids and status —
an agent got a different parameter set depending on where it looked, and
the help topic is the more persuasive of the two.
Fixed structurally rather than by patching Help: VisibleProps decides once
and both callers use it, so the two cannot drift again.
This is the invariant documented on Define two commits ago, which listed
four mode-dependent things and missed a fifth. The comment is now wrong by
omission in a way the code no longer is.
**The mode exemption still allowed a silent ignore.** rejectUnknownArgs
lets a hidden argument through so the write guard can answer it, which is
right for {"action":"create","type":"HTTP"}. But it applied whatever
action was requested, so {"action":"list","type":"HTTP"} was exempted too,
ignored by the handler, and returned an unfiltered list that read as a
filtered one — the exact failure the guard exists to prevent. The
exemption now applies only when the requested action is itself hidden.
**Outpost never marked any prop.** Prop.Write is opt-in and only Gateway
had applied it, so read-only Outpost sessions were offered config,
credentials, filter, metadata, values, unset, hostname and theme. Marked,
and TestReadOnlyPropSurface now pins the whole read-only surface per tool
so a new property forces a deliberate choice instead of defaulting to
visible.
While there: Outpost's spec list existed in three places and had already
drifted over publish. resourceSpecs() replaces it, matching Gateway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Deleting a connection does not delete the source and destination it was created with. `gateway connection create --source-name … --destination-name …` creates three resources, and almost every test registers cleanup for the connection alone: 157 `deleteConnection` call sites against 30 `deleteSource` and 24 `deleteDestination`. The test projects had reached 27,621 sources and 37,327 destinations, growing by ~120 of each per CI run of one slice. Patching 150 call sites would have fixed the tests that exist and none of the ones written next, so the bookkeeping goes in CLIRunner instead. Every command run through it that reports creating a gateway resource has that id recorded — including the source and destination a connection create returns inline — every delete by id strikes one off, and whatever is left when the test ends is deleted, connections first. A test that already cleans up costs nothing: its resources are struck off before the sweep runs, so the sweep makes no API calls for them. It also covers the case explicit cleanup structurally cannot. When a `require` fails between the create and the `t.Cleanup` below it, the cleanup is never registered — which is exactly what happened in a run here, where a rate-limited spec download printed a warning line before the JSON body, five tests failed to parse their own output, and every resource they had created was orphaned. The recorded ids do not depend on the test getting that far. `hookdeck listen` is the other half of the leak: it creates the source it is pointed at when that source does not exist, plus a `cli-<source>` connection and destination. None of that goes through Run, so nothing sees an id. `startListenCapturingOutput` and `RunListenWithTimeout` now register a cleanup by name, which is all the test knows, and the two tests that start the binary themselves call it directly — replacing two hand-rolled cleanups that listed every source in the project to find one name and deleted only the source. `cleanupConnections` was defined and called from nowhere. Removed. Measured on the two projects, before and after, with the counts taken either side of a full slice run: slice 2 before the change 60 → 75 sources, 60 → 75 destinations, +5 connections slice 2 after the change 90 → 90 sources, 90 → 90 destinations, +0 connections slice 0 (CI, before) +121 sources, +124 destinations, +6 connections slice 0 after the change 27,621 → 27,621 sources, 37,327 → 37,327 destinations The sweep costs one delete per resource that would otherwise have leaked: slice 2 went from 215s to 223s. Refs #362 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
A review pass found five defects sharing one shape: the code reported the
operation it intended rather than the outcome it got. All five are verified
against the live API rather than inferred.
transformation run treated log_level "error" as a failure. log_level is the
highest severity a run logged, not a completion flag — a handler that calls
console.error and returns a transformed request reports "error" and succeeded.
The result was thrown away and the command exited non-zero on working code.
Only "fatal" means the run did not complete, and it coincides with an absent
request in every observed case. A completed run now also prints its console
output, which was previously shown only on failure and so was hidden from the
runs most likely to need it.
outpost destination create applied the destination type's schema defaults on
the CLI path only, so the MCP server still created rabbitmq destinations with
tls unset and sent SASL credentials in the clear. The lookup moves to
outposttypes.ApplyDefaultsForType and both entry points use it. Verified by
creating a destination through the MCP server: tls is now stored as true.
outpost destination update could not clear a filter. The API replaces filter
wholesale, so an empty object clears it, but omitempty on a map drops an empty
map — --filter '{}' marshalled to nothing, changed nothing and reported
success. Filter becomes a pointer, separating "not supplied" from "supplied as
empty". Verified end to end: omitting the flag leaves the filter alone and
'{}' clears it.
outpost event retry printed a success tick without reading the success flag,
and its MCP sibling reported status "queued" even when the retry was declined.
outpost destination enable/disable announced the verb without checking the
returned state, the same defect already fixed for gateway events.
Two test fixtures stubbed a transformation response with no request, a shape
the endpoint never returns; they passed only because the old success check was
too permissive.
Also hardens the request-path backstop, which had three gaps. It compared
paths but not hosts, so a reference carrying its own authority resolved to
another host with a correct-looking path and would have been sent there with
the API key attached. Relative references skipped the rewrite comparison
entirely, and empty path segments passed. apiPath already rejects all of these
at the source; a backstop that lets them through is not one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Manual QA against the live API keeps finding defects the automated suites cannot — wrong success messages, schema defaults that are never sent, filters that silently do nothing — but it needs real projects and destructive commands, and doing that by hand has twice gone wrong: once writing CI credentials over a working login, once running against an unintended project. The guard makes both hard. It authenticates into an isolated config so the operator's own login is never read or written, and it confirms the project from the credential rather than from the argument, refusing to return a config if the resolved project is not the one named up front or is outside the acceptance-test organisation. Safety comes from where the credential lives rather than from a list of project IDs: keys must come from the gitignored test/acceptance/.env, so the blast radius is the test projects those keys can reach. A hardcoded list in a public repo would rot and would publish identifiers for no benefit. Also includes an MCP stdio driver, since the MCP surfaces cannot be exercised with CLI invocations and testing them through an editor is neither fast nor repeatable, and per-surface checklists written around the defect patterns that have actually yielded findings rather than around the happy path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The schema declares a type for every property, and as with
additionalProperties nothing enforced it at this layer. The input helpers
discard what they cannot convert, so a non-string element was dropped from an
array and a non-string scalar read as absent: {"topics":["orders",5]}
subscribed a destination to one topic and reported success.
The check is deliberately narrower than schema validation, because two
conversions are intentional and in use — a comma-separated string where an
array is declared, and numbers and booleans as strings. Enforcing the declared
type strictly would reject the callers those helpers exist to support. Only
values nothing can consume are rejected: a non-string inside a string array,
and an array where a single value belongs.
Writing the check surfaced six properties whose schema was inaccurate. The
payload filters accept an object or a string and their documented examples
pass objects, but they declared "string", so the schema told callers something
the tools did not mean and a strict client would have rejected the documented
usage. Prop.JSONValue now marks them and widens the emitted type to
["object","string"].
TestDeliberateArgumentConversionsStillWork covers the leniencies, so a later
tightening of this check fails loudly rather than quietly breaking them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Verified against the live API: upsert replaces a tenant wholesale, so `outpost tenant upsert acme` run on an existing tenant removes any metadata it had. The help already said metadata is replaced rather than merged, but not that omitting it entirely counts as replacing it with nothing — and the first example was the destructive case, captioned "create or update a tenant". That is the shape an operator or agent reaches for to make sure a tenant exists, and it destroys data while reporting success. The behaviour is the endpoint's and is left alone; merging client-side would mean a read before every write and would race. Both surfaces now say what it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
run_parallel.sh described itself as running the same jobs as CI and did not: CI has a fourth matrix slice for the outpost tag, and this script ran slices 0-2 plus telemetry. Every local "full pass" therefore skipped the entire Outpost surface — the newest and least proven code in the tree — while reporting success. Slice 3 now runs, and is reported as SKIPPED in bold terms rather than silently omitted when the Outpost key is absent. The summary is per slice. Previously any failure tailed all four logs together, so a run with seventy failures in one slice and passes everywhere else read as an undifferentiated wall of output; the failures were easy to scroll past, which is what happened. Each slice now reports PASS, FAIL with a count, or "no ok line — the run did not finish", and only failing logs are expanded. A log containing HTTP 429 responses is called out where the failures are listed. Rate limiting presents as a large number of unrelated-looking failures, and it is worth saying once that they may not be defects rather than leaving it to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
A manual QA pass found four commands that accepted a value, sent something
other than it, and reported success.
gateway destination update dropped --url, --cli-path, --http-method and
--path-forwarding-disabled unless --type was also passed. Those flags are read
per destination type, and update does not require a type, so the switch fell
through and discarded every one of them — including in the form the help text
gives as its example. The stored type is now looked up when such a flag is set
and no --type is given, so the documented example works. The lookup only
happens when one of those flags is present, so a rename-only update still
costs a single request.
outpost publish sent no data at all for --data '{}', and the publish then
failed with "data is required" for a payload the caller had supplied. This is
the same omitempty-drops-an-empty-object defect as the destination filter,
which was fixed without the fix being carried across; the helper is now shared
between them rather than duplicated.
gateway connection update could not clear a ruleset. The API replaces rules
wholesale so [] removes them all, but an empty slice marshalled to nothing:
--rules '[]' changed nothing and exited 0. Rules is now keyed off whether the
flag was supplied rather than whether it produced any rules.
outpost destination update documented --metadata as replacing wholesale. It
merges — verified against the API, including a direct call, so this is the
endpoint's behaviour and not something the CLI imposes. The help said the
opposite, and an empty --metadata-file reported success while sending nothing.
The text is corrected and clearing is now refused with the reason rather than
silently accepted.
Verified against the live API: --url alone now updates a destination and an
unrelated update leaves it alone; --data '{}' publishes; --rules '[]' clears a
ruleset while an unrelated update preserves it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
A manual QA pass found that enabling writes undid the argument guard for read
actions. A write-only property is hidden in read-only mode and therefore
rejected, but --allow-write made it visible on every action, so a read action
accepted an argument it never reads and ignored it: gateway_sources with
{"action":"list","type":"STRIPE"} returned all 70 sources, none of them
STRIPE, and reported no error. gateway_issues did the same with status on
list. That is precisely the failure rejectUnknownArgs was written to prevent,
described in its own docstring, reintroduced by the mode that matters most.
A write-only property is now rejected on an action that does not write,
independently of mode. The properties read actions legitimately need are not
marked write — transformations run takes code, env and request and is a read —
so this narrows what is accepted without narrowing what works.
It is reported separately from an unknown argument, because the tool does have
the property: saying "unknown argument: type" while listing type among the
accepted ones contradicts itself and sends the caller hunting for a typo that
is not there. The message now names the actions the property belongs to.
gateway_metrics read measures with StringSlice, the only two places on this
surface not using StringList, so the comma-separated form every other tool
accepts was dropped and the caller was told "measures is required" for an
argument they had supplied.
gateway_connections accepted disabled:false and ignored it, returning every
connection as though filtered to the enabled ones. Sending the value through
instead turned out to be worse: verified against the API with a disabled
connection present, disabled_at[any] selects disabled connections whatever
value it is given, and disabled_at[is_null]=true returns everything — there is
no enabled-only filter to send. It is now refused with that explanation rather
than answered wrongly in either direction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…nged
RetryRequest discarded the response body, so both surfaces reported a fixed
outcome. The MCP tool answered {"status":"retried"} and the CLI printed "retry
requested" whether the retry created events or matched nothing at all — a
retry aimed at a connection the request never went through is accepted and
produces nothing, and both said the same thing for it. The client now returns
the request and the events created; the MCP tool reports the event ids, and
the CLI fails when there are none.
The stub for that endpoint returned an empty body, which is not a response the
API sends, and the test asserted the hardcoded status — so it passed whether
or not a retry had produced anything. Both are corrected.
Also makes issue dismissal legible. Two independent QA passes reported
"dismiss does not dismiss" after checking status and finding it unchanged.
Verified against the API: DELETE returns 200 with dismissed_at populated and
status deliberately untouched, because dismissal and resolution are separate
axes. Nothing was broken, so nothing is changed except what is said — the CLI
now names dismissed_at and states that status is unchanged, and the client
records the finding so a third pass does not re-derive it. Switching to
PUT {"status":"IGNORED"}, which does change status, would have conflated the
two concepts and thrown away the distinction the API draws.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two workstreams, one release. This is a major version: MCP tool names change.
Breaking change — read this first
The Event Gateway MCP server's product tools move from the
hookdeck_prefix togateway_:hookdeck_connectionsgateway_connectionshookdeck_sourcesgateway_sourceshookdeck_destinationsgateway_destinationshookdeck_transformationsgateway_transformationshookdeck_requestsgateway_requestshookdeck_eventsgateway_eventshookdeck_attemptsgateway_attemptshookdeck_issuesgateway_issueshookdeck_metricsgateway_metricshookdeck_helpgateway_helphookdeck_loginandhookdeck_projectsare unchanged. They are platform tools shared with the Outpost server — logging in and switching projects are Hookdeck operations whichever product you are in.Per-tool permission grants and
allowedToolsconfig do not survive a rename. Every MCP user must re-grant after upgrading.What's in it
Outpost support — a
hookdeck outpost …command group covering the managed Outpost API (tenants, destinations, events, attempts, publish, topics, destination types, metrics, operator config, custom domain, status), plushookdeck outpost mcp.Gateway MCP write mode —
--allow-write(orHOOKDECK_MCP_ALLOW_WRITE), off by default. In read-only mode write actions are absent from the tool schema entirely, so an agent is never offered something it cannot do.--read-onlyis accepted explicitly and wins if both are passed.Two deliberate calls on what counts as a write:
connections pause/unpausestay available in read-only mode. They ship today, and read-only MCP is the incident-investigation tool — pausing a misbehaving connection is the natural end of an investigation, not a configuration change.transformations runis a read. Checked against the API rather than assumed: a run creates no execution record and returns no execution id. Gating it would leave a session able to read transformation code but unable to try it, which is the debugging work read-only mode exists for.Shared MCP core — the product-agnostic machinery (input parsing, response envelopes, error translation, auth, login/projects tools, telemetry, and the action/write-gating model) lives in
pkg/mcpcore, so the Gateway and Outpost servers no longer carry two copies.Testing
Both servers now have a coverage gate that fails when an action ships without a test making a successful call — not merely a test proving it is blocked:
pkg/gateway/mcp: 55 of 55 actions covered, including all 28 write actions (was 7)pkg/outpost/mcp: every action coveredTests assert the request that goes on the wire — method, path, query and body — because a stub server answers whatever it is asked, and every wire-shape defect found during development would have passed a "did not error" assertion. That caught real things:
upsertis aPUTto the collection rather than to an id,updatemust omit fields the caller did not mention, and the MCP-to-API parameter renames (connection_id→webhook_id,connection_ids→webhook_ids,filter_status→status).Also: a live Outpost smoke suite that previously ran nowhere is now on a nightly schedule, and the portal acceptance test no longer assumes custom-domain propagation is instantaneous.
Verified locally on the merged tree: build, vet, full unit suite,
REFERENCE.md --check, and both acceptance slices (mcp,outpost).Notes for review
mcpcore— a clean merge that did not compile. Fixed in the merge commit.