pi-session-mcp 0.0.0-stage → 0.6.2
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/.pi-session-mcp-cli/config.js +88 -0
- package/.pi-session-mcp-cli/doctor-cli.js +11 -0
- package/.pi-session-mcp-cli/doctor.js +178 -0
- package/.pi-session-mcp-cli/execution-profile.js +29 -0
- package/.pi-session-mcp-cli/mcp-config.js +70 -0
- package/.pi-session-mcp-cli/package-metadata.js +32 -0
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +57 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/SECURITY.md +37 -0
- package/dist/capabilities.d.ts +25 -0
- package/dist/capabilities.js +26 -0
- package/dist/client-adapters/claude-code.d.ts +4 -0
- package/dist/client-adapters/claude-code.js +33 -0
- package/dist/client-adapters/codex.d.ts +4 -0
- package/dist/client-adapters/codex.js +73 -0
- package/dist/client-adapters/command.d.ts +12 -0
- package/dist/client-adapters/command.js +25 -0
- package/dist/client-adapters/index.d.ts +4 -0
- package/dist/client-adapters/index.js +3 -0
- package/dist/client-adapters/mcp-verifier.d.ts +20 -0
- package/dist/client-adapters/mcp-verifier.js +174 -0
- package/dist/client-adapters/types.d.ts +54 -0
- package/dist/client-adapters/types.js +1 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +88 -0
- package/dist/doctor-cli.d.ts +1 -0
- package/dist/doctor-cli.js +11 -0
- package/dist/doctor.d.ts +47 -0
- package/dist/doctor.js +178 -0
- package/dist/entry-point.d.ts +5 -0
- package/dist/entry-point.js +16 -0
- package/dist/execution-profile.d.ts +37 -0
- package/dist/execution-profile.js +29 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +51 -0
- package/dist/mcp-config.d.ts +22 -0
- package/dist/mcp-config.js +70 -0
- package/dist/mcp-session-tools.d.ts +54 -0
- package/dist/mcp-session-tools.js +323 -0
- package/dist/mcp-stdio-client.d.ts +3 -0
- package/dist/mcp-stdio-client.js +59 -0
- package/dist/package-metadata.d.ts +18 -0
- package/dist/package-metadata.js +32 -0
- package/dist/pi-adapter.d.ts +42 -0
- package/dist/pi-adapter.js +22 -0
- package/dist/process-diagnostics.d.ts +8 -0
- package/dist/process-diagnostics.js +9 -0
- package/dist/sdk-pi-adapter.d.ts +75 -0
- package/dist/sdk-pi-adapter.js +410 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +102 -0
- package/dist/session-registry.d.ts +104 -0
- package/dist/session-registry.js +373 -0
- package/dist/setup-cli.d.ts +11 -0
- package/dist/setup-cli.js +38 -0
- package/dist/setup-command-guardian.d.ts +2 -0
- package/dist/setup-command-guardian.js +297 -0
- package/dist/setup-main.d.ts +8 -0
- package/dist/setup-main.js +60 -0
- package/dist/setup-ownership.d.ts +53 -0
- package/dist/setup-ownership.js +228 -0
- package/dist/setup-process.d.ts +30 -0
- package/dist/setup-process.js +385 -0
- package/dist/setup-result.d.ts +22 -0
- package/dist/setup-result.js +8 -0
- package/dist/setup.d.ts +55 -0
- package/dist/setup.js +327 -0
- package/dist/shutdown.d.ts +10 -0
- package/dist/shutdown.js +52 -0
- package/docs/acceptance.md +227 -0
- package/docs/adr/0001-stdio-first.md +12 -0
- package/docs/adr/0002-embedded-pi-sdk.md +11 -0
- package/docs/adr/0003-post-mvp-maintenance.md +60 -0
- package/docs/adr/0004-client-contract-not-version.md +31 -0
- package/docs/architecture.md +82 -0
- package/docs/client-setup-design.md +90 -0
- package/docs/client-setup.md +207 -0
- package/docs/doctor-design.md +47 -0
- package/docs/installation.md +352 -0
- package/docs/releasing.md +216 -0
- package/docs/session-mcp.md +260 -0
- package/docs/threat-model.md +89 -0
- package/docs/tool-contracts.md +143 -0
- package/package.json +64 -4
- package/pi-session-mcp.example.json +21 -0
- package/scripts/doctor.mjs +72 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
const aliasPattern = /^[a-z][a-z0-9_-]{0,23}$/;
|
|
3
|
+
const environmentPattern = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
4
|
+
export const MAX_MCP_SERVERS = 8;
|
|
5
|
+
export const MAX_MCP_TOOLS = 64;
|
|
6
|
+
export function mcpToolName(server, tool) {
|
|
7
|
+
return `mcp_${server}_${tool}`;
|
|
8
|
+
}
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
12
|
+
}
|
|
13
|
+
function keysAre(value, allowed) {
|
|
14
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
15
|
+
}
|
|
16
|
+
function text(value, maximum) {
|
|
17
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum && !value.includes("\0");
|
|
18
|
+
}
|
|
19
|
+
/** Also used at the adapter boundary: a manually constructed profile must not bypass policy. */
|
|
20
|
+
export function isMcpServers(value, permissionProfile = "coding") {
|
|
21
|
+
if (permissionProfile !== "read-only" && permissionProfile !== "coding")
|
|
22
|
+
return false;
|
|
23
|
+
if (!record(value) || Object.keys(value).length > MAX_MCP_SERVERS)
|
|
24
|
+
return false;
|
|
25
|
+
const toolNames = new Set();
|
|
26
|
+
for (const [serverAlias, server] of Object.entries(value)) {
|
|
27
|
+
if (!aliasPattern.test(serverAlias) || !record(server) || !keysAre(server, ["command", "args", "envFrom", "tools"]))
|
|
28
|
+
return false;
|
|
29
|
+
if (!text(server.command, 4096) || !isAbsolute(server.command))
|
|
30
|
+
return false;
|
|
31
|
+
if (server.args !== undefined && (!Array.isArray(server.args) || server.args.length > 128
|
|
32
|
+
|| !server.args.every((arg) => typeof arg === "string" && arg.length <= 4096 && !arg.includes("\0"))))
|
|
33
|
+
return false;
|
|
34
|
+
if (server.envFrom !== undefined && (!record(server.envFrom) || Object.keys(server.envFrom).length > 64
|
|
35
|
+
|| !Object.entries(server.envFrom).every(([target, source]) => environmentPattern.test(target)
|
|
36
|
+
&& typeof source === "string" && environmentPattern.test(source))))
|
|
37
|
+
return false;
|
|
38
|
+
if (!record(server.tools) || Object.keys(server.tools).length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
const remoteNames = new Set();
|
|
41
|
+
for (const [toolAlias, grant] of Object.entries(server.tools)) {
|
|
42
|
+
if (!aliasPattern.test(toolAlias) || !record(grant) || !keysAre(grant, ["name", "readOnly"])
|
|
43
|
+
|| !text(grant.name, 256) || typeof grant.readOnly !== "boolean")
|
|
44
|
+
return false;
|
|
45
|
+
if (permissionProfile === "read-only" && !grant.readOnly)
|
|
46
|
+
return false;
|
|
47
|
+
const exposedName = mcpToolName(serverAlias, toolAlias);
|
|
48
|
+
// Underscore-containing aliases can otherwise create cross-server name collisions.
|
|
49
|
+
if (toolNames.has(exposedName) || remoteNames.has(grant.name))
|
|
50
|
+
return false;
|
|
51
|
+
toolNames.add(exposedName);
|
|
52
|
+
remoteNames.add(grant.name);
|
|
53
|
+
if (toolNames.size > MAX_MCP_TOOLS)
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
export function snapshotMcpServers(servers) {
|
|
60
|
+
return Object.freeze(Object.fromEntries(Object.entries(servers).map(([alias, server]) => [alias, Object.freeze({
|
|
61
|
+
command: server.command,
|
|
62
|
+
args: Object.freeze([...(server.args ?? [])]),
|
|
63
|
+
envFrom: Object.freeze({ ...server.envFrom }),
|
|
64
|
+
tools: Object.freeze(Object.fromEntries(Object.entries(server.tools).map(([name, grant]) => [name, Object.freeze({ ...grant })]))),
|
|
65
|
+
})])));
|
|
66
|
+
}
|
|
67
|
+
/** Safe discovery metadata; never project commands, arguments, paths, or environment references. */
|
|
68
|
+
export function configuredMcpToolNames(servers) {
|
|
69
|
+
return Object.entries(servers).flatMap(([server, config]) => Object.keys(config.tools).map((tool) => mcpToolName(server, tool))).sort();
|
|
70
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type McpServerConfig, type McpServers } from "./mcp-config.js";
|
|
3
|
+
export declare const MCP_STARTUP_TIMEOUT_MS = 10000;
|
|
4
|
+
export declare const MCP_CALL_TIMEOUT_MS = 30000;
|
|
5
|
+
export declare const MCP_CLOSE_TIMEOUT_MS = 4500;
|
|
6
|
+
export interface McpRemoteTool {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly description?: string | undefined;
|
|
9
|
+
readonly inputSchema: {
|
|
10
|
+
readonly type: "object";
|
|
11
|
+
readonly [key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export interface McpToolPage {
|
|
15
|
+
readonly tools: readonly McpRemoteTool[];
|
|
16
|
+
readonly nextCursor?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
export interface McpCallResult {
|
|
19
|
+
readonly content: readonly {
|
|
20
|
+
readonly type: string;
|
|
21
|
+
readonly [key: string]: unknown;
|
|
22
|
+
}[];
|
|
23
|
+
readonly structuredContent?: unknown;
|
|
24
|
+
readonly isError?: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** One connection is owned by one session. Implementations must tolerate repeated close calls. */
|
|
27
|
+
export interface McpConnection {
|
|
28
|
+
connect(signal: AbortSignal): Promise<void>;
|
|
29
|
+
listTools(cursor: string | undefined, signal: AbortSignal): Promise<McpToolPage>;
|
|
30
|
+
callTool(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<McpCallResult>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export type McpConnectionFactory = (server: McpServerConfig, cwd: string) => McpConnection;
|
|
34
|
+
export interface SessionMcpTools {
|
|
35
|
+
readonly tools: ToolDefinition[];
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface McpBridgeOptions {
|
|
39
|
+
readonly startupMs?: number;
|
|
40
|
+
readonly callMs?: number;
|
|
41
|
+
readonly closeMs?: number;
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
export type McpStartupErrorCode = "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing";
|
|
45
|
+
export interface McpStartupFailure {
|
|
46
|
+
readonly code: McpStartupErrorCode;
|
|
47
|
+
readonly serverAlias?: string;
|
|
48
|
+
readonly toolAlias?: string;
|
|
49
|
+
readonly discoveredToolCount?: number;
|
|
50
|
+
}
|
|
51
|
+
/** Returns only classifications created by this module, never lookalike foreign errors. */
|
|
52
|
+
export declare function classifyMcpStartupError(error: unknown): McpStartupFailure | undefined;
|
|
53
|
+
/** Explicit, fail-closed tool grants. No extension loading, model calls, or global MCP config. */
|
|
54
|
+
export declare function connectSessionMcpTools(configured: McpServers, permissionProfile: "read-only" | "coding", cwd: string, factory: McpConnectionFactory, options?: McpBridgeOptions): Promise<SessionMcpTools>;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { isMcpServers, mcpToolName, snapshotMcpServers } from "./mcp-config.js";
|
|
2
|
+
export const MCP_STARTUP_TIMEOUT_MS = 10_000;
|
|
3
|
+
export const MCP_CALL_TIMEOUT_MS = 30_000;
|
|
4
|
+
// The stdio SDK allows 2s for EOF and 2s for SIGTERM before sending SIGKILL.
|
|
5
|
+
// This bound must exceed that escalation window and remain below registry cleanup (5s).
|
|
6
|
+
export const MCP_CLOSE_TIMEOUT_MS = 4_500;
|
|
7
|
+
const MAX_DISCOVERED_TOOLS = 512;
|
|
8
|
+
const MAX_DISCOVERY_PAGES = 16;
|
|
9
|
+
const MAX_RESULT_BYTES = 256 * 1024;
|
|
10
|
+
const RESULT_LIMIT_MESSAGE = "External MCP result exceeds the bridge limit of 262144 bytes; request less data.";
|
|
11
|
+
const MAX_ERROR_RESULT_BYTES = 1024;
|
|
12
|
+
const MAX_ERROR_MESSAGE_CHARACTERS = 200;
|
|
13
|
+
const SOURCE_TOO_LARGE_MESSAGE = JSON.stringify({
|
|
14
|
+
code: "SOURCE_TOO_LARGE",
|
|
15
|
+
message: "Requested source exceeds the response limit; increase maxBytes.",
|
|
16
|
+
});
|
|
17
|
+
class McpStartupError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
serverAlias;
|
|
20
|
+
toolAlias;
|
|
21
|
+
discoveredToolCount;
|
|
22
|
+
constructor(code, details = {}) {
|
|
23
|
+
super("External MCP operation failed");
|
|
24
|
+
this.name = "McpStartupError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
if (details.serverAlias !== undefined)
|
|
27
|
+
this.serverAlias = details.serverAlias;
|
|
28
|
+
if (details.toolAlias !== undefined)
|
|
29
|
+
this.toolAlias = details.toolAlias;
|
|
30
|
+
if (details.discoveredToolCount !== undefined)
|
|
31
|
+
this.discoveredToolCount = details.discoveredToolCount;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function startupFailure(code, details = {}) {
|
|
35
|
+
return new McpStartupError(code, details);
|
|
36
|
+
}
|
|
37
|
+
/** Returns only classifications created by this module, never lookalike foreign errors. */
|
|
38
|
+
export function classifyMcpStartupError(error) {
|
|
39
|
+
if (!(error instanceof McpStartupError))
|
|
40
|
+
return undefined;
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
code: error.code,
|
|
43
|
+
...(error.serverAlias === undefined ? {} : { serverAlias: error.serverAlias }),
|
|
44
|
+
...(error.toolAlias === undefined ? {} : { toolAlias: error.toolAlias }),
|
|
45
|
+
...(error.discoveredToolCount === undefined ? {} : { discoveredToolCount: error.discoveredToolCount }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function failure() { return new Error("External MCP operation failed"); }
|
|
49
|
+
async function bounded(operation, milliseconds, onTimeout = () => undefined) {
|
|
50
|
+
let timer;
|
|
51
|
+
const timeout = new Promise((_, reject) => {
|
|
52
|
+
timer = setTimeout(() => {
|
|
53
|
+
onTimeout();
|
|
54
|
+
reject(failure());
|
|
55
|
+
}, milliseconds);
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
return await Promise.race([operation, timeout]);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (timer !== undefined)
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function object(value) {
|
|
66
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
function remoteTool(value) {
|
|
69
|
+
return object(value)
|
|
70
|
+
&& typeof value.name === "string" && value.name.length > 0 && value.name.length <= 256
|
|
71
|
+
&& (value.description === undefined || typeof value.description === "string")
|
|
72
|
+
&& object(value.inputSchema) && value.inputSchema.type === "object";
|
|
73
|
+
}
|
|
74
|
+
function closedObject(value, fields) {
|
|
75
|
+
if (!object(value))
|
|
76
|
+
return false;
|
|
77
|
+
const prototype = Object.getPrototypeOf(value);
|
|
78
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
79
|
+
return false;
|
|
80
|
+
return Reflect.ownKeys(value).every((key) => typeof key === "string" && fields.includes(key)
|
|
81
|
+
&& Object.getOwnPropertyDescriptor(value, key)?.get === undefined
|
|
82
|
+
&& Object.getOwnPropertyDescriptor(value, key)?.set === undefined);
|
|
83
|
+
}
|
|
84
|
+
function sourceTooLarge(value) {
|
|
85
|
+
return closedObject(value, ["code", "message"])
|
|
86
|
+
&& Object.keys(value).length === 2
|
|
87
|
+
&& value.code === "SOURCE_TOO_LARGE" && typeof value.message === "string"
|
|
88
|
+
&& value.message.length > 0 && Array.from(value.message).length <= MAX_ERROR_MESSAGE_CHARACTERS
|
|
89
|
+
// Reject control/format characters, path separators, URL schemes and domain-like indicators.
|
|
90
|
+
// Even admitted ordinary foreign text is never copied into the controller message.
|
|
91
|
+
&& !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(value.message)
|
|
92
|
+
&& !/[\\/]|[a-z][a-z0-9+.-]*:|\b[a-z0-9-]+\.[a-z][a-z0-9-]+/iu.test(value.message);
|
|
93
|
+
}
|
|
94
|
+
// The only accepted raw JSON grammar is a two-member object with string values.
|
|
95
|
+
// Decode the keys before comparing them so escaped duplicate keys cannot be hidden
|
|
96
|
+
// by JSON.parse's last-key-wins behavior. Structured content is already SDK-decoded.
|
|
97
|
+
const jsonString = '"(?:[^"\\\\\\u0000-\\u001f]|\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4}))*"';
|
|
98
|
+
const jsonWhitespace = "[\\t\\n\\r ]*";
|
|
99
|
+
const jsonMember = `${jsonWhitespace}(${jsonString})${jsonWhitespace}:${jsonWhitespace}${jsonString}${jsonWhitespace}`;
|
|
100
|
+
const errorTextObject = new RegExp(`^${jsonWhitespace}\\{${jsonMember},${jsonMember}\\}${jsonWhitespace}$`);
|
|
101
|
+
function sourceTooLargeText(text) {
|
|
102
|
+
const match = errorTextObject.exec(text);
|
|
103
|
+
if (!match || JSON.parse(match[1]) === JSON.parse(match[2]))
|
|
104
|
+
return undefined;
|
|
105
|
+
const parsed = JSON.parse(text);
|
|
106
|
+
return sourceTooLarge(parsed) ? parsed : undefined;
|
|
107
|
+
}
|
|
108
|
+
function projectedErrorMessage(result) {
|
|
109
|
+
if (!closedObject(result, ["content", "structuredContent", "isError"])
|
|
110
|
+
|| result.isError !== true || !Array.isArray(result.content)
|
|
111
|
+
|| Buffer.byteLength(JSON.stringify(result), "utf8") > MAX_ERROR_RESULT_BYTES)
|
|
112
|
+
return undefined;
|
|
113
|
+
let textError;
|
|
114
|
+
if (result.content.length === 1) {
|
|
115
|
+
const block = result.content[0];
|
|
116
|
+
if (!closedObject(block, ["type", "text"]) || Object.keys(block).length !== 2
|
|
117
|
+
|| block.type !== "text" || typeof block.text !== "string")
|
|
118
|
+
return undefined;
|
|
119
|
+
textError = sourceTooLargeText(block.text);
|
|
120
|
+
if (!textError)
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
else if (result.content.length !== 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (result.structuredContent !== undefined) {
|
|
126
|
+
if (!sourceTooLarge(result.structuredContent))
|
|
127
|
+
return undefined;
|
|
128
|
+
if (textError && (textError.code !== result.structuredContent.code
|
|
129
|
+
|| textError.message !== result.structuredContent.message))
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
else if (!textError)
|
|
133
|
+
return undefined;
|
|
134
|
+
return SOURCE_TOO_LARGE_MESSAGE;
|
|
135
|
+
}
|
|
136
|
+
/** Explicit, fail-closed tool grants. No extension loading, model calls, or global MCP config. */
|
|
137
|
+
export async function connectSessionMcpTools(configured, permissionProfile, cwd, factory, options = {}) {
|
|
138
|
+
if (!isMcpServers(configured, permissionProfile))
|
|
139
|
+
throw failure();
|
|
140
|
+
const servers = snapshotMcpServers(configured);
|
|
141
|
+
const connections = [];
|
|
142
|
+
const lifetime = new AbortController();
|
|
143
|
+
const closeMs = options.closeMs ?? MCP_CLOSE_TIMEOUT_MS;
|
|
144
|
+
const callMs = options.callMs ?? MCP_CALL_TIMEOUT_MS;
|
|
145
|
+
let closing;
|
|
146
|
+
const connectionClosures = new WeakMap();
|
|
147
|
+
const closeConnection = (connection) => {
|
|
148
|
+
const existing = connectionClosures.get(connection);
|
|
149
|
+
if (existing)
|
|
150
|
+
return existing;
|
|
151
|
+
// Join an in-progress transport close instead of racing its process escalation.
|
|
152
|
+
const pending = bounded(Promise.resolve().then(() => connection.close()), closeMs).then(() => undefined, () => undefined);
|
|
153
|
+
connectionClosures.set(connection, pending);
|
|
154
|
+
void pending.then(() => { if (connectionClosures.get(connection) === pending)
|
|
155
|
+
connectionClosures.delete(connection); });
|
|
156
|
+
return pending;
|
|
157
|
+
};
|
|
158
|
+
const close = () => {
|
|
159
|
+
if (!closing) {
|
|
160
|
+
lifetime.abort();
|
|
161
|
+
options.signal?.removeEventListener("abort", onShutdown);
|
|
162
|
+
closing = Promise.all(connections.map(closeConnection)).then(() => undefined);
|
|
163
|
+
}
|
|
164
|
+
return closing;
|
|
165
|
+
};
|
|
166
|
+
const ensureOpen = () => { if (lifetime.signal.aborted)
|
|
167
|
+
throw failure(); };
|
|
168
|
+
// Keep the shutdown link for the entire session, including pending SDK construction.
|
|
169
|
+
const onShutdown = () => { void close(); };
|
|
170
|
+
options.signal?.addEventListener("abort", onShutdown, { once: true });
|
|
171
|
+
if (options.signal?.aborted)
|
|
172
|
+
onShutdown();
|
|
173
|
+
const initialize = async () => {
|
|
174
|
+
ensureOpen();
|
|
175
|
+
const tools = [];
|
|
176
|
+
for (const [serverAlias, server] of Object.entries(servers)) {
|
|
177
|
+
try {
|
|
178
|
+
ensureOpen();
|
|
179
|
+
const connection = factory(server, cwd);
|
|
180
|
+
connections.push(connection);
|
|
181
|
+
// If an implementation ignores cancellation, still clean up a late connection.
|
|
182
|
+
await Promise.resolve().then(() => {
|
|
183
|
+
ensureOpen();
|
|
184
|
+
return connection.connect(lifetime.signal);
|
|
185
|
+
}).finally(async () => {
|
|
186
|
+
// A late failure can own a process too; cleanup is not success-only.
|
|
187
|
+
if (lifetime.signal.aborted)
|
|
188
|
+
await closeConnection(connection);
|
|
189
|
+
});
|
|
190
|
+
ensureOpen();
|
|
191
|
+
const discovered = new Map();
|
|
192
|
+
const cursors = new Set();
|
|
193
|
+
let cursor;
|
|
194
|
+
for (let pageIndex = 0;; pageIndex++) {
|
|
195
|
+
ensureOpen();
|
|
196
|
+
if (pageIndex >= MAX_DISCOVERY_PAGES) {
|
|
197
|
+
throw startupFailure("external_mcp_unavailable", { serverAlias });
|
|
198
|
+
}
|
|
199
|
+
const page = await connection.listTools(cursor, lifetime.signal);
|
|
200
|
+
ensureOpen();
|
|
201
|
+
if (!object(page) || !Array.isArray(page.tools)
|
|
202
|
+
|| (page.nextCursor !== undefined && typeof page.nextCursor !== "string")) {
|
|
203
|
+
throw startupFailure("external_mcp_unavailable", { serverAlias });
|
|
204
|
+
}
|
|
205
|
+
for (const tool of page.tools) {
|
|
206
|
+
if (!remoteTool(tool) || discovered.has(tool.name) || discovered.size >= MAX_DISCOVERED_TOOLS) {
|
|
207
|
+
throw startupFailure("external_mcp_unavailable", { serverAlias });
|
|
208
|
+
}
|
|
209
|
+
discovered.set(tool.name, tool);
|
|
210
|
+
}
|
|
211
|
+
if (page.nextCursor === undefined)
|
|
212
|
+
break;
|
|
213
|
+
if (cursors.has(page.nextCursor)) {
|
|
214
|
+
throw startupFailure("external_mcp_unavailable", { serverAlias });
|
|
215
|
+
}
|
|
216
|
+
cursors.add(page.nextCursor);
|
|
217
|
+
cursor = page.nextCursor;
|
|
218
|
+
}
|
|
219
|
+
if (discovered.size === 0) {
|
|
220
|
+
throw startupFailure("external_mcp_no_tools", { serverAlias, discoveredToolCount: 0 });
|
|
221
|
+
}
|
|
222
|
+
for (const [toolAlias, grant] of Object.entries(server.tools)) {
|
|
223
|
+
const remote = discovered.get(grant.name);
|
|
224
|
+
if (!remote) {
|
|
225
|
+
throw startupFailure("external_mcp_grant_tool_missing", {
|
|
226
|
+
serverAlias,
|
|
227
|
+
toolAlias,
|
|
228
|
+
discoveredToolCount: discovered.size,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
// MCP JSON Schemas are passed intact to Pi's JSON-Schema argument validator.
|
|
232
|
+
const parameters = structuredClone(remote.inputSchema);
|
|
233
|
+
tools.push({
|
|
234
|
+
name: mcpToolName(serverAlias, toolAlias),
|
|
235
|
+
label: `MCP ${serverAlias}/${toolAlias}`,
|
|
236
|
+
description: remote.description ?? `Call the configured ${serverAlias}/${toolAlias} MCP tool.`,
|
|
237
|
+
parameters,
|
|
238
|
+
promptSnippet: "Call an explicitly granted external MCP tool.",
|
|
239
|
+
executionMode: "sequential",
|
|
240
|
+
async execute(_id, args, signal) {
|
|
241
|
+
ensureOpen();
|
|
242
|
+
if (!object(args) || signal?.aborted)
|
|
243
|
+
throw failure();
|
|
244
|
+
const call = new AbortController();
|
|
245
|
+
let rejectCancellation;
|
|
246
|
+
const cancellation = new Promise((_, reject) => { rejectCancellation = reject; });
|
|
247
|
+
void cancellation.catch(() => undefined);
|
|
248
|
+
const cancel = () => { call.abort(); rejectCancellation(failure()); };
|
|
249
|
+
lifetime.signal.addEventListener("abort", cancel, { once: true });
|
|
250
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
251
|
+
let projectedError;
|
|
252
|
+
try {
|
|
253
|
+
// Check again after subscribing; close/abort must never race a new call admission.
|
|
254
|
+
if (lifetime.signal.aborted || signal?.aborted)
|
|
255
|
+
throw failure();
|
|
256
|
+
const result = await bounded(Promise.race([cancellation, Promise.resolve().then(() => {
|
|
257
|
+
if (call.signal.aborted)
|
|
258
|
+
throw failure();
|
|
259
|
+
return connection.callTool(grant.name, args, call.signal);
|
|
260
|
+
})]), callMs, cancel);
|
|
261
|
+
if (call.signal.aborted)
|
|
262
|
+
throw failure();
|
|
263
|
+
if (result.isError !== undefined && typeof result.isError !== "boolean")
|
|
264
|
+
throw failure();
|
|
265
|
+
if (result.isError === true) {
|
|
266
|
+
const message = projectedErrorMessage(result);
|
|
267
|
+
if (message === undefined)
|
|
268
|
+
throw failure();
|
|
269
|
+
// A private per-execution identity, never a foreign error's name/code/message,
|
|
270
|
+
// is the only exception permitted to survive the sanitizing catch below.
|
|
271
|
+
projectedError = new Error(message);
|
|
272
|
+
throw projectedError;
|
|
273
|
+
}
|
|
274
|
+
if (Buffer.byteLength(JSON.stringify(result), "utf8") > MAX_RESULT_BYTES) {
|
|
275
|
+
projectedError = new Error(RESULT_LIMIT_MESSAGE);
|
|
276
|
+
throw projectedError;
|
|
277
|
+
}
|
|
278
|
+
const content = result.content.map((block) => ({
|
|
279
|
+
type: "text", text: block.type === "text" && typeof block.text === "string" ? block.text : JSON.stringify(block),
|
|
280
|
+
}));
|
|
281
|
+
if (content.length === 0 && result.structuredContent !== undefined) {
|
|
282
|
+
content.push({ type: "text", text: JSON.stringify(result.structuredContent) });
|
|
283
|
+
}
|
|
284
|
+
return { content, details: {
|
|
285
|
+
server: serverAlias, tool: toolAlias,
|
|
286
|
+
...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
|
|
287
|
+
} };
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// A transport exception or MCP isError must not become a successful Pi tool result.
|
|
291
|
+
if (!call.signal.aborted && projectedError !== undefined && error === projectedError)
|
|
292
|
+
throw error;
|
|
293
|
+
throw failure();
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
call.abort();
|
|
297
|
+
lifetime.signal.removeEventListener("abort", cancel);
|
|
298
|
+
signal?.removeEventListener("abort", cancel);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error instanceof McpStartupError)
|
|
306
|
+
throw error;
|
|
307
|
+
throw startupFailure("external_mcp_unavailable", { serverAlias });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
ensureOpen();
|
|
311
|
+
return tools;
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
const tools = await bounded(initialize(), options.startupMs ?? MCP_STARTUP_TIMEOUT_MS, () => lifetime.abort());
|
|
315
|
+
return { tools, close };
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
await close();
|
|
319
|
+
if (error instanceof McpStartupError)
|
|
320
|
+
throw error;
|
|
321
|
+
throw startupFailure("external_mcp_unavailable");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
3
|
+
import packageMetadata from "../package.json" with { type: "json" };
|
|
4
|
+
import { MCP_CALL_TIMEOUT_MS, MCP_STARTUP_TIMEOUT_MS } from "./mcp-session-tools.js";
|
|
5
|
+
/** Uses only the MCP SDK's public exports. No HTTP, shell, or global MCP config. */
|
|
6
|
+
export const createStdioMcpConnection = (server, cwd) => {
|
|
7
|
+
const env = {};
|
|
8
|
+
for (const [target, source] of Object.entries(server.envFrom ?? {})) {
|
|
9
|
+
const value = process.env[source];
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
throw new Error("External MCP environment is unavailable");
|
|
12
|
+
env[target] = value;
|
|
13
|
+
}
|
|
14
|
+
const transport = new StdioClientTransport({
|
|
15
|
+
command: server.command, args: [...(server.args ?? [])], cwd, env, stderr: "pipe",
|
|
16
|
+
});
|
|
17
|
+
// The SDK supplies its small default OS environment; never spread process.env.
|
|
18
|
+
// Drain child diagnostics without copying secrets to controller stdout/stderr.
|
|
19
|
+
transport.stderr?.on("data", () => undefined);
|
|
20
|
+
const client = new Client({ name: "pi-session-mcp-session", version: packageMetadata.version }, { capabilities: {} });
|
|
21
|
+
client.onerror = () => undefined;
|
|
22
|
+
const discoveredTools = new Map();
|
|
23
|
+
return {
|
|
24
|
+
async connect(signal) {
|
|
25
|
+
await client.connect(transport, { signal, timeout: MCP_STARTUP_TIMEOUT_MS });
|
|
26
|
+
if (!client.getServerCapabilities()?.tools)
|
|
27
|
+
throw new Error("External MCP tools capability is unavailable");
|
|
28
|
+
},
|
|
29
|
+
async listTools(cursor, signal) {
|
|
30
|
+
// `Client.listTools({})` auto-aggregates and hides cursor loops. Issue one
|
|
31
|
+
// validated protocol request per page so the bridge owns completeness limits.
|
|
32
|
+
const page = await client.request({
|
|
33
|
+
method: "tools/list",
|
|
34
|
+
params: cursor === undefined ? {} : { cursor },
|
|
35
|
+
}, { signal, timeout: MCP_STARTUP_TIMEOUT_MS });
|
|
36
|
+
for (const tool of page.tools)
|
|
37
|
+
discoveredTools.set(tool.name, tool);
|
|
38
|
+
return page;
|
|
39
|
+
},
|
|
40
|
+
callTool(name, args, signal) {
|
|
41
|
+
const toolDefinition = discoveredTools.get(name);
|
|
42
|
+
if (!toolDefinition)
|
|
43
|
+
throw new Error("External MCP tool definition is unavailable");
|
|
44
|
+
return client.callTool({ name, arguments: args }, {
|
|
45
|
+
signal, timeout: MCP_CALL_TIMEOUT_MS, maxTotalTimeout: MCP_CALL_TIMEOUT_MS, resetTimeoutOnProgress: false,
|
|
46
|
+
toolDefinition,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
async close() {
|
|
50
|
+
// Close the transport too if initialization failed before Client took ownership.
|
|
51
|
+
try {
|
|
52
|
+
await client.close();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await transport.close();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const PACKAGE_NAME: string;
|
|
2
|
+
export declare const PACKAGE_VERSION: string;
|
|
3
|
+
export declare const PACKAGE_NODE_ENGINE: string;
|
|
4
|
+
export interface LoadedPackageMetadata {
|
|
5
|
+
readonly version: string;
|
|
6
|
+
readonly engines: {
|
|
7
|
+
readonly node?: string;
|
|
8
|
+
};
|
|
9
|
+
/** Absent in an npm installation: npm never ships a lockfile inside a package. */
|
|
10
|
+
readonly lockVersion?: string;
|
|
11
|
+
}
|
|
12
|
+
type ReadFile = (path: string, encoding: "utf8") => Promise<string>;
|
|
13
|
+
export declare function isMissingFile(error: unknown): boolean;
|
|
14
|
+
/** The lockfile text of a Git checkout, or undefined for an npm installation. Other read errors propagate. */
|
|
15
|
+
export declare function readLockfile(packageRoot: string, readFile: ReadFile): Promise<string | undefined>;
|
|
16
|
+
/** Read the package and lock metadata using the same shape as production. */
|
|
17
|
+
export declare function readPackageMetadata(packageRoot: string, readFile: ReadFile): Promise<LoadedPackageMetadata>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import packageMetadata from "../package.json" with { type: "json" };
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const PACKAGE_NAME = packageMetadata.name;
|
|
4
|
+
export const PACKAGE_VERSION = packageMetadata.version;
|
|
5
|
+
export const PACKAGE_NODE_ENGINE = packageMetadata.engines.node;
|
|
6
|
+
export function isMissingFile(error) {
|
|
7
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
8
|
+
}
|
|
9
|
+
/** The lockfile text of a Git checkout, or undefined for an npm installation. Other read errors propagate. */
|
|
10
|
+
export async function readLockfile(packageRoot, readFile) {
|
|
11
|
+
try {
|
|
12
|
+
return await readFile(join(packageRoot, "package-lock.json"), "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (isMissingFile(error))
|
|
16
|
+
return undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Read the package and lock metadata using the same shape as production. */
|
|
21
|
+
export async function readPackageMetadata(packageRoot, readFile) {
|
|
22
|
+
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
|
23
|
+
const lockText = await readLockfile(packageRoot, readFile);
|
|
24
|
+
const lock = lockText === undefined ? undefined : JSON.parse(lockText);
|
|
25
|
+
const engines = packageJson.engines?.node;
|
|
26
|
+
const lockVersion = lock?.packages?.[""]?.version;
|
|
27
|
+
return {
|
|
28
|
+
version: packageJson.version,
|
|
29
|
+
engines: engines === undefined ? {} : { node: engines },
|
|
30
|
+
...(lockVersion === undefined ? {} : { lockVersion: lockVersion }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PermissionProfile, ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
|
|
2
|
+
export type { PermissionProfile };
|
|
3
|
+
export type PiTurnOutcome = {
|
|
4
|
+
status: "completed";
|
|
5
|
+
assistantText: string;
|
|
6
|
+
} | {
|
|
7
|
+
status: "failed";
|
|
8
|
+
} | {
|
|
9
|
+
status: "aborted";
|
|
10
|
+
};
|
|
11
|
+
export interface PiAppliedSelection {
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly model: string;
|
|
14
|
+
readonly thinkingLevel: ThinkingLevel;
|
|
15
|
+
}
|
|
16
|
+
export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "pi_session_creation_failed";
|
|
17
|
+
export declare class PiSessionCreationError extends Error {
|
|
18
|
+
readonly code: PiSessionCreationErrorCode;
|
|
19
|
+
constructor(code: PiSessionCreationErrorCode);
|
|
20
|
+
}
|
|
21
|
+
export interface PiSessionHandle {
|
|
22
|
+
readonly sdkSessionId: string;
|
|
23
|
+
/** Only whitelisted, post-verified selection metadata may cross the adapter boundary. */
|
|
24
|
+
readonly appliedSelection: PiAppliedSelection;
|
|
25
|
+
/**
|
|
26
|
+
* `onActivity` is an optional content-free liveness callback, invoked after a completed
|
|
27
|
+
* tool execution and after a completed assistant message. It receives no event payload
|
|
28
|
+
* and must only be used for timestamping; it never carries tool names, arguments,
|
|
29
|
+
* results, text, paths, or credentials.
|
|
30
|
+
*/
|
|
31
|
+
prompt(text: string, preflight: (accepted: boolean) => void, onActivity?: () => void): Promise<PiTurnOutcome>;
|
|
32
|
+
abort(): Promise<void>;
|
|
33
|
+
/** May await bounded external-resource cleanup; synchronous fakes remain supported. */
|
|
34
|
+
dispose(): void | Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface PiSessionAdapter {
|
|
37
|
+
create(input: {
|
|
38
|
+
cwd: string;
|
|
39
|
+
executionProfile: ResolvedExecutionProfile;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}): Promise<PiSessionHandle>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const PI_SESSION_CREATION_MESSAGES = {
|
|
2
|
+
unknown_provider: "Execution profile provider is not in the local Pi catalog",
|
|
3
|
+
unknown_model: "Execution profile model is not in the local Pi catalog",
|
|
4
|
+
local_authentication_missing: "Local authentication for the execution profile is missing",
|
|
5
|
+
local_authentication_unavailable: "Local authentication for the execution profile is unavailable",
|
|
6
|
+
model_unavailable: "Execution profile model is not locally available",
|
|
7
|
+
thinking_level_unavailable: "Execution profile thinking level is unavailable for the model",
|
|
8
|
+
execution_selection_mismatch: "Execution profile could not be applied",
|
|
9
|
+
external_mcp_unavailable: "Configured external MCP tools are unavailable",
|
|
10
|
+
external_mcp_no_tools: "Configured external MCP server returned no tools",
|
|
11
|
+
external_mcp_grant_tool_missing: "Configured external MCP grant is missing from discovery",
|
|
12
|
+
external_mcp_activation_mismatch: "Configured external MCP tools could not be activated",
|
|
13
|
+
pi_session_creation_failed: "Pi session creation failed",
|
|
14
|
+
};
|
|
15
|
+
export class PiSessionCreationError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code) {
|
|
18
|
+
super(PI_SESSION_CREATION_MESSAGES[code]);
|
|
19
|
+
this.name = "PiSessionCreationError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ConfigErrorCode } from "./config.js";
|
|
2
|
+
export interface StartupDiagnostic {
|
|
3
|
+
readonly level: "error";
|
|
4
|
+
readonly event: "startup_failed";
|
|
5
|
+
readonly stage: "startup";
|
|
6
|
+
readonly code: ConfigErrorCode | "startup_failed";
|
|
7
|
+
}
|
|
8
|
+
export declare function startupFailureDiagnostic(error: unknown): StartupDiagnostic;
|