@nifrajs/mcp 3.3.0 → 4.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/src/http.ts CHANGED
@@ -8,12 +8,20 @@
8
8
  * the MCP Apps `ui://` widgets) so each host supplies its own corpus/source behind one core.
9
9
  */
10
10
 
11
+ import {
12
+ assertTransportByteLimit,
13
+ MAX_TRANSPORT_BYTES,
14
+ parseTransportContentLength,
15
+ readBoundedTransportBytes,
16
+ } from "@nifrajs/agent-protocol"
11
17
  import {
12
18
  handleRpc,
19
+ isJsonRpcRequest,
13
20
  type JsonRpcNotification,
14
21
  type JsonRpcRequest,
15
22
  type JsonRpcResponse,
16
23
  MCP_ERROR,
24
+ type McpProtocolState,
17
25
  type McpServerFeatures,
18
26
  type McpTool,
19
27
  modernVersionOf,
@@ -32,28 +40,119 @@ const CORS_BASE: Record<string, string> = {
32
40
  "access-control-max-age": "86400",
33
41
  }
34
42
 
43
+ /**
44
+ * Same host, and an Origin scheme equal to or stronger than the request URL's: an `https:` page may reach
45
+ * an `http:` URL (a TLS-terminating proxy in front of the server), never the reverse. Mirrors core's
46
+ * `isSameOriginRequest`; inlined because `@nifrajs/core` is an optional peer of this transport.
47
+ */
48
+ function isSameOrigin(origin: string, request: Request): boolean {
49
+ try {
50
+ const from = new URL(origin)
51
+ const own = new URL(request.url)
52
+ if (from.host !== own.host) return false
53
+ if (from.protocol === "https:") return own.protocol === "https:" || own.protocol === "http:"
54
+ if (from.protocol === "http:") return own.protocol === "http:"
55
+ return false
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ interface HostAuthority {
62
+ readonly hostname: string
63
+ readonly port?: string
64
+ }
65
+
66
+ /** Parse a bare `host[:port]` authority without accepting userinfo, paths, whitespace, or fragments. */
67
+ function parseHostAuthority(value: string): HostAuthority | undefined {
68
+ if (value.length === 0 || /[\r\n\s@/?#]/.test(value)) return undefined
69
+ let hostname: string
70
+ let port: string | undefined
71
+ if (value.startsWith("[")) {
72
+ const close = value.indexOf("]")
73
+ if (close === -1) return undefined
74
+ const address = value.slice(1, close)
75
+ if (address.length === 0 || !/^[0-9a-f:.%]+$/i.test(address)) return undefined
76
+ hostname = `[${address.toLowerCase()}]`
77
+ const rest = value.slice(close + 1)
78
+ if (rest !== "") {
79
+ if (!rest.startsWith(":")) return undefined
80
+ port = rest.slice(1)
81
+ }
82
+ } else {
83
+ const colon = value.indexOf(":")
84
+ if (colon === -1) {
85
+ hostname = value.toLowerCase()
86
+ } else {
87
+ if (value.indexOf(":", colon + 1) !== -1) return undefined
88
+ hostname = value.slice(0, colon).toLowerCase()
89
+ port = value.slice(colon + 1)
90
+ }
91
+ if (hostname.length === 0 || !/^[a-z0-9.-]+$/.test(hostname)) return undefined
92
+ }
93
+ if (port !== undefined) {
94
+ if (!/^\d{1,5}$/.test(port)) return undefined
95
+ const numeric = Number(port)
96
+ if (!Number.isSafeInteger(numeric) || numeric > 65_535) return undefined
97
+ port = String(numeric)
98
+ }
99
+ return port === undefined ? { hostname } : { hostname, port }
100
+ }
101
+
102
+ function defaultPort(protocol: string): string | undefined {
103
+ if (protocol === "http:") return "80"
104
+ if (protocol === "https:") return "443"
105
+ return undefined
106
+ }
107
+
108
+ /**
109
+ * The Host guard. Prefer the inbound Host header: an adapter may deliberately build `request.url`
110
+ * with a canonical authority, but that must not hide the attacker-controlled Host this guard exists
111
+ * to check. An allowlist entry without a port matches any port; one with a port matches the effective
112
+ * port (so `localhost:80` matches an HTTP URL whose canonical form omits `:80`).
113
+ */
114
+ function hostAllowed(request: Request, allowedHosts: readonly string[] | undefined): boolean {
115
+ if (allowedHosts === undefined) return true
116
+ const url = new URL(request.url)
117
+ const actual = parseHostAuthority(request.headers.get("host") ?? url.host)
118
+ if (actual === undefined) return false
119
+ const actualPort = actual.port ?? defaultPort(url.protocol)
120
+ for (const value of allowedHosts) {
121
+ const candidate = parseHostAuthority(value)
122
+ if (candidate === undefined || candidate.hostname !== actual.hostname) continue
123
+ if (candidate.port === undefined || candidate.port === actualPort) return true
124
+ }
125
+ return false
126
+ }
127
+
35
128
  /**
36
129
  * Resolve the CORS/Origin headers for one request against the host's `allowedOrigins` policy, or `null`
37
130
  * when the request's `Origin` is present but not allowed - the caller then answers 403, per the
38
131
  * Streamable-HTTP DNS-rebinding rule ("Servers MUST validate the `Origin` header ... respond with 403").
39
- * With no policy (the default) every origin is allowed and reflected as `*` - correct for a public,
40
- * secret-free, unauthenticated server, whose browser clients send arbitrary origins we can't enumerate.
132
+ * The default is same-origin. Public, secret-free servers must explicitly opt into `allowAnyOrigin`.
41
133
  */
42
134
  function corsFor(
43
135
  request: Request,
44
136
  allowedOrigins: readonly string[] | undefined,
137
+ allowAnyOrigin: boolean,
45
138
  ): Record<string, string> | null {
46
- if (allowedOrigins === undefined) return { ...CORS_BASE, "access-control-allow-origin": "*" }
139
+ if (allowAnyOrigin) return { ...CORS_BASE, "access-control-allow-origin": "*" }
47
140
  const origin = request.headers.get("origin")
48
- // A caller with no Origin (curl, server-to-server) can't mount a DNS-rebinding attack - allow it.
141
+ // No Origin: curl, server-to-server, or a same-origin browser GET. `allowedHosts` covers the last one.
49
142
  if (origin === null) return { ...CORS_BASE, vary: "Origin" }
50
- if (allowedOrigins.includes(origin)) {
143
+ if (allowedOrigins?.includes(origin)) {
144
+ return { ...CORS_BASE, "access-control-allow-origin": origin, vary: "Origin" }
145
+ }
146
+ if (allowedOrigins === undefined && isSameOrigin(origin, request)) {
51
147
  return { ...CORS_BASE, "access-control-allow-origin": origin, vary: "Origin" }
52
148
  }
53
149
  return null
54
150
  }
55
151
 
56
152
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
153
+ const MAX_BODY_BYTES = MAX_TRANSPORT_BYTES
154
+ const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000
155
+ const MAX_RESPONSE_BYTES = MAX_TRANSPORT_BYTES
57
156
  const TEXT_DECODER = new TextDecoder()
58
157
  const TEXT_ENCODER = new TextEncoder()
59
158
  const SSE_CONTENT_TYPE = "text/event-stream; charset=utf-8"
@@ -62,24 +161,53 @@ const SSE_KEEP_ALIVE_MS = 15_000
62
161
  /** Invalid byte caps make `total > maxBytes` fail open (especially for `NaN`). Reject configuration
63
162
  * before any request body is read so MCP cannot silently lose its memory bound. */
64
163
  function assertByteLimit(value: number): void {
65
- if (!Number.isSafeInteger(value) || value < 0) {
66
- throw new RangeError("MCP maxBodyBytes must be a non-negative safe integer")
67
- }
164
+ assertTransportByteLimit(value, { maximum: MAX_BODY_BYTES, label: "MCP maxBodyBytes" })
165
+ }
166
+
167
+ function assertResponseLimit(value: number): void {
168
+ assertTransportByteLimit(value, {
169
+ minimum: 1_024,
170
+ maximum: MAX_RESPONSE_BYTES,
171
+ label: "MCP maxResponseBytes",
172
+ })
68
173
  }
69
174
 
70
175
  export interface McpHttpOptions {
71
176
  /** Maximum JSON-RPC request body size in bytes. Default 1 MB. */
72
177
  readonly maxBodyBytes?: number
178
+ /** Maximum serialized JSON-RPC response or SSE frame size. Default 1 MB. */
179
+ readonly maxResponseBytes?: number
73
180
  /** Resources / prompts / the MCP Apps UI extension served alongside the tools. */
74
181
  readonly features?: McpServerFeatures
75
182
  /** Shown on the GET health page so each host can describe itself. */
76
183
  readonly health?: string
184
+ /** Explicitly expose a secret-free, unauthenticated server to browser clients from any origin. */
185
+ readonly allowAnyOrigin?: boolean
186
+ /** Origin allowlist for the DNS-rebinding guard. When set, only exact origins are accepted. */
187
+ readonly allowedOrigins?: readonly string[]
77
188
  /**
78
- * Origin allowlist for the DNS-rebinding guard. Omit (the default) to allow any origin - the right
79
- * choice for a public, unauthenticated docs server that can't enumerate its browser clients' origins.
80
- * Set it (e.g. a localhost origin for a hardened local host) to reject any other browser origin with 403.
189
+ * Host allowlist - the DNS-rebinding guard for a server on localhost or a private network. A rebound
190
+ * page reaches the server under the attacker's hostname with a matching Origin, so the Origin check
191
+ * alone cannot stop it. Entries are `host[:port]`; without a port they match any port. Every request,
192
+ * with or without an Origin, whose Host is not listed gets 403. Example: `["localhost", "127.0.0.1"]`.
81
193
  */
82
- readonly allowedOrigins?: readonly string[]
194
+ readonly allowedHosts?: readonly string[]
195
+ /**
196
+ * Shared request registry for one authenticated MCP session. Pass the same state to the
197
+ * request that starts a tool call and its `notifications/cancelled` request. Do not share one
198
+ * state across unrelated callers unless the surrounding authorization boundary makes their
199
+ * request-id namespace exclusive.
200
+ */
201
+ readonly state?: McpProtocolState
202
+ /**
203
+ * Resolve a session-scoped request registry after authorization. This is the preferred seam for
204
+ * multi-user HTTP hosts: bind the returned state to the authenticated caller/session and return
205
+ * `undefined` for stateless requests. A resolver failure fails closed with a generic denial.
206
+ */
207
+ readonly resolveState?: (
208
+ request: Request,
209
+ message: JsonRpcRequest,
210
+ ) => McpProtocolState | undefined | Promise<McpProtocolState | undefined>
83
211
  /**
84
212
  * Optional authorization, run once per request against the parsed message - after the body has been
85
213
  * read under the size cap, so it never costs a second read of the stream. Returning `false` answers
@@ -108,6 +236,7 @@ function eventStreamResponse(
108
236
  headers: Record<string, string>,
109
237
  run: (stream: EventStreamControl) => void | Promise<void>,
110
238
  keepAliveMs = 0,
239
+ maxFrameBytes = DEFAULT_MAX_RESPONSE_BYTES,
111
240
  ): Response {
112
241
  const abortController = new AbortController()
113
242
  let closed = false
@@ -154,7 +283,15 @@ function eventStreamResponse(
154
283
  signal: abortController.signal,
155
284
  closed: closedPromise,
156
285
  send(message) {
157
- write(`data: ${JSON.stringify(message)}\n\n`)
286
+ const encoded = encodeJson(message, maxFrameBytes)
287
+ const id = "id" in message ? message.id : null
288
+ const payload = encoded ?? JSON.stringify(rpcError(id ?? null, -32000, "response too large"))
289
+ const frame = `data: ${payload}\n\n`
290
+ write(
291
+ TEXT_ENCODER.encode(frame).byteLength <= maxFrameBytes
292
+ ? frame
293
+ : `data: ${JSON.stringify(rpcError(id ?? null, -32000, "response too large"))}\n\n`,
294
+ )
158
295
  },
159
296
  comment(value = "") {
160
297
  write(`: ${value.replace(/[\r\n]/g, "")}\n\n`)
@@ -205,16 +342,16 @@ function eventStreamResponse(
205
342
  })
206
343
  }
207
344
 
208
- function parseContentLength(value: string): number | undefined {
209
- if (value.length === 0) return undefined
210
- let length = 0
211
- for (let i = 0; i < value.length; i++) {
212
- const digit = value.charCodeAt(i) - 48
213
- if (digit < 0 || digit > 9) return undefined
214
- length = length * 10 + digit
215
- if (length > Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY
216
- }
217
- return length
345
+ /** Validate the HTTP envelope before any method-specific access or authorization callback. */
346
+ function parseHttpMessage(value: unknown): JsonRpcRequest | undefined {
347
+ return isJsonRpcRequest(value) ? value : undefined
348
+ }
349
+
350
+ function unauthorizedResponse(id: JsonRpcRequest["id"], headers: Record<string, string>): Response {
351
+ return Response.json(rpcError(id ?? null, MCP_ERROR.UNAUTHORIZED, "unauthorized"), {
352
+ status: 403,
353
+ headers,
354
+ })
218
355
  }
219
356
 
220
357
  async function readJsonBounded(
@@ -223,7 +360,7 @@ async function readJsonBounded(
223
360
  ): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> {
224
361
  const declared = request.headers.get("content-length")
225
362
  if (declared !== null) {
226
- const length = parseContentLength(declared)
363
+ const length = parseTransportContentLength(declared)
227
364
  if (length === undefined) return { ok: false, status: 400 }
228
365
  if (length > maxBytes) return { ok: false, status: 413 }
229
366
  }
@@ -231,33 +368,31 @@ async function readJsonBounded(
231
368
  const body = request.body
232
369
  if (body === null) return { ok: false, status: 400 }
233
370
 
234
- const reader = body.getReader()
235
- const chunks: Uint8Array[] = []
236
- let total = 0
237
- for (;;) {
238
- const { done, value } = await reader.read()
239
- if (done) break
240
- total += value.byteLength
241
- if (total > maxBytes) {
242
- await reader.cancel()
243
- return { ok: false, status: 413 }
244
- }
245
- chunks.push(value)
371
+ const read = await readBoundedTransportBytes(body, maxBytes)
372
+ if (!read.ok) return { ok: false, status: read.reason === "too-large" ? 413 : 400 }
373
+ try {
374
+ return { ok: true, value: JSON.parse(TEXT_DECODER.decode(read.bytes)) as unknown }
375
+ } catch {
376
+ return { ok: false, status: 400 }
246
377
  }
378
+ }
247
379
 
248
- const bytes = new Uint8Array(total)
249
- let offset = 0
250
- for (const chunk of chunks) {
251
- bytes.set(chunk, offset)
252
- offset += chunk.byteLength
253
- }
380
+ function encodeJson(value: unknown, maxBytes: number): string | undefined {
254
381
  try {
255
- return { ok: true, value: JSON.parse(TEXT_DECODER.decode(bytes)) as unknown }
382
+ const encoded = JSON.stringify(value)
383
+ if (typeof encoded !== "string") return undefined
384
+ if (TEXT_ENCODER.encode(encoded).byteLength > maxBytes) return undefined
385
+ return encoded
256
386
  } catch {
257
- return { ok: false, status: 400 }
387
+ return undefined
258
388
  }
259
389
  }
260
390
 
391
+ function boundedResponse(response: JsonRpcResponse, maxBytes: number): JsonRpcResponse {
392
+ if (encodeJson(response, maxBytes) !== undefined) return response
393
+ return rpcError(response.id, -32000, "response too large")
394
+ }
395
+
261
396
  const SENTINEL_PREFIX = "=?base64?"
262
397
  const SENTINEL_SUFFIX = "?="
263
398
 
@@ -332,7 +467,12 @@ export async function respondMcpHttp(
332
467
  ): Promise<Response> {
333
468
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
334
469
  assertByteLimit(maxBodyBytes)
335
- const cors = corsFor(request, options.allowedOrigins)
470
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
471
+ assertResponseLimit(maxResponseBytes)
472
+ if (!hostAllowed(request, options.allowedHosts)) {
473
+ return Response.json(rpcError(null, -32600, "host not allowed"), { status: 403 })
474
+ }
475
+ const cors = corsFor(request, options.allowedOrigins, options.allowAnyOrigin === true)
336
476
  if (cors === null) {
337
477
  // Origin present but not allowlisted: reject before the body is ever read (DNS-rebinding guard). No
338
478
  // `id` (no request parsed) and no CORS headers - a disallowed origin gets nothing to work with.
@@ -352,6 +492,7 @@ export async function respondMcpHttp(
352
492
  await stream.closed
353
493
  },
354
494
  SSE_KEEP_ALIVE_MS,
495
+ maxResponseBytes,
355
496
  )
356
497
  }
357
498
  return new Response(
@@ -376,14 +517,31 @@ export async function respondMcpHttp(
376
517
  }
377
518
  return Response.json(rpcError(null, -32700, "parse error"), { status: 400, headers: cors })
378
519
  }
379
- const message = parsed.value as JsonRpcRequest
520
+ const message = parseHttpMessage(parsed.value)
521
+ if (message === undefined)
522
+ return Response.json(rpcError(null, -32600, "Invalid Request"), {
523
+ status: 400,
524
+ headers: cors,
525
+ })
380
526
  if (options.authorizeMessage !== undefined) {
381
- const authorized = await options.authorizeMessage(message, request)
527
+ let authorized = false
528
+ try {
529
+ authorized = await options.authorizeMessage(message, request)
530
+ } catch {
531
+ // Authorization is an application boundary. Fail closed without reflecting verifier or
532
+ // credential-store diagnostics to an unauthenticated caller.
533
+ return unauthorizedResponse(message.id, cors)
534
+ }
382
535
  if (!authorized) {
383
- return Response.json(rpcError(message.id ?? null, MCP_ERROR.UNAUTHORIZED, "unauthorized"), {
384
- status: 403,
385
- headers: cors,
386
- })
536
+ return unauthorizedResponse(message.id, cors)
537
+ }
538
+ }
539
+ let state: McpProtocolState | undefined = options.state
540
+ if (options.resolveState !== undefined) {
541
+ try {
542
+ state = await options.resolveState(request, message)
543
+ } catch {
544
+ return unauthorizedResponse(message.id, cors)
387
545
  }
388
546
  }
389
547
  const bodyVersion = modernVersionOf(message.params)
@@ -397,20 +555,27 @@ export async function respondMcpHttp(
397
555
  }
398
556
  const dispatch = (stream?: EventStreamControl): Promise<JsonRpcResponse | null> =>
399
557
  handleRpc(message, tools, serverInfo, options.features ?? {}, {
558
+ ...(state === undefined ? {} : { state }),
400
559
  signal: stream?.signal ?? request.signal,
401
560
  ...(stream !== undefined ? { sendNotification: stream.send } : {}),
402
561
  })
403
562
  if (acceptsEventStream(request)) {
404
- return eventStreamResponse(request, headers, async (stream) => {
405
- const response = await dispatch(stream)
406
- if (response !== null) stream.send(response)
407
- stream.close()
408
- })
563
+ return eventStreamResponse(
564
+ request,
565
+ headers,
566
+ async (stream) => {
567
+ const response = await dispatch(stream)
568
+ if (response !== null) stream.send(response)
569
+ stream.close()
570
+ },
571
+ 0,
572
+ maxResponseBytes,
573
+ )
409
574
  }
410
575
  const response = await dispatch()
411
576
  // A notification (no id) yields null - acknowledge with 202 Accepted and no body (Streamable-HTTP).
412
577
  if (response === null) return new Response(null, { status: 202, headers })
413
578
  // Modern requests carry spec HTTP statuses (400 version/header, 404 unknown method); legacy stays 200.
414
579
  const status = bodyVersion !== undefined ? modernErrorStatus(response) : 200
415
- return Response.json(response, { status, headers })
580
+ return Response.json(boundedResponse(response, maxResponseBytes), { status, headers })
416
581
  }
package/src/protocol.ts CHANGED
@@ -38,6 +38,9 @@ export const MCP_ERROR = {
38
38
  } as const
39
39
  const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"
40
40
  const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
41
+ const MAX_RPC_ID_LENGTH = 128
42
+ const MAX_RPC_METHOD_LENGTH = 128
43
+ const MAX_CANCEL_REASON_LENGTH = 512
41
44
 
42
45
  /** The MIME type a UI resource MUST use so a host recognizes it as an MCP App widget (SEP-1865). */
43
46
  export const UI_MIME = "text/html;profile=mcp-app"
@@ -177,15 +180,86 @@ export interface McpProtocolOptions {
177
180
  readonly state?: McpProtocolState
178
181
  readonly signal?: AbortSignal
179
182
  readonly sendNotification?: (notification: JsonRpcNotification) => void
183
+ /**
184
+ * Answer a throwing tool with its error message, not only "Tool execution failed". Only for a
185
+ * transport whose caller already has the project, such as a local stdio server: paths, SQL
186
+ * diagnostics and provider responses end up in error messages, so a remote caller never sees them.
187
+ */
188
+ readonly exposeToolErrors?: boolean
180
189
  }
181
190
 
191
+ const MAX_TOOL_ERROR_CHARS = 16_384
192
+
182
193
  export function createMcpProtocolState(): McpProtocolState {
183
194
  return { activeRequests: new Map() }
184
195
  }
185
196
 
186
197
  function requestKey(id: unknown): string | undefined {
187
198
  if (typeof id === "string") return `s:${id}`
188
- return typeof id === "number" && Number.isFinite(id) ? `n:${id}` : undefined
199
+ return typeof id === "number" && Number.isSafeInteger(id) ? `n:${id}` : undefined
200
+ }
201
+
202
+ function record(value: unknown): value is Record<string, unknown> {
203
+ return value !== null && typeof value === "object" && !Array.isArray(value)
204
+ }
205
+
206
+ /** Validate a JSON-RPC request/notification before any transport or method-specific access. */
207
+ export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
208
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
209
+ const record = value as Record<string, unknown>
210
+ if (Object.hasOwn(record, "result") || Object.hasOwn(record, "error")) return false
211
+ if (Object.hasOwn(record, "jsonrpc") && record.jsonrpc !== "2.0") return false
212
+ if (
213
+ typeof record.method !== "string" ||
214
+ record.method.length === 0 ||
215
+ record.method.length > MAX_RPC_METHOD_LENGTH
216
+ )
217
+ return false
218
+ if (Object.hasOwn(record, "id")) {
219
+ const id = record.id
220
+ if (
221
+ id !== null &&
222
+ typeof id !== "string" &&
223
+ !(typeof id === "number" && Number.isSafeInteger(id))
224
+ )
225
+ return false
226
+ if (typeof id === "string" && id.length > MAX_RPC_ID_LENGTH) return false
227
+ }
228
+ return (
229
+ !Object.hasOwn(record, "params") ||
230
+ (record.params !== null && typeof record.params === "object" && !Array.isArray(record.params))
231
+ )
232
+ }
233
+
234
+ /** Validate a JSON-RPC response received by a transport-side client hook. Responses are not accepted
235
+ * by {@link handleRpc}; this guard exists for the stdio roots/list answer, which is the one server-
236
+ * initiated request response the CLI consumes itself. */
237
+ export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse {
238
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
239
+ const record = value as Record<string, unknown>
240
+ if (record.jsonrpc !== "2.0") return false
241
+ if (Object.hasOwn(record, "method") || Object.hasOwn(record, "params")) return false
242
+ if (
243
+ record.id !== null &&
244
+ typeof record.id !== "string" &&
245
+ !(typeof record.id === "number" && Number.isSafeInteger(record.id))
246
+ )
247
+ return false
248
+ if (typeof record.id === "string" && record.id.length > MAX_RPC_ID_LENGTH) return false
249
+ const hasResult = Object.hasOwn(record, "result")
250
+ const hasError = Object.hasOwn(record, "error")
251
+ if (hasResult === hasError) return false
252
+ if (!hasError) return true
253
+ const error = record.error
254
+ if (error === null || typeof error !== "object" || Array.isArray(error)) return false
255
+ const errorRecord = error as Record<string, unknown>
256
+ return (
257
+ typeof errorRecord.code === "number" &&
258
+ Number.isSafeInteger(errorRecord.code) &&
259
+ typeof errorRecord.message === "string" &&
260
+ errorRecord.message.length > 0 &&
261
+ errorRecord.message.length <= MAX_RPC_METHOD_LENGTH
262
+ )
189
263
  }
190
264
 
191
265
  function metaObject(
@@ -213,7 +287,9 @@ function progressTokenOf(params: Record<string, unknown> | undefined): ProgressT
213
287
 
214
288
  function cancellationReason(params: Record<string, unknown> | undefined): string | undefined {
215
289
  const reason = params?.reason
216
- return typeof reason === "string" && reason.length > 0 ? reason : undefined
290
+ return typeof reason === "string" && reason.length > 0
291
+ ? reason.slice(0, MAX_CANCEL_REASON_LENGTH)
292
+ : undefined
217
293
  }
218
294
 
219
295
  function linkAbortSignal(parent: AbortSignal | undefined, child: AbortController): () => void {
@@ -294,6 +370,7 @@ export async function handleRpc(
294
370
  features: McpServerFeatures = {},
295
371
  options: McpProtocolOptions = {},
296
372
  ): Promise<JsonRpcResponse | null> {
373
+ if (!isJsonRpcRequest(message)) return rpcError(null, -32600, "Invalid Request")
297
374
  const { id, method, params } = message
298
375
  const isNotification = id === undefined
299
376
  const rid = id ?? null
@@ -416,7 +493,10 @@ export async function handleRpc(
416
493
  const name = params?.name
417
494
  const tool = tools.find((t) => t.name === name)
418
495
  if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
419
- const args = (params?.arguments as Record<string, unknown>) ?? {}
496
+ const suppliedArguments = params?.arguments
497
+ if (suppliedArguments !== undefined && !record(suppliedArguments))
498
+ return rpcError(rid, -32602, "tool arguments must be an object")
499
+ const args = suppliedArguments ?? {}
420
500
  const controller = new AbortController()
421
501
  const key = requestKey(id)
422
502
  if (key !== undefined && state.activeRequests.has(key)) {
@@ -458,15 +538,23 @@ export async function handleRpc(
458
538
  if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
459
539
  reportProgress(1, 1)
460
540
  return reply(toolCallResult(result, tool))
461
- } catch (err) {
541
+ } catch (error) {
462
542
  if (controller.signal.aborted) {
463
543
  return reply({
464
544
  content: [{ type: "text", text: abortMessage(controller.signal) }],
465
545
  isError: true,
466
546
  })
467
547
  }
468
- const msg = err instanceof Error ? err.message : String(err)
469
- return reply({ content: [{ type: "text", text: `Error: ${msg}` }], isError: true })
548
+ // Handler errors are application-internal data. Never reflect their messages to a remote
549
+ // MCP caller: paths, SQL diagnostics, provider responses, and secrets commonly end up there.
550
+ const detail =
551
+ options.exposeToolErrors === true && error instanceof Error && error.message !== ""
552
+ ? `: ${error.message.slice(0, MAX_TOOL_ERROR_CHARS)}`
553
+ : ""
554
+ return reply({
555
+ content: [{ type: "text", text: `Tool execution failed${detail}` }],
556
+ isError: true,
557
+ })
470
558
  } finally {
471
559
  cleanupAbort()
472
560
  if (key !== undefined && state.activeRequests.get(key) === controller) {
@@ -526,15 +614,18 @@ export async function handleRpc(
526
614
  const name = params?.name
527
615
  const prompt = prompts.find((p) => p.name === name)
528
616
  if (!prompt) return rpcError(rid, -32602, `unknown prompt: ${String(name)}`)
529
- const args = (params?.arguments as Record<string, unknown>) ?? {}
617
+ const suppliedArguments = params?.arguments
618
+ if (suppliedArguments !== undefined && !record(suppliedArguments))
619
+ return rpcError(rid, -32602, "prompt arguments must be an object")
620
+ const args = suppliedArguments ?? {}
530
621
  try {
531
622
  return reply({
532
623
  description: prompt.description,
533
624
  messages: await prompt.handler(args),
534
625
  })
535
- } catch (err) {
536
- const msg = err instanceof Error ? err.message : String(err)
537
- return rpcError(rid, -32000, msg)
626
+ } catch {
627
+ // Prompt handlers have the same trust boundary as tools; expose only a stable protocol error.
628
+ return rpcError(rid, -32000, "Prompt execution failed")
538
629
  }
539
630
  }
540
631
  default:
package/src/react.ts CHANGED
@@ -82,6 +82,8 @@ export async function reactWidget(opts: ReactWidgetOptions): Promise<McpWidget>
82
82
  ...(opts.description !== undefined ? { description: opts.description } : {}),
83
83
  ...(opts.title !== undefined ? { title: opts.title } : {}),
84
84
  ...(opts.head !== undefined ? { head: opts.head } : {}),
85
- html: `<div id="root"></div>\n<script>${safe}</script>`,
85
+ // `safe` is a Bun-generated JavaScript bundle with every HTML script end-tag escaped before it is
86
+ // placed in this inline script. The widget test asserts the generated document remains intact.
87
+ html: `<div id="root"></div>\n<script>${safe}</script>`, // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
86
88
  })
87
89
  }