Skip to main content
Glama
magves56

attestive-mcp-example

by magves56
README.md
# attestive-mcp-example

A thin wrapper that hooks [Attestive](https://attestive.io)'s `AuditClient.record()`
into an MCP server's tool-call lifecycle — the natural interception point on the
*server* side of MCP, the same way
[attestive-agent-sdk-example](https://github.com/magves56/attestive-agent-sdk-example)'s
`PostToolUse` hook is the natural interception point on the *client* side. One
line — `auditMcpServer(server, auditClient)` — and every tool registered on
that server from then on gets its calls recorded automatically, no matter
which MCP host (Claude Desktop, Claude Code, or anything else speaking MCP)
ends up calling it.

**This wrapper is fail-open, stated plainly up front:** the real tool runs
first; logging happens after, not atomically with it; and if logging itself
fails, that failure is swallowed to stderr while the tool's actual result
still goes back to the caller unchanged. See "Fail-open, stated plainly"
below for exactly what that means and why.

Extracted from Attestive's main repo (`git subtree split`) so it's readable
and runnable on its own. The hosted product's implementation lives in a
private repository — this example is complete and independently runnable on
its own, not a partial extract of something you'd need private access to
fully evaluate; see "Why this includes a copy of AuditClient" below for what
that means for `attestive-client.ts` specifically.

- `attestive-audit.ts` — the reusable wrapper. `withAudit()` wraps one tool
  callback; `auditMcpServer()` wraps `server.registerTool` so every future
  registration gets `withAudit()` applied automatically. This is the file to
  read if you're integrating this into your own server — everything else here
  is example scaffolding.
- `demo-server.ts` — a real MCP server with two tools a support agent might
  call (`check_order_status`, a read-only lookup; `issue_refund`, an actual
  decision with a real approve/deny outcome), both audited via one call to
  `auditMcpServer()`. Runs on stdio, exactly like any MCP server a real host
  spawns.
- `run-example.ts` — the real thing: spawns `demo-server.ts` as an actual
  subprocess, connects a real MCP `Client` over `StdioClientTransport`, calls
  both tools through the real MCP protocol, then exports and verifies the
  resulting chain with the actual published `attestive-verify` package.
- `smoke-test.ts` — exercises `withAudit()` directly against fake tool
  callbacks (both MCP callback shapes, plus a callback that throws), no MCP
  server or subprocess involved. Useful for iterating on the wrapper itself.
- `attestive-client.ts` — a self-contained copy of `AuditClient` and the
  hash-chain/framework-citation logic it depends on, so this repo runs on its
  own with nothing but `npm install`. See its own header comment, and
  "Why this includes a copy of AuditClient" below.

## Steps: npm install → first verified logged decision

```bash
npm install
npm run smoke-test    # proves the wrapping logic works in isolation
npm run start         # the real thing -- spawns a real MCP server, calls real tools, verifies the chain
```

Neither command needs any credentials — MCP tool-calling doesn't require an
LLM in the loop to demonstrate, unlike the Claude Agent SDK example, which
needs `ANTHROPIC_API_KEY` or a Claude Code login to run the live agent path.
That's a real difference worth knowing if you're choosing which example to
try first: this one you can fully verify end to end with nothing but Node
installed.

## Honest timing

Measured against this repo, standalone, on a clean install:

| Step | Time |
| --- | --- |
| `npm install` (97 packages, cold) | ~9s |
| `npm run smoke-test` | instant; proves `withAudit()` → `record()` → `exportEvidence()` → `verifyChain()`, including a simulated thrown error |
| `npx tsc --noEmit` against the installed SDK's real `.d.ts` | clean, no errors |
| `npm run start` — spawn a real subprocess, connect a real MCP `Client`, call two real tools through the real protocol, export, verify | ~2.4s wall clock, all in |

Total, start to a verified logged decision: under 15 seconds, no external
accounts needed.

## What "real running MCP server" means here

`run-example.ts` doesn't mock anything: `demo-server.ts` is spawned as a
genuine child process (`node demo-server.ts`, via the SDK's own
`StdioClientTransport`, which is exactly how Claude Desktop and Claude Code
launch MCP servers), and every tool call crosses a real stdio pipe as real
JSON-RPC messages, dispatched by the real `@modelcontextprotocol/sdk`
`McpServer`. The one piece of demo-only plumbing is `_export_chain`, a third
tool registered *before* `auditMcpServer()` runs (so it isn't itself
audited — exporting a chain isn't a decision) purely so `run-example.ts` has
a way to read back what the subprocess recorded in its own memory, the same
way a real integrator's own tooling would need to reach into wherever they
point `ChainStore`.

## Fail-open, stated plainly

Look at `withAudit()` in `attestive-audit.ts` and this is exactly what it
does — this section just says it out loud instead of leaving it to be
discovered by reading the source:

1. The real tool handler runs **first**. Its result (or thrown error) is
   captured, but nothing about the audit trail has happened yet.
2. **After** the handler has already run, `withAudit()` calls
   `auditClient.record()` to log the decision.
3. If that `record()` call itself fails — the backing `ChainStore` is
   unreachable, a database outage, a bug — the failure is caught and
   printed to `stderr`. It is not retried, not queued, not surfaced to the
   MCP client in any way.
4. Either way, the tool's *original* result (or error) is what the MCP
   client receives. A logging failure never blocks, delays, or changes the
   underlying action.

The consequence: **this wrapper does not guarantee that every tool call
gets captured, only that calls which *are* successfully captured are
recorded faithfully.** If `record()` fails, the refund still gets issued,
the order still gets updated — the action completes normally — but no
decision record exists for it, and nothing tells you that gap happened
unless you're watching that `stderr` output yourself. This is a deliberate
trade-off, not an oversight: the alternative (blocking or failing a real
action because logging hiccupped) is usually worse for whatever the tool
actually does. But it means audit-trail completeness depends on your
`ChainStore` staying up, not just on this wrapper being installed —
verified directly in `smoke-test.ts`'s thrown-error case, which exercises
exactly this failure mode.

## Coverage and limits, stated plainly

- Only the current, recommended `registerTool()` API is wrapped by
  `auditMcpServer()`. The deprecated `.tool()` overloads are not intercepted
  automatically — migrate to `registerTool()` first (the MCP SDK already
  recommends this regardless of auditing), or call `withAudit()` directly
  around a `.tool()` callback if you can't migrate yet:
  `server.tool("name", schema, withAudit("name", auditClient, handler))`.
- The default `agentId` is the MCP transport's `sessionId` if one is
  available (Streamable HTTP provides this), falling back to `"mcp-server"`
  otherwise. Stdio transports are typically one server process per session
  already, so one chain per process is a reasonable default there — but for
  a multi-tenant server where `sessionId` alone isn't the right audit
  boundary, pass `agentId: (extra) => yourOwnId(extra)` to `auditMcpServer()`.
- The default `decisionType` is `"automated_decision"` — the same value
  the Claude Agent SDK example uses, which `suggestControlsForDecisionType()`
  maps to human-oversight and record-keeping citations. Pass `decisionType`
  to `auditMcpServer()` (or per-tool to `withAudit()`) if a more specific
  type fits your tools better.
- See "Fail-open, stated plainly" above for how audit-logging failures are
  handled — the short version is: swallowed to `stderr`, never allowed to
  change what the MCP client receives, which also means never guaranteed to
  actually get recorded.

## Why this includes a copy of AuditClient

`attestive-client.ts` in this repo is a deliberate copy of `AuditClient`, not
an import of it — `AuditClient` isn't published as an installable package
yet (unlike `attestive-verify`, which this example genuinely depends on via
`npm install`), and the monorepo paths it lives at only resolve inside that
monorepo's own layout. The hosted product's implementation lives in a
private repository, so there's no public copy to import from or link to
instead — this example is complete and independently runnable on its own,
not a partial extract of something else. The monorepo guards the two from
silently drifting apart with a cross-check test that isn't (and can't be)
included here — see `attestive-client.ts`'s own header comment.

## Swapping in the real backend

This example uses `InMemoryChainStore` — the point is proving the MCP
integration works, not standing up a database. For a real deployment, swap
in a persistent `ChainStore` implementation (see `attestive-client.ts`'s
`ChainStore` interface — the hosted product backs its own chain with
Postgres, in a private repository not included here), and pass a real
`organizationId`/`apiKey` to `AuditClient` instead of the `"local-demo"`
placeholder used here.

## License

MIT — see [LICENSE](LICENSE).