@phi-code-admin/phi-code 0.82.3 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.83.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - **Built-in MCP (Model Context Protocol) client.** phi now ships a bundled MCP
8
+ extension (`extensions/phi/mcp/`), on by default with no install: connect to any
9
+ MCP server (Supabase, Playwright, Context7, filesystem, databases, and more)
10
+ over `stdio`, `streamable-http`, or `sse`, including OAuth for remote servers.
11
+ Each server's tools are bridged into the agent as `<prefix>_<server>_<tool>`.
12
+ New commands: `/mcp`, `/mcp <name>`, `/mcp:start`, `/mcp:stop`, `/mcp:auth`.
13
+
14
+ Configure servers in `~/.phi/agent/mcp.json` (global) or
15
+ `<project>/.phi/mcp.json` (project). Vendored from the MIT-licensed
16
+ `pi-mcp-extension` by irahardianto and adapted for phi: imports resolve via
17
+ `phi-code`, config uses the phi `configDir` (`.phi`), and it ships bundled
18
+ instead of requiring `phi install`.
19
+
20
+ - Dependencies: `@modelcontextprotocol/sdk` and `zod`, used by the bundled MCP
21
+ extension.
22
+
3
23
  ## [0.82.3] - 2026-06-14
4
24
 
5
25
  ### Added
package/docs/usage.md CHANGED
@@ -272,6 +272,6 @@ pi --tools read,grep,find,ls -p "Review the code"
272
272
 
273
273
  Pi keeps the core small and pushes workflow-specific behavior into extensions, skills, prompt templates, and packages.
274
274
 
275
- It intentionally does not include built-in MCP, sub-agents, permission popups, plan mode, to-dos, or background bash. You can build or install those workflows as extensions or packages, or use external tools such as containers and tmux.
275
+ It bundles MCP support (see the MCP extension) and sub-agents. It does not include permission popups, to-dos, or background bash by default: you can build or install those workflows as extensions or packages, or use external tools such as containers and tmux.
276
276
 
277
277
  For the full rationale, read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 irahardianto
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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * MCP OAuth Callback Server
3
+ *
4
+ * Simple HTTP server that handles OAuth callbacks from the authorization server.
5
+ * Uses Node.js http module for compatibility with no external dependencies.
6
+ */
7
+
8
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
9
+ import { URL } from "url";
10
+
11
+ interface PendingAuth {
12
+ resolve: (code: string) => void;
13
+ reject: (error: Error) => void;
14
+ timeout: ReturnType<typeof setTimeout>;
15
+ }
16
+
17
+ let server: Server | null = null;
18
+ let actualServerPort: number | null = null;
19
+ const pendingAuths = new Map<string, PendingAuth>();
20
+
21
+ const DEFAULT_PORT = 19876;
22
+ const CALLBACK_PATH = "/callback";
23
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
24
+
25
+ const HTML_SUCCESS = `<!DOCTYPE html>
26
+ <html><head><title>Pi - Authorization Successful</title>
27
+ <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
28
+ .container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
29
+ </head><body>
30
+ <div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
31
+ <script>setTimeout(()=>window.close(),2000)</script>
32
+ </body></html>`;
33
+
34
+ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
35
+ <html><head><title>Pi - Authorization Failed</title>
36
+ <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
37
+ .container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
38
+ .error{color:#fca5a5;font-family:monospace;margin-top:1rem;padding:1rem;background:rgba(248,113,113,0.1);border-radius:.5rem;white-space:pre-wrap;word-break:break-word}</style>
39
+ </head><body>
40
+ <div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
41
+ </body></html>`;
42
+
43
+ /**
44
+ * Escape HTML entities to prevent XSS attacks.
45
+ */
46
+ function escapeHtml(text: string): string {
47
+ return text
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;")
52
+ .replace(/'/g, "&#039;");
53
+ }
54
+
55
+ /**
56
+ * Handle incoming HTTP requests to the callback server.
57
+ */
58
+ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
59
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
60
+
61
+ // Only handle the callback path
62
+ if (url.pathname !== CALLBACK_PATH) {
63
+ res.writeHead(404, { "Content-Type": "text/plain" });
64
+ res.end("Not found");
65
+ return;
66
+ }
67
+
68
+ const code = url.searchParams.get("code");
69
+ const state = url.searchParams.get("state");
70
+ const error = url.searchParams.get("error");
71
+ const errorDescription = url.searchParams.get("error_description");
72
+
73
+ // Enforce state parameter presence for CSRF protection
74
+ if (!state) {
75
+ const errorMsg = "Missing required state parameter - potential CSRF attack";
76
+ res.writeHead(400, { "Content-Type": "text/html" });
77
+ res.end(HTML_ERROR(errorMsg));
78
+ return;
79
+ }
80
+
81
+ // Handle OAuth errors
82
+ if (error) {
83
+ const errorMsg = errorDescription || error;
84
+ res.writeHead(200, { "Content-Type": "text/html" });
85
+ res.end(HTML_ERROR(errorMsg));
86
+ if (pendingAuths.has(state)) {
87
+ const pending = pendingAuths.get(state)!;
88
+ clearTimeout(pending.timeout);
89
+ pendingAuths.delete(state);
90
+ setTimeout(() => pending.reject(new Error(errorMsg)), 0);
91
+ }
92
+ return;
93
+ }
94
+
95
+ // Require authorization code
96
+ if (!code) {
97
+ res.writeHead(400, { "Content-Type": "text/html" });
98
+ res.end(HTML_ERROR("No authorization code provided"));
99
+ return;
100
+ }
101
+
102
+ // Validate state parameter
103
+ if (!pendingAuths.has(state)) {
104
+ const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
105
+ res.writeHead(400, { "Content-Type": "text/html" });
106
+ res.end(HTML_ERROR(errorMsg));
107
+ return;
108
+ }
109
+
110
+ const pending = pendingAuths.get(state)!;
111
+
112
+ // Clear timeout and resolve the pending promise
113
+ clearTimeout(pending.timeout);
114
+ pendingAuths.delete(state);
115
+ pending.resolve(code);
116
+
117
+ res.writeHead(200, { "Content-Type": "text/html" });
118
+ res.end(HTML_SUCCESS);
119
+ }
120
+
121
+ /**
122
+ * Ensure the callback server is running.
123
+ * Scans forward for an available local port if the preferred port is busy.
124
+ *
125
+ * @param preferredPort - The preferred port to use (default: 19876)
126
+ * @returns The actual port the server is listening on
127
+ */
128
+ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
129
+ if (server) {
130
+ // Already running, return the tracked actual port
131
+ if (actualServerPort !== null) {
132
+ return actualServerPort;
133
+ }
134
+ // Fallback: try to get the port from the server
135
+ const address = server.address();
136
+ if (address && typeof address === "object" && "port" in address) {
137
+ actualServerPort = address.port;
138
+ return address.port;
139
+ }
140
+ // If we still can't determine the port, return the preferred port
141
+ // (this shouldn't happen in practice)
142
+ return preferredPort;
143
+ }
144
+
145
+ const maxAttempts = 25; // Try up to 25 ports
146
+
147
+ for (let offset = 0; offset < maxAttempts; offset++) {
148
+ const candidatePort = preferredPort + offset;
149
+ const candidateServer = createServer(handleRequest);
150
+
151
+ try {
152
+ await new Promise<void>((resolve, reject) => {
153
+ candidateServer.once("error", (err: any) => {
154
+ reject(err);
155
+ });
156
+
157
+ // Bind to 127.0.0.1 explicitly (IPv4) to avoid issues with IPv6
158
+ candidateServer.listen(candidatePort, "127.0.0.1", () => {
159
+ resolve();
160
+ });
161
+ });
162
+
163
+ server = candidateServer;
164
+ actualServerPort = candidatePort;
165
+ server.unref(); // Don't block process exit
166
+ return candidatePort;
167
+ } catch (error) {
168
+ const nodeError = error as NodeJS.ErrnoException;
169
+ await new Promise<void>((resolve) => {
170
+ candidateServer.close(() => resolve());
171
+ });
172
+
173
+ // If not EADDRINUSE, rethrow
174
+ if (nodeError.code !== "EADDRINUSE") {
175
+ throw error;
176
+ }
177
+ }
178
+ }
179
+
180
+ throw new Error(
181
+ `OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Wait for a callback with the given OAuth state.
187
+ * Returns a promise that resolves with the authorization code.
188
+ *
189
+ * @param oauthState - The OAuth state parameter to wait for
190
+ * @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
191
+ * @returns Promise that resolves with the authorization code
192
+ */
193
+ export function waitForCallback(
194
+ oauthState: string,
195
+ timeoutMs: number = TIMEOUT_MS
196
+ ): Promise<string> {
197
+ return new Promise((resolve, reject) => {
198
+ const timeout = setTimeout(() => {
199
+ if (pendingAuths.has(oauthState)) {
200
+ pendingAuths.delete(oauthState);
201
+ reject(new Error("OAuth callback timeout - authorization took too long"));
202
+ }
203
+ }, timeoutMs);
204
+
205
+ pendingAuths.set(oauthState, { resolve, reject, timeout });
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Cancel a pending authorization by state.
211
+ *
212
+ * @param oauthState - The OAuth state to cancel
213
+ */
214
+ export function cancelCallback(oauthState: string): void {
215
+ const pending = pendingAuths.get(oauthState);
216
+ if (pending) {
217
+ clearTimeout(pending.timeout);
218
+ pendingAuths.delete(oauthState);
219
+ pending.reject(new Error("Authorization cancelled"));
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Stop the callback server and reject all pending authorizations.
225
+ */
226
+ export async function stopCallbackServer(): Promise<void> {
227
+ if (server) {
228
+ await new Promise<void>((resolve) => {
229
+ server!.close(() => {
230
+ resolve();
231
+ });
232
+ });
233
+ server = null;
234
+ actualServerPort = null;
235
+ }
236
+
237
+ // Reject all pending auths (defer to allow any pending operations to complete)
238
+ const pendingList = Array.from(pendingAuths.entries());
239
+ pendingAuths.clear();
240
+ setTimeout(() => {
241
+ for (const [, pending] of pendingList) {
242
+ clearTimeout(pending.timeout);
243
+ pending.reject(new Error("OAuth callback server stopped"));
244
+ }
245
+ }, 0);
246
+ }
247
+
248
+ /**
249
+ * Check if the callback server is running.
250
+ */
251
+ export function isCallbackServerRunning(): boolean {
252
+ return server !== null;
253
+ }
254
+
255
+ /**
256
+ * Get the number of pending authorizations.
257
+ */
258
+ export function getPendingAuthCount(): number {
259
+ return pendingAuths.size;
260
+ }
261
+
262
+ // Export constants for testing/config
263
+ export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Configuration loading, validation, and merging for pi-mcp.
3
+ *
4
+ * Config file locations (Pi-native convention, highest priority first):
5
+ * 1. <cwd>/.phi/mcp.json — project-level config
6
+ * 2. ~/.phi/agent/mcp.json — global config
7
+ *
8
+ * Project servers/settings override global servers/settings per-key (shallow merge).
9
+ * No deep merge, no env var interpolation — WYSIWYG config.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { getAgentDir } from "phi-code";
15
+ import { z } from "zod";
16
+ import { McpError } from "./errors.js";
17
+
18
+ // ─── Zod Schemas ──────────────────────────────────────────────────────────────
19
+
20
+ const AuthConfigSchema = z.object({
21
+ /** Auth type. Currently only "oauth" is supported. Default: "oauth". */
22
+ type: z.enum(["oauth"]).default("oauth"),
23
+ /**
24
+ * Callback URL for the OAuth redirect.
25
+ * Default: auto-detected local callback server.
26
+ */
27
+ redirectUrl: z.string().optional(),
28
+ /**
29
+ * Optional scope to request during authorization.
30
+ */
31
+ scope: z.string().optional(),
32
+ /**
33
+ * Pre-registered client_id (skip dynamic client registration).
34
+ */
35
+ clientId: z.string().optional(),
36
+ /**
37
+ * Pre-registered client_secret.
38
+ */
39
+ clientSecret: z.string().optional(),
40
+ });
41
+
42
+ const ServerConfigSchema = z
43
+ .object({
44
+ /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
45
+ command: z.string().optional(),
46
+ /** Arguments passed to command. */
47
+ args: z.array(z.string()).default([]),
48
+ /**
49
+ * Extra environment variables passed to the child process as literals.
50
+ * These merge with process.env; project env overrides parent env.
51
+ * No ${VAR} interpolation — set vars in your shell environment instead.
52
+ */
53
+ env: z.record(z.string()).optional(),
54
+ /** Transport protocol. Default: "stdio". */
55
+ transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
56
+ /**
57
+ * URL for streamable-http or sse transports.
58
+ * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
59
+ */
60
+ url: z.string().url().optional(),
61
+ /**
62
+ * Static HTTP headers to include with every request (streamable-http / sse only).
63
+ * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
64
+ * For OAuth2, use the "auth" field instead.
65
+ */
66
+ headers: z.record(z.string()).optional(),
67
+ /**
68
+ * OAuth2 configuration for servers that require authorization.
69
+ * When set, the transport will use the SDK's OAuth flow (discovery,
70
+ * dynamic client registration, PKCE, token refresh).
71
+ * Only applies to streamable-http and sse transports.
72
+ */
73
+ auth: AuthConfigSchema.optional(),
74
+ /**
75
+ * "eager" — start at session_start.
76
+ * "lazy" — start manually via /mcp:start command.
77
+ */
78
+ lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
79
+ /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
80
+ requestTimeoutMs: z.number().positive().optional(),
81
+ /**
82
+ * Opt-in heartbeat interval (ping) in ms.
83
+ * Only useful for long-lived connections where you want proactive liveness checks.
84
+ * Default: disabled.
85
+ */
86
+ healthCheckIntervalMs: z.number().positive().optional(),
87
+ })
88
+ .refine(
89
+ (cfg) => {
90
+ if (cfg.transport === "stdio") return cfg.command !== undefined;
91
+ return cfg.url !== undefined;
92
+ },
93
+ (cfg) => ({
94
+ message:
95
+ cfg.transport === "stdio"
96
+ ? `"command" is required for stdio transport`
97
+ : `"url" is required for ${cfg.transport} transport`,
98
+ }),
99
+ );
100
+
101
+ const SettingsSchema = z.object({
102
+ /**
103
+ * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
104
+ * Must match [a-zA-Z0-9_]. Default: "mcp".
105
+ */
106
+ toolPrefix: z
107
+ .string()
108
+ .regex(/^[a-zA-Z0-9_]+$/, "toolPrefix must match [a-zA-Z0-9_]")
109
+ .default("mcp"),
110
+ /** Default per-request timeout in ms for all servers. Default: 30000. */
111
+ requestTimeoutMs: z.number().positive().default(30000),
112
+ /** Maximum retry attempts when a server fails to connect. Default: 5. */
113
+ maxRetries: z.number().int().min(0).max(10).default(5),
114
+ });
115
+
116
+ const McpConfigSchema = z.object({
117
+ settings: SettingsSchema.default({}),
118
+ mcpServers: z.record(ServerConfigSchema).default({}),
119
+ });
120
+
121
+ // ─── Public Types ─────────────────────────────────────────────────────────────
122
+
123
+ export type AuthConfig = z.output<typeof AuthConfigSchema>;
124
+ export type ServerConfig = z.output<typeof ServerConfigSchema>;
125
+ export type Settings = z.output<typeof SettingsSchema>;
126
+ export type McpConfig = z.output<typeof McpConfigSchema>;
127
+
128
+ // ─── Loader ───────────────────────────────────────────────────────────────────
129
+
130
+ async function readJsonFile(path: string): Promise<unknown | null> {
131
+ try {
132
+ const text = await readFile(path, "utf8");
133
+ return JSON.parse(text) as unknown;
134
+ } catch (err) {
135
+ // ENOENT → file doesn't exist, silently skip
136
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ function parseConfig(raw: unknown, sourcePath: string): McpConfig {
142
+ const result = McpConfigSchema.safeParse(raw);
143
+ if (!result.success) {
144
+ const issues = result.error.issues
145
+ .map((i) => ` ${i.path.join(".")}: ${i.message}`)
146
+ .join("\n");
147
+ throw new McpError(
148
+ `Invalid mcp.json at ${sourcePath}:\n${issues}`,
149
+ "<config>",
150
+ "config",
151
+ );
152
+ }
153
+ return result.data;
154
+ }
155
+
156
+ function mergeConfigs(
157
+ globalCfg: McpConfig,
158
+ projectCfg: McpConfig,
159
+ ): McpConfig {
160
+ return {
161
+ // Shallow spread: project settings override global settings per key
162
+ settings: { ...globalCfg.settings, ...projectCfg.settings },
163
+ // Per-server override: project server entry completely replaces global entry with same name
164
+ mcpServers: { ...globalCfg.mcpServers, ...projectCfg.mcpServers },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Load and merge global (~/.phi/agent/mcp.json) and project (<cwd>/.phi/mcp.json) configs.
170
+ * Project config takes precedence over global config.
171
+ * Returns a fully validated, merged config.
172
+ */
173
+ export async function loadConfig(cwd: string): Promise<McpConfig> {
174
+ const globalPath = join(getAgentDir(), "mcp.json");
175
+ const projectPath = join(cwd, ".phi", "mcp.json");
176
+
177
+ const [globalRaw, projectRaw] = await Promise.all([
178
+ readJsonFile(globalPath),
179
+ readJsonFile(projectPath),
180
+ ]);
181
+
182
+ // If neither file exists, return an empty valid config
183
+ if (globalRaw === null && projectRaw === null) {
184
+ return McpConfigSchema.parse({});
185
+ }
186
+
187
+ const globalCfg = globalRaw !== null
188
+ ? parseConfig(globalRaw, globalPath)
189
+ : McpConfigSchema.parse({});
190
+
191
+ if (projectRaw === null) return globalCfg;
192
+
193
+ const projectCfg = parseConfig(projectRaw, projectPath);
194
+ return mergeConfigs(globalCfg, projectCfg);
195
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Single error class for all MCP-related failures.
3
+ * `code` field enables programmatic discrimination without needing
4
+ * instanceof checks against a hierarchy of classes.
5
+ */
6
+
7
+ export type McpErrorCode =
8
+ | "config" // Configuration loading or validation failed
9
+ | "connection" // Transport/connection failed
10
+ | "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
11
+ | "tool"; // Tool execution error (isError: true from server)
12
+
13
+ export class McpError extends Error {
14
+ public readonly server: string;
15
+ public readonly code: McpErrorCode;
16
+ public readonly cause: unknown;
17
+
18
+ constructor(
19
+ message: string,
20
+ server: string,
21
+ code: McpErrorCode,
22
+ cause?: unknown,
23
+ ) {
24
+ super(message);
25
+ this.name = "McpError";
26
+ this.server = server;
27
+ this.code = code;
28
+ this.cause = cause;
29
+ }
30
+
31
+ /** Short user-facing message suitable for ctx.ui.notify() */
32
+ get userMessage(): string {
33
+ return `[${this.server}] ${this.message}`;
34
+ }
35
+ }