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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Koa-style middleware composition for MCP request/response pipelines.
|
|
3
|
+
*
|
|
4
|
+
* `(req, next) => Promise<Res>` — call `next(req)` to delegate downstream.
|
|
5
|
+
* Middlewares wrap like an onion: outer runs before+after inner.
|
|
6
|
+
*
|
|
7
|
+
* `pipe([piiMW, auditMW])` execution order:
|
|
8
|
+
* 1. auditMW enter → capture startTime
|
|
9
|
+
* 2. piiMW enter → call next
|
|
10
|
+
* 3. upstream call → raw response
|
|
11
|
+
* 4. piiMW exit → redact PII
|
|
12
|
+
* 5. auditMW exit → log clean result
|
|
13
|
+
*/
|
|
14
|
+
import { createProxyContext, type ProxyContext } from './proxyContext.js';
|
|
15
|
+
|
|
16
|
+
/** Single middleware unit. May transform req before `next` or res after. */
|
|
17
|
+
export type Middleware<Req, Res> = (
|
|
18
|
+
req: Req,
|
|
19
|
+
next: (req: Req) => Promise<Res>,
|
|
20
|
+
context: ProxyContext,
|
|
21
|
+
) => Promise<Res>;
|
|
22
|
+
|
|
23
|
+
type MiddlewarePipeline<Req, Res> = {
|
|
24
|
+
(req: Req, next: (req: Req) => Promise<Res>): Promise<Res>;
|
|
25
|
+
(
|
|
26
|
+
req: Req,
|
|
27
|
+
next: (req: Req) => Promise<Res>,
|
|
28
|
+
context: ProxyContext,
|
|
29
|
+
): Promise<Res>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Composes middlewares into one, outermost-first (Koa-style).
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const pipeline = compose([auditMW, piiMW]);
|
|
37
|
+
* const result = await pipeline(req, (r) => upstream.callTool(r.params));
|
|
38
|
+
*/
|
|
39
|
+
export function compose<Req, Res>(
|
|
40
|
+
middlewares: ReadonlyArray<Middleware<Req, Res>>,
|
|
41
|
+
): MiddlewarePipeline<Req, Res> {
|
|
42
|
+
return (req, next, context: ProxyContext = createProxyContext()) => {
|
|
43
|
+
let index = -1;
|
|
44
|
+
|
|
45
|
+
const dispatch = (i: number, currentReq: Req): Promise<Res> => {
|
|
46
|
+
if (i <= index) {
|
|
47
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
48
|
+
}
|
|
49
|
+
index = i;
|
|
50
|
+
|
|
51
|
+
// Past the end of the pipeline, the terminal handler runs.
|
|
52
|
+
const fn: Middleware<Req, Res> = middlewares[i] ?? ((r) => next(r));
|
|
53
|
+
|
|
54
|
+
return Promise.resolve(
|
|
55
|
+
fn(currentReq, (r) => dispatch(i + 1, r), context),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return dispatch(0, req);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Like `compose` but in response-processing order (first = innermost).
|
|
65
|
+
* `pipe([piiMW, auditMW])` ≡ `compose([auditMW, piiMW])`.
|
|
66
|
+
* Used internally by mcpose core — consumers pass arrays to `ProxyOptions`.
|
|
67
|
+
*/
|
|
68
|
+
export function pipe<Req, Res>(
|
|
69
|
+
middlewares: ReadonlyArray<Middleware<Req, Res>>,
|
|
70
|
+
): MiddlewarePipeline<Req, Res> {
|
|
71
|
+
return compose([...middlewares].reverse());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PASS_THROUGH_OBSERVER = Symbol.for('mcpose.passThroughObserver');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Marks a middleware as a pass-through observer: it still runs for tools
|
|
78
|
+
* listed in `ProxyOptions.passThroughTools`, which skip all other middleware.
|
|
79
|
+
*
|
|
80
|
+
* Use for middleware that must see every call regardless of pass-through
|
|
81
|
+
* status — audit logging, telemetry — never for middleware that transforms
|
|
82
|
+
* requests or responses (pass-through means "forward upstream as-is").
|
|
83
|
+
*
|
|
84
|
+
* Returns a new middleware; the input is not mutated.
|
|
85
|
+
*/
|
|
86
|
+
export function markPassThroughObserver<Req, Res>(
|
|
87
|
+
mw: Middleware<Req, Res>,
|
|
88
|
+
): Middleware<Req, Res> {
|
|
89
|
+
const marked: Middleware<Req, Res> = (req, next, context) =>
|
|
90
|
+
mw(req, next, context);
|
|
91
|
+
Object.defineProperty(marked, PASS_THROUGH_OBSERVER, {
|
|
92
|
+
value: true,
|
|
93
|
+
enumerable: false,
|
|
94
|
+
});
|
|
95
|
+
return marked;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when the middleware was wrapped by {@link markPassThroughObserver}. */
|
|
99
|
+
export function isPassThroughObserver(mw: unknown): boolean {
|
|
100
|
+
return typeof mw === 'function' && PASS_THROUGH_OBSERVER in mw;
|
|
101
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Identity } from './identity.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The proxy instance a request passed through, as resolved from
|
|
6
|
+
* `ProxyOptions.name`/`version` (defaults included). Provenance, not a
|
|
7
|
+
* principal: it is never an entry in `delegatedFrom` and takes no part in
|
|
8
|
+
* the caller-attribution model (ADR-0011, ADR-0012).
|
|
9
|
+
*/
|
|
10
|
+
export interface ProxyIdentity {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly version: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A policy decision already made, stamped on the context by the policy
|
|
17
|
+
* middleware (ADR-0017). It is a record, not a handle to query: core never
|
|
18
|
+
* evaluates policy itself, and a second evaluation path would let inner
|
|
19
|
+
* middleware ask a different question than the gate answered.
|
|
20
|
+
*
|
|
21
|
+
* `reason` carries the `RejectionReason` that accompanied a denial.
|
|
22
|
+
*/
|
|
23
|
+
export interface PolicyDecision {
|
|
24
|
+
readonly decision: 'allow' | 'deny';
|
|
25
|
+
readonly ruleId?: string;
|
|
26
|
+
readonly reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalized request metadata that mcpose passes through middleware layers. */
|
|
30
|
+
export interface ProxyContext {
|
|
31
|
+
requestId: string;
|
|
32
|
+
transport: 'stdio' | 'http';
|
|
33
|
+
sessionId?: string;
|
|
34
|
+
headers?: Readonly<Record<string, string>>;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** Resolved caller identity. Present when {@link HttpProxyOptions.resolveIdentity} is configured. */
|
|
37
|
+
identity?: Identity;
|
|
38
|
+
/**
|
|
39
|
+
* Agent delegation chain, oldest-first. Populated from the inbound
|
|
40
|
+
* request's `_meta["mcpose/delegation"]` when a caller presents one
|
|
41
|
+
* (ADR-0016), or by a host that stamps its own, which wins.
|
|
42
|
+
*
|
|
43
|
+
* Attribution, never authorization: every entry extracted from the wire
|
|
44
|
+
* carries empty `roles` and `claims`, because a presented chain is written
|
|
45
|
+
* by the previous hop.
|
|
46
|
+
*/
|
|
47
|
+
delegatedFrom?: Identity[];
|
|
48
|
+
/** The proxy instance handling this request. Stamped by {@link createProxyServer}. */
|
|
49
|
+
proxy?: ProxyIdentity;
|
|
50
|
+
/**
|
|
51
|
+
* The policy decision for this call. Stamped by the `@mcpose/policy`
|
|
52
|
+
* middleware before it calls `next` (allow) or throws (deny), so
|
|
53
|
+
* middleware running inside the policy layer can rely on
|
|
54
|
+
* `decision: 'allow'` (ADR-0017). Core never writes it.
|
|
55
|
+
*/
|
|
56
|
+
policy?: PolicyDecision;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Canonical oldest-first delegation chain a host attaches to outbound calls
|
|
61
|
+
* made on behalf of the inbound caller (ADR-0011). Returns a fresh array and
|
|
62
|
+
* never mutates or aliases `ctx.delegatedFrom`, because the audit middleware
|
|
63
|
+
* reads the same context object when recording the inbound call.
|
|
64
|
+
*
|
|
65
|
+
* Core attaches this chain itself to every call it forwards to a backend, at
|
|
66
|
+
* `params._meta["mcpose/delegation"]` (ADR-0016). A local tool handler makes
|
|
67
|
+
* its own outbound calls, so its host attaches the chain: for an outbound MCP
|
|
68
|
+
* call, `serializeDelegationChain(outboundDelegationChain(context))` under
|
|
69
|
+
* `DELEGATION_META_KEY` produces exactly what core sends.
|
|
70
|
+
*/
|
|
71
|
+
export function outboundDelegationChain(ctx: ProxyContext): Identity[] {
|
|
72
|
+
return [
|
|
73
|
+
...(ctx.delegatedFrom ?? []),
|
|
74
|
+
...(ctx.identity === undefined ? [] : [ctx.identity]),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Creates a middleware context with a fresh request ID.
|
|
80
|
+
*
|
|
81
|
+
* Every override may be passed explicitly as `undefined` — it means "not
|
|
82
|
+
* provided" and the key is dropped from the result, which is why the
|
|
83
|
+
* parameter is not a plain `Partial<ProxyContext>` under
|
|
84
|
+
* `exactOptionalPropertyTypes`.
|
|
85
|
+
*/
|
|
86
|
+
export function createProxyContext(
|
|
87
|
+
overrides: { [K in keyof ProxyContext]?: ProxyContext[K] | undefined } = {},
|
|
88
|
+
): ProxyContext {
|
|
89
|
+
return {
|
|
90
|
+
// Deliberately `||`, not `??`: an empty-string requestId is useless for
|
|
91
|
+
// correlation, so it is regenerated like a missing one.
|
|
92
|
+
requestId: overrides.requestId || randomUUID(),
|
|
93
|
+
transport: overrides.transport ?? 'stdio',
|
|
94
|
+
...(overrides.sessionId === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { sessionId: overrides.sessionId }),
|
|
97
|
+
...(overrides.headers === undefined ? {} : { headers: overrides.headers }),
|
|
98
|
+
...(overrides.signal === undefined ? {} : { signal: overrides.signal }),
|
|
99
|
+
...(overrides.identity === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { identity: overrides.identity }),
|
|
102
|
+
...(overrides.delegatedFrom === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { delegatedFrom: overrides.delegatedFrom }),
|
|
105
|
+
...(overrides.proxy === undefined ? {} : { proxy: overrides.proxy }),
|
|
106
|
+
...(overrides.policy === undefined ? {} : { policy: overrides.policy }),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/rejection.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structured reason for a rejected call.
|
|
5
|
+
*
|
|
6
|
+
* Placed in the MCP error `data` field — the top-level error code is
|
|
7
|
+
* unchanged (`MethodNotFound` / `InvalidRequest`) so existing clients are
|
|
8
|
+
* unaffected. Agents and audit middleware can inspect `error.data.rejectionReason`
|
|
9
|
+
* for programmatic handling and compliance logging.
|
|
10
|
+
*
|
|
11
|
+
* @stable
|
|
12
|
+
*/
|
|
13
|
+
export type RejectionReason =
|
|
14
|
+
| 'TOOL_HIDDEN' // tool exists but is hidden from this caller
|
|
15
|
+
| 'RESOURCE_HIDDEN' // resource exists but is hidden from this caller
|
|
16
|
+
| 'BACKEND_UNROUTABLE' // mesh mode: no `<backendKey>__` name prefix or `mcpose://<backendKey>/` URI prefix names a configured backend
|
|
17
|
+
| 'POLICY_DENIED' // @mcpose/policy: a rule denied, or no rule allowed (ADR-0017)
|
|
18
|
+
| 'IDENTITY_UNRESOLVED' // identity could not be established; @mcpose/policy is its first emitter (ADR-0017)
|
|
19
|
+
| 'CONSENT_MISSING' // v3: GDPR/CCPA consent gate blocked the call
|
|
20
|
+
| 'SENSITIVITY_BLOCKED' // @mcpose/policy: a sensitivity-tier rule blocked the call (ADR-0017)
|
|
21
|
+
| 'DELEGATION_INVALID' // the presented delegation chain is malformed (ADR-0016)
|
|
22
|
+
| 'BUDGET_EXCEEDED' // @mcpose/policy: the per-session call budget is exhausted (ADR-0017)
|
|
23
|
+
| 'SESSION_LIMIT' // max concurrent sessions reached (HTTP 503)
|
|
24
|
+
| 'BODY_LIMIT'; // request body exceeded maxBodyBytes (HTTP 413)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates an `McpError` with the rejection reason embedded in `error.data`.
|
|
28
|
+
* The top-level `code` is unchanged so clients that only inspect the code
|
|
29
|
+
* are unaffected.
|
|
30
|
+
*/
|
|
31
|
+
export function rejectionMcpError(
|
|
32
|
+
reason: RejectionReason,
|
|
33
|
+
code: ErrorCode,
|
|
34
|
+
message: string,
|
|
35
|
+
): McpError {
|
|
36
|
+
return new McpError(code, message, { rejectionReason: reason });
|
|
37
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Egress sanitizer for the tool catalog: `tools/list` forwards upstream
|
|
3
|
+
* descriptions verbatim into model context, and real upstreams leak org
|
|
4
|
+
* slugs and internal hostnames there. See ADR-0010.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ListToolsMiddleware } from './core.js';
|
|
8
|
+
|
|
9
|
+
/** Matches http(s) URLs; always stripped regardless of `patterns`. */
|
|
10
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/g;
|
|
11
|
+
|
|
12
|
+
/** Options for {@link sanitizeToolDescriptions}. */
|
|
13
|
+
export interface SanitizeToolDescriptionsOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Extra patterns to remove. A string replaces every literal occurrence;
|
|
16
|
+
* a RegExp is normalized to global, with any sticky flag dropped, so
|
|
17
|
+
* every match is replaced.
|
|
18
|
+
*/
|
|
19
|
+
patterns?: ReadonlyArray<string | RegExp>;
|
|
20
|
+
/** Replacement text for every match. Default: `''` (plain removal). */
|
|
21
|
+
replacement?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rebuilds a schema node with every string-valued `description` property
|
|
26
|
+
* sanitized, at any depth through plain objects and arrays. Returns the
|
|
27
|
+
* original node when nothing changed.
|
|
28
|
+
*/
|
|
29
|
+
function sanitizeSchema(
|
|
30
|
+
node: unknown,
|
|
31
|
+
sanitize: (text: string) => string,
|
|
32
|
+
): unknown {
|
|
33
|
+
if (Array.isArray(node)) {
|
|
34
|
+
const mapped = node.map((item) => sanitizeSchema(item, sanitize));
|
|
35
|
+
return mapped.some((item, i) => item !== node[i]) ? mapped : node;
|
|
36
|
+
}
|
|
37
|
+
if (node === null || typeof node !== 'object') return node;
|
|
38
|
+
const entries = Object.entries(node).map(([key, value]) => [
|
|
39
|
+
key,
|
|
40
|
+
key === 'description' && typeof value === 'string'
|
|
41
|
+
? sanitize(value)
|
|
42
|
+
: sanitizeSchema(value, sanitize),
|
|
43
|
+
]);
|
|
44
|
+
return entries.some(
|
|
45
|
+
([key, value]) =>
|
|
46
|
+
value !== (node as Record<string, unknown>)[key as string],
|
|
47
|
+
)
|
|
48
|
+
? Object.fromEntries(entries)
|
|
49
|
+
: node;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A {@link ListToolsMiddleware} that sanitizes tool descriptions and every
|
|
54
|
+
* `description` field nested in `inputSchema` / `outputSchema`. Names,
|
|
55
|
+
* titles, and schema structure are untouched, because clients route on
|
|
56
|
+
* them.
|
|
57
|
+
*
|
|
58
|
+
* Always strips http(s) URLs; `patterns` adds more. Place it **last** in
|
|
59
|
+
* `listToolsMiddleware` so it sanitizes the output of other list
|
|
60
|
+
* middleware and local tools.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* listToolsMiddleware: [
|
|
64
|
+
* sanitizeToolDescriptions({
|
|
65
|
+
* patterns: ['acme-corp', /\bi-[0-9a-f]{8,17}\b/],
|
|
66
|
+
* replacement: '[redacted]',
|
|
67
|
+
* }),
|
|
68
|
+
* ]
|
|
69
|
+
*/
|
|
70
|
+
export function sanitizeToolDescriptions(
|
|
71
|
+
options: SanitizeToolDescriptionsOptions = {},
|
|
72
|
+
): ListToolsMiddleware {
|
|
73
|
+
const replacement = options.replacement ?? '';
|
|
74
|
+
const patterns: ReadonlyArray<string | RegExp> = [
|
|
75
|
+
URL_PATTERN,
|
|
76
|
+
// Rebuild with exactly one `g` and no `y`: String.replace with a
|
|
77
|
+
// sticky regex only replaces matches anchored at index 0, so a sticky
|
|
78
|
+
// pattern would silently leak later occurrences.
|
|
79
|
+
...(options.patterns ?? []).map((p) =>
|
|
80
|
+
p instanceof RegExp
|
|
81
|
+
? new RegExp(p.source, p.flags.replace(/[gy]/g, '') + 'g')
|
|
82
|
+
: p,
|
|
83
|
+
),
|
|
84
|
+
];
|
|
85
|
+
// replaceAll treats a string pattern literally and requires the g flag
|
|
86
|
+
// on a RegExp, which the normalization above guarantees.
|
|
87
|
+
const sanitize = (text: string): string =>
|
|
88
|
+
patterns.reduce<string>((acc, p) => acc.replaceAll(p, replacement), text);
|
|
89
|
+
|
|
90
|
+
return async (req, next) => {
|
|
91
|
+
const result = await next(req);
|
|
92
|
+
const tools = result.tools.map((tool) => {
|
|
93
|
+
const description =
|
|
94
|
+
typeof tool.description === 'string'
|
|
95
|
+
? sanitize(tool.description)
|
|
96
|
+
: tool.description;
|
|
97
|
+
const inputSchema = sanitizeSchema(
|
|
98
|
+
tool.inputSchema,
|
|
99
|
+
sanitize,
|
|
100
|
+
) as typeof tool.inputSchema;
|
|
101
|
+
const outputSchema = sanitizeSchema(
|
|
102
|
+
tool.outputSchema,
|
|
103
|
+
sanitize,
|
|
104
|
+
) as typeof tool.outputSchema;
|
|
105
|
+
return description === tool.description &&
|
|
106
|
+
inputSchema === tool.inputSchema &&
|
|
107
|
+
outputSchema === tool.outputSchema
|
|
108
|
+
? tool
|
|
109
|
+
: {
|
|
110
|
+
...tool,
|
|
111
|
+
...(description === undefined ? {} : { description }),
|
|
112
|
+
inputSchema,
|
|
113
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
return tools.some((tool, i) => tool !== result.tools[i])
|
|
117
|
+
? { ...result, tools }
|
|
118
|
+
: result;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared session registry: the transport-level record of live session ids,
|
|
3
|
+
* so a resume can survive a proxy restart or reach another instance (#155).
|
|
4
|
+
*/
|
|
5
|
+
import * as http from 'node:http';
|
|
6
|
+
import * as net from 'node:net';
|
|
7
|
+
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import {
|
|
9
|
+
isInitializeRequest,
|
|
10
|
+
type InitializeRequestParams,
|
|
11
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import type { Identity } from './identity.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Everything another instance needs to take over a session. Plain JSON: an
|
|
16
|
+
* adapter serializes it as is, so `identity.claims` must survive a JSON
|
|
17
|
+
* round-trip.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionRecord {
|
|
20
|
+
/**
|
|
21
|
+
* The client's `initialize` params, verbatim. A resuming instance replays
|
|
22
|
+
* them into a fresh SDK transport, so the rebuilt session negotiates exactly
|
|
23
|
+
* what the original did: protocol version, client capabilities, clientInfo.
|
|
24
|
+
*/
|
|
25
|
+
initialize: InitializeRequestParams;
|
|
26
|
+
/**
|
|
27
|
+
* The identity `resolveIdentity` produced at initialize. Never re-resolved
|
|
28
|
+
* on resume; `validateSession` still runs on every routed request.
|
|
29
|
+
*/
|
|
30
|
+
identity?: Identity;
|
|
31
|
+
/**
|
|
32
|
+
* Epoch milliseconds after which the session is dead on every instance.
|
|
33
|
+
* Fixed at creation from `sessionTtlMs` and absent when that is `Infinity`.
|
|
34
|
+
* A resuming instance honours the remaining lifetime, not its own TTL.
|
|
35
|
+
*/
|
|
36
|
+
expiresAt?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where `startHttpProxy` keeps its session records when one is supplied. The
|
|
41
|
+
* local in-memory map stays authoritative for sessions this instance holds;
|
|
42
|
+
* the registry is consulted only for an id this instance does not know.
|
|
43
|
+
*
|
|
44
|
+
* `@mcpose/store-redis` and `@mcpose/store-postgres` ship implementations
|
|
45
|
+
* alongside their `EventStore`s. Share the event store too, or a resumed
|
|
46
|
+
* session has a live id and no replay history.
|
|
47
|
+
*/
|
|
48
|
+
export interface SessionRegistry {
|
|
49
|
+
/**
|
|
50
|
+
* Persists a record. Awaited before the client learns its session id, so a
|
|
51
|
+
* rejection fails the initialize with a 500 rather than handing out an id
|
|
52
|
+
* that no other instance could honour.
|
|
53
|
+
*/
|
|
54
|
+
set(sessionId: string, record: SessionRecord): Promise<void>;
|
|
55
|
+
/** `undefined` for an unknown or expired id. */
|
|
56
|
+
get(sessionId: string): Promise<SessionRecord | undefined>;
|
|
57
|
+
/**
|
|
58
|
+
* Called on client DELETE and TTL expiry. Never on server shutdown: a
|
|
59
|
+
* session that outlives the process is the point of the registry.
|
|
60
|
+
*/
|
|
61
|
+
delete(sessionId: string): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pulls the `initialize` params out of a POST body the SDK has already
|
|
66
|
+
* accepted as an initialize request, so a parse failure here is a bug in the
|
|
67
|
+
* SDK's acceptance, not client input to be tolerated.
|
|
68
|
+
*/
|
|
69
|
+
export function initializeParamsOf(body: string): InitializeRequestParams {
|
|
70
|
+
const parsed: unknown = JSON.parse(body);
|
|
71
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
72
|
+
const initialize = messages.find(isInitializeRequest);
|
|
73
|
+
if (initialize === undefined) {
|
|
74
|
+
throw new Error('mcpose: initialize request not found in accepted body');
|
|
75
|
+
}
|
|
76
|
+
return initialize.params;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drives a fresh transport through the SDK's own initialize path with the
|
|
81
|
+
* stored params, so it accepts `sessionId` on later requests. The SDK cannot
|
|
82
|
+
* rehydrate a transport and its initialized flag is private, so the public
|
|
83
|
+
* request path is the one that survives an SDK bump.
|
|
84
|
+
*
|
|
85
|
+
* The request never touches a socket: the SDK's Node adapter reads method,
|
|
86
|
+
* url, headers and the body stream, and the response is captured rather than
|
|
87
|
+
* written anywhere. `Host` and `Origin` are copied from the request being
|
|
88
|
+
* resumed so the transport's DNS-rebinding check sees what the client sent.
|
|
89
|
+
*
|
|
90
|
+
* @returns the status and body the SDK answered, for the caller to relay if
|
|
91
|
+
* the transport did not come up.
|
|
92
|
+
*/
|
|
93
|
+
export function replayInitialize(
|
|
94
|
+
transport: StreamableHTTPServerTransport,
|
|
95
|
+
params: InitializeRequestParams,
|
|
96
|
+
headers: { host?: string; origin?: string },
|
|
97
|
+
): Promise<{ status: number; body: string }> {
|
|
98
|
+
const body = JSON.stringify({
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: 0,
|
|
101
|
+
method: 'initialize',
|
|
102
|
+
params,
|
|
103
|
+
});
|
|
104
|
+
const req = new http.IncomingMessage(new net.Socket());
|
|
105
|
+
req.method = 'POST';
|
|
106
|
+
req.url = '/';
|
|
107
|
+
req.headers = {
|
|
108
|
+
'content-type': 'application/json',
|
|
109
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
110
|
+
accept: 'application/json, text/event-stream',
|
|
111
|
+
...(headers.host === undefined ? {} : { host: headers.host }),
|
|
112
|
+
...(headers.origin === undefined ? {} : { origin: headers.origin }),
|
|
113
|
+
};
|
|
114
|
+
// The SDK's adapter reads the raw header list, not the parsed record.
|
|
115
|
+
req.rawHeaders = Object.entries(req.headers).flatMap(([name, value]) => [
|
|
116
|
+
name,
|
|
117
|
+
String(value),
|
|
118
|
+
]);
|
|
119
|
+
req.push(body);
|
|
120
|
+
req.push(null);
|
|
121
|
+
|
|
122
|
+
const res = new http.ServerResponse(req);
|
|
123
|
+
let out = '';
|
|
124
|
+
const append = (chunk: unknown): void => {
|
|
125
|
+
if (typeof chunk === 'string') out += chunk;
|
|
126
|
+
else if (chunk instanceof Uint8Array) out += Buffer.from(chunk).toString();
|
|
127
|
+
};
|
|
128
|
+
// No socket: a real write would buffer forever and report back-pressure,
|
|
129
|
+
// and 'finish' would never fire. Capture instead.
|
|
130
|
+
res.write = (chunk: unknown) => {
|
|
131
|
+
append(chunk);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
const done = new Promise<{ status: number; body: string }>((resolve) => {
|
|
135
|
+
res.end = ((chunk?: unknown) => {
|
|
136
|
+
append(chunk);
|
|
137
|
+
resolve({ status: res.statusCode, body: out });
|
|
138
|
+
return res;
|
|
139
|
+
}) as typeof res.end;
|
|
140
|
+
});
|
|
141
|
+
return transport.handleRequest(req, res).then(() => done);
|
|
142
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Identity } from './identity.js';
|
|
2
|
+
import type { ProxyIdentity } from './proxyContext.js';
|
|
3
|
+
import type { RejectionReason } from './rejection.js';
|
|
4
|
+
|
|
5
|
+
/** Emitted after every tool call (success, error, or rejection). */
|
|
6
|
+
export interface ToolCallTelemetryEvent {
|
|
7
|
+
type: 'tool_call';
|
|
8
|
+
requestId: string;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
duration_ms: number;
|
|
12
|
+
outcome: 'success' | 'error' | 'rejected';
|
|
13
|
+
/** Populated when `outcome` is `'rejected'`. */
|
|
14
|
+
rejectionReason?: RejectionReason;
|
|
15
|
+
identity?: Identity;
|
|
16
|
+
/** The proxy instance that handled the call (ADR-0012). */
|
|
17
|
+
proxy?: ProxyIdentity;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Emitted when one backend of a mesh fails a list call and the proxy
|
|
22
|
+
* returns the remaining backends' entries instead of failing the whole
|
|
23
|
+
* list (ADR-0013). Without an `onTelemetry` sink a degraded mesh is
|
|
24
|
+
* invisible, so an operator running one should wire it.
|
|
25
|
+
*/
|
|
26
|
+
export interface BackendDegradedTelemetryEvent {
|
|
27
|
+
type: 'backend_degraded';
|
|
28
|
+
requestId: string;
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
/** The backend key whose entries are missing from the response. */
|
|
31
|
+
backend: string;
|
|
32
|
+
/** The list call that degraded. */
|
|
33
|
+
method: 'tools/list' | 'prompts/list' | 'resources/list';
|
|
34
|
+
/** Whatever the backend threw. */
|
|
35
|
+
error: unknown;
|
|
36
|
+
identity?: Identity;
|
|
37
|
+
/** The proxy instance that handled the call (ADR-0012). */
|
|
38
|
+
proxy?: ProxyIdentity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An observability signal from the proxy, discriminated on `type`. Narrow
|
|
43
|
+
* on it before reading a member's own fields.
|
|
44
|
+
*/
|
|
45
|
+
export type TelemetryEvent =
|
|
46
|
+
ToolCallTelemetryEvent | BackendDegradedTelemetryEvent;
|