@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/README.md +16 -1
- 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 +25 -5
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +191 -56
- package/dist/http.js.map +1 -1
- package/dist/protocol.d.ts +12 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +92 -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 +17 -7
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +14 -4
- package/dist/server.js.map +1 -1
- package/package.json +9 -3
- package/src/bridge.ts +63 -5
- package/src/http.ts +222 -57
- package/src/protocol.ts +101 -10
- package/src/react.ts +3 -1
- package/src/server.ts +31 -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,
|
|
@@ -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
|
-
*
|
|
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 (
|
|
139
|
+
if (allowAnyOrigin) return { ...CORS_BASE, "access-control-allow-origin": "*" }
|
|
47
140
|
const origin = request.headers.get("origin")
|
|
48
|
-
//
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
stream
|
|
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.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
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
|
|
536
|
-
|
|
537
|
-
return rpcError(rid, -32000,
|
|
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
|
-
|
|
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
|
}
|