pi-mcp-extension 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,21 +151,37 @@ In project-level `.pi/mcp.json`, `settings` keys carry no defaults — only the
151
151
 
152
152
  ### Per-Server Config
153
153
 
154
- For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, and `timeout` (in seconds) as an alias for `requestTimeoutMs`.
154
+ For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, `timeout` (in seconds) as an alias for `requestTimeoutMs`, and `enabled: false` as a request to keep the server out of the eager set (`lifecycle: "lazy"`). Pi's `auth.provider` (`/login` provider token) is **rejected** at load time instead of being silently downgraded to OAuth 2.1 — use `auth` (OAuth) or static `headers`.
155
155
 
156
156
  | Field | Type | Default | Description |
157
157
  |---|---|---|---|
158
158
  | `transport` (or `type`) | `"stdio" \| "streamable-http" \| "http" \| "sse"` | `"stdio"` (`"streamable-http"` when `url` is set without `command`) | Transport protocol (`"http"` is normalized to `"streamable-http"`) |
159
159
  | `command` | `string` | — | Executable to spawn (**required** for `stdio`) |
160
160
  | `args` | `string[]` | `[]` | Arguments for the command |
161
+ | `cwd` | `string` | session working directory | Working directory for a `stdio` server (rejected on `streamable-http` / `sse`). A relative path resolves against the session working directory and a leading `~` expands to your home directory |
161
162
  | `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
162
163
  | `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
163
164
  | `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
164
165
  | `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
165
166
  | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` (`"eager"` for `pi.registerMcpServer()` unless `enabled: false`) | `eager` = auto-start on `session_start`, `lazy` = connect on request via `mcp_connect`, `/mcp:start`, or `/mcp:auth` |
167
+ | `enabled` | `boolean` | `true` | Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to `lifecycle: "lazy"`, even when `lifecycle: "eager"` is also set) |
166
168
  | `requestTimeoutMs` (or `timeout`) | `number` | global setting | Per-server timeout override in ms (`timeout` is specified in seconds and multiplied by `1000`) |
167
169
  | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval in ms for proactive connection health monitoring |
168
170
 
171
+ ### Project Override Entries
172
+
173
+ A trusted project's `.pi/mcp.json` may carry a Pi 1.3.0 **partial override** for a global server: an entry with no `command`, `url`, `type`, or `transport` changes only per-project settings and keeps the rest of the global entry, including credentials the project cannot set itself.
174
+
175
+ ```jsonc
176
+ // ~/.pi/agent/mcp.json
177
+ { "mcpServers": { "internal-tools": { "command": "internal-mcp", "lifecycle": "eager" } } }
178
+
179
+ // <project>/.pi/mcp.json — turn it off for this project only
180
+ { "mcpServers": { "internal-tools": { "enabled": false } } }
181
+ ```
182
+
183
+ An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure` (the two Pi-native exposure keys are accepted for Pi compatibility and ignored, because this extension decides tool activation itself). Any other key, or an override for a name no global entry defines, is reported as a config error naming the file and the server.
184
+
169
185
  ### OAuth Config
170
186
 
171
187
  Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
@@ -176,6 +192,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
176
192
  | `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
177
193
  | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
178
194
  | `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
195
+ | `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
179
196
  | `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
180
197
 
181
198
  ## Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/config.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * $PI_CODING_AGENT_DIR when set (Pi profiles / alternative agent dirs), else ~/.pi/agent
8
8
  *
9
9
  * Project servers/settings override global servers/settings per-key (shallow merge).
10
- * No deep merge, no env var interpolation — WYSIWYG config.
10
+ * A Pi 1.3.0 partial project entry (`{ "enabled": false }` — no `command`/`url`) overrides only the
11
+ * per-project keys of the global server of that name, keeping the global entry's credentials.
12
+ * No deep merge of any other keys, no env var interpolation — WYSIWYG config.
11
13
  */
12
14
 
13
15
  import { readFile } from "node:fs/promises";
@@ -58,8 +60,27 @@ const AuthConfigSchema = z.preprocess(
58
60
  * Pre-registered client_secret.
59
61
  */
60
62
  clientSecret: z.string().optional(),
63
+ /**
64
+ * `client_name` sent with Dynamic Client Registration (Pi 1.3.0 `oauth.clientName`).
65
+ * Default: `pi-mcp/<server name>`.
66
+ */
67
+ clientName: z.string().trim().min(1).optional(),
68
+ /**
69
+ * Pi's `/login` provider auth (`auth.provider`), accepted only so it can be rejected with a
70
+ * clear error: this extension runs OAuth 2.1 and cannot forward a Pi provider token.
71
+ */
72
+ provider: z.unknown().optional(),
61
73
  })
62
74
  .superRefine((auth, ctx) => {
75
+ if (auth.provider !== undefined) {
76
+ ctx.addIssue({
77
+ code: "custom",
78
+ path: ["provider"],
79
+ message:
80
+ '"auth.provider" sends a Pi /login provider token, which pi-mcp-extension does not support; ' +
81
+ 'remove it to use OAuth 2.1, or send a static credential with "headers"',
82
+ });
83
+ }
63
84
  if (auth.redirectUrl !== undefined) {
64
85
  try {
65
86
  callbackServerConfigFromRedirectUrl(auth.redirectUrl);
@@ -104,6 +125,17 @@ function normalizeAuthAlias(obj: Record<string, unknown>): void {
104
125
  }
105
126
  }
106
127
 
128
+ /**
129
+ * Map Pi 1.3.0 `enabled: false` onto `lifecycle: "lazy"`. Pi keeps a disabled entry configured but
130
+ * out of the connect set; the closest behavior here is the lazy lifecycle (manual `/mcp:start`).
131
+ * An explicit `lifecycle: "eager"` never wins over `enabled: false`, which is what Pi does too.
132
+ */
133
+ function normalizeEnabledAlias(obj: Record<string, unknown>): void {
134
+ if (obj.enabled === false) {
135
+ obj.lifecycle = "lazy";
136
+ }
137
+ }
138
+
107
139
  /** Map legacy `timeout` (seconds) onto `requestTimeoutMs` (milliseconds). */
108
140
  function normalizeTimeoutAlias(obj: Record<string, unknown>): void {
109
141
  if (obj.requestTimeoutMs !== undefined) {
@@ -115,13 +147,14 @@ function normalizeTimeoutAlias(obj: Record<string, unknown>): void {
115
147
  obj.requestTimeoutMs = obj.timeout * 1000;
116
148
  }
117
149
 
118
- /** Rewrite Pi 1.x config aliases (`type`, `oauth`, `timeout`) before schema validation. */
150
+ /** Rewrite Pi 1.x config aliases (`type`, `oauth`, `timeout`, `enabled`) before schema validation. */
119
151
  function normalizeServerConfigAliases(raw: unknown): unknown {
120
152
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
121
153
  const obj = { ...(raw as Record<string, unknown>) };
122
154
  normalizeTransportAlias(obj);
123
155
  normalizeAuthAlias(obj);
124
156
  normalizeTimeoutAlias(obj);
157
+ normalizeEnabledAlias(obj);
125
158
  return obj;
126
159
  }
127
160
 
@@ -133,6 +166,11 @@ export const ServerConfigSchema = z.preprocess(
133
166
  command: z.string().optional(),
134
167
  /** Arguments passed to command. */
135
168
  args: z.array(z.string()).default([]),
169
+ /**
170
+ * Working directory for a stdio server (Pi 1.3.0 `cwd`). A leading `~` expands to the home
171
+ * directory and a relative path resolves against the session working directory.
172
+ */
173
+ cwd: z.string().min(1).optional(),
136
174
  /**
137
175
  * Extra environment variables passed to the child process as literals.
138
176
  * These merge with process.env; project env overrides parent env.
@@ -164,6 +202,11 @@ export const ServerConfigSchema = z.preprocess(
164
202
  * "lazy" — start manually via /mcp:start command.
165
203
  */
166
204
  lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
205
+ /**
206
+ * Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to
207
+ * `lifecycle: "lazy"`). Default: `true`.
208
+ */
209
+ enabled: z.boolean().optional(),
167
210
  /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
168
211
  requestTimeoutMs: z.number().positive().optional(),
169
212
  /**
@@ -188,11 +231,20 @@ export const ServerConfigSchema = z.preprocess(
188
231
  message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
189
232
  });
190
233
  }
191
- } else if (cfg.url === undefined) {
192
- ctx.addIssue({
193
- code: "custom",
194
- message: `"url" is required for ${cfg.transport} transport`,
195
- });
234
+ } else {
235
+ if (cfg.url === undefined) {
236
+ ctx.addIssue({
237
+ code: "custom",
238
+ message: `"url" is required for ${cfg.transport} transport`,
239
+ });
240
+ }
241
+ if (cfg.cwd !== undefined) {
242
+ ctx.addIssue({
243
+ code: "custom",
244
+ path: ["cwd"],
245
+ message: `"cwd" is only supported for 'stdio' transport, not '${cfg.transport}'`,
246
+ });
247
+ }
196
248
  }
197
249
  }),
198
250
  );
@@ -242,6 +294,91 @@ const ProjectConfigSchema = z.object({
242
294
  });
243
295
  type ProjectConfig = z.output<typeof ProjectConfigSchema>;
244
296
 
297
+ // ─── Pi 1.3.0 project override entries ────────────────────────────────────────
298
+
299
+ /**
300
+ * Keys a Pi 1.3.0 project entry may override on the global entry of the same name (`/mcp` writes
301
+ * such an entry as soon as a global server is disabled or its exposure is changed for one project).
302
+ * `exposure` / `toolExposure` are Pi-native tool-exposure settings: they are accepted so a shared
303
+ * `mcp.json` loads, and ignored because this extension decides tool activation itself. `lifecycle`
304
+ * is this extension's equivalent of `enabled`.
305
+ */
306
+ const PROJECT_OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure", "lifecycle"] as const;
307
+
308
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
309
+ return typeof value === "object" && value !== null && !Array.isArray(value);
310
+ }
311
+
312
+ /** A project entry without a transport definition only overrides the global server of that name. */
313
+ function isProjectOverrideEntry(value: unknown): value is Record<string, unknown> {
314
+ if (!isPlainObject(value)) return false;
315
+ return (
316
+ value.command === undefined &&
317
+ value.url === undefined &&
318
+ value.type === undefined &&
319
+ value.transport === undefined
320
+ );
321
+ }
322
+
323
+ function invalidProjectConfig(projectPath: string, detail: string): McpError {
324
+ return new McpError(`Invalid mcp.json at ${projectPath}:\n ${detail}`, "<config>", "config");
325
+ }
326
+
327
+ /** Merge a partial project override onto the raw global entry, keeping the global credentials. */
328
+ function mergeProjectOverride(
329
+ name: string,
330
+ override: Record<string, unknown>,
331
+ globalServers: Record<string, unknown>,
332
+ projectPath: string,
333
+ ): Record<string, unknown> {
334
+ const base = globalServers[name];
335
+ if (!isPlainObject(base)) {
336
+ throw invalidProjectConfig(
337
+ projectPath,
338
+ `mcpServers.${name}: a project override needs "command" or "url" in this file, or a global server of that name`,
339
+ );
340
+ }
341
+ const unsupported = Object.keys(override).filter(
342
+ (key) => !(PROJECT_OVERRIDE_KEYS as readonly string[]).includes(key),
343
+ );
344
+ if (unsupported.length > 0) {
345
+ throw invalidProjectConfig(
346
+ projectPath,
347
+ `mcpServers.${name}: a project override can only set ${PROJECT_OVERRIDE_KEYS.join(", ")} (found ${unsupported.join(", ")})`,
348
+ );
349
+ }
350
+ const merged: Record<string, unknown> = { ...base };
351
+ if (override.lifecycle !== undefined && override.enabled === undefined) {
352
+ delete merged.enabled;
353
+ }
354
+ for (const key of PROJECT_OVERRIDE_KEYS) {
355
+ if (override[key] !== undefined) {
356
+ merged[key] = override[key];
357
+ }
358
+ }
359
+ return merged;
360
+ }
361
+
362
+ /**
363
+ * Resolve Pi 1.3.0 partial project entries (`{ "<server>": { "enabled": false } }`) against the raw
364
+ * global file *before* validation, so `ProjectConfigSchema` sees a complete entry instead of
365
+ * failing with `"command" is required for stdio transport` (which tore down every global server on
366
+ * `session_start`).
367
+ */
368
+ function applyProjectOverrides(projectRaw: unknown, globalRaw: unknown, projectPath: string): unknown {
369
+ if (!isPlainObject(projectRaw) || !isPlainObject(projectRaw.mcpServers)) return projectRaw;
370
+ const globalServers = isPlainObject(globalRaw) && isPlainObject(globalRaw.mcpServers)
371
+ ? globalRaw.mcpServers
372
+ : {};
373
+ const servers: Record<string, unknown> = {};
374
+ for (const [name, value] of Object.entries(projectRaw.mcpServers)) {
375
+ servers[name] = isProjectOverrideEntry(value)
376
+ ? mergeProjectOverride(name, value, globalServers, projectPath)
377
+ : value;
378
+ }
379
+ return { ...projectRaw, mcpServers: servers };
380
+ }
381
+
245
382
  // ─── Public Types ─────────────────────────────────────────────────────────────
246
383
 
247
384
  export type AuthConfig = z.output<typeof AuthConfigSchema>;
@@ -425,7 +562,11 @@ export async function loadConfigWithOrigins(
425
562
 
426
563
  const projectCfg = projectRaw === undefined
427
564
  ? undefined
428
- : parseWith(ProjectConfigSchema, projectRaw, projectConfigPath);
565
+ : parseWith(
566
+ ProjectConfigSchema,
567
+ applyProjectOverrides(projectRaw, globalRaw, projectConfigPath),
568
+ projectConfigPath,
569
+ );
429
570
 
430
571
  return {
431
572
  config: projectCfg === undefined ? globalCfg : mergeConfigs(globalCfg, projectCfg),
@@ -57,6 +57,8 @@ export interface AuthConfig {
57
57
  * Pre-registered client_secret.
58
58
  */
59
59
  clientSecret?: string | undefined;
60
+ /** `client_name` sent with Dynamic Client Registration. Default: `pi-mcp/<server name>`. */
61
+ clientName?: string | undefined;
60
62
  }
61
63
 
62
64
  const DEFAULT_CALLBACK_PORT = 19876;
@@ -252,7 +254,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
252
254
  redirectUris = [String(this.redirectUrl)];
253
255
  }
254
256
  return {
255
- client_name: `pi-mcp/${this.serverName}`,
257
+ client_name: this.authConfig.clientName ?? `pi-mcp/${this.serverName}`,
256
258
  redirect_uris: redirectUris,
257
259
  grant_types: ["authorization_code", "refresh_token"],
258
260
  response_types: ["code"],
@@ -111,6 +111,67 @@ function hasArraySchemaKeywords(schema: Record<string, unknown>): boolean {
111
111
  return schema["items"] !== undefined || Array.isArray(schema["prefixItems"]);
112
112
  }
113
113
 
114
+ /** Whether `ref` is already on the active resolution path. */
115
+ function isRefSeenOnPath(
116
+ ref: string,
117
+ ctx: ConversionContext,
118
+ extraRefs: ReadonlySet<string>,
119
+ ): boolean {
120
+ return ctx.seenRefs.has(ref) || extraRefs.has(ref);
121
+ }
122
+
123
+ /** Whether a `$ref` on an `allOf` member resolves to an object-like schema. */
124
+ function isRefToObjectSchema(
125
+ ref: unknown,
126
+ ctx: ConversionContext,
127
+ seenRefs: Set<string>,
128
+ ): boolean {
129
+ if (typeof ref !== "string" || isRefSeenOnPath(ref, ctx, seenRefs)) {
130
+ return false;
131
+ }
132
+ seenRefs.add(ref);
133
+ const resolved = resolveRefTarget(ref, ctx.rootDefs, ctx.effectiveRoot);
134
+ return resolved ? isObjectLikeSubSchema(resolved, ctx, seenRefs) : false;
135
+ }
136
+
137
+ /** Whether a sub-schema describes an object directly, through a `$ref`, or through a nested `allOf`. */
138
+ function isObjectLikeSubSchema(
139
+ sub: Record<string, unknown>,
140
+ ctx: ConversionContext,
141
+ seenRefs: Set<string>,
142
+ ): boolean {
143
+ return (
144
+ sub["type"] === "object" ||
145
+ hasObjectSchemaKeywords(sub) ||
146
+ hasAllOfObjectSubSchema(sub, ctx, seenRefs) ||
147
+ isRefToObjectSchema(sub["$ref"], ctx, seenRefs)
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Whether `allOf` composes object sub-schemas / `$ref`s to object definitions.
153
+ * WHY: JSON Schema generators (Pydantic, FastAPI, OpenAPI 3.0) omit `type: "object"`
154
+ * on the `allOf` wrapper. Without object inference the schema becomes a bare
155
+ * `Type.Intersect`, which has no `properties` — Pi's `normalizeOptionalNulls` then
156
+ * skips it (coercing optional `null`s into `"null"` / `0` / `false`) and Anthropic's
157
+ * adapter sends an empty `input_schema`.
158
+ */
159
+ function hasAllOfObjectSubSchema(
160
+ schema: Record<string, unknown>,
161
+ ctx: ConversionContext,
162
+ seenRefs: Set<string> = new Set<string>(),
163
+ ): boolean {
164
+ const allOf = schema["allOf"];
165
+ if (!Array.isArray(allOf)) {
166
+ return false;
167
+ }
168
+ return allOf.some((sub) => isPlainObject(sub) && isObjectLikeSubSchema(sub, ctx, seenRefs));
169
+ }
170
+
171
+ function hasObjectSchemaShape(schema: Record<string, unknown>, ctx: ConversionContext): boolean {
172
+ return hasObjectSchemaKeywords(schema) || hasAllOfObjectSubSchema(schema, ctx);
173
+ }
174
+
114
175
  interface ConversionContext {
115
176
  rootDefs: Record<string, Record<string, unknown>>;
116
177
  effectiveRoot: Record<string, unknown>;
@@ -347,23 +408,34 @@ function convertArraySchema(
347
408
  );
348
409
  }
349
410
 
350
- /** Resolve an `allOf` sub-schema's `$ref` (unless it is already being resolved). */
351
- function resolveAllOfSubSchema(sub: Record<string, unknown>, ctx: ConversionContext): Record<string, unknown> {
352
- const ref = sub["$ref"];
353
- if (typeof ref !== "string" || ctx.seenRefs.has(ref)) {
354
- return sub;
355
- }
356
- return resolveRefTarget(ref, ctx.rootDefs, ctx.effectiveRoot) ?? sub;
411
+ /** A property schema paired with the `$ref` path that was dereferenced to reach it. */
412
+ interface MergedProperty {
413
+ schema: Record<string, unknown>;
414
+ seenRefs: ReadonlySet<string>;
415
+ }
416
+
417
+ /** Accumulates flattened `allOf` contributions plus the refs already walked. */
418
+ interface AllOfMerge {
419
+ properties: Record<string, MergedProperty>;
420
+ required: Set<string>;
421
+ visitedRefs: Set<string>;
357
422
  }
358
423
 
359
424
  /** Merge a sub-schema's properties without overwriting properties declared by the caller. */
360
- function mergeSubSchemaProperties(props: unknown, rawProperties: Record<string, unknown>): void {
425
+ function mergeSubSchemaProperties(
426
+ props: unknown,
427
+ mergedProperties: Record<string, MergedProperty>,
428
+ seenRefs: ReadonlySet<string>,
429
+ ): void {
361
430
  if (!isPlainObject(props)) {
362
431
  return;
363
432
  }
364
433
  for (const [key, value] of Object.entries(props)) {
365
- if (!(key in rawProperties)) {
366
- rawProperties[key] = value;
434
+ if (!(key in mergedProperties)) {
435
+ mergedProperties[key] = {
436
+ schema: value as Record<string, unknown>,
437
+ seenRefs,
438
+ };
367
439
  }
368
440
  }
369
441
  }
@@ -380,18 +452,51 @@ function mergeSubSchemaRequired(req: unknown, required: Set<string>): void {
380
452
  }
381
453
  }
382
454
 
455
+ /**
456
+ * Follow an `allOf` member's `$ref` if not yet visited, recording `ref` on the active
457
+ * resolution path for any properties inherited from the referenced definition.
458
+ * WHY: generators chain definitions (`A: { $ref: B }`) and emit recursive fields wrapped
459
+ * in `allOf: [{ $ref: "#/$defs/Node" }]`; carrying `nextActiveRefs` onto inherited
460
+ * properties ensures `convertJsonSchemaToTypebox` breaks `$ref` cycles on inlined fields.
461
+ */
462
+ function mergeAllOfRef(
463
+ ref: unknown,
464
+ merge: AllOfMerge,
465
+ ctx: ConversionContext,
466
+ activeRefs: ReadonlySet<string>,
467
+ ): void {
468
+ if (typeof ref !== "string" || isRefSeenOnPath(ref, ctx, merge.visitedRefs)) {
469
+ return;
470
+ }
471
+ merge.visitedRefs.add(ref);
472
+ const resolved = resolveRefTarget(ref, ctx.rootDefs, ctx.effectiveRoot);
473
+ if (!resolved) {
474
+ return;
475
+ }
476
+ const nextActiveRefs = new Set(activeRefs);
477
+ nextActiveRefs.add(ref);
478
+ mergeAllOfSubSchema(resolved, merge, ctx, nextActiveRefs);
479
+ }
480
+
481
+ /** Merge one `allOf` member (and every `$ref` or nested `allOf` beneath it) into the accumulator. */
383
482
  function mergeAllOfSubSchema(
384
483
  sub: unknown,
385
- rawProperties: Record<string, unknown>,
386
- required: Set<string>,
484
+ merge: AllOfMerge,
387
485
  ctx: ConversionContext,
486
+ activeRefs: ReadonlySet<string> = ctx.seenRefs,
388
487
  ): void {
389
488
  if (!isPlainObject(sub)) {
390
489
  return;
391
490
  }
392
- const resolvedSub = resolveAllOfSubSchema(sub, ctx);
393
- mergeSubSchemaProperties(resolvedSub["properties"], rawProperties);
394
- mergeSubSchemaRequired(resolvedSub["required"], required);
491
+ mergeSubSchemaProperties(sub["properties"], merge.properties, activeRefs);
492
+ mergeSubSchemaRequired(sub["required"], merge.required);
493
+ mergeAllOfRef(sub["$ref"], merge, ctx, activeRefs);
494
+ const nested = sub["allOf"];
495
+ if (Array.isArray(nested)) {
496
+ for (const nestedSub of nested) {
497
+ mergeAllOfSubSchema(nestedSub, merge, ctx, activeRefs);
498
+ }
499
+ }
395
500
  }
396
501
 
397
502
  function convertObjectSchema(
@@ -399,14 +504,16 @@ function convertObjectSchema(
399
504
  opts: Record<string, unknown>,
400
505
  ctx: ConversionContext,
401
506
  ): TSchema {
402
- const rawProperties: Record<string, unknown> = isPlainObject(schema["properties"])
403
- ? { ...schema["properties"] }
404
- : {};
405
- const required = new Set<string>(
406
- Array.isArray(schema["required"])
407
- ? (schema["required"] as unknown[]).filter((r): r is string => typeof r === "string")
408
- : [],
409
- );
507
+ const merge: AllOfMerge = {
508
+ properties: {},
509
+ required: new Set<string>(
510
+ Array.isArray(schema["required"])
511
+ ? (schema["required"] as unknown[]).filter((r): r is string => typeof r === "string")
512
+ : [],
513
+ ),
514
+ visitedRefs: new Set<string>(),
515
+ };
516
+ mergeSubSchemaProperties(schema["properties"], merge.properties, ctx.seenRefs);
410
517
  const additionalProperties = schema["additionalProperties"];
411
518
 
412
519
  let allOfSchemas: TSchema[] | undefined;
@@ -420,19 +527,19 @@ function convertObjectSchema(
420
527
  ),
421
528
  );
422
529
  for (const sub of schema["allOf"] as unknown[]) {
423
- mergeAllOfSubSchema(sub, rawProperties, required, ctx);
530
+ mergeAllOfSubSchema(sub, merge, ctx);
424
531
  }
425
532
  }
426
533
 
427
534
  const props: Record<string, TSchema> = {};
428
- for (const [key, value] of Object.entries(rawProperties)) {
535
+ for (const [key, entry] of Object.entries(merge.properties)) {
429
536
  const converted = convertJsonSchemaToTypebox(
430
- value as Record<string, unknown>,
537
+ entry.schema,
431
538
  ctx.rootDefs,
432
539
  ctx.effectiveRoot,
433
- ctx.seenRefs,
540
+ entry.seenRefs,
434
541
  );
435
- props[key] = required.has(key) ? converted : Type.Optional(converted);
542
+ props[key] = merge.required.has(key) ? converted : Type.Optional(converted);
436
543
  }
437
544
 
438
545
  const objOpts: Record<string, unknown> = { ...opts };
@@ -447,8 +554,8 @@ function convertObjectSchema(
447
554
  );
448
555
  }
449
556
 
450
- if (required.size > 0) {
451
- objOpts["required"] = Array.from(required);
557
+ if (merge.required.size > 0) {
558
+ objOpts["required"] = Array.from(merge.required);
452
559
  }
453
560
 
454
561
  if (Array.isArray(schema["oneOf"])) {
@@ -511,6 +618,23 @@ function convertTypedSchema(
511
618
  return Type.Any(opts);
512
619
  }
513
620
 
621
+ function inferSchemaType(
622
+ schema: Record<string, unknown>,
623
+ ctx: ConversionContext,
624
+ ): string | undefined {
625
+ const rawType = schema["type"];
626
+ if (typeof rawType === "string") {
627
+ return rawType;
628
+ }
629
+ if (hasObjectSchemaShape(schema, ctx)) {
630
+ return "object";
631
+ }
632
+ if (hasArraySchemaKeywords(schema)) {
633
+ return "array";
634
+ }
635
+ return undefined;
636
+ }
637
+
514
638
  function resolveNonTypedSchema(
515
639
  schema: Record<string, unknown>,
516
640
  opts: Record<string, unknown>,
@@ -524,14 +648,6 @@ function resolveNonTypedSchema(
524
648
  if (Array.isArray(rawType)) {
525
649
  return convertMultiTypeSchema(schema, rawType, opts, ctx);
526
650
  }
527
- if (
528
- !hasObjectSchemaKeywords(schema) &&
529
- !hasArraySchemaKeywords(schema) &&
530
- rawType !== "object" &&
531
- rawType !== "array"
532
- ) {
533
- return convertCombinatorSchema(schema, rawType, opts, ctx) ?? null;
534
- }
535
651
  return null;
536
652
  }
537
653
 
@@ -544,7 +660,9 @@ function resolveNonTypedSchema(
544
660
  * - Nullable and multi-type arrays ("type": ["string", "null"]) with Type.Null() ordered first
545
661
  * - Cycle-safe $ref (#, #/$defs/, #/definitions/, and arbitrary RFC 6901 JSON Pointers)
546
662
  * - oneOf / anyOf → TypeBox Union
547
- * - allOf → TypeBox Intersect
663
+ * - allOf → TypeBox Intersect; when it composes object sub-schemas or `$ref`s to
664
+ * object definitions, an object schema with merged `properties` / `required`
665
+ * (recursing through nested `allOf` and chained `$ref`s) that keeps the members
548
666
  * Falls back to Type.Any() for unresolvable $ref, recursive cycles, or missing type.
549
667
  */
550
668
  export function convertJsonSchemaToTypebox(
@@ -575,15 +693,13 @@ export function convertJsonSchemaToTypebox(
575
693
  return nonTyped;
576
694
  }
577
695
 
578
- const rawType = schema["type"];
579
- const type =
580
- typeof rawType === "string"
581
- ? rawType
582
- : hasObjectSchemaKeywords(schema)
583
- ? "object"
584
- : hasArraySchemaKeywords(schema)
585
- ? "array"
586
- : undefined;
696
+ const type = inferSchemaType(schema, ctx);
697
+ if (type !== "object" && type !== "array") {
698
+ const combinator = convertCombinatorSchema(schema, type, opts, ctx);
699
+ if (combinator) {
700
+ return combinator;
701
+ }
702
+ }
587
703
 
588
704
  return convertTypedSchema(type, schema, opts, ctx);
589
705
  }
@@ -6,6 +6,8 @@
6
6
  * SDK workarounds.
7
7
  */
8
8
 
9
+ import { homedir } from "node:os";
10
+ import { join, resolve } from "node:path";
9
11
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
12
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
13
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
@@ -67,15 +69,36 @@ function wireStdioStderr(transport: StdioClientTransport, onStderr: (line: strin
67
69
  transport.stderr.on("close", flush);
68
70
  }
69
71
 
72
+ /** Expand a leading `~` / `~/` / `~\` to the home directory, the way Pi resolves a config `cwd`. */
73
+ function expandHome(path: string): string {
74
+ if (path === "~") return homedir();
75
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
76
+ return path;
77
+ }
78
+
79
+ /**
80
+ * Resolve the working directory for a stdio server: Pi 1.3.0's per-server `cwd` (relative paths
81
+ * resolve against the session working directory, `~` expands to the home directory), else the
82
+ * session working directory unchanged.
83
+ */
84
+ export function resolveStdioCwd(
85
+ configuredCwd: string | undefined,
86
+ sessionCwd: string | undefined,
87
+ ): string | undefined {
88
+ if (configuredCwd === undefined) return sessionCwd;
89
+ return resolve(sessionCwd ?? process.cwd(), expandHome(configuredCwd));
90
+ }
91
+
70
92
  function createStdioTransport(
71
93
  serverName: string,
72
94
  config: ServerConfig,
73
- cwd: string | undefined,
95
+ sessionCwd: string | undefined,
74
96
  onStderr: ((line: string) => void) | undefined,
75
97
  ): Transport {
76
98
  if (!config.command) {
77
99
  throw new McpError("stdio transport requires 'command'", serverName, "config");
78
100
  }
101
+ const cwd = resolveStdioCwd(config.cwd, sessionCwd);
79
102
  const transport = new StdioClientTransport({
80
103
  command: config.command,
81
104
  args: config.args,