@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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/pi-mcp-cli.js +10948 -0
- package/package.json +64 -0
- package/src/index.ts +2 -0
- package/src/mcp-auth-store.ts +393 -0
- package/src/mcp-command.ts +893 -0
- package/src/mcp-content.ts +212 -0
- package/src/mcp-host.ts +971 -0
- package/src/mcp-oauth.ts +740 -0
- package/src/mcp-server-client.ts +375 -0
- package/src/mcp-session-files.ts +127 -0
- package/src/mcp-settings-store.ts +455 -0
- package/src/mcp-tool-catalog.ts +464 -0
- package/src/pi-mcp-cli.ts +507 -0
- package/src/pi-mcp-extension.ts +1013 -0
- package/src/pi-mcp-settings.ts +619 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
Client,
|
|
5
|
+
ProtocolError,
|
|
6
|
+
ProtocolErrorCode,
|
|
7
|
+
SSEClientTransport,
|
|
8
|
+
StreamableHTTPClientTransport,
|
|
9
|
+
createFetchWithInit,
|
|
10
|
+
type AuthProvider,
|
|
11
|
+
type ClientContext,
|
|
12
|
+
type ClientOptions,
|
|
13
|
+
type CreateMessageRequest,
|
|
14
|
+
type CreateMessageResult,
|
|
15
|
+
type CreateMessageResultWithTools,
|
|
16
|
+
type ElicitRequest,
|
|
17
|
+
type ElicitResult,
|
|
18
|
+
type FetchLike,
|
|
19
|
+
type Implementation,
|
|
20
|
+
type ListRootsRequest,
|
|
21
|
+
type ListRootsResult,
|
|
22
|
+
type OAuthClientProvider,
|
|
23
|
+
type RequestOptions,
|
|
24
|
+
} from "@modelcontextprotocol/client";
|
|
25
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
|
|
26
|
+
import { MCP_SHUTDOWN_TIMEOUT_MS, type McpServerDefinition } from "./pi-mcp-settings.js";
|
|
27
|
+
|
|
28
|
+
const MAX_MCP_LIST_PAGES = 1_000;
|
|
29
|
+
|
|
30
|
+
type McpConnectableServerDefinition =
|
|
31
|
+
| Pick<
|
|
32
|
+
Extract<McpServerDefinition, { readonly transport: "stdio" }>,
|
|
33
|
+
"args" | "command" | "cwd" | "environment" | "transport"
|
|
34
|
+
>
|
|
35
|
+
| Pick<
|
|
36
|
+
Extract<McpServerDefinition, { readonly transport: "http" | "sse" }>,
|
|
37
|
+
"auth" | "headers" | "transport" | "url"
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
type McpClientTransport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport;
|
|
41
|
+
|
|
42
|
+
/** Request-scoped Host callbacks used for MCP sampling, elicitation, and roots. */
|
|
43
|
+
export interface McpServerRequestCallbacks<PiContext> {
|
|
44
|
+
readonly onElicitation?: (
|
|
45
|
+
request: ElicitRequest,
|
|
46
|
+
piContext: PiContext,
|
|
47
|
+
clientContext: ClientContext,
|
|
48
|
+
) => Promise<ElicitResult> | ElicitResult;
|
|
49
|
+
readonly onListRoots?: (
|
|
50
|
+
request: ListRootsRequest,
|
|
51
|
+
piContext: PiContext,
|
|
52
|
+
clientContext: ClientContext,
|
|
53
|
+
) => Promise<ListRootsResult> | ListRootsResult;
|
|
54
|
+
readonly onSampling?: (
|
|
55
|
+
request: CreateMessageRequest,
|
|
56
|
+
piContext: PiContext,
|
|
57
|
+
clientContext: ClientContext,
|
|
58
|
+
) =>
|
|
59
|
+
| Promise<CreateMessageResult | CreateMessageResultWithTools>
|
|
60
|
+
| CreateMessageResult
|
|
61
|
+
| CreateMessageResultWithTools;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-call cancellation, progress, and Pi request context. */
|
|
65
|
+
export interface McpServerRunOptions<PiContext> {
|
|
66
|
+
readonly callbacks?: McpServerRequestCallbacks<PiContext>;
|
|
67
|
+
readonly onProgress?: NonNullable<RequestOptions["onprogress"]>;
|
|
68
|
+
readonly piContext?: PiContext;
|
|
69
|
+
readonly signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inputs required to connect one owned MCP Client to one Server Definition. */
|
|
73
|
+
export interface McpServerClientConnectOptions {
|
|
74
|
+
readonly authProvider?: AuthProvider | OAuthClientProvider;
|
|
75
|
+
readonly clientInfo: Implementation;
|
|
76
|
+
readonly connectTimeoutMs: number;
|
|
77
|
+
readonly definition: McpConnectableServerDefinition;
|
|
78
|
+
readonly onConnectionClose?: () => void;
|
|
79
|
+
readonly listChanged?: ClientOptions["listChanged"];
|
|
80
|
+
readonly onError?: (error: Error) => void;
|
|
81
|
+
readonly onStderr?: (text: string) => void;
|
|
82
|
+
readonly piCwd: string;
|
|
83
|
+
readonly requestTimeoutMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ActiveMcpRunContext {
|
|
87
|
+
callbacks?: McpServerRequestCallbacks<unknown>;
|
|
88
|
+
piContext?: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requestHeaders(
|
|
92
|
+
definition: Extract<McpConnectableServerDefinition, { transport: "http" | "sse" }>,
|
|
93
|
+
): Headers {
|
|
94
|
+
const headers = new Headers(definition.headers);
|
|
95
|
+
if (definition.auth?.type === "bearer") {
|
|
96
|
+
headers.set("Authorization", `Bearer ${definition.auth.token}`);
|
|
97
|
+
}
|
|
98
|
+
return headers;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readMcpAuthToken(
|
|
102
|
+
authProvider: AuthProvider | OAuthClientProvider,
|
|
103
|
+
): Promise<string | undefined> {
|
|
104
|
+
if ("token" in authProvider) return authProvider.token();
|
|
105
|
+
return (await authProvider.tokens())?.access_token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createAuthenticatedFetch(
|
|
109
|
+
authProvider: AuthProvider | OAuthClientProvider,
|
|
110
|
+
requestInit: RequestInit,
|
|
111
|
+
): FetchLike {
|
|
112
|
+
const fetchWithHeaders = createFetchWithInit(undefined, requestInit);
|
|
113
|
+
return async (input, init) => {
|
|
114
|
+
const token = await readMcpAuthToken(authProvider);
|
|
115
|
+
if (token === undefined) return fetchWithHeaders(input, init);
|
|
116
|
+
const headers = new Headers(init?.headers);
|
|
117
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
118
|
+
return fetchWithHeaders(input, { ...init, headers });
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createMcpTransport(options: McpServerClientConnectOptions): McpClientTransport {
|
|
123
|
+
const definition = options.definition;
|
|
124
|
+
if (definition.transport === "stdio") {
|
|
125
|
+
return new StdioClientTransport({
|
|
126
|
+
args: [...definition.args],
|
|
127
|
+
command: definition.command,
|
|
128
|
+
cwd: resolve(options.piCwd, definition.cwd ?? "."),
|
|
129
|
+
env: { ...getDefaultEnvironment(), ...definition.environment },
|
|
130
|
+
stderr: "pipe",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const headers = requestHeaders(definition);
|
|
135
|
+
const requestInit = { headers } satisfies RequestInit;
|
|
136
|
+
if (definition.transport === "http") {
|
|
137
|
+
const transportOptions: NonNullable<
|
|
138
|
+
ConstructorParameters<typeof StreamableHTTPClientTransport>[1]
|
|
139
|
+
> = { requestInit };
|
|
140
|
+
if (options.authProvider !== undefined) transportOptions.authProvider = options.authProvider;
|
|
141
|
+
return new StreamableHTTPClientTransport(new URL(definition.url), transportOptions);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const transportOptions: NonNullable<ConstructorParameters<typeof SSEClientTransport>[1]> = {
|
|
145
|
+
requestInit,
|
|
146
|
+
};
|
|
147
|
+
if ([...headers].length > 0) {
|
|
148
|
+
transportOptions.eventSourceInit = {
|
|
149
|
+
fetch:
|
|
150
|
+
options.authProvider === undefined
|
|
151
|
+
? createFetchWithInit(undefined, requestInit)
|
|
152
|
+
: createAuthenticatedFetch(options.authProvider, requestInit),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (options.authProvider !== undefined) transportOptions.authProvider = options.authProvider;
|
|
156
|
+
return new SSEClientTransport(new URL(definition.url), transportOptions);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function closeBeforeDeadline(
|
|
160
|
+
close: () => Promise<void>,
|
|
161
|
+
processId: number | undefined,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
return new Promise((resolveClose) => {
|
|
164
|
+
let settled = false;
|
|
165
|
+
const finish = () => {
|
|
166
|
+
if (settled) return;
|
|
167
|
+
settled = true;
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolveClose();
|
|
170
|
+
};
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
if (processId !== undefined) {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(processId, "SIGKILL");
|
|
175
|
+
} catch {
|
|
176
|
+
// The owned child already exited.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
finish();
|
|
180
|
+
}, MCP_SHUTDOWN_TIMEOUT_MS);
|
|
181
|
+
timer.unref();
|
|
182
|
+
close().then(finish, finish);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Owns one official MCP Client, its transport, request contexts, and bounded cleanup. */
|
|
187
|
+
export class McpServerClient {
|
|
188
|
+
private readonly activeRuns = new Set<ActiveMcpRunContext>();
|
|
189
|
+
private callbackTail: Promise<void> = Promise.resolve();
|
|
190
|
+
private closePromise: Promise<void> | undefined;
|
|
191
|
+
private closing = false;
|
|
192
|
+
private connected = false;
|
|
193
|
+
private readonly requestContext = new AsyncLocalStorage<ActiveMcpRunContext>();
|
|
194
|
+
|
|
195
|
+
private constructor(
|
|
196
|
+
private readonly client: Client,
|
|
197
|
+
private readonly transport: McpClientTransport,
|
|
198
|
+
private readonly requestTimeoutMs: number,
|
|
199
|
+
) {}
|
|
200
|
+
|
|
201
|
+
/** Connect one MCP Client with automatic current/legacy protocol negotiation. */
|
|
202
|
+
static async connect(options: McpServerClientConnectOptions): Promise<McpServerClient> {
|
|
203
|
+
const transport = createMcpTransport(options);
|
|
204
|
+
if (transport instanceof StdioClientTransport && options.onStderr !== undefined) {
|
|
205
|
+
transport.stderr?.on("data", (chunk: Buffer | string) =>
|
|
206
|
+
options.onStderr?.(chunk.toString()),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const clientOptions: ClientOptions = {
|
|
211
|
+
capabilities: {
|
|
212
|
+
elicitation: { form: {}, url: {} },
|
|
213
|
+
roots: {},
|
|
214
|
+
sampling: {},
|
|
215
|
+
},
|
|
216
|
+
inputRequired: { maxRounds: 10 },
|
|
217
|
+
listMaxPages: MAX_MCP_LIST_PAGES,
|
|
218
|
+
versionNegotiation: {
|
|
219
|
+
mode: "auto",
|
|
220
|
+
probe: { timeoutMs: options.connectTimeoutMs },
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
if (options.listChanged !== undefined) clientOptions.listChanged = options.listChanged;
|
|
224
|
+
const client = new Client(options.clientInfo, clientOptions);
|
|
225
|
+
const owner = new McpServerClient(client, transport, options.requestTimeoutMs);
|
|
226
|
+
if (options.onError !== undefined) client.onerror = options.onError;
|
|
227
|
+
client.onclose = () => {
|
|
228
|
+
const wasUnexpected = owner.connected && !owner.closing;
|
|
229
|
+
owner.connected = false;
|
|
230
|
+
if (wasUnexpected) options.onConnectionClose?.();
|
|
231
|
+
};
|
|
232
|
+
owner.registerRequestHandlers();
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
await client.connect(transport, {
|
|
236
|
+
maxTotalTimeout: options.connectTimeoutMs,
|
|
237
|
+
timeout: options.connectTimeoutMs,
|
|
238
|
+
});
|
|
239
|
+
owner.connected = true;
|
|
240
|
+
return owner;
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
await owner.close();
|
|
243
|
+
throw cause;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Negotiated protocol revision for the connected MCP Server. */
|
|
248
|
+
get negotiatedProtocolVersion(): string | undefined {
|
|
249
|
+
return this.client.getNegotiatedProtocolVersion();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Negotiated current or legacy protocol era. */
|
|
253
|
+
get protocolEra(): "modern" | "legacy" | undefined {
|
|
254
|
+
return this.client.getProtocolEra();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Server Instructions reported during connection. */
|
|
258
|
+
get instructions(): string | undefined {
|
|
259
|
+
return this.client.getInstructions();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Direct stdio child process identifier, when this Client owns one. */
|
|
263
|
+
get processId(): number | undefined {
|
|
264
|
+
if (!(this.transport instanceof StdioClientTransport)) return undefined;
|
|
265
|
+
return this.transport.pid ?? undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Run one MCP operation with isolated Pi context, cancellation, progress, and timeout reset. */
|
|
269
|
+
async run<Result, PiContext = undefined>(
|
|
270
|
+
operation: (client: Client, requestOptions: RequestOptions) => Promise<Result>,
|
|
271
|
+
options: McpServerRunOptions<PiContext> = {},
|
|
272
|
+
): Promise<Result> {
|
|
273
|
+
if (this.closing || !this.connected) {
|
|
274
|
+
throw new Error("Pi MCP Client is not connected");
|
|
275
|
+
}
|
|
276
|
+
if (options.callbacks !== undefined) {
|
|
277
|
+
const predecessor = this.callbackTail;
|
|
278
|
+
let release: () => void = () => undefined;
|
|
279
|
+
this.callbackTail = new Promise<void>((resolveTail) => {
|
|
280
|
+
release = resolveTail;
|
|
281
|
+
});
|
|
282
|
+
await predecessor;
|
|
283
|
+
try {
|
|
284
|
+
return await this.runOperation(operation, options);
|
|
285
|
+
} finally {
|
|
286
|
+
release();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return this.runOperation(operation, options);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async runOperation<Result, PiContext = undefined>(
|
|
293
|
+
operation: (client: Client, requestOptions: RequestOptions) => Promise<Result>,
|
|
294
|
+
options: McpServerRunOptions<PiContext>,
|
|
295
|
+
): Promise<Result> {
|
|
296
|
+
const context: ActiveMcpRunContext = {};
|
|
297
|
+
if (options.callbacks !== undefined) {
|
|
298
|
+
// SAFETY: The callback and piContext originate from the same generic run invocation and stay paired in this private context.
|
|
299
|
+
context.callbacks = options.callbacks as McpServerRequestCallbacks<unknown>;
|
|
300
|
+
}
|
|
301
|
+
if (options.piContext !== undefined) context.piContext = options.piContext;
|
|
302
|
+
const requestOptions: RequestOptions = {
|
|
303
|
+
resetTimeoutOnProgress: true,
|
|
304
|
+
timeout: this.requestTimeoutMs,
|
|
305
|
+
};
|
|
306
|
+
if (options.onProgress !== undefined) requestOptions.onprogress = options.onProgress;
|
|
307
|
+
if (options.signal !== undefined) requestOptions.signal = options.signal;
|
|
308
|
+
if (options.callbacks !== undefined) this.activeRuns.add(context);
|
|
309
|
+
try {
|
|
310
|
+
return await this.requestContext.run(context, () => operation(this.client, requestOptions));
|
|
311
|
+
} finally {
|
|
312
|
+
if (options.callbacks !== undefined) this.activeRuns.delete(context);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Close the Client once, forcing its owned stdio process after five seconds. */
|
|
317
|
+
close(): Promise<void> {
|
|
318
|
+
if (this.closePromise !== undefined) return this.closePromise;
|
|
319
|
+
this.closing = true;
|
|
320
|
+
const processId = this.processId;
|
|
321
|
+
this.closePromise = closeBeforeDeadline(() => this.client.close(), processId).finally(() => {
|
|
322
|
+
this.connected = false;
|
|
323
|
+
});
|
|
324
|
+
return this.closePromise;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private registerRequestHandlers(): void {
|
|
328
|
+
this.client.setRequestHandler("sampling/createMessage", (request, clientContext) => {
|
|
329
|
+
const context = this.resolveRequestContext("sampling/createMessage");
|
|
330
|
+
const callback = context.callbacks?.onSampling;
|
|
331
|
+
if (callback === undefined) {
|
|
332
|
+
throw new ProtocolError(
|
|
333
|
+
ProtocolErrorCode.InternalError,
|
|
334
|
+
"Pi MCP sampling callback is unavailable",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return callback(request, context.piContext, clientContext);
|
|
338
|
+
});
|
|
339
|
+
this.client.setRequestHandler("elicitation/create", (request, clientContext) => {
|
|
340
|
+
const context = this.resolveRequestContext("elicitation/create");
|
|
341
|
+
const callback = context.callbacks?.onElicitation;
|
|
342
|
+
if (callback === undefined) {
|
|
343
|
+
throw new ProtocolError(
|
|
344
|
+
ProtocolErrorCode.InternalError,
|
|
345
|
+
"Pi MCP elicitation callback is unavailable",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return callback(request, context.piContext, clientContext);
|
|
349
|
+
});
|
|
350
|
+
this.client.setRequestHandler("roots/list", (request, clientContext) => {
|
|
351
|
+
const context = this.resolveRequestContext("roots/list");
|
|
352
|
+
const callback = context.callbacks?.onListRoots;
|
|
353
|
+
if (callback === undefined) {
|
|
354
|
+
throw new ProtocolError(
|
|
355
|
+
ProtocolErrorCode.InternalError,
|
|
356
|
+
"Pi MCP roots callback is unavailable",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return callback(request, context.piContext, clientContext);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private resolveRequestContext(method: string): ActiveMcpRunContext {
|
|
364
|
+
const current = this.requestContext.getStore();
|
|
365
|
+
if (current !== undefined) return current;
|
|
366
|
+
if (this.activeRuns.size === 1) {
|
|
367
|
+
const onlyContext = this.activeRuns.values().next().value;
|
|
368
|
+
if (onlyContext !== undefined) return onlyContext;
|
|
369
|
+
}
|
|
370
|
+
throw new ProtocolError(
|
|
371
|
+
ProtocolErrorCode.InternalError,
|
|
372
|
+
`Pi MCP ${method} request context is ambiguous`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Maximum stderr and MCP logging bytes retained for one MCP Server. */
|
|
5
|
+
export const MAX_MCP_SERVER_LOG_BYTES = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
/** Retains only the newest MCP Server stderr and logging bytes. */
|
|
8
|
+
export class RetainedMcpServerLog {
|
|
9
|
+
private content = Buffer.alloc(0);
|
|
10
|
+
|
|
11
|
+
/** Append one stderr or logging chunk in arrival order. */
|
|
12
|
+
append(chunk: string | Uint8Array): void {
|
|
13
|
+
const combined = Buffer.concat([this.content, Buffer.from(chunk)]);
|
|
14
|
+
const overflow = Math.max(0, combined.length - MAX_MCP_SERVER_LOG_BYTES);
|
|
15
|
+
this.content = overflow === 0 ? combined : Buffer.from(combined.subarray(overflow));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Return the current bounded stderr and logging tail as UTF-8 text. */
|
|
19
|
+
read(): string {
|
|
20
|
+
return this.content.toString("utf8");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One private on-disk tail for an MCP Server's stderr and logging notifications. */
|
|
25
|
+
interface McpServerLogFile {
|
|
26
|
+
readonly log: RetainedMcpServerLog;
|
|
27
|
+
readonly path: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Private Result Spill, unsupported-content, and per-server log files owned by one Pi session. */
|
|
31
|
+
export interface McpSessionFiles {
|
|
32
|
+
/** Private directory removed when the Pi session shuts down. */
|
|
33
|
+
readonly directoryPath: string;
|
|
34
|
+
/** Write complete truncated model-facing output to a Result Spill file. */
|
|
35
|
+
writeResultSpill(output: string): Promise<string>;
|
|
36
|
+
/** Write unsupported MCP binary or audio bytes to a private session file. */
|
|
37
|
+
writeUnsupportedContent(content: Uint8Array, mimeType: string): Promise<string>;
|
|
38
|
+
/** Append stderr or logging bytes, retaining only the newest 256 KB for that server. */
|
|
39
|
+
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>;
|
|
42
|
+
/** Remove all session files after queued writes finish. */
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class McpSessionFileStore implements McpSessionFiles {
|
|
47
|
+
private closed = false;
|
|
48
|
+
private closePromise: Promise<void> | undefined;
|
|
49
|
+
private nextFileIndex = 0;
|
|
50
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
51
|
+
private readonly serverLogs = new Map<string, McpServerLogFile>();
|
|
52
|
+
|
|
53
|
+
/** Create a private file store rooted at a Pi session directory. */
|
|
54
|
+
constructor(readonly directoryPath: string) {}
|
|
55
|
+
|
|
56
|
+
writeResultSpill(output: string): Promise<string> {
|
|
57
|
+
const path = join(this.directoryPath, `result-spill-${this.nextFileIndex++}.txt`);
|
|
58
|
+
return this.enqueueMcpSessionFileWrite(async () => {
|
|
59
|
+
await writeFile(path, output, { encoding: "utf8", mode: 0o600 });
|
|
60
|
+
await chmod(path, 0o600);
|
|
61
|
+
return path;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
writeUnsupportedContent(content: Uint8Array, _mimeType: string): Promise<string> {
|
|
66
|
+
const path = join(this.directoryPath, `unsupported-content-${this.nextFileIndex++}.bin`);
|
|
67
|
+
return this.enqueueMcpSessionFileWrite(async () => {
|
|
68
|
+
await writeFile(path, content, { mode: 0o600 });
|
|
69
|
+
await chmod(path, 0o600);
|
|
70
|
+
return path;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
appendServerLog(serverName: string, chunk: string | Uint8Array): Promise<void> {
|
|
75
|
+
const serverLog = this.getMcpServerLogFile(serverName);
|
|
76
|
+
return this.enqueueMcpSessionFileWrite(async () => {
|
|
77
|
+
serverLog.log.append(chunk);
|
|
78
|
+
await writeFile(serverLog.path, serverLog.log.read(), { encoding: "utf8", mode: 0o600 });
|
|
79
|
+
await chmod(serverLog.path, 0o600);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
readServerLog(serverName: string): Promise<string> {
|
|
84
|
+
return this.enqueueMcpSessionFileWrite(
|
|
85
|
+
async () => this.serverLogs.get(serverName)?.log.read() ?? "",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
close(): Promise<void> {
|
|
90
|
+
if (this.closePromise !== undefined) return this.closePromise;
|
|
91
|
+
this.closed = true;
|
|
92
|
+
this.closePromise = this.writeQueue.then(
|
|
93
|
+
() => rm(this.directoryPath, { force: true, recursive: true }),
|
|
94
|
+
() => rm(this.directoryPath, { force: true, recursive: true }),
|
|
95
|
+
);
|
|
96
|
+
return this.closePromise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private getMcpServerLogFile(serverName: string): McpServerLogFile {
|
|
100
|
+
const existing = this.serverLogs.get(serverName);
|
|
101
|
+
if (existing !== undefined) return existing;
|
|
102
|
+
const serverLog = {
|
|
103
|
+
log: new RetainedMcpServerLog(),
|
|
104
|
+
path: join(this.directoryPath, `server-log-${this.nextFileIndex++}.log`),
|
|
105
|
+
};
|
|
106
|
+
this.serverLogs.set(serverName, serverLog);
|
|
107
|
+
return serverLog;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private enqueueMcpSessionFileWrite<T>(write: () => Promise<T>): Promise<T> {
|
|
111
|
+
if (this.closed) return Promise.reject(new Error("Pi MCP: session files are closed"));
|
|
112
|
+
const result = this.writeQueue.then(write);
|
|
113
|
+
this.writeQueue = result.then(
|
|
114
|
+
() => undefined,
|
|
115
|
+
() => undefined,
|
|
116
|
+
);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Create a mode-safe private directory for MCP Result Spills, content, and server logs. */
|
|
122
|
+
export async function createMcpSessionFiles(sessionDirectory: string): Promise<McpSessionFiles> {
|
|
123
|
+
await mkdir(sessionDirectory, { mode: 0o700, recursive: true });
|
|
124
|
+
const directoryPath = await mkdtemp(join(sessionDirectory, "pi-mcp-"));
|
|
125
|
+
await chmod(directoryPath, 0o700);
|
|
126
|
+
return new McpSessionFileStore(directoryPath);
|
|
127
|
+
}
|