mcpose 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/core.d.ts +26 -5
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +120 -51
- package/dist/core.js.map +1 -1
- package/dist/rejection.d.ts +6 -0
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +23 -0
- package/dist/rejection.js.map +1 -1
- package/dist/sanitizeCatalog.d.ts.map +1 -1
- package/dist/sanitizeCatalog.js +8 -2
- package/dist/sanitizeCatalog.js.map +1 -1
- package/dist/sessionRegistry.d.ts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/src/__tests__/core.test.ts +86 -0
- package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
- package/src/__tests__/hasToolContent.test.ts +63 -0
- package/src/__tests__/sanitizeCatalog.test.ts +21 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +37 -0
- package/src/__tests__/startHttpProxy.test.ts +80 -0
- package/src/core.ts +145 -58
- package/src/rejection.ts +25 -0
- package/src/sanitizeCatalog.ts +8 -2
- package/src/sessionRegistry.ts +1 -1
- package/src/version.ts +1 -1
package/src/core.ts
CHANGED
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
type SessionRegistry,
|
|
71
71
|
} from './sessionRegistry.js';
|
|
72
72
|
import type { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
73
|
-
import { rejectionMcpError } from './rejection.js';
|
|
73
|
+
import { isRejectionReason, rejectionMcpError } from './rejection.js';
|
|
74
74
|
import type { RejectionReason } from './rejection.js';
|
|
75
75
|
import { VERSION } from './version.js';
|
|
76
76
|
|
|
@@ -115,11 +115,24 @@ export type ListToolsMiddleware = Middleware<ListToolsRequest, ListToolsResult>;
|
|
|
115
115
|
* This guard narrows `CompatibilityCallToolResult` to `CallToolResult`
|
|
116
116
|
* (has `.content` array). Both union members carry `[x: string]: unknown`,
|
|
117
117
|
* so this avoids unsafe casts.
|
|
118
|
+
*
|
|
119
|
+
* A guard, not an assertion: the union's two members are both objects, but a
|
|
120
|
+
* pipeline result is not the union. The legacy shape has no `.content`, and a
|
|
121
|
+
* handler that produced nothing at all (a local tool with a missing `return`,
|
|
122
|
+
* or a middleware that dropped the result) produces a nullish one, which has
|
|
123
|
+
* none either. Both answer `false` here. Throwing instead replaced whatever
|
|
124
|
+
* the pipeline produced with `TypeError: Cannot read properties of undefined
|
|
125
|
+
* (reading 'content')` — inside `mapToolResult`, and inside the `tools/call`
|
|
126
|
+
* handler's telemetry read of `result.isError`.
|
|
118
127
|
*/
|
|
119
128
|
export function hasToolContent(
|
|
120
129
|
r: CompatibilityCallToolResult,
|
|
121
130
|
): r is CallToolResult {
|
|
122
|
-
return
|
|
131
|
+
return (
|
|
132
|
+
r !== null &&
|
|
133
|
+
typeof r === 'object' &&
|
|
134
|
+
Array.isArray((r as { content?: unknown }).content)
|
|
135
|
+
);
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
/**
|
|
@@ -202,10 +215,20 @@ export interface HttpProxyOptions {
|
|
|
202
215
|
/**
|
|
203
216
|
* Called on unhandled errors instead of console.error, and on every auth
|
|
204
217
|
* hook that throws: the request still fails closed, and the operator still
|
|
205
|
-
* sees why.
|
|
218
|
+
* sees why. If this hook itself throws or returns a rejected promise, the
|
|
219
|
+
* failure is contained and changes nothing the caller sees.
|
|
220
|
+
*/
|
|
221
|
+
onError?: (err: unknown) => unknown;
|
|
222
|
+
/**
|
|
223
|
+
* Maximum request body size in bytes. Excess POST bodies return 413.
|
|
224
|
+
* Default: 4 MB. Set to `Infinity` to opt out of the cap.
|
|
225
|
+
*
|
|
226
|
+
* `0` is a valid value and means "reject every POST body", so it is not the
|
|
227
|
+
* opt-out. The two shapes that are mistakes rather than choices throw at
|
|
228
|
+
* startup: a negative number returns 413 for every POST, and `NaN`, which is
|
|
229
|
+
* what `Number(process.env.MAX_BODY_BYTES)` returns for an unset key,
|
|
230
|
+
* compares false against every body size and so gates nothing at all.
|
|
206
231
|
*/
|
|
207
|
-
onError?: (err: unknown) => void;
|
|
208
|
-
/** Maximum request body size in bytes. Default: 4 MB. */
|
|
209
232
|
maxBodyBytes?: number;
|
|
210
233
|
/**
|
|
211
234
|
* Maximum number of concurrent MCP sessions. Excess requests return 503.
|
|
@@ -494,12 +517,40 @@ export interface ProxyOptions {
|
|
|
494
517
|
* Called after every tool call with timing and outcome data. Wire to any
|
|
495
518
|
* custom telemetry sink, or to `createOtelTelemetry` from `@mcpose/otel`
|
|
496
519
|
* for OpenTelemetry spans.
|
|
497
|
-
* A
|
|
520
|
+
* A sink that throws, or whose returned promise rejects, is logged and never
|
|
521
|
+
* fails the tool call. The sink is not awaited, so a slow one does not delay
|
|
522
|
+
* the call either.
|
|
498
523
|
*/
|
|
499
|
-
onTelemetry?: (event: TelemetryEvent) =>
|
|
524
|
+
onTelemetry?: (event: TelemetryEvent) => unknown;
|
|
500
525
|
}
|
|
501
526
|
|
|
502
527
|
type ProgressToken = string | number;
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Hands one event to the telemetry sink. A sink that throws, or whose returned
|
|
531
|
+
* promise rejects, is logged here and nowhere else: the caller is already past
|
|
532
|
+
* the point where the tool call could fail, and a rejection that nobody observes
|
|
533
|
+
* ends the process on Node's default `--unhandled-rejections=throw`.
|
|
534
|
+
*
|
|
535
|
+
* The sink is called, not awaited, so a slow exporter cannot delay a tool call.
|
|
536
|
+
*
|
|
537
|
+
* `Promise.resolve` rather than an `instanceof Promise` test: a sink loaded from
|
|
538
|
+
* another realm (`node:vm`), or one handing back a non-native thenable, would
|
|
539
|
+
* fail the latter and leave its rejection unobserved all the same.
|
|
540
|
+
*/
|
|
541
|
+
function callTelemetrySink(
|
|
542
|
+
sink: ((event: TelemetryEvent) => unknown) | undefined,
|
|
543
|
+
event: TelemetryEvent,
|
|
544
|
+
): void {
|
|
545
|
+
try {
|
|
546
|
+
Promise.resolve(sink?.(event)).catch((err: unknown) => {
|
|
547
|
+
console.error(err);
|
|
548
|
+
});
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error(err);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
503
554
|
type BackendRequestOptions = Parameters<BackendClient['listTools']>[1];
|
|
504
555
|
// Structural mirror of the SDK's `RequestHandlerExtra`. The SDK declares its
|
|
505
556
|
// optional members as `?: T | undefined`, so this type has to as well to stay
|
|
@@ -658,10 +709,10 @@ function getMiddlewareContext(
|
|
|
658
709
|
}
|
|
659
710
|
|
|
660
711
|
function getRejectionReason(err: unknown): RejectionReason | undefined {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
712
|
+
const reason = (err as { data?: { rejectionReason?: unknown } } | null)?.data
|
|
713
|
+
?.rejectionReason;
|
|
714
|
+
// A reason outside the union is an ordinary failure, not a rejection (#238).
|
|
715
|
+
return isRejectionReason(reason) ? reason : undefined;
|
|
665
716
|
}
|
|
666
717
|
|
|
667
718
|
/**
|
|
@@ -957,24 +1008,18 @@ export function createProxyServer(
|
|
|
957
1008
|
method: BackendDegradedTelemetryEvent['method'],
|
|
958
1009
|
error: unknown,
|
|
959
1010
|
): void => {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
: { identity: context.identity }),
|
|
973
|
-
...(context.proxy === undefined ? {} : { proxy: context.proxy }),
|
|
974
|
-
});
|
|
975
|
-
} catch (err) {
|
|
976
|
-
console.error(err);
|
|
977
|
-
}
|
|
1011
|
+
callTelemetrySink(options.onTelemetry, {
|
|
1012
|
+
type: 'backend_degraded',
|
|
1013
|
+
requestId: context.requestId,
|
|
1014
|
+
...(context.sessionId === undefined
|
|
1015
|
+
? {}
|
|
1016
|
+
: { sessionId: context.sessionId }),
|
|
1017
|
+
backend: backendKey,
|
|
1018
|
+
method,
|
|
1019
|
+
error,
|
|
1020
|
+
...(context.identity === undefined ? {} : { identity: context.identity }),
|
|
1021
|
+
...(context.proxy === undefined ? {} : { proxy: context.proxy }),
|
|
1022
|
+
});
|
|
978
1023
|
};
|
|
979
1024
|
|
|
980
1025
|
// ── Tool handlers ──────────────────────────────────────────────────────────
|
|
@@ -1062,26 +1107,21 @@ export function createProxyServer(
|
|
|
1062
1107
|
outcome: ToolCallTelemetryEvent['outcome'],
|
|
1063
1108
|
rejectionReason?: RejectionReason,
|
|
1064
1109
|
) => {
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
});
|
|
1081
|
-
} catch (err) {
|
|
1082
|
-
// A throwing telemetry sink must never fail the tool call.
|
|
1083
|
-
console.error(err);
|
|
1084
|
-
}
|
|
1110
|
+
callTelemetrySink(options.onTelemetry, {
|
|
1111
|
+
type: 'tool_call',
|
|
1112
|
+
requestId: context.requestId,
|
|
1113
|
+
...(context.sessionId === undefined
|
|
1114
|
+
? {}
|
|
1115
|
+
: { sessionId: context.sessionId }),
|
|
1116
|
+
tool: name,
|
|
1117
|
+
duration_ms: Math.round(performance.now() - start),
|
|
1118
|
+
outcome,
|
|
1119
|
+
...(rejectionReason === undefined ? {} : { rejectionReason }),
|
|
1120
|
+
...(context.identity === undefined
|
|
1121
|
+
? {}
|
|
1122
|
+
: { identity: context.identity }),
|
|
1123
|
+
...(context.proxy === undefined ? {} : { proxy: context.proxy }),
|
|
1124
|
+
});
|
|
1085
1125
|
};
|
|
1086
1126
|
|
|
1087
1127
|
// Hidden beats pass-through. The rejection is thrown by the innermost
|
|
@@ -1168,10 +1208,29 @@ export function createProxyServer(
|
|
|
1168
1208
|
context,
|
|
1169
1209
|
);
|
|
1170
1210
|
// MCP signals tool-level failures in-band via isError, not by throwing.
|
|
1211
|
+
// Three shapes reach here, and `hasToolContent` alone cannot tell them
|
|
1212
|
+
// apart because it answers false for two of them:
|
|
1213
|
+
//
|
|
1214
|
+
// - a CallToolResult: judge it by `isError`;
|
|
1215
|
+
// - the legacy `{ toolResult }` shape (protocol 2024-10-07): a valid
|
|
1216
|
+
// result with no `.content`, so `mapToolResult` passes it through —
|
|
1217
|
+
// a success;
|
|
1218
|
+
// - nothing at all, from a local tool with a missing `return` or a
|
|
1219
|
+
// middleware that dropped the result: the call produced no result,
|
|
1220
|
+
// so it is not a success.
|
|
1221
|
+
//
|
|
1222
|
+
// A result-less call used to reach the `catch` as a TypeError from
|
|
1223
|
+
// `hasToolContent` and be recorded as an error; now that the guard
|
|
1224
|
+
// answers false instead of throwing, the shape has to be distinguished
|
|
1225
|
+
// here or a `&&` would report it as a clean one.
|
|
1226
|
+
const isResultless =
|
|
1227
|
+
result === null || result === undefined || typeof result !== 'object';
|
|
1171
1228
|
emitTelemetry(
|
|
1172
|
-
|
|
1229
|
+
isResultless
|
|
1173
1230
|
? 'error'
|
|
1174
|
-
:
|
|
1231
|
+
: hasToolContent(result) && result.isError === true
|
|
1232
|
+
? 'error'
|
|
1233
|
+
: 'success',
|
|
1175
1234
|
);
|
|
1176
1235
|
return result;
|
|
1177
1236
|
} catch (err) {
|
|
@@ -1550,6 +1609,21 @@ export function startHttpProxy(
|
|
|
1550
1609
|
`mcpose: sessionTtlMs must be <= ${MAX_NODE_TIMER_DELAY_MS} or Infinity, got ${String(httpOptions.sessionTtlMs)}`,
|
|
1551
1610
|
);
|
|
1552
1611
|
}
|
|
1612
|
+
// Resolved once, like the two limits above, so a bad value fails at startup
|
|
1613
|
+
// instead of quietly gating nothing. A `NaN` cap compares false against both
|
|
1614
|
+
// the declared `Content-Length` and the running byte count, so a body far
|
|
1615
|
+
// larger than the configured limit passes this layer untouched.
|
|
1616
|
+
// `Number(process.env.MAX_BODY_BYTES)` is the ordinary way a host wires an
|
|
1617
|
+
// operator-supplied limit in, and it is NaN when the key is unset or
|
|
1618
|
+
// misspelled; #264 rejected a non-finite `budget.maxCallsPerSession` for
|
|
1619
|
+
// exactly this reason. `Infinity` opts out, and `0`, like `maxSessions: 0`,
|
|
1620
|
+
// keeps its literal meaning instead of doubling as one.
|
|
1621
|
+
const maxBodyBytes = httpOptions.maxBodyBytes ?? 4 * 1024 * 1024;
|
|
1622
|
+
if (!(maxBodyBytes >= 0)) {
|
|
1623
|
+
throw new Error(
|
|
1624
|
+
`mcpose: maxBodyBytes must be >= 0 or Infinity, got ${String(httpOptions.maxBodyBytes)}`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1553
1627
|
// Filled in once the server is listening (the real port is only known
|
|
1554
1628
|
// then); sessions are only created after that. The SDK transport
|
|
1555
1629
|
// validates Host/Origin only against a non-empty list, so an enabled flag
|
|
@@ -1577,7 +1651,24 @@ export function startHttpProxy(
|
|
|
1577
1651
|
let pendingSessions = 0;
|
|
1578
1652
|
|
|
1579
1653
|
const reportError = (err: unknown): void => {
|
|
1580
|
-
|
|
1654
|
+
// `onError` is observability, so its own failure must change nothing, the
|
|
1655
|
+
// same containment `onAuditError` applies to `@mcpose/audit`'s reporter
|
|
1656
|
+
// (#172) and `createConsentMiddleware` applies to its resolver hook. Left
|
|
1657
|
+
// unguarded, a throwing reporter replaced the 401 this file documents for
|
|
1658
|
+
// a failed auth hook with a 500, aborted `destroySession` before it closed
|
|
1659
|
+
// the proxy server, and threw out of the `server.on('error')` handler
|
|
1660
|
+
// that exists precisely so a post-listen error does not crash the process.
|
|
1661
|
+
// An async reporter that rejects after its first await escapes the same
|
|
1662
|
+
// guard one tick later (#245): nobody observes the rejection, and Node's
|
|
1663
|
+
// default --unhandled-rejections=throw ends the process, so the returned
|
|
1664
|
+
// promise is adopted here too.
|
|
1665
|
+
try {
|
|
1666
|
+
Promise.resolve((httpOptions.onError ?? console.error)(err)).catch(() => {
|
|
1667
|
+
// Nothing left to report it to.
|
|
1668
|
+
});
|
|
1669
|
+
} catch {
|
|
1670
|
+
// Nothing left to report it to.
|
|
1671
|
+
}
|
|
1581
1672
|
};
|
|
1582
1673
|
|
|
1583
1674
|
/**
|
|
@@ -1773,11 +1864,7 @@ export function startHttpProxy(
|
|
|
1773
1864
|
}
|
|
1774
1865
|
|
|
1775
1866
|
if (method === 'POST') {
|
|
1776
|
-
const rejected = applyBodySizeLimit(
|
|
1777
|
-
req,
|
|
1778
|
-
res,
|
|
1779
|
-
httpOptions.maxBodyBytes ?? 4 * 1024 * 1024,
|
|
1780
|
-
);
|
|
1867
|
+
const rejected = applyBodySizeLimit(req, res, maxBodyBytes);
|
|
1781
1868
|
if (rejected) return;
|
|
1782
1869
|
}
|
|
1783
1870
|
|
|
@@ -1955,7 +2042,7 @@ export function startHttpProxy(
|
|
|
1955
2042
|
|
|
1956
2043
|
handle().catch((err) => {
|
|
1957
2044
|
if (!res.headersSent) res.writeHead(500).end();
|
|
1958
|
-
(
|
|
2045
|
+
reportError(err);
|
|
1959
2046
|
});
|
|
1960
2047
|
};
|
|
1961
2048
|
|
package/src/rejection.ts
CHANGED
|
@@ -23,6 +23,31 @@ export type RejectionReason =
|
|
|
23
23
|
| 'SESSION_LIMIT' // max concurrent sessions reached (HTTP 503)
|
|
24
24
|
| 'BODY_LIMIT'; // request body exceeded maxBodyBytes (HTTP 413)
|
|
25
25
|
|
|
26
|
+
// The `Record` annotation keeps this table and the union from drifting apart:
|
|
27
|
+
// a missing or extra key is a compile error.
|
|
28
|
+
const REJECTION_REASONS: Record<RejectionReason, true> = {
|
|
29
|
+
TOOL_HIDDEN: true,
|
|
30
|
+
RESOURCE_HIDDEN: true,
|
|
31
|
+
BACKEND_UNROUTABLE: true,
|
|
32
|
+
POLICY_DENIED: true,
|
|
33
|
+
IDENTITY_UNRESOLVED: true,
|
|
34
|
+
CONSENT_MISSING: true,
|
|
35
|
+
SENSITIVITY_BLOCKED: true,
|
|
36
|
+
DELEGATION_INVALID: true,
|
|
37
|
+
BUDGET_EXCEEDED: true,
|
|
38
|
+
SESSION_LIMIT: true,
|
|
39
|
+
BODY_LIMIT: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Narrows an untrusted value to `RejectionReason`.
|
|
44
|
+
* `Object.hasOwn`, not `in`: the value can come from an upstream error, so a
|
|
45
|
+
* prototype key like `constructor` must not pass as a member.
|
|
46
|
+
*/
|
|
47
|
+
export function isRejectionReason(value: unknown): value is RejectionReason {
|
|
48
|
+
return typeof value === 'string' && Object.hasOwn(REJECTION_REASONS, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
/**
|
|
27
52
|
* Creates an `McpError` with the rejection reason embedded in `error.data`.
|
|
28
53
|
* The top-level `code` is unchanged so clients that only inspect the code
|
package/src/sanitizeCatalog.ts
CHANGED
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ListToolsMiddleware } from './core.js';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Matches http(s) URLs; always stripped regardless of `patterns`.
|
|
11
|
+
* The `i` flag is load-bearing: URI schemes are case-insensitive
|
|
12
|
+
* (RFC 3986 §6.2.2), so an uppercase `HTTPS://` is the same URL, and a
|
|
13
|
+
* case-sensitive strip would let an upstream leak a hostname simply by
|
|
14
|
+
* capitalizing the scheme.
|
|
15
|
+
*/
|
|
16
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/gi;
|
|
11
17
|
|
|
12
18
|
/** Options for {@link sanitizeToolDescriptions}. */
|
|
13
19
|
export interface SanitizeToolDescriptionsOptions {
|
package/src/sessionRegistry.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface SessionRecord {
|
|
|
48
48
|
export interface SessionRegistry {
|
|
49
49
|
/**
|
|
50
50
|
* Persists a record. Awaited before the client learns its session id, so a
|
|
51
|
-
* rejection fails the initialize with a
|
|
51
|
+
* rejection fails the initialize with a 400 rather than handing out an id
|
|
52
52
|
* that no other instance could honour.
|
|
53
53
|
*/
|
|
54
54
|
set(sessionId: string, record: SessionRecord): Promise<void>;
|
package/src/version.ts
CHANGED