@nifrajs/mcp 3.2.0 → 3.5.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,
@@ -36,24 +44,30 @@ const CORS_BASE: Record<string, string> = {
36
44
  * Resolve the CORS/Origin headers for one request against the host's `allowedOrigins` policy, or `null`
37
45
  * when the request's `Origin` is present but not allowed - the caller then answers 403, per the
38
46
  * 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.
47
+ * The default is same-origin. Public, secret-free servers must explicitly opt into `allowAnyOrigin`.
41
48
  */
42
49
  function corsFor(
43
50
  request: Request,
44
51
  allowedOrigins: readonly string[] | undefined,
52
+ allowAnyOrigin: boolean,
45
53
  ): Record<string, string> | null {
46
- if (allowedOrigins === undefined) return { ...CORS_BASE, "access-control-allow-origin": "*" }
54
+ if (allowAnyOrigin) return { ...CORS_BASE, "access-control-allow-origin": "*" }
47
55
  const origin = request.headers.get("origin")
48
56
  // A caller with no Origin (curl, server-to-server) can't mount a DNS-rebinding attack - allow it.
49
57
  if (origin === null) return { ...CORS_BASE, vary: "Origin" }
50
- if (allowedOrigins.includes(origin)) {
58
+ if (allowedOrigins?.includes(origin)) {
59
+ return { ...CORS_BASE, "access-control-allow-origin": origin, vary: "Origin" }
60
+ }
61
+ if (allowedOrigins === undefined && origin === new URL(request.url).origin) {
51
62
  return { ...CORS_BASE, "access-control-allow-origin": origin, vary: "Origin" }
52
63
  }
53
64
  return null
54
65
  }
55
66
 
56
67
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
68
+ const MAX_BODY_BYTES = MAX_TRANSPORT_BYTES
69
+ const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000
70
+ const MAX_RESPONSE_BYTES = MAX_TRANSPORT_BYTES
57
71
  const TEXT_DECODER = new TextDecoder()
58
72
  const TEXT_ENCODER = new TextEncoder()
59
73
  const SSE_CONTENT_TYPE = "text/event-stream; charset=utf-8"
@@ -62,24 +76,46 @@ const SSE_KEEP_ALIVE_MS = 15_000
62
76
  /** Invalid byte caps make `total > maxBytes` fail open (especially for `NaN`). Reject configuration
63
77
  * before any request body is read so MCP cannot silently lose its memory bound. */
64
78
  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
- }
79
+ assertTransportByteLimit(value, { maximum: MAX_BODY_BYTES, label: "MCP maxBodyBytes" })
80
+ }
81
+
82
+ function assertResponseLimit(value: number): void {
83
+ assertTransportByteLimit(value, {
84
+ minimum: 1_024,
85
+ maximum: MAX_RESPONSE_BYTES,
86
+ label: "MCP maxResponseBytes",
87
+ })
68
88
  }
69
89
 
70
90
  export interface McpHttpOptions {
71
91
  /** Maximum JSON-RPC request body size in bytes. Default 1 MB. */
72
92
  readonly maxBodyBytes?: number
93
+ /** Maximum serialized JSON-RPC response or SSE frame size. Default 1 MB. */
94
+ readonly maxResponseBytes?: number
73
95
  /** Resources / prompts / the MCP Apps UI extension served alongside the tools. */
74
96
  readonly features?: McpServerFeatures
75
97
  /** Shown on the GET health page so each host can describe itself. */
76
98
  readonly health?: string
99
+ /** Explicitly expose a secret-free, unauthenticated server to browser clients from any origin. */
100
+ readonly allowAnyOrigin?: boolean
101
+ /** Origin allowlist for the DNS-rebinding guard. When set, only exact origins are accepted. */
102
+ readonly allowedOrigins?: readonly string[]
77
103
  /**
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.
104
+ * Shared request registry for one authenticated MCP session. Pass the same state to the
105
+ * request that starts a tool call and its `notifications/cancelled` request. Do not share one
106
+ * state across unrelated callers unless the surrounding authorization boundary makes their
107
+ * request-id namespace exclusive.
81
108
  */
82
- readonly allowedOrigins?: readonly string[]
109
+ readonly state?: McpProtocolState
110
+ /**
111
+ * Resolve a session-scoped request registry after authorization. This is the preferred seam for
112
+ * multi-user HTTP hosts: bind the returned state to the authenticated caller/session and return
113
+ * `undefined` for stateless requests. A resolver failure fails closed with a generic denial.
114
+ */
115
+ readonly resolveState?: (
116
+ request: Request,
117
+ message: JsonRpcRequest,
118
+ ) => McpProtocolState | undefined | Promise<McpProtocolState | undefined>
83
119
  /**
84
120
  * Optional authorization, run once per request against the parsed message - after the body has been
85
121
  * read under the size cap, so it never costs a second read of the stream. Returning `false` answers
@@ -108,6 +144,7 @@ function eventStreamResponse(
108
144
  headers: Record<string, string>,
109
145
  run: (stream: EventStreamControl) => void | Promise<void>,
110
146
  keepAliveMs = 0,
147
+ maxFrameBytes = DEFAULT_MAX_RESPONSE_BYTES,
111
148
  ): Response {
112
149
  const abortController = new AbortController()
113
150
  let closed = false
@@ -154,7 +191,15 @@ function eventStreamResponse(
154
191
  signal: abortController.signal,
155
192
  closed: closedPromise,
156
193
  send(message) {
157
- write(`data: ${JSON.stringify(message)}\n\n`)
194
+ const encoded = encodeJson(message, maxFrameBytes)
195
+ const id = "id" in message ? message.id : null
196
+ const payload = encoded ?? JSON.stringify(rpcError(id ?? null, -32000, "response too large"))
197
+ const frame = `data: ${payload}\n\n`
198
+ write(
199
+ TEXT_ENCODER.encode(frame).byteLength <= maxFrameBytes
200
+ ? frame
201
+ : `data: ${JSON.stringify(rpcError(id ?? null, -32000, "response too large"))}\n\n`,
202
+ )
158
203
  },
159
204
  comment(value = "") {
160
205
  write(`: ${value.replace(/[\r\n]/g, "")}\n\n`)
@@ -205,16 +250,16 @@ function eventStreamResponse(
205
250
  })
206
251
  }
207
252
 
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
253
+ /** Validate the HTTP envelope before any method-specific access or authorization callback. */
254
+ function parseHttpMessage(value: unknown): JsonRpcRequest | undefined {
255
+ return isJsonRpcRequest(value) ? value : undefined
256
+ }
257
+
258
+ function unauthorizedResponse(id: JsonRpcRequest["id"], headers: Record<string, string>): Response {
259
+ return Response.json(rpcError(id ?? null, MCP_ERROR.UNAUTHORIZED, "unauthorized"), {
260
+ status: 403,
261
+ headers,
262
+ })
218
263
  }
219
264
 
220
265
  async function readJsonBounded(
@@ -223,7 +268,7 @@ async function readJsonBounded(
223
268
  ): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> {
224
269
  const declared = request.headers.get("content-length")
225
270
  if (declared !== null) {
226
- const length = parseContentLength(declared)
271
+ const length = parseTransportContentLength(declared)
227
272
  if (length === undefined) return { ok: false, status: 400 }
228
273
  if (length > maxBytes) return { ok: false, status: 413 }
229
274
  }
@@ -231,33 +276,31 @@ async function readJsonBounded(
231
276
  const body = request.body
232
277
  if (body === null) return { ok: false, status: 400 }
233
278
 
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)
279
+ const read = await readBoundedTransportBytes(body, maxBytes)
280
+ if (!read.ok) return { ok: false, status: read.reason === "too-large" ? 413 : 400 }
281
+ try {
282
+ return { ok: true, value: JSON.parse(TEXT_DECODER.decode(read.bytes)) as unknown }
283
+ } catch {
284
+ return { ok: false, status: 400 }
246
285
  }
286
+ }
247
287
 
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
- }
288
+ function encodeJson(value: unknown, maxBytes: number): string | undefined {
254
289
  try {
255
- return { ok: true, value: JSON.parse(TEXT_DECODER.decode(bytes)) as unknown }
290
+ const encoded = JSON.stringify(value)
291
+ if (typeof encoded !== "string") return undefined
292
+ if (TEXT_ENCODER.encode(encoded).byteLength > maxBytes) return undefined
293
+ return encoded
256
294
  } catch {
257
- return { ok: false, status: 400 }
295
+ return undefined
258
296
  }
259
297
  }
260
298
 
299
+ function boundedResponse(response: JsonRpcResponse, maxBytes: number): JsonRpcResponse {
300
+ if (encodeJson(response, maxBytes) !== undefined) return response
301
+ return rpcError(response.id, -32000, "response too large")
302
+ }
303
+
261
304
  const SENTINEL_PREFIX = "=?base64?"
262
305
  const SENTINEL_SUFFIX = "?="
263
306
 
@@ -332,7 +375,9 @@ export async function respondMcpHttp(
332
375
  ): Promise<Response> {
333
376
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
334
377
  assertByteLimit(maxBodyBytes)
335
- const cors = corsFor(request, options.allowedOrigins)
378
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
379
+ assertResponseLimit(maxResponseBytes)
380
+ const cors = corsFor(request, options.allowedOrigins, options.allowAnyOrigin === true)
336
381
  if (cors === null) {
337
382
  // Origin present but not allowlisted: reject before the body is ever read (DNS-rebinding guard). No
338
383
  // `id` (no request parsed) and no CORS headers - a disallowed origin gets nothing to work with.
@@ -352,6 +397,7 @@ export async function respondMcpHttp(
352
397
  await stream.closed
353
398
  },
354
399
  SSE_KEEP_ALIVE_MS,
400
+ maxResponseBytes,
355
401
  )
356
402
  }
357
403
  return new Response(
@@ -376,14 +422,31 @@ export async function respondMcpHttp(
376
422
  }
377
423
  return Response.json(rpcError(null, -32700, "parse error"), { status: 400, headers: cors })
378
424
  }
379
- const message = parsed.value as JsonRpcRequest
425
+ const message = parseHttpMessage(parsed.value)
426
+ if (message === undefined)
427
+ return Response.json(rpcError(null, -32600, "Invalid Request"), {
428
+ status: 400,
429
+ headers: cors,
430
+ })
380
431
  if (options.authorizeMessage !== undefined) {
381
- const authorized = await options.authorizeMessage(message, request)
432
+ let authorized = false
433
+ try {
434
+ authorized = await options.authorizeMessage(message, request)
435
+ } catch {
436
+ // Authorization is an application boundary. Fail closed without reflecting verifier or
437
+ // credential-store diagnostics to an unauthenticated caller.
438
+ return unauthorizedResponse(message.id, cors)
439
+ }
382
440
  if (!authorized) {
383
- return Response.json(rpcError(message.id ?? null, MCP_ERROR.UNAUTHORIZED, "unauthorized"), {
384
- status: 403,
385
- headers: cors,
386
- })
441
+ return unauthorizedResponse(message.id, cors)
442
+ }
443
+ }
444
+ let state: McpProtocolState | undefined = options.state
445
+ if (options.resolveState !== undefined) {
446
+ try {
447
+ state = await options.resolveState(request, message)
448
+ } catch {
449
+ return unauthorizedResponse(message.id, cors)
387
450
  }
388
451
  }
389
452
  const bodyVersion = modernVersionOf(message.params)
@@ -397,20 +460,27 @@ export async function respondMcpHttp(
397
460
  }
398
461
  const dispatch = (stream?: EventStreamControl): Promise<JsonRpcResponse | null> =>
399
462
  handleRpc(message, tools, serverInfo, options.features ?? {}, {
463
+ ...(state === undefined ? {} : { state }),
400
464
  signal: stream?.signal ?? request.signal,
401
465
  ...(stream !== undefined ? { sendNotification: stream.send } : {}),
402
466
  })
403
467
  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
- })
468
+ return eventStreamResponse(
469
+ request,
470
+ headers,
471
+ async (stream) => {
472
+ const response = await dispatch(stream)
473
+ if (response !== null) stream.send(response)
474
+ stream.close()
475
+ },
476
+ 0,
477
+ maxResponseBytes,
478
+ )
409
479
  }
410
480
  const response = await dispatch()
411
481
  // A notification (no id) yields null - acknowledge with 202 Accepted and no body (Streamable-HTTP).
412
482
  if (response === null) return new Response(null, { status: 202, headers })
413
483
  // Modern requests carry spec HTTP statuses (400 version/header, 404 unknown method); legacy stays 200.
414
484
  const status = bodyVersion !== undefined ? modernErrorStatus(response) : 200
415
- return Response.json(response, { status, headers })
485
+ return Response.json(boundedResponse(response, maxResponseBytes), { status, headers })
416
486
  }
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"
@@ -185,7 +188,70 @@ export function createMcpProtocolState(): McpProtocolState {
185
188
 
186
189
  function requestKey(id: unknown): string | undefined {
187
190
  if (typeof id === "string") return `s:${id}`
188
- return typeof id === "number" && Number.isFinite(id) ? `n:${id}` : undefined
191
+ return typeof id === "number" && Number.isSafeInteger(id) ? `n:${id}` : undefined
192
+ }
193
+
194
+ function record(value: unknown): value is Record<string, unknown> {
195
+ return value !== null && typeof value === "object" && !Array.isArray(value)
196
+ }
197
+
198
+ /** Validate a JSON-RPC request/notification before any transport or method-specific access. */
199
+ export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
200
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
201
+ const record = value as Record<string, unknown>
202
+ if (Object.hasOwn(record, "result") || Object.hasOwn(record, "error")) return false
203
+ if (Object.hasOwn(record, "jsonrpc") && record.jsonrpc !== "2.0") return false
204
+ if (
205
+ typeof record.method !== "string" ||
206
+ record.method.length === 0 ||
207
+ record.method.length > MAX_RPC_METHOD_LENGTH
208
+ )
209
+ return false
210
+ if (Object.hasOwn(record, "id")) {
211
+ const id = record.id
212
+ if (
213
+ id !== null &&
214
+ typeof id !== "string" &&
215
+ !(typeof id === "number" && Number.isSafeInteger(id))
216
+ )
217
+ return false
218
+ if (typeof id === "string" && id.length > MAX_RPC_ID_LENGTH) return false
219
+ }
220
+ return (
221
+ !Object.hasOwn(record, "params") ||
222
+ (record.params !== null && typeof record.params === "object" && !Array.isArray(record.params))
223
+ )
224
+ }
225
+
226
+ /** Validate a JSON-RPC response received by a transport-side client hook. Responses are not accepted
227
+ * by {@link handleRpc}; this guard exists for the stdio roots/list answer, which is the one server-
228
+ * initiated request response the CLI consumes itself. */
229
+ export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse {
230
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
231
+ const record = value as Record<string, unknown>
232
+ if (record.jsonrpc !== "2.0") return false
233
+ if (Object.hasOwn(record, "method") || Object.hasOwn(record, "params")) return false
234
+ if (
235
+ record.id !== null &&
236
+ typeof record.id !== "string" &&
237
+ !(typeof record.id === "number" && Number.isSafeInteger(record.id))
238
+ )
239
+ return false
240
+ if (typeof record.id === "string" && record.id.length > MAX_RPC_ID_LENGTH) return false
241
+ const hasResult = Object.hasOwn(record, "result")
242
+ const hasError = Object.hasOwn(record, "error")
243
+ if (hasResult === hasError) return false
244
+ if (!hasError) return true
245
+ const error = record.error
246
+ if (error === null || typeof error !== "object" || Array.isArray(error)) return false
247
+ const errorRecord = error as Record<string, unknown>
248
+ return (
249
+ typeof errorRecord.code === "number" &&
250
+ Number.isSafeInteger(errorRecord.code) &&
251
+ typeof errorRecord.message === "string" &&
252
+ errorRecord.message.length > 0 &&
253
+ errorRecord.message.length <= MAX_RPC_METHOD_LENGTH
254
+ )
189
255
  }
190
256
 
191
257
  function metaObject(
@@ -213,7 +279,9 @@ function progressTokenOf(params: Record<string, unknown> | undefined): ProgressT
213
279
 
214
280
  function cancellationReason(params: Record<string, unknown> | undefined): string | undefined {
215
281
  const reason = params?.reason
216
- return typeof reason === "string" && reason.length > 0 ? reason : undefined
282
+ return typeof reason === "string" && reason.length > 0
283
+ ? reason.slice(0, MAX_CANCEL_REASON_LENGTH)
284
+ : undefined
217
285
  }
218
286
 
219
287
  function linkAbortSignal(parent: AbortSignal | undefined, child: AbortController): () => void {
@@ -294,6 +362,7 @@ export async function handleRpc(
294
362
  features: McpServerFeatures = {},
295
363
  options: McpProtocolOptions = {},
296
364
  ): Promise<JsonRpcResponse | null> {
365
+ if (!isJsonRpcRequest(message)) return rpcError(null, -32600, "Invalid Request")
297
366
  const { id, method, params } = message
298
367
  const isNotification = id === undefined
299
368
  const rid = id ?? null
@@ -416,7 +485,10 @@ export async function handleRpc(
416
485
  const name = params?.name
417
486
  const tool = tools.find((t) => t.name === name)
418
487
  if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
419
- const args = (params?.arguments as Record<string, unknown>) ?? {}
488
+ const suppliedArguments = params?.arguments
489
+ if (suppliedArguments !== undefined && !record(suppliedArguments))
490
+ return rpcError(rid, -32602, "tool arguments must be an object")
491
+ const args = suppliedArguments ?? {}
420
492
  const controller = new AbortController()
421
493
  const key = requestKey(id)
422
494
  if (key !== undefined && state.activeRequests.has(key)) {
@@ -458,15 +530,19 @@ export async function handleRpc(
458
530
  if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
459
531
  reportProgress(1, 1)
460
532
  return reply(toolCallResult(result, tool))
461
- } catch (err) {
533
+ } catch {
462
534
  if (controller.signal.aborted) {
463
535
  return reply({
464
536
  content: [{ type: "text", text: abortMessage(controller.signal) }],
465
537
  isError: true,
466
538
  })
467
539
  }
468
- const msg = err instanceof Error ? err.message : String(err)
469
- return reply({ content: [{ type: "text", text: `Error: ${msg}` }], isError: true })
540
+ // Handler errors are application-internal data. Never reflect their messages to a remote
541
+ // MCP caller: paths, SQL diagnostics, provider responses, and secrets commonly end up there.
542
+ return reply({
543
+ content: [{ type: "text", text: "Tool execution failed" }],
544
+ isError: true,
545
+ })
470
546
  } finally {
471
547
  cleanupAbort()
472
548
  if (key !== undefined && state.activeRequests.get(key) === controller) {
@@ -526,15 +602,18 @@ export async function handleRpc(
526
602
  const name = params?.name
527
603
  const prompt = prompts.find((p) => p.name === name)
528
604
  if (!prompt) return rpcError(rid, -32602, `unknown prompt: ${String(name)}`)
529
- const args = (params?.arguments as Record<string, unknown>) ?? {}
605
+ const suppliedArguments = params?.arguments
606
+ if (suppliedArguments !== undefined && !record(suppliedArguments))
607
+ return rpcError(rid, -32602, "prompt arguments must be an object")
608
+ const args = suppliedArguments ?? {}
530
609
  try {
531
610
  return reply({
532
611
  description: prompt.description,
533
612
  messages: await prompt.handler(args),
534
613
  })
535
- } catch (err) {
536
- const msg = err instanceof Error ? err.message : String(err)
537
- return rpcError(rid, -32000, msg)
614
+ } catch {
615
+ // Prompt handlers have the same trust boundary as tools; expose only a stable protocol error.
616
+ return rpcError(rid, -32000, "Prompt execution failed")
538
617
  }
539
618
  }
540
619
  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
  }
package/src/server.ts CHANGED
@@ -15,9 +15,9 @@
15
15
  * {@link McpServer.handle} dispatches a single JSON-RPC message directly (for headless verification / tests).
16
16
  *
17
17
  * SECURITY: this has NO built-in authentication - once mounted, every tool is callable by anyone who can
18
- * reach the route (the CORS header is `*` and no credentials are used, so it is effectively public). That
19
- * is fine for read-only/public tools; if any tool mutates state or returns private data, gate the route
20
- * yourself (check an `Authorization` header / session in the nifra handler before calling `mcp.fetch`).
18
+ * reach the route. Browser access is same-origin by default; set `allowAnyOrigin: true` only for a
19
+ * secret-free public server. If any tool mutates state or returns private data, gate the route yourself
20
+ * (check an `Authorization` header / session in the nifra handler before calling `mcp.fetch`).
21
21
  */
22
22
 
23
23
  import { type McpHttpOptions, respondMcpHttp } from "./http.ts"
@@ -26,6 +26,7 @@ import {
26
26
  type JsonRpcRequest,
27
27
  type JsonRpcResponse,
28
28
  type McpPrompt,
29
+ type McpProtocolState,
29
30
  type McpResource,
30
31
  type McpServerFeatures,
31
32
  type McpTool,
@@ -46,11 +47,18 @@ export interface CreateMcpServerOptions {
46
47
  readonly health?: string
47
48
  /** Max JSON-RPC body size in bytes (default 1 MB). */
48
49
  readonly maxBodyBytes?: number
50
+ /** Max serialized JSON-RPC response or SSE frame size (default 1 MB). */
51
+ readonly maxResponseBytes?: number
49
52
  /** Natural-language guidance for LLMs, surfaced in the modern `server/discover` result (2026-07-28). */
50
53
  readonly instructions?: string
51
- /** Origin allowlist for the DNS-rebinding guard. Omit to allow any origin; set it to reject other
52
- * browser origins with 403 (e.g. a hardened, non-public mount). */
54
+ /** Explicitly allow browser clients from any origin. Omit for the secure same-origin default. */
55
+ readonly allowAnyOrigin?: boolean
56
+ /** Origin allowlist for the DNS-rebinding guard. Set it to permit exact cross-origin clients. */
53
57
  readonly allowedOrigins?: readonly string[]
58
+ /** Shared state for one authenticated MCP session; prefer `resolveState` for multi-session hosts. */
59
+ readonly state?: McpProtocolState
60
+ /** Resolve a session-scoped request registry after per-message authorization. */
61
+ readonly resolveState?: McpHttpOptions["resolveState"]
54
62
  /**
55
63
  * Per-message authorization, applied after the message parses and before any tool runs. Return
56
64
  * `false` to answer 403 with a JSON-RPC `unauthorized` error (`MCP_ERROR.UNAUTHORIZED`) and no result.
@@ -71,7 +79,10 @@ export interface McpServer {
71
79
  readonly features: McpServerFeatures
72
80
  readonly serverInfo: { name: string; version: string }
73
81
  /** Web `fetch` handler - mount at `POST /mcp` (GET is a health page, OPTIONS the CORS preflight). */
74
- fetch(request: Request, overrides?: Pick<McpHttpOptions, "authorizeMessage">): Promise<Response>
82
+ fetch(
83
+ request: Request,
84
+ overrides?: Pick<McpHttpOptions, "authorizeMessage" | "state" | "resolveState">,
85
+ ): Promise<Response>
75
86
  /** Dispatch one JSON-RPC message directly (no HTTP) - for headless verification and unit tests. */
76
87
  handle(message: JsonRpcRequest): Promise<JsonRpcResponse | null>
77
88
  /**
@@ -103,12 +114,21 @@ export function createMcpServer(opts: CreateMcpServerOptions): McpServer {
103
114
  features,
104
115
  ...(opts.health !== undefined ? { health: opts.health } : {}),
105
116
  ...(opts.maxBodyBytes !== undefined ? { maxBodyBytes: opts.maxBodyBytes } : {}),
117
+ ...(opts.maxResponseBytes !== undefined ? { maxResponseBytes: opts.maxResponseBytes } : {}),
118
+ ...(opts.allowAnyOrigin === true ? { allowAnyOrigin: true } : {}),
106
119
  ...(opts.allowedOrigins !== undefined ? { allowedOrigins: opts.allowedOrigins } : {}),
120
+ ...(opts.state === undefined ? {} : { state: opts.state }),
121
+ ...(opts.resolveState === undefined ? {} : { resolveState: opts.resolveState }),
107
122
  ...(opts.authorizeMessage !== undefined ? { authorizeMessage: opts.authorizeMessage } : {}),
108
123
  ...(overrides?.authorizeMessage !== undefined
109
124
  ? { authorizeMessage: overrides.authorizeMessage }
110
125
  : {}),
126
+ ...(overrides?.state === undefined ? {} : { state: overrides.state }),
127
+ ...(overrides?.resolveState === undefined ? {} : { resolveState: overrides.resolveState }),
128
+ }),
129
+ handle: (message) =>
130
+ handleRpc(message, tools, serverInfo, features, {
131
+ ...(opts.state === undefined ? {} : { state: opts.state }),
111
132
  }),
112
- handle: (message) => handleRpc(message, tools, serverInfo, features),
113
133
  }
114
134
  }