ADR-0005: MCP Tool and Resource Contract Shape
Status · proposed · Date · 2026-07-05 · Deciders · Joe Stump
Context and Problem Statement
switchboard exposes its stored events to MCP clients (Claude Code and other agents) through the official Go MCP SDK (https://github.com/modelcontextprotocol/go-sdk), running as a server mounted in the same Go HTTP server as the web UI (ADR-0001) and reading the same PostgreSQL layer (ADR-0002). The brief fixes the minimum tool set — list_webhook_events, get_webhook_event, replay_webhook_event, list_providers — plus a resource stream of recent events (§7). This ADR pins the shape of that contract: naming, input/output schemas at a design level, pagination and filtering semantics, how the one side-effecting tool (replay) is made safe, and the tools-vs-resources split. The exact JSON schemas live in docs/openspec/specs/mcp-tools/spec.md; this ADR records the decisions that spec must reflect. What contract makes the event log queryable and replayable by an agent while staying honest about trust (ADR-0003) and safe about outbound side effects?
Decision Drivers
- Shape parity with the DB and UI. An MCP
eventobject should be the same normalized shape the web UI renders and theeventstable stores, so the three surfaces never drift. - Trust is part of every payload. Every event returned over MCP carries its
trust_mode/verified/verify_detail— an agent must be able to tell a signed event from an unverified one, just like the UI (ADR-0003). - Cheap list, full detail on demand.
listreturns compact summaries (no full payload/headers) for fast scans and small context;getreturns the complete record. This keeps agent context lean. - Deterministic pagination. Filtering by provider/type/time with a stable order and a cursor so an agent can page without duplicates or gaps.
- Replay is the one dangerous verb. It performs an outbound HTTP POST. It must be explicit about its target, constrained, and never a hidden side effect of reading.
- SDK-idiomatic. Tool names, input schemas, and structured output follow the Go MCP SDK conventions; resources use the SDK's resource/URI model.
- Secrets never cross the boundary. MCP responses contain sanitized headers only (ADR-0002) — no signing secrets, no raw signature header values.
Considered Options
- Surface split: (A) tools only; (B) tools + a read-only resource for recent events (chosen); (C) resources-only.
listpayload weight: (A) return full events; (B) return compact summaries, full record viaget(chosen).- Pagination: (A)
limitonly; (B)limit+since+ opaquecursorwith stable ordering (chosen); (C) numeric offset pages. - Replay target: (A) required explicit
target_urlevery call; (B) optionaltarget_url, else a configured default replay target; error if neither (chosen); (C) replay back to the originating provider. - Output typing: (A) free-form text/JSON blobs; (B) SDK structured output with declared schemas (chosen).
Decision Outcome
Chosen: tools + a recent-events resource (B), compact list summaries with full get detail (B), limit + since + opaque cursor pagination (B), optional target_url with a configured default and a hard error if neither is set (B), and SDK structured output with declared schemas (B).
Tools (MVP)
| Tool | Inputs | Returns | Side effects |
|---|---|---|---|
list_webhook_events | provider?, event_type?, since? (ISO-8601 or event id), limit? (default 50, max 200), cursor? | { events: EventSummary[], next_cursor?: string } | none (read) |
get_webhook_event | id (required) | EventDetail (full: sanitized headers, raw payload, verification result) | none (read) |
replay_webhook_event | id (required), target_url? | { status, target_url, response_status, response_ms } | outbound HTTP POST |
list_providers | — | { providers: ProviderStatus[] } | none (read) |
EventSummary(compact, forlist):id,provider,event_type,trust_mode,verified,payload_size,received_at. No payload, no headers — keeps agent context small.EventDetail(full, forget): all summary fields plusverify_detail, sanitizedheaders(object), rawpayload,content_type,source_ip,external_id.ProviderStatus:name,family(webhook/queue),trust_mode,enabled,secret_status(configured/missing/none-by-design), and the route path or Redis channel. Never the secret value.
Every event object includes trust_mode + verified + verify_detail so an agent can distinguish a signed, verified event from an unverified/Redis one — the MCP surface is held to the same honesty bar as the UI (ADR-0003).
Pagination and filtering
list_webhook_events orders by received_at DESC, id DESC (stable). since accepts either an ISO-8601 timestamp or an event id and bounds the lower edge. cursor is an opaque token encoding the last (received_at, id) seen; when the result set is truncated at limit, a next_cursor is returned. Cursors are preferred over numeric offset because new events arriving between pages would otherwise shift offsets and cause duplicates/gaps. limit defaults to 50 and is capped at 200 to bound response size.
Replay safety
replay_webhook_event is the only tool with side effects, and the contract makes that explicit:
- It re-POSTs the stored raw payload and a replay-safe subset of headers to a target — for exercising a downstream consumer under local development, per the brief.
target_urlis optional; if omitted it falls back to a configured default replay target (asettingsvalue, ADR-0002). If neither is provided, the tool returns a clear error rather than guessing.- It does not replay back to the originating provider — providers are senders, not receivers; that option is nonsensical and rejected.
- For the MVP the target is expected to be localhost/trusted-network; the tool validates the URL scheme (
http/https) and the code session SHOULD constrain targets (e.g. an allowlist or localhost-only default) to avoid turning the tool into an SSRF primitive. This constraint is noted as an implementation requirement. - The response reports what happened (
target_url, downstreamresponse_status, elapsedresponse_ms) so the agent gets feedback, and the replay attempt itself is logged.
Resource
Recent events are also exposed as a read-only MCP resource (e.g. URI switchboard://events/recent), returning the same EventSummary shape as list. This lets clients that model context as resources subscribe to "recent events" without a tool call. It is strictly read-only; all mutation/replay stays in tools. Whether the SDK's resource-subscription/update mechanism is wired for push is left to the code session; the MVP guarantees at least a pull-able recent-events resource.
Structured output
Tools declare input JSON Schemas and return SDK structured output with the EventSummary/EventDetail/ProviderStatus shapes above, so clients get typed, validated results rather than parsing free-form text. The authoritative schemas live in docs/openspec/specs/mcp-tools/spec.md and are kept in sync with this ADR and the events schema (ADR-0002).
Consequences
- Good, because
list/get/replay/list_providersmap directly onto the DB layer and the UI's needs — one normalized event shape across all three surfaces. - Good, because compact summaries keep agent context small while
getstill offers full fidelity. - Good, because cursor pagination is correct under concurrent inserts (an always-on receiver).
- Good, because trust metadata travels with every event, so agents cannot mistake unverified data for signed.
- Good, because
replayis the single, clearly-marked side-effecting verb with an explicit target and no back-to-provider footgun. - Bad, because summary/detail split means two round-trips when an agent wants full payloads for many events — acceptable; the common case is scan-then-drill-down.
- Bad, because
replayis inherently an outbound-request tool that needs SSRF-conscious constraints — mitigated by scheme validation, a localhost/trusted default, and a noted allowlist requirement for the code session.
Confirmation
docs/openspec/specs/mcp-tools/spec.mddocuments exact input/output JSON Schemas matching the shapes above; a test asserts tool responses validate against them.- Tests cover:
listfiltering by provider/type/since, cursor round-trip (no duplicate/gap across pages),getreturning full sanitized detail,list_providersreporting correctsecret_status, andreplayposting the stored payload to a target and reporting the downstream status. - A test asserts every event object over MCP includes
trust_mode/verified/verify_detailand that no secret/full-signature value is present. - A test asserts
replaywith neithertarget_urlnor a configured default returns an error rather than performing a request.
Pros and Cons of the Options
Surface: tools + recent-events resource (chosen) vs. tools-only vs. resources-only
- Good (tools + resource), because tools cover query/detail/replay while the resource gives resource-modeling clients a pull-able "recent events" without a tool call.
- Bad (tools-only), because it ignores the brief's explicit "expose recent events as a resource stream, not just tools."
- Bad (resources-only), because replay and parameterized filtering are naturally tools, not resources.
list weight: summaries (chosen) vs. full events
- Good (summaries), because it keeps agent context lean and lists fast.
- Bad (full events), because returning every payload/headers blob on a broad list floods context and is slow.
Pagination: cursor (chosen) vs. offset vs. limit-only
- Good (cursor), because it is stable under concurrent inserts.
- Bad (offset), because new rows shift offsets → duplicates/gaps between pages.
- Bad (limit-only), because it cannot page beyond the first window.
Replay target: optional + configured default (chosen) vs. always-required vs. back-to-provider
- Good (optional + default), because ergonomic for the common "replay to my local consumer" case while still explicit.
- Bad (always-required), because it is tedious when a stable dev target exists.
- Bad (back-to-provider), because providers do not receive webhooks; the option is meaningless and a footgun.
Output: structured (chosen) vs. free-form
- Good (structured), because clients get typed, validated results and the contract is enforceable.
- Bad (free-form), because it pushes parsing and drift risk onto every client.
Architecture Diagram
More Information
- Minimum tool set and the recent-events resource are from brief §7. Go MCP SDK: https://github.com/modelcontextprotocol/go-sdk; MCP spec: https://modelcontextprotocol.io/.
- Event shape and trust fields derive from ADR-0002 (schema) and ADR-0003 (trust semantics).
- Exact schemas:
docs/openspec/specs/mcp-tools/spec.md(this session). The AsyncAPI SSE contract (docs/reference/asyncapi.yaml) shares the sameEventSummaryshape for the UI's live stream. replaySSRF constraints (scheme validation, localhost/trusted default, allowlist) are flagged here as a code-session implementation requirement.