@plurnk/plurnk-mcp 1.7.0 → 1.8.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/.env.defaults +25 -0
- package/README.md +79 -25
- package/SPEC.md +260 -46
- package/dist/McpExecutor.d.ts +4 -2
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +44 -6
- package/dist/McpExecutor.js.map +1 -1
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +358 -151
- package/dist/Module.js.map +1 -1
- package/dist/ToolPresentation.d.ts +3 -1
- package/dist/ToolPresentation.d.ts.map +1 -1
- package/dist/ToolPresentation.js +145 -55
- package/dist/ToolPresentation.js.map +1 -1
- package/dist/capabilityMatrix.d.ts +23 -0
- package/dist/capabilityMatrix.d.ts.map +1 -0
- package/dist/capabilityMatrix.js +403 -0
- package/dist/capabilityMatrix.js.map +1 -0
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +95 -42
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +8 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +169 -32
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-watchdog.mjs +106 -0
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/package.json +6 -5
package/dist/Module.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Problems, Validator, } from "@plurnk/plurnk-contracts";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import McpExecutor, { runtimeDecl } from "./McpExecutor.js";
|
|
2
|
+
import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, } from "./client.js";
|
|
3
|
+
import { expandedServerNames, overlayServerDefinitions, serviceDefinitions, serviceEnabledNames, summaryOverrides, } from "./config.js";
|
|
4
|
+
import McpExecutor, { runtimeDecl, serverSummary } from "./McpExecutor.js";
|
|
6
5
|
import McpResources from "./McpResources.js";
|
|
7
|
-
import { MCP_PROTOCOL_VERSION } from "./protocol.js";
|
|
8
6
|
const OWNER = "@plurnk/plurnk-mcp";
|
|
9
7
|
const STATE_VERSION = 1;
|
|
8
|
+
const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
|
|
10
9
|
class ModuleActionError extends Error {
|
|
11
10
|
problem;
|
|
12
11
|
constructor(problem, cause) {
|
|
@@ -21,36 +20,20 @@ const actionError = (code, status, detail, extensions = {}, cause) => new Module
|
|
|
21
20
|
...extensions,
|
|
22
21
|
}), cause);
|
|
23
22
|
const errorsOf = (error) => error instanceof AggregateError ? [...error.errors] : [error];
|
|
24
|
-
const errorTreeSome = (error, predicate, seen = new Set()) => {
|
|
25
|
-
if ((typeof error !== "object" && typeof error !== "function") || error === null) {
|
|
26
|
-
return predicate(error);
|
|
27
|
-
}
|
|
28
|
-
if (seen.has(error))
|
|
29
|
-
return false;
|
|
30
|
-
seen.add(error);
|
|
31
|
-
if (predicate(error))
|
|
32
|
-
return true;
|
|
33
|
-
if (error instanceof AggregateError) {
|
|
34
|
-
return [...error.errors].some((candidate) => errorTreeSome(candidate, predicate, seen));
|
|
35
|
-
}
|
|
36
|
-
return error instanceof Error && error.cause !== undefined
|
|
37
|
-
? errorTreeSome(error.cause, predicate, seen)
|
|
38
|
-
: false;
|
|
39
|
-
};
|
|
40
|
-
const lacksRequiredProtocolRevision = (error) => errorTreeSome(error, (candidate) => SdkError.isInstance(candidate)
|
|
41
|
-
&& candidate.code === SdkErrorCode.EraNegotiationFailed
|
|
42
|
-
&& candidate.message.includes("the server did not offer pinned protocol version")
|
|
43
|
-
&& candidate.message.includes("via server/discover"));
|
|
44
23
|
const preparationError = (definition, cause, closeCause) => {
|
|
45
24
|
const completeCause = closeCause === undefined
|
|
46
25
|
? cause
|
|
47
26
|
: new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
|
|
48
|
-
|
|
49
|
-
|
|
27
|
+
// {§oauth-client-credentials} — a rejected grant is an authorization fact,
|
|
28
|
+
// never a generic unavailability.
|
|
29
|
+
if (definition.authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
|
|
30
|
+
return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
|
|
50
31
|
server: definition.name,
|
|
51
32
|
transport: definition.transport,
|
|
52
|
-
|
|
53
|
-
|
|
33
|
+
clientId: definition.authorization.clientId,
|
|
34
|
+
...(definition.authorization.issuer === undefined
|
|
35
|
+
? {}
|
|
36
|
+
: { issuer: definition.authorization.issuer }),
|
|
54
37
|
retryable: false,
|
|
55
38
|
}, completeCause);
|
|
56
39
|
}
|
|
@@ -94,26 +77,30 @@ const parseState = (source) => {
|
|
|
94
77
|
const parsed = {};
|
|
95
78
|
for (const [name, raw] of Object.entries(servers).toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
96
79
|
const value = objectOf(raw);
|
|
97
|
-
if (value === null || (value.kind !== "
|
|
80
|
+
if (value === null || (value.kind !== "service" && value.kind !== "workspace")) {
|
|
98
81
|
throw new Error(`MCP workspace server '${name}' has an invalid state.`);
|
|
99
82
|
}
|
|
100
|
-
if (value.
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
if (typeof value.enabled !== "boolean") {
|
|
84
|
+
throw new Error(`MCP workspace server '${name}' must declare boolean enabledness.`);
|
|
85
|
+
}
|
|
86
|
+
if (value.kind === "service") {
|
|
87
|
+
assertExactKeys(value, ["kind", "enabled"], `MCP workspace server '${name}'`);
|
|
88
|
+
parsed[name] = { kind: "service", enabled: value.enabled };
|
|
103
89
|
continue;
|
|
104
90
|
}
|
|
105
|
-
assertExactKeys(value, ["kind", "definition"], `MCP workspace server '${name}'`);
|
|
91
|
+
assertExactKeys(value, ["kind", "definition", "enabled"], `MCP workspace server '${name}'`);
|
|
106
92
|
const definition = structuredClone(Validator.assertMcpServerDefinition(value.definition));
|
|
107
93
|
if (definition.name !== name) {
|
|
108
94
|
throw new Error(`MCP workspace server key '${name}' does not match definition '${definition.name}'.`);
|
|
109
95
|
}
|
|
110
|
-
parsed[name] = { kind: "
|
|
96
|
+
parsed[name] = { kind: "workspace", definition, enabled: value.enabled };
|
|
111
97
|
}
|
|
112
98
|
return { version: STATE_VERSION, servers: parsed };
|
|
113
99
|
};
|
|
114
100
|
const persistedState = (state) => Object.keys(state.servers).length === 0 ? null : state;
|
|
115
101
|
const cloneState = (state) => parseState(structuredClone(state));
|
|
116
102
|
const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
103
|
+
const sameServerState = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
117
104
|
const requiredString = (params, field) => {
|
|
118
105
|
const value = params[field];
|
|
119
106
|
if (typeof value !== "string" || value.length === 0) {
|
|
@@ -121,6 +108,12 @@ const requiredString = (params, field) => {
|
|
|
121
108
|
}
|
|
122
109
|
return value;
|
|
123
110
|
};
|
|
111
|
+
const assertActionKeys = (params, allowed) => {
|
|
112
|
+
const extras = Object.keys(params).filter((key) => !allowed.includes(key));
|
|
113
|
+
if (extras.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
throw actionError("parameters-invalid", 400, `MCP action contains unsupported parameter(s): ${extras.join(", ")}.`, { fields: extras, retryable: false });
|
|
116
|
+
};
|
|
124
117
|
const workspaceIdOf = (context) => {
|
|
125
118
|
if (context.scope !== "workspace") {
|
|
126
119
|
throw actionError("workspace-context-required", 500, "MCP action received no workspace context.");
|
|
@@ -138,8 +131,13 @@ const statusOf = (error) => {
|
|
|
138
131
|
};
|
|
139
132
|
export default class Module {
|
|
140
133
|
#env;
|
|
134
|
+
#summaries;
|
|
135
|
+
#expanded;
|
|
141
136
|
#defaults;
|
|
137
|
+
#defaultEnabled;
|
|
142
138
|
#workspaces = new Map();
|
|
139
|
+
// {§oauth-lifetime} — pending candidates are process-memory per
|
|
140
|
+
// (workspace, alias); a restart loses them by design.
|
|
143
141
|
#pending = new Map();
|
|
144
142
|
#locks = new Map();
|
|
145
143
|
#refreshTimers = new Map();
|
|
@@ -151,6 +149,9 @@ export default class Module {
|
|
|
151
149
|
constructor(environ) {
|
|
152
150
|
this.#env = environ;
|
|
153
151
|
this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
|
|
152
|
+
this.#defaultEnabled = new Set(serviceEnabledNames(environ));
|
|
153
|
+
this.#summaries = summaryOverrides(environ);
|
|
154
|
+
this.#expanded = new Set(expandedServerNames(environ));
|
|
154
155
|
}
|
|
155
156
|
async setup(seam) {
|
|
156
157
|
this.#seam = seam;
|
|
@@ -159,15 +160,19 @@ export default class Module {
|
|
|
159
160
|
const state = parseState(await seam.readWorkspaceModuleState(workspaceId, OWNER));
|
|
160
161
|
await this.#applyState(workspaceId, state, {
|
|
161
162
|
authorizationDisposition: "publish-required",
|
|
163
|
+
preparationFailureDisposition: "publish-unavailable",
|
|
162
164
|
});
|
|
163
165
|
}),
|
|
164
166
|
});
|
|
165
167
|
const action = (name, handler) => seam.registerModuleAction({ name, scope: "workspace", handler });
|
|
166
|
-
action("workspace.mcp.list", async (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
action("workspace.mcp.
|
|
168
|
+
action("workspace.mcp.list", async (params, context) => {
|
|
169
|
+
assertActionKeys(params, ["overlay"]);
|
|
170
|
+
return this.#list(workspaceIdOf(context), params.overlay);
|
|
171
|
+
});
|
|
172
|
+
action("workspace.mcp.add", async (params, context) => this.#add(workspaceIdOf(context), params));
|
|
173
|
+
action("workspace.mcp.enable", async (params, context) => this.#setEnabled(workspaceIdOf(context), params, true));
|
|
174
|
+
action("workspace.mcp.disable", async (params, context) => this.#setEnabled(workspaceIdOf(context), params, false));
|
|
175
|
+
action("workspace.mcp.remove", async (params, context) => this.#remove(workspaceIdOf(context), params));
|
|
171
176
|
action("workspace.mcp.oauth.complete", async (params, context) => this.#completeOAuth(workspaceIdOf(context), params));
|
|
172
177
|
action("workspace.mcp.complete", async (params, context) => this.#complete(workspaceIdOf(context), params));
|
|
173
178
|
}
|
|
@@ -190,21 +195,34 @@ export default class Module {
|
|
|
190
195
|
this.#locks.delete(workspaceId);
|
|
191
196
|
}
|
|
192
197
|
}
|
|
193
|
-
#
|
|
194
|
-
const
|
|
198
|
+
#available(state) {
|
|
199
|
+
const available = new Map([...this.#defaults].map(([name, definition]) => [
|
|
195
200
|
name,
|
|
196
|
-
{
|
|
201
|
+
{
|
|
202
|
+
definition: structuredClone(definition),
|
|
203
|
+
source: "service",
|
|
204
|
+
enabled: this.#defaultEnabled.has(name),
|
|
205
|
+
},
|
|
197
206
|
]));
|
|
198
207
|
for (const [name, value] of Object.entries(state.servers)) {
|
|
199
|
-
if (value.kind === "
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
208
|
+
if (value.kind === "service") {
|
|
209
|
+
const configured = available.get(name);
|
|
210
|
+
if (configured !== undefined)
|
|
211
|
+
available.set(name, { ...configured, enabled: value.enabled });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
available.set(name, {
|
|
215
|
+
definition: structuredClone(value.definition),
|
|
216
|
+
source: "workspace",
|
|
217
|
+
enabled: value.enabled,
|
|
218
|
+
});
|
|
206
219
|
}
|
|
207
|
-
return new Map([...
|
|
220
|
+
return new Map([...available].toSorted(([left], [right]) => left.localeCompare(right)));
|
|
221
|
+
}
|
|
222
|
+
#enabled(state) {
|
|
223
|
+
return new Map([...this.#available(state)]
|
|
224
|
+
.filter(([, value]) => value.enabled)
|
|
225
|
+
.map(([name, { definition, source }]) => [name, { definition, source }]));
|
|
208
226
|
}
|
|
209
227
|
async #prepareAttachment(workspaceId, effective, connection) {
|
|
210
228
|
const definition = effective.definition;
|
|
@@ -220,7 +238,7 @@ export default class Module {
|
|
|
220
238
|
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
221
239
|
},
|
|
222
240
|
});
|
|
223
|
-
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] });
|
|
241
|
+
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
|
|
224
242
|
try {
|
|
225
243
|
const availability = await executor.requireAvailable();
|
|
226
244
|
return {
|
|
@@ -230,7 +248,7 @@ export default class Module {
|
|
|
230
248
|
executor,
|
|
231
249
|
runtime: {
|
|
232
250
|
namespaceOwner: OWNER,
|
|
233
|
-
decl: runtimeDecl(definition.name),
|
|
251
|
+
decl: runtimeDecl(definition.name, serverSummary(definition.name, executor.catalog, this.#summaries.servers.get(definition.name)), this.#expanded.has(definition.name)),
|
|
234
252
|
executor,
|
|
235
253
|
availability,
|
|
236
254
|
scheme: new McpResources(definition.name, candidate, executor.catalog),
|
|
@@ -261,14 +279,14 @@ export default class Module {
|
|
|
261
279
|
if (seam === undefined)
|
|
262
280
|
throw new Error("MCP module is not set up.");
|
|
263
281
|
const current = this.#workspaces.get(workspaceId);
|
|
264
|
-
const effective = this.#
|
|
282
|
+
const effective = this.#enabled(state);
|
|
265
283
|
const force = options.force ?? new Set();
|
|
266
284
|
for (const [name, attachment] of current?.attachments ?? []) {
|
|
267
285
|
const next = effective.get(name);
|
|
268
286
|
if (next === undefined
|
|
269
287
|
|| force.has(name)
|
|
270
288
|
|| !sameDefinition(attachment.definition, next.definition))
|
|
271
|
-
attachment
|
|
289
|
+
attachmentConnection(attachment)?.assertReplaceable();
|
|
272
290
|
}
|
|
273
291
|
const next = new Map();
|
|
274
292
|
const fresh = [];
|
|
@@ -282,9 +300,26 @@ export default class Module {
|
|
|
282
300
|
continue;
|
|
283
301
|
}
|
|
284
302
|
const supplied = options.prepared?.get(name);
|
|
285
|
-
|
|
303
|
+
let attachment;
|
|
304
|
+
try {
|
|
305
|
+
attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
|
|
306
|
+
}
|
|
307
|
+
catch (cause) {
|
|
308
|
+
if (options.preparationFailureDisposition === "reject")
|
|
309
|
+
throw cause;
|
|
310
|
+
const failure = cause instanceof ModuleActionError
|
|
311
|
+
? cause
|
|
312
|
+
: preparationError(definition.definition, cause);
|
|
313
|
+
attachment = {
|
|
314
|
+
kind: "unavailable",
|
|
315
|
+
...definition,
|
|
316
|
+
problem: structuredClone(failure.problem),
|
|
317
|
+
};
|
|
318
|
+
console.error(`MCP server '${name}' unavailable during workspace hydration: ${failure.problem.detail}`, failure.cause ?? failure);
|
|
319
|
+
}
|
|
286
320
|
next.set(name, attachment);
|
|
287
|
-
|
|
321
|
+
if (attachment.kind !== "unavailable")
|
|
322
|
+
fresh.push(attachment);
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
catch (cause) {
|
|
@@ -319,9 +354,9 @@ export default class Module {
|
|
|
319
354
|
throw cause;
|
|
320
355
|
}
|
|
321
356
|
this.#workspaces.set(workspaceId, { state: cloneState(state), attachments: next });
|
|
322
|
-
const retained = new Set([...next.values()].
|
|
357
|
+
const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
|
|
323
358
|
const obsolete = [...current?.attachments.values() ?? []]
|
|
324
|
-
.
|
|
359
|
+
.flatMap((attachment) => attachmentConnection(attachment) ?? [])
|
|
325
360
|
.filter((connection) => !retained.has(connection));
|
|
326
361
|
if (obsolete.length > 0) {
|
|
327
362
|
try {
|
|
@@ -336,8 +371,10 @@ export default class Module {
|
|
|
336
371
|
if (attachment.kind !== "authorization-required")
|
|
337
372
|
continue;
|
|
338
373
|
await this.#setPending(workspaceId, name, {
|
|
339
|
-
operation: "
|
|
374
|
+
operation: "enable",
|
|
375
|
+
expectedState: structuredClone(state.servers[name] ?? null),
|
|
340
376
|
expectedDefinition: structuredClone(attachment.definition),
|
|
377
|
+
expectedEnabled: true,
|
|
341
378
|
definition: {
|
|
342
379
|
definition: structuredClone(attachment.definition),
|
|
343
380
|
source: attachment.source,
|
|
@@ -377,17 +414,31 @@ export default class Module {
|
|
|
377
414
|
}
|
|
378
415
|
return snapshot;
|
|
379
416
|
}
|
|
380
|
-
#summary(
|
|
381
|
-
const definition =
|
|
417
|
+
#summary(alias, available, attachment) {
|
|
418
|
+
const definition = available.definition;
|
|
382
419
|
const base = {
|
|
383
|
-
|
|
384
|
-
source:
|
|
420
|
+
alias,
|
|
421
|
+
source: available.source,
|
|
385
422
|
transport: definition.transport,
|
|
386
|
-
|
|
423
|
+
target: definition.transport === "http" ? definition.url : definition.command,
|
|
424
|
+
enabled: available.enabled,
|
|
425
|
+
state: available.enabled
|
|
426
|
+
? attachment?.kind === "active"
|
|
427
|
+
? "connected"
|
|
428
|
+
: attachment?.kind === "authorization-required"
|
|
429
|
+
? "authorization-required"
|
|
430
|
+
: "unavailable"
|
|
431
|
+
: "disabled",
|
|
387
432
|
enabledTools: definition.tools ?? null,
|
|
388
433
|
read: definition.read ?? [],
|
|
389
434
|
};
|
|
390
|
-
if (attachment
|
|
435
|
+
if (attachment?.kind === "authorization-required") {
|
|
436
|
+
return { ...base, authorization: { url: attachment.authorizationUrl } };
|
|
437
|
+
}
|
|
438
|
+
if (attachment?.kind === "unavailable") {
|
|
439
|
+
return { ...base, problem: structuredClone(attachment.problem) };
|
|
440
|
+
}
|
|
441
|
+
if (attachment?.kind !== "active")
|
|
391
442
|
return base;
|
|
392
443
|
const catalog = attachment.executor.catalog;
|
|
393
444
|
return {
|
|
@@ -401,48 +452,86 @@ export default class Module {
|
|
|
401
452
|
prompts: catalog.prompts.length,
|
|
402
453
|
};
|
|
403
454
|
}
|
|
404
|
-
#
|
|
455
|
+
#overlayDefinitions(state, value) {
|
|
456
|
+
if (value === undefined)
|
|
457
|
+
return new Map();
|
|
458
|
+
try {
|
|
459
|
+
const bases = new Map([...this.#available(state)]
|
|
460
|
+
.map(([alias, { definition }]) => [alias, definition]));
|
|
461
|
+
return overlayServerDefinitions(structuredClone(value), bases);
|
|
462
|
+
}
|
|
463
|
+
catch (cause) {
|
|
464
|
+
throw actionError("configuration-invalid", 400, "Client MCP configuration is invalid.", { retryable: false }, cause);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
#list(workspaceId, overlay) {
|
|
405
468
|
const snapshot = this.#snapshot(workspaceId);
|
|
469
|
+
const available = this.#available(snapshot.state);
|
|
470
|
+
const configured = this.#overlayDefinitions(snapshot.state, overlay);
|
|
471
|
+
const summaries = new Map([...available].map(([alias, definition]) => [
|
|
472
|
+
alias,
|
|
473
|
+
this.#summary(alias, definition, snapshot.attachments.get(alias)),
|
|
474
|
+
]));
|
|
475
|
+
for (const [alias, definition] of configured) {
|
|
476
|
+
if (available.has(alias))
|
|
477
|
+
continue;
|
|
478
|
+
summaries.set(alias, this.#summary(alias, {
|
|
479
|
+
definition,
|
|
480
|
+
source: "client",
|
|
481
|
+
enabled: false,
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
406
484
|
return {
|
|
407
|
-
servers: [...
|
|
485
|
+
servers: [...summaries]
|
|
408
486
|
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
409
|
-
.map(([
|
|
487
|
+
.map(([, summary]) => summary),
|
|
410
488
|
};
|
|
411
489
|
}
|
|
412
490
|
#definition(params) {
|
|
491
|
+
assertActionKeys(params, ["alias", "target", "options"]);
|
|
492
|
+
const alias = requiredString(params, "alias");
|
|
493
|
+
const target = requiredString(params, "target");
|
|
413
494
|
try {
|
|
414
|
-
|
|
495
|
+
const options = structuredClone(Validator.assertMcpServerOptions((params.options ?? {})));
|
|
496
|
+
const definition = /^https?:\/\//u.test(target)
|
|
497
|
+
? { name: alias, transport: "http", url: target, ...options }
|
|
498
|
+
: {
|
|
499
|
+
name: alias,
|
|
500
|
+
transport: "stdio",
|
|
501
|
+
command: target,
|
|
502
|
+
...options,
|
|
503
|
+
args: options.args ?? [],
|
|
504
|
+
};
|
|
505
|
+
return structuredClone(Validator.assertMcpServerDefinition(definition));
|
|
415
506
|
}
|
|
416
507
|
catch (cause) {
|
|
417
|
-
throw actionError("definition-invalid", 400,
|
|
508
|
+
throw actionError("definition-invalid", 400, `MCP server '${alias}' has an invalid target or options.`, { alias, retryable: false }, cause);
|
|
418
509
|
}
|
|
419
510
|
}
|
|
420
|
-
async #
|
|
511
|
+
async #add(workspaceId, params) {
|
|
421
512
|
return this.#serialize(workspaceId, async () => {
|
|
422
513
|
const definition = this.#definition(params);
|
|
514
|
+
const alias = definition.name;
|
|
423
515
|
const snapshot = this.#snapshot(workspaceId);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (operation === "attach" ? exists : !exists) {
|
|
427
|
-
throw actionError(operation === "attach" ? "server-exists" : "server-not-found", operation === "attach" ? 409 : 404, operation === "attach"
|
|
428
|
-
? `MCP server '${definition.name}' is already attached to this workspace.`
|
|
429
|
-
: `MCP server '${definition.name}' is not attached to this workspace.`, { workspaceId, name: definition.name, retryable: false });
|
|
516
|
+
if (this.#available(snapshot.state).has(alias)) {
|
|
517
|
+
throw actionError("server-exists", 409, `MCP server alias '${alias}' is already available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
430
518
|
}
|
|
431
519
|
const state = cloneState(snapshot.state);
|
|
432
|
-
state.servers[
|
|
433
|
-
kind: "
|
|
520
|
+
state.servers[alias] = {
|
|
521
|
+
kind: "workspace",
|
|
434
522
|
definition,
|
|
523
|
+
enabled: true,
|
|
435
524
|
};
|
|
436
525
|
const result = await this.#applyState(workspaceId, state, {
|
|
437
|
-
force: new Set([definition.name]),
|
|
438
526
|
authorizationDisposition: "defer-mutation",
|
|
527
|
+
preparationFailureDisposition: "reject",
|
|
439
528
|
});
|
|
440
529
|
if (result.authorization !== undefined) {
|
|
441
|
-
await this.#setPending(workspaceId,
|
|
442
|
-
operation,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
530
|
+
await this.#setPending(workspaceId, alias, {
|
|
531
|
+
operation: "add",
|
|
532
|
+
expectedState: structuredClone(snapshot.state.servers[alias] ?? null),
|
|
533
|
+
expectedDefinition: null,
|
|
534
|
+
expectedEnabled: false,
|
|
446
535
|
definition: { definition, source: "workspace" },
|
|
447
536
|
connection: result.authorization.connection,
|
|
448
537
|
authorizationUrl: result.authorization.authorizationUrl,
|
|
@@ -452,52 +541,118 @@ export default class Module {
|
|
|
452
541
|
authorization: { url: result.authorization.authorizationUrl },
|
|
453
542
|
};
|
|
454
543
|
}
|
|
455
|
-
const
|
|
456
|
-
|
|
544
|
+
const committed = this.#snapshot(workspaceId);
|
|
545
|
+
const attached = committed.attachments.get(alias);
|
|
546
|
+
if (attached === undefined || attached.kind === "unavailable") {
|
|
457
547
|
throw new Error("Committed MCP attachment is absent.");
|
|
458
|
-
await this.#clearPending(workspaceId, definition.name, attached.connection);
|
|
459
|
-
return { status: operation === "attach" ? 201 : 200, server: this.#summary(definition.name, attached) };
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
async #detach(workspaceId, params) {
|
|
463
|
-
return this.#serialize(workspaceId, async () => {
|
|
464
|
-
const name = requiredString(params, "name");
|
|
465
|
-
const snapshot = this.#snapshot(workspaceId);
|
|
466
|
-
if (!this.#effective(snapshot.state).has(name)) {
|
|
467
|
-
throw actionError("server-not-found", 404, `MCP server '${name}' is not attached to this workspace.`, { workspaceId, name, retryable: false });
|
|
468
548
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
delete state.servers[name];
|
|
475
|
-
}
|
|
476
|
-
await this.#applyState(workspaceId, state, {
|
|
477
|
-
force: new Set([name]),
|
|
478
|
-
authorizationDisposition: "defer-mutation",
|
|
479
|
-
});
|
|
480
|
-
await this.#clearPending(workspaceId, name);
|
|
481
|
-
return { status: 200, name, detached: true };
|
|
549
|
+
await this.#clearPending(workspaceId, alias, attached.connection);
|
|
550
|
+
const available = this.#available(committed.state).get(alias);
|
|
551
|
+
if (available === undefined)
|
|
552
|
+
throw new Error("Committed MCP definition is absent.");
|
|
553
|
+
return { status: 201, server: this.#summary(alias, available, attached) };
|
|
482
554
|
});
|
|
483
555
|
}
|
|
484
|
-
|
|
556
|
+
#stateWithEnabled(state, alias, available, enabled) {
|
|
557
|
+
const next = cloneState(state);
|
|
558
|
+
if (available.source === "service") {
|
|
559
|
+
next.servers[alias] = { kind: "service", enabled };
|
|
560
|
+
return next;
|
|
561
|
+
}
|
|
562
|
+
const current = next.servers[alias];
|
|
563
|
+
if (current?.kind !== "workspace") {
|
|
564
|
+
throw new Error(`Workspace MCP server '${alias}' has no owned definition.`);
|
|
565
|
+
}
|
|
566
|
+
next.servers[alias] = { ...current, enabled };
|
|
567
|
+
return next;
|
|
568
|
+
}
|
|
569
|
+
#stateWithDefinition(state, alias, definition, enabled) {
|
|
570
|
+
const next = cloneState(state);
|
|
571
|
+
next.servers[alias] = this.#definitionSource(alias, definition) === "service"
|
|
572
|
+
? { kind: "service", enabled }
|
|
573
|
+
: {
|
|
574
|
+
kind: "workspace",
|
|
575
|
+
definition: structuredClone(definition),
|
|
576
|
+
enabled,
|
|
577
|
+
};
|
|
578
|
+
return next;
|
|
579
|
+
}
|
|
580
|
+
#definitionSource(alias, definition) {
|
|
581
|
+
const service = this.#defaults.get(alias);
|
|
582
|
+
return service !== undefined && sameDefinition(service, definition)
|
|
583
|
+
? "service"
|
|
584
|
+
: "workspace";
|
|
585
|
+
}
|
|
586
|
+
#definitionWithOptions(definition, value) {
|
|
587
|
+
if (value === undefined)
|
|
588
|
+
return structuredClone(definition);
|
|
589
|
+
try {
|
|
590
|
+
const options = structuredClone(Validator.assertMcpServerOptions(value));
|
|
591
|
+
return structuredClone(Validator.assertMcpServerDefinition({
|
|
592
|
+
...definition,
|
|
593
|
+
...options,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
catch (cause) {
|
|
597
|
+
throw actionError("definition-invalid", 400, `MCP server '${definition.name}' has invalid enable options.`, { alias: definition.name, retryable: false }, cause);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async #setEnabled(workspaceId, params, enabled) {
|
|
485
601
|
return this.#serialize(workspaceId, async () => {
|
|
486
|
-
|
|
602
|
+
assertActionKeys(params, enabled ? ["alias", "overlay", "options"] : ["alias"]);
|
|
603
|
+
const alias = requiredString(params, "alias");
|
|
487
604
|
const snapshot = this.#snapshot(workspaceId);
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
605
|
+
const current = this.#available(snapshot.state).get(alias);
|
|
606
|
+
const configured = enabled
|
|
607
|
+
? this.#overlayDefinitions(snapshot.state, params.overlay).get(alias)
|
|
608
|
+
: undefined;
|
|
609
|
+
if (current === undefined && configured === undefined) {
|
|
610
|
+
throw actionError("server-not-found", 404, `MCP server alias '${alias}' is not available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
611
|
+
}
|
|
612
|
+
if (current === undefined && !enabled)
|
|
613
|
+
throw new Error("Disabled MCP target is absent.");
|
|
614
|
+
const candidate = configured ?? current?.definition;
|
|
615
|
+
if (candidate === undefined)
|
|
616
|
+
throw new Error("Enabled MCP target is absent.");
|
|
617
|
+
const definition = enabled
|
|
618
|
+
? this.#definitionWithOptions(candidate, params.options)
|
|
619
|
+
: structuredClone(candidate);
|
|
620
|
+
const attachment = snapshot.attachments.get(alias);
|
|
621
|
+
const retryUnavailable = enabled
|
|
622
|
+
&& current?.enabled === true
|
|
623
|
+
&& sameDefinition(current.definition, definition)
|
|
624
|
+
&& attachment?.kind === "unavailable";
|
|
625
|
+
const definitionChanged = current === undefined
|
|
626
|
+
|| !sameDefinition(current.definition, definition);
|
|
627
|
+
if (current?.enabled === enabled && !definitionChanged && !retryUnavailable) {
|
|
628
|
+
if (attachment?.kind === "authorization-required") {
|
|
629
|
+
return {
|
|
630
|
+
status: 202,
|
|
631
|
+
authorization: { url: attachment.authorizationUrl },
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return { status: 200, server: this.#summary(alias, current, attachment) };
|
|
491
635
|
}
|
|
492
|
-
const
|
|
493
|
-
|
|
636
|
+
const state = retryUnavailable
|
|
637
|
+
? cloneState(snapshot.state)
|
|
638
|
+
: enabled
|
|
639
|
+
? this.#stateWithDefinition(snapshot.state, alias, definition, true)
|
|
640
|
+
: this.#stateWithEnabled(snapshot.state, alias, current, false);
|
|
641
|
+
const result = await this.#applyState(workspaceId, state, {
|
|
642
|
+
...(retryUnavailable ? { force: new Set([alias]) } : {}),
|
|
494
643
|
authorizationDisposition: "defer-mutation",
|
|
644
|
+
preparationFailureDisposition: "reject",
|
|
495
645
|
});
|
|
496
646
|
if (result.authorization !== undefined) {
|
|
497
|
-
await this.#setPending(workspaceId,
|
|
498
|
-
operation: "
|
|
499
|
-
|
|
500
|
-
definition,
|
|
647
|
+
await this.#setPending(workspaceId, alias, {
|
|
648
|
+
operation: "enable",
|
|
649
|
+
expectedState: structuredClone(snapshot.state.servers[alias] ?? null),
|
|
650
|
+
expectedDefinition: structuredClone(current?.definition ?? null),
|
|
651
|
+
expectedEnabled: current?.enabled ?? false,
|
|
652
|
+
definition: {
|
|
653
|
+
definition: structuredClone(definition),
|
|
654
|
+
source: this.#definitionSource(alias, definition),
|
|
655
|
+
},
|
|
501
656
|
connection: result.authorization.connection,
|
|
502
657
|
authorizationUrl: result.authorization.authorizationUrl,
|
|
503
658
|
});
|
|
@@ -506,43 +661,86 @@ export default class Module {
|
|
|
506
661
|
authorization: { url: result.authorization.authorizationUrl },
|
|
507
662
|
};
|
|
508
663
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
664
|
+
await this.#clearPending(workspaceId, alias);
|
|
665
|
+
const committed = this.#snapshot(workspaceId);
|
|
666
|
+
const committedDefinition = this.#available(committed.state).get(alias);
|
|
667
|
+
if (committedDefinition === undefined)
|
|
668
|
+
throw new Error("Committed MCP definition is absent.");
|
|
669
|
+
return {
|
|
670
|
+
status: 200,
|
|
671
|
+
server: this.#summary(alias, committedDefinition, committed.attachments.get(alias)),
|
|
672
|
+
};
|
|
514
673
|
});
|
|
515
674
|
}
|
|
516
|
-
#
|
|
675
|
+
async #remove(workspaceId, params) {
|
|
676
|
+
return this.#serialize(workspaceId, async () => {
|
|
677
|
+
assertActionKeys(params, ["alias"]);
|
|
678
|
+
const alias = requiredString(params, "alias");
|
|
679
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
680
|
+
const available = this.#available(snapshot.state).get(alias);
|
|
681
|
+
if (available === undefined) {
|
|
682
|
+
throw actionError("server-not-found", 404, `MCP server alias '${alias}' is not available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
683
|
+
}
|
|
684
|
+
if (available.source === "service") {
|
|
685
|
+
throw actionError("server-service-owned", 409, `MCP server alias '${alias}' is service-owned and can be disabled, not removed.`, { workspaceId, alias, recovery: `Use workspace.mcp.disable for '${alias}'.`, retryable: false });
|
|
686
|
+
}
|
|
687
|
+
const state = cloneState(snapshot.state);
|
|
688
|
+
const service = this.#defaults.get(alias);
|
|
689
|
+
if (service === undefined) {
|
|
690
|
+
delete state.servers[alias];
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
state.servers[alias] = {
|
|
694
|
+
kind: "service",
|
|
695
|
+
enabled: false,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
await this.#applyState(workspaceId, state, {
|
|
699
|
+
authorizationDisposition: "defer-mutation",
|
|
700
|
+
preparationFailureDisposition: "reject",
|
|
701
|
+
});
|
|
702
|
+
await this.#clearPending(workspaceId, alias);
|
|
703
|
+
return { status: 200, alias, removed: true };
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
#oauthCompletionState(workspaceId, alias, pending) {
|
|
517
707
|
const snapshot = this.#snapshot(workspaceId);
|
|
518
|
-
const
|
|
708
|
+
const available = this.#available(snapshot.state).get(alias);
|
|
709
|
+
const current = available?.definition ?? null;
|
|
519
710
|
const expected = pending.expectedDefinition;
|
|
520
|
-
if ((
|
|
521
|
-
|| (expected
|
|
522
|
-
|
|
711
|
+
if (!sameServerState(snapshot.state.servers[alias] ?? null, pending.expectedState)
|
|
712
|
+
|| (expected === null && current !== null)
|
|
713
|
+
|| (expected !== null && (current === null || !sameDefinition(expected, current)))
|
|
714
|
+
|| (available?.enabled ?? false) !== pending.expectedEnabled) {
|
|
715
|
+
throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, {
|
|
523
716
|
workspaceId,
|
|
524
|
-
|
|
717
|
+
alias,
|
|
525
718
|
recovery: "Start authorization again from the server's current definition.",
|
|
526
719
|
retryable: false,
|
|
527
720
|
});
|
|
528
721
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
state.servers[
|
|
532
|
-
kind: "
|
|
722
|
+
if (pending.operation === "add") {
|
|
723
|
+
const state = cloneState(snapshot.state);
|
|
724
|
+
state.servers[alias] = {
|
|
725
|
+
kind: "workspace",
|
|
533
726
|
definition: structuredClone(pending.definition.definition),
|
|
727
|
+
enabled: true,
|
|
534
728
|
};
|
|
729
|
+
return state;
|
|
535
730
|
}
|
|
536
|
-
return state;
|
|
731
|
+
return this.#stateWithDefinition(snapshot.state, alias, pending.definition.definition, true);
|
|
537
732
|
}
|
|
538
733
|
async #completeOAuth(workspaceId, params) {
|
|
539
734
|
return this.#serialize(workspaceId, async () => {
|
|
540
|
-
|
|
735
|
+
assertActionKeys(params, ["alias", "callbackUrl"]);
|
|
736
|
+
const alias = requiredString(params, "alias");
|
|
541
737
|
const callbackUrl = requiredString(params, "callbackUrl");
|
|
542
|
-
const key = this.#pendingKey(workspaceId,
|
|
738
|
+
const key = this.#pendingKey(workspaceId, alias);
|
|
543
739
|
const pending = this.#pending.get(key);
|
|
544
740
|
if (pending === undefined) {
|
|
545
|
-
|
|
741
|
+
// {§oauth-lifetime} — restart during pending authorization
|
|
742
|
+
// surfaces as a factual not-pending state, never a secret replay.
|
|
743
|
+
throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workspaceId, alias, retryable: false });
|
|
546
744
|
}
|
|
547
745
|
if (pending.prepared === undefined) {
|
|
548
746
|
try {
|
|
@@ -554,23 +752,30 @@ export default class Module {
|
|
|
554
752
|
pending.prepared = prepared;
|
|
555
753
|
}
|
|
556
754
|
catch (cause) {
|
|
557
|
-
throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${
|
|
755
|
+
throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workspaceId, alias, retryable: false }, cause);
|
|
558
756
|
}
|
|
559
757
|
}
|
|
560
|
-
const state = this.#oauthCompletionState(workspaceId,
|
|
758
|
+
const state = this.#oauthCompletionState(workspaceId, alias, pending);
|
|
561
759
|
await this.#applyState(workspaceId, state, {
|
|
562
|
-
force: new Set([
|
|
563
|
-
prepared: new Map([[
|
|
760
|
+
force: new Set([alias]),
|
|
761
|
+
prepared: new Map([[alias, pending.prepared]]),
|
|
564
762
|
authorizationDisposition: "defer-mutation",
|
|
763
|
+
preparationFailureDisposition: "reject",
|
|
565
764
|
});
|
|
566
765
|
this.#pending.delete(key);
|
|
567
|
-
const
|
|
568
|
-
|
|
766
|
+
const committed = this.#snapshot(workspaceId);
|
|
767
|
+
const attached = committed.attachments.get(alias);
|
|
768
|
+
if (attached === undefined || attached.kind !== "active") {
|
|
569
769
|
throw new Error("Authorized MCP attachment is absent.");
|
|
570
|
-
|
|
770
|
+
}
|
|
771
|
+
const available = this.#available(committed.state).get(alias);
|
|
772
|
+
if (available === undefined)
|
|
773
|
+
throw new Error("Authorized MCP definition is absent.");
|
|
774
|
+
return { status: 200, server: this.#summary(alias, available, attached) };
|
|
571
775
|
});
|
|
572
776
|
}
|
|
573
777
|
async #complete(workspaceId, params) {
|
|
778
|
+
assertActionKeys(params, ["server", "ref", "argument", "context"]);
|
|
574
779
|
const server = requiredString(params, "server");
|
|
575
780
|
const attachment = this.#snapshot(workspaceId).attachments.get(server);
|
|
576
781
|
if (attachment === undefined || attachment.kind !== "active") {
|
|
@@ -598,14 +803,14 @@ export default class Module {
|
|
|
598
803
|
const executor = new McpExecutor({ runtime: name, glyph: "🔌" }, attachment.connection, {
|
|
599
804
|
tools: attachment.definition.tools ?? null,
|
|
600
805
|
read: attachment.definition.read ?? [],
|
|
601
|
-
});
|
|
806
|
+
}, this.#summaries.tools);
|
|
602
807
|
const availability = await executor.requireAvailable();
|
|
603
808
|
const refreshed = {
|
|
604
809
|
...attachment,
|
|
605
810
|
executor,
|
|
606
811
|
runtime: {
|
|
607
812
|
namespaceOwner: OWNER,
|
|
608
|
-
decl: runtimeDecl(name),
|
|
813
|
+
decl: runtimeDecl(name, serverSummary(name, executor.catalog, this.#summaries.servers.get(name)), this.#expanded.has(name)),
|
|
609
814
|
executor,
|
|
610
815
|
availability,
|
|
611
816
|
scheme: new McpResources(name, attachment.connection, executor.catalog),
|
|
@@ -656,7 +861,9 @@ export default class Module {
|
|
|
656
861
|
const connections = new Set();
|
|
657
862
|
for (const snapshot of this.#workspaces.values()) {
|
|
658
863
|
for (const attachment of snapshot.attachments.values()) {
|
|
659
|
-
|
|
864
|
+
const connection = attachmentConnection(attachment);
|
|
865
|
+
if (connection !== undefined)
|
|
866
|
+
connections.add(connection);
|
|
660
867
|
}
|
|
661
868
|
}
|
|
662
869
|
for (const pending of this.#pending.values())
|