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 +18 -1
- package/package.json +1 -1
- package/src/config.ts +149 -8
- package/src/oauth-provider.ts +3 -1
- package/src/schema-convert.ts +164 -48
- package/src/transport-factory.ts +24 -1
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`,
|
|
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
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
|
-
*
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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),
|
package/src/oauth-provider.ts
CHANGED
|
@@ -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"],
|
package/src/schema-convert.ts
CHANGED
|
@@ -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
|
-
/**
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
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
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
:
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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,
|
|
530
|
+
mergeAllOfSubSchema(sub, merge, ctx);
|
|
424
531
|
}
|
|
425
532
|
}
|
|
426
533
|
|
|
427
534
|
const props: Record<string, TSchema> = {};
|
|
428
|
-
for (const [key,
|
|
535
|
+
for (const [key, entry] of Object.entries(merge.properties)) {
|
|
429
536
|
const converted = convertJsonSchemaToTypebox(
|
|
430
|
-
|
|
537
|
+
entry.schema,
|
|
431
538
|
ctx.rootDefs,
|
|
432
539
|
ctx.effectiveRoot,
|
|
433
|
-
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
}
|
package/src/transport-factory.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|