Skip to content

feat(outpost): Outpost MCP server, and extract pkg/mcpcore - #349

Draft
leggetter wants to merge 9 commits into
feat/outpost-api-clientfrom
integrate/outpost-mcp
Draft

feat(outpost): Outpost MCP server, and extract pkg/mcpcore#349
leggetter wants to merge 9 commits into
feat/outpost-api-clientfrom
integrate/outpost-mcp

Conversation

@leggetter

Copy link
Copy Markdown
Collaborator

Phase 3 of Outpost support (#346): an MCP server for Outpost, and the refactor that makes it possible without duplicating the Event Gateway's MCP plumbing.

Targets feat/outpost-api-client (PR #348), not main — it depends on the Outpost API client from Phases 1–2. Merge #348 first, or retarget this once that lands.

Two commits, deliberately separate

refactor(mcp): extract product-agnostic MCP server into pkg/mcpcore

Moves input parsing, the response envelope, error translation, the auth gate, the login and projects tools, and the server scaffolding out of pkg/gateway/mcp and into pkg/mcpcore. pkg/gateway/mcp keeps only tools.go and its tool_<resource>.go handlers, and constructs an mcpcore.Server.

Gateway behaviour must not change, and its existing tests are the gate: pkg/gateway/mcp and the mcp acceptance slice both pass unmodified. Most files are tracked as renames, so the diff is smaller than the line count suggests.

This commit touches nothing Outpost-specific, so it can be cherry-picked onto a main-based branch if we'd rather review and land it independently.

feat(outpost): add an MCP server for AI agent access

hookdeck outpost mcp, with 13 tools following the existing one-tool-per-resource-with-an-action-enum pattern. Tools are outpost_-prefixed so both servers can be configured in the same client without colliding.

Write mode

Read-only by default. --allow-write (or HOOKDECK_MCP_ALLOW_WRITE) opts in.

The gate is the schema, not a runtime rejection: in read-only mode write actions are absent from the action enum and the description entirely, and a tool with only write actions isn't registered. An agent never sees a capability it can't use. A RequireWrite guard sits behind that as defence in depth.

default            outpost_tenants  list,get
--allow-write      outpost_tenants  list,get,upsert,delete,token,portal

Three details worth review:

  • token and portal are gated as writes even though both are GETs. token mints a 24-hour tenant JWT and portal returns a URL granting portal access — a naive read/write split would let an agent mint reusable credentials in read-only mode.
  • --read-only is accepted as an explicit no-op alias and wins if both are passed. Users arriving from the GitHub or Supabase MCP servers type it reflexively; an "unknown flag" error is a poor first run.
  • outpost_publish is gated on write mode and a resolvable Project API key, since the publish API doesn't accept the credentials hookdeck login stores. Without a key the tool isn't registered, and outpost_help explains why.

Tools also carry ReadOnlyHint / DestructiveHint annotations. Per the MCP spec these are client UX hints and explicitly not a security boundary — the flag does the enforcing.

A bug this caught

ListProjects and ValidateAPIKey are account-level calls, but the Outpost server was sending them to the Outpost host, where they 404. outpost_projects would have been broken outright, with an error logged on every tool call. Fixed by giving mcpcore a separate account client: account-level calls go to the main API, while project switches and logins update both clients.

Testing

  • go build, go vet, go test ./... green, including pkg/mcpcore and pkg/outpost/mcp
  • go test -tags=mcp passes unchanged — the regression gate for the refactor
  • go test -tags=outpost passes, including 9 new MCP acceptance tests
  • Verified over real stdio: read-only omits every write action and outpost_publish; --allow-write exposes them; --read-only wins when both are passed; stdout is JSON-RPC only
  • generate-reference --check green, with a GENERATE block for outpost mcp and README sections

Notes for review

  • NewServer doesn't auto-register the projects/login tools — the product supplies them via ToolDefs with its own description, so Gateway's advertised text is unchanged.
  • --api-key reads HOOKDECK_API_KEY at run time rather than as the flag default, so the key never appears in --help. outpost publish in feat(outpost): Outpost API client and command tree #348 should be changed to match.
  • outpost_destination_types omits icon and instructions unless include_setup_docs: true — they're setup-UI sized and would swamp an agent's context.
  • One pre-existing rough edge, not introduced here: a project-scoped credential logs an ERROR-level line to stderr when the projects tool can't list projects. The message is correct and the call now goes to the right host, but an anticipated condition arguably shouldn't log at error level.

leggetter and others added 5 commits August 14, 2026 15:44
…345)

govulncheck started failing on every PR and on main, reporting 5
vulnerabilities in the Go standard library. All are pinned to the CI
toolchain rather than to any repo code:

  net/url@go1.26.5        -> fixed in go1.26.6  (GO-2026-6218)
  crypto/tls@go1.26.5     -> fixed in go1.26.6  (GO-2026-6090)
  net/http@go1.26.5       -> fixed in go1.26.6  (GO-2026-6089, GO-2026-5026)
  encoding/asn1@go1.26.5  -> fixed in go1.26.6  (GO-2026-5972)

This is a time-based failure, not a regression. Commit 44fe940 passed on
2026-08-13 and failed on 2026-08-14 with no code change in between: Go
1.26.6 shipped these fixes and the vulnerability database picked them up,
so the pinned 1.26.5 became flagged.

Bump all 12 `go-version` pins across test.yml, release.yml,
acceptance.yml and test-npm-build.yml from 1.26.5 to 1.26.6.
test-homebrew-build.yml reads `go-version-file: go.mod` and is unchanged.

`go.mod` keeps `go 1.25.0` — that is the language-version floor, which is
independent of the toolchain CI installs, and no finding implicates it.


Claude-Session: https://claude.ai/code/session_013P3MKxbE4rQEYLuz5XR7eA

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md referenced `cmd/hookdeck/main.go` as the CLI entrypoint, but
there is no `cmd/` directory in this repo — the entrypoint is `main.go`
at the repo root. Following the documented build command failed with
"no such file or directory".

Replace all six occurrences with `go build -o hookdeck .` and `go run .`,
using the package path rather than the single file so the build includes
every file in the root package. Both forms verified against the current
tree.

This matters more than a typical docs typo: AGENTS.md is the file agents
read to learn how to build this repo, so a wrong build command here is an
instruction that every future agent follows and fails on.


Claude-Session: https://claude.ai/code/session_013P3MKxbE4rQEYLuz5XR7eA

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 4 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
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