mcpose 2.1.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +461 -62
  2. package/dist/backendClient.d.ts +1 -1
  3. package/dist/backendClient.d.ts.map +1 -1
  4. package/dist/backendClient.js +6 -1
  5. package/dist/backendClient.js.map +1 -1
  6. package/dist/core.d.ts +309 -35
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/core.js +1023 -186
  9. package/dist/core.js.map +1 -1
  10. package/dist/delegation.d.ts +89 -0
  11. package/dist/delegation.d.ts.map +1 -0
  12. package/dist/delegation.js +175 -0
  13. package/dist/delegation.js.map +1 -0
  14. package/dist/eventStore.d.ts +12 -0
  15. package/dist/eventStore.d.ts.map +1 -1
  16. package/dist/eventStore.js +43 -5
  17. package/dist/eventStore.js.map +1 -1
  18. package/dist/hiddenTools.d.ts +42 -0
  19. package/dist/hiddenTools.d.ts.map +1 -0
  20. package/dist/hiddenTools.js +53 -0
  21. package/dist/hiddenTools.js.map +1 -0
  22. package/dist/index.d.ts +15 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mesh.d.ts +82 -0
  27. package/dist/mesh.d.ts.map +1 -0
  28. package/dist/mesh.js +141 -0
  29. package/dist/mesh.js.map +1 -0
  30. package/dist/middleware.d.ts +13 -0
  31. package/dist/middleware.d.ts.map +1 -1
  32. package/dist/middleware.js +27 -3
  33. package/dist/middleware.js.map +1 -1
  34. package/dist/proxyContext.d.ts +65 -5
  35. package/dist/proxyContext.d.ts.map +1 -1
  36. package/dist/proxyContext.js +39 -4
  37. package/dist/proxyContext.js.map +1 -1
  38. package/dist/rejection.d.ts +7 -1
  39. package/dist/rejection.d.ts.map +1 -1
  40. package/dist/rejection.js +24 -1
  41. package/dist/rejection.js.map +1 -1
  42. package/dist/sanitizeCatalog.d.ts +37 -0
  43. package/dist/sanitizeCatalog.d.ts.map +1 -0
  44. package/dist/sanitizeCatalog.js +92 -0
  45. package/dist/sanitizeCatalog.js.map +1 -0
  46. package/dist/sessionRegistry.d.ts +79 -0
  47. package/dist/sessionRegistry.d.ts.map +1 -0
  48. package/dist/sessionRegistry.js +83 -0
  49. package/dist/sessionRegistry.js.map +1 -0
  50. package/dist/telemetry.d.ts +29 -1
  51. package/dist/telemetry.d.ts.map +1 -1
  52. package/dist/testing.d.ts +18 -2
  53. package/dist/testing.d.ts.map +1 -1
  54. package/dist/testing.js +30 -8
  55. package/dist/testing.js.map +1 -1
  56. package/dist/version.d.ts +7 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +7 -0
  59. package/dist/version.js.map +1 -0
  60. package/package.json +22 -8
  61. package/src/__tests__/_helpers.ts +64 -0
  62. package/src/__tests__/backendClient.test.ts +198 -0
  63. package/src/__tests__/core.delegation.test.ts +964 -0
  64. package/src/__tests__/core.edge.test.ts +314 -0
  65. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  66. package/src/__tests__/core.localTools.test.ts +314 -0
  67. package/src/__tests__/core.mesh.test.ts +1182 -0
  68. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  69. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  70. package/src/__tests__/core.test.ts +1105 -0
  71. package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/hasToolContent.test.ts +63 -0
  74. package/src/__tests__/mapToolResult.test.ts +101 -0
  75. package/src/__tests__/middleware.edge.test.ts +126 -0
  76. package/src/__tests__/middleware.test.ts +233 -0
  77. package/src/__tests__/proxyContext.test.ts +140 -0
  78. package/src/__tests__/sanitizeCatalog.test.ts +380 -0
  79. package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
  80. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  81. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  82. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  83. package/src/__tests__/startHttpProxy.test.ts +1065 -0
  84. package/src/__tests__/testing.test.ts +250 -0
  85. package/src/__tests__/version.test.ts +12 -0
  86. package/src/backendClient.ts +83 -0
  87. package/src/core.ts +2129 -0
  88. package/src/delegation.ts +224 -0
  89. package/src/eventStore.ts +106 -0
  90. package/src/hiddenTools.ts +84 -0
  91. package/src/identity.ts +15 -0
  92. package/src/index.ts +63 -0
  93. package/src/mesh.ts +204 -0
  94. package/src/middleware.ts +101 -0
  95. package/src/proxyContext.ts +108 -0
  96. package/src/rejection.ts +62 -0
  97. package/src/sanitizeCatalog.ts +126 -0
  98. package/src/sessionRegistry.ts +142 -0
  99. package/src/telemetry.ts +46 -0
  100. package/src/testing.ts +174 -0
  101. package/src/version.ts +6 -0
package/README.md CHANGED
@@ -2,30 +2,17 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/mcpose)](https://www.npmjs.com/package/mcpose)
4
4
  [![license](https://img.shields.io/npm/l/mcpose)](https://github.com/amir-gorji/mcpose/blob/main/LICENSE)
5
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
6
- [![CI](https://github.com/amir-gorji/mcpose/actions/workflows/deploy.yml/badge.svg)](https://github.com/amir-gorji/mcpose/actions/workflows/deploy.yml)
5
+ [![node](https://img.shields.io/node/v/mcpose)](https://nodejs.org/)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-6.x-blue)](https://www.typescriptlang.org/)
7
+ [![CI](https://github.com/amir-gorji/mcpose/actions/workflows/ci.yml/badge.svg)](https://github.com/amir-gorji/mcpose/actions/workflows/ci.yml)
7
8
 
8
9
  **The composable middleware proxy for MCP.**
9
10
 
10
- mcpose sits between a **client** (an LLM or agent) and an **upstream** MCP server, forwarding every tool, resource, and `list_tools` call through a **pipeline** of composable middleware. It is a transparent proxy: the client talks to mcpose exactly as it would talk to the upstream, while you intercept, transform, hide, or govern calls in between, without touching the upstream server.
11
+ mcpose sits between a **client** (an LLM or agent) and an **upstream** MCP server, forwarding every tool, resource, and `list_tools` call through a **pipeline** of composable middleware.
12
+ It is a transparent proxy: the client talks to mcpose exactly as it would talk to the upstream, while you intercept, transform, hide, or govern calls in between, without touching the upstream server.
11
13
 
12
- ## When to reach for it
13
-
14
- - Add cross-cutting behavior (logging, PII redaction, identity resolution, rate limiting) to an MCP server you don't own.
15
- - Hide or gate specific tools/resources per caller.
16
- - Resolve a caller **identity** once per session and stamp it on every request.
17
- - Lay the foundation for compliance-grade audit trails with [`@mcpose/audit`](https://www.npmjs.com/package/@mcpose/audit).
18
-
19
- If you only need the audit chain or the compliance test helpers, see the ecosystem packages below; they build on this core.
20
-
21
- ## Features
22
-
23
- - **Transparent proxy**: wrap any upstream MCP server without modifying it.
24
- - **Composable middleware pipeline** with a predictable onion model: each layer runs before *and* after the inner pipeline.
25
- - **Per-session identity resolution**: resolve a caller once, then stamp the `Identity` on every request in the session.
26
- - **Tool and resource governance**: hide or gate specific tools/resources per caller, or pass them straight through the pipeline.
27
- - **Dual transport**: serve over stdio (lightweight, process-local) or HTTP/SSE with mTLS, session limits, and SSE reconnect replay.
28
- - **ESM-first**: ships native ESM with first-class TypeScript types.
14
+ This is the core package.
15
+ For tamper-evident audit trails see [`@mcpose/audit`](https://www.npmjs.com/package/@mcpose/audit); for compliance assertions see [`@mcpose/testing`](https://www.npmjs.com/package/@mcpose/testing).
29
16
 
30
17
  ## Table of Contents
31
18
 
@@ -33,19 +20,47 @@ If you only need the audit chain or the compliance test helpers, see the ecosyst
33
20
  - [Features](#features)
34
21
  - [Install](#install)
35
22
  - [Quick start](#quick-start)
23
+ - [Serving over HTTP/SSE](#serving-over-httpsse)
24
+ - [Surviving a restart](#surviving-a-restart)
36
25
  - [Core concepts](#core-concepts)
37
26
  - [API surface](#api-surface)
27
+ - [Backend config (`BackendConfig`)](#backend-config-backendconfig)
28
+ - [Proxy options (`ProxyOptions`)](#proxy-options-proxyoptions)
29
+ - [HTTP options (`HttpProxyOptions`)](#http-options-httpproxyoptions)
30
+ - [Context and identity](#context-and-identity)
31
+ - [Rejection reasons](#rejection-reasons)
32
+ - [Test helpers: `mcpose/testing`](#test-helpers-mcposetesting)
38
33
  - [The mcpose ecosystem](#the-mcpose-ecosystem)
39
34
  - [Documentation](#documentation)
40
35
  - [License](#license)
41
36
 
37
+ ## When to reach for it
38
+
39
+ - Add cross-cutting behavior (logging, PII redaction, identity resolution, rate limiting) to an MCP server you do not own.
40
+ - Hide or gate specific tools and resources per caller, with a structured reason rather than an opaque error.
41
+ - Resolve a caller **identity** once per session and read it from every request in that session.
42
+ - Lay the foundation for compliance-grade audit trails with [`@mcpose/audit`](https://www.npmjs.com/package/@mcpose/audit).
43
+
44
+ If you own the single MCP server and need one hook in one place, a request handler in that server is simpler than a proxy hop.
45
+ mcpose earns its keep when the concern is cross-cutting, the server is not yours, or the behavior has to be tested independently of any one server.
46
+
47
+ ## Features
48
+
49
+ - **Transparent proxy**: wrap any upstream MCP server without modifying it.
50
+ - **Composable middleware pipeline** with a predictable onion model: each layer runs before *and* after the layers inside it.
51
+ - **Per-session identity resolution**: resolve a caller once, then read the same `Identity` from every request in the session.
52
+ - **Tool and resource governance**: hide tools, gate them per caller, or pass them straight through the pipeline.
53
+ - **Dual transport**: stdio (lightweight, process-local) or HTTP/SSE with mTLS, session limits, and SSE reconnect replay.
54
+ - **ESM-first**: native ESM with first-class TypeScript types, and no runtime dependencies beyond the MCP SDK.
55
+
42
56
  ## Install
43
57
 
44
58
  ```bash
45
- npm install mcpose
59
+ npm install mcpose @modelcontextprotocol/sdk
46
60
  ```
47
61
 
48
- Requires Node.js 18+. Ships ESM with TypeScript types.
62
+ Requires Node.js 20+.
63
+ `@modelcontextprotocol/sdk` (`^1.17.0`) is a peer dependency you install yourself.
49
64
 
50
65
  ## Quick start
51
66
 
@@ -55,7 +70,7 @@ Connect to an upstream over stdio, add one middleware, and serve the proxy:
55
70
  import { createBackendClient, startProxy } from 'mcpose';
56
71
  import type { ToolMiddleware } from 'mcpose';
57
72
 
58
- // 1. Connect to the upstream MCP server (stdio)
73
+ // 1. Connect to the upstream MCP server (stdio).
59
74
  const backend = await createBackendClient({
60
75
  command: 'node',
61
76
  args: ['/path/to/backend-server.mjs'],
@@ -69,13 +84,15 @@ const loggingMW: ToolMiddleware = async (req, next) => {
69
84
  return result;
70
85
  };
71
86
 
72
- // 3. Start the proxy on stdio
73
- await startProxy(backend, {
74
- toolMiddleware: [loggingMW],
75
- });
87
+ // 3. Start the proxy on stdio.
88
+ await startProxy(backend, { name: 'my-proxy', toolMiddleware: [loggingMW] });
76
89
  ```
77
90
 
78
- To serve over HTTP instead, with identity resolution, mTLS, session limits, and SSE reconnect replay, use `startHttpProxy`:
91
+ Point your MCP client at this process instead of the upstream, and every tool call flows through `loggingMW`.
92
+
93
+ ## Serving over HTTP/SSE
94
+
95
+ `startHttpProxy` adds per-session identity resolution, mTLS, session limits, and SSE reconnect replay:
79
96
 
80
97
  ```ts
81
98
  import { createBackendClient, startHttpProxy } from 'mcpose';
@@ -84,9 +101,9 @@ import { createBackendClient, startHttpProxy } from 'mcpose';
84
101
  // extractJwt: a resolveIdentity function returning an Identity from the request
85
102
  const backend = await createBackendClient({ url: 'http://localhost:9000/mcp' });
86
103
 
87
- await startHttpProxy(
104
+ const server = await startHttpProxy(
88
105
  backend,
89
- { toolMiddleware: [loggingMW] },
106
+ { name: 'my-proxy', toolMiddleware: [loggingMW] },
90
107
  {
91
108
  port: 3000,
92
109
  resolveIdentity: extractJwt, // stamped on every ProxyContext in the session
@@ -95,26 +112,92 @@ await startHttpProxy(
95
112
  );
96
113
  ```
97
114
 
115
+ The returned `http.Server` is already listening; call `.close()` to shut down.
116
+ Active proxy sessions are closed before the underlying server finishes closing.
117
+
118
+ Behavior worth knowing before you deploy it:
119
+
120
+ - **The proxy binds loopback by default.** `host` defaults to `127.0.0.1`, DNS-rebinding protection is on for loopback binds with `allowedHosts` / `allowedOrigins` derived from the effective bind address and real listening port, and a non-loopback bind without `resolveIdentity` reports a startup warning through `onError`.
121
+ See [ADR-0005](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0005-loopback-bind-by-default.md).
122
+ - **Session creation is restricted.** Only an `initialize` POST can create a session; a session-less GET or DELETE returns 400.
123
+ - **Sessions can be re-validated per request.** `validateSession(req, { sessionId, identity })` runs on every routed request; return `false` or throw for a 401.
124
+ A throw is reported through `onError` first, so a broken validator shows up as an error rather than as a wave of 401s: the same is true of `onRequest` and `resolveIdentity`.
125
+ Use it to bind a session to its original credential, so a leaked `mcp-session-id` alone cannot take a session over.
126
+ - **Credential headers never reach middleware.** `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, and `x-api-key` are stripped from `ProxyContext.headers`, and with that from anything middleware logs.
127
+ `resolveIdentity` reads the raw `http.IncomingMessage`, so it still sees them.
128
+ - **`onSessionClosed` fires on every session-end path**: client DELETE, TTL expiry, and server shutdown, so audit manifests flush in all three cases.
129
+ A returned promise is awaited, and `server.close()` does not complete until every session's hook has settled, so `await` the close before exiting the process.
130
+ A hook that throws or rejects is reported through `onError` and never breaks teardown.
131
+ - **Limit breaches are structured.** 503 (session limit) and 413 (body limit) responses carry `error.data.rejectionReason` set to `SESSION_LIMIT` / `BODY_LIMIT`.
132
+ - **SSE replay is scoped per stream and per session.** The in-memory store replays only events from the reconnecting stream; an unknown or already-evicted `Last-Event-ID` replays nothing.
133
+ Stream ids are namespaced as `<sessionId>:<streamId>` before they reach any `EventStore`, so one store shared by every session keeps their histories apart, and a `Last-Event-ID` presented from another session is rejected with a 400 rather than replayed.
134
+ - **Sessions live in the process unless you share them.** Without a `sessionRegistry`, a resume against a restarted proxy or another instance is a 404 no matter how durable the event store is, because the session id itself is unknown there.
135
+ With one, a record of every session is written before the initialize response goes out, and an `mcp-session-id` this instance does not hold is looked up and rebuilt from it.
136
+ See [Surviving a restart](#surviving-a-restart).
137
+
138
+ ### Surviving a restart
139
+
140
+ A durable `eventStore` keeps the replay history; a `sessionRegistry` keeps the session.
141
+ [`@mcpose/store-redis`](https://github.com/amir-gorji/mcpose/blob/main/packages/store-redis/README.md) and [`@mcpose/store-postgres`](https://github.com/amir-gorji/mcpose/blob/main/packages/store-postgres/README.md) ship both halves, and you want them from the same backing store:
142
+
143
+ ```ts
144
+ await startHttpProxy(backend, { name: 'my-proxy' }, {
145
+ eventStore: createRedisEventStore(redis),
146
+ sessionRegistry: createRedisSessionRegistry(redis),
147
+ });
148
+ ```
149
+
150
+ What a `SessionRegistry` holds is a `SessionRecord`: the client's `initialize` params verbatim, the `Identity` that `resolveIdentity` produced, and the deadline the session was given at creation.
151
+ The record is plain JSON, so an `Identity` whose `claims` do not survive a JSON round-trip will not come back the way it went in.
152
+ The rules that follow from that, per [ADR-0021](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0021-session-resume-replays-the-initialize.md):
153
+
154
+ - **A resumed session negotiates what the original did.** The instance that resumes it replays the stored `initialize` through the SDK's own request path, so protocol version, client capabilities and client info are what the client sent, and the SDK's Host and Origin checks run against the resuming request.
155
+ If the SDK refuses that replay, the client gets the SDK's answer, not a 404.
156
+ - **Identity is carried, not re-resolved.** `resolveIdentity` ran once, at initialize; `validateSession` still runs on every routed request, on the resumed instance too, so a leaked id is no easier to use after a restart.
157
+ - **The deadline is fixed at creation.** A resumed session gets what is left of its original `sessionTtlMs`, never a fresh one, so a session cannot outlive its bound by hopping between instances.
158
+ - **Client DELETE and TTL expiry delete the record; server shutdown keeps it.** A session that outlives the process is the point, and its deadline expires it on its own.
159
+ `onSessionClosed` fires on the shutting-down instance as before, and again on whichever instance finally closes the session.
160
+ - **`maxSessions` is per instance.** It bounds the memory each process holds, so a resume against a full instance is a 503 and the record stays for another one.
161
+ - **A registry failure fails closed.** An initialize whose record cannot be written is rejected (a 400 from the SDK, with the real error on `onError` and nothing about the backing store in the response), and a lookup that fails is a 500.
162
+ Sessions this instance already holds are never affected: the registry is read only for an id it does not know.
163
+
164
+ Two things a registry does not do.
165
+ It does not carry the audit chain: `@mcpose/audit` keeps its per-session state in memory, so the shutting-down instance seals its manifest and the resuming instance starts a new chain under the same session id.
166
+ And it does not make a fleet without sticky routing safe from double work: two instances can both hold a live copy of one session, and both will fire `onSessionClosed` for it.
167
+
98
168
  ## Core concepts
99
169
 
100
- - **Middleware**: a single function `(req, next, ctx) => Promise<result>`. Call `next(req)` to delegate downstream; transform the request before, or the response after. Middlewares nest onion-style.
101
- - **Pipeline**: middlewares passed to `ProxyOptions` run in response-processing order (first = innermost). `[piiMW, auditMW]` redacts before it audits.
102
- - **ProxyContext**: per-request metadata threaded through the pipeline: `requestId`, `transport`, `sessionId`, resolved `identity`, and the agent `delegatedFrom` chain.
170
+ - **Middleware**: a single function `(req, next, ctx) => Promise<result>`.
171
+ Call `next(req)` to delegate inward; transform the request before, or the response after.
172
+ Middlewares nest onion-style.
173
+ - **Pipeline**: middleware passed to `ProxyOptions` runs in **response-processing order**, so the first element processes the response first and is therefore the innermost layer.
174
+ `[piiMW, auditMW]` redacts before it audits.
175
+ Reversing that order fails silently: `[auditMW, piiMW]` fills the audit store with unredacted payloads while the client still receives redacted data; see [the array order rule](https://github.com/amir-gorji/mcpose#array-order-the-one-surprising-rule).
176
+ Note that `compose()` uses the opposite, outermost-first convention, so the two are not interchangeable.
177
+ See [ADR-0002](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0002-proxy-options-array-response-processing-order.md).
178
+ - **ProxyContext**: per-request metadata threaded through the pipeline: `requestId`, `transport`, `sessionId`, the resolved `identity`, the `delegatedFrom` delegation chain, and the `proxy` instance identity.
103
179
 
104
180
  ## API surface
105
181
 
106
182
  | Export | Purpose |
107
183
  |---|---|
108
184
  | `createBackendClient(config)` | Connect to an upstream over stdio (`command`/`args`) or HTTP (`url`). |
109
- | `startProxy(backend, options?)` | Serve the proxy over **stdio**. |
110
- | `startHttpProxy(backend, proxyOptions?, httpOptions?)` | Serve over **HTTP/SSE**: identity, mTLS, sessions, reconnect replay. |
111
- | `createProxyServer(backend, options?)` | Build the underlying `Server` without binding a transport. |
112
- | `compose(middlewares)` | Compose middlewares into one (outermost-first). |
113
- | `createProxyContext(overrides?)` | Construct a `ProxyContext` (useful in tests). |
185
+ | `startProxy(backends, options)` | Serve the proxy over **stdio**. Resolves when the transport closes. |
186
+ | `startHttpProxy(backends, proxyOptions, httpOptions?)` | Serve over **HTTP/SSE**: identity, mTLS, sessions, reconnect replay. Resolves with a listening `http.Server`. |
187
+ | `createProxyServer(backends, options)` | Build the underlying `Server` without binding a transport. Throws on a blank `options.name` or if a backend is not connected, so a mis-wired proxy fails at startup rather than on the first call. |
188
+ | `compose(middlewares)` | Compose middleware into one, **outermost-first**. |
189
+ | `markPassThroughObserver(mw)` | Return a new middleware that still runs for `passThroughTools`. For observers only, never transformers. |
190
+ | `rejectionMcpError(reason, code, message)` | Build an `McpError` carrying a `RejectionReason` in `error.data`. |
191
+ | `createProxyContext(overrides?)` | Construct a `ProxyContext`. Useful in tests. |
114
192
  | `createInMemoryEventStore()` | Default SSE reconnect event store; swap for a `PersistentEventStore`. |
115
- | `hasToolContent(result)` | Type guard for tool-call results. |
193
+ | `hasToolContent(result)` | Type guard narrowing `CompatibilityCallToolResult` to `CallToolResult`. |
194
+ | `mapToolResult(result, handlers)` | Map every payload channel of a tool result (text blocks, non-text blocks, `structuredContent`) through required handlers, so a redaction cannot silently miss one. |
195
+ | `dispatcherAwareBlock(options)` | `HiddenToolPredicate` blocking hidden tools both directly and through dispatcher (meta) tools. Fail-closed. |
196
+ | `sanitizeToolDescriptions(options?)` | `ListToolsMiddleware` stripping URLs and configured patterns from tool and schema descriptions, because the catalog is an egress channel into model context. |
197
+
198
+ **Key types:** `Middleware<Req, Res>`, `ToolMiddleware`, `ResourceMiddleware`, `PromptMiddleware`, `ListToolsMiddleware`, `ToolResultHandlers`, `ProxyContext`, `ProxyIdentity`, `Identity`, `BackendConfig`, `Backends`, `ProxyOptions`, `HttpProxyOptions`, `LocalTool`, `HiddenToolPredicate`, `DispatcherAwareBlockOptions`, `SanitizeToolDescriptionsOptions`, `RejectionReason`, `TelemetryEvent`, `ToolCallTelemetryEvent`, `BackendDegradedTelemetryEvent`, `PersistentEventStore`.
116
199
 
117
- **Key types:** `Middleware<Req, Res>`, `ToolMiddleware`, `ResourceMiddleware`, `ListToolsMiddleware`, `ProxyContext`, `Identity`, `BackendConfig`, `ProxyOptions`, `HttpProxyOptions`, `RejectionReason`, `TelemetryEvent`, `PersistentEventStore`.
200
+ `PersistentEventStore` is an alias of the SDK's `EventStore` type, so any SDK-compatible store plugs in directly.
118
201
 
119
202
  ### Backend config (`BackendConfig`)
120
203
 
@@ -122,14 +205,13 @@ await startHttpProxy(
122
205
 
123
206
  | Field | Mode | Description |
124
207
  |---|---|---|
125
- | `command` | stdio | Shell command to spawn the backend (e.g. `"node"`). |
126
- | `args` | stdio | Args passed to `command` (e.g. `["/path/to/server.mjs"]`). |
208
+ | `command` | stdio | Executable to spawn for the backend (e.g. `"node"`). |
209
+ | `args` | stdio | Arguments passed to `command` (e.g. `["/path/to/server.mjs"]`). |
127
210
  | `url` | HTTP/SSE | URL of a running backend. Takes precedence over stdio. |
128
- | `headers` | HTTP/SSE | Custom HTTP headers sent on every request to the backend. |
129
- | `authProvider` | HTTP/SSE | `OAuthClientProvider` for interactive/browser OAuth and transparent token refresh. |
211
+ | `headers` | HTTP/SSE | Custom headers sent on every request to the backend. Ignored in stdio mode. |
212
+ | `authProvider` | HTTP/SSE | `OAuthClientProvider` for interactive OAuth with transparent token refresh. Ignored in stdio mode. |
130
213
 
131
- `headers` is HTTP/SSE only and is ignored in stdio mode.
132
- Use it to authenticate with the upstream, for example an API key or bearer token.
214
+ Use `headers` to authenticate with the upstream using a static credential:
133
215
 
134
216
  ```ts
135
217
  const backend = await createBackendClient({
@@ -138,9 +220,8 @@ const backend = await createBackendClient({
138
220
  });
139
221
  ```
140
222
 
141
- For backends that require OAuth rather than a static token, pass an `authProvider`.
142
- mcpose forwards it to the HTTP/SSE transport, which runs the MCP OAuth flow (interactive/browser authorization with transparent token refresh), so you don't manage tokens yourself.
143
- Like `headers`, it is HTTP/SSE only and ignored in stdio mode.
223
+ For upstreams that require OAuth rather than a static token, pass an `authProvider`.
224
+ mcpose forwards it to the HTTP/SSE transport, which runs the MCP OAuth flow (browser authorization plus transparent refresh) so you do not manage tokens yourself.
144
225
 
145
226
  ```ts
146
227
  import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth';
@@ -149,39 +230,357 @@ const authProvider: OAuthClientProvider = {
149
230
  // redirectUrl, clientMetadata, and the token/authorization-code callbacks
150
231
  };
151
232
 
152
- const backend = await createBackendClient({
153
- url: 'https://mcp.example.com/sse',
154
- authProvider,
155
- });
233
+ const backend = await createBackendClient({ url: 'https://mcp.example.com/sse', authProvider });
234
+ ```
235
+
236
+ See [`oauth-upstream-client.ts`](https://github.com/amir-gorji/mcpose/blob/main/examples/oauth-upstream-client.ts) for a complete implementation.
237
+
238
+ ### Many upstreams: mesh mode (`Backends`)
239
+
240
+ `createProxyServer`, `startProxy`, and `startHttpProxy` all take a `Backends`: either a single `BackendClient`, which is the 1:1 proxy described everywhere else in this document, or a `Record<string, BackendClient>` of named backends, which is **mesh mode**.
241
+
242
+ ```ts
243
+ await startProxy(
244
+ {
245
+ crm: await createBackendClient({ url: 'https://crm.internal/mcp' }),
246
+ docs: await createBackendClient({ command: 'node', args: ['./docs-server.mjs'] }),
247
+ },
248
+ { name: 'my-proxy', hiddenTools: ['crm__delete_account'] },
249
+ );
250
+ ```
251
+
252
+ The record keys are **backend keys**, and a mesh exposes every upstream tool and prompt as `<backendKey>__<name>`: `crm__lookup`, `docs__search`.
253
+ A key must be non-empty and must not itself contain `__`, so the first separator in an exposed name always splits key from upstream name, even for an upstream name that contains one.
254
+ An empty record or an invalid key throws at `createProxyServer` (and at `startHttpProxy`, before the first session).
255
+
256
+ | Behaviour | Mesh mode |
257
+ |---|---|
258
+ | Routing | By the `<backendKey>__` prefix, resolved at the innermost `next`, so middleware can re-route deliberately. |
259
+ | Un-namespaced or unknown prefix | Rejected with `MethodNotFound` (`InvalidRequest` for a resource) and `BACKEND_UNROUTABLE`, thrown inside the pipeline so audit records it. There is no "resolve it if only one backend has that name" fallback. |
260
+ | Configuration | Global and matched against the **namespaced** name. `hiddenTools: ['crm__delete_account']` hides one tool on one upstream; a `HiddenToolPredicate` can hide a whole backend with `name.startsWith('crm__')`. |
261
+ | One backend down | `tools/list`, `prompts/list`, and `resources/list` return the live backends' entries and report a `backend_degraded` telemetry event naming the key. A call routed to a down backend fails only that call. |
262
+ | Audit | One session and one pipeline span the mesh. Backend attribution comes from the namespaced `tool` field plus `ProxyContext.proxy`. |
263
+ | Local tools | Not namespaced, because they belong to the proxy. A local tool named `crm__lookup` shadows the upstream tool exposed under that name. |
264
+ | Capabilities | The union across backends, plus the `localTools` rule. `listChanged` is advertised when any backend advertises it, and every backend's notification is forwarded. |
265
+ | Lists | Unpaginated: cursors are per-backend, so a mesh drains every backend and returns one complete page with no `nextCursor`. |
266
+ | Resources | Exposed as `mcpose://<backendKey>/<uri>`, the upstream URI appended verbatim, so two backends serving the same URI never collide. `resources/read` accepts only that form, strips the prefix, and forwards the original URI; anything else is `BACKEND_UNROUTABLE`. `hiddenResources` and `passThroughResources` match the exposed URI ([ADR-0022](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0022-mesh-resources-under-the-mcpose-scheme.md)). |
267
+
268
+ Degradation covers runtime failures, not startup: every backend in the record must already be connected, and `createProxyServer` throws and names the key of one that is not, exactly as it does for a single unconnected backend.
269
+ Without an `onTelemetry` sink a degraded mesh is invisible, so wire one when you run a mesh.
270
+ Backend keys are identifiers (`[A-Za-z0-9][A-Za-z0-9._-]*`, never containing `__`), because a key is spliced into tool names and into the authority of a resource URI.
271
+ See [ADR-0013](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0013-multi-backend-composition.md) and [ADR-0022](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0022-mesh-resources-under-the-mcpose-scheme.md).
272
+
273
+ ### Proxy options (`ProxyOptions`)
274
+
275
+ <details>
276
+ <summary>Show the <code>ProxyOptions</code> interface</summary>
277
+
278
+ ```ts
279
+ interface ProxyOptions {
280
+ name: string; // Required, non-blank
281
+ version?: string;
282
+ toolMiddleware?: ReadonlyArray<ToolMiddleware>;
283
+ resourceMiddleware?: ReadonlyArray<ResourceMiddleware>;
284
+ promptMiddleware?: ReadonlyArray<PromptMiddleware>;
285
+ listToolsMiddleware?: ReadonlyArray<ListToolsMiddleware>;
286
+ passThroughTools?: ReadonlyArray<string>;
287
+ passThroughResources?: ReadonlyArray<string>;
288
+ hiddenTools?: ReadonlyArray<string> | HiddenToolPredicate;
289
+ hiddenResources?: ReadonlyArray<string>;
290
+ localTools?: ReadonlyArray<LocalTool>;
291
+ stripRequestMeta?: boolean; // Default: true
292
+ stripResultMeta?: boolean; // Default: true
293
+ onTelemetry?: (event: TelemetryEvent) => unknown;
294
+ }
295
+
296
+ interface LocalTool {
297
+ tool: Tool; // advertised in tools/list
298
+ handler: (params: CallToolRequestParams, context: ProxyContext) => Promise<CallToolResult>;
299
+ }
300
+ ```
301
+
302
+ </details>
303
+
304
+ - `name` and `version` set the MCP server identity returned in `initialize`.
305
+ `name` is required and an empty or whitespace-only one throws at `createProxyServer` (and at `startHttpProxy`, before the first session).
306
+ It is also recorded as provenance on every `ProxyContext`, audit event, and `ReplayManifest` ([ADR-0012](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0012-proxy-identity-recorded-as-provenance.md)), so a default would record a whole fleet under one name and give a trail attribution in appearance only.
307
+ `version` keeps its default of the mcpose library version; set your own when you ship a proxy.
308
+ - `hiddenTools` / `hiddenResources` reject calls with a structured [`RejectionReason`](#rejection-reasons) in the MCP error `data` field.
309
+ The rejection is thrown *inside* the pipeline, so observing middleware such as audit records it; the upstream is never called.
310
+ - `hiddenTools` also accepts a `HiddenToolPredicate` (`(name, args) => boolean`), because a name array cannot see through a dispatcher (meta-tool) that takes the real tool name as an argument.
311
+ The predicate receives `undefined` args during list filtering and always an object at call time, so it can keep the dispatcher listed while failing closed on a malformed call.
312
+ `dispatcherAwareBlock({ tools, dispatchers, argPath })` implements the common case and blocks whenever the target name is missing or is not a string.
313
+ See [ADR-0006](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0006-hidden-tools-accept-a-predicate.md).
314
+ - `sanitizeToolDescriptions({ patterns?, replacement? })` builds a `ListToolsMiddleware` that strips http(s) URLs, plus any configured literal strings or regexes, from tool descriptions and from `description` fields nested in `inputSchema` / `outputSchema`, because the catalog is forwarded verbatim into model context and real upstreams leak org slugs and internal hostnames there.
315
+ Names, titles, and schema structure stay intact because clients route on them; place it last in `listToolsMiddleware` so it sanitizes the output of other list middleware and local tools.
316
+ See [ADR-0010](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0010-tool-catalog-egress-sanitizer.md).
317
+ - `promptMiddleware` runs around every `prompts/get`, in both 1:1 and mesh mode, in the same response-processing order as the other middleware arrays.
318
+ Mesh routing and the `BACKEND_UNROUTABLE` rejection for an unroutable prompt name happen inside the innermost `next`, so an observing middleware such as `@mcpose/audit`'s `promptMiddleware` records every prompt call and every rejection.
319
+ `prompts/list` has no pipeline yet, so there is no prompt equivalent of `hiddenTools` or `passThroughTools`.
320
+ See [ADR-0014](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0014-prompt-calls-run-a-pipeline-and-are-audited.md).
321
+ - `passThroughTools` / `passThroughResources` skip transforming middleware, but middleware wrapped in `markPassThroughObserver()` still runs for them.
322
+ A tool that is both hidden and pass-through stays hidden.
323
+ - `localTools` are tools the proxy implements itself.
324
+ They appear in `tools/list` (first page only, so pagination does not duplicate them), route to their handler instead of the upstream, and still run through the full `toolMiddleware` pipeline, so audit and redaction apply to them.
325
+ Precedence: `hiddenTools` beats a local tool, a local tool beats (and shadows) an upstream tool of the same name, and `passThroughTools` does not apply to local tools.
326
+ The proxy advertises the `tools` capability when `localTools` is non-empty even if the upstream has none, and a duplicate local tool name throws at `createProxyServer`.
327
+ A handler runs with the proxy's own credentials, so attribute any outbound calls it makes via `outboundDelegationChain(context)`, and bound a call with `AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)].filter((s) => s !== undefined))` inside the handler.
328
+ See [ADR-0007](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0007-local-tools-run-the-full-pipeline.md) and [ADR-0011](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0011-proxy-originated-call-attribution.md).
329
+ - `stripRequestMeta` (default `true`) removes `params._meta` from every forwarded request, tool calls, resource reads, list and prompt calls alike, because MCP clients put correlation identifiers there (VS Code sends `progressToken`, a W3C `traceparent`, and `vscode/conversationId`) and the upstream is frequently a third party.
330
+ The strip happens at the proxy boundary before the pipeline, so middleware can still add `_meta` deliberately, and it applies to pass-through tools too; disable only globally with `stripRequestMeta: false`.
331
+ Progress relay is unaffected: the proxy reads the client's progress token from the request `extra`, and the SDK client stamps its own token on the upstream request.
332
+ See [ADR-0008](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0008-strip-request-meta.md).
333
+ - `stripResultMeta` (default `true`) is the response-direction mirror: top-level `_meta` is removed from every result returned from the upstream, because upstreams stamp correlation identifiers there (the SDK stamps `io.modelcontextprotocol/related-task`) and the client is a third party to them.
334
+ The strip happens at the upstream boundary inside the innermost `next`, so middleware sees the stripped result and middleware-added `_meta` still reaches the client.
335
+ Local tool results and nested `_meta` (per-tool in `tools[]`, per-block in `content`) are untouched; disable only globally with `stripResultMeta: false`.
336
+ See [ADR-0009](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0009-strip-result-meta.md).
337
+ - `onTelemetry` receives a `TelemetryEvent`, a union discriminated on `type`.
338
+ A `'tool_call'` event fires after every tool call with timing, outcome, tool name, and identity; results with `isError: true` are reported as outcome `'error'`.
339
+ A `'backend_degraded'` event fires when one backend of a mesh drops out of a list call (`tools/list`, `prompts/list`, or `resources/list`), naming the backend key, the method, and the error.
340
+ Both variants carry an optional `proxy: ProxyIdentity`, the same frozen `{ name, version }` stamped on `ProxyContext` (ADR-0012), so a fleet of proxies feeding one telemetry sink can attribute events to an instance.
341
+ A sink that throws, or whose returned promise rejects, is logged but never fails the call; the sink is not awaited, so a slow one does not delay it either.
342
+ [`@mcpose/otel`](https://github.com/amir-gorji/mcpose/blob/main/packages/otel/README.md) maps both variants onto OpenTelemetry spans if you do not want to write the sink yourself.
343
+
344
+ ### HTTP options (`HttpProxyOptions`)
345
+
346
+ <details>
347
+ <summary>Show the <code>HttpProxyOptions</code> interface and <code>startHttpProxy()</code> signature</summary>
348
+
349
+ ```ts
350
+ interface HttpProxyOptions {
351
+ port?: number; // Default: 3000
352
+ host?: string; // Default: '127.0.0.1' (loopback); non-loopback is a deliberate opt-in
353
+ path?: string; // Default: '/mcp'
354
+ onRequest?: (req: http.IncomingMessage, res: http.ServerResponse) => boolean | Promise<boolean>;
355
+ onError?: (err: unknown) => unknown;
356
+ maxBodyBytes?: number; // Default: 4 MB (4,194,304); excess returns 413; Infinity opts out
357
+ maxSessions?: number; // Default: 1000; excess requests return 503; Infinity opts out
358
+ sessionTtlMs?: number; // Default: 30 minutes (1,800,000); max finite 2,147,483,647; Infinity opts out
359
+ /** Resolves caller identity once per session. A throw reports through onError and aborts the session with 401. */
360
+ resolveIdentity?: (req: http.IncomingMessage) => Identity | Promise<Identity>;
361
+ /** Re-validates an existing session on every routed request. Return false, or throw (reported through onError), for a 401. */
362
+ validateSession?: (
363
+ req: http.IncomingMessage,
364
+ session: { sessionId: string; identity?: Identity },
365
+ ) => boolean | Promise<boolean>;
366
+ /** mTLS: Node's https.ServerOptions (key, cert, ca, requestCert, rejectUnauthorized). */
367
+ tlsOptions?: https.ServerOptions;
368
+ /** SSE reconnect replay store. Defaults to in-memory. Pass null to disable. */
369
+ eventStore?: PersistentEventStore | null;
370
+ /** Shared record of live sessions, so a resume survives a restart or reaches another instance. */
371
+ sessionRegistry?: SessionRegistry;
372
+ /** Called when a session closes: client DELETE, TTL expiry, or server shutdown. A returned promise is awaited. */
373
+ onSessionClosed?: (sessionId: string) => unknown;
374
+ /** Hosts allowed in the Host header. Default: derived from the bind address and real port on loopback. */
375
+ allowedHosts?: string[];
376
+ /** Origins allowed in the Origin header. Default: derived, matching allowedHosts. */
377
+ allowedOrigins?: string[];
378
+ /** SDK Host/Origin checks. Default: true on a loopback bind, false otherwise. */
379
+ enableDnsRebindingProtection?: boolean;
380
+ }
381
+
382
+ function startHttpProxy(
383
+ backend: BackendClient,
384
+ options: ProxyOptions,
385
+ httpOptions?: HttpProxyOptions,
386
+ ): Promise<http.Server>;
387
+ ```
388
+
389
+ </details>
390
+
391
+ `httpOptions` is meaningful only for HTTP/SSE transport; omit it when serving over stdio.
392
+
393
+ **Session lifecycle is bounded by default.**
394
+ `maxSessions` defaults to 1000 and `sessionTtlMs` defaults to 30 minutes, the same safe-by-default posture `maxBodyBytes` already had at 4 MB.
395
+ Unlimited sessions that never expire are a memory exhaustion path open to any client that can initialize, and each live session also pins whatever per-session state a host keeps beside it.
396
+ Both are overridable, and `Infinity` is the explicit opt-out for either.
397
+ `maxSessions: 0` is not an opt-out: it already means "reject every session", so it keeps that meaning.
398
+ A `maxSessions` below zero, a `sessionTtlMs` of zero or below, or a `maxBodyBytes` that is below zero or is not a number at all, throws at startup rather than quietly enforcing something other than what the option reads as.
399
+ So does a finite `sessionTtlMs` above 2,147,483,647 ms (about 24.8 days), Node's maximum timer delay: `setTimeout` would otherwise clamp it to 1 ms and expire every session immediately, so anything longer must use `Infinity`.
400
+ A `maxBodyBytes` of `NaN`, which is what `Number(process.env.MAX_BODY_BYTES)` gives for an unset key, compares false against every body size, so mcpose reads and serves a body far larger than the cap it was configured with instead of answering 413.
401
+ `maxBodyBytes: 0` and `maxBodyBytes: Infinity` keep their literal meanings: reject every POST body, and no cap.
402
+
403
+ This is a behavior change: both options used to be opt-in, so a proxy started with defaults accepted unlimited sessions that never expired.
404
+ A long-lived deployment that relied on that must now pass `sessionTtlMs: Infinity`, `maxSessions: Infinity`, or values that match its own limits.
405
+ TTL expiry fires `onSessionClosed`, so a host holding per-session state should wire that hook rather than assume sessions only end on a client DELETE.
406
+ `allowedHosts`, `allowedOrigins`, and `enableDnsRebindingProtection` are forwarded to the SDK transport, which only validates against a non-empty list; that is why mcpose derives enforcing defaults for a loopback bind instead of shipping an inert flag.
407
+ An explicit `allowedHosts` or `allowedOrigins` is used verbatim and never merged with the derived list.
408
+ See [ADR-0005](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0005-loopback-bind-by-default.md) and the network posture section of [`SECURITY.md`](https://github.com/amir-gorji/mcpose/blob/main/SECURITY.md).
409
+ The behavioral notes in [Serving over HTTP/SSE](#serving-over-httpsse) apply to all of these.
410
+
411
+ ### Context and identity
412
+
413
+ Every middleware receives a normalized `ProxyContext` as its third argument.
414
+
415
+ <details>
416
+ <summary>Show <code>ProxyContext</code>, <code>Identity</code>, and the middleware types</summary>
417
+
418
+ ```ts
419
+ interface ProxyContext {
420
+ requestId: string;
421
+ transport: 'stdio' | 'http';
422
+ sessionId?: string;
423
+ headers?: Readonly<Record<string, string>>;
424
+ signal?: AbortSignal;
425
+ /** Resolved caller identity. Present when resolveIdentity is configured. */
426
+ identity?: Identity;
427
+ /** Agent delegation chain, oldest-first. Extracted from the inbound request, or stamped by the host. */
428
+ delegatedFrom?: Identity[];
429
+ /** The proxy instance handling this request. Stamped by createProxyServer. */
430
+ proxy?: ProxyIdentity;
431
+ /** The policy decision for this call, stamped by @mcpose/policy. */
432
+ policy?: PolicyDecision;
433
+ }
434
+
435
+ interface PolicyDecision {
436
+ decision: 'allow' | 'deny';
437
+ ruleId?: string; // the rule that produced the decision, when one did
438
+ reason?: string; // the RejectionReason accompanying a denial
439
+ }
440
+
441
+ interface ProxyIdentity {
442
+ name: string; // ProxyOptions.name, required and non-blank
443
+ version: string; // ProxyOptions.version, defaulted to the mcpose library version
444
+ }
445
+
446
+ interface Identity {
447
+ sub: string;
448
+ type: 'human' | 'agent' | 'service';
449
+ displayName?: string;
450
+ roles: string[];
451
+ claims: Record<string, unknown>;
452
+ resolvedAt: string; // ISO 8601
453
+ source: 'jwt' | 'mtls' | 'apikey' | 'custom';
454
+ }
455
+
456
+ type Middleware<Req, Res> = (
457
+ req: Req,
458
+ next: (req: Req) => Promise<Res>,
459
+ context: ProxyContext,
460
+ ) => Promise<Res>;
461
+
462
+ type ToolMiddleware = Middleware<CallToolRequest, CompatibilityCallToolResult>;
463
+ type ResourceMiddleware = Middleware<ReadResourceRequest, ReadResourceResult>;
464
+ type PromptMiddleware = Middleware<GetPromptRequest, GetPromptResult>;
465
+ type ListToolsMiddleware = Middleware<ListToolsRequest, ListToolsResult>;
466
+ ```
467
+
468
+ </details>
469
+
470
+ `sessionId` is present on HTTP transport only.
471
+ Over stdio there is no session concept, which is why `@mcpose/audit` produces no `ReplayManifest` there.
472
+
473
+ `proxy` records which proxy instance handled the request, resolved from `ProxyOptions.name` and `version` including their defaults.
474
+ It is provenance, not a principal: it never appears in `delegatedFrom` and takes no part in the caller-attribution model, per [ADR-0012](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0012-proxy-identity-recorded-as-provenance.md).
475
+
476
+ `policy` holds a decision already made, not a handle to query.
477
+ Core never writes it: [`@mcpose/policy`](https://www.npmjs.com/package/@mcpose/policy) stamps it before calling `next` or throwing, so middleware running inside the policy layer can rely on `decision: 'allow'`, per [ADR-0017](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0017-policy-engine-contract.md).
478
+ It was published as `policy?: never` through v2 and widened to `PolicyDecision` in v3, a breaking change for anyone who narrowed it.
479
+
480
+ ### The delegation chain
481
+
482
+ `delegatedFrom` records agent-to-agent handoffs, oldest-first.
483
+ Core populates it from the inbound request: a caller presents its chain at `params._meta["mcpose/delegation"]`, read at the proxy boundary before the request-`_meta` strip so it survives a chained proxy, per [ADR-0016](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0016-delegation-chain-wire-format.md).
484
+ A chain your host application stamps on the context takes precedence: core fills the field only when the host left it unset.
485
+
486
+ ```json
487
+ { "v": 1, "chain": [{ "sub": "agent-a", "type": "agent" }] }
156
488
  ```
157
489
 
158
- ### Governance options (`ProxyOptions`)
490
+ Each entry carries `sub` (required, non-empty string) and `type` (`human` | `agent` | `service`), plus optional `displayName`, `resolvedAt`, and `source`.
491
+
492
+ **The chain is attribution, never authorization.**
493
+ Extraction produces an `Identity` with `roles: []` and `claims: {}` for every entry, unconditionally, and ignores any it finds on the wire.
494
+ A presented chain is written by the previous hop, so nothing in it may grant a privilege; authorization comes only from the identity this proxy resolves itself through `resolveIdentity`.
495
+ Entries are not signed: a proxy trusts a presented chain exactly as much as it trusts the immediate caller it authenticated, who vouches for the history it forwards.
496
+
497
+ A malformed payload rejects the call with `DELEGATION_INVALID` (`InvalidRequest`), thrown inside the pipeline so audit middleware records the attempt.
498
+ Malformed means an unknown `v`, a `chain` that is not an array, more than 32 entries, an entry that is not an object, a missing or empty or non-string `sub`, or a `type` outside the union.
499
+ An absent payload is not an error and leaves `delegatedFrom` unset.
500
+
501
+ A chain that loops back through this proxy rejects with `DELEGATION_INVALID` too.
502
+ A chain loops when the `sub` of the identity the proxy resolved for the caller already appears in the chain that same caller presents: the authenticated caller is a link in the history it hands over, so the call has cycled.
503
+ The check runs after identity resolution and inside the pipeline, so the rejection is audited against the identity that tripped it.
504
+ With no resolved identity there is nothing to look for, which is the stdio case: only the structural validation above applies there.
505
+
506
+ What loop detection does not cover: core sees only the calls it forwards itself.
507
+ A cycle that closes through a local tool handler's own outbound call is invisible to it, because that call is host code that may not be MCP at all, which is why [ADR-0011](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0011-proxy-originated-call-attribution.md) rejected a hop counter and why the 32-entry cap is a parse bound rather than a depth policy.
508
+ A deployment that legitimately re-enters the same proxy has no opt-out: re-entry under the same resolved identity is the cycle, and a switch to allow it would make the control decorative.
509
+ Resolve a distinct identity for the re-entering hop, or do not route the call back through the proxy it already passed.
510
+
511
+ For the outbound direction, `outboundDelegationChain(context)` returns the oldest-first chain (`delegatedFrom` plus the current identity), per [ADR-0011](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0011-proxy-originated-call-attribution.md).
512
+ Core attaches it to every call it forwards to a backend, so the backend sees the chain the proxy can vouch for and never the caller's own copy, which the strip removed.
513
+ A local tool handler calls out through your code, so your host attaches it: for an outbound MCP call, `serializeDelegationChain(outboundDelegationChain(context))` placed at `_meta[DELEGATION_META_KEY]` is exactly what core sends.
514
+ The serializer drops `roles` and `claims` too, so a chain never leaves this proxy carrying privileges.
159
515
 
160
- `hiddenTools` / `hiddenResources` reject calls with a structured [`RejectionReason`](https://github.com/amir-gorji/mcpose#rejectionreason) in the MCP error `data` field; `passThroughTools` skip the pipeline entirely; `onTelemetry` emits per-call timing and outcome.
516
+ Loop detection and the `@mcpose/testing` continuity assertion follow in [#125](https://github.com/amir-gorji/mcpose/issues/125) and [#126](https://github.com/amir-gorji/mcpose/issues/126).
517
+
518
+ ### Rejection reasons
519
+
520
+ Every blocked call embeds a `RejectionReason` in the MCP error `data` field.
521
+ The top-level error code is unchanged, so clients that only inspect the code are unaffected, while audit middleware and agents can branch on `error.data.rejectionReason`.
522
+
523
+ Use `rejectionMcpError(reason, code, message)` to reject from your own middleware in the same structured shape the proxy uses.
524
+ A `data.rejectionReason` outside the union is not a rejection: `onTelemetry` and the audit middleware both report that call as an ordinary `error`.
525
+
526
+ <details>
527
+ <summary>Show the <code>RejectionReason</code> union</summary>
528
+
529
+ ```ts
530
+ type RejectionReason =
531
+ | 'TOOL_HIDDEN' // tool exists but is hidden from this caller
532
+ | 'RESOURCE_HIDDEN' // resource exists but is hidden from this caller
533
+ | 'BACKEND_UNROUTABLE' // mesh mode: the name or URI prefix names no configured backend
534
+ | 'POLICY_DENIED' // @mcpose/policy: a rule denied, or no rule allowed
535
+ | 'IDENTITY_UNRESOLVED' // identity could not be established
536
+ | 'CONSENT_MISSING' // v3: GDPR/CCPA consent gate blocked the call
537
+ | 'SENSITIVITY_BLOCKED' // @mcpose/policy: a sensitivity-tier rule blocked the call
538
+ | 'DELEGATION_INVALID' // the presented delegation chain is malformed
539
+ | 'BUDGET_EXCEEDED' // @mcpose/policy: the per-session call budget is exhausted
540
+ | 'SESSION_LIMIT' // max concurrent sessions reached (HTTP 503)
541
+ | 'BODY_LIMIT'; // request body exceeded maxBodyBytes (HTTP 413)
542
+ ```
543
+
544
+ </details>
545
+
546
+ `POLICY_DENIED`, `SENSITIVITY_BLOCKED`, and `BUDGET_EXCEEDED` are emitted by [`@mcpose/policy`](https://www.npmjs.com/package/@mcpose/policy), which is also the first emitter of `IDENTITY_UNRESOLVED` without owning it ([ADR-0017](https://github.com/amir-gorji/mcpose/blob/main/docs/adr/0017-policy-engine-contract.md)).
547
+ The values still marked v3 are reserved: the union is declared ahead of the middleware that will emit them so that `error.data` consumers written today keep compiling.
161
548
 
162
549
  ### Test helpers: `mcpose/testing`
163
550
 
164
- The core package exposes proxy/middleware test utilities under a subpath:
551
+ The core package exposes proxy and middleware test utilities under a subpath:
165
552
 
166
553
  ```ts
167
- import { createMockBackendClient, runToolMiddleware } from 'mcpose/testing';
554
+ import {
555
+ createMockBackendClient,
556
+ runToolMiddleware,
557
+ runListToolsMiddleware,
558
+ runResourceMiddleware,
559
+ } from 'mcpose/testing';
168
560
  ```
169
561
 
170
- > **Not to be confused with** [`@mcpose/testing`](https://www.npmjs.com/package/@mcpose/testing), the separate package of compliance-chain assertions.
562
+ `createMockBackendClient()` returns an in-memory backend stub with capability lookup and notification hooks.
563
+ It works with both `createProxyServer()` and `startHttpProxy()` tests, so an example or test suite needs no real upstream.
564
+ `runToolMiddleware()`, `runListToolsMiddleware()`, and `runResourceMiddleware()` drive a single middleware in isolation; each takes the middleware, the request, a `next`, and an optional `ProxyContext` that defaults to a fresh `createProxyContext()`, so tests never pass `undefined as never` for the context argument.
565
+ `runToolMiddleware()` additionally narrows away the legacy `{ toolResult }` shape; the list and resource results have no legacy variant, so the other two have nothing to narrow.
566
+
567
+ > **Not to be confused with** [`@mcpose/testing`](https://www.npmjs.com/package/@mcpose/testing), a separate package of compliance assertions over audit chains.
568
+ > This subpath mocks the proxy; that package verifies the audit trail.
171
569
 
172
570
  ## The mcpose ecosystem
173
571
 
174
572
  | Package | What it adds |
175
573
  |---|---|
176
574
  | **`mcpose`** (this package) | Proxy core: pipeline, transports, identity, governance. |
177
- | [`@mcpose/audit`](https://www.npmjs.com/package/@mcpose/audit) | Tamper-evident, HMAC-chained audit events + Merkle `ReplayManifest`. |
178
- | [`@mcpose/testing`](https://www.npmjs.com/package/@mcpose/testing) | Runner-agnostic compliance assertions for the audit chain. |
575
+ | [`@mcpose/audit`](https://www.npmjs.com/package/@mcpose/audit) | Tamper-evident HMAC-chained audit events and a signed Merkle `ReplayManifest`. |
576
+ | [`@mcpose/testing`](https://www.npmjs.com/package/@mcpose/testing) | Runner-agnostic compliance assertions over an audit trail. |
179
577
 
180
578
  ## Documentation
181
579
 
182
- - [Full README & API reference](https://github.com/amir-gorji/mcpose#readme)
183
- - [CONTEXT.md](https://github.com/amir-gorji/mcpose/blob/main/CONTEXT.md): canonical domain glossary
580
+ - [Project README](https://github.com/amir-gorji/mcpose#readme): concepts, comparison, guides, and examples
581
+ - [`CONTEXT.md`](https://github.com/amir-gorji/mcpose/blob/main/CONTEXT.md): the canonical domain glossary
184
582
  - [Architecture decision records](https://github.com/amir-gorji/mcpose/tree/main/docs/adr)
583
+ - [Runnable examples](https://github.com/amir-gorji/mcpose/tree/main/examples)
185
584
 
186
585
  ## License
187
586