@ian-pascoe/pi-mcp 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,212 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ truncateHead,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { ContentBlock, JSONValue } from "@modelcontextprotocol/client";
7
+ import type { McpSessionFiles } from "./mcp-session-files.js";
8
+
9
+ /** MCP text content received from a Server Tool, Resource, or Prompt. */
10
+ export type McpTextContent = Extract<ContentBlock, { readonly type: "text" }>;
11
+
12
+ /** MCP image content represented as base64 data and its declared media type. */
13
+ export type McpImageContent = Extract<ContentBlock, { readonly type: "image" }>;
14
+
15
+ /** MCP audio content that Pi stores privately because Pi has no native audio tool content. */
16
+ export type McpAudioContent = Extract<ContentBlock, { readonly type: "audio" }>;
17
+
18
+ /** MCP embedded resource content, including either text or base64 binary data. */
19
+ export type McpResourceContent = Extract<ContentBlock, { readonly type: "resource" }>;
20
+
21
+ /** Text or binary resource embedded directly in MCP content. */
22
+ export type McpEmbeddedResource = McpResourceContent["resource"];
23
+
24
+ /** A server-provided reference to an MCP Resource that remains unread. */
25
+ export type McpResourceLinkContent = Extract<ContentBlock, { readonly type: "resource_link" }>;
26
+
27
+ /** Public MCP v2 content blocks that Pi can map to model content or private session files. */
28
+ export type McpContentBlock =
29
+ | McpTextContent
30
+ | McpImageContent
31
+ | McpAudioContent
32
+ | McpResourceContent
33
+ | McpResourceLinkContent;
34
+
35
+ /** Pi-native text or image content that remains visible to the model. */
36
+ export type McpModelContent =
37
+ | { readonly type: "text"; readonly text: string }
38
+ | { readonly type: "image"; readonly data: string; readonly mimeType: string };
39
+
40
+ /** One unsupported MCP content block stored privately instead of being discarded. */
41
+ export interface McpStoredContent {
42
+ /** Original MCP content category that Pi cannot render natively. */
43
+ readonly kind: "audio" | "embedded_binary";
44
+ /** Server-declared media type, if it supplied one. */
45
+ readonly mimeType?: string;
46
+ /** Mode-safe private session file that contains the original bytes. */
47
+ readonly path: string;
48
+ /** Embedded resource URI when the stored bytes came from an MCP Resource. */
49
+ readonly uri?: string;
50
+ }
51
+
52
+ /** Bounded diagnostic details retained beside mapped MCP model content. */
53
+ export interface McpContentResultDetails {
54
+ /** Complete oversized text representation, retained only in a private Result Spill. */
55
+ readonly spillPath?: string;
56
+ /** Every audio or binary payload retained in a private session file. */
57
+ readonly storedContent: readonly McpStoredContent[];
58
+ /** Model-facing textual representation, bounded to Pi's line and byte limits. */
59
+ readonly summary: string;
60
+ }
61
+
62
+ /** Pi-native content plus bounded diagnostic details produced from one MCP result. */
63
+ export interface McpContentResult {
64
+ readonly content: readonly McpModelContent[];
65
+ readonly details: McpContentResultDetails;
66
+ }
67
+
68
+ function hasMcpEmbeddedText(
69
+ resource: McpEmbeddedResource,
70
+ ): resource is Extract<McpEmbeddedResource, { readonly text: string }> {
71
+ return "text" in resource;
72
+ }
73
+
74
+ function hasMcpEmbeddedBlob(
75
+ resource: McpEmbeddedResource,
76
+ ): resource is Extract<McpEmbeddedResource, { readonly blob: string }> {
77
+ return "blob" in resource;
78
+ }
79
+
80
+ function describeMcpEmbeddedTextResource(
81
+ resource: Extract<McpEmbeddedResource, { readonly text: string }>,
82
+ ): string {
83
+ const mediaType = resource.mimeType === undefined ? "unknown media type" : resource.mimeType;
84
+ return `[MCP embedded resource: ${resource.uri} (${mediaType})]\n${resource.text}`;
85
+ }
86
+
87
+ function describeMcpResourceLink(resourceLink: McpResourceLinkContent): string {
88
+ const details = [
89
+ resourceLink.mimeType,
90
+ resourceLink.size === undefined ? undefined : `${resourceLink.size} bytes`,
91
+ ]
92
+ .filter((detail): detail is string => detail !== undefined)
93
+ .join(", ");
94
+ const suffix = resourceLink.description === undefined ? "" : ` — ${resourceLink.description}`;
95
+ return `[MCP resource link: ${resourceLink.name}] ${resourceLink.uri}${
96
+ details.length === 0 ? "" : ` (${details})`
97
+ }${suffix}`;
98
+ }
99
+
100
+ function stringifyMcpStructuredContent(structuredContent: JSONValue): string {
101
+ try {
102
+ const serialized = JSON.stringify(structuredContent);
103
+ return serialized ?? "null";
104
+ } catch (cause) {
105
+ throw new Error("Pi MCP: cannot serialize structured content for model output", { cause });
106
+ }
107
+ }
108
+
109
+ function decodeMcpBase64Content(data: string, subject: string): Uint8Array {
110
+ try {
111
+ return Buffer.from(data, "base64");
112
+ } catch (cause) {
113
+ throw new Error(`Pi MCP: cannot decode ${subject} base64 content`, { cause });
114
+ }
115
+ }
116
+
117
+ /** Map MCP content losslessly to native Pi content or labelled mode-safe session-file references. */
118
+ export async function createMcpContentResult(
119
+ contentBlocks: readonly McpContentBlock[],
120
+ structuredContent: JSONValue | undefined,
121
+ sessionFiles: McpSessionFiles,
122
+ ): Promise<McpContentResult> {
123
+ const textParts: string[] = [];
124
+ const mappedContent: McpModelContent[] = [];
125
+ const storedContent: McpStoredContent[] = [];
126
+ const addModelText = (text: string): void => {
127
+ textParts.push(text);
128
+ mappedContent.push({ type: "text", text });
129
+ };
130
+
131
+ for (const content of contentBlocks) {
132
+ switch (content.type) {
133
+ case "text":
134
+ addModelText(content.text);
135
+ break;
136
+ case "image":
137
+ mappedContent.push({ type: "image", data: content.data, mimeType: content.mimeType });
138
+ break;
139
+ case "audio": {
140
+ const path = await sessionFiles.writeUnsupportedContent(
141
+ decodeMcpBase64Content(content.data, "audio"),
142
+ content.mimeType,
143
+ );
144
+ storedContent.push({ kind: "audio", mimeType: content.mimeType, path });
145
+ addModelText(`[MCP unsupported audio (${content.mimeType}) stored at: ${path}]`);
146
+ break;
147
+ }
148
+ case "resource": {
149
+ if (hasMcpEmbeddedText(content.resource)) {
150
+ addModelText(describeMcpEmbeddedTextResource(content.resource));
151
+ break;
152
+ }
153
+ if (hasMcpEmbeddedBlob(content.resource)) {
154
+ const path = await sessionFiles.writeUnsupportedContent(
155
+ decodeMcpBase64Content(content.resource.blob, "embedded resource"),
156
+ content.resource.mimeType ?? "application/octet-stream",
157
+ );
158
+ storedContent.push({
159
+ kind: "embedded_binary",
160
+ ...(content.resource.mimeType !== undefined && { mimeType: content.resource.mimeType }),
161
+ path,
162
+ uri: content.resource.uri,
163
+ });
164
+ addModelText(
165
+ `[MCP embedded binary resource: ${content.resource.uri} (${content.resource.mimeType ?? "unknown media type"}) stored at: ${path}]`,
166
+ );
167
+ break;
168
+ }
169
+ break;
170
+ }
171
+ case "resource_link":
172
+ addModelText(describeMcpResourceLink(content));
173
+ break;
174
+ }
175
+ }
176
+
177
+ if (structuredContent !== undefined) {
178
+ addModelText(`[MCP structured content]\n${stringifyMcpStructuredContent(structuredContent)}`);
179
+ }
180
+
181
+ const completeText = textParts.join("\n\n");
182
+ const truncation = truncateHead(completeText, {
183
+ maxBytes: DEFAULT_MAX_BYTES,
184
+ maxLines: DEFAULT_MAX_LINES,
185
+ });
186
+ const spillPath = truncation.truncated
187
+ ? await sessionFiles.writeResultSpill(completeText)
188
+ : undefined;
189
+ const modelText =
190
+ spillPath === undefined
191
+ ? completeText
192
+ : `${truncation.content}\n\n[Pi MCP: content truncated; complete Result Spill: ${spillPath}]`;
193
+ const content =
194
+ spillPath === undefined
195
+ ? mappedContent
196
+ : [
197
+ ...(modelText.length === 0 ? [] : [{ type: "text" as const, text: modelText }]),
198
+ ...mappedContent.filter(
199
+ (content): content is Extract<McpModelContent, { type: "image" }> =>
200
+ content.type === "image",
201
+ ),
202
+ ];
203
+
204
+ return {
205
+ content,
206
+ details: {
207
+ ...(spillPath !== undefined && { spillPath }),
208
+ storedContent,
209
+ summary: truncation.content,
210
+ },
211
+ };
212
+ }