@bounded-sh/observe-mcp
v0.1.1
Published
Bounded observe MCP wrapper: `bounded observe mcp -- <server>`. Faithfully proxies MCP JSON-RPC (local stdio, T9-clean; or streamable-HTTP passthrough) and reports every tools/call as an action-grade observe event. The sensor key IS the actor. Never break
Downloads
361
Readme
@bounded-sh/observe-mcp
bounded observe mcp — the MCP observe wrapper. It wraps an existing MCP
server and reports every tool call as an action-grade observe event, without
changing what the wrapped server does. One line to wrap; obviously safe.
Origin b in SPEC-OBSERVE-ENFORCE-CUSTODY.md (§3.1b, §3.1f, §3.2, §3.7, T9).
NEW isolated package — evidence plane only, shares no code path with enforcement.
- Local stdio wrapper (primary, T9-clean): spawns the target MCP server as a child and transparently proxies stdio JSON-RPC between the client (agent) and the server. Runs in-process on the customer's box — Bounded is never in the data path; if Bounded is down, the wrapped server is completely unaffected.
- Streamable-HTTP passthrough (secondary): a transparent JSON/SSE reverse proxy in front of a remote MCP endpoint (Mode-B, by necessity for hosted MCP).
One-line wrap
Prefix your MCP server command with bounded observe mcp --:
# before
node my-mcp-server.js
# after (observed)
bounded-observe-mcp -- node my-mcp-server.jsClaude Code / Cursor mcp.json
Point the server command at the wrapper and pass the original command after --:
{
"mcpServers": {
"payments": {
"command": "bounded-observe-mcp",
"args": ["--", "node", "/abs/path/my-mcp-server.js"],
"env": {
"BOUNDED_SENSOR_TOKEN": "obs1.<keyId>.<sig>", // the key IS the actor
"BOUNDED_ORG": "acme-demo",
"BOUNDED_ACTOR": "agent:refunds-bot" // optional; else mcp:<keyId>
}
}
}
}The wrapped server behaves identically. The wrapper only observes; it never alters, blocks, or delays a tool call, and all its own logging goes to stderr (stdout stays the byte-clean MCP channel).
Remote MCP endpoint (HTTP)
bounded-observe-mcp --http --upstream https://remote-mcp.example.com/mcp
# prints: [observe-mcp] listening http://127.0.0.1:<port> → https://remote-mcp.example.com/mcp
# point your MCP client at the printed local URLWhat's captured
Per tools/call, one action event (or error on a failed/declined call):
| field | value |
|---|---|
| class | action (success) / error (JSON-RPC error or result.isError) |
| rec | { rail: "mcp", action: "<tool>", registryVersion, fields? } |
| dest | { host: "mcp://<serverName>", pathTemplate: "/<tool>", method: "POST" } (stdio) / upstream host (http) |
| actor | { id, kind: "agent", grade: "attested" } — the sensor key is the actor |
| onBehalfOf | opaque end-customer id, if the client supplies params._meta["bounded/onBehalfOf"] |
| status, dur_ms, bytes | HTTP-ish status, measured latency, request/response byte sizes |
serverName is learned from the server's own initialize response
(serverInfo.name), overridable with --name. tools/list is observed to cache
the tool catalog but is not emitted (action-grade by construction).
Events are batched (≤500), reported fire-and-forget to the ingest, and never
block the JSON-RPC path. Reporting failures are retried once then dropped with an
honest dropped counter; a kill switch (BOUNDED_OBSERVE_MCP_DISABLED=1) turns
the wrapper into a pure passthrough with zero observation.
PII posture (default-deny)
- Metadata by default. Only the tool name, status, latency, byte sizes, and actor leave the process by default. Request/response contents never do.
- Manifest-allowed arg values only. A small allow-set of action-grade,
non-PII scalar keys (amounts, currency, quantity, reason, status, …) may have
their value captured into
rec.fields(see "Amounts" below). Extend it with--capture <field>/BOUNDED_CAPTURE_FIELDS=a,b. - Hard PII denylist (L2). A compiled-in denylist (email, phone, card, ssn, token, secret, name, address, …) blocks any PII-shaped arg key from ever being captured — it overrides the allow-set and is not runtime-configurable. The ingest independently re-applies the same key denylist and an L4 value scrubber (Luhn/email/JWT) server-side. Defense in depth: a bad manifest cannot leak PII.
- Server-authoritative scope.
org/sensorare stamped by ingest from the key; the wrapper never sends them, so a misconfigured org cannot leak or spoof.
Actor = key (§3.1f, U20)
A minted sensor key is an attested credential, so every event is attributed
to an attested agent actor. The id defaults to mcp:<keyId> (derived from the
token) and can be overridden per-server with --actor / BOUNDED_ACTOR. An
asserted _meta.onBehalfOf names the end-customer but never the actor.
Amounts (envelope v1 → v2)
Amounts (and other allowed scalar values) ride rec.fields. The wrapper attaches
them optimistically and then runs every event through the shared
validateAndFilterEvent (the exact L3 filter ingest runs) before sending — so it
always sends the already-filtered result:
- On the v2 envelope (current:
EVENT_SCHEMA_VERSION = 2),rec.fieldssurvives andissue_refund { amount_cents: 4900 }is captured asrec.fields.amount_cents = 4900. - On a v1 envelope,
rec.fieldsis stripped and only the fact of the call + its actor transit.
This is one code path with zero version branching: the wrapper emits v =
whatever the bundled observe-shared reports, so rebuilding against a new
envelope is the only step needed. (This package was built against the v2
observe-shared — amounts transit today; verified live.)
Config
Flags or env (flag wins). --capture is repeatable.
| flag | env | default |
|---|---|---|
| --token | BOUNDED_SENSOR_TOKEN | (required) obs1.<keyId>.<sig> |
| --org | BOUNDED_ORG | (logs/self-filter only; ingest stamps authoritatively) |
| --actor | BOUNDED_ACTOR | mcp:<keyId> |
| --name | BOUNDED_MCP_SERVER_NAME | serverInfo.name / mcp |
| --ingest | BOUNDED_INGEST_BASE | prod ingest |
| --capture <f> | BOUNDED_CAPTURE_FIELDS=a,b | built-in safe set |
| --on-behalf-of | BOUNDED_ON_BEHALF_OF | (none) |
| --http / --upstream | BOUNDED_MCP_HTTP / BOUNDED_MCP_UPSTREAM | stdio mode |
| --port | BOUNDED_MCP_PORT | ephemeral (http mode) |
| --flush-ms | BOUNDED_FLUSH_MS | 2000 |
| --debug | BOUNDED_DEBUG | off |
| (kill switch) | BOUNDED_OBSERVE_MCP_DISABLED=1 | pure passthrough |
Build / test
npm run build # esbuild bundle -> dist/cli.js (bin) + dist/index.js
npm run typecheck
npm test # vitest: passthrough fidelity, event shape, redaction, latency, http
npm run test:live # LIVE prod: mint key -> wrap mock server -> verify events land -> revokenpm run test:live needs the org acme-demo ADMIN_SECRET at
/tmp/observe-admin-secret.txt (or $OBSERVE_ADMIN_SECRET). It drives the mock
server through the wrapper against prod ingest and verifies the rail:mcp events
land (rollup deltas + raw R2 evidence readback) with the attested agent actor and
the captured refund amount, then revokes the key.
Embedding as bounded observe mcp
The bin bounded-observe-mcp is also the body of the bounded observe mcp
subcommand: runMcpObserve(argv, env) is exported from the package entry, and the
arg parser tolerates a leading observe mcp, so a parent bounded CLI can mount
it directly. Other building blocks (runStdioProxy, runHttpProxy, Observer,
Reporter, buildActionEvent) are exported for reuse.
