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/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 Array.isArray(r.content);
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 throwing sink is logged and never fails the tool call.
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) => void;
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 data = (err as { data?: { rejectionReason?: unknown } } | null)?.data;
662
- return typeof data?.rejectionReason === 'string'
663
- ? (data.rejectionReason as RejectionReason)
664
- : undefined;
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
- try {
961
- options.onTelemetry?.({
962
- type: 'backend_degraded',
963
- requestId: context.requestId,
964
- ...(context.sessionId === undefined
965
- ? {}
966
- : { sessionId: context.sessionId }),
967
- backend: backendKey,
968
- method,
969
- error,
970
- ...(context.identity === undefined
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
- try {
1066
- options.onTelemetry?.({
1067
- type: 'tool_call',
1068
- requestId: context.requestId,
1069
- ...(context.sessionId === undefined
1070
- ? {}
1071
- : { sessionId: context.sessionId }),
1072
- tool: name,
1073
- duration_ms: Math.round(performance.now() - start),
1074
- outcome,
1075
- ...(rejectionReason === undefined ? {} : { rejectionReason }),
1076
- ...(context.identity === undefined
1077
- ? {}
1078
- : { identity: context.identity }),
1079
- ...(context.proxy === undefined ? {} : { proxy: context.proxy }),
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
- hasToolContent(result) && result.isError === true
1229
+ isResultless
1173
1230
  ? 'error'
1174
- : 'success',
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
- (httpOptions.onError ?? console.error)(err);
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
- (httpOptions.onError ?? console.error)(err);
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
@@ -6,8 +6,14 @@
6
6
 
7
7
  import type { ListToolsMiddleware } from './core.js';
8
8
 
9
- /** Matches http(s) URLs; always stripped regardless of `patterns`. */
10
- const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/g;
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 {
@@ -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 500 rather than handing out an id
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
@@ -3,4 +3,4 @@
3
3
  * `createRequire('../package.json')` lookup breaks when consumers bundle
4
4
  * the package). Kept in sync with package.json by `version.test.ts`.
5
5
  */
6
- export const VERSION = '3.0.0';
6
+ export const VERSION = '3.1.0';