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.
- package/LICENSE +21 -0
- package/README.md +494 -37
- package/dist/backendClient.d.ts +5 -0
- package/dist/backendClient.d.ts.map +1 -1
- package/dist/backendClient.js +17 -9
- package/dist/backendClient.js.map +1 -1
- package/dist/core.d.ts +293 -28
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +969 -175
- package/dist/core.js.map +1 -1
- package/dist/delegation.d.ts +89 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +175 -0
- package/dist/delegation.js.map +1 -0
- package/dist/eventStore.d.ts +12 -0
- package/dist/eventStore.d.ts.map +1 -1
- package/dist/eventStore.js +43 -5
- package/dist/eventStore.js.map +1 -1
- package/dist/hiddenTools.d.ts +42 -0
- package/dist/hiddenTools.d.ts.map +1 -0
- package/dist/hiddenTools.js +53 -0
- package/dist/hiddenTools.js.map +1 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mesh.d.ts +82 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +141 -0
- package/dist/mesh.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +27 -3
- package/dist/middleware.js.map +1 -1
- package/dist/proxyContext.d.ts +65 -5
- package/dist/proxyContext.d.ts.map +1 -1
- package/dist/proxyContext.js +39 -4
- package/dist/proxyContext.js.map +1 -1
- package/dist/rejection.d.ts +1 -1
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +1 -1
- package/dist/rejection.js.map +1 -1
- package/dist/sanitizeCatalog.d.ts +37 -0
- package/dist/sanitizeCatalog.d.ts.map +1 -0
- package/dist/sanitizeCatalog.js +86 -0
- package/dist/sanitizeCatalog.js.map +1 -0
- package/dist/sessionRegistry.d.ts +79 -0
- package/dist/sessionRegistry.d.ts.map +1 -0
- package/dist/sessionRegistry.js +83 -0
- package/dist/sessionRegistry.js.map +1 -0
- package/dist/telemetry.d.ts +29 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/testing.d.ts +18 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +30 -8
- package/dist/testing.js.map +1 -1
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +30 -14
- package/src/__tests__/_helpers.ts +64 -0
- package/src/__tests__/backendClient.test.ts +198 -0
- package/src/__tests__/core.delegation.test.ts +964 -0
- package/src/__tests__/core.edge.test.ts +314 -0
- package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
- package/src/__tests__/core.localTools.test.ts +314 -0
- package/src/__tests__/core.mesh.test.ts +1182 -0
- package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
- package/src/__tests__/core.stripResultMeta.test.ts +217 -0
- package/src/__tests__/core.test.ts +1019 -0
- package/src/__tests__/eventStore.test.ts +141 -0
- package/src/__tests__/mapToolResult.test.ts +101 -0
- package/src/__tests__/middleware.edge.test.ts +126 -0
- package/src/__tests__/middleware.test.ts +233 -0
- package/src/__tests__/proxyContext.test.ts +140 -0
- package/src/__tests__/sanitizeCatalog.test.ts +359 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -0
- package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
- package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
- package/src/__tests__/startHttpProxy.security.test.ts +288 -0
- package/src/__tests__/startHttpProxy.test.ts +985 -0
- package/src/__tests__/testing.test.ts +250 -0
- package/src/__tests__/version.test.ts +12 -0
- package/src/backendClient.ts +83 -0
- package/src/core.ts +2042 -0
- package/src/delegation.ts +224 -0
- package/src/eventStore.ts +106 -0
- package/src/hiddenTools.ts +84 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +63 -0
- package/src/mesh.ts +204 -0
- package/src/middleware.ts +101 -0
- package/src/proxyContext.ts +108 -0
- package/src/rejection.ts +37 -0
- package/src/sanitizeCatalog.ts +120 -0
- package/src/sessionRegistry.ts +142 -0
- package/src/telemetry.ts +46 -0
- package/src/testing.ts +174 -0
- 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
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -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
|
+
}
|