mcpose 2.0.2 → 3.0.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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -37
  3. package/dist/backendClient.d.ts +5 -0
  4. package/dist/backendClient.d.ts.map +1 -1
  5. package/dist/backendClient.js +17 -9
  6. package/dist/backendClient.js.map +1 -1
  7. package/dist/core.d.ts +293 -28
  8. package/dist/core.d.ts.map +1 -1
  9. package/dist/core.js +969 -175
  10. package/dist/core.js.map +1 -1
  11. package/dist/delegation.d.ts +89 -0
  12. package/dist/delegation.d.ts.map +1 -0
  13. package/dist/delegation.js +175 -0
  14. package/dist/delegation.js.map +1 -0
  15. package/dist/eventStore.d.ts +12 -0
  16. package/dist/eventStore.d.ts.map +1 -1
  17. package/dist/eventStore.js +43 -5
  18. package/dist/eventStore.js.map +1 -1
  19. package/dist/hiddenTools.d.ts +42 -0
  20. package/dist/hiddenTools.d.ts.map +1 -0
  21. package/dist/hiddenTools.js +53 -0
  22. package/dist/hiddenTools.js.map +1 -0
  23. package/dist/index.d.ts +15 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/mesh.d.ts +82 -0
  28. package/dist/mesh.d.ts.map +1 -0
  29. package/dist/mesh.js +141 -0
  30. package/dist/mesh.js.map +1 -0
  31. package/dist/middleware.d.ts +13 -0
  32. package/dist/middleware.d.ts.map +1 -1
  33. package/dist/middleware.js +27 -3
  34. package/dist/middleware.js.map +1 -1
  35. package/dist/proxyContext.d.ts +65 -5
  36. package/dist/proxyContext.d.ts.map +1 -1
  37. package/dist/proxyContext.js +39 -4
  38. package/dist/proxyContext.js.map +1 -1
  39. package/dist/rejection.d.ts +1 -1
  40. package/dist/rejection.d.ts.map +1 -1
  41. package/dist/rejection.js +1 -1
  42. package/dist/rejection.js.map +1 -1
  43. package/dist/sanitizeCatalog.d.ts +37 -0
  44. package/dist/sanitizeCatalog.d.ts.map +1 -0
  45. package/dist/sanitizeCatalog.js +86 -0
  46. package/dist/sanitizeCatalog.js.map +1 -0
  47. package/dist/sessionRegistry.d.ts +79 -0
  48. package/dist/sessionRegistry.d.ts.map +1 -0
  49. package/dist/sessionRegistry.js +83 -0
  50. package/dist/sessionRegistry.js.map +1 -0
  51. package/dist/telemetry.d.ts +29 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/testing.d.ts +18 -2
  54. package/dist/testing.d.ts.map +1 -1
  55. package/dist/testing.js +30 -8
  56. package/dist/testing.js.map +1 -1
  57. package/dist/version.d.ts +7 -0
  58. package/dist/version.d.ts.map +1 -0
  59. package/dist/version.js +7 -0
  60. package/dist/version.js.map +1 -0
  61. package/package.json +30 -14
  62. package/src/__tests__/_helpers.ts +64 -0
  63. package/src/__tests__/backendClient.test.ts +198 -0
  64. package/src/__tests__/core.delegation.test.ts +964 -0
  65. package/src/__tests__/core.edge.test.ts +314 -0
  66. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  67. package/src/__tests__/core.localTools.test.ts +314 -0
  68. package/src/__tests__/core.mesh.test.ts +1182 -0
  69. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  70. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  71. package/src/__tests__/core.test.ts +1019 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/mapToolResult.test.ts +101 -0
  74. package/src/__tests__/middleware.edge.test.ts +126 -0
  75. package/src/__tests__/middleware.test.ts +233 -0
  76. package/src/__tests__/proxyContext.test.ts +140 -0
  77. package/src/__tests__/sanitizeCatalog.test.ts +359 -0
  78. package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -0
  79. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  80. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  81. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  82. package/src/__tests__/startHttpProxy.test.ts +985 -0
  83. package/src/__tests__/testing.test.ts +250 -0
  84. package/src/__tests__/version.test.ts +12 -0
  85. package/src/backendClient.ts +83 -0
  86. package/src/core.ts +2042 -0
  87. package/src/delegation.ts +224 -0
  88. package/src/eventStore.ts +106 -0
  89. package/src/hiddenTools.ts +84 -0
  90. package/src/identity.ts +15 -0
  91. package/src/index.ts +63 -0
  92. package/src/mesh.ts +204 -0
  93. package/src/middleware.ts +101 -0
  94. package/src/proxyContext.ts +108 -0
  95. package/src/rejection.ts +37 -0
  96. package/src/sanitizeCatalog.ts +120 -0
  97. package/src/sessionRegistry.ts +142 -0
  98. package/src/telemetry.ts +46 -0
  99. package/src/testing.ts +174 -0
  100. package/src/version.ts +6 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The delegation chain wire format: how a chain crosses the wire in
3
+ * `params._meta` and how it is read back (ADR-0016).
4
+ *
5
+ * The chain is attribution, never authorization. A presented chain is
6
+ * written by the previous hop and is attacker-influenced at the boundary,
7
+ * so `roles` and `claims` never travel on the wire and are never taken
8
+ * from one: extraction produces identities with empty roles and claims,
9
+ * unconditionally.
10
+ */
11
+ import { ErrorCode, type McpError } from '@modelcontextprotocol/sdk/types.js';
12
+ import type { Identity } from './identity.js';
13
+ import { outboundDelegationChain, type ProxyContext } from './proxyContext.js';
14
+ import { rejectionMcpError } from './rejection.js';
15
+
16
+ /**
17
+ * The `params._meta` key carrying the delegation chain. Exported so a host
18
+ * attaching {@link outboundDelegationChain} to its own outbound MCP calls
19
+ * uses the same key core does.
20
+ */
21
+ // A public wire constant, not a credential: the trailing allow keeps
22
+ // gitleaks' generic-api-key rule from matching the `_KEY` name.
23
+ export const DELEGATION_META_KEY = 'mcpose/delegation'; // gitleaks:allow
24
+
25
+ /** The only wire version this proxy parses or emits. */
26
+ const WIRE_VERSION = 1;
27
+
28
+ /**
29
+ * Parse bound on a presented chain, not the hop-count policy knob ADR-0011
30
+ * rejected: it constrains what this proxy will parse, not what host
31
+ * outbound code does.
32
+ */
33
+ const MAX_CHAIN_ENTRIES = 32;
34
+
35
+ const IDENTITY_TYPES = ['human', 'agent', 'service'] as const;
36
+ const IDENTITY_SOURCES = ['jwt', 'mtls', 'apikey', 'custom'] as const;
37
+
38
+ const ISO_8601 =
39
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
40
+
41
+ /** One entry of the wire chain: no `roles`, no `claims`, ever. */
42
+ export interface DelegationWireEntry {
43
+ sub: string;
44
+ type: Identity['type'];
45
+ displayName?: string;
46
+ resolvedAt?: string;
47
+ source?: Identity['source'];
48
+ }
49
+
50
+ /** The versioned payload carried at `params._meta["mcpose/delegation"]`. */
51
+ export interface DelegationWirePayload {
52
+ v: number;
53
+ chain: DelegationWireEntry[];
54
+ }
55
+
56
+ /** What reading the inbound payload produced. Both fields absent means "no chain". */
57
+ export interface InboundDelegation {
58
+ /** The presented chain, oldest-first. Absent when there was none. */
59
+ chain?: Identity[];
60
+ /**
61
+ * The rejection a malformed payload earned. Thrown inside the pipeline by
62
+ * the caller, so observing middleware records the attempt.
63
+ */
64
+ error?: McpError;
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function isIsoTimestamp(value: unknown): value is string {
72
+ return (
73
+ typeof value === 'string' &&
74
+ ISO_8601.test(value) &&
75
+ !Number.isNaN(Date.parse(value))
76
+ );
77
+ }
78
+
79
+ function rejection(detail: string): McpError {
80
+ return rejectionMcpError(
81
+ 'DELEGATION_INVALID',
82
+ ErrorCode.InvalidRequest,
83
+ `Invalid delegation chain: ${detail}`,
84
+ );
85
+ }
86
+
87
+ function invalid(detail: string): InboundDelegation {
88
+ return { error: rejection(detail) };
89
+ }
90
+
91
+ /**
92
+ * Serializes a chain to its wire payload, dropping `roles` and `claims`.
93
+ *
94
+ * Exported for hosts that attach {@link outboundDelegationChain} to their own
95
+ * outbound MCP calls from a local tool handler, which ADR-0011 leaves to the
96
+ * host because outbound host code may not be MCP at all.
97
+ */
98
+ export function serializeDelegationChain(
99
+ chain: ReadonlyArray<Identity>,
100
+ ): DelegationWirePayload {
101
+ return {
102
+ v: WIRE_VERSION,
103
+ chain: chain.map((identity) => ({
104
+ sub: identity.sub,
105
+ type: identity.type,
106
+ ...(identity.displayName === undefined
107
+ ? {}
108
+ : { displayName: identity.displayName }),
109
+ resolvedAt: identity.resolvedAt,
110
+ source: identity.source,
111
+ })),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Reads the chain from a raw request, before the ADR-0008 request-`_meta`
117
+ * strip runs: read first, strip second, or the marker never survives a
118
+ * chained proxy (the ADR-0011 boundary-ordering constraint).
119
+ *
120
+ * An absent payload is not an error. A present-but-malformed one is, and is
121
+ * returned rather than thrown so the caller can throw it inside the pipeline.
122
+ */
123
+ export function readInboundDelegation(req: {
124
+ params?: { _meta?: unknown } | undefined;
125
+ }): InboundDelegation {
126
+ const meta = req.params?._meta;
127
+ const payload = isRecord(meta) ? meta[DELEGATION_META_KEY] : undefined;
128
+ if (payload === undefined) return {};
129
+ if (!isRecord(payload)) return invalid('payload is not an object');
130
+ if (payload.v !== WIRE_VERSION) {
131
+ return invalid(`unsupported version ${JSON.stringify(payload.v)}`);
132
+ }
133
+ const wireChain = payload.chain;
134
+ if (!Array.isArray(wireChain)) return invalid('chain is not an array');
135
+ if (wireChain.length > MAX_CHAIN_ENTRIES) {
136
+ return invalid(`chain exceeds ${MAX_CHAIN_ENTRIES} entries`);
137
+ }
138
+
139
+ const receivedAt = new Date().toISOString();
140
+ const chain: Identity[] = [];
141
+ for (const entry of wireChain as unknown[]) {
142
+ if (!isRecord(entry)) return invalid('an entry is not an object');
143
+ const { sub, type } = entry;
144
+ if (typeof sub !== 'string' || sub === '') {
145
+ return invalid('an entry has no sub');
146
+ }
147
+ if (!IDENTITY_TYPES.includes(type as Identity['type'])) {
148
+ return invalid(`entry ${JSON.stringify(sub)} has an unknown type`);
149
+ }
150
+ chain.push({
151
+ sub,
152
+ type: type as Identity['type'],
153
+ ...(typeof entry.displayName === 'string'
154
+ ? { displayName: entry.displayName }
155
+ : {}),
156
+ // Attribution, never authorization: whatever the wire claimed here is
157
+ // discarded, so a presented chain cannot smuggle a privilege.
158
+ roles: [],
159
+ claims: {},
160
+ resolvedAt: isIsoTimestamp(entry.resolvedAt)
161
+ ? entry.resolvedAt
162
+ : receivedAt,
163
+ source: IDENTITY_SOURCES.includes(entry.source as Identity['source'])
164
+ ? (entry.source as Identity['source'])
165
+ : 'custom',
166
+ });
167
+ }
168
+ // An empty presented chain says the same thing as no chain at all, and
169
+ // leaving `delegatedFrom` unset keeps it either absent or non-empty.
170
+ return chain.length === 0 ? {} : { chain };
171
+ }
172
+
173
+ /**
174
+ * Detects a chain that loops back through this proxy: the sub of the identity
175
+ * it resolved for the caller already appears in the chain that caller
176
+ * presents, so the call has cycled (ADR-0016).
177
+ *
178
+ * Runs after identity resolution and returns the rejection rather than
179
+ * throwing it, so the caller throws it inside the pipeline and the audit
180
+ * trail records the attempt against the identity that tripped it.
181
+ *
182
+ * With no resolved identity there is nothing to look for, which is the stdio
183
+ * case: only the structural validation in {@link readInboundDelegation}
184
+ * applies there. Core sees only the calls it forwards itself, so a cycle that
185
+ * closes through a local tool handler's own outbound call is outside this
186
+ * check, exactly as ADR-0011 said a partial control must admit.
187
+ */
188
+ export function detectDelegationLoop(
189
+ context: ProxyContext,
190
+ ): McpError | undefined {
191
+ const sub = context.identity?.sub;
192
+ if (sub === undefined) return undefined;
193
+ const looped = (context.delegatedFrom ?? []).some(
194
+ (entry) => entry.sub === sub,
195
+ );
196
+ return looped
197
+ ? rejection(`the caller ${JSON.stringify(sub)} is already in the chain`)
198
+ : undefined;
199
+ }
200
+
201
+ /**
202
+ * Attaches the chain this proxy can vouch for to a forwarded request's
203
+ * params, closing the loop for a chained proxy: the inbound strip removes
204
+ * the caller's copy, and core re-attaches the extended chain (ADR-0016).
205
+ *
206
+ * A no-op when the outbound chain is empty, so a proxy with no identity and
207
+ * no inbound chain forwards params untouched.
208
+ */
209
+ export function attachDelegationMeta<P>(params: P, context: ProxyContext): P {
210
+ const chain = outboundDelegationChain(context);
211
+ if (chain.length === 0) return params;
212
+ const existing = (params as { _meta?: Record<string, unknown> } | undefined)
213
+ ?._meta;
214
+ // P is inferred from the call site, so the result is exactly the params
215
+ // shape the callee expects, plus a `_meta` every request type allows —
216
+ // which the compiler cannot prove for an unresolved generic.
217
+ return {
218
+ ...params,
219
+ _meta: {
220
+ ...existing,
221
+ [DELEGATION_META_KEY]: serializeDelegationChain(chain),
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,106 @@
1
+ import type {
2
+ EventStore,
3
+ EventId,
4
+ StreamId,
5
+ } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
7
+
8
+ /**
9
+ * Plug in a persistent event store to support SSE reconnect replay across
10
+ * restarts or load-balanced instances. Re-exports the SDK's `EventStore`
11
+ * interface — any implementation satisfying it is compatible.
12
+ *
13
+ * For single-instance deployments, use {@link createInMemoryEventStore}.
14
+ * For production (multi-instance, HA), implement against Redis or Postgres.
15
+ */
16
+ export type { EventStore as PersistentEventStore };
17
+
18
+ /**
19
+ * In-memory EventStore — default for single-instance deployments.
20
+ * Dropped connections can replay missed notifications on reconnect.
21
+ * Events are evicted FIFO once `maxEvents` is reached.
22
+ */
23
+ export function createInMemoryEventStore(maxEvents = 1000): EventStore {
24
+ const store = new Map<
25
+ EventId,
26
+ { streamId: StreamId; message: JSONRPCMessage }
27
+ >();
28
+ let seq = 0;
29
+
30
+ return {
31
+ async storeEvent(streamId, message) {
32
+ const id = String(++seq);
33
+ store.set(id, { streamId, message });
34
+ if (store.size > maxEvents) {
35
+ store.delete(store.keys().next().value!);
36
+ }
37
+ return id;
38
+ },
39
+
40
+ async getStreamIdForEventId(eventId) {
41
+ return store.get(eventId)?.streamId;
42
+ },
43
+
44
+ async replayEventsAfter(lastEventId, { send }) {
45
+ // Unknown or malformed Last-Event-ID (or one already evicted): replay
46
+ // nothing rather than the whole buffer.
47
+ const origin = store.get(lastEventId);
48
+ if (!origin) return '';
49
+
50
+ const afterSeq = parseInt(lastEventId, 10);
51
+ for (const [id, { streamId, message }] of store) {
52
+ if (streamId === origin.streamId && parseInt(id, 10) > afterSeq) {
53
+ await send(id, message);
54
+ }
55
+ }
56
+ return origin.streamId;
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Namespaces every stream id by session before it reaches the store.
63
+ *
64
+ * The SDK gives every session's standalone SSE stream the literal id
65
+ * `_GET_stream`, so one store shared by many sessions would otherwise keep
66
+ * their standalone-stream history under one key and a `Last-Event-ID` from
67
+ * one session would replay another's events (#154). Prefixing the stream id
68
+ * with the session id keeps each session's history apart, and a cursor that
69
+ * belongs to another session is reported as unknown, which the transport
70
+ * turns into a 400.
71
+ */
72
+ export function scopeEventStore(
73
+ store: EventStore,
74
+ sessionId: string,
75
+ ): EventStore {
76
+ const prefix = `${sessionId}:`;
77
+ const own = (streamId: StreamId | undefined): StreamId | undefined =>
78
+ streamId?.startsWith(prefix) ? streamId.slice(prefix.length) : undefined;
79
+
80
+ return {
81
+ storeEvent: (streamId, message) =>
82
+ store.storeEvent(prefix + streamId, message),
83
+
84
+ getStreamIdForEventId: async (eventId) =>
85
+ own(await streamIdOf(store, eventId)),
86
+
87
+ async replayEventsAfter(lastEventId, { send }) {
88
+ if (own(await streamIdOf(store, lastEventId)) === undefined) return '';
89
+ return own(await store.replayEventsAfter(lastEventId, { send })) ?? '';
90
+ },
91
+ };
92
+ }
93
+
94
+ // ponytail: a store without `getStreamIdForEventId` (none of ours) is asked
95
+ // to replay into a sink once to learn the owner; give it that method if the
96
+ // double replay ever matters.
97
+ async function streamIdOf(
98
+ store: EventStore,
99
+ eventId: EventId,
100
+ ): Promise<StreamId | undefined> {
101
+ if (store.getStreamIdForEventId) return store.getStreamIdForEventId(eventId);
102
+ const streamId = await store.replayEventsAfter(eventId, {
103
+ send: async () => undefined,
104
+ });
105
+ return streamId === '' ? undefined : streamId;
106
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Predicate form of `ProxyOptions.hiddenTools`, closing the dispatcher
3
+ * bypass a name-only blocklist cannot see (a meta-tool that takes the real
4
+ * tool name as an argument). See ADR-0006.
5
+ */
6
+
7
+ /**
8
+ * Decides whether a tool is hidden.
9
+ *
10
+ * `args` carries the phase: `undefined` during list filtering (a listed
11
+ * tool has no arguments), and always an object at call time (empty when
12
+ * the client sent none). That distinction lets a predicate keep a
13
+ * dispatcher visible in `tools/list` while failing closed on a dispatcher
14
+ * call whose target argument is missing.
15
+ */
16
+ export type HiddenToolPredicate = (
17
+ name: string,
18
+ args: Readonly<Record<string, unknown>> | undefined,
19
+ ) => boolean;
20
+
21
+ /** Options for {@link dispatcherAwareBlock}. */
22
+ export interface DispatcherAwareBlockOptions {
23
+ /** Tool names to hide, whether called directly or through a dispatcher. */
24
+ tools: ReadonlyArray<string>;
25
+ /** Dispatcher (meta-tool) names whose target argument is checked. */
26
+ dispatchers: ReadonlyArray<string>;
27
+ /** Path to the target tool name inside the dispatcher's arguments. Dotted paths are supported, e.g. `'request.tool.name'`. */
28
+ argPath: string;
29
+ }
30
+
31
+ /**
32
+ * Resolves a dotted path through own enumerable-or-not plain-object keys.
33
+ * Traversing a non-object, an array, or a prototype key (e.g.
34
+ * `constructor`) resolves to `undefined` — the caller fails closed.
35
+ */
36
+ function resolveArgPath(
37
+ args: Readonly<Record<string, unknown>>,
38
+ path: string,
39
+ ): unknown {
40
+ let current: unknown = args;
41
+ for (const key of path.split('.')) {
42
+ if (
43
+ current === null ||
44
+ typeof current !== 'object' ||
45
+ Array.isArray(current) ||
46
+ !Object.hasOwn(current, key)
47
+ ) {
48
+ return undefined;
49
+ }
50
+ current = (current as Record<string, unknown>)[key];
51
+ }
52
+ return current;
53
+ }
54
+
55
+ /**
56
+ * A {@link HiddenToolPredicate} that blocks the listed tools both directly
57
+ * and through the listed dispatchers.
58
+ *
59
+ * Fail-closed: a dispatcher call whose target argument is missing, `null`,
60
+ * a number, an object, or an array is blocked, as is one whose `argPath`
61
+ * cannot be resolved. The dispatcher itself stays listed in `tools/list`
62
+ * and callable with a permitted target.
63
+ *
64
+ * @example
65
+ * hiddenTools: dispatcherAwareBlock({
66
+ * tools: ['update_issue', 'delete_issue'],
67
+ * dispatchers: ['execute_sentry_tool'],
68
+ * argPath: 'name',
69
+ * })
70
+ */
71
+ export function dispatcherAwareBlock(
72
+ options: DispatcherAwareBlockOptions,
73
+ ): HiddenToolPredicate {
74
+ const blocked = new Set(options.tools);
75
+ const dispatchers = new Set(options.dispatchers);
76
+
77
+ return (name, args) => {
78
+ if (blocked.has(name)) return true;
79
+ // args === undefined is the list phase: the dispatcher stays listed.
80
+ if (args === undefined || !dispatchers.has(name)) return false;
81
+ const target = resolveArgPath(args, options.argPath);
82
+ return typeof target !== 'string' || blocked.has(target);
83
+ };
84
+ }
@@ -0,0 +1,15 @@
1
+ /** Who made the request — resolved once per session via {@link resolveIdentity}. */
2
+ export interface Identity {
3
+ /** Stable unique identifier (e.g. OIDC `sub` claim). */
4
+ sub: string;
5
+ /** Whether the caller is a human user, an AI agent, or an automated service. */
6
+ type: 'human' | 'agent' | 'service';
7
+ displayName?: string;
8
+ roles: string[];
9
+ /** Arbitrary claims from the resolved identity source (JWT payload, cert CN, etc.). */
10
+ claims: Record<string, unknown>;
11
+ /** ISO 8601 timestamp of when identity was resolved. */
12
+ resolvedAt: string;
13
+ /** Which resolver produced this identity. */
14
+ source: 'jwt' | 'mtls' | 'apikey' | 'custom';
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ export type { Middleware } from './middleware.js';
2
+ export { compose, markPassThroughObserver } from './middleware.js';
3
+
4
+ export type { BackendConfig, BackendClient } from './backendClient.js';
5
+ export { createBackendClient } from './backendClient.js';
6
+
7
+ export type { Identity } from './identity.js';
8
+
9
+ export type {
10
+ ProxyContext,
11
+ ProxyIdentity,
12
+ PolicyDecision,
13
+ } from './proxyContext.js';
14
+ export { createProxyContext, outboundDelegationChain } from './proxyContext.js';
15
+
16
+ export type {
17
+ TelemetryEvent,
18
+ ToolCallTelemetryEvent,
19
+ BackendDegradedTelemetryEvent,
20
+ } from './telemetry.js';
21
+
22
+ export type { Backends } from './mesh.js';
23
+
24
+ export type { PersistentEventStore } from './eventStore.js';
25
+ export { createInMemoryEventStore } from './eventStore.js';
26
+
27
+ export type { SessionRecord, SessionRegistry } from './sessionRegistry.js';
28
+
29
+ export type {
30
+ DelegationWireEntry,
31
+ DelegationWirePayload,
32
+ } from './delegation.js';
33
+ export { DELEGATION_META_KEY, serializeDelegationChain } from './delegation.js';
34
+
35
+ export type { RejectionReason } from './rejection.js';
36
+ export { rejectionMcpError } from './rejection.js';
37
+
38
+ export type {
39
+ HiddenToolPredicate,
40
+ DispatcherAwareBlockOptions,
41
+ } from './hiddenTools.js';
42
+ export { dispatcherAwareBlock } from './hiddenTools.js';
43
+
44
+ export type { SanitizeToolDescriptionsOptions } from './sanitizeCatalog.js';
45
+ export { sanitizeToolDescriptions } from './sanitizeCatalog.js';
46
+
47
+ export type {
48
+ ProxyOptions,
49
+ HttpProxyOptions,
50
+ LocalTool,
51
+ ToolMiddleware,
52
+ ResourceMiddleware,
53
+ PromptMiddleware,
54
+ ListToolsMiddleware,
55
+ ToolResultHandlers,
56
+ } from './core.js';
57
+ export {
58
+ hasToolContent,
59
+ mapToolResult,
60
+ createProxyServer,
61
+ startProxy,
62
+ startHttpProxy,
63
+ } from './core.js';
package/src/mesh.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Multi-backend (mesh) composition: backend-key validation, namespacing,
3
+ * routing, and failure-isolated listing. See ADR-0013.
4
+ */
5
+ import type { BackendClient } from './backendClient.js';
6
+
7
+ /**
8
+ * Separates a backend key from the upstream name in mesh mode:
9
+ * `<backendKey>__<name>`. A key may not contain it, so the first occurrence
10
+ * always splits the two, even when the upstream name contains one itself.
11
+ */
12
+ const BACKEND_NAMESPACE_SEPARATOR = '__';
13
+
14
+ /**
15
+ * A backend key is an identifier: it is spliced into tool and prompt names
16
+ * and, for resources, into the authority of a `mcpose://<key>/<uri>` URI
17
+ * (ADR-0022), so it is limited to what both a name and a URI host accept.
18
+ */
19
+ const BACKEND_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
20
+
21
+ /**
22
+ * The scheme a mesh exposes upstream resources under. The key is the
23
+ * authority and the upstream URI follows verbatim as the path, so the
24
+ * original is recovered by stripping a fixed prefix and never parsed.
25
+ */
26
+ const RESOURCE_SCHEME = 'mcpose://';
27
+
28
+ /**
29
+ * Pages drained from one backend before the proxy gives up on it. A backend
30
+ * that never advances its cursor would otherwise hang the whole list.
31
+ */
32
+ const MAX_PAGES_PER_BACKEND = 100;
33
+
34
+ /** One named upstream of a mesh. */
35
+ export interface MeshEntry {
36
+ readonly key: string;
37
+ readonly client: BackendClient;
38
+ }
39
+
40
+ /**
41
+ * Either a single upstream (1:1 proxy, no namespacing) or a record of named
42
+ * backends keyed by backend key (mesh mode). See ADR-0013.
43
+ */
44
+ export type Backends = BackendClient | Readonly<Record<string, BackendClient>>;
45
+
46
+ const isBackendClient = (backends: Backends): backends is BackendClient =>
47
+ typeof (backends as BackendClient).getServerCapabilities === 'function';
48
+
49
+ /**
50
+ * Normalizes the `backends` argument to an ordered entry list. The 1:1 form
51
+ * yields a single entry with an empty key and `mesh: false`.
52
+ *
53
+ * @throws On an empty record, an empty backend key, or a key containing the
54
+ * namespace separator — each would make an exposed name ambiguous, and
55
+ * guessing which upstream a caller meant is the failure mesh routing exists
56
+ * to prevent.
57
+ */
58
+ export function normalizeBackends(backends: Backends): {
59
+ mesh: boolean;
60
+ entries: ReadonlyArray<MeshEntry>;
61
+ } {
62
+ if (isBackendClient(backends)) {
63
+ return { mesh: false, entries: [{ key: '', client: backends }] };
64
+ }
65
+
66
+ const entries = Object.entries(backends).map(([key, client]) => ({
67
+ key,
68
+ client,
69
+ }));
70
+
71
+ if (entries.length === 0) {
72
+ throw new Error(
73
+ 'mcpose: backends record is empty — pass at least one named backend, or a single BackendClient for a 1:1 proxy.',
74
+ );
75
+ }
76
+ for (const { key } of entries) {
77
+ if (key === '') {
78
+ throw new Error('mcpose: backend key must not be empty');
79
+ }
80
+ if (key.includes(BACKEND_NAMESPACE_SEPARATOR)) {
81
+ throw new Error(
82
+ `mcpose: backend key "${key}" must not contain "${BACKEND_NAMESPACE_SEPARATOR}" — it is the namespace separator`,
83
+ );
84
+ }
85
+ if (!BACKEND_KEY.test(key)) {
86
+ throw new Error(
87
+ `mcpose: backend key "${key}" must be an identifier (letters, digits, ".", "_" and "-", starting with a letter or digit) — it names the backend inside tool names and resource URIs`,
88
+ );
89
+ }
90
+ }
91
+
92
+ return { mesh: true, entries };
93
+ }
94
+
95
+ /** Namespaces a listed tool or prompt as `<key>__<name>`. */
96
+ export function namespaceName<Item extends { name: string }>(
97
+ key: string,
98
+ item: Item,
99
+ ): Item {
100
+ return { ...item, name: `${key}${BACKEND_NAMESPACE_SEPARATOR}${item.name}` };
101
+ }
102
+
103
+ /**
104
+ * Exposes a listed resource as `mcpose://<key>/<uri>`. The upstream URI is
105
+ * appended untouched, so what the client holds is still one valid URI whose
106
+ * only meaning is "read me through this proxy", exactly as opaque as before.
107
+ */
108
+ export function namespaceUri<Item extends { uri: string }>(
109
+ key: string,
110
+ item: Item,
111
+ ): Item {
112
+ return { ...item, uri: `${RESOURCE_SCHEME}${key}/${item.uri}` };
113
+ }
114
+
115
+ /**
116
+ * Resolves an exposed resource URI to its backend and upstream URI.
117
+ *
118
+ * Returns `undefined` for a URI not under `mcpose://`, an unknown key, or an
119
+ * empty upstream URI, and the caller rejects it as `BACKEND_UNROUTABLE`:
120
+ * there is no "which backend listed this URI" lookup, for the same reason
121
+ * tool names get no single-backend fallback.
122
+ */
123
+ export function routeResourceUri(
124
+ uri: string,
125
+ byKey: ReadonlyMap<string, BackendClient>,
126
+ ): { client: BackendClient; uri: string } | undefined {
127
+ if (!uri.startsWith(RESOURCE_SCHEME)) return undefined;
128
+ const rest = uri.slice(RESOURCE_SCHEME.length);
129
+ const slash = rest.indexOf('/');
130
+ if (slash <= 0) return undefined;
131
+ const client = byKey.get(rest.slice(0, slash));
132
+ const upstreamUri = rest.slice(slash + 1);
133
+ if (client === undefined || upstreamUri === '') return undefined;
134
+ return { client, uri: upstreamUri };
135
+ }
136
+
137
+ /**
138
+ * Resolves an exposed name to its backend and upstream name.
139
+ *
140
+ * Returns `undefined` for an un-namespaced name, an unknown key, or an empty
141
+ * upstream name. The caller turns that into a loud `BACKEND_UNROUTABLE`
142
+ * rejection: there is deliberately no "resolve it if only one backend has
143
+ * that name" fallback, because it silently re-routes once a backend is added.
144
+ */
145
+ export function routeNamespaced(
146
+ name: string,
147
+ byKey: ReadonlyMap<string, BackendClient>,
148
+ ): { client: BackendClient; name: string } | undefined {
149
+ const at = name.indexOf(BACKEND_NAMESPACE_SEPARATOR);
150
+ if (at <= 0) return undefined;
151
+ const client = byKey.get(name.slice(0, at));
152
+ const upstreamName = name.slice(at + BACKEND_NAMESPACE_SEPARATOR.length);
153
+ if (client === undefined || upstreamName === '') return undefined;
154
+ return { client, name: upstreamName };
155
+ }
156
+
157
+ /** One page of a paginated list surface. */
158
+ export interface MeshPage<Item> {
159
+ items: ReadonlyArray<Item>;
160
+ nextCursor: string | undefined;
161
+ }
162
+
163
+ /**
164
+ * Lists one paginated surface across every backend concurrently, exposing
165
+ * each entry under its backend key via `expose` and isolating failures: a
166
+ * backend that throws (or never finishes paginating) contributes nothing and
167
+ * is reported through `onDegraded`, while the live backends' entries are
168
+ * still returned.
169
+ *
170
+ * Entries keep backend declaration order. A backend that fails part-way
171
+ * through pagination contributes nothing at all rather than a truncated
172
+ * list, so the response never looks complete when it is not.
173
+ */
174
+ export async function listAcrossMesh<Item>(
175
+ entries: ReadonlyArray<MeshEntry>,
176
+ listPage: (
177
+ client: BackendClient,
178
+ cursor: string | undefined,
179
+ ) => Promise<MeshPage<Item>>,
180
+ onDegraded: (key: string, error: unknown) => void,
181
+ expose: (key: string, item: Item) => Item,
182
+ ): Promise<Item[]> {
183
+ const settled = await Promise.allSettled(
184
+ entries.map(async ({ key, client }) => {
185
+ const collected: Item[] = [];
186
+ let cursor: string | undefined;
187
+ for (let page = 0; page < MAX_PAGES_PER_BACKEND; page += 1) {
188
+ const { items, nextCursor } = await listPage(client, cursor);
189
+ for (const item of items) collected.push(expose(key, item));
190
+ if (nextCursor === undefined) return collected;
191
+ cursor = nextCursor;
192
+ }
193
+ throw new Error(
194
+ `mcpose: backend "${key}" did not finish paginating within ${MAX_PAGES_PER_BACKEND} pages`,
195
+ );
196
+ }),
197
+ );
198
+
199
+ return settled.flatMap((outcome, index) => {
200
+ if (outcome.status === 'fulfilled') return outcome.value;
201
+ onDegraded(entries[index]!.key, outcome.reason);
202
+ return [];
203
+ });
204
+ }