@ian-pascoe/pi-mcp 0.1.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/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/pi-mcp-cli.js +10948 -0
- package/package.json +64 -0
- package/src/index.ts +2 -0
- package/src/mcp-auth-store.ts +393 -0
- package/src/mcp-command.ts +893 -0
- package/src/mcp-content.ts +212 -0
- package/src/mcp-host.ts +971 -0
- package/src/mcp-oauth.ts +740 -0
- package/src/mcp-server-client.ts +375 -0
- package/src/mcp-session-files.ts +127 -0
- package/src/mcp-settings-store.ts +455 -0
- package/src/mcp-tool-catalog.ts +464 -0
- package/src/pi-mcp-cli.ts +507 -0
- package/src/pi-mcp-extension.ts +1013 -0
- package/src/pi-mcp-settings.ts +619 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import type { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type, type TSchema } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MCP_RETRY = {
|
|
6
|
+
backoffFactor: 1.5,
|
|
7
|
+
initialDelayMs: 1_000,
|
|
8
|
+
maxDelayMs: 30_000,
|
|
9
|
+
maxRetries: 2,
|
|
10
|
+
} as const;
|
|
11
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
12
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
/** Fixed shutdown budget for every MCP Client, in milliseconds. */
|
|
15
|
+
export const MCP_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
const JsonValueSchema = Type.Any();
|
|
18
|
+
const NonEmptyStringSchema = Type.String({ minLength: 1 });
|
|
19
|
+
const PositiveSafeIntegerSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
20
|
+
const RetryCountSchema = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
|
|
21
|
+
const BackoffFactorSchema = Type.Number({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
22
|
+
const StringMapSchema = Type.Record(Type.String(), Type.String());
|
|
23
|
+
const McpAuthWireSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
clientId: Type.Optional(Type.String()),
|
|
26
|
+
clientSecret: Type.Optional(Type.String()),
|
|
27
|
+
redirectUri: Type.Optional(Type.String()),
|
|
28
|
+
scopes: Type.Optional(Type.Array(Type.String())),
|
|
29
|
+
token: Type.Optional(Type.String()),
|
|
30
|
+
type: Type.String(),
|
|
31
|
+
},
|
|
32
|
+
{ additionalProperties: false },
|
|
33
|
+
);
|
|
34
|
+
const McpServerDefinitionWireSchema = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
args: Type.Optional(Type.Array(Type.String())),
|
|
37
|
+
auth: Type.Optional(McpAuthWireSchema),
|
|
38
|
+
command: Type.Optional(Type.String()),
|
|
39
|
+
cwd: Type.Optional(Type.String()),
|
|
40
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
41
|
+
environment: Type.Optional(StringMapSchema),
|
|
42
|
+
headers: Type.Optional(StringMapSchema),
|
|
43
|
+
transport: Type.Optional(Type.String()),
|
|
44
|
+
url: Type.Optional(Type.String()),
|
|
45
|
+
},
|
|
46
|
+
{ additionalProperties: false },
|
|
47
|
+
);
|
|
48
|
+
const McpRetryWireSchema = Type.Object(
|
|
49
|
+
{
|
|
50
|
+
backoffFactor: Type.Optional(BackoffFactorSchema),
|
|
51
|
+
initialDelayMs: Type.Optional(PositiveSafeIntegerSchema),
|
|
52
|
+
maxDelayMs: Type.Optional(PositiveSafeIntegerSchema),
|
|
53
|
+
maxRetries: Type.Optional(RetryCountSchema),
|
|
54
|
+
},
|
|
55
|
+
{ additionalProperties: false },
|
|
56
|
+
);
|
|
57
|
+
const McpLayerWireSchema = Type.Object(
|
|
58
|
+
{
|
|
59
|
+
connectTimeoutMs: Type.Optional(PositiveSafeIntegerSchema),
|
|
60
|
+
requestTimeoutMs: Type.Optional(PositiveSafeIntegerSchema),
|
|
61
|
+
retry: Type.Optional(McpRetryWireSchema),
|
|
62
|
+
servers: Type.Optional(
|
|
63
|
+
Type.Record(Type.String(), Type.Union([McpServerDefinitionWireSchema, Type.Null()])),
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{ additionalProperties: false },
|
|
67
|
+
);
|
|
68
|
+
const SettingsDocumentSchema = Type.Object({ mcp: Type.Optional(JsonValueSchema) });
|
|
69
|
+
|
|
70
|
+
/** JSON value accepted at the Pi settings boundary. */
|
|
71
|
+
export type McpSettingsJsonValue =
|
|
72
|
+
| null
|
|
73
|
+
| boolean
|
|
74
|
+
| number
|
|
75
|
+
| string
|
|
76
|
+
| readonly McpSettingsJsonValue[]
|
|
77
|
+
| { readonly [key: string]: McpSettingsJsonValue };
|
|
78
|
+
|
|
79
|
+
type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
|
|
80
|
+
type McpLayerWire = Static<typeof McpLayerWireSchema>;
|
|
81
|
+
type McpServerDefinitionWire = Static<typeof McpServerDefinitionWireSchema>;
|
|
82
|
+
type McpSettingsScope = "global" | "project";
|
|
83
|
+
|
|
84
|
+
/** Pi settings document or a test boundary document carrying the package-owned `mcp` field. */
|
|
85
|
+
export type McpSettingsDocumentInput = PiSettingsDocument | { readonly mcp?: McpSettingsJsonValue };
|
|
86
|
+
|
|
87
|
+
/** Reads Pi's already trust-filtered global and project settings layers. */
|
|
88
|
+
export interface McpSettingsReader {
|
|
89
|
+
getGlobalSettings(): McpSettingsDocumentInput;
|
|
90
|
+
getProjectSettings(): McpSettingsDocumentInput;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Host-wide retry policy shared by every MCP Server connection. */
|
|
94
|
+
export interface McpRetrySettings {
|
|
95
|
+
readonly backoffFactor: number;
|
|
96
|
+
readonly initialDelayMs: number;
|
|
97
|
+
readonly maxDelayMs: number;
|
|
98
|
+
readonly maxRetries: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Authentication behavior for one remote MCP Server. */
|
|
102
|
+
export type McpServerAuth =
|
|
103
|
+
| { readonly type: "none" }
|
|
104
|
+
| { readonly token: string; readonly type: "bearer" }
|
|
105
|
+
| {
|
|
106
|
+
readonly clientId?: string;
|
|
107
|
+
readonly clientSecret?: string;
|
|
108
|
+
readonly redirectUri?: string;
|
|
109
|
+
readonly scopes: readonly string[];
|
|
110
|
+
readonly type: "oauth";
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
interface ParsedMcpOAuthAuth {
|
|
114
|
+
clientId?: string;
|
|
115
|
+
clientSecret?: string;
|
|
116
|
+
redirectUri?: string;
|
|
117
|
+
scopes: string[];
|
|
118
|
+
type: "oauth";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface McpServerDefinitionBase {
|
|
122
|
+
readonly enabled: boolean;
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly provenance: McpSettingsScope;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Validated local or remote MCP Server connection definition. */
|
|
128
|
+
export type McpServerDefinition =
|
|
129
|
+
| (McpServerDefinitionBase & {
|
|
130
|
+
readonly args: readonly string[];
|
|
131
|
+
readonly command: string;
|
|
132
|
+
readonly cwd?: string;
|
|
133
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
134
|
+
readonly transport: "stdio";
|
|
135
|
+
})
|
|
136
|
+
| (McpServerDefinitionBase & {
|
|
137
|
+
readonly auth?: McpServerAuth;
|
|
138
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
139
|
+
readonly transport: "http" | "sse";
|
|
140
|
+
readonly url: string;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Records a project-layer mask that hides an inherited Server Definition. */
|
|
144
|
+
export interface McpServerMask {
|
|
145
|
+
readonly id: string;
|
|
146
|
+
readonly inherited: boolean;
|
|
147
|
+
readonly provenance: "project";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Path-qualified, non-sensitive MCP settings failure. */
|
|
151
|
+
export class McpSettingsError extends Error {
|
|
152
|
+
readonly _tag = "McpSettingsError" as const;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
/** JSON-style path to the invalid MCP setting. */
|
|
156
|
+
readonly path: string,
|
|
157
|
+
message: string,
|
|
158
|
+
) {
|
|
159
|
+
super(`MCP settings invalid at ${path}: ${message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Holds effective secret and interpolated values privately for safe diagnostics. */
|
|
164
|
+
export class McpResolvedSecrets {
|
|
165
|
+
private readonly matcher: RegExp | undefined;
|
|
166
|
+
|
|
167
|
+
/** Build a redactor from effective values that diagnostics must not expose. */
|
|
168
|
+
constructor(values: Iterable<string> = []) {
|
|
169
|
+
const alternatives = [...new Set(values)]
|
|
170
|
+
.filter((value) => value.length > 0)
|
|
171
|
+
.sort((left, right) => right.length - left.length)
|
|
172
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
173
|
+
this.matcher = alternatives.length === 0 ? undefined : new RegExp(alternatives.join("|"), "gu");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Replace every tracked non-empty effective value with a stable marker. */
|
|
177
|
+
redact(text: string): string {
|
|
178
|
+
return this.matcher === undefined ? text : text.replace(this.matcher, "[REDACTED]");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Effective trusted MCP settings; invalid input leaves Server Definitions disabled. */
|
|
183
|
+
export interface ResolvedMcpSettings {
|
|
184
|
+
readonly connectTimeoutMs: number;
|
|
185
|
+
readonly errors: readonly McpSettingsError[];
|
|
186
|
+
readonly masks: ReadonlyMap<string, McpServerMask>;
|
|
187
|
+
readonly requestTimeoutMs: number;
|
|
188
|
+
readonly retry: McpRetrySettings;
|
|
189
|
+
readonly secrets: McpResolvedSecrets;
|
|
190
|
+
readonly servers: ReadonlyMap<string, McpServerDefinition>;
|
|
191
|
+
readonly valid: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface ParsedMcpLayer {
|
|
195
|
+
readonly errors: readonly McpSettingsError[];
|
|
196
|
+
readonly scope: McpSettingsScope;
|
|
197
|
+
readonly value: McpLayerWire;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type ParsedServerDefinition =
|
|
201
|
+
| { readonly error: McpSettingsError }
|
|
202
|
+
| { readonly value: McpServerDefinition };
|
|
203
|
+
|
|
204
|
+
interface MergedServerDefinitions {
|
|
205
|
+
readonly errors: readonly McpSettingsError[];
|
|
206
|
+
readonly masks: ReadonlyMap<string, McpServerMask>;
|
|
207
|
+
readonly servers: ReadonlyMap<string, McpServerDefinition>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type InterpolatedServerDefinition =
|
|
211
|
+
| { readonly error: McpSettingsError }
|
|
212
|
+
| { readonly value: McpServerDefinitionWire };
|
|
213
|
+
|
|
214
|
+
type InterpolatedMcpValue =
|
|
215
|
+
| { readonly error: McpSettingsError }
|
|
216
|
+
| { readonly value: McpSettingsJsonValue };
|
|
217
|
+
|
|
218
|
+
const MCP_ENVIRONMENT_TEMPLATE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
219
|
+
|
|
220
|
+
function schemaSettingsError(
|
|
221
|
+
schema: TSchema,
|
|
222
|
+
value: McpSettingsJsonValue,
|
|
223
|
+
path: string,
|
|
224
|
+
): McpSettingsError {
|
|
225
|
+
const issue = Value.Errors(schema, value)[0];
|
|
226
|
+
const instancePath = issue?.instancePath.replaceAll("/", ".") ?? "";
|
|
227
|
+
const field =
|
|
228
|
+
issue?.keyword === "additionalProperties"
|
|
229
|
+
? issue.params.additionalProperties[0]
|
|
230
|
+
: issue?.keyword === "required"
|
|
231
|
+
? issue.params.requiredProperties[0]
|
|
232
|
+
: undefined;
|
|
233
|
+
const suffix = field === undefined ? "" : `.${String(field)}`;
|
|
234
|
+
return new McpSettingsError(`${path}${instancePath}${suffix}`, issue?.message ?? "invalid value");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function interpolateMcpString(
|
|
238
|
+
value: string,
|
|
239
|
+
path: string,
|
|
240
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
241
|
+
secrets: Set<string>,
|
|
242
|
+
): InterpolatedMcpValue {
|
|
243
|
+
let missingVariable: string | undefined;
|
|
244
|
+
const interpolated = value.replace(MCP_ENVIRONMENT_TEMPLATE, (template, variableName: string) => {
|
|
245
|
+
const resolved = environment[variableName];
|
|
246
|
+
if (resolved === undefined) {
|
|
247
|
+
missingVariable ??= variableName;
|
|
248
|
+
return template;
|
|
249
|
+
}
|
|
250
|
+
if (resolved.length > 0) secrets.add(resolved);
|
|
251
|
+
return resolved;
|
|
252
|
+
});
|
|
253
|
+
return missingVariable === undefined
|
|
254
|
+
? { value: interpolated }
|
|
255
|
+
: {
|
|
256
|
+
error: new McpSettingsError(path, `environment variable ${missingVariable} is not defined`),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// oxlint-disable anti-slop/no-unknown-parameters -- SAFETY: McpServerDefinitionWireSchema parses the complete value before this recursive interpolation boundary.
|
|
261
|
+
function interpolateMcpValue(
|
|
262
|
+
value: unknown,
|
|
263
|
+
path: string,
|
|
264
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
265
|
+
secrets: Set<string>,
|
|
266
|
+
): InterpolatedMcpValue {
|
|
267
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: The owning Server Definition schema already established the recursive JSON representation.
|
|
268
|
+
if (typeof value === "string") {
|
|
269
|
+
return interpolateMcpString(value, path, environment, secrets);
|
|
270
|
+
}
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
const interpolated: McpSettingsJsonValue[] = [];
|
|
273
|
+
for (const [index, item] of value.entries()) {
|
|
274
|
+
const result = interpolateMcpValue(item, `${path}.${index}`, environment, secrets);
|
|
275
|
+
if ("error" in result) return result;
|
|
276
|
+
interpolated.push(result.value);
|
|
277
|
+
}
|
|
278
|
+
return { value: interpolated };
|
|
279
|
+
}
|
|
280
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: The owning Server Definition schema already established the recursive JSON representation.
|
|
281
|
+
if (value !== null && typeof value === "object") {
|
|
282
|
+
const interpolated: Record<string, McpSettingsJsonValue> = {};
|
|
283
|
+
for (const [key, item] of Object.entries(value)) {
|
|
284
|
+
const result = interpolateMcpValue(item, `${path}.${key}`, environment, secrets);
|
|
285
|
+
if ("error" in result) return result;
|
|
286
|
+
interpolated[key] = result.value;
|
|
287
|
+
}
|
|
288
|
+
return { value: interpolated };
|
|
289
|
+
}
|
|
290
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: The owning Server Definition schema already established the recursive JSON representation.
|
|
291
|
+
if (value === null || typeof value === "boolean" || typeof value === "number") {
|
|
292
|
+
return { value };
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
error: new McpSettingsError(path, "expected a JSON Server Definition value"),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// oxlint-enable anti-slop/no-unknown-parameters
|
|
299
|
+
|
|
300
|
+
function interpolateServerDefinition(
|
|
301
|
+
wire: McpServerDefinitionWire,
|
|
302
|
+
path: string,
|
|
303
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
304
|
+
secrets: Set<string>,
|
|
305
|
+
): InterpolatedServerDefinition {
|
|
306
|
+
const result = interpolateMcpValue(wire, path, environment, secrets);
|
|
307
|
+
if ("error" in result) return result;
|
|
308
|
+
if (!Value.Check(McpServerDefinitionWireSchema, result.value)) {
|
|
309
|
+
return {
|
|
310
|
+
error: new McpSettingsError(path, "environment interpolation produced an invalid value"),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return { value: result.value };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parseMcpLayer(
|
|
317
|
+
document: McpSettingsDocumentInput,
|
|
318
|
+
scope: McpSettingsScope,
|
|
319
|
+
): ParsedMcpLayer {
|
|
320
|
+
if (!Value.Check(SettingsDocumentSchema, document)) {
|
|
321
|
+
return {
|
|
322
|
+
errors: [new McpSettingsError(`${scope} settings`, "expected a JSON object")],
|
|
323
|
+
scope,
|
|
324
|
+
value: {},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (document.mcp === undefined) return { errors: [], scope, value: {} };
|
|
328
|
+
if (!Value.Check(McpLayerWireSchema, document.mcp)) {
|
|
329
|
+
return {
|
|
330
|
+
errors: [schemaSettingsError(McpLayerWireSchema, document.mcp, `${scope} mcp`)],
|
|
331
|
+
scope,
|
|
332
|
+
value: {},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return { errors: [], scope, value: document.mcp };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseRemoteUrl(value: string, path: string): string | McpSettingsError {
|
|
339
|
+
try {
|
|
340
|
+
const url = new URL(value);
|
|
341
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
342
|
+
return new McpSettingsError(path, "URL protocol must be http or https");
|
|
343
|
+
}
|
|
344
|
+
return url.toString();
|
|
345
|
+
} catch {
|
|
346
|
+
return new McpSettingsError(path, "expected an absolute HTTP URL");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function parseOAuthRedirectUri(value: string, path: string): string | McpSettingsError {
|
|
351
|
+
try {
|
|
352
|
+
const url = new URL(value);
|
|
353
|
+
const loopbackHosts = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
354
|
+
if (url.protocol !== "http:" || !loopbackHosts.has(url.hostname)) {
|
|
355
|
+
return new McpSettingsError(path, "expected an HTTP loopback redirect URI");
|
|
356
|
+
}
|
|
357
|
+
return url.toString();
|
|
358
|
+
} catch {
|
|
359
|
+
return new McpSettingsError(path, "expected an HTTP loopback redirect URI");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseServerAuth(
|
|
364
|
+
wire: McpServerDefinitionWire["auth"],
|
|
365
|
+
headers: Readonly<Record<string, string>>,
|
|
366
|
+
path: string,
|
|
367
|
+
): McpServerAuth | McpSettingsError | undefined {
|
|
368
|
+
if (wire === undefined) return undefined;
|
|
369
|
+
if (wire.type === "none") {
|
|
370
|
+
return Object.keys(wire).length === 1
|
|
371
|
+
? { type: "none" }
|
|
372
|
+
: new McpSettingsError(path, "none authentication accepts only the type field");
|
|
373
|
+
}
|
|
374
|
+
if (wire.type === "bearer") {
|
|
375
|
+
if (!Value.Check(NonEmptyStringSchema, wire.token)) {
|
|
376
|
+
return new McpSettingsError(`${path}.token`, "must be a non-empty string");
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
wire.clientId !== undefined ||
|
|
380
|
+
wire.clientSecret !== undefined ||
|
|
381
|
+
wire.redirectUri !== undefined ||
|
|
382
|
+
wire.scopes !== undefined
|
|
383
|
+
) {
|
|
384
|
+
return new McpSettingsError(path, "bearer authentication accepts only type and token");
|
|
385
|
+
}
|
|
386
|
+
const authorizationHeader = Object.keys(headers).find(
|
|
387
|
+
(name) => name.toLowerCase() === "authorization",
|
|
388
|
+
);
|
|
389
|
+
if (authorizationHeader !== undefined) {
|
|
390
|
+
return new McpSettingsError(
|
|
391
|
+
`${path.replace(/\.auth$/, "")}.headers.${authorizationHeader}`,
|
|
392
|
+
"Authorization header conflicts with bearer authentication",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return { token: wire.token, type: "bearer" };
|
|
396
|
+
}
|
|
397
|
+
if (wire.type !== "oauth") {
|
|
398
|
+
return new McpSettingsError(`${path}.type`, "expected none, bearer, or oauth");
|
|
399
|
+
}
|
|
400
|
+
if (wire.token !== undefined) {
|
|
401
|
+
return new McpSettingsError(path, "oauth authentication does not accept token");
|
|
402
|
+
}
|
|
403
|
+
for (const [name, value] of [
|
|
404
|
+
["clientId", wire.clientId],
|
|
405
|
+
["clientSecret", wire.clientSecret],
|
|
406
|
+
] as const) {
|
|
407
|
+
if (value !== undefined && value.length === 0) {
|
|
408
|
+
return new McpSettingsError(`${path}.${name}`, "must not be empty");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (wire.clientSecret !== undefined && wire.clientId === undefined) {
|
|
412
|
+
return new McpSettingsError(`${path}.clientId`, "is required with clientSecret");
|
|
413
|
+
}
|
|
414
|
+
if ((wire.scopes ?? []).some((scope) => scope.length === 0)) {
|
|
415
|
+
return new McpSettingsError(`${path}.scopes`, "scope values must not be empty");
|
|
416
|
+
}
|
|
417
|
+
let redirectUri: string | undefined;
|
|
418
|
+
if (wire.redirectUri !== undefined) {
|
|
419
|
+
const parsedRedirectUri = parseOAuthRedirectUri(wire.redirectUri, `${path}.redirectUri`);
|
|
420
|
+
if (parsedRedirectUri instanceof McpSettingsError) return parsedRedirectUri;
|
|
421
|
+
redirectUri = parsedRedirectUri;
|
|
422
|
+
}
|
|
423
|
+
const oauth: ParsedMcpOAuthAuth = {
|
|
424
|
+
scopes: [...(wire.scopes ?? [])],
|
|
425
|
+
type: "oauth",
|
|
426
|
+
};
|
|
427
|
+
if (wire.clientId !== undefined) oauth.clientId = wire.clientId;
|
|
428
|
+
if (wire.clientSecret !== undefined) oauth.clientSecret = wire.clientSecret;
|
|
429
|
+
if (redirectUri !== undefined) oauth.redirectUri = redirectUri;
|
|
430
|
+
return oauth;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseServerDefinition(
|
|
434
|
+
id: string,
|
|
435
|
+
wire: McpServerDefinitionWire,
|
|
436
|
+
scope: McpSettingsScope,
|
|
437
|
+
secrets: Set<string>,
|
|
438
|
+
): ParsedServerDefinition {
|
|
439
|
+
const path = `${scope} mcp.servers.${id}`;
|
|
440
|
+
const hasCommand = wire.command !== undefined;
|
|
441
|
+
const hasUrl = wire.url !== undefined;
|
|
442
|
+
if (hasCommand === hasUrl) {
|
|
443
|
+
return {
|
|
444
|
+
error: new McpSettingsError(path, "exactly one of command or url is required"),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const transport = wire.transport ?? (hasCommand ? "stdio" : "http");
|
|
449
|
+
if (transport !== "stdio" && transport !== "http" && transport !== "sse") {
|
|
450
|
+
return { error: new McpSettingsError(`${path}.transport`, "expected stdio, http, or sse") };
|
|
451
|
+
}
|
|
452
|
+
if (hasCommand && transport !== "stdio") {
|
|
453
|
+
return { error: new McpSettingsError(`${path}.transport`, "command requires stdio") };
|
|
454
|
+
}
|
|
455
|
+
if (hasUrl && transport === "stdio") {
|
|
456
|
+
return { error: new McpSettingsError(`${path}.transport`, "url requires http or sse") };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const common = { enabled: wire.enabled ?? true, id, provenance: scope } as const;
|
|
460
|
+
if (hasCommand) {
|
|
461
|
+
if (!Value.Check(NonEmptyStringSchema, wire.command)) {
|
|
462
|
+
return { error: new McpSettingsError(`${path}.command`, "must not be empty") };
|
|
463
|
+
}
|
|
464
|
+
if (wire.headers !== undefined || wire.auth !== undefined) {
|
|
465
|
+
return {
|
|
466
|
+
error: new McpSettingsError(path, "stdio definitions cannot contain headers or auth"),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const environment = { ...wire.environment };
|
|
470
|
+
for (const value of Object.values(environment)) {
|
|
471
|
+
if (value.length > 0) secrets.add(value);
|
|
472
|
+
}
|
|
473
|
+
const stdio = {
|
|
474
|
+
...common,
|
|
475
|
+
args: [...(wire.args ?? [])],
|
|
476
|
+
command: wire.command,
|
|
477
|
+
environment,
|
|
478
|
+
transport: "stdio" as const,
|
|
479
|
+
};
|
|
480
|
+
return wire.cwd === undefined ? { value: stdio } : { value: { ...stdio, cwd: wire.cwd } };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (
|
|
484
|
+
wire.args !== undefined ||
|
|
485
|
+
wire.cwd !== undefined ||
|
|
486
|
+
wire.environment !== undefined ||
|
|
487
|
+
wire.command !== undefined
|
|
488
|
+
) {
|
|
489
|
+
return {
|
|
490
|
+
error: new McpSettingsError(
|
|
491
|
+
path,
|
|
492
|
+
"http and sse definitions cannot contain command, args, cwd, or environment",
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (transport === "stdio") {
|
|
497
|
+
return { error: new McpSettingsError(`${path}.transport`, "url requires http or sse") };
|
|
498
|
+
}
|
|
499
|
+
const remoteTransport: "http" | "sse" = transport === "sse" ? "sse" : "http";
|
|
500
|
+
const parsedUrl = parseRemoteUrl(wire.url ?? "", `${path}.url`);
|
|
501
|
+
if (parsedUrl instanceof McpSettingsError) return { error: parsedUrl };
|
|
502
|
+
const headers = { ...wire.headers };
|
|
503
|
+
const auth = parseServerAuth(wire.auth, headers, `${path}.auth`);
|
|
504
|
+
if (auth instanceof McpSettingsError) return { error: auth };
|
|
505
|
+
secrets.add(parsedUrl);
|
|
506
|
+
for (const value of Object.values(headers)) {
|
|
507
|
+
if (value.length > 0) secrets.add(value);
|
|
508
|
+
}
|
|
509
|
+
if (auth?.type === "bearer") secrets.add(auth.token);
|
|
510
|
+
if (auth?.type === "oauth" && auth.clientSecret !== undefined) secrets.add(auth.clientSecret);
|
|
511
|
+
const remote = {
|
|
512
|
+
...common,
|
|
513
|
+
headers,
|
|
514
|
+
transport: remoteTransport,
|
|
515
|
+
url: parsedUrl,
|
|
516
|
+
};
|
|
517
|
+
return auth === undefined ? { value: remote } : { value: { ...remote, auth } };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function mergeServerDefinitions(
|
|
521
|
+
globalLayer: ParsedMcpLayer,
|
|
522
|
+
projectLayer: ParsedMcpLayer,
|
|
523
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
524
|
+
secrets: Set<string>,
|
|
525
|
+
): MergedServerDefinitions {
|
|
526
|
+
const definitions = new Map<string, { scope: McpSettingsScope; wire: McpServerDefinitionWire }>();
|
|
527
|
+
const masks = new Map<string, McpServerMask>();
|
|
528
|
+
const errors: McpSettingsError[] = [];
|
|
529
|
+
for (const [id, wire] of Object.entries(globalLayer.value.servers ?? {})) {
|
|
530
|
+
if (id.length === 0) {
|
|
531
|
+
errors.push(new McpSettingsError("global mcp.servers", "Server Definition ID is empty"));
|
|
532
|
+
} else if (wire === null || (wire.enabled === false && Object.keys(wire).length === 1)) {
|
|
533
|
+
errors.push(
|
|
534
|
+
new McpSettingsError(
|
|
535
|
+
`global mcp.servers.${id}`,
|
|
536
|
+
"a mask is valid only in project settings",
|
|
537
|
+
),
|
|
538
|
+
);
|
|
539
|
+
} else {
|
|
540
|
+
definitions.set(id, { scope: "global", wire });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
for (const [id, wire] of Object.entries(projectLayer.value.servers ?? {})) {
|
|
544
|
+
const inherited = definitions.has(id);
|
|
545
|
+
definitions.delete(id);
|
|
546
|
+
masks.delete(id);
|
|
547
|
+
if (id.length === 0) {
|
|
548
|
+
errors.push(new McpSettingsError("project mcp.servers", "Server Definition ID is empty"));
|
|
549
|
+
} else if (wire === null || (wire.enabled === false && Object.keys(wire).length === 1)) {
|
|
550
|
+
masks.set(id, { id, inherited, provenance: "project" });
|
|
551
|
+
} else {
|
|
552
|
+
definitions.set(id, { scope: "project", wire });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const servers = new Map<string, McpServerDefinition>();
|
|
557
|
+
for (const [id, configured] of [...definitions].sort(([left], [right]) =>
|
|
558
|
+
left.localeCompare(right),
|
|
559
|
+
)) {
|
|
560
|
+
const path = `${configured.scope} mcp.servers.${id}`;
|
|
561
|
+
const interpolated = interpolateServerDefinition(configured.wire, path, environment, secrets);
|
|
562
|
+
if ("error" in interpolated) {
|
|
563
|
+
errors.push(interpolated.error);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const parsed = parseServerDefinition(id, interpolated.value, configured.scope, secrets);
|
|
567
|
+
if ("error" in parsed) errors.push(parsed.error);
|
|
568
|
+
else servers.set(id, parsed.value);
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
errors,
|
|
572
|
+
masks: new Map([...masks].sort(([left], [right]) => left.localeCompare(right))),
|
|
573
|
+
servers,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Merge and parse global plus trusted-project MCP settings without starting any MCP Server. */
|
|
578
|
+
export function resolveMcpSettings(
|
|
579
|
+
reader: McpSettingsReader,
|
|
580
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
581
|
+
): ResolvedMcpSettings {
|
|
582
|
+
const globalLayer = parseMcpLayer(reader.getGlobalSettings(), "global");
|
|
583
|
+
const projectLayer = parseMcpLayer(reader.getProjectSettings(), "project");
|
|
584
|
+
const layerErrors = [...globalLayer.errors, ...projectLayer.errors];
|
|
585
|
+
const secretValues = new Set<string>();
|
|
586
|
+
const mergedServers = mergeServerDefinitions(
|
|
587
|
+
globalLayer,
|
|
588
|
+
projectLayer,
|
|
589
|
+
environment,
|
|
590
|
+
secretValues,
|
|
591
|
+
);
|
|
592
|
+
const retry = Object.assign(
|
|
593
|
+
{},
|
|
594
|
+
DEFAULT_MCP_RETRY,
|
|
595
|
+
globalLayer.value.retry,
|
|
596
|
+
projectLayer.value.retry,
|
|
597
|
+
);
|
|
598
|
+
const retryRangeErrors =
|
|
599
|
+
retry.initialDelayMs <= retry.maxDelayMs
|
|
600
|
+
? []
|
|
601
|
+
: [new McpSettingsError("mcp.retry.initialDelayMs", "must not exceed mcp.retry.maxDelayMs")];
|
|
602
|
+
const errors = [...layerErrors, ...mergedServers.errors, ...retryRangeErrors];
|
|
603
|
+
return {
|
|
604
|
+
connectTimeoutMs:
|
|
605
|
+
projectLayer.value.connectTimeoutMs ??
|
|
606
|
+
globalLayer.value.connectTimeoutMs ??
|
|
607
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
608
|
+
errors,
|
|
609
|
+
masks: errors.length === 0 ? mergedServers.masks : new Map(),
|
|
610
|
+
requestTimeoutMs:
|
|
611
|
+
projectLayer.value.requestTimeoutMs ??
|
|
612
|
+
globalLayer.value.requestTimeoutMs ??
|
|
613
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
614
|
+
retry,
|
|
615
|
+
secrets: new McpResolvedSecrets(secretValues),
|
|
616
|
+
servers: errors.length === 0 ? mergedServers.servers : new Map(),
|
|
617
|
+
valid: errors.length === 0,
|
|
618
|
+
};
|
|
619
|
+
}
|