@ian-pascoe/pi-mcp 0.1.0 → 0.2.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,660 @@
1
+ /* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-known-value-widening, anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-unsafe-dictionary-type -- MCP Transcript Presentation owns Pi's untyped historical result, tool-argument, and custom-message rendering boundaries. */
2
+ import {
3
+ DEFAULT_MAX_BYTES,
4
+ DEFAULT_MAX_LINES,
5
+ keyText,
6
+ truncateHead,
7
+ type AgentToolResult,
8
+ type MessageRenderer,
9
+ type MessageRenderOptions,
10
+ type Theme,
11
+ type ThemeColor,
12
+ type ToolRenderResultOptions,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ Box,
16
+ Container,
17
+ Spacer,
18
+ stripTerminalSequences,
19
+ Text,
20
+ truncateToWidth,
21
+ visibleWidth,
22
+ type Component,
23
+ } from "@earendil-works/pi-tui";
24
+ import { Type } from "typebox";
25
+ import { Value } from "typebox/value";
26
+ import type { McpModelContent } from "./mcp-content.js";
27
+
28
+ const MCP_DETAILS_OWNER = "pi-mcp";
29
+ const MCP_PRESENTATION_ELLIPSIS = "…";
30
+ const MCP_PRESENTATION_FULL_RESET = "\u001b[0m";
31
+ const MCP_PRESENTATION_TEXT_STYLE_RESET = "\u001b[22;39m";
32
+ const MCP_PRESENTATION_METADATA_LIMIT = 20;
33
+ const MCP_PRESENTATION_RESERVED_BYTES = 8 * 1024;
34
+ const MCP_PRESENTATION_RESERVED_LINES = 64;
35
+ const McpResultDetailsMarkerSchema = Type.Object(
36
+ {
37
+ mcp: Type.Object(
38
+ {
39
+ isError: Type.Boolean(),
40
+ operation: Type.Optional(Type.String()),
41
+ outputSchemaError: Type.Optional(Type.String()),
42
+ outputSchemaValid: Type.Optional(Type.Boolean()),
43
+ owner: Type.Literal(MCP_DETAILS_OWNER),
44
+ serverId: Type.Optional(Type.String()),
45
+ toolName: Type.Optional(Type.String()),
46
+ },
47
+ { additionalProperties: true },
48
+ ),
49
+ result: Type.Any(),
50
+ },
51
+ { additionalProperties: true },
52
+ );
53
+
54
+ /** Theme operations used by MCP Transcript Presentation and custom-message renderers. */
55
+ export type McpRenderTheme = Pick<Theme, "bg" | "bold" | "fg">;
56
+
57
+ /** Exact-value redactor applied only to human-facing MCP presentation copy. */
58
+ export type McpPresentationRedactor = (text: string) => string;
59
+
60
+ /** Existing MCP result marker persisted beside model-visible tool content. */
61
+ export interface McpResultMarker {
62
+ readonly isError: boolean;
63
+ readonly operation?: string;
64
+ readonly outputSchemaError?: string;
65
+ readonly outputSchemaValid?: boolean;
66
+ readonly owner: typeof MCP_DETAILS_OWNER;
67
+ readonly serverId?: string;
68
+ readonly toolName?: string;
69
+ }
70
+
71
+ /** Existing persisted MCP tool details consumed without changing their stored shape. */
72
+ export interface McpResultDetails {
73
+ readonly mcp: McpResultMarker;
74
+ readonly result: unknown;
75
+ }
76
+
77
+ /** One role-faithful Prompt message persisted for context replay and TUI presentation. */
78
+ export interface McpPromptReplayMessage {
79
+ readonly content: readonly McpModelContent[];
80
+ readonly role: "assistant" | "user";
81
+ readonly timestamp: number;
82
+ }
83
+
84
+ /** Version-1 MCP Prompt details already persisted in Pi custom messages. */
85
+ export interface McpPromptMessageDetails {
86
+ readonly mcpMessages: readonly unknown[];
87
+ readonly replayMessages: readonly McpPromptReplayMessage[];
88
+ readonly version: 1;
89
+ }
90
+
91
+ /** Durable custom-message fields needed by MCP Prompt and Resource Update renderers. */
92
+ export type McpPresentationMessage = Pick<Parameters<MessageRenderer>[0], "content" | "details">;
93
+
94
+ /** Fixed Resource operation rendered in MCP Transcript Presentation. */
95
+ export type McpResourcePresentationOperation =
96
+ | "list_resources"
97
+ | "list_resource_templates"
98
+ | "read_resource";
99
+
100
+ const identityRedactor: McpPresentationRedactor = (text) => text;
101
+
102
+ function truncateMcpPresentationToWidth(text: string, width: number): string {
103
+ const availableWidth = Math.max(1, width);
104
+ if (visibleWidth(text) <= availableWidth) return text;
105
+ const truncated = truncateToWidth(text, availableWidth - 1, "");
106
+ const prefix = truncated.endsWith(MCP_PRESENTATION_FULL_RESET)
107
+ ? truncated.slice(0, -MCP_PRESENTATION_FULL_RESET.length)
108
+ : truncated;
109
+ return `${prefix}${MCP_PRESENTATION_TEXT_STYLE_RESET}${MCP_PRESENTATION_ELLIPSIS}${MCP_PRESENTATION_TEXT_STYLE_RESET}`;
110
+ }
111
+
112
+ class McpSingleLine implements Component {
113
+ constructor(private readonly text: string) {}
114
+
115
+ invalidate(): void {}
116
+
117
+ render(width: number): string[] {
118
+ return [truncateMcpPresentationToWidth(this.text, width)];
119
+ }
120
+ }
121
+
122
+ /** Remove terminal sequences and unsafe C0/C1 controls while preserving line breaks and tabs. */
123
+ export function sanitizeMcpPresentationText(text: string): string {
124
+ return (
125
+ stripTerminalSequences(text)
126
+ .replaceAll("\r\n", "\n")
127
+ .replaceAll("\r", "\n")
128
+ // oxlint-disable-next-line eslint/no-control-regex -- SAFETY: Transcript text permits tabs/newlines but no other C0/C1 terminal controls.
129
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
130
+ );
131
+ }
132
+
133
+ function presentationText(text: string, redact: McpPresentationRedactor): string {
134
+ return sanitizeMcpPresentationText(redact(text));
135
+ }
136
+
137
+ function boundedMcpPreview(text: string, redact: McpPresentationRedactor, width = 160): string {
138
+ const safe = presentationText(text, redact).replace(/\s+/g, " ").trim();
139
+ if (visibleWidth(safe) <= width) return safe;
140
+ return truncateMcpPresentationToWidth(safe, width);
141
+ }
142
+
143
+ function boundedMcpMetadataText(
144
+ text: string,
145
+ redact: McpPresentationRedactor,
146
+ width: number,
147
+ maxBytes: number,
148
+ ): string {
149
+ const columns = truncateMcpPresentationToWidth(
150
+ presentationText(text, redact).replace(/\s+/g, " ").trim(),
151
+ width,
152
+ );
153
+ const bytes = truncateHead(columns, { maxBytes: maxBytes - 3, maxLines: 1 });
154
+ return bytes.truncated ? `${bytes.content}${MCP_PRESENTATION_ELLIPSIS}` : columns;
155
+ }
156
+
157
+ function boundedMcpText(text: string, redact: McpPresentationRedactor): string {
158
+ const safe = presentationText(text, redact);
159
+ const truncation = truncateHead(safe, {
160
+ maxBytes: DEFAULT_MAX_BYTES - MCP_PRESENTATION_RESERVED_BYTES,
161
+ maxLines: DEFAULT_MAX_LINES - MCP_PRESENTATION_RESERVED_LINES,
162
+ });
163
+ if (!truncation.truncated) return safe;
164
+ const omittedLines = Math.max(0, truncation.totalLines - truncation.outputLines);
165
+ const notice =
166
+ omittedLines === 0
167
+ ? "… output truncated"
168
+ : `… ${omittedLines} line${omittedLines === 1 ? "" : "s"} omitted`;
169
+ return truncation.content.length === 0 ? notice : `${truncation.content}\n${notice}`;
170
+ }
171
+
172
+ function isRecord(value: unknown): value is Record<string, unknown> {
173
+ return value !== null && typeof value === "object" && !Array.isArray(value);
174
+ }
175
+
176
+ function sortedPresentationValue(value: unknown): unknown {
177
+ if (Array.isArray(value)) return value.map(sortedPresentationValue);
178
+ if (!isRecord(value)) return value;
179
+ return Object.fromEntries(
180
+ Object.keys(value)
181
+ .sort()
182
+ .map((key) => [key, sortedPresentationValue(value[key])]),
183
+ );
184
+ }
185
+
186
+ function stringifyPresentationValue(value: unknown, pretty = false): string {
187
+ try {
188
+ return (
189
+ JSON.stringify(sortedPresentationValue(value), undefined, pretty ? 2 : undefined) ?? "null"
190
+ );
191
+ } catch {
192
+ return "(unavailable)";
193
+ }
194
+ }
195
+
196
+ function argumentPreview(arguments_: unknown, redact: McpPresentationRedactor): string | undefined {
197
+ if (!isRecord(arguments_) || Object.keys(arguments_).length === 0) return undefined;
198
+ return Object.keys(arguments_)
199
+ .sort()
200
+ .map(
201
+ (key) =>
202
+ `${presentationText(key, redact)}=${boundedMcpPreview(stringifyPresentationValue(arguments_[key]), redact, 72)}`,
203
+ )
204
+ .join(" ");
205
+ }
206
+
207
+ function expansionHint(theme: McpRenderTheme): string {
208
+ return `${theme.fg("dim", ` · ${keyText("app.tools.expand")}`)}${theme.fg("muted", " to expand")}`;
209
+ }
210
+
211
+ function renderMcpCall(
212
+ heading: string,
213
+ arguments_: unknown,
214
+ theme: McpRenderTheme,
215
+ expanded: boolean,
216
+ redact: McpPresentationRedactor,
217
+ previewArguments: boolean,
218
+ ): Component {
219
+ const container = new Container();
220
+ const preview = previewArguments ? argumentPreview(arguments_, redact) : undefined;
221
+ container.addChild(
222
+ new McpSingleLine(
223
+ `${heading}${preview === undefined ? "" : ` ${theme.fg("muted", preview)}`}`,
224
+ ),
225
+ );
226
+ if (expanded) {
227
+ container.addChild(new Spacer(1));
228
+ container.addChild(
229
+ new Text(boundedMcpText(stringifyPresentationValue(arguments_, true), redact), 0, 0),
230
+ );
231
+ }
232
+ return container;
233
+ }
234
+
235
+ /** Render one dynamic Server Tool call with its original MCP Server and Server Tool identities. */
236
+ export function renderMcpServerToolCall(
237
+ serverId: string,
238
+ toolName: string,
239
+ arguments_: unknown,
240
+ theme: McpRenderTheme,
241
+ expanded: boolean,
242
+ redact: McpPresentationRedactor = identityRedactor,
243
+ ): Component {
244
+ const heading = [
245
+ theme.fg("toolTitle", theme.bold("MCP")),
246
+ theme.fg(
247
+ "accent",
248
+ `${presentationText(serverId, redact)} / ${presentationText(toolName, redact)}`,
249
+ ),
250
+ ].join(" ");
251
+ return renderMcpCall(heading, arguments_, theme, expanded, redact, true);
252
+ }
253
+
254
+ function resourceOperationLabel(operation: McpResourcePresentationOperation): string {
255
+ switch (operation) {
256
+ case "list_resources":
257
+ return "List Resources";
258
+ case "list_resource_templates":
259
+ return "List Resource Templates";
260
+ case "read_resource":
261
+ return "Read Resource";
262
+ }
263
+ }
264
+
265
+ /** Render one fixed Resource tool call with its semantic operation and selected target. */
266
+ export function renderMcpResourceToolCall(
267
+ operation: McpResourcePresentationOperation,
268
+ arguments_: unknown,
269
+ theme: McpRenderTheme,
270
+ expanded: boolean,
271
+ redact: McpPresentationRedactor = identityRedactor,
272
+ ): Component {
273
+ const record = isRecord(arguments_) ? arguments_ : {};
274
+ const server =
275
+ typeof record.server === "string" ? presentationText(record.server, redact) : undefined;
276
+ const uri = typeof record.uri === "string" ? presentationText(record.uri, redact) : undefined;
277
+ const heading = [
278
+ theme.fg("toolTitle", theme.bold("MCP")),
279
+ theme.fg("accent", resourceOperationLabel(operation)),
280
+ server === undefined ? undefined : theme.fg("muted", server),
281
+ uri === undefined ? undefined : theme.fg("muted", uri),
282
+ ]
283
+ .filter((part): part is string => part !== undefined)
284
+ .join(" ");
285
+ return renderMcpCall(heading, arguments_, theme, expanded, redact, false);
286
+ }
287
+
288
+ /** Parse existing MCP result details at the persisted custom-tool boundary. */
289
+ export function parseMcpResultDetails(input: unknown): McpResultDetails | undefined {
290
+ if (!Value.Check(McpResultDetailsMarkerSchema, input)) return undefined;
291
+ // SAFETY: The result schema established every typed field consumed by presentation and the result bridge while permitting historical additional fields.
292
+ return input as McpResultDetails;
293
+ }
294
+
295
+ function parseMcpPromptReplayMessage(value: unknown): McpPromptReplayMessage | undefined {
296
+ if (
297
+ !isRecord(value) ||
298
+ (value.role !== "user" && value.role !== "assistant") ||
299
+ typeof value.timestamp !== "number" ||
300
+ !Array.isArray(value.content)
301
+ ) {
302
+ return undefined;
303
+ }
304
+ const content: McpModelContent[] = [];
305
+ for (const block of value.content) {
306
+ if (!isRecord(block)) return undefined;
307
+ if (block.type === "text" && typeof block.text === "string") {
308
+ content.push({ text: block.text, type: "text" });
309
+ continue;
310
+ }
311
+ if (
312
+ block.type === "image" &&
313
+ typeof block.data === "string" &&
314
+ typeof block.mimeType === "string"
315
+ ) {
316
+ content.push({ data: block.data, mimeType: block.mimeType, type: "image" });
317
+ continue;
318
+ }
319
+ return undefined;
320
+ }
321
+ return { content, role: value.role, timestamp: value.timestamp };
322
+ }
323
+
324
+ /** Parse the existing version-1 role-faithful Prompt replay messages without enriching details. */
325
+ export function parseMcpPromptReplayMessages(
326
+ value: unknown,
327
+ ): readonly McpPromptReplayMessage[] | undefined {
328
+ if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.replayMessages)) {
329
+ return undefined;
330
+ }
331
+ const messages: McpPromptReplayMessage[] = [];
332
+ for (const item of value.replayMessages) {
333
+ const parsed = parseMcpPromptReplayMessage(item);
334
+ if (parsed === undefined) return undefined;
335
+ messages.push(parsed);
336
+ }
337
+ return messages;
338
+ }
339
+
340
+ function toolResultText(result: AgentToolResult<unknown>): string {
341
+ return result.content
342
+ .filter(
343
+ (content): content is Extract<(typeof result.content)[number], { type: "text" }> =>
344
+ content.type === "text",
345
+ )
346
+ .map((content) => content.text)
347
+ .join("\n");
348
+ }
349
+
350
+ function pluralizedCount(count: number, noun: string): string {
351
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
352
+ }
353
+
354
+ function contentCountParts(result: AgentToolResult<unknown>): string[] {
355
+ const textCount = result.content.filter((content) => content.type === "text").length;
356
+ const imageCount = result.content.filter((content) => content.type === "image").length;
357
+ const parts: string[] = [];
358
+ if (textCount > 0) parts.push(pluralizedCount(textCount, "text block"));
359
+ if (imageCount > 0) parts.push(pluralizedCount(imageCount, "image"));
360
+ return parts;
361
+ }
362
+
363
+ function firstUsefulLine(text: string, redact: McpPresentationRedactor): string | undefined {
364
+ const line = presentationText(text, redact)
365
+ .split("\n")
366
+ .map((candidate) => candidate.trim())
367
+ .find(Boolean);
368
+ return line === undefined ? undefined : boundedMcpPreview(line, identityRedactor);
369
+ }
370
+
371
+ interface McpResultSummary {
372
+ readonly color: ThemeColor;
373
+ readonly text: string;
374
+ }
375
+
376
+ function mcpResultSummary(
377
+ result: AgentToolResult<unknown>,
378
+ details: McpResultDetails | undefined,
379
+ isError: boolean,
380
+ redact: McpPresentationRedactor,
381
+ ): McpResultSummary {
382
+ const text = toolResultText(result);
383
+ const error = isError || details?.mcp.isError === true;
384
+ const usefulLine = firstUsefulLine(text, redact);
385
+ if (error && /\b(?:abort(?:ed)?|cancel(?:led|ed|ation)?)\b/iu.test(text)) {
386
+ return {
387
+ color: "warning",
388
+ text: `■ cancelled${usefulLine === undefined ? "" : ` · ${usefulLine}`}`,
389
+ };
390
+ }
391
+ if (error) {
392
+ return {
393
+ color: "error",
394
+ text: `× failed${usefulLine === undefined ? "" : ` · ${usefulLine}`}`,
395
+ };
396
+ }
397
+ if (details?.mcp.outputSchemaValid === false) {
398
+ return { color: "warning", text: "! completed with output-schema failure" };
399
+ }
400
+ const counts = contentCountParts(result);
401
+ return {
402
+ color: "success",
403
+ text: `✓ completed · ${counts.length === 0 ? "no content" : counts.join(" · ")}`,
404
+ };
405
+ }
406
+
407
+ function renderMcpFallback(
408
+ result: AgentToolResult<unknown>,
409
+ options: ToolRenderResultOptions,
410
+ theme: McpRenderTheme,
411
+ isError: boolean,
412
+ redact: McpPresentationRedactor,
413
+ ): Component {
414
+ const text = toolResultText(result);
415
+ const safe = boundedMcpText(text, redact);
416
+ const usefulLine = firstUsefulLine(text, redact);
417
+ const summary = mcpResultSummary(result, undefined, isError, redact);
418
+ if (isError) {
419
+ if (options.expanded) {
420
+ const container = new Container();
421
+ container.addChild(new Text(theme.fg(summary.color, summary.text), 0, 0));
422
+ if (safe.length > 0) {
423
+ container.addChild(new Spacer(1));
424
+ container.addChild(new Text(safe, 0, 0));
425
+ }
426
+ return container;
427
+ }
428
+ return new McpSingleLine(theme.fg(summary.color, summary.text));
429
+ }
430
+ if (options.expanded && safe.length > 0) return new Text(safe, 0, 0);
431
+ if (usefulLine !== undefined) {
432
+ const hint = !options.isPartial && text.includes("\n") ? expansionHint(theme) : "";
433
+ return new McpSingleLine(theme.fg(isError ? "error" : "toolOutput", `${usefulLine}${hint}`));
434
+ }
435
+ return new McpSingleLine(theme.fg(summary.color, summary.text));
436
+ }
437
+
438
+ function resultMetadata(value: unknown): {
439
+ readonly spillPath?: string;
440
+ readonly storedContent: readonly Record<string, unknown>[];
441
+ } {
442
+ if (!isRecord(value)) return { storedContent: [] };
443
+ const storedContent = Array.isArray(value.storedContent)
444
+ ? value.storedContent.filter(isRecord)
445
+ : [];
446
+ return {
447
+ ...(typeof value.spillPath === "string" ? { spillPath: value.spillPath } : {}),
448
+ storedContent,
449
+ };
450
+ }
451
+
452
+ function appendMcpResultDetails(
453
+ container: Container,
454
+ result: AgentToolResult<unknown>,
455
+ details: McpResultDetails,
456
+ theme: McpRenderTheme,
457
+ redact: McpPresentationRedactor,
458
+ ): void {
459
+ const counts = contentCountParts(result);
460
+ container.addChild(
461
+ new Text(
462
+ `${theme.fg("muted", "Content:")} ${counts.length === 0 ? "no content" : counts.join(" · ")}`,
463
+ 0,
464
+ 0,
465
+ ),
466
+ );
467
+ const text = toolResultText(result);
468
+ if (text.length > 0) {
469
+ container.addChild(new Spacer(1));
470
+ container.addChild(new Text(boundedMcpText(text, redact), 0, 0));
471
+ }
472
+ const metadata = resultMetadata(details.result);
473
+ for (const stored of metadata.storedContent.slice(0, MCP_PRESENTATION_METADATA_LIMIT)) {
474
+ const fields = [stored.kind, stored.mimeType, stored.uri, stored.path]
475
+ .filter((field): field is string => typeof field === "string")
476
+ .map((field) => boundedMcpMetadataText(field, redact, 240, 256));
477
+ container.addChild(
478
+ new Text(`${theme.fg("muted", "Stored content:")} ${fields.join(" · ")}`, 0, 0),
479
+ );
480
+ }
481
+ if (metadata.storedContent.length > MCP_PRESENTATION_METADATA_LIMIT) {
482
+ container.addChild(
483
+ new Text(
484
+ theme.fg(
485
+ "muted",
486
+ `Stored content: ${metadata.storedContent.length - MCP_PRESENTATION_METADATA_LIMIT} more entries omitted`,
487
+ ),
488
+ 0,
489
+ 0,
490
+ ),
491
+ );
492
+ }
493
+ if (details.mcp.outputSchemaValid === false) {
494
+ const outcome =
495
+ details.mcp.outputSchemaError === undefined
496
+ ? "failed"
497
+ : `failed · ${boundedMcpPreview(details.mcp.outputSchemaError, redact)}`;
498
+ container.addChild(new Text(`${theme.fg("warning", "Output schema:")} ${outcome}`, 0, 0));
499
+ }
500
+ if (metadata.spillPath !== undefined) {
501
+ container.addChild(
502
+ new Text(
503
+ `${theme.fg("muted", "Result Spill:")} ${boundedMcpMetadataText(metadata.spillPath, redact, 1_000, 1_024)}`,
504
+ 0,
505
+ 0,
506
+ ),
507
+ );
508
+ }
509
+ }
510
+
511
+ /** Render final or partial MCP tool output without changing its model-visible content or details. */
512
+ export function renderMcpToolResult(
513
+ result: AgentToolResult<unknown>,
514
+ options: ToolRenderResultOptions,
515
+ theme: McpRenderTheme,
516
+ isError: boolean,
517
+ redact: McpPresentationRedactor = identityRedactor,
518
+ ): Component {
519
+ if (options.isPartial) {
520
+ const details = isRecord(result.details) ? result.details : undefined;
521
+ const progress =
522
+ details === undefined || !("progress" in details)
523
+ ? undefined
524
+ : boundedMcpPreview(stringifyPresentationValue(details.progress), redact, 120);
525
+ return new McpSingleLine(
526
+ theme.fg("accent", `Running…${progress === undefined ? "" : ` · ${progress}`}`),
527
+ );
528
+ }
529
+ const details = parseMcpResultDetails(result.details);
530
+ if (details === undefined) return renderMcpFallback(result, options, theme, isError, redact);
531
+ const summary = mcpResultSummary(result, details, isError, redact);
532
+ if (!options.expanded) {
533
+ const hasDetails =
534
+ toolResultText(result).length > 0 || resultMetadata(details.result).storedContent.length > 0;
535
+ return new McpSingleLine(
536
+ `${theme.fg(summary.color, summary.text)}${hasDetails ? expansionHint(theme) : ""}`,
537
+ );
538
+ }
539
+ const container = new Container();
540
+ container.addChild(new Text(theme.fg(summary.color, summary.text), 0, 0));
541
+ container.addChild(new Spacer(1));
542
+ appendMcpResultDetails(container, result, details, theme, redact);
543
+ return container;
544
+ }
545
+
546
+ function customMessageText(message: McpPresentationMessage): string {
547
+ if (typeof message.content === "string") return message.content;
548
+ return message.content
549
+ .filter((block) => block.type === "text")
550
+ .map((block) => block.text)
551
+ .join("\n");
552
+ }
553
+
554
+ function promptIdentity(
555
+ content: string,
556
+ ): { readonly prompt: string; readonly server: string } | undefined {
557
+ const prefix = "MCP Prompt ";
558
+ if (!content.startsWith(prefix)) return undefined;
559
+ const identity = content.slice(prefix.length);
560
+ const separator = identity.indexOf("/");
561
+ if (separator <= 0 || separator === identity.length - 1) return undefined;
562
+ return { prompt: identity.slice(separator + 1), server: identity.slice(0, separator) };
563
+ }
564
+
565
+ function messageBox(options: MessageRenderOptions, theme: McpRenderTheme): Box {
566
+ return new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
567
+ }
568
+
569
+ /** Render an existing Prompt custom message from its version-1 replay details or durable content. */
570
+ export function renderMcpPromptMessage(
571
+ message: McpPresentationMessage,
572
+ options: MessageRenderOptions,
573
+ theme: McpRenderTheme,
574
+ redact: McpPresentationRedactor = identityRedactor,
575
+ ): Component {
576
+ const box = messageBox(options, theme);
577
+ const content = customMessageText(message);
578
+ const replay = parseMcpPromptReplayMessages(message.details);
579
+ const identity = promptIdentity(content);
580
+ if (replay === undefined || identity === undefined) {
581
+ box.addChild(new Text(boundedMcpText(content, redact), 0, 0));
582
+ return box;
583
+ }
584
+ const roles = [...new Set(replay.map((entry) => entry.role))].join(", ");
585
+ const heading = [
586
+ theme.fg("accent", theme.bold("MCP Prompt")),
587
+ `${boundedMcpMetadataText(identity.server, redact, 120, 256)} / ${boundedMcpMetadataText(identity.prompt, redact, 120, 256)}`,
588
+ theme.fg("muted", pluralizedCount(replay.length, "message")),
589
+ roles.length === 0 ? undefined : theme.fg("muted", roles),
590
+ ]
591
+ .filter((part): part is string => part !== undefined)
592
+ .join(" ");
593
+ box.addChild(new Text(heading, 0, 0));
594
+ if (!options.expanded) return box;
595
+ const expandedMessages: string[] = [];
596
+ for (const entry of replay) {
597
+ const role = entry.role === "user" ? "User" : "Assistant";
598
+ expandedMessages.push(role);
599
+ for (const block of entry.content) {
600
+ if (block.type === "text") {
601
+ expandedMessages.push(block.text);
602
+ } else {
603
+ expandedMessages.push(`${role} image: ${block.mimeType}`);
604
+ }
605
+ }
606
+ }
607
+ box.addChild(new Spacer(1));
608
+ box.addChild(new Text(boundedMcpText(expandedMessages.join("\n"), redact), 0, 0));
609
+ return box;
610
+ }
611
+
612
+ function resourceUpdateIdentity(
613
+ content: string,
614
+ ): { readonly server: string; readonly uri: string } | undefined {
615
+ const match =
616
+ /^MCP Resource updated on (.*?): (.*?)\. Read it explicitly before using the new content\.$/u.exec(
617
+ content,
618
+ );
619
+ return match?.[1] === undefined || match[2] === undefined
620
+ ? undefined
621
+ : { server: match[1], uri: match[2] };
622
+ }
623
+
624
+ /** Render a Resource Update Notice without reading the Resource or triggering a model turn. */
625
+ export function renderMcpResourceUpdateMessage(
626
+ message: McpPresentationMessage,
627
+ options: MessageRenderOptions,
628
+ theme: McpRenderTheme,
629
+ redact: McpPresentationRedactor = identityRedactor,
630
+ ): Component {
631
+ const box = messageBox(options, theme);
632
+ const content = customMessageText(message);
633
+ const identity = resourceUpdateIdentity(content);
634
+ if (identity === undefined) {
635
+ box.addChild(new Text(boundedMcpText(content, redact), 0, 0));
636
+ return box;
637
+ }
638
+ box.addChild(
639
+ new Text(
640
+ [
641
+ theme.fg("accent", theme.bold("MCP Resource Update")),
642
+ boundedMcpMetadataText(identity.server, redact, 120, 256),
643
+ theme.fg("muted", boundedMcpMetadataText(identity.uri, redact, 240, 512)),
644
+ ].join(" "),
645
+ 0,
646
+ 0,
647
+ ),
648
+ );
649
+ if (options.expanded) {
650
+ box.addChild(new Spacer(1));
651
+ box.addChild(
652
+ new Text(
653
+ theme.fg("muted", "The Resource remains unread until the agent explicitly reads it."),
654
+ 0,
655
+ 0,
656
+ ),
657
+ );
658
+ }
659
+ return box;
660
+ }
@@ -27,6 +27,14 @@ interface McpServerLogFile {
27
27
  readonly path: string;
28
28
  }
29
29
 
30
+ /** Bounded retained server log text and its private complete-tail path. */
31
+ export interface McpRetainedServerLog {
32
+ /** Private session path containing the complete 256-KB retained server tail. */
33
+ readonly path: string;
34
+ /** Current bounded stderr and logging tail text. */
35
+ readonly text: string;
36
+ }
37
+
30
38
  /** Private Result Spill, unsupported-content, and per-server log files owned by one Pi session. */
31
39
  export interface McpSessionFiles {
32
40
  /** Private directory removed when the Pi session shuts down. */
@@ -37,8 +45,8 @@ export interface McpSessionFiles {
37
45
  writeUnsupportedContent(content: Uint8Array, mimeType: string): Promise<string>;
38
46
  /** Append stderr or logging bytes, retaining only the newest 256 KB for that server. */
39
47
  appendServerLog(serverName: string, chunk: string | Uint8Array): Promise<void>;
40
- /** Read the current bounded stderr and logging tail for one MCP Server. */
41
- readServerLog(serverName: string): Promise<string>;
48
+ /** Read the current bounded stderr and logging tail plus its private retained path. */
49
+ readServerLog(serverName: string): Promise<McpRetainedServerLog>;
42
50
  /** Remove all session files after queued writes finish. */
43
51
  close(): Promise<void>;
44
52
  }
@@ -80,10 +88,11 @@ class McpSessionFileStore implements McpSessionFiles {
80
88
  });
81
89
  }
82
90
 
83
- readServerLog(serverName: string): Promise<string> {
84
- return this.enqueueMcpSessionFileWrite(
85
- async () => this.serverLogs.get(serverName)?.log.read() ?? "",
86
- );
91
+ readServerLog(serverName: string): Promise<McpRetainedServerLog> {
92
+ return this.enqueueMcpSessionFileWrite(async () => {
93
+ const serverLog = this.getMcpServerLogFile(serverName);
94
+ return { path: serverLog.path, text: serverLog.log.read() };
95
+ });
87
96
  }
88
97
 
89
98
  close(): Promise<void> {