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.
Files changed (88) hide show
  1. package/.pi-session-mcp-cli/config.js +88 -0
  2. package/.pi-session-mcp-cli/doctor-cli.js +11 -0
  3. package/.pi-session-mcp-cli/doctor.js +178 -0
  4. package/.pi-session-mcp-cli/execution-profile.js +29 -0
  5. package/.pi-session-mcp-cli/mcp-config.js +70 -0
  6. package/.pi-session-mcp-cli/package-metadata.js +32 -0
  7. package/CHANGELOG.md +62 -0
  8. package/CONTRIBUTING.md +57 -0
  9. package/LICENSE +21 -0
  10. package/README.md +183 -2
  11. package/SECURITY.md +37 -0
  12. package/dist/capabilities.d.ts +25 -0
  13. package/dist/capabilities.js +26 -0
  14. package/dist/client-adapters/claude-code.d.ts +4 -0
  15. package/dist/client-adapters/claude-code.js +33 -0
  16. package/dist/client-adapters/codex.d.ts +4 -0
  17. package/dist/client-adapters/codex.js +73 -0
  18. package/dist/client-adapters/command.d.ts +12 -0
  19. package/dist/client-adapters/command.js +25 -0
  20. package/dist/client-adapters/index.d.ts +4 -0
  21. package/dist/client-adapters/index.js +3 -0
  22. package/dist/client-adapters/mcp-verifier.d.ts +20 -0
  23. package/dist/client-adapters/mcp-verifier.js +174 -0
  24. package/dist/client-adapters/types.d.ts +54 -0
  25. package/dist/client-adapters/types.js +1 -0
  26. package/dist/config.d.ts +50 -0
  27. package/dist/config.js +88 -0
  28. package/dist/doctor-cli.d.ts +1 -0
  29. package/dist/doctor-cli.js +11 -0
  30. package/dist/doctor.d.ts +47 -0
  31. package/dist/doctor.js +178 -0
  32. package/dist/entry-point.d.ts +5 -0
  33. package/dist/entry-point.js +16 -0
  34. package/dist/execution-profile.d.ts +37 -0
  35. package/dist/execution-profile.js +29 -0
  36. package/dist/main.d.ts +2 -0
  37. package/dist/main.js +51 -0
  38. package/dist/mcp-config.d.ts +22 -0
  39. package/dist/mcp-config.js +70 -0
  40. package/dist/mcp-session-tools.d.ts +54 -0
  41. package/dist/mcp-session-tools.js +323 -0
  42. package/dist/mcp-stdio-client.d.ts +3 -0
  43. package/dist/mcp-stdio-client.js +59 -0
  44. package/dist/package-metadata.d.ts +18 -0
  45. package/dist/package-metadata.js +32 -0
  46. package/dist/pi-adapter.d.ts +42 -0
  47. package/dist/pi-adapter.js +22 -0
  48. package/dist/process-diagnostics.d.ts +8 -0
  49. package/dist/process-diagnostics.js +9 -0
  50. package/dist/sdk-pi-adapter.d.ts +75 -0
  51. package/dist/sdk-pi-adapter.js +410 -0
  52. package/dist/server.d.ts +5 -0
  53. package/dist/server.js +102 -0
  54. package/dist/session-registry.d.ts +104 -0
  55. package/dist/session-registry.js +373 -0
  56. package/dist/setup-cli.d.ts +11 -0
  57. package/dist/setup-cli.js +38 -0
  58. package/dist/setup-command-guardian.d.ts +2 -0
  59. package/dist/setup-command-guardian.js +297 -0
  60. package/dist/setup-main.d.ts +8 -0
  61. package/dist/setup-main.js +60 -0
  62. package/dist/setup-ownership.d.ts +53 -0
  63. package/dist/setup-ownership.js +228 -0
  64. package/dist/setup-process.d.ts +30 -0
  65. package/dist/setup-process.js +385 -0
  66. package/dist/setup-result.d.ts +22 -0
  67. package/dist/setup-result.js +8 -0
  68. package/dist/setup.d.ts +55 -0
  69. package/dist/setup.js +327 -0
  70. package/dist/shutdown.d.ts +10 -0
  71. package/dist/shutdown.js +52 -0
  72. package/docs/acceptance.md +227 -0
  73. package/docs/adr/0001-stdio-first.md +12 -0
  74. package/docs/adr/0002-embedded-pi-sdk.md +11 -0
  75. package/docs/adr/0003-post-mvp-maintenance.md +60 -0
  76. package/docs/adr/0004-client-contract-not-version.md +31 -0
  77. package/docs/architecture.md +82 -0
  78. package/docs/client-setup-design.md +90 -0
  79. package/docs/client-setup.md +207 -0
  80. package/docs/doctor-design.md +47 -0
  81. package/docs/installation.md +352 -0
  82. package/docs/releasing.md +216 -0
  83. package/docs/session-mcp.md +260 -0
  84. package/docs/threat-model.md +89 -0
  85. package/docs/tool-contracts.md +143 -0
  86. package/package.json +64 -4
  87. package/pi-session-mcp.example.json +21 -0
  88. package/scripts/doctor.mjs +72 -0
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
6
+ import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
7
+ const alias = z.string().regex(ALIAS_PATTERN);
8
+ const permissionProfile = z.enum(["read-only", "coding"]);
9
+ const thinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
10
+ const profileSchema = z.object({
11
+ default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
12
+ model: z.string().trim().min(1).max(256), thinkingLevel,
13
+ mcpServers: z.custom((value) => isMcpServers(value)).optional(),
14
+ }).strict().superRefine((profile, context) => {
15
+ if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
16
+ context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
17
+ }
18
+ });
19
+ const CONFIG_ERROR_MESSAGES = {
20
+ config_path_required: "Configuration path is required",
21
+ invalid_execution_profile_config: "Execution profile configuration is invalid",
22
+ invalid_thinking_level: "Execution profile thinking level is invalid",
23
+ };
24
+ export class ConfigError extends Error {
25
+ code;
26
+ constructor(code) {
27
+ super(CONFIG_ERROR_MESSAGES[code]);
28
+ this.name = "ConfigError";
29
+ this.code = code;
30
+ }
31
+ }
32
+ export const configSchema = z.object({
33
+ workspaces: z.record(alias, z.string().min(1)),
34
+ executionProfiles: z.record(alias, profileSchema).superRefine((profiles, context) => {
35
+ const entries = Object.entries(profiles);
36
+ if (entries.length === 0)
37
+ context.addIssue({ code: "custom", message: "At least one execution profile is required" });
38
+ const defaults = entries.filter(([, profile]) => profile.default);
39
+ if (defaults.length !== 1)
40
+ context.addIssue({ code: "custom", message: "Exactly one execution profile must be the default" });
41
+ if (defaults.length === 1 && defaults[0][1].permissionProfile !== "read-only")
42
+ context.addIssue({ code: "custom", message: "The default execution profile must be read-only" });
43
+ }),
44
+ }).strict();
45
+ /** The only supported reload behavior: a running controller keeps the snapshot it loaded at startup. */
46
+ export const RELOAD_POLICY = "restart-required";
47
+ export const CONFIGURATION_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
48
+ function configurationFingerprint(bytes) {
49
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
50
+ }
51
+ /**
52
+ * The configuration path comes only from the caller or from `PI_SESSION_MCP_CONFIG`.
53
+ * There is no default file: a server's working directory is the client's project,
54
+ * which may be untrusted, so a file found there must never become the policy.
55
+ */
56
+ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
57
+ if (!path)
58
+ throw new ConfigError("config_path_required");
59
+ const absoluteConfig = resolve(path);
60
+ // One read: the same bytes feed both the fingerprint and the parse.
61
+ const bytes = await readFile(absoluteConfig);
62
+ const configuration = Object.freeze({
63
+ fingerprint: configurationFingerprint(bytes),
64
+ reloadPolicy: RELOAD_POLICY,
65
+ });
66
+ const input = JSON.parse(bytes.toString("utf8"));
67
+ let parsed;
68
+ try {
69
+ parsed = configSchema.parse(input);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof z.ZodError) {
73
+ const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
74
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : "invalid_execution_profile_config");
75
+ }
76
+ throw error;
77
+ }
78
+ const base = dirname(absoluteConfig);
79
+ const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
80
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, ...value }]) => [key, Object.freeze({
81
+ alias: key, ...value,
82
+ ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
83
+ })]));
84
+ const defaultEntry = [...executionProfiles].find(([, profile]) => profile.default);
85
+ if (!defaultEntry)
86
+ throw new Error("Execution profile configuration is invalid");
87
+ return { workspaces, executionProfiles, defaultExecutionProfile: defaultEntry[0], configuration };
88
+ }
@@ -0,0 +1,11 @@
1
+ import { parseDoctorArgs, renderDoctorHuman, renderDoctorJson, runDoctor } from "./doctor.js";
2
+ const options = parseDoctorArgs(process.argv.slice(2));
3
+ if (!options) {
4
+ process.stderr.write("Usage: doctor [--json]\n");
5
+ process.exitCode = 64;
6
+ }
7
+ else {
8
+ const result = await runDoctor();
9
+ process.stdout.write(options.json ? renderDoctorJson(result) : renderDoctorHuman(result));
10
+ process.exitCode = result.exitCode;
11
+ }
@@ -0,0 +1,178 @@
1
+ import { access, readFile, readdir, stat } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { readPackageMetadata } from "./package-metadata.js";
6
+ const defaultFileSystem = { readFile, stat, access };
7
+ const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable"];
8
+ function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
9
+ function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
10
+ function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
11
+ function isMissing(error) { return typeof error === "object" && error !== null && "code" in error && ["ENOENT", "config_path_required"].includes(String(error.code)); }
12
+ /** The lockfile's modification time, or undefined for an npm installation, which has no lockfile. */
13
+ async function lockfileMtime(fs, root) {
14
+ try {
15
+ return (await fs.stat(join(root, "package-lock.json"))).mtimeMs;
16
+ }
17
+ catch (error) {
18
+ if (isMissing(error))
19
+ return undefined;
20
+ throw error;
21
+ }
22
+ }
23
+ function isUnreadable(error) { return typeof error === "object" && error !== null && "code" in error && ["EACCES", "EPERM"].includes(String(error.code)); }
24
+ function isSemver(value) { return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value); }
25
+ function satisfiesNode(version, range) {
26
+ const minimum = range.match(/>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
27
+ const actual = version.match(/^(\d+)\.(\d+)\.(\d+)/);
28
+ if (!minimum || !actual)
29
+ return false;
30
+ const want = [Number(minimum[1]), Number(minimum[2] ?? 0), Number(minimum[3] ?? 0)];
31
+ const got = [Number(actual[1]), Number(actual[2]), Number(actual[3])];
32
+ return got[0] > want[0] || (got[0] === want[0] && (got[1] > want[1] || (got[1] === want[1] && got[2] >= want[2])));
33
+ }
34
+ /** Build inputs beyond the manifests. An installed package has none of them, which is not staleness. */
35
+ async function productionBuildInputMtimes(root) {
36
+ const mtimes = [];
37
+ for (const name of ["tsconfig.json", "tsconfig.build.json"]) {
38
+ try {
39
+ mtimes.push((await stat(join(root, name))).mtimeMs);
40
+ }
41
+ catch (error) {
42
+ if (!isMissing(error))
43
+ throw error;
44
+ }
45
+ }
46
+ const visit = async (directory) => {
47
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
48
+ const path = join(directory, entry.name);
49
+ if (entry.isDirectory())
50
+ await visit(path);
51
+ else if (entry.isFile() && entry.name.endsWith(".ts"))
52
+ mtimes.push((await stat(path)).mtimeMs);
53
+ }
54
+ };
55
+ try {
56
+ await visit(join(root, "src"));
57
+ }
58
+ catch (error) {
59
+ if (!isMissing(error))
60
+ throw error;
61
+ }
62
+ return mtimes;
63
+ }
64
+ export async function runDoctor(dependencies = {}) {
65
+ const fs = dependencies.fileSystem ?? defaultFileSystem;
66
+ const root = dependencies.packageRoot ?? process.cwd();
67
+ const checks = [];
68
+ let metadata;
69
+ try {
70
+ const injected = dependencies.packageMetadata !== undefined;
71
+ if (dependencies.packageMetadata)
72
+ metadata = await dependencies.packageMetadata();
73
+ else {
74
+ metadata = await readPackageMetadata(root, fs.readFile);
75
+ }
76
+ if (!metadata || !isSemver(metadata.version) || typeof metadata.engines?.node !== "string" || !/>=\s*\d+/.test(metadata.engines.node))
77
+ throw new Error("invalid package metadata");
78
+ if (!injected && metadata.lockVersion !== undefined && !isSemver(metadata.lockVersion))
79
+ throw new Error("invalid lock metadata");
80
+ }
81
+ catch {
82
+ checks.push(failure("package_metadata_unreadable", "package metadata", "repair package metadata"));
83
+ }
84
+ const nodeRange = metadata?.engines?.node;
85
+ if (nodeRange && !satisfiesNode(dependencies.nodeVersion ?? process.versions.node, nodeRange))
86
+ checks.push(failure("node_version_unsupported", "Node.js", "use a supported Node.js version"));
87
+ else if (nodeRange)
88
+ checks.push(ok("node_version_unsupported", "Node.js"));
89
+ let buildMtime;
90
+ try {
91
+ const build = await fs.stat(join(root, "dist", "main.js"));
92
+ if (build.isFile()) {
93
+ buildMtime = build.mtimeMs;
94
+ checks.push(ok("build_missing", "build output"));
95
+ }
96
+ else
97
+ checks.push(failure("build_missing", "build output", "run the production build"));
98
+ }
99
+ catch (error) {
100
+ checks.push(failure("build_missing", "build output", isMissing(error) ? "run the production build" : "check build output access"));
101
+ }
102
+ if (buildMtime !== undefined) {
103
+ try {
104
+ const packageMtime = (await fs.stat(join(root, "package.json"))).mtimeMs;
105
+ const lockMtime = await lockfileMtime(fs, root);
106
+ const inputMtimes = [packageMtime, lockMtime].filter((value) => value !== undefined);
107
+ if (!dependencies.fileSystem)
108
+ inputMtimes.push(...await productionBuildInputMtimes(root));
109
+ if (inputMtimes.some((mtime) => mtime > buildMtime))
110
+ checks.push(warning("build_stale", "build output", "run the production build"));
111
+ else
112
+ checks.push(ok("build_stale", "build output"));
113
+ }
114
+ catch {
115
+ checks.push(warning("build_stale", "build output", "rebuild to establish current build state"));
116
+ }
117
+ }
118
+ if (metadata) {
119
+ const expectedVersion = metadata.expectedVersion ?? metadata.lockVersion;
120
+ if (expectedVersion && metadata.version !== expectedVersion)
121
+ checks.push(warning("version_mismatch", "package version", "reinstall matching build artifacts"));
122
+ else
123
+ checks.push(ok("version_mismatch", "package version"));
124
+ }
125
+ let config;
126
+ try {
127
+ config = await (dependencies.loadConfig ?? (() => loadConfig()))();
128
+ checks.push(ok("config_invalid", "configuration"));
129
+ }
130
+ catch (error) {
131
+ if (isMissing(error))
132
+ checks.push(failure("config_missing", "configuration", "create PI_SESSION_MCP_CONFIG configuration"));
133
+ else if (isUnreadable(error))
134
+ checks.push(failure("config_unreadable", "configuration", "grant configuration read access"));
135
+ else
136
+ checks.push(failure("config_invalid", "configuration", "fix configuration using the documented schema"));
137
+ }
138
+ if (config) {
139
+ for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
140
+ try {
141
+ const info = await fs.stat(path);
142
+ if (!info.isDirectory())
143
+ checks.push(failure("workspace_not_directory", alias, "point the workspace alias to a directory"));
144
+ else {
145
+ checks.push(ok("workspace_not_directory", alias));
146
+ try {
147
+ await fs.access(path, constants.R_OK);
148
+ checks.push(ok("workspace_unreadable", alias));
149
+ }
150
+ catch {
151
+ checks.push(failure("workspace_unreadable", alias, "grant workspace read access"));
152
+ }
153
+ const canWrite = [...config.executionProfiles.values()].some((profile) => profile.permissionProfile === "coding");
154
+ if (canWrite) {
155
+ try {
156
+ await fs.access(path, constants.W_OK);
157
+ checks.push(ok("workspace_not_writable", alias));
158
+ }
159
+ catch {
160
+ checks.push(failure("workspace_not_writable", alias, "grant coding workspace write access"));
161
+ }
162
+ }
163
+ }
164
+ }
165
+ catch (error) {
166
+ checks.push(failure(isMissing(error) ? "workspace_missing" : "workspace_unreadable", alias, isMissing(error) ? "fix the workspace alias target" : "grant workspace access"));
167
+ }
168
+ }
169
+ }
170
+ const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
171
+ checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
172
+ const hasError = checks.some((check) => check.severity === "error");
173
+ const result = { schemaVersion: 1, ok: !hasError, ...(metadata ? { packageVersion: metadata.version } : {}), checks: Object.freeze(checks), exitCode: hasError ? 2 : checks.some((check) => check.severity === "warning") ? 1 : 0 };
174
+ return Object.freeze(result);
175
+ }
176
+ export function renderDoctorJson(result) { return JSON.stringify(result) + "\n"; }
177
+ export function renderDoctorHuman(result) { return result.checks.map((check) => `${check.severity.toUpperCase()} ${check.id} [${check.subject}]${check.remediation ? `: ${check.remediation}` : ""}`).join("\n") + "\n"; }
178
+ export function parseDoctorArgs(args) { return args.length === 0 ? { json: false } : args.length === 1 && args[0] === "--json" ? { json: true } : undefined; }
@@ -0,0 +1,29 @@
1
+ import { snapshotMcpServers } from "./mcp-config.js";
2
+ export const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
3
+ export function toResolvedExecutionProfile(profile) {
4
+ return Object.freeze({
5
+ alias: profile.alias,
6
+ permissionProfile: profile.permissionProfile,
7
+ provider: profile.provider,
8
+ model: profile.model,
9
+ thinkingLevel: profile.thinkingLevel,
10
+ ...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
11
+ });
12
+ }
13
+ /** A small read-only map implementation which has no mutating methods at runtime. */
14
+ export class ImmutableMap {
15
+ entriesMap;
16
+ constructor(entries) {
17
+ this.entriesMap = new Map(entries);
18
+ }
19
+ get size() { return this.entriesMap.size; }
20
+ get(key) { return this.entriesMap.get(key); }
21
+ has(key) { return this.entriesMap.has(key); }
22
+ entries() { return this.entriesMap.entries(); }
23
+ keys() { return this.entriesMap.keys(); }
24
+ values() { return this.entriesMap.values(); }
25
+ forEach(callbackfn, thisArg) {
26
+ this.entriesMap.forEach((value, key) => callbackfn.call(thisArg, value, key, this));
27
+ }
28
+ [Symbol.iterator]() { return this.entriesMap.entries(); }
29
+ }
@@ -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,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
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.6.2 — 2026-10-09
6
+
7
+ - The server now shuts down when the MCP client closes its stdin. Previously
8
+ only `SIGINT` and `SIGTERM` ran the bounded shutdown, so after stdin EOF an
9
+ idle Pi session and its external MCP server kept running. EOF uses the same
10
+ 15,000 ms shutdown and exits with status `0`; the first of EOF and a signal
11
+ sets the exit status. Public tools, fields, states and error codes are
12
+ unchanged.
13
+ - Built on Pi SDK `1.1.0` (was `1.0.4`). Pi now retries `server_busy` provider
14
+ errors instead of ending the turn. The new `aborted` flag on settled runs is
15
+ not a closed terminal cause, so failed turns still report `turn_failed`.
16
+ Public tools, fields, states and error codes are unchanged.
17
+ - Built on the MCP TypeScript SDK `2.3.1` (was `2.2.0`). Its packages are now
18
+ licensed under Apache-2.0 instead of MIT. The stdio entry point already
19
+ builds one server per connection, which the SDK now requires.
20
+ - Validates with Zod `4.6.5` (was `4.5.4`), which fixes an out-of-memory
21
+ regression of Zod 4.5 in recursive schemas.
22
+ - Development: MCP Inspector `2.10.1` (was the deprecated `0.19.0`). `npm run
23
+ inspect` works as before. Inspector 2.x keeps its catalog in
24
+ `~/.mcp-inspector/`; see the README for its secret storage. The lockfile
25
+ loses 216 packages and gains 89, and the earlier lockfile patches for
26
+ `proxy-addr`, `shell-quote` and `concurrently` leave the tree with the old
27
+ Inspector.
28
+ - Development: TypeScript `7.0.2` (was `5.9.3`), Vitest `5.0.3` (was
29
+ `4.1.11`) and `@types/node` `22.20.5` (was `22.19.19`). The emitted
30
+ JavaScript is byte-identical to the TypeScript 5.9 build.
31
+ - `setup` was re-recorded against Codex CLI `0.162.0` and Claude Code
32
+ `2.1.295`; their `mcp` command contracts are unchanged.
33
+
34
+ ## 0.6.1 — 2026-10-07
35
+
36
+ - The stdio fixture tests no longer fail when they read a log line that the
37
+ fixture is still writing: only newline-terminated lines are parsed. Test-only
38
+ change.
39
+ - Published as the npm package `pi-session-mcp` with the executables
40
+ `pi-session-mcp`, `pi-session-mcp-setup` and `pi-session-mcp-doctor`. The
41
+ package contains the built files, the documentation and the example
42
+ configuration, but no lockfile, because npm does not ship lockfiles inside
43
+ packages. Doctor and `setup` therefore accept an installation without a
44
+ lockfile, and the entry points start through npm's `bin` symlinks. Public
45
+ tools, fields, states and error codes are unchanged.
46
+
47
+ ## 0.6.0 — 2026-10-06
48
+
49
+ - First public release, version `0.6.0`. Earlier 0.x versions were developed
50
+ privately; their history is not part of this repository.
51
+ - Eight MCP tools over stdio for controlled Pi coding-agent sessions:
52
+ capability discovery, session start, list, get, prompt, abort and close, and
53
+ turn polling. Results and errors are sanitized and stable.
54
+ - Operator-defined workspace aliases and execution profiles. A profile binds a
55
+ permission profile, a provider, a model and a thinking level, and can grant
56
+ individual tools of local stdio MCP servers to its sessions.
57
+ - An offline `doctor` command and a reproducible `setup` command for Codex CLI
58
+ and Claude Code with plan, apply, verify, rollback and remove operations.
59
+ `setup` accepts clients by contract, not by version: it checks the client's
60
+ public `mcp` command output and fails closed on any unexpected output.
61
+ - Built on Pi SDK `1.0.4` and the MCP TypeScript SDK `2.2.0`. A nightly canary
62
+ checks the sources against the newest published Pi SDK.
@@ -0,0 +1,57 @@
1
+ # Contributing
2
+
3
+ Pi Session MCP is in maintenance mode: defects and upstream drift get focused
4
+ fixes, and no new feature phase is planned. The reasons and the conditions for
5
+ revisiting that decision are in
6
+ [ADR 0003](docs/adr/0003-post-mvp-maintenance.md). Please open an issue before
7
+ working on anything.
8
+
9
+ ## Development setup
10
+
11
+ Linux and Node.js 22.19.0 or newer:
12
+
13
+ ```bash
14
+ npm ci
15
+ npm run typecheck
16
+ npm run build:cli
17
+ npm test
18
+ npm run build
19
+ ```
20
+
21
+ Tests use a fake Pi adapter or a scripted model. They need no provider
22
+ credentials and no network access beyond the npm registry.
23
+
24
+ ## Rules
25
+
26
+ The binding project rules are in [AGENTS.md](AGENTS.md). The most important
27
+ ones:
28
+
29
+ - TypeScript strict mode, ESM, and `.js` suffixes in relative imports.
30
+ - Pin every direct dependency exactly. Do not use range prefixes.
31
+ - Stdout carries MCP frames only. Diagnostics go to stderr and contain neither
32
+ secrets nor complete prompts.
33
+ - Workspaces are reached only through configured aliases. No tool argument may
34
+ accept an arbitrary working directory.
35
+ - Use only documented exports of the Pi SDK package root.
36
+ - Write documentation in English.
37
+
38
+ ## Pull requests
39
+
40
+ Pull requests are limited to collaborators. If you are not a collaborator and
41
+ want to contribute a fix, open an issue that describes it.
42
+
43
+ - Keep a pull request to one focused change, and say which issue it addresses.
44
+ - Run the checks above and `npm audit` before opening it.
45
+ - Describe what you verified and how. State only what was run: provider-free
46
+ tests do not establish live client or model behavior.
47
+ - Never put credentials, prompts, transcripts, absolute local paths or raw
48
+ provider errors into issues, pull requests, tests or logs.
49
+
50
+ Updating the Pi SDK pin, recording a client contract and patching audit
51
+ advisories each have a procedure in [Releasing](docs/releasing.md). The
52
+ acceptance levels are defined in [Acceptance](docs/acceptance.md).
53
+
54
+ ## Security
55
+
56
+ Do not report vulnerabilities in public issues. See the
57
+ [security policy](SECURITY.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 plagor44-ux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.