@frockbot/plugin-mcp 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,645 @@
1
+ /**
2
+ * A minimal Model Context Protocol client for remote servers.
3
+ *
4
+ * Written against the specification rather than taken from
5
+ * `@modelcontextprotocol/sdk`: the SDK is not a dependency of this repository,
6
+ * it carries Node transports (stdio, `EventSource`, process spawning) this
7
+ * Package must never reach for, and the surface FrockBot needs is four
8
+ * messages wide — `initialize`, `notifications/initialized`, `tools/list` and
9
+ * `tools/call`. Everything here runs inside a Durable Object, so every read is
10
+ * bounded and every request goes through the `fetch` the host supplies, which
11
+ * is the Package's own outbound seam.
12
+ *
13
+ * Two transports are covered:
14
+ *
15
+ * - `streamable-http` (spec revision 2025-03-26 and later): one POST per
16
+ * JSON-RPC message. The response is either `application/json` carrying the
17
+ * reply, or `text/event-stream` carrying it as an SSE `message` event.
18
+ * - `sse` (the 2024-11-05 HTTP+SSE transport): a long-lived GET stream that
19
+ * opens with an `endpoint` event naming the URL to POST messages to; every
20
+ * reply arrives back on the stream.
21
+ */
22
+
23
+ const CLIENT_INFO = { name: "frockbot", version: "0.0.1" } as const;
24
+
25
+ /**
26
+ * The revision this client speaks. A server that answers `initialize` with a
27
+ * different revision is taken at its word: the messages this client sends are
28
+ * unchanged across every revision that defines them.
29
+ */
30
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
31
+
32
+ /** Bounds. Every one of them is a refusal, never a truncation. */
33
+ export const MAX_MCP_RESPONSE_BYTES = 256 * 1024;
34
+ export const MAX_MCP_TOOLS_PER_SERVER = 64;
35
+ const MAX_TOOL_LIST_PAGES = 8;
36
+ const MAX_TOOL_NAME_LENGTH = 128;
37
+ const MAX_TOOL_DESCRIPTION_LENGTH = 4_096;
38
+
39
+ export type McpTransportV1 = "streamable-http" | "sse";
40
+
41
+ /**
42
+ * The outbound seam, narrowed to what this client calls. Narrower than
43
+ * `typeof fetch` on purpose: the global cannot be passed by reference inside a
44
+ * Durable Object, so what is handed in here is always a small wrapper.
45
+ */
46
+ export type McpFetch = (
47
+ input: string | URL | Request,
48
+ init?: RequestInit,
49
+ ) => Promise<Response>;
50
+
51
+ export interface McpToolDeclarationV1 {
52
+ name: string;
53
+ description?: string;
54
+ /** The server's own JSON Schema, passed through to the model unchanged. */
55
+ inputSchema: Record<string, unknown>;
56
+ }
57
+
58
+ export interface McpHandshakeV1 {
59
+ protocolVersion: string;
60
+ serverName?: string;
61
+ serverVersion?: string;
62
+ }
63
+
64
+ export interface McpToolResultV1 {
65
+ content: string;
66
+ isError: boolean;
67
+ }
68
+
69
+ export interface McpClientConfig {
70
+ url: URL;
71
+ transport: McpTransportV1;
72
+ fetch: McpFetch;
73
+ /** The credential, already opened from its lease. Never stored. */
74
+ apiKey?: string;
75
+ /** The header the key travels in. `Authorization` means `Bearer <key>`. */
76
+ headerName?: string;
77
+ maxResponseBytes?: number;
78
+ maxTools?: number;
79
+ }
80
+
81
+ export class McpProtocolError extends Error {
82
+ /**
83
+ * The HTTP status the server answered with, when there was one. The
84
+ * lifecycle needs it to tell `needs-auth` from `error`: a 401 is a
85
+ * credential a User can replace, and everything else is a server that is
86
+ * not there.
87
+ */
88
+ readonly status?: number;
89
+
90
+ /**
91
+ * The `WWW-Authenticate` header the server answered a 401 with, verbatim.
92
+ *
93
+ * It is carried rather than parsed here because this client speaks MCP and
94
+ * not OAuth: `plugin-mcp/src/oauth.ts` reads the `resource_metadata`
95
+ * parameter out of it (RFC 9728 §5.1) to find where the server's protected
96
+ * resource metadata lives, and the classification of the failure as
97
+ * "this server wants authorization" is the same header seen from L4b.
98
+ */
99
+ readonly wwwAuthenticate?: string;
100
+
101
+ constructor(
102
+ message: string,
103
+ status?: number,
104
+ wwwAuthenticate?: string | null,
105
+ ) {
106
+ super(message);
107
+ if (status !== undefined) this.status = status;
108
+ if (wwwAuthenticate) {
109
+ this.wwwAuthenticate = wwwAuthenticate.slice(0, 2_048);
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The server said "authorize first".
116
+ *
117
+ * A 401 carrying `WWW-Authenticate: Bearer …` is the one MCP failure that is
118
+ * not a broken server: it is a durable pending decision for the User. It is a
119
+ * subclass rather than a sibling so every existing `McpProtocolError` handler
120
+ * keeps working, and typed rather than a status check so the runtime seam and
121
+ * the durable record agree on what it means without re-deriving it from prose.
122
+ */
123
+ export class McpAuthorizationRequiredError extends McpProtocolError {
124
+ /** RFC 9728 §5.1's `resource_metadata`, when the server named one. */
125
+ readonly resourceMetadataUrl?: string;
126
+
127
+ constructor(message: string, wwwAuthenticate?: string | null) {
128
+ super(message, 401, wwwAuthenticate);
129
+ const named = mcpResourceMetadataChallengeV1(wwwAuthenticate ?? null);
130
+ if (named) this.resourceMetadataUrl = named;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The `resource_metadata` parameter of a `WWW-Authenticate` challenge. Parsed
136
+ * defensively: the header is the server's, and the URL it names is still put
137
+ * through the outbound classifier before anything fetches it.
138
+ */
139
+ export function mcpResourceMetadataChallengeV1(
140
+ header: string | null,
141
+ ): string | undefined {
142
+ if (!header) return undefined;
143
+ const match = header.match(
144
+ /(?:^|[\s,])resource_metadata\s*=\s*(?:"([^"]*)"|([^\s,]+))/i,
145
+ );
146
+ const value = match?.[1] ?? match?.[2];
147
+ return value && value.length <= 2_048 ? value : undefined;
148
+ }
149
+
150
+ interface JsonRpcResponse {
151
+ id: number;
152
+ result?: unknown;
153
+ error?: { code?: number; message?: string };
154
+ }
155
+
156
+ function record(value: unknown, label: string): Record<string, unknown> {
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
158
+ throw new McpProtocolError(`${label} is invalid`);
159
+ }
160
+ return value as Record<string, unknown>;
161
+ }
162
+
163
+ function boundedText(value: string, maximum: number): string {
164
+ return value.length <= maximum ? value : `${value.slice(0, maximum)}…`;
165
+ }
166
+
167
+ /** Read a whole body, refusing anything past the cap rather than truncating. */
168
+ async function boundedBody(
169
+ response: Response,
170
+ maximum: number,
171
+ ): Promise<string> {
172
+ const declared = Number(response.headers.get("content-length"));
173
+ if (Number.isFinite(declared) && declared > maximum) {
174
+ throw new McpProtocolError("MCP response is too large");
175
+ }
176
+ const reader = response.body?.getReader();
177
+ if (!reader) return "";
178
+ const decoder = new TextDecoder();
179
+ let text = "";
180
+ let length = 0;
181
+ try {
182
+ for (;;) {
183
+ const chunk = await reader.read();
184
+ if (chunk.done) break;
185
+ length += chunk.value.byteLength;
186
+ if (length > maximum) {
187
+ throw new McpProtocolError("MCP response is too large");
188
+ }
189
+ text += decoder.decode(chunk.value, { stream: true });
190
+ }
191
+ } finally {
192
+ reader.releaseLock();
193
+ }
194
+ return text + decoder.decode();
195
+ }
196
+
197
+ export interface SseEvent {
198
+ event: string;
199
+ data: string;
200
+ }
201
+
202
+ /**
203
+ * Parse a complete SSE body into its events. Fields other than `event` and
204
+ * `data` are ignored: this client uses neither `id` resumption nor `retry`.
205
+ */
206
+ export function parseSseEventsV1(body: string): SseEvent[] {
207
+ const events: SseEvent[] = [];
208
+ for (const block of body.split(/\r?\n\r?\n/)) {
209
+ if (!block.trim()) continue;
210
+ let name = "message";
211
+ const data: string[] = [];
212
+ for (const line of block.split(/\r?\n/)) {
213
+ if (line.startsWith(":")) continue;
214
+ const separator = line.indexOf(":");
215
+ const field = separator === -1 ? line : line.slice(0, separator);
216
+ const raw = separator === -1 ? "" : line.slice(separator + 1);
217
+ const value = raw.startsWith(" ") ? raw.slice(1) : raw;
218
+ if (field === "event") name = value;
219
+ else if (field === "data") data.push(value);
220
+ }
221
+ if (data.length > 0 || name !== "message") {
222
+ events.push({ event: name, data: data.join("\n") });
223
+ }
224
+ }
225
+ return events;
226
+ }
227
+
228
+ function decodeJsonRpcResponse(value: unknown): JsonRpcResponse {
229
+ const message = record(value, "MCP response");
230
+ if (message.jsonrpc !== "2.0" || typeof message.id !== "number") {
231
+ throw new McpProtocolError("MCP response is not a JSON-RPC reply");
232
+ }
233
+ if (message.error !== undefined) {
234
+ const error = record(message.error, "MCP error");
235
+ return {
236
+ id: message.id,
237
+ error: {
238
+ ...(typeof error.code === "number" ? { code: error.code } : {}),
239
+ message:
240
+ typeof error.message === "string"
241
+ ? boundedText(error.message, 2_000)
242
+ : "MCP request failed",
243
+ },
244
+ };
245
+ }
246
+ return { id: message.id, result: message.result };
247
+ }
248
+
249
+ /** The reply carried by one HTTP body, whichever content type it arrived in. */
250
+ function replyFromBody(
251
+ contentType: string,
252
+ body: string,
253
+ id: number,
254
+ ): JsonRpcResponse {
255
+ if (contentType.includes("text/event-stream")) {
256
+ for (const event of parseSseEventsV1(body)) {
257
+ if (event.event !== "message" || !event.data) continue;
258
+ const reply = decodeJsonRpcResponse(JSON.parse(event.data));
259
+ if (reply.id === id) return reply;
260
+ }
261
+ throw new McpProtocolError("MCP stream carried no reply");
262
+ }
263
+ const reply = decodeJsonRpcResponse(JSON.parse(body));
264
+ if (reply.id !== id) {
265
+ throw new McpProtocolError("MCP reply does not answer the request");
266
+ }
267
+ return reply;
268
+ }
269
+
270
+ /**
271
+ * The long-lived GET stream of the legacy HTTP+SSE transport. It is opened
272
+ * once per client, yields the message endpoint, and then carries every reply.
273
+ */
274
+ class SseSession {
275
+ private buffer = "";
276
+ private consumed = 0;
277
+ private readonly decoder = new TextDecoder();
278
+ private pending: SseEvent[] = [];
279
+
280
+ constructor(
281
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>,
282
+ private readonly maximum: number,
283
+ ) {}
284
+
285
+ /** The next event on the stream, or `undefined` when it ends. */
286
+ async next(): Promise<SseEvent | undefined> {
287
+ for (;;) {
288
+ const ready = this.pending.shift();
289
+ if (ready) return ready;
290
+ const chunk = await this.reader.read();
291
+ if (chunk.done) {
292
+ const rest = parseSseEventsV1(this.buffer);
293
+ this.buffer = "";
294
+ this.pending.push(...rest);
295
+ return this.pending.shift();
296
+ }
297
+ this.consumed += chunk.value.byteLength;
298
+ if (this.consumed > this.maximum) {
299
+ throw new McpProtocolError("MCP response is too large");
300
+ }
301
+ this.buffer += this.decoder.decode(chunk.value, { stream: true });
302
+ const boundary = this.buffer.lastIndexOf("\n\n");
303
+ if (boundary === -1) continue;
304
+ const complete = this.buffer.slice(0, boundary + 2);
305
+ this.buffer = this.buffer.slice(boundary + 2);
306
+ this.pending.push(...parseSseEventsV1(complete));
307
+ }
308
+ }
309
+
310
+ /** Bytes read so far, reset between requests so each one has its own cap. */
311
+ resetBudget(): void {
312
+ this.consumed = 0;
313
+ }
314
+
315
+ async close(): Promise<void> {
316
+ try {
317
+ await this.reader.cancel();
318
+ } catch {
319
+ // A server that already closed the stream is not a failure.
320
+ }
321
+ this.reader.releaseLock();
322
+ }
323
+ }
324
+
325
+ export class McpClient {
326
+ private nextId = 1;
327
+ private sessionId?: string;
328
+ private protocolVersion?: string;
329
+ private session?: SseSession;
330
+ private messageUrl?: URL;
331
+ private readonly maxResponseBytes: number;
332
+ private readonly maxTools: number;
333
+
334
+ constructor(private readonly config: McpClientConfig) {
335
+ this.maxResponseBytes = config.maxResponseBytes ?? MAX_MCP_RESPONSE_BYTES;
336
+ this.maxTools = config.maxTools ?? MAX_MCP_TOOLS_PER_SERVER;
337
+ }
338
+
339
+ private headers(accept: string): Headers {
340
+ const headers = new Headers({
341
+ accept,
342
+ "content-type": "application/json",
343
+ });
344
+ if (this.config.apiKey) {
345
+ const name = this.config.headerName ?? "Authorization";
346
+ headers.set(
347
+ name,
348
+ name.toLowerCase() === "authorization"
349
+ ? `Bearer ${this.config.apiKey}`
350
+ : this.config.apiKey,
351
+ );
352
+ }
353
+ if (this.sessionId) headers.set("mcp-session-id", this.sessionId);
354
+ if (this.protocolVersion) {
355
+ headers.set("mcp-protocol-version", this.protocolVersion);
356
+ }
357
+ return headers;
358
+ }
359
+
360
+ private async post(
361
+ url: URL,
362
+ body: unknown,
363
+ accept: string,
364
+ ): Promise<Response> {
365
+ const response = await this.config.fetch(url.toString(), {
366
+ method: "POST",
367
+ headers: this.headers(accept),
368
+ body: JSON.stringify(body),
369
+ });
370
+ if (!response.ok) {
371
+ const detail = boundedText(
372
+ await response.text().catch(() => ""),
373
+ 200,
374
+ ).trim();
375
+ const message = `MCP server answered ${response.status}${detail ? `: ${detail}` : ""}`;
376
+ const challenge = response.headers.get("www-authenticate");
377
+ if (response.status === 401) {
378
+ throw new McpAuthorizationRequiredError(message, challenge);
379
+ }
380
+ throw new McpProtocolError(message, response.status, challenge);
381
+ }
382
+ return response;
383
+ }
384
+
385
+ /** One request/response exchange, on whichever transport is configured. */
386
+ private async request(method: string, params?: unknown): Promise<unknown> {
387
+ const id = this.nextId++;
388
+ const message = {
389
+ jsonrpc: "2.0",
390
+ id,
391
+ method,
392
+ ...(params === undefined ? {} : { params }),
393
+ };
394
+ const reply =
395
+ this.config.transport === "sse"
396
+ ? await this.requestOverSse(message, id)
397
+ : await this.requestOverStreamableHttp(message, id);
398
+ if (reply.error) {
399
+ throw new McpProtocolError(reply.error.message ?? "MCP request failed");
400
+ }
401
+ return reply.result;
402
+ }
403
+
404
+ private async requestOverStreamableHttp(
405
+ message: unknown,
406
+ id: number,
407
+ ): Promise<JsonRpcResponse> {
408
+ const response = await this.post(
409
+ this.config.url,
410
+ message,
411
+ "application/json, text/event-stream",
412
+ );
413
+ const session = response.headers.get("mcp-session-id");
414
+ if (session) this.sessionId = session;
415
+ const contentType = response.headers.get("content-type") ?? "";
416
+ const body = await boundedBody(response, this.maxResponseBytes);
417
+ return replyFromBody(contentType, body, id);
418
+ }
419
+
420
+ private async requestOverSse(
421
+ message: unknown,
422
+ id: number,
423
+ ): Promise<JsonRpcResponse> {
424
+ const session = await this.openSseSession();
425
+ session.resetBudget();
426
+ const accepted = await this.post(
427
+ this.messageUrl!,
428
+ message,
429
+ "application/json",
430
+ );
431
+ // The legacy transport answers the POST with 202 and delivers the reply on
432
+ // the stream; a server that answers inline is honoured too.
433
+ const contentType = accepted.headers.get("content-type") ?? "";
434
+ if (contentType.includes("application/json")) {
435
+ const body = await boundedBody(accepted, this.maxResponseBytes);
436
+ if (body.trim()) return replyFromBody(contentType, body, id);
437
+ } else {
438
+ await accepted.body?.cancel();
439
+ }
440
+ for (;;) {
441
+ const event = await session.next();
442
+ if (!event)
443
+ throw new McpProtocolError("MCP stream closed before replying");
444
+ if (event.event !== "message" || !event.data) continue;
445
+ const reply = decodeJsonRpcResponse(JSON.parse(event.data));
446
+ if (reply.id === id) return reply;
447
+ }
448
+ }
449
+
450
+ private async openSseSession(): Promise<SseSession> {
451
+ if (this.session) return this.session;
452
+ const response = await this.config.fetch(this.config.url.toString(), {
453
+ method: "GET",
454
+ headers: this.headers("text/event-stream"),
455
+ });
456
+ if (!response.ok || !response.body) {
457
+ const message = `MCP server answered ${response.status} opening its event stream`;
458
+ const challenge = response.headers.get("www-authenticate");
459
+ if (response.status === 401) {
460
+ throw new McpAuthorizationRequiredError(message, challenge);
461
+ }
462
+ throw new McpProtocolError(message, response.status, challenge);
463
+ }
464
+ const session = new SseSession(
465
+ response.body.getReader(),
466
+ this.maxResponseBytes,
467
+ );
468
+ const first = await session.next();
469
+ if (first?.event !== "endpoint" || !first.data) {
470
+ await session.close();
471
+ throw new McpProtocolError("MCP stream did not name a message endpoint");
472
+ }
473
+ let endpoint: URL;
474
+ try {
475
+ endpoint = new URL(first.data, this.config.url);
476
+ } catch {
477
+ await session.close();
478
+ throw new McpProtocolError("MCP message endpoint is invalid");
479
+ }
480
+ if (endpoint.origin !== this.config.url.origin) {
481
+ await session.close();
482
+ throw new McpProtocolError("MCP message endpoint changed origin");
483
+ }
484
+ this.messageUrl = endpoint;
485
+ this.session = session;
486
+ return session;
487
+ }
488
+
489
+ private async notify(method: string, params?: unknown): Promise<void> {
490
+ const message = {
491
+ jsonrpc: "2.0",
492
+ method,
493
+ ...(params === undefined ? {} : { params }),
494
+ };
495
+ const target =
496
+ this.config.transport === "sse"
497
+ ? (this.messageUrl ?? this.config.url)
498
+ : this.config.url;
499
+ const response = await this.post(
500
+ target,
501
+ message,
502
+ "application/json, text/event-stream",
503
+ );
504
+ await response.body?.cancel();
505
+ }
506
+
507
+ /** `initialize` plus `notifications/initialized`: the whole handshake. */
508
+ async connect(): Promise<McpHandshakeV1> {
509
+ const result = record(
510
+ await this.request("initialize", {
511
+ protocolVersion: MCP_PROTOCOL_VERSION,
512
+ capabilities: {},
513
+ clientInfo: CLIENT_INFO,
514
+ }),
515
+ "MCP initialize result",
516
+ );
517
+ const version = result.protocolVersion;
518
+ if (typeof version !== "string" || version.length === 0) {
519
+ throw new McpProtocolError("MCP server declared no protocol version");
520
+ }
521
+ this.protocolVersion = boundedText(version, 64);
522
+ const info =
523
+ result.serverInfo && typeof result.serverInfo === "object"
524
+ ? (result.serverInfo as Record<string, unknown>)
525
+ : {};
526
+ await this.notify("notifications/initialized");
527
+ return {
528
+ protocolVersion: this.protocolVersion,
529
+ ...(typeof info.name === "string"
530
+ ? { serverName: boundedText(info.name, 128) }
531
+ : {}),
532
+ ...(typeof info.version === "string"
533
+ ? { serverVersion: boundedText(info.version, 64) }
534
+ : {}),
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Every tool the server offers, paginated until it stops or the per-server
540
+ * ceiling is reached. Exceeding the ceiling is a refusal: a Bot must not be
541
+ * handed a partial catalog it cannot tell from a complete one.
542
+ */
543
+ async listTools(): Promise<McpToolDeclarationV1[]> {
544
+ const tools: McpToolDeclarationV1[] = [];
545
+ let cursor: string | undefined;
546
+ for (let page = 0; page < MAX_TOOL_LIST_PAGES; page += 1) {
547
+ const result = record(
548
+ await this.request(
549
+ "tools/list",
550
+ cursor === undefined ? {} : { cursor },
551
+ ),
552
+ "MCP tools/list result",
553
+ );
554
+ if (!Array.isArray(result.tools)) {
555
+ throw new McpProtocolError("MCP tools/list returned no tools array");
556
+ }
557
+ for (const candidate of result.tools) {
558
+ tools.push(decodeToolDeclaration(candidate));
559
+ if (tools.length > this.maxTools) {
560
+ throw new McpProtocolError(
561
+ `MCP server offers more than ${this.maxTools} tools`,
562
+ );
563
+ }
564
+ }
565
+ const next = result.nextCursor;
566
+ if (typeof next !== "string" || next.length === 0) return tools;
567
+ cursor = next;
568
+ }
569
+ throw new McpProtocolError("MCP tools/list did not terminate");
570
+ }
571
+
572
+ /**
573
+ * One `tools/call`. A protocol error and a tool that reports `isError` are
574
+ * both errors the Bot sees; neither throws past the Agent loop.
575
+ */
576
+ async callTool(
577
+ name: string,
578
+ args: Record<string, unknown>,
579
+ ): Promise<McpToolResultV1> {
580
+ const result = record(
581
+ await this.request("tools/call", { name, arguments: args }),
582
+ "MCP tools/call result",
583
+ );
584
+ return {
585
+ content: renderToolContent(result),
586
+ isError: result.isError === true,
587
+ };
588
+ }
589
+
590
+ async close(): Promise<void> {
591
+ await this.session?.close();
592
+ this.session = undefined;
593
+ }
594
+ }
595
+
596
+ function decodeToolDeclaration(value: unknown): McpToolDeclarationV1 {
597
+ const tool = record(value, "MCP tool");
598
+ if (
599
+ typeof tool.name !== "string" ||
600
+ tool.name.length === 0 ||
601
+ tool.name.length > MAX_TOOL_NAME_LENGTH
602
+ ) {
603
+ throw new McpProtocolError("MCP tool name is invalid");
604
+ }
605
+ // The server's schema reaches the model unchanged: it is the contract the
606
+ // server itself will validate the call against, and rewriting it here would
607
+ // make the two disagree.
608
+ const inputSchema =
609
+ tool.inputSchema && typeof tool.inputSchema === "object"
610
+ ? (tool.inputSchema as Record<string, unknown>)
611
+ : { type: "object" };
612
+ return {
613
+ name: tool.name,
614
+ ...(typeof tool.description === "string"
615
+ ? {
616
+ description: boundedText(
617
+ tool.description,
618
+ MAX_TOOL_DESCRIPTION_LENGTH,
619
+ ),
620
+ }
621
+ : {}),
622
+ inputSchema,
623
+ };
624
+ }
625
+
626
+ /**
627
+ * A tool result as one text payload. Text content blocks are joined; anything
628
+ * else (images, embedded resources, structured content) is carried as its own
629
+ * JSON, so nothing the server returned is silently dropped.
630
+ */
631
+ function renderToolContent(result: Record<string, unknown>): string {
632
+ const blocks = Array.isArray(result.content) ? result.content : [];
633
+ const parts = blocks.map((block) => {
634
+ if (!block || typeof block !== "object") return JSON.stringify(block);
635
+ const value = block as Record<string, unknown>;
636
+ if (value.type === "text" && typeof value.text === "string") {
637
+ return value.text;
638
+ }
639
+ return JSON.stringify(value);
640
+ });
641
+ if (parts.length === 0 && result.structuredContent !== undefined) {
642
+ return JSON.stringify(result.structuredContent);
643
+ }
644
+ return parts.join("\n");
645
+ }