Skip to content

feat(gateway mcp): write mode behind --allow-write, and gateway_ tool rename (v3.0.0) - #358

Merged
leggetter merged 5 commits into
feat/outpost-api-clientfrom
feat/gateway-mcp-write-mode
Aug 19, 2026
Merged

feat(gateway mcp): write mode behind --allow-write, and gateway_ tool rename (v3.0.0)#358
leggetter merged 5 commits into
feat/outpost-api-clientfrom
feat/gateway-mcp-write-mode

Conversation

@leggetter

Copy link
Copy Markdown
Collaborator

Implements #357: write mode for the Event Gateway MCP server behind --allow-write, and the hookdeck_*gateway_* tool rename. Both halves ship together as v3.0.0.

Base branch: this targets feat/outpost-api-client, not main. pkg/mcpcore and pkg/outpost/mcp do not exist on main — they land via #348, which is still an open draft — and every part of this change builds on them. #349 used the same pattern and merged cleanly. Once #348 merges, this retargets to main.

Breaking change

The nine product tools and the help tool are renamed. Per-tool permission grants and allowedTools config do not survive a rename — every user must re-grant them.

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 unchanged (platform)
hookdeck_projects unchanged (platform)

Logging in and switching project are Hookdeck operations whichever product's server you are in, so they keep the platform prefix and stay identical across both servers.

What changed

1. The write-gating machinery moved into pkg/mcpcore. action / actionSet / toolSpec / dispatch were package-private inside pkg/outpost/mcp/tools.go, so a second server could not use them. They are now exported as mcpcore.Action, ActionSet, ToolSpec and Dispatch in pkg/mcpcore/toolspec.go, and both servers are built on them. Not duplicated.

The read-only description suffix previously hardcoded see outpost_help; it now comes from Server.HelpToolName(). The shared input helpers (RequireString, Object, StringMap, StringList) moved to mcpcore too, and resolveAllowWrite is now shared rather than copied.

Outpost behaviour is unchanged — pkg/outpost/mcp/tools_test.go passes with only identifier renames (9 lines).

2. Gateway tools ported onto ToolSpec and given write actions. Every client method already existed; this is tool-layer work only.

Tool 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
attempts, metrics — read-only in both modes

events/retry and requests/retry were built first: Gateway MCP usage is overwhelmingly failure investigation, and retry is the action that follows one.

3. Read-only by default. --allow-write, also read from HOOKDECK_MCP_ALLOW_WRITE; --read-only is accepted and wins if both are passed. The gate is in the schema — in read-only mode write actions are absent from the action enum and the description, so an agent is never offered something it cannot do. mcpcore.RequireWrite sits behind that as defence in depth. delete, cancel, mute and dismiss carry DestructiveHint; per the MCP spec annotations are client UX hints and not the enforcement boundary — the flag is.

Decisions worth reviewing

pause/unpause stay available in read-only mode. They are mutations, and they are deliberately not gated. Read-only is the mode incidents get investigated in, and stopping a misbehaving connection is the natural end of an investigation; requiring a server restart at that moment would be the wrong trade. Both are reversible and pausing buffers delivery rather than dropping events. The rationale is recorded at the action definition, and two tests exist purely to fail if someone later gates them.

transformations/run is gated as a write even though it stores nothing. It executes caller-supplied JavaScript, and a read-only session should not be able to cause that. The issue floated "leaning read"; this goes the other way, on the basis that the gate is about what a session can cause to happen, not only about what it persists.

gateway_help is now generated from the tool specs rather than a static map of ~40 hardcoded names. It reports the current mode and cannot advertise an action the session is unable to perform. Hand-written guidance that is not derivable from the schema (filter mappings, worked examples) is preserved via a new ToolSpec.Notes field.

One real bug fixed along the way: mcpcore.HelpTopic only qualified a bare topic with the product prefix, so topic: "projects" stopped resolving once the Gateway prefix was no longer hookdeck. It now tries the product prefix then the platform prefix. Outpost had the same latent bug and is fixed by the same change.

Testing

  • pkg/gateway/mcp/write_mode_test.go — action enum and description per mode, annotations, the handler guard refusing all 19 write actions without the flag, and successful writes asserted on the request the handler sends (method + path), not on "did not error".
  • pkg/gateway/mcp/server_test.go passes with only tool-name changes. TestConnectionsPause_Success, _ByName and TestConnectionsUnpause_* still pass without a write-enabled server — the assertion that the pause decision held.
  • Acceptance under the existing mcp tag: tools/list omits write actions without the flag and includes them with it; renamed tools present, old names absent; help reports the mode.
  • Run locally, both green: go test -tags=mcp ./test/acceptance/... (171s) and go test -tags=outpost ./test/acceptance/... (208s). Outpost is the blast radius of the refactor and had to stay green.
  • go build, go fmt, go vet, go test ./... clean. go run ./tools/generate-reference --check passes.

Not run: the Claude Desktop end-to-end check from the plan's manual verification.

Follow-ups

  • #352 (the rename) is absorbed here and can be closed on merge.
  • Part of the #354 v3.0.0 epic.
  • Release notes must lead with the breaking change and carry the mapping table above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3

leggetter and others added 3 commits August 19, 2026 13:07
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 2 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
@leggetter
leggetter merged commit a965637 into feat/outpost-api-client Aug 19, 2026
2 checks passed
@leggetter
leggetter deleted the feat/gateway-mcp-write-mode branch August 19, 2026 14:19
@leggetter

Copy link
Copy Markdown
Collaborator Author

Absorbed into #348, which is now the single v3.0.0 PR targeting main.

Every commit from this branch is in feat/outpost-api-client — the write mode, the gateway_ rename, the transformations run flip to a read, and the 55/55 action coverage. Closing here so there is one place to review and one branch to release from.

Practical reason for the consolidation: a PR based on a feature branch gets no CI, because the workflows trigger only on PRs to main/next. This branch never had a single check run against it. #348 does.

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