Skip to content

Epic: Outpost support in the CLI — commands and MCP #346

Description

@leggetter

Why

The CLI already knows Outpost projects exist — project list reports them and project use will
switch to one — but nothing can be done with one once selected. Every gateway command rejects an
Outpost project by design, so a user whose active project is Outpost has a CLI that can run login,
whoami and project use and nothing else.

Hookdeck Outpost has a managed API and published SDKs, but no CLI and no MCP server. This epic adds
both: a hookdeck outpost … command group mirroring the shape of hookdeck gateway …, and
hookdeck outpost mcp.

Scope

The full managed Outpost API: tenants, destinations, events, attempts, retry, publish, topics,
destination types, metrics, operator config, custom domain, and deployment status.

hookdeck outpost
├── tenant|tenants    list · get · upsert · delete · token · portal
├── destination|destinations   list · get · create · update · delete · enable · disable
├── event|events      list · get · retry
├── attempt|attempts  list · get
├── publish
├── topic|topics      list
├── destination-type|destination-types   list · get
├── metrics           events · attempts
├── config            get · set · custom-domain (get · set · delete)
├── status
└── mcp               [--allow-write]

Inventory

Phase 1 — API client

  • Outpost API base URL constant + GetOutpostAPIClient(), reusing the existing client (auth,
    project header, telemetry, verbose transport and error types all carry over unchanged)
  • Hidden --outpost-api-base flag for dev/test, registered in the global-flag arity map
  • IsOutpostProject() alongside IsGatewayProject()
  • Per-resource client methods. Two shape differences from the Gateway API: array query params use
    bracket notation (id[0]=…) and time filters use time[gte] / time[lte]; and the
    tenant destination list is not paginated — it returns a bare array
  • Destination config/credentials validated dynamically from GET /destination-types rather
    than hardcoded per-type structs, mirroring how source types are handled today. Warn and continue
    if the fetch fails; let the API be the authority

Live validation of Phase 1 (done)

Phase 1's unit tests run against stub servers, so they could only assert their own assumptions. A
build-tagged live suite (//go:build outpostlive, test/acceptance/outpost_live_test.go) makes real
requests. It found two genuine bugs that no stub test could have:

  • Destination-type options is [{label, value}], not []string — decoding the real
    /destination-types list failed outright. The stub fixture had encoded the wrong shape, which is
    exactly why the unit tests passed. Surfaced on kafka, the type the OpenAPI enum omits.
  • Only HTTP 200 was treated as success. The Event Gateway API answers 200 to everything, so
    this had never surfaced; 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, so
    Gateway behaviour is unchanged.

Now proven end to end against a real project: CLI-key auth against the Outpost host, tenant and
destination CRUD, the topics union decoding the bare "*" form, tenant token minting, publish with a
Project API key, event and attempt reads, and the tenant-scoped attempts route.

Phase 2 — Commands

  • outpost command group with an Outpost-project guard mirroring the Gateway one
  • Resource groups with singular Use + plural alias, --output json, and shared help text
  • Destructive commands (tenant delete, destination delete, config custom-domain delete) use
    the existing confirm + --force helper
  • config set gets --dry-run — it changes delivery behaviour for every tenant at once
  • outpost publish needs a Project API key. The Publish API accepts a Project API key only;
    the credentials stored by hookdeck login are not accepted for it. publish therefore takes
    --api-key (defaulting to HOOKDECK_API_KEY), like hookdeck ci. Every other outpost
    command uses the stored credentials. Document in AGENTS.md, the command help, and the README
  • REFERENCE.md blocks + README section

Phase 2 notes

Two decisions worth recording, both made while building:

  • Config and credential fields are repeatable key=value pairs, not flat per-field flags. Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema, so flat flags would require a network call before a command could parse its own arguments. The schema is still used, for validation and for help. Dotted paths (--config a.b=c) are supported so a nested type shipping server-side would not need a CLI release. See Standardize config input: --config means two different things across gateway and outpost #347 for standardizing this with gateway.
  • --type <type> --help lists that type's fields. Cobra parses flags before running the help function, so once a type is named the help can show exactly its fields — cache-first, degrading silently to static help when unauthenticated, offline or cold. Plain --help is unchanged and needs no network. REFERENCE.md is provably unaffected, since the generator reads command metadata directly and never invokes help.

Phase 3 — MCP

  • Extract the product-agnostic MCP plumbing (input parsing, response envelope, error translation,
    auth gate, login and projects tools, telemetry wrapper, server scaffolding) out of
    pkg/gateway/mcp into a shared package. Gateway behaviour must not change — its existing
    tests are the regression gate
  • Generalise the argv sniffing in root.go so MCP stdout hygiene applies to any <group> mcp,
    not just gateway mcp
  • Parameterise the tool-name prefix — the login and help tools currently hardcode hookdeck_ in
    user-facing strings
  • Outpost tool set, one tool per resource with an action enum, outpost_-prefixed so both
    servers can be configured in one client without name collisions
  • --allow-write (default off, HOOKDECK_MCP_ALLOW_WRITE). Write actions are omitted from the
    schema entirely in read-only mode, with a handler-side guard behind it. Accept a bare
    --read-only as an explicit no-op alias; if both are passed, read-only wins
  • Treat credential-returning reads as writes for gating: tenant token mints a 24-hour tenant JWT
    and portal returns a URL granting portal access. Both are GETs, so a naive read/write split
    would leave them available in read-only mode
  • Set ReadOnlyHint / DestructiveHint tool annotations (the gateway server sets none today).
    Per the MCP spec these are client UX hints and explicitly not a security boundary — the flag
    does the enforcing
  • Fix the type check in projects use: it currently switches to any project id without checking
    the type, so an agent can move the session to a project the server cannot serve

Testing prerequisites — blocking, needs someone with access

  • A development Outpost project with at least one topic configured, plus a Project API key for
    testing publish
  • A separate throwaway CI Outpost project and repo secret for the acceptance slice. The
    existing acceptance slices all authenticate against Gateway projects, so a new matrix entry and
    secret are required. Keep it separate from the development project: these tests create and
    delete tenants and destinations, and config set coverage mutates project-wide settings

On write tools in MCP

#228 sets out the split — skills + CLI for
building, MCP for investigation — and #340
restates that hookdeck gateway mcp is deliberately read-only.

hookdeck outpost mcp takes a different approach deliberately: it ships write tools, gated behind an
explicit --allow-write opt-in and off by default. The read-only default preserves the safe
out-of-the-box behaviour, while making the full surface available to operators who choose it.

The reasoning behind #228 does not transfer cleanly to Outpost. That split assumed skills + CLI
already covered the build path — true for the Event Gateway, which has published agent skills and full
CRUD commands, and not yet true for Outpost. More importantly, Outpost is operator-facing: managing
tenants and destinations is routine operational work, not just setup, and it is often done from
exactly the chat contexts #228 identifies as MCP's home ground.

The flag name follows the convention used by MCP servers that are read-only by default and opt in to
writes (the awslabs/mcp servers use exactly --allow-write). The more common --read-only spelling
is not used here because every server that uses it defaults to read-write, which is the wrong default
for this.

Prior art

  • #228 — MCP RFC and the skills-vs-MCP split
  • #340 — agent/automation safety epic; the
    output-contract and non-interactive work there applies to these commands too
  • #220 — gateway command UX and --output json
    duplication; the new commands should not add more hand-rolled output branches than necessary

Sequencing

  1. Testing prerequisites (blocking — start now, it needs a human)
  2. Phase 1 — API client
  3. Phase 2 — commands
  4. Phase 3a — shared MCP package extraction, gateway tests green and unchanged
  5. Phase 3b — Outpost MCP tools and --allow-write

Phases 1–2, 3a and 3b are three separate PRs; 3a lands on its own so the gateway refactor is reviewed
without new tools mixed in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions