MCP审计治理代理

mcpose 是一个透明中间件代理,位于LLM客户端和MCP服务器之间,通过可组合中间件实现身份解析、可见性过滤和防篡改审计跟踪,适用于需要审计与治理的受监管环境。

  • by amir-gorji
  • TypeScript
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 amir-gorji/mcpose

使用说明

mcpose

The audit and governance layer for MCP.

mcpose is a transparent middleware proxy for MCP servers. It sits between an LLM client and an upstream server, routing every tool, resource, and list_tools call through composable middleware you control. The client sees a normal MCP server; the upstream sees a normal MCP client; neither has to change.

┌──────────────┐        ┌────────────────────────────────┐        ┌────────────────────┐
│  LLM client  │ ◄────► │  mcpose                        │ ◄────► │  Upstream MCP      │
│  (Claude,    │        │  · identity resolution         │        │  server            │
│   Cursor…)   │        │  · visibility filters          │        │  (stdio or HTTP)   │
└──────────────┘        │  · middleware pipelines        │        └────────────────────┘
                        │  · audit trail                 │
                        └────────────────────────────────┘

Add @mcpose/audit and every call through that pipeline also becomes a tamper-evident audit record: HMAC-chained, Merkle-anchored, and signed, for evidence that stands up under DORA Article 17 and SR 11-7 review.

Table of Contents


Features

  • Transparent proxy. Wrap any upstream MCP server without modifying it, over stdio or HTTP/SSE.
  • Composable middleware with a predictable onion model: each layer runs before and after the layers inside it.
  • Hide or gate tools and resources per caller, with a structured RejectionReason on every blocked call instead of an opaque error.
  • Resolve caller identity once per session, then read the same Identity from every request in that session.
  • Tamper-evident audit trails via @mcpose/audit: HMAC-chained events covering tool and prompt calls, a signed Merkle ReplayManifest, and AES-256-GCM encryption for high-sensitivity payloads.
  • Deny-by-default authorization via @mcpose/policy: RBAC rules over roles and tool names, sensitivity-tier rules, and per-session call budgets, evaluated locally with no remote decision point.
  • Prove it in CI with @mcpose/testing: runner-agnostic assertions that a trail is intact, redacted, and internally consistent.
  • Production-minded. Native ESM, first-class TypeScript types, Node.js 20+, no runtime dependencies beyond the MCP SDK.

Who this is for

Reach for mcpose when:

  • You operate MCP servers in a regulated environment (finance, healthcare) and need PII redaction, caller identity, and a defensible audit trail on every tool call.
  • You need cross-cutting behavior (logging, redaction, rate limiting, governance) on an MCP server you do not own or cannot modify.
  • You need to hide or gate specific tools and resources per caller, and to record what was blocked as well as what ran.

Look elsewhere when:

  • You control the single MCP server and need one hook in one place. A request handler in your own server is less machinery than a proxy hop.
  • Your requirement is purely transport-level: TLS termination, IP allowlists, or rate limiting by source address. An HTTP gateway already does that, and mcpose does not replace it.
  • You need the audit trail to re-execute historical calls. A ReplayManifest proves what happened; session re-execution is v4 work.

How it compares

mcpose composes with the tools below far more often than it replaces them. Versions noted where they matter: mcpose 3.1.0, @mcpose/audit 3.1.0.

If you are currently It works well when Where it runs out
Writing the concern into each MCP server You have one server, one team, and the concern is a few lines of logging. Every new server re-implements redaction and audit. The logic is not independently testable, and it ships on the server's release cycle.
Putting an HTTP gateway in front of the MCP endpoint (Envoy, Kong, nginx) You need TLS, IP policy, or coarse rate limits. Gateways are excellent at this. A gateway sees a JSON-RPC body, not a tool call. It cannot filter list_tools, reject transfer_funds for one role, or hash a tool's arguments into an audit chain without parsing MCP itself.
The MCP SDK's own request handlers You own the server and want one hook with zero extra hops. Handlers are per-server and do not compose. There is no shared onion, no passThroughTools escape hatch, and no cross-server identity or audit story.
A log pipeline (OpenTelemetry, Splunk, ELK) You need observability: latency, error rates, volume. mcpose feeds these through onTelemetry. Logs are mutable and unordered by design. They answer "what did we observe"; a regulator asks "prove this record was not altered", which needs the HMAC chain and the signed manifest.

The honest summary: if you need observability, use a log pipeline, and let mcpose feed it. If you need evidence, you need the chain and the manifest, and that is what @mcpose/audit adds.

Packages

This is a monorepo. Each package publishes independently and carries its own README on npm.

Package Version Stability What it does
mcpose Stable Proxy core: middleware pipeline, stdio and HTTP transports, identity, governance.
@mcpose/audit Stable Tamper-evident HMAC audit chain and signed Merkle ReplayManifest.
@mcpose/policy Stable Deny-by-default RBAC middleware: role rules, sensitivity tiers, per-session call budgets.
@mcpose/consent Stable Fail-closed GDPR/CCPA consent gate: host-resolved consent, audited refusals.
@mcpose/testing Stable Runner-agnostic compliance assertions over an audit trail.
@mcpose/otel Preview OpenTelemetry span adapter for the onTelemetry hook.
@mcpose/store-redis Beta Redis-backed EventStore and SessionRegistry: SSE reconnect replay that survives a restart.
@mcpose/store-postgres Beta Postgres-backed EventStore and SessionRegistry: SSE reconnect replay that survives a restart.

A fintech identity package was considered and rejected; identity mapping is host resolveIdentity code (ADR-0020).

Install

Prerequisites

  • Node.js 20 or newer.
  • @modelcontextprotocol/sdk ^1.17.0, a peer dependency you install yourself.
npm install mcpose @modelcontextprotocol/sdk

For tamper-evident audit trails, add the audit package and its test-time assertions:

npm install @mcpose/audit
npm install --save-dev @mcpose/testing

For deny-by-default authorization, add the policy engine:

npm install @mcpose/policy

For a GDPR/CCPA consent gate in front of your tools, add the consent middleware:

npm install @mcpose/consent

For OpenTelemetry spans from the onTelemetry hook, add the span adapter and your own OpenTelemetry SDK:

npm install @mcpose/otel @opentelemetry/api

Quick start

Connect to an upstream over stdio, add one middleware, and serve:

import { createBackendClient, startProxy } from 'mcpose';
import type { ToolMiddleware } from 'mcpose';

// 1. Connect to the upstream MCP server.
const backend = await createBackendClient({
  command: 'node',
  args: ['/path/to/backend-server.mjs'],
});

// 2. Define middleware: (req, next, ctx) => Promise<result>
const loggingMW: ToolMiddleware = async (req, next) => {
  console.error(`→ ${req.params.name}`);
  const result = await next(req);
  console.error(`← ${req.params.name} done`);
  return result;
};

// 3. Serve the proxy over stdio.
await startProxy(backend, { name: 'my-proxy', toolMiddleware: [loggingMW] });

Point your MCP client at this process instead of the upstream, and every tool call now flows through loggingMW.

To serve over HTTP/SSE instead, with per-session identity, mTLS, session limits, and reconnect replay, swap startProxy for startHttpProxy.

One endpoint over many upstreams

Pass a record of named backends instead of one client, and the same proxy governs all of them through one pipeline and one audit trail:

await startProxy(
  {
    crm: await createBackendClient({ url: 'https://crm.internal/mcp' }),
    docs: await createBackendClient({ command: 'node', args: ['./docs-server.mjs'] }),
  },
  { name: 'my-proxy', toolMiddleware: [loggingMW], hiddenTools: ['crm__delete_account'] },
);

The record keys are backend keys, and the client sees crm__lookup, docs__search, and so on. Every option matches those namespaced names, an upstream that goes down degrades its own tools out of tools/list rather than failing it, and a call that carries no key that names a configured backend is rejected rather than guessed at. See mesh mode for the full behaviour, and ADR-0013 for why it works that way.

How it works

Routing: hidden, pass-through, middleware

For each tool or resource, mcpose picks exactly one path:

Path Option Behavior
Hidden hiddenTools / hiddenResources Omitted from list responses, and rejected at call time with TOOL_HIDDEN / RESOURCE_HIDDEN. hiddenTools also takes a predicate, so a dispatcher (meta-tool) cannot reach a hidden tool by argument; see dispatcherAwareBlock.
Pass-through passThroughTools / passThroughResources Forwarded to the upstream untouched. Transforming middleware is skipped.
Local localTools Served by the proxy itself instead of the upstream, still through the full toolMiddleware pipeline. Hidden beats local; local beats an upstream tool of the same name; pass-through does not apply.
Middleware everything else Routed through the full toolMiddleware / resourceMiddleware pipeline. A prompts/get call runs the promptMiddleware pipeline the same way.

Three consequences worth knowing up front:

  • A hidden tool is rejected inside the pipeline, so observing middleware such as audit still records the attempt. The upstream is never called.
  • A tool listed in both hiddenTools and passThroughTools stays hidden.
  • Pass-through skips transforming middleware only. Middleware wrapped in markPassThroughObserver() still runs, which is how audit and telemetry keep full coverage. The middleware from createAuditMiddleware is wrapped for you.

Mesh mode adds no fifth path. Choosing which upstream a call goes to happens inside the innermost next of whichever path above applies, so a mesh call runs exactly the pipeline a 1:1 call runs, audit included. A name that resolves to no configured backend is rejected there too, with BACKEND_UNROUTABLE, so observing middleware records the attempt. Resources follow the same rule under the URI a mesh exposes, mcpose://<backendKey>/<uri> (ADR-0022). That holds for prompts as well: prompts/get runs promptMiddleware, and its unroutable-name rejection is thrown inside the pipeline, so audit sees prompt calls and prompt rejections (ADR-0014). Prompt hiding and prompt pass-through do not exist yet, because prompts/list has no pipeline.

The middleware onion

Each middleware receives the request, a next function that invokes the rest of the pipeline, and a normalized ProxyContext. Outer layers run code before and after the inner ones:

  request ──►
             ┌──────────────────────────────────────────┐
             │  outerMW  (enter)                        │
             │  ┌────────────────────────────────────┐  │
             │  │  innerMW  (enter)                  │  │
             │  │  ┌──────────────────────────────┐  │  │
             │  │  │  upstream call               │  │  │
             │  │  └──────────────────────────────┘  │  │
             │  │  innerMW  (exit) ◄── response      │  │
             │  └────────────────────────────────────┘  │
             │  outerMW  (exit) ◄── response            │
             └──────────────────────────────────────────┘
  ◄── response

Array order: the one surprising rule

Middleware arrays in ProxyOptions are in response-processing order: the first element processes the response first, which makes it the innermost layer. This is deliberate, because it is the ordering that makes the safety property obvious. To guarantee audit never sees raw PII, put redaction first:

toolMiddleware: [piiMW, auditMW]
// Execution:
// 1. auditMW enter  → capture startTime         (outermost)
// 2. piiMW enter    → transform request
// 3. upstream call
// 4. piiMW exit     → redact PII from response  (processes response first)
// 5. auditMW exit   → log already-clean data    (processes response last)

Mapping array indices onto both traversal directions:

toolMiddleware: [ piiMW,  auditMW ]
                  index 0  index 1

request:   client ──► auditMW ──► piiMW ──► upstream   (last index first)
response:  upstream ──► piiMW ──► auditMW ──► client   (index 0 first)

The rule in one line: the last element is outermost. The practical form: transformers first, observers last.

Getting the order backwards fails silently. [auditMW, piiMW] puts audit innermost, so the audit store fills with unredacted payloads. The client still receives redacted data, so nothing looks wrong, and the failure surfaces only when someone reads the trail. No error is raised, and the configuration reads plausibly.

Ordering What audit records
[piiMW, auditMW] (safe) The redacted response: PII never reaches the log.
[auditMW, piiMW] (unsafe) The raw upstream response, unredacted, silently.

compose([outerMW, innerMW]) uses the opposite, outermost-first convention. ProxyOptions arrays and compose() arguments are not interchangeable. See ADR-0002 for why.

What the proxy preserves

Transparency is a contract, not a slogan. End to end, mcpose:

  • mirrors the upstream's advertised capabilities,
  • forwards abort signals to upstream tool, resource, and prompt calls,
  • relays upstream progress updates back to the client,
  • advertises and fans out list-changed notifications when the upstream supports them,
  • forwards prompts/list as-is when the upstream supports prompts, and runs prompts/get through promptMiddleware,
  • applies hiddenTools filtering both before and after listToolsMiddleware, so middleware cannot re-add a hidden tool.

One deliberate exception to transparency, in both directions: params._meta is stripped from every forwarded request, and top-level _meta from every upstream result, by default. Clients put correlation identifiers in request _meta (traceparent, vscode/conversationId) and the upstream is frequently a third party; upstreams stamp their own into result _meta (the SDK stamps io.modelcontextprotocol/related-task) and the client is a third party to those. Set stripRequestMeta: false / stripResultMeta: false to restore verbatim forwarding. See ADR-0008 and ADR-0009.

Guides

PII redaction with audit

The origin use case: a financial-grade MCP server where every response must be scrubbed of PII before it reaches the LLM or the audit log.

import { mapToolResult, startHttpProxy } from 'mcpose';
import type { ToolMiddleware } from 'mcpose';
import {
  createAuditMiddleware,
  createDefaultSigningKeyProvider,
  createSensitivityResolver,
} from '@mcpose/audit';

// Supplied by your application: backend, auditLog, manifestStore, extractJwt.

function createPiiMiddleware(patterns: RegExp[]): ToolMiddleware {
  const scrub = (text: string) =>
    patterns.reduce((t, re) => t.replace(re, '[REDACTED]'), text);
  // mapToolResult requires a handler per payload channel (text blocks,
  // non-text blocks, structuredContent), so nothing slips through unmapped.
  return async (req, next) =>
    mapToolResult(await next(req), {
      onText: (block) => ({ ...block, text: scrub(block.text) }),
      onOther: () => null, // drop images/audio/resources we cannot scrub
      onStructured: (structured) =>
        JSON.parse(scrub(JSON.stringify(structured))),
    });
}

const auditHandle = createAuditMiddleware({
  signingKey: createDefaultSigningKeyProvider(process.env.AUDIT_SECRET!),
  sensitivityResolver: createSensitivityResolver({ search: 'medium', transfer: 'high' }),
  onEvent: (e) => auditLog.append(e),
  onManifest: (m) => manifestStore.save(m),
});

await startHttpProxy(
  backend,
  {
    name: 'my-proxy',
    toolMiddleware: [
      createPiiMiddleware([/\b\d{9}\b/g, /[A-Z]{2}\d{6}/g]), // redaction runs first
      auditHandle.middleware,                                // so audit records clean data
    ],
    // Prompt calls cross the same trust boundary, so audit them too.
    promptMiddleware: [auditHandle.promptMiddleware],
  },
  {
    resolveIdentity: extractJwt,
    onSessionClosed: (id) => auditHandle.closeSession(id),
  },
);

Because of the array order rule, PII is redacted before the audit layer ever sees the response, so no raw PII reaches a log.

Reference implementation: elastic-pii-proxy runs this pattern in production, proxying Elasticsearch to LLM agents over mcpose with redaction and @mcpose/audit.

Rewriting list_tools

Reshape the tool catalog the client sees without touching the upstream:

import type { ListToolsMiddleware } from 'mcpose';

const enrichDescriptions: ListToolsMiddleware = async (req, next) => {
  const result = await next(req);
  return {
    ...result,
    tools: result.tools.map((tool) =>
      tool.name === 'wire_transfer'
        ? { ...tool, description: `${tool.description ?? ''} (approval required)` }
        : tool,
    ),
  };
};

hiddenTools stays authoritative: filtering is applied after this middleware too, so it cannot re-add a hidden tool.

For the common security case, the shipped sanitizeToolDescriptions() middleware strips URLs and configured patterns from tool and schema descriptions, because the catalog is an egress channel into model context (ADR-0010):

listToolsMiddleware: [sanitizeToolDescriptions({ patterns: ['acme-corp'] })],

Place it last in the array so it sanitizes the output of other list middleware and local tools.

Examples

Runnable, commented examples live in examples/.

Example What it shows Needs an upstream?
governance-proxy.ts hiddenTools, passThroughTools, and onTelemetry, against a mock backend. No
pii-redaction-audit.ts The canonical pattern: redaction composed with audit over HTTP/SSE, with per-session identity. Yes
oauth-upstream-client.ts Connecting to an OAuth-protected upstream: dynamic registration, PKCE, persisted tokens with refresh. Yes, OAuth-capable

Start with governance-proxy, which is self-contained:

pnpm install
pnpm --filter mcpose-examples governance-proxy

See the examples README for the full run matrix.

API reference

Each package's README is the canonical reference for its own exports, and each is self-contained:

Package Reference covers
mcpose createBackendClient, startProxy, startHttpProxy, createProxyServer, compose, markPassThroughObserver, rejectionMcpError, createProxyContext, createInMemoryEventStore, hasToolContent, mapToolResult, dispatcherAwareBlock, and the ProxyContext / Identity / Backends / ProxyOptions / HttpProxyOptions / LocalTool / HiddenToolPredicate / RejectionReason types, plus the ToolMiddleware / ResourceMiddleware / PromptMiddleware / ListToolsMiddleware middleware types.
@mcpose/audit createAuditMiddleware (returning middleware, promptMiddleware, and closeSession), createSensitivityResolver, createDefaultSigningKeyProvider, createInMemorySubjectKeyStore, verifyAuditChain, verifyManifestSignature, the Merkle helpers, the canonical serializers, and the AuditEvent / ReplayManifest / AuditOptions / SubjectKeyStore schemas, plus erasable mode.
@mcpose/policy createPolicyMiddleware (returning middleware, promptMiddleware, and evictSession), and the PolicyOptions / PolicyRule / SensitivityRule types, plus the recommended composition order relative to @mcpose/audit.
@mcpose/consent createConsentMiddleware (returning middleware and promptMiddleware), the ConsentOptions / ResolveConsentFn types, every way the gate fails closed, and how it pairs with erasable audit mode.
@mcpose/testing assertAuditChainIntegrity, assertReplayManifestValid, assertPiiRedacted, assertDelegationHonored, each with what it does and does not prove.
@mcpose/otel createOtelTelemetry, plus the span and attribute mapping for each TelemetryEvent variant.

Test helpers for the proxy itself (createMockBackendClient, runToolMiddleware, runListToolsMiddleware, runResourceMiddleware) ship in the core package under the mcpose/testing subpath. That is a different thing from the @mcpose/testing package, which asserts audit chains. See the core README for the distinction.

Vocabulary used across all of these is defined once in CONTEXT.md.

Tamper-evidence: what is and is not guaranteed

The value of an audit trail is what it can prove to someone who does not trust you, so the boundaries matter more than the feature list.

With the signing secret, verifyAuditChain(events, signingKey) recomputes every chain hash and verifyManifestSignature(manifest, signingKey) checks the manifest signature. Together these detect insertion, deletion, reordering, and modification anywhere in the trail. The signature covers the canonical serialization of the entire manifest, not just the Merkle root, so no field is silently swappable.

Without the signing secret, the assertions in @mcpose/testing prove internal consistency only. They catch reordering, renumbering, duplication, head or middle deletion, and a swapped Merkle root. They do not prove authenticity: a forger who rewrites every event and regenerates the root and proofs produces a document they accept, and needs no secret to do it. Tail truncation also leaves a valid prefix, so assertAuditChainIntegrity alone accepts it; the manifest's eventCount is what catches that.

Two more boundaries worth stating plainly:

  • The chain binds payloads through inputHash / outputHash, so the raw and encrypted bodies themselves are not in the preimage. The sensitivityTier is covered, so relabelling a high event as low is chain-detectable (ADR-0015).
  • Chaining requires a session id. Over stdio there is no session, so every event carries position 0 and no manifest is produced.

The reasoning behind the key hierarchy is in ADR-0003, and the canonical-serialization format in ADR-0004. @mcpose/audit 3.0.0 writes audit format v2, a breaking change: archives written by a 2.x release verify only under a pinned 2.x.

Background

mcpose was extracted from financial-elastic-mcp-server, an Elasticsearch MCP server built for financial institutions that needed PII redaction and audit logging on every tool call. Those concerns started life hardcoded into one server. mcpose lifts the pattern into a reusable middleware layer that can wrap any upstream MCP server.

Release history lives in CHANGELOG.md.

Roadmap

Shipped:

  • [x] Composable middleware: startProxy(), startHttpProxy(), createProxyServer()
  • [x] Streamable HTTP transport with stateful sessions and SSE reconnect replay
  • [x] Identity resolution: the resolveIdentity hook, with Identity on every ProxyContext
  • [x] mTLS through tlsOptions on HttpProxyOptions
  • [x] @mcpose/audit: HMAC chain, Merkle proofs, signed ReplayManifest, sensitivity tiers
  • [x] @mcpose/policy: deny-by-default RBAC engine, sensitivity-tier rules, per-session call budgets (ADR-0017)
  • [x] @mcpose/testing: compliance assertions
  • [x] Multi-backend composition: one governed endpoint over many upstreams, with namespaced tools
  • [x] @mcpose/otel: OpenTelemetry span adapter for onTelemetry
  • [x] Persistent EventStore adapters: @mcpose/store-redis and @mcpose/store-postgres
  • [x] The delegation chain wire format, so core populates delegatedFrom from the request itself
  • [x] @mcpose/consent and cryptographic erasure: a fail-closed consent gate, plus an opt-in erasable audit mode whose per-subject keys can be destroyed without touching the chain (ADR-0018)
  • [x] A shared session registry, so a resume survives a restart or reaches a different instance, with Redis and Postgres adapters beside the event stores (ADR-0021)

Session re-execution from a ReplayManifest is v4.

Contributing

Contributions are welcome. CONTRIBUTING.md covers the development setup, the common pnpm commands, and the conventions around CONTEXT.md and the ADRs.

Three rules carry more weight than the rest:

  • Read ADR-0003 and ADR-0004 before touching the chain, key derivation, encryption, or ReplayManifest. These invariants fail silently: a change can compile, pass tests, and still void the guarantee.
  • Run pnpm ts:ci and pnpm test before opening a pull request.
  • Never weaken an existing audit assertion to make a test pass.

By participating you agree to uphold the Code of Conduct.

Security

Report vulnerabilities privately by following SECURITY.md. Do not open a public issue for a security report.

License

MIT © Amir Gorji