@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/README.md +4 -0
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +61 -5
- package/dist/bridge.js.map +1 -1
- package/dist/http.d.ts +18 -5
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +91 -55
- package/dist/http.js.map +1 -1
- package/dist/protocol.d.ts +6 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +88 -10
- package/dist/protocol.js.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +3 -1
- package/dist/react.js.map +1 -1
- package/dist/server.d.ts +14 -7
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -4
- package/dist/server.js.map +1 -1
- package/package.json +6 -3
- package/src/bridge.ts +63 -5
- package/src/http.ts +126 -56
- package/src/protocol.ts +89 -10
- package/src/react.ts +3 -1
- package/src/server.ts +27 -7
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
|
-
*
|
|
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 (
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 =
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
stream
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
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
|
|
536
|
-
|
|
537
|
-
return rpcError(rid, -32000,
|
|
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
|
-
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
52
|
-
|
|
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(
|
|
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
|
}
|