The audit and governance layer for MCP.
Drop mcpose between any LLM client and any MCP server. Intercept, transform, and govern every tool call through composable onion middleware — and log it in a tamper-evident, compliance-grade audit trail. Nothing upstream changes.
// Wrap any upstream MCP server — unmodified
const backend = await createBackendClient({
command: 'node',
args: ['./es-server.mjs'],
});
const audit = createAuditMiddleware({
signingKey: createDefaultSigningKeyProvider(process.env.AUDIT_SECRET!),
onEvent: (e) => auditLog.append(e), // HMAC-chained, tamper-evident
});
await startHttpProxy(backend, {
hiddenTools: ['delete_index'], // rejections are audited too
toolMiddleware: [redactPii, audit.middleware],
}); // audit only ever sees redacted dataThe same three concerns, lifted out.
Nothing here is a competitor comparison, because there is no competitor to name. The alternative is the code you already have: auth, redaction, and logging written into the handlers of a server you control.
server.setRequestHandler(CallToolRequestSchema, async (req) => {
// auth, inline
const id = await verifyJwt(req.params._meta?.token);
if (!id.roles.includes('analyst')) throw new Error('denied');
const out = await runTool(req.params.name, req.params.args);
// redaction, inline
const text = PII.reduce(
(t, re) => t.replace(re, '[REDACTED]'),
out.text,
);
// "audit", inline
console.log(JSON.stringify({ tool: req.params.name }));
return { content: [{ type: 'text', text }] };
});
// Welded to this server. Nothing chains the log lines,
// so nothing detects that one was edited or dropped.
// Copy the whole thing for the next server.// The upstream tool handler is untouched.
const backend = await createBackendClient({
url: UPSTREAM_URL,
});
await startHttpProxy(
backend,
{ toolMiddleware: [redactPii, audit.middleware] },
{
port: 3000,
resolveIdentity,
onSessionClosed: audit.closeSession,
},
);
// Ordering is the contract: redactPii is listed first,
// so audit never sees raw PII. Point it at the next
// server and it is the same three lines.RejectionReason.Identity on every request.Three shapes people actually ship.
Each one is a recipe in the docs, and each one is the same primitive: a function that sees the request on the way in and the response on the way out.
The origin use case: every upstream response is scrubbed before it reaches the LLM, and before it reaches the audit trail. Ordering is the whole point — the redaction middleware is listed first, so audit never sees raw data.
// recipes/pii-redaction-audit
const redactPii: ToolMiddleware = async (req, next) => {
const result = await next(req);
if (!hasToolContent(result)) return result;
return {
...result,
content: result.content.map((item) =>
item.type === 'text'
? { ...item, text: redact(item.text) }
: item,
),
};
};
// redactPii is listed first, so audit only ever
// sees text that has already been scrubbed.
await startHttpProxy(backend, {
toolMiddleware: [redactPii, audit.middleware],
});The tool list is a response like any other, so it goes through middleware too. A caller without the role never learns the tool exists, and a blocked call still produces an audited rejection rather than a silent failure.
// recipes/list-tools-rewriting
// The tool list is a response like any other, so it
// goes through middleware too — an analyst never
// learns that wire_transfer exists.
const roleAware: ListToolsMiddleware = async (req, next, ctx) => {
const result = await next(req);
if (ctx.identity?.roles.includes('treasury')) return result;
return {
...result,
tools: result.tools.filter((t) => t.name !== 'wire_transfer'),
};
};
await startHttpProxy(backend, {
listToolsMiddleware: [roleAware],
hiddenTools: ['delete_index'], // rejections are audited
});Upstream servers often sit behind their own auth. mcpose terminates it, so the credentials live in one place you control and the LLM client never handles them.
// recipes/oauth-upstream
// The upstream has its own auth. mcpose holds those
// credentials, so the LLM client never sees them and
// never needs to.
const backend = await createBackendClient({
url: 'https://vendor.example.com/mcp',
authProvider, // drives MCP OAuth, refreshes tokens
headers: { 'x-tenant': process.env.TENANT_ID! },
});
await startHttpProxy(backend, {
toolMiddleware: [audit.middleware],
});One proxy in the middle.
mcpose mirrors the upstream MCP surface and routes supported calls through middleware. Capabilities, abort signals, progress, and list-changed notifications pass through intact.
Omitted from list responses; rejected with TOOL_HIDDEN at call time. The rejection still hits the audit trail.
Forwarded raw. Transformers are skipped; observers wrapped in markPassThroughObserver() still run.
Routed through the full toolMiddleware / resourceMiddleware pipeline.
Audit trails an examiner can verify.
Built for DORA Art. 17 and SR 11-7. Every event chains to the last; every session closes with a signed Merkle manifest. Extracted from a production financial deployment.
| Tier | Stored fields |
|---|---|
| low | inputRaw, outputRaw (plaintext) |
| medium | inputRaw, outputRaw (PII redacted upstream) |
| high | inputEncrypted, outputEncrypted (AES-256-GCM) |
Three packages, one surface.
| Package | What it does | Version |
|---|---|---|
| mcpose | Proxy core — pipeline, transports, identity, governance. | v2.1.1 |
| @mcpose/audit | Tamper-evident HMAC audit chain + Merkle replay manifest. | v2.0.3 |
| @mcpose/testing | Runner-agnostic compliance assertions for the audit chain. | v2.0.3 |
@modelcontextprotocol/sdk ≥ 1.0 — installed separately.Drop it in front of any MCP server.
Ten lines of glue. Nothing upstream changes.