@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/dist/Module.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Problems, Validator, } from "@plurnk/plurnk-contracts";
2
- import { SdkError, SdkErrorCode, } from "@modelcontextprotocol/client";
3
- import ServerConnection, { AuthorizationRequiredError } from "./client.js";
4
- import { serviceDefinitions } from "./config.js";
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
- if (lacksRequiredProtocolRevision(cause)) {
49
- return actionError("protocol-revision-unsupported", 502, `MCP server '${definition.name}' did not offer required revision ${MCP_PROTOCOL_VERSION} through server/discover; upgrade or replace the legacy endpoint.`, {
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
- requiredRevision: MCP_PROTOCOL_VERSION,
53
- requiredMethod: "server/discover",
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 !== "attached" && value.kind !== "detached")) {
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.kind === "detached") {
101
- assertExactKeys(value, ["kind"], `MCP workspace server '${name}'`);
102
- parsed[name] = { kind: "detached" };
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: "attached", definition };
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 (_params, context) => this.#list(workspaceIdOf(context)));
167
- action("workspace.mcp.attach", async (params, context) => this.#change("attach", workspaceIdOf(context), params));
168
- action("workspace.mcp.replace", async (params, context) => this.#change("replace", workspaceIdOf(context), params));
169
- action("workspace.mcp.detach", async (params, context) => this.#detach(workspaceIdOf(context), params));
170
- action("workspace.mcp.reconnect", async (params, context) => this.#reconnect(workspaceIdOf(context), params));
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
- #effective(state) {
194
- const effective = new Map([...this.#defaults].map(([name, definition]) => [
198
+ #available(state) {
199
+ const available = new Map([...this.#defaults].map(([name, definition]) => [
195
200
  name,
196
- { definition: structuredClone(definition), source: "service" },
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 === "detached")
200
- effective.delete(name);
201
- else
202
- effective.set(name, {
203
- definition: structuredClone(value.definition),
204
- source: "workspace",
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([...effective].toSorted(([left], [right]) => left.localeCompare(right)));
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.#effective(state);
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.connection.assertReplaceable();
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
- const attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
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
- fresh.push(attachment);
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()].map(({ connection }) => connection));
357
+ const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
323
358
  const obsolete = [...current?.attachments.values() ?? []]
324
- .map(({ connection }) => connection)
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: "reconnect",
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(name, attachment) {
381
- const definition = attachment.definition;
417
+ #summary(alias, available, attachment) {
418
+ const definition = available.definition;
382
419
  const base = {
383
- name,
384
- source: attachment.source,
420
+ alias,
421
+ source: available.source,
385
422
  transport: definition.transport,
386
- state: attachment.kind === "active" ? "connected" : "authorization-required",
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.kind !== "active")
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
- #list(workspaceId) {
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: [...snapshot.attachments]
485
+ servers: [...summaries]
408
486
  .toSorted(([left], [right]) => left.localeCompare(right))
409
- .map(([name, attachment]) => this.#summary(name, attachment)),
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
- return structuredClone(Validator.assertMcpServerDefinition(params.server));
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, "The MCP server definition is invalid.", { field: "server", retryable: false }, cause);
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 #change(operation, workspaceId, params) {
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
- const effective = this.#effective(snapshot.state);
425
- const exists = effective.has(definition.name);
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[definition.name] = {
433
- kind: "attached",
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, definition.name, {
442
- operation,
443
- expectedDefinition: exists
444
- ? structuredClone(effective.get(definition.name)?.definition ?? null)
445
- : null,
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 attached = this.#snapshot(workspaceId).attachments.get(definition.name);
456
- if (attached === undefined)
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
- const state = cloneState(snapshot.state);
470
- if (this.#defaults.has(name)) {
471
- state.servers[name] = { kind: "detached" };
472
- }
473
- else {
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
- async #reconnect(workspaceId, params) {
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
- const name = requiredString(params, "name");
602
+ assertActionKeys(params, enabled ? ["alias", "overlay", "options"] : ["alias"]);
603
+ const alias = requiredString(params, "alias");
487
604
  const snapshot = this.#snapshot(workspaceId);
488
- const definition = this.#effective(snapshot.state).get(name);
489
- if (definition === undefined) {
490
- throw actionError("server-not-found", 404, `MCP server '${name}' is not attached to this workspace.`, { workspaceId, name, retryable: false });
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 result = await this.#applyState(workspaceId, snapshot.state, {
493
- force: new Set([name]),
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, name, {
498
- operation: "reconnect",
499
- expectedDefinition: structuredClone(definition.definition),
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
- const attached = this.#snapshot(workspaceId).attachments.get(name);
510
- if (attached === undefined)
511
- throw new Error("Reconnected MCP attachment is absent.");
512
- await this.#clearPending(workspaceId, name, attached.connection);
513
- return { status: 200, server: this.#summary(name, attached) };
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
- #oauthCompletionState(workspaceId, name, pending) {
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 current = this.#effective(snapshot.state).get(name)?.definition ?? null;
708
+ const available = this.#available(snapshot.state).get(alias);
709
+ const current = available?.definition ?? null;
519
710
  const expected = pending.expectedDefinition;
520
- if ((expected === null && current !== null)
521
- || (expected !== null && (current === null || !sameDefinition(expected, current)))) {
522
- throw actionError("oauth-target-conflict", 409, `MCP server '${name}' changed while its OAuth authorization was pending.`, {
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
- name,
717
+ alias,
525
718
  recovery: "Start authorization again from the server's current definition.",
526
719
  retryable: false,
527
720
  });
528
721
  }
529
- const state = cloneState(snapshot.state);
530
- if (pending.operation === "attach" || pending.operation === "replace") {
531
- state.servers[name] = {
532
- kind: "attached",
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
- const name = requiredString(params, "name");
735
+ assertActionKeys(params, ["alias", "callbackUrl"]);
736
+ const alias = requiredString(params, "alias");
541
737
  const callbackUrl = requiredString(params, "callbackUrl");
542
- const key = this.#pendingKey(workspaceId, name);
738
+ const key = this.#pendingKey(workspaceId, alias);
543
739
  const pending = this.#pending.get(key);
544
740
  if (pending === undefined) {
545
- throw actionError("oauth-not-pending", 404, `MCP server '${name}' has no pending OAuth authorization.`, { workspaceId, name, retryable: false });
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 '${name}' could not be completed.`, { workspaceId, name, retryable: false }, cause);
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, name, pending);
758
+ const state = this.#oauthCompletionState(workspaceId, alias, pending);
561
759
  await this.#applyState(workspaceId, state, {
562
- force: new Set([name]),
563
- prepared: new Map([[name, pending.prepared]]),
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 attached = this.#snapshot(workspaceId).attachments.get(name);
568
- if (attached === undefined)
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
- return { status: 200, server: this.#summary(name, attached) };
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
- connections.add(attachment.connection);
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())