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
package/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # pi-session-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local, stdio-only [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets a coding agent such as Codex or Claude Code delegate work to a [Pi](https://github.com/earendil-works/pi) coding-agent session, under rules that the operator sets.
4
+
5
+ The client never chooses a directory, a model or a permission level. It selects from workspace and execution-profile aliases that you define in a local configuration file, starts a session, sends prompts, polls the turn and closes the session. Pi Session MCP embeds the Pi SDK, keeps all state in memory and talks only over stdin and stdout.
6
+
7
+ > Pi Session MCP is an independent project. It is not affiliated with or endorsed by the makers of Pi, OpenAI or Anthropic. Product names are used only to describe compatibility.
8
+
9
+ ## Why use it
10
+
11
+ Codex and Claude Code are good at driving a task. Pi is a coding agent that runs against many model providers, including local OpenAI-compatible endpoints. Pi Session MCP connects the two: your main agent stays in charge and hands selected work to a Pi session that you configured.
12
+
13
+ Reasons to do that:
14
+
15
+ - **A second model without leaving your agent.** A read-only Pi session on another model can review a change, answer a question about a codebase or give a second opinion. The main agent asks, polls and reads the answer.
16
+ - **Bulk reading stays out of the main context.** The Pi session does the exploring. Only its final answer returns to the client, capped at 64 KiB, so the main agent's context stays small.
17
+ - **Delegated work on a model of your choice.** An execution profile binds a provider, a model and a thinking level. Routine work can go to a local or cheaper model while the main agent keeps the hard parts.
18
+ - **Follow-up turns keep their context.** A session stays open across prompts, so a follow-up question does not pay for the same exploration twice.
19
+ - **You set the limits, not the agent.** The client can only pick from the workspaces and profiles in your configuration file. It cannot name a directory, a model, a provider or a permission level, and the default profile is read-only.
20
+ - **Exactly the extra tools a task needs.** A profile can grant individual tools of local stdio MCP servers to its sessions, and nothing else from those servers.
21
+ - **Predictable to automate.** Sessions and turns have a small set of states and stable error codes. Results contain no paths, credentials, prompts or raw provider errors.
22
+ - **Small footprint.** No daemon, no network listener and no stored state. An offline `doctor` checks an installation, and `setup` registers, verifies and removes the server reproducibly.
23
+
24
+ It is not the right tool when:
25
+
26
+ - you want to work with Pi yourself. Run Pi directly;
27
+ - you need remote access, several users or sessions that survive a restart. These are out of scope by design;
28
+ - you need to contain untrusted code. A `coding` profile limits Pi's tools, but it is not an operating-system sandbox.
29
+
30
+ ## Status
31
+
32
+ The package version is **0.6.2**.
33
+
34
+ - **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
35
+ - **Linux only**, Node.js 22.19.0 or newer.
36
+ - **Clients:** Codex CLI and Claude Code. The `setup` command does not pin client versions: it accepts a client whose public `mcp` command output matches the expected contract and fails closed otherwise.
37
+ - **Out of scope by design:** HTTP, a web UI, persistence, resume, remote access and multi-user behavior. One trusted local MCP peer talks to one server process.
38
+ - **Not a sandbox.** A `coding` profile restricts the Pi tools that a session may use. It does not isolate the process from the operating system.
39
+ - **Acceptance.** Every release passes the provider-free checks. The live two-client acceptance has not yet been repeated on a build with Pi SDK `1.x`; see [Acceptance](docs/acceptance.md).
40
+
41
+ ## How it works
42
+
43
+ ```text
44
+ Codex or Claude Code ──MCP over stdio──▶ pi-session-mcp ──embedded Pi SDK──▶ Pi session ──▶ model provider
45
+ (client) (this server) (in memory)
46
+ ```
47
+
48
+ The client starts `node dist/main.js` as a child process. Pi Session MCP reads its configuration once, exposes eight tools, and runs Pi sessions inside that process through the embedded Pi SDK. Pi's existing local authentication is reused and never stored, printed or returned.
49
+
50
+ | Tool | Purpose |
51
+ | --- | --- |
52
+ | `pi_capabilities_get` | List the configured workspace and execution-profile aliases. |
53
+ | `pi_session_start` | Start a session for one workspace alias and one execution-profile alias. |
54
+ | `pi_session_list` | List the sessions of this server process. |
55
+ | `pi_session_get` | Read one session. |
56
+ | `pi_session_prompt` | Send a prompt and get a running turn back. |
57
+ | `pi_turn_get` | Poll a turn until it is `completed`, `failed` or `aborted`. |
58
+ | `pi_session_abort` | Abort a running turn. |
59
+ | `pi_session_close` | End a session and release its resources. |
60
+
61
+ The [tool contracts](docs/tool-contracts.md) define every field, state and error code.
62
+
63
+ ## Quick start
64
+
65
+ ```bash
66
+ npm install -g pi-session-mcp@<version>
67
+
68
+ mkdir -p "$HOME/.config/pi-session-mcp"
69
+ cp "$(npm root -g)/pi-session-mcp/pi-session-mcp.example.json" "$HOME/.config/pi-session-mcp/pi-session-mcp.json" # then edit it
70
+ chmod 600 "$HOME/.config/pi-session-mcp/pi-session-mcp.json"
71
+ export PI_SESSION_MCP_CONFIG="$HOME/.config/pi-session-mcp/pi-session-mcp.json"
72
+
73
+ pi-session-mcp-doctor
74
+ pi-session-mcp-setup --dry-run --target claude-code:user:pi-session-mcp
75
+ pi-session-mcp-setup --apply --target claude-code:user:pi-session-mcp
76
+ pi-session-mcp-setup --verify --target claude-code:user:pi-session-mcp
77
+ ```
78
+
79
+ Use `codex:user:pi-session-mcp` for Codex. The example configuration contains placeholders only: replace the workspace, provider and model with real local values, and never add credentials to the file.
80
+
81
+ `pi-session-mcp-doctor` is an offline health check. `pi-session-mcp-setup` plans by default, changes a client registration only with an explicit operation, and never replaces an existing divergent registration. Neither needs credentials, a provider or a model.
82
+
83
+ The [installation guide](docs/installation.md) also covers installing from a release tag with the exact locked dependency tree, the configuration rules, existing registrations, configuration changes, upgrades and removal.
84
+
85
+ ## Configuration
86
+
87
+ ```json
88
+ {
89
+ "workspaces": {
90
+ "my-project": "/absolute/path/to/my-project"
91
+ },
92
+ "executionProfiles": {
93
+ "safe-readonly": {
94
+ "default": true,
95
+ "permissionProfile": "read-only",
96
+ "provider": "your-configured-provider",
97
+ "model": "your-configured-model",
98
+ "thinkingLevel": "medium"
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ - A workspace alias maps to a directory. Clients can select aliases only.
105
+ - An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`.
106
+ - A profile can grant individual tools of local stdio MCP servers to its sessions; see [External MCP tools in embedded sessions](docs/session-mcp.md).
107
+ - The server reads the file once at startup. Start a new client session after changing it.
108
+
109
+ ## Run it by hand
110
+
111
+ ```bash
112
+ pi-session-mcp # npm installation
113
+ npm start # release checkout
114
+ ```
115
+
116
+ This starts the server on stdin and stdout. `PI_SESSION_MCP_CONFIG` must name the configuration file; the server never looks for one in its working directory. Do not type ordinary text into the process and do not redirect diagnostics to stdout: stdout carries MCP frames only.
117
+
118
+ To explore the tools without a coding agent, use the pinned MCP Inspector:
119
+
120
+ ```bash
121
+ npm run build
122
+ npm run inspect
123
+ ```
124
+
125
+ Listing tools and testing input validation needs no provider credentials. An accepted prompt needs existing local Pi authentication and an available provider and model.
126
+
127
+ Inspector 2.x keeps its server catalog in `~/.mcp-inspector/`. Without an OS keychain it also stores secrets there unencrypted; `MCP_INSPECTOR_SECRET_STORE=memory` keeps them in memory only.
128
+
129
+ ## Security model
130
+
131
+ - **Local and single-peer.** No network listener, no daemon between client sessions, no persisted session state.
132
+ - **Aliases instead of paths.** No tool accepts a working directory, a model, a provider or a credential.
133
+ - **Explicit policy file.** The configuration path comes only from `PI_SESSION_MCP_CONFIG`. A file in the working directory, which is the client's project, is never read as configuration.
134
+ - **Sanitized results.** Public errors are stable codes. They contain no paths, prompts, provider errors, authentication data or credential locations. `assistantText` can repeat workspace content and must be treated as sensitive.
135
+ - **Fail closed.** Unknown fields, unknown aliases, unexpected client output and divergent registrations are rejected, not repaired.
136
+ - **Bounded waiting.** Session creation, prompt acceptance, abort, close and shutdown have fixed server-side deadlines.
137
+
138
+ The [threat model](docs/threat-model.md) states the assumptions and the limits, including what a `coding` profile does not protect against.
139
+
140
+ ## Documentation
141
+
142
+ Operate:
143
+
144
+ - [Installation](docs/installation.md)
145
+ - [Client setup reference](docs/client-setup.md): manual registration, the tool flow, deadlines and troubleshooting
146
+ - [External MCP tools in embedded sessions](docs/session-mcp.md)
147
+ - [Changelog](CHANGELOG.md)
148
+
149
+ Contract and design:
150
+
151
+ - [Tool contracts](docs/tool-contracts.md)
152
+ - [Architecture](docs/architecture.md)
153
+ - [Threat model](docs/threat-model.md)
154
+ - [Setup lifecycle design](docs/client-setup-design.md)
155
+ - [Doctor contract](docs/doctor-design.md)
156
+ - [ADR 0001: stdio first](docs/adr/0001-stdio-first.md)
157
+ - [ADR 0002: embedded Pi SDK](docs/adr/0002-embedded-pi-sdk.md)
158
+ - [ADR 0003: no new feature phase after the MVP](docs/adr/0003-post-mvp-maintenance.md)
159
+ - [ADR 0004: setup accepts clients by contract, not by version](docs/adr/0004-client-contract-not-version.md)
160
+
161
+ Maintain:
162
+
163
+ - [Acceptance](docs/acceptance.md)
164
+ - [Releasing](docs/releasing.md)
165
+ - [Contributing](CONTRIBUTING.md)
166
+ - [Security policy](SECURITY.md)
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ npm ci
172
+ npm run typecheck
173
+ npm run build:cli
174
+ npm test
175
+ npm run build
176
+ ```
177
+
178
+ These are the checks that CI runs. Tests use a fake Pi adapter or a scripted model and need no provider credentials. Every direct dependency is pinned exactly, and the lockfile is committed. The project rules for contributors and coding agents are in [AGENTS.md](AGENTS.md).
179
+
180
+ The Pi SDK moves quickly, so a nightly canary checks the unchanged sources against the newest published SDK without touching the pin. [Releasing](docs/releasing.md) describes how to update the pin, record a client contract, patch audit advisories and prepare a release.
181
+
182
+ ## License
183
+
184
+ [MIT](LICENSE)
package/SECURITY.md ADDED
@@ -0,0 +1,37 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Only the latest release receives fixes.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Report vulnerabilities privately through GitHub's private vulnerability
10
+ reporting: open the **Security** tab of this repository and choose **Report a
11
+ vulnerability**. Do not open a public issue for a suspected vulnerability.
12
+
13
+ Include the Pi Session MCP version, the client and its version, the shape of the
14
+ configuration without any real values, and the steps to reproduce. Never
15
+ include credentials, prompts, transcripts or absolute local paths.
16
+
17
+ ## What counts
18
+
19
+ Pi Session MCP promises a narrow boundary, described in the
20
+ [threat model](docs/threat-model.md). Reports about a broken promise are in
21
+ scope, for example:
22
+
23
+ - a path, credential, prompt, provider error or authentication location in a
24
+ public tool result, a `doctor` report or a `setup` report;
25
+ - a client reaching a directory, model, provider or permission level that the
26
+ configuration does not offer through an alias;
27
+ - a session receiving a tool that its execution profile does not grant;
28
+ - `setup` changing or removing a registration that it does not own.
29
+
30
+ ## What does not count
31
+
32
+ - A `coding` profile is an application-level tool allowlist. It is not an
33
+ operating-system sandbox, and this is a documented limit, not a defect.
34
+ - Vulnerabilities in Pi, in the MCP clients, in model providers or in other
35
+ dependencies belong to those projects. A dependency advisory that affects Pi
36
+ Control's own use of the dependency is in scope.
37
+ - The server trusts its one local MCP peer and the operator's configuration.
@@ -0,0 +1,25 @@
1
+ import type { ConfigurationMetadata } from "./config.js";
2
+ import type { ConfiguredExecutionProfile } from "./execution-profile.js";
3
+ /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
4
+ export declare function projectCapabilities(workspaces: ReadonlyMap<string, string>, executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>, configuration: ConfigurationMetadata): {
5
+ ok: true;
6
+ server: {
7
+ name: string;
8
+ version: string;
9
+ };
10
+ configuration: {
11
+ fingerprint: `sha256:${string}`;
12
+ reloadPolicy: "restart-required";
13
+ };
14
+ workspaces: {
15
+ alias: string;
16
+ }[];
17
+ executionProfiles: {
18
+ alias: string;
19
+ default: boolean;
20
+ permissionProfile: import("./execution-profile.js").PermissionProfile;
21
+ provider: string;
22
+ model: string;
23
+ thinkingLevel: import("./execution-profile.js").ThinkingLevel;
24
+ }[];
25
+ };
@@ -0,0 +1,26 @@
1
+ import packageMetadata from "../package.json" with { type: "json" };
2
+ const compareAliases = (left, right) => left < right ? -1 : left > right ? 1 : 0;
3
+ /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
4
+ export function projectCapabilities(workspaces, executionProfiles, configuration) {
5
+ return {
6
+ ok: true,
7
+ server: { name: packageMetadata.name, version: packageMetadata.version },
8
+ configuration: {
9
+ fingerprint: configuration.fingerprint,
10
+ reloadPolicy: configuration.reloadPolicy,
11
+ },
12
+ workspaces: [...workspaces.keys()]
13
+ .sort(compareAliases)
14
+ .map((alias) => ({ alias })),
15
+ executionProfiles: [...executionProfiles.values()]
16
+ .sort((left, right) => compareAliases(left.alias, right.alias))
17
+ .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel }) => ({
18
+ alias,
19
+ default: isDefault,
20
+ permissionProfile,
21
+ provider,
22
+ model,
23
+ thinkingLevel,
24
+ })),
25
+ };
26
+ }
@@ -0,0 +1,4 @@
1
+ import type { ClientAdapter, CommandRunner } from "./types.js";
2
+ export declare function createClaudeCodeAdapter(input: {
3
+ readonly runner: CommandRunner;
4
+ }): ClientAdapter;
@@ -0,0 +1,33 @@
1
+ import { safeRunner, versionFromOutput } from "./command.js";
2
+ const NAME = "pi-session-mcp";
3
+ function scopeArgs(scope) { return ["-s", scope]; }
4
+ function inspectText(text, intent) {
5
+ const bounded = text.slice(0, 32_768);
6
+ if (!/(?:^|\n)\s*pi-session-mcp\s*:/i.test(bounded))
7
+ return { state: "absent", expected: false };
8
+ const scope = /(?:^|\n)\s*scope\s*:\s*(user|project|local)\s+config\b.*$/im.exec(bounded)?.[1]?.toLowerCase();
9
+ const type = /(?:^|\n)\s*type\s*:\s*(\S+)\s*$/im.exec(bounded)?.[1];
10
+ const command = /(?:^|\n)\s*command\s*:\s*(\S+)\s*$/im.exec(bounded)?.[1];
11
+ const args = /(?:^|\n)\s*args?\s*:\s*(.+)\s*$/im.exec(bounded)?.[1];
12
+ const environmentBlock = /(?:^|\n)\s*environment\s*:\s*\n((?:\s{4,}[^\n]+\n?)+)/im.exec(bounded)?.[1];
13
+ const environment = environmentBlock?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
14
+ const configLine = environment[0];
15
+ const config = configLine?.startsWith("PI_SESSION_MCP_CONFIG=") ? configLine.slice("PI_SESSION_MCP_CONFIG=".length) : undefined;
16
+ if (!scope || type !== "stdio" || !command || !args || environment.length !== 1 || !config)
17
+ return { state: "unsupported", expected: false };
18
+ const expected = scope === intent.scope && command === intent.nodePath && args.trim() === intent.entryPath && config === intent.configPath;
19
+ return { state: expected ? "equivalent" : "divergent", expected };
20
+ }
21
+ export function createClaudeCodeAdapter(input) {
22
+ const runner = safeRunner(input.runner.run);
23
+ return {
24
+ client: "claude-code",
25
+ async discover() { const versionResult = await runner.run("claude", ["--version"]); const v = versionResult.exitCode === 0 ? versionFromOutput("claude-code", versionResult.stdout) : undefined; const probe = await runner.run("claude", ["mcp", "--help"]); const supported = probe.exitCode === 0 && ["get", "add", "remove"].every((command) => new RegExp(`\\b${command}\\b`).test(probe.stdout)); return { client: "claude-code", version: v ?? "unknown", supportsJson: false, supportsAdd: supported, supportsRemove: supported, scopes: supported ? ["user", "project", "local"] : [] }; },
26
+ async inspect(scope, intent) { const r = await runner.run("claude", ["mcp", "get", NAME]); const absent = /^No MCP server named \"pi-session-mcp\"\.(?:(?:\s+Configured servers:.*)|(?:\s+Run `claude mcp add` to add one\.))?\s*$/i.test(r.stderr.trim()); const x = r.exitCode === 0 ? inspectText(r.stdout, intent) : absent ? { state: "absent", expected: false } : { state: "unsupported", expected: false }; return { client: "claude-code", scope, name: NAME, state: x.state, command: x.expected ? "node" : "unknown", hasExpectedConfig: x.expected, supported: x.state !== "unsupported" }; },
27
+ async plan(intent) { const r = await this.inspect(intent.scope, intent); return { client: "claude-code", scope: intent.scope, state: r.state, operation: r.state === "absent" ? "add" : "none", reason: r.state === "absent" ? "missing" : r.state === "equivalent" ? "already_registered" : r.state }; },
28
+ async add(intent, execution) { const r = await (execution?.runner ?? runner).run("claude", ["mcp", "add", NAME, "-s", intent.scope, "-e", `PI_SESSION_MCP_CONFIG=${intent.configPath}`, "--", intent.nodePath, intent.entryPath], execution?.signal); if (r.exitCode !== 0)
29
+ throw new Error("client registration failed"); },
30
+ async remove(scope, execution) { const r = await (execution?.runner ?? runner).run("claude", ["mcp", "remove", NAME, ...scopeArgs(scope)], execution?.signal); if (r.exitCode !== 0)
31
+ throw new Error("client removal failed"); },
32
+ };
33
+ }
@@ -0,0 +1,4 @@
1
+ import type { ClientAdapter, CommandRunner } from "./types.js";
2
+ export declare function createCodexAdapter(input: {
3
+ readonly runner: CommandRunner;
4
+ }): ClientAdapter;
@@ -0,0 +1,73 @@
1
+ import { safeRunner, versionFromOutput } from "./command.js";
2
+ const NAME = "pi-session-mcp";
3
+ function equivalent(value, intent) {
4
+ if (!value || typeof value !== "object")
5
+ return false;
6
+ const root = value;
7
+ if (root.name !== NAME || root.enabled !== true)
8
+ return false;
9
+ const transport = root.transport;
10
+ if (!transport || typeof transport !== "object")
11
+ return false;
12
+ const entry = transport;
13
+ const args = Array.isArray(entry.args) ? entry.args : [];
14
+ const env = entry.env;
15
+ const envRecord = env && typeof env === "object" ? env : undefined;
16
+ return entry.type === "stdio"
17
+ && entry.command === intent.nodePath
18
+ && args.length === 1 && args[0] === intent.entryPath
19
+ && !!envRecord && Object.keys(envRecord).length === 1 && envRecord.PI_SESSION_MCP_CONFIG === intent.configPath
20
+ && Array.isArray(entry.env_vars) && entry.env_vars.length === 0
21
+ && entry.cwd === null;
22
+ }
23
+ export function createCodexAdapter(input) {
24
+ const runner = safeRunner(input.runner.run);
25
+ return {
26
+ client: "codex",
27
+ async discover() {
28
+ const versionResult = await runner.run("codex", ["--version"]);
29
+ const version = versionResult.exitCode === 0 ? versionFromOutput("codex", versionResult.stdout) : undefined;
30
+ const probe = await runner.run("codex", ["mcp", "--help"]);
31
+ const supported = probe.exitCode === 0 && ["get", "add", "remove"].every((command) => new RegExp(`\\b${command}\\b`).test(probe.stdout));
32
+ return { client: "codex", version: version ?? "unknown", supportsJson: supported, supportsAdd: supported, supportsRemove: supported, scopes: supported ? ["user"] : [] };
33
+ },
34
+ async inspect(scope, intent) {
35
+ if (scope !== "user")
36
+ return { client: "codex", scope, name: NAME, state: "unsupported", command: "unknown", hasExpectedConfig: false, supported: false };
37
+ const result = await runner.run("codex", ["mcp", "get", NAME, "--json"]);
38
+ let state = "absent";
39
+ let supported = true;
40
+ let hasExpectedConfig = false;
41
+ let command = "unknown";
42
+ if (result.exitCode === 0) {
43
+ try {
44
+ const value = JSON.parse(result.stdout);
45
+ const transport = value && typeof value === "object" && value.transport;
46
+ if (!transport || typeof transport !== "object")
47
+ throw new Error("schema");
48
+ hasExpectedConfig = equivalent(value, intent);
49
+ state = hasExpectedConfig ? "equivalent" : "divergent";
50
+ command = hasExpectedConfig ? "node" : "unknown";
51
+ }
52
+ catch {
53
+ state = "unsupported";
54
+ supported = false;
55
+ }
56
+ }
57
+ else if (/^Error: No MCP server named 'pi-session-mcp' found\.\s*$/i.test(result.stderr.trim()))
58
+ state = "absent";
59
+ else {
60
+ state = "unsupported";
61
+ supported = false;
62
+ }
63
+ return { client: "codex", scope, name: NAME, state, command, hasExpectedConfig, supported };
64
+ },
65
+ async plan(intent) { const r = await this.inspect(intent.scope, intent); return { client: "codex", scope: intent.scope, state: r.state, operation: r.state === "absent" ? "add" : "none", reason: r.state === "absent" ? "missing" : r.state === "equivalent" ? "already_registered" : r.state }; },
66
+ async add(intent, execution) { if (intent.scope !== "user")
67
+ throw new Error("unsupported client scope"); const r = await (execution?.runner ?? runner).run("codex", ["mcp", "add", NAME, "--env", `PI_SESSION_MCP_CONFIG=${intent.configPath}`, "--", intent.nodePath, intent.entryPath], execution?.signal); if (r.exitCode !== 0)
68
+ throw new Error("client registration failed"); },
69
+ async remove(scope, execution) { if (scope !== "user")
70
+ throw new Error("unsupported client scope"); const r = await (execution?.runner ?? runner).run("codex", ["mcp", "remove", NAME], execution?.signal); if (r.exitCode !== 0)
71
+ throw new Error("client removal failed"); },
72
+ };
73
+ }
@@ -0,0 +1,12 @@
1
+ import type { CommandResult, CommandRunner } from "./types.js";
2
+ export declare const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
3
+ export declare const MAX_OUTPUT_BYTES = 32768;
4
+ /**
5
+ * Clients are accepted by contract, not by version: an adapter supports a
6
+ * client whose public `mcp` command output parses as expected and fails closed
7
+ * on any other output. The version is reported for information only.
8
+ */
9
+ export type ClientName = "codex" | "claude-code";
10
+ export declare function boundedResult(result: CommandResult): CommandResult;
11
+ export declare function versionFromOutput(client: ClientName, output: string): string | undefined;
12
+ export declare function safeRunner(run: CommandRunner["run"]): CommandRunner;
@@ -0,0 +1,25 @@
1
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
2
+ export const MAX_OUTPUT_BYTES = 32_768;
3
+ export function boundedResult(result) {
4
+ const truncate = (value) => {
5
+ const bytes = Buffer.from(value, "utf8");
6
+ return bytes.byteLength <= MAX_OUTPUT_BYTES ? value : bytes.subarray(0, MAX_OUTPUT_BYTES).toString("utf8");
7
+ };
8
+ return {
9
+ exitCode: result.exitCode,
10
+ stdout: truncate(result.stdout),
11
+ stderr: truncate(result.stderr),
12
+ };
13
+ }
14
+ export function versionFromOutput(client, output) {
15
+ // Each CLI has its own complete public line. Keep the boundary narrow: `\\s`
16
+ // would accept arbitrary additional lines and trailing diagnostic text.
17
+ const pattern = client === "codex"
18
+ ? /^[ \t]*codex(?:-cli)?[ \t]+v?(\d+\.\d+\.\d+)[ \t]*(?:\r?\n)?$/
19
+ : /^[ \t]*(?:(?:claude(?:-code)?)[ \t]+)?v?(\d+\.\d+\.\d+)(?:[ \t]+\(Claude Code\))?[ \t]*(?:\r?\n)?$/;
20
+ const match = pattern.exec(output);
21
+ return match?.[1];
22
+ }
23
+ export function safeRunner(run) {
24
+ return { run: async (command, args, signal) => boundedResult(await run(command, args, signal)) };
25
+ }
@@ -0,0 +1,4 @@
1
+ export type { ClientAdapter, ClientCapabilities, ClientName, ClientRegistration, ClientScope, CommandResult, CommandRunner, RegistrationIntent, RegistrationPlan, RegistrationState } from "./types.js";
2
+ export { createCodexAdapter } from "./codex.js";
3
+ export { createClaudeCodeAdapter } from "./claude-code.js";
4
+ export { verifyMcpCapabilities, type McpVerifierOptions, type McpVerifierResult, type McpStdioProcess } from "./mcp-verifier.js";
@@ -0,0 +1,3 @@
1
+ export { createCodexAdapter } from "./codex.js";
2
+ export { createClaudeCodeAdapter } from "./claude-code.js";
3
+ export { verifyMcpCapabilities } from "./mcp-verifier.js";
@@ -0,0 +1,20 @@
1
+ export declare const MCP_VERIFY_TIMEOUT_MS = 5000;
2
+ export declare const MCP_MAX_FRAME_BYTES = 65536;
3
+ export interface McpStdioProcess {
4
+ write(frame: string): void | Promise<void>;
5
+ read(): Promise<string | null>;
6
+ kill(): void | Promise<void>;
7
+ }
8
+ export interface McpVerifierOptions {
9
+ readonly launch: (signal: AbortSignal) => Promise<McpStdioProcess>;
10
+ readonly timeoutMs?: number;
11
+ }
12
+ export interface McpVerifierResult {
13
+ readonly status: "verified" | "unsupported" | "timeout" | "failed";
14
+ readonly initialize: boolean;
15
+ readonly toolsList: boolean;
16
+ readonly capabilitiesGet: boolean;
17
+ readonly tools: readonly string[];
18
+ }
19
+ /** Performs only initialize, tools/list, and pi_capabilities_get({}). */
20
+ export declare function verifyMcpCapabilities(options: McpVerifierOptions): Promise<McpVerifierResult>;
@@ -0,0 +1,174 @@
1
+ import { PACKAGE_NAME, PACKAGE_VERSION } from "../package-metadata.js";
2
+ export const MCP_VERIFY_TIMEOUT_MS = 5_000;
3
+ export const MCP_MAX_FRAME_BYTES = 65_536;
4
+ const EXPECTED_TOOLS = ["pi_capabilities_get", "pi_session_start", "pi_session_list", "pi_session_get", "pi_session_prompt", "pi_session_abort", "pi_session_close", "pi_turn_get"];
5
+ const EXPECTED_PROTOCOL = "2025-03-26";
6
+ const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
7
+ const FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
8
+ const RELOAD_POLICY = "restart-required";
9
+ class VerificationTimeoutError extends Error {
10
+ }
11
+ async function beforeDeadline(operation, deadline) {
12
+ const remaining = deadline - Date.now();
13
+ if (remaining <= 0)
14
+ throw new VerificationTimeoutError();
15
+ let timer;
16
+ const timeout = new Promise((_resolve, reject) => {
17
+ timer = setTimeout(() => reject(new VerificationTimeoutError()), remaining);
18
+ });
19
+ try {
20
+ return await Promise.race([operation, timeout]);
21
+ }
22
+ finally {
23
+ if (timer)
24
+ clearTimeout(timer);
25
+ }
26
+ }
27
+ function frame(value) {
28
+ const body = JSON.stringify(value);
29
+ return `${body}\n`;
30
+ }
31
+ function parseFrame(value) {
32
+ if (Buffer.byteLength(value, "utf8") > MCP_MAX_FRAME_BYTES)
33
+ return undefined;
34
+ const split = value.indexOf("\r\n\r\n");
35
+ const body = (split >= 0 ? value.slice(split + 4) : value).trim();
36
+ try {
37
+ const parsed = JSON.parse(body);
38
+ return parsed && typeof parsed === "object" ? parsed : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function parseToolNames(value) {
45
+ if (!Array.isArray(value))
46
+ return undefined;
47
+ const names = [];
48
+ for (const tool of value) {
49
+ if (!tool || typeof tool !== "object")
50
+ return undefined;
51
+ const name = tool.name;
52
+ if (typeof name !== "string" || name.length === 0 || name.length > 128 || names.includes(name))
53
+ return undefined;
54
+ names.push(name);
55
+ }
56
+ return names;
57
+ }
58
+ async function readResponse(process, id, deadline) {
59
+ while (Date.now() < deadline) {
60
+ const line = await beforeDeadline(process.read(), deadline);
61
+ if (line === null)
62
+ return undefined;
63
+ const response = parseFrame(line);
64
+ if (response?.id === id)
65
+ return response;
66
+ }
67
+ return undefined;
68
+ }
69
+ function validConfiguration(value) {
70
+ if (!value || typeof value !== "object")
71
+ return false;
72
+ const record = value;
73
+ return exactKeys(record, ["fingerprint", "reloadPolicy"])
74
+ && typeof record.fingerprint === "string" && FINGERPRINT_PATTERN.test(record.fingerprint)
75
+ && record.reloadPolicy === RELOAD_POLICY;
76
+ }
77
+ function validCapabilities(value) {
78
+ if (!value || typeof value !== "object")
79
+ return false;
80
+ const x = value;
81
+ if (x.ok !== true || !exactKeys(x, ["ok", "server", "configuration", "workspaces", "executionProfiles"]) || !x.server || typeof x.server !== "object")
82
+ return false;
83
+ const info = x.server;
84
+ if (!exactKeys(info, ["name", "version"]) || info.name !== PACKAGE_NAME || info.version !== PACKAGE_VERSION || !Array.isArray(x.workspaces) || !Array.isArray(x.executionProfiles))
85
+ return false;
86
+ if (!validConfiguration(x.configuration))
87
+ return false;
88
+ const workspaces = x.workspaces;
89
+ const profiles = x.executionProfiles;
90
+ if (!workspaces.every((workspace) => {
91
+ if (!workspace || typeof workspace !== "object")
92
+ return false;
93
+ const record = workspace;
94
+ return exactKeys(record, ["alias"]) && typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias);
95
+ }))
96
+ return false;
97
+ if (!profiles.every((profile) => {
98
+ if (!profile || typeof profile !== "object")
99
+ return false;
100
+ const record = profile;
101
+ return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel"])
102
+ && typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias)
103
+ && typeof record.default === "boolean"
104
+ && (record.permissionProfile === "read-only" || record.permissionProfile === "coding")
105
+ && typeof record.provider === "string" && record.provider.length > 0 && record.provider.length <= 256
106
+ && typeof record.model === "string" && record.model.length > 0 && record.model.length <= 256
107
+ && typeof record.thinkingLevel === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(record.thinkingLevel);
108
+ }))
109
+ return false;
110
+ const defaults = profiles.filter((profile) => profile.default === true);
111
+ if (defaults.length !== 1 || defaults[0].permissionProfile !== "read-only")
112
+ return false;
113
+ return true;
114
+ }
115
+ function exactKeys(value, expected) {
116
+ const keys = Object.keys(value).sort();
117
+ return keys.length === expected.length && [...expected].sort().every((key, index) => keys[index] === key);
118
+ }
119
+ async function performVerification(process, deadline) {
120
+ const result = { status: "failed", initialize: false, toolsList: false, capabilitiesGet: false, tools: [] };
121
+ await beforeDeadline(Promise.resolve(process.write(frame({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: EXPECTED_PROTOCOL, capabilities: {}, clientInfo: { name: "pi-session-mcp-verifier", version: PACKAGE_VERSION } } }))), deadline);
122
+ const initialized = await readResponse(process, 1, deadline);
123
+ const initializedResult = initialized?.result && typeof initialized.result === "object" ? initialized.result : undefined;
124
+ const serverInfo = initializedResult?.serverInfo;
125
+ if (!initializedResult || !serverInfo || typeof serverInfo !== "object"
126
+ || initializedResult.protocolVersion !== EXPECTED_PROTOCOL
127
+ || serverInfo.name !== PACKAGE_NAME
128
+ || serverInfo.version !== PACKAGE_VERSION)
129
+ return { ...result, status: "unsupported" };
130
+ result.initialize = true;
131
+ await beforeDeadline(Promise.resolve(process.write(frame({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }))), deadline);
132
+ await beforeDeadline(Promise.resolve(process.write(frame({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }))), deadline);
133
+ const listed = await readResponse(process, 3, deadline);
134
+ if (!listed?.result || typeof listed.result !== "object")
135
+ return { ...result, status: "unsupported" };
136
+ const tools = parseToolNames(listed.result.tools);
137
+ result.toolsList = true;
138
+ if (!tools || tools.length !== EXPECTED_TOOLS.length || EXPECTED_TOOLS.some((name) => !tools.includes(name)))
139
+ return { ...result, status: "unsupported" };
140
+ result.tools = [...EXPECTED_TOOLS];
141
+ await beforeDeadline(Promise.resolve(process.write(frame({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "pi_capabilities_get", arguments: {} } }))), deadline);
142
+ const called = await readResponse(process, 4, deadline);
143
+ const structured = called?.result && typeof called.result === "object" ? called.result.structuredContent : undefined;
144
+ if (!called?.result || !validCapabilities(structured))
145
+ return { ...result, status: "failed" };
146
+ result.capabilitiesGet = true;
147
+ return { ...result, status: "verified" };
148
+ }
149
+ /** Performs only initialize, tools/list, and pi_capabilities_get({}). */
150
+ export async function verifyMcpCapabilities(options) {
151
+ const timeout = Math.max(1, options.timeoutMs ?? MCP_VERIFY_TIMEOUT_MS);
152
+ const deadline = Date.now() + timeout;
153
+ const empty = { status: "failed", initialize: false, toolsList: false, capabilitiesGet: false, tools: [] };
154
+ const controller = new AbortController();
155
+ let process;
156
+ let outcome = empty;
157
+ try {
158
+ process = await beforeDeadline(options.launch(controller.signal), deadline);
159
+ outcome = await performVerification(process, deadline);
160
+ }
161
+ catch (error) {
162
+ outcome = { ...empty, status: error instanceof VerificationTimeoutError ? "timeout" : "failed" };
163
+ }
164
+ controller.abort();
165
+ if (process) {
166
+ try {
167
+ await beforeDeadline(Promise.resolve(process.kill()), Date.now() + Math.min(timeout, 1_000));
168
+ }
169
+ catch {
170
+ return { ...outcome, status: "failed" };
171
+ }
172
+ }
173
+ return outcome;
174
+ }