Skip to content

v3.0.0: Outpost support, Gateway MCP write mode, and the gateway_ tool rename - #348

Open
leggetter wants to merge 38 commits into
mainfrom
feat/outpost-api-client
Open

v3.0.0: Outpost support, Gateway MCP write mode, and the gateway_ tool rename#348
leggetter wants to merge 38 commits into
mainfrom
feat/outpost-api-client

Conversation

@leggetter

@leggetter leggetter commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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 to gateway_:

Before After
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. 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 allowedTools config 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), plus hookdeck outpost mcp.

Gateway MCP write mode--allow-write (or HOOKDECK_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-only is accepted explicitly and wins if both are passed.

Two deliberate calls on what counts as a write:

  • connections pause/unpause stay 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 run is 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 covered

Tests 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: upsert is a PUT to the collection rather than to an id, update must omit fields the caller did not mention, and the MCP-to-API parameter renames (connection_idwebhook_id, connection_idswebhook_ids, filter_statusstatus).

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

leggetter and others added 12 commits August 14, 2026 15:42
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
leggetter and others added 11 commits August 14, 2026 19:34
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
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
leggetter and others added 3 commits August 19, 2026 13:32
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
leggetter and others added 3 commits August 19, 2026 13:32
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
@leggetter
leggetter marked this pull request as ready for review August 19, 2026 12:39
leggetter and others added 4 commits August 19, 2026 13:53
A run was gated behind --allow-write on the reasoning that it executes
caller-supplied code. Checked against the API instead of assumed: a run
creates no execution record and returns no execution id, modifies no
transformation, connection or event, and delivers nothing to a
destination. The execution_id and request_id fields on the response are
populated only when running against an already captured request, and
reference that existing record rather than creating one.

Gating it also worked against the mode it was meant to protect. A
read-only session can already read transformation code; without run it
cannot try that code against a sample payload, so it cannot debug a
transformation at all — which is the investigation work read-only mode
exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Twenty-one of the twenty-eight write actions had only a read-only refusal
test, so the first successful delete, upsert or disable would have run for
the first time in a user's project. Several reads had no test at all.

Each action now has a test that makes the call through a real MCP session
and asserts the request that goes on the wire — method, path, query and
body. A "no error" assertion proves little here: the stub answers whatever
it is asked, so a handler sending the wrong method or path still passes.

TestEveryActionHasBeenCalledSuccessfully enumerates the actions from the
tool specs themselves and fails when one has no successful-call test
recorded, so adding an action without covering it breaks the build. A
companion test rejects checklist entries for actions that no longer exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The test waited up to 90s for the portal to appear after setting a custom
domain, then treated it as available for every later assertion. Propagation
is not only delayed but uneven: in a real run the portal answered the poll
and then 404'd on the very next call, failing the theme subtest.

Every portal call now goes through a helper that retries while the
deployment still reports the portal as unconfigured, so the test measures
the command rather than the propagation delay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Brings the two workstreams onto one branch for a single 3.0.0 release:
Outpost CLI support and MCP server, plus Gateway MCP write mode behind
--allow-write and the hookdeck_ -> gateway_ product tool rename.

The branches combined without textual conflict apart from README.md, but
the Outpost coverage checklist referenced the package-private actionSet
type that the Gateway work lifted into mcpcore. Git could not see that —
a clean merge that did not compile — so the checklist now reads
mcpcore.ActionSet. It is the test that fails when an action ships without
a successful-call test, so losing it would have quietly reopened the gap
it exists to close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
@leggetter leggetter changed the title feat(outpost): Outpost API client and command tree v3.0.0: Outpost support, Gateway MCP write mode, and the gateway_ tool rename Aug 19, 2026
leggetter and others added 3 commits August 19, 2026 16:36
The HOOKDECK_CLI_TESTING_CLI_KEY secret has existed since March, but the
acceptance job never put it in the environment. TestProjectList and the
project-switch tests read it with os.Getenv, found nothing, and skipped
themselves — so project list and project use have had no automated
coverage anywhere, while reporting green.

The tests skip rather than fail when the key is absent, which is what
kept this invisible. Wiring the secret through makes CI answer whether
the stored value is a user-associated key: project-scoped keys cannot
list or switch projects and will now fail loudly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…singular by-id tools

gateway_events had 6 actions and 25 parameters, gateway_requests 6 and 19,
and in both only `id` was shared: every other parameter was a list filter, so
raw_body, retry, cancel and mute each needed one argument and were shown all
of them. Irrelevant parameters in a schema are the single most damaging thing
you can put in front of a model, and this was our widest surface by far.

Split each along the seam that already existed:

  gateway_events   list only, keeps the filters (25 params)
  gateway_event    get, raw_body, retry, cancel, mute (1 param: id)
  gateway_requests list only, keeps the filters (18 params)
  gateway_request  get, raw_body, events, ignored_events, retry (2 params)

connection_ids stays on gateway_request: it is the retry body parameter, not
a list filter.

Both halves keep the action enum, so the shape stays consistent with the other
tools. Write semantics are unchanged: retry/cancel/mute are still writes,
cancel/mute still destructive. The plural tools now carry no write actions at
all, so they are annotated read-only in both modes.

Plural-versus-singular is a subtle distinction for a model to hold, so the
descriptions state it explicitly on both sides and the help topics name their
counterpart. They also document the one relationship traversal the API offers
— GET /requests/{id}/events — and say that neither a request_id filter on
events nor an event_id filter on requests exists, so an agent does not hunt
for one.

Every action on both new tools has a test asserting the request that goes on
the wire, and the coverage checklist gates them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The README tool table now lists all four tools, and says which one searches
and which one acts on a record. Records the one traversal direction the API
supports so the table is not read as implying the reverse filter exists.

The acceptance suite asserts the four action enums and that both singular
tools are reachable end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant