@plurnk/plurnk-mcp 1.8.0 → 1.9.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
@@ -4,7 +4,17 @@ import { expandedServerNames, overlayServerDefinitions, serviceDefinitions, serv
4
4
  import McpExecutor, { runtimeDecl, serverSummary } from "./McpExecutor.js";
5
5
  import McpResources from "./McpResources.js";
6
6
  const OWNER = "@plurnk/plurnk-mcp";
7
- const STATE_VERSION = 1;
7
+ const FAMILY = "mcp";
8
+ const NONEMPTY_STRING = { type: "string", minLength: 1 };
9
+ const OPEN_OBJECT = { type: "object", additionalProperties: true };
10
+ const MCP_DEFINITION = { $ref: "https://schemas.plurnk.xyz/v0/McpServerDefinition.json" };
11
+ const MUTATION_RESULT = { $ref: "https://schemas.plurnk.xyz/v0/FunctionalityMutationResult.json" };
12
+ const actionInput = (properties, required = []) => ({
13
+ type: "object",
14
+ additionalProperties: false,
15
+ properties,
16
+ ...(required.length === 0 ? {} : { required: [...required] }),
17
+ });
8
18
  const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
9
19
  class ModuleActionError extends Error {
10
20
  problem;
@@ -55,93 +65,60 @@ export const closeConnections = async (connections) => {
55
65
  const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
56
66
  ? value
57
67
  : null;
58
- const assertExactKeys = (value, allowed, owner) => {
59
- const extras = Object.keys(value).filter((key) => !allowed.includes(key));
60
- if (extras.length > 0)
61
- throw new Error(`${owner} contains unsupported field(s): ${extras.join(", ")}.`);
62
- };
63
- const emptyState = () => ({ version: STATE_VERSION, servers: {} });
64
- const parseState = (source) => {
65
- if (source === null)
66
- return emptyState();
67
- const state = objectOf(source);
68
- if (state === null)
69
- throw new Error("MCP workspace state must be an object.");
70
- assertExactKeys(state, ["version", "servers"], "MCP workspace state");
71
- if (state.version !== STATE_VERSION) {
72
- throw new Error(`MCP workspace state version must be ${STATE_VERSION}.`);
73
- }
74
- const servers = objectOf(state.servers);
75
- if (servers === null)
76
- throw new Error("MCP workspace state servers must be an object.");
77
- const parsed = {};
78
- for (const [name, raw] of Object.entries(servers).toSorted(([left], [right]) => left.localeCompare(right))) {
79
- const value = objectOf(raw);
80
- if (value === null || (value.kind !== "service" && value.kind !== "workspace")) {
81
- throw new Error(`MCP workspace server '${name}' has an invalid state.`);
82
- }
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 };
89
- continue;
90
- }
91
- assertExactKeys(value, ["kind", "definition", "enabled"], `MCP workspace server '${name}'`);
92
- const definition = structuredClone(Validator.assertMcpServerDefinition(value.definition));
93
- if (definition.name !== name) {
94
- throw new Error(`MCP workspace server key '${name}' does not match definition '${definition.name}'.`);
95
- }
96
- parsed[name] = { kind: "workspace", definition, enabled: value.enabled };
68
+ const assertActionKeys = (params, allowed) => {
69
+ const extras = Object.keys(params).filter((key) => !allowed.includes(key));
70
+ if (extras.length > 0) {
71
+ throw actionError("parameters-invalid", 400, `Unsupported parameter(s): ${extras.join(", ")}.`, { retryable: false });
97
72
  }
98
- return { version: STATE_VERSION, servers: parsed };
99
73
  };
100
- const persistedState = (state) => Object.keys(state.servers).length === 0 ? null : state;
101
- const cloneState = (state) => parseState(structuredClone(state));
102
- const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
103
- const sameServerState = (left, right) => JSON.stringify(left) === JSON.stringify(right);
104
- const requiredString = (params, field) => {
105
- const value = params[field];
74
+ const requiredString = (params, key) => {
75
+ const value = params[key];
106
76
  if (typeof value !== "string" || value.length === 0) {
107
- throw actionError("parameter-required", 400, `MCP action requires a non-empty '${field}' string.`, { field, retryable: false });
77
+ throw actionError("parameters-invalid", 400, `'${key}' must be a non-empty string.`, { field: key, retryable: false });
108
78
  }
109
79
  return value;
110
80
  };
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
- };
117
- const workspaceIdOf = (context) => {
118
- if (context.scope !== "workspace") {
119
- throw actionError("workspace-context-required", 500, "MCP action received no workspace context.");
120
- }
121
- return context.workspaceId;
81
+ const workerIdentityOf = (context) => {
82
+ if (context.scope !== "worker")
83
+ throw new Error("MCP actions require a worker-scoped context.");
84
+ return { workspaceId: context.workspaceId, workerId: context.workerId };
122
85
  };
86
+ const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
123
87
  const statusOf = (error) => {
124
- const candidate = objectOf(error);
125
- const direct = objectOf(candidate?.problem);
126
- if (typeof direct?.status === "number")
127
- return direct.status;
128
- const result = objectOf(candidate?.result);
129
- const problem = objectOf(result?.problem);
88
+ if (typeof error !== "object" || error === null)
89
+ return null;
90
+ const problem = error.problem
91
+ ?? error.result?.problem;
130
92
  return typeof problem?.status === "number" ? problem.status : null;
131
93
  };
94
+ const catalogDetail = (executor) => {
95
+ const catalog = executor.catalog;
96
+ return {
97
+ protocolVersion: catalog.protocolVersion,
98
+ server: catalog.server ?? null,
99
+ capabilities: catalog.capabilities,
100
+ tools: catalog.tools.map(({ name }) => name).toSorted(),
101
+ resources: catalog.resources.length,
102
+ resourceTemplates: catalog.resourceTemplates.length,
103
+ prompts: catalog.prompts.length,
104
+ };
105
+ };
132
106
  export default class Module {
133
107
  #env;
134
108
  #summaries;
135
109
  #expanded;
136
110
  #defaults;
137
111
  #defaultEnabled;
138
- #workspaces = new Map();
139
- // {§oauth-lifetime} — pending candidates are process-memory per
140
- // (workspace, alias); a restart loses them by design.
112
+ // The committed attachments per Worker: the adapter's mirror of the snapshot
113
+ // the coordinator holds, for continuations and refresh.
114
+ #attachments = new Map();
115
+ #identities = new Map();
141
116
  #pending = new Map();
142
- #locks = new Map();
117
+ #dirty = new Set();
118
+ #connections = new Set();
143
119
  #refreshTimers = new Map();
144
- #seam;
120
+ #retainWorker = new Map();
121
+ #handle;
145
122
  #closed = false;
146
123
  static init(options = {}) {
147
124
  return new Module(options.env ?? process.env);
@@ -154,96 +131,182 @@ export default class Module {
154
131
  this.#expanded = new Set(expandedServerNames(environ));
155
132
  }
156
133
  async setup(seam) {
157
- this.#seam = seam;
158
- seam.registerWorkspaceCapabilityProvider(OWNER, {
159
- hydrate: async (workspaceId) => this.#serialize(workspaceId, async () => {
160
- const state = parseState(await seam.readWorkspaceModuleState(workspaceId, OWNER));
161
- await this.#applyState(workspaceId, state, {
162
- authorizationDisposition: "publish-required",
163
- preparationFailureDisposition: "publish-unavailable",
164
- });
165
- }),
134
+ this.#handle = seam.registerFunctionalityAdapter({
135
+ family: FAMILY,
136
+ namespaceOwner: OWNER,
137
+ summary: "Manage this Worker's MCP server attachments: list, discover, add, enable, disable, remove.",
138
+ definitionSchema: MCP_DEFINITION,
139
+ available: async () => [...this.#defaults].map(([name, definition]) => ({
140
+ alias: name,
141
+ definition: structuredClone(definition),
142
+ enabled: this.#defaultEnabled.has(name),
143
+ })),
144
+ discover: (query) => this.#discover(query),
145
+ admit: async (input) => this.#admit(input),
146
+ prepare: (preparation) => this.#prepare(preparation),
147
+ teardown: (snapshot, identity) => this.#teardown(snapshot, identity),
148
+ });
149
+ // Protocol continuations beneath the common grammar.
150
+ seam.registerModuleAction({
151
+ name: "worker.mcp.oauth.complete",
152
+ scope: "worker",
153
+ inputSchema: actionInput({ alias: NONEMPTY_STRING, callbackUrl: NONEMPTY_STRING }, ["alias", "callbackUrl"]),
154
+ outputSchema: MUTATION_RESULT,
155
+ handler: (params, context) => this.#completeOAuth(workerIdentityOf(context), params),
166
156
  });
167
- const action = (name, handler) => seam.registerModuleAction({ name, scope: "workspace", handler });
168
- action("workspace.mcp.list", async (params, context) => {
169
- assertActionKeys(params, ["overlay"]);
170
- return this.#list(workspaceIdOf(context), params.overlay);
157
+ seam.registerModuleAction({
158
+ name: "worker.mcp.complete",
159
+ scope: "worker",
160
+ inputSchema: actionInput({
161
+ server: NONEMPTY_STRING,
162
+ ref: OPEN_OBJECT,
163
+ argument: OPEN_OBJECT,
164
+ context: OPEN_OBJECT,
165
+ }, ["server", "ref", "argument"]),
166
+ outputSchema: OPEN_OBJECT,
167
+ handler: (params, context) => this.#complete(workerIdentityOf(context).workerId, params),
171
168
  });
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));
176
- action("workspace.mcp.oauth.complete", async (params, context) => this.#completeOAuth(workspaceIdOf(context), params));
177
- action("workspace.mcp.complete", async (params, context) => this.#complete(workspaceIdOf(context), params));
178
169
  }
179
- #pendingKey(workspaceId, name) {
180
- return `${workspaceId}:${name}`;
170
+ #assertOpen() {
171
+ if (this.#closed)
172
+ throw new Error("MCP module is closed.");
181
173
  }
182
- async #serialize(workspaceId, run) {
183
- const prior = this.#locks.get(workspaceId) ?? Promise.resolve();
184
- let release = () => undefined;
185
- const barrier = new Promise((resolve) => { release = resolve; });
186
- const queued = prior.then(() => barrier, () => barrier);
187
- this.#locks.set(workspaceId, queued);
188
- await prior.catch(() => undefined);
189
- try {
190
- return await run();
191
- }
192
- finally {
193
- release();
194
- if (this.#locks.get(workspaceId) === queued)
195
- this.#locks.delete(workspaceId);
174
+ #handleOrThrow() {
175
+ if (this.#handle === undefined)
176
+ throw new Error("MCP module is not set up.");
177
+ return this.#handle;
178
+ }
179
+ #pendingKey(workerId, name) {
180
+ return `${workerId}:${name}`;
181
+ }
182
+ #retain(workerId) {
183
+ const retain = this.#retainWorker.get(workerId);
184
+ if (retain === undefined) {
185
+ throw new Error(`MCP worker ${workerId} has no Functionality residency context.`);
196
186
  }
187
+ return retain();
197
188
  }
198
- #available(state) {
199
- const available = new Map([...this.#defaults].map(([name, definition]) => [
200
- name,
201
- {
202
- definition: structuredClone(definition),
203
- source: "service",
204
- enabled: this.#defaultEnabled.has(name),
205
- },
206
- ]));
207
- for (const [name, value] of Object.entries(state.servers)) {
208
- if (value.kind === "service") {
209
- const configured = available.get(name);
210
- if (configured !== undefined)
211
- available.set(name, { ...configured, enabled: value.enabled });
189
+ async #closeOwned(connections) {
190
+ const owned = [...new Set(connections)];
191
+ const results = await Promise.allSettled(owned.map((connection) => connection.close()));
192
+ const errors = [];
193
+ for (const [index, result] of results.entries()) {
194
+ if (result.status === "fulfilled") {
195
+ this.#connections.delete(owned[index]);
212
196
  continue;
213
197
  }
214
- available.set(name, {
215
- definition: structuredClone(value.definition),
216
- source: "workspace",
217
- enabled: value.enabled,
198
+ errors.push(...errorsOf(result.reason));
199
+ }
200
+ if (errors.length > 0)
201
+ throw new AggregateError(errors, "MCP connection shutdown failed");
202
+ }
203
+ // {§mcp-discovery} — inert: a direct target is probed and disconnected;
204
+ // caller configuration is parsed into candidates; registry search waits for
205
+ // a configured downstream registry.
206
+ async #discover(query) {
207
+ this.#assertOpen();
208
+ const candidates = [];
209
+ if (query.configuration !== undefined) {
210
+ let definitions;
211
+ try {
212
+ definitions = overlayServerDefinitions(structuredClone(query.configuration), this.#defaults);
213
+ }
214
+ catch (cause) {
215
+ throw actionError("configuration-invalid", 400, "The supplied MCP configuration is invalid.", { retryable: false }, cause);
216
+ }
217
+ for (const [name, definition] of definitions) {
218
+ candidates.push({
219
+ alias: name,
220
+ definition,
221
+ provenance: { kind: "client-configuration", source: `PLURNK_MCP_${name.toUpperCase().replaceAll("-", "_")}` },
222
+ summary: `${definition.transport} ${definition.transport === "http" ? definition.url : definition.command}`,
223
+ });
224
+ }
225
+ }
226
+ if (query.source !== undefined) {
227
+ // A URL is a Streamable HTTP target; anything else is a command line
228
+ // split on whitespace (exact paths with spaces are added, not probed).
229
+ const source = query.source;
230
+ const [command = "", ...args] = source.trim().split(/\s+/u);
231
+ const definition = /^https?:\/\//u.test(source)
232
+ ? { name: "discovered", transport: "http", url: source }
233
+ : { name: "discovered", transport: "stdio", command, args };
234
+ Validator.assertMcpServerDefinition(definition);
235
+ const connection = new ServerConnection(definition, this.#env, {
236
+ onCatalogChanged: () => undefined,
237
+ onInfrastructureError: () => undefined,
218
238
  });
239
+ this.#connections.add(connection);
240
+ const executor = new McpExecutor({ runtime: "discovered", glyph: "🔌" }, connection, () => () => undefined);
241
+ try {
242
+ await executor.requireAvailable();
243
+ const catalog = executor.catalog;
244
+ const name = catalog.server?.name?.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "discovered";
245
+ candidates.push({
246
+ alias: name,
247
+ definition: { ...definition, name },
248
+ provenance: { kind: "direct-target", source },
249
+ summary: serverSummary(name, catalog, undefined),
250
+ });
251
+ }
252
+ catch (cause) {
253
+ if (cause instanceof AuthorizationRequiredError) {
254
+ candidates.push({
255
+ alias: "discovered",
256
+ definition,
257
+ provenance: { kind: "direct-target", source },
258
+ summary: "The server requires authorization before it can be inspected; add it and complete the authorization.",
259
+ });
260
+ }
261
+ else {
262
+ throw actionError("discover-failed", 502, `MCP target '${source}' could not be inspected.`, { source, retryable: true }, cause);
263
+ }
264
+ }
265
+ finally {
266
+ await this.#closeOwned([connection]);
267
+ }
268
+ }
269
+ if (query.query !== undefined && query.source === undefined && query.configuration === undefined) {
270
+ throw actionError("registry-not-configured", 501, "MCP registry search requires a configured downstream registry; none is configured.", { query: query.query, recovery: "Discover an explicit source (URL or command) or configure a registry.", retryable: false });
219
271
  }
220
- return new Map([...available].toSorted(([left], [right]) => left.localeCompare(right)));
272
+ return candidates;
221
273
  }
222
- #enabled(state) {
223
- return new Map([...this.#available(state)]
224
- .filter(([, value]) => value.enabled)
225
- .map(([name, { definition, source }]) => [name, { definition, source }]));
274
+ #admit(input) {
275
+ const params = objectOf(input) ?? {};
276
+ let definition;
277
+ try {
278
+ definition = structuredClone(Validator.assertMcpServerDefinition(structuredClone(params.definition)));
279
+ }
280
+ catch (cause) {
281
+ throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { retryable: false }, cause);
282
+ }
283
+ const alias = typeof params.alias === "string" ? params.alias : definition.name;
284
+ if (alias !== definition.name) {
285
+ throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
286
+ }
287
+ return { alias, definition };
226
288
  }
227
- async #prepareAttachment(workspaceId, effective, connection) {
228
- const definition = effective.definition;
289
+ async #prepareAttachment(workerId, definition, connection) {
290
+ this.#assertOpen();
229
291
  const candidate = connection ?? new ServerConnection(definition, this.#env, {
230
292
  onCatalogChanged: (error) => {
231
293
  if (error !== null) {
232
294
  console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
233
295
  return;
234
296
  }
235
- this.#scheduleCatalogRefresh(workspaceId, definition.name);
297
+ this.#scheduleCatalogRefresh(workerId, definition.name);
236
298
  },
237
299
  onInfrastructureError: (error) => {
238
300
  console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
239
301
  },
240
302
  });
241
- const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
303
+ this.#connections.add(candidate);
304
+ const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workerId), { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
242
305
  try {
243
306
  const availability = await executor.requireAvailable();
244
307
  return {
245
308
  kind: "active",
246
- ...effective,
309
+ definition,
247
310
  connection: candidate,
248
311
  executor,
249
312
  runtime: {
@@ -259,14 +322,14 @@ export default class Module {
259
322
  if (cause instanceof AuthorizationRequiredError) {
260
323
  return {
261
324
  kind: "authorization-required",
262
- ...effective,
325
+ definition,
263
326
  connection: candidate,
264
327
  authorizationUrl: cause.authorizationUrl,
265
328
  };
266
329
  }
267
330
  let closeCause;
268
331
  try {
269
- await candidate.close();
332
+ await this.#closeOwned([candidate]);
270
333
  }
271
334
  catch (error) {
272
335
  closeCause = error;
@@ -274,512 +337,191 @@ export default class Module {
274
337
  throw preparationError(definition, cause, closeCause);
275
338
  }
276
339
  }
277
- async #applyState(workspaceId, state, options) {
278
- const seam = this.#seam;
279
- if (seam === undefined)
280
- throw new Error("MCP module is not set up.");
281
- const current = this.#workspaces.get(workspaceId);
282
- const effective = this.#enabled(state);
283
- const force = options.force ?? new Set();
284
- for (const [name, attachment] of current?.attachments ?? []) {
285
- const next = effective.get(name);
286
- if (next === undefined
287
- || force.has(name)
288
- || !sameDefinition(attachment.definition, next.definition))
340
+ // {§functionality-adapter} two-phase preparation: reuse unchanged live
341
+ // attachments, prepare the rest, and hand the coordinator runtimes and
342
+ // outcomes; commit adopts the set and closes what it no longer uses, abort
343
+ // closes only what this attempt opened.
344
+ async #prepare(preparation) {
345
+ this.#assertOpen();
346
+ const { workspaceId, workerId, enabled, failure, force } = preparation;
347
+ this.#identities.set(workerId, { workspaceId, workerId });
348
+ this.#retainWorker.set(workerId, preparation.retain);
349
+ const previous = preparation.previous ?? new Map();
350
+ for (const [name, attachment] of previous) {
351
+ const nextDefinition = enabled.get(name);
352
+ if (nextDefinition === undefined || force === name || !sameDefinition(attachment.definition, nextDefinition)) {
289
353
  attachmentConnection(attachment)?.assertReplaceable();
354
+ }
290
355
  }
291
356
  const next = new Map();
357
+ const outcomes = new Map();
292
358
  const fresh = [];
359
+ const consumedPending = new Map();
293
360
  try {
294
- for (const [name, definition] of effective) {
295
- const existing = current?.attachments.get(name);
361
+ for (const [name, value] of enabled) {
362
+ const definition = value;
363
+ const existing = previous.get(name);
364
+ const pending = this.#pending.get(this.#pendingKey(workerId, name));
296
365
  if (existing !== undefined
297
- && !force.has(name)
298
- && sameDefinition(existing.definition, definition.definition)) {
366
+ && force !== name
367
+ && !this.#dirty.has(this.#pendingKey(workerId, name))
368
+ && sameDefinition(existing.definition, definition)
369
+ && !(pending?.prepared !== undefined)) {
299
370
  next.set(name, existing);
300
371
  continue;
301
372
  }
302
- const supplied = options.prepared?.get(name);
303
373
  let attachment;
304
- try {
305
- attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
374
+ if (pending?.prepared !== undefined && sameDefinition(pending.definition, definition)) {
375
+ attachment = pending.prepared;
376
+ consumedPending.set(name, pending);
306
377
  }
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);
378
+ else {
379
+ try {
380
+ attachment = await this.#prepareAttachment(workerId, definition);
381
+ }
382
+ catch (cause) {
383
+ this.#assertOpen();
384
+ if (failure === "reject")
385
+ throw cause;
386
+ const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, cause);
387
+ attachment = { kind: "unavailable", definition, problem: structuredClone(refusal.problem) };
388
+ console.error(`MCP server '${name}' unavailable: ${refusal.problem.detail}`, refusal.cause ?? refusal);
389
+ }
390
+ if (attachment.kind !== "unavailable")
391
+ fresh.push(attachment);
319
392
  }
320
393
  next.set(name, attachment);
321
- if (attachment.kind !== "unavailable")
322
- fresh.push(attachment);
323
- }
324
- }
325
- catch (cause) {
326
- const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
327
- const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
328
- if (failures.length > 0) {
329
- throw new AggregateError([cause, ...failures], "MCP workspace preparation and cleanup failed.");
394
+ this.#dirty.delete(this.#pendingKey(workerId, name));
330
395
  }
331
- throw cause;
332
- }
333
- const authorization = [...fresh].find((attachment) => attachment.kind === "authorization-required");
334
- if (authorization !== undefined && options.authorizationDisposition === "defer-mutation") {
335
- const discard = fresh.filter((attachment) => attachment !== authorization);
336
- await closeConnections(discard.map(({ connection }) => connection));
337
- return { authorization };
338
- }
339
- const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
340
- try {
341
- await seam.replaceWorkspaceCapabilities({
342
- workspaceId,
343
- namespaceOwner: OWNER,
344
- state: persistedState(state),
345
- runtimes,
346
- });
347
396
  }
348
397
  catch (cause) {
349
- const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
398
+ const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
350
399
  const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
351
- if (failures.length > 0) {
352
- throw new AggregateError([cause, ...failures], "MCP workspace commit and cleanup failed.");
353
- }
400
+ if (failures.length > 0)
401
+ throw new AggregateError([cause, ...failures], "MCP worker preparation and cleanup failed.");
354
402
  throw cause;
355
403
  }
356
- this.#workspaces.set(workspaceId, { state: cloneState(state), attachments: next });
357
- const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
358
- const obsolete = [...current?.attachments.values() ?? []]
359
- .flatMap((attachment) => attachmentConnection(attachment) ?? [])
360
- .filter((connection) => !retained.has(connection));
361
- if (obsolete.length > 0) {
362
- try {
363
- await closeConnections(obsolete);
364
- }
365
- catch (cause) {
366
- throw actionError("obsolete-connection-close-failed", 500, "The MCP capability change committed, but an obsolete connection did not close cleanly.", { workspaceId, committed: true }, cause);
367
- }
368
- }
369
- if (options.authorizationDisposition === "publish-required") {
370
- for (const [name, attachment] of next) {
371
- if (attachment.kind !== "authorization-required")
372
- continue;
373
- await this.#setPending(workspaceId, name, {
374
- operation: "enable",
375
- expectedState: structuredClone(state.servers[name] ?? null),
376
- expectedDefinition: structuredClone(attachment.definition),
377
- expectedEnabled: true,
378
- definition: {
379
- definition: structuredClone(attachment.definition),
380
- source: attachment.source,
381
- },
382
- connection: attachment.connection,
383
- authorizationUrl: attachment.authorizationUrl,
384
- }, false);
404
+ for (const [name, attachment] of next) {
405
+ switch (attachment.kind) {
406
+ case "active":
407
+ outcomes.set(name, { state: "active", detail: catalogDetail(attachment.executor) });
408
+ break;
409
+ case "unavailable":
410
+ outcomes.set(name, { state: "unavailable", problem: attachment.problem });
411
+ break;
412
+ case "authorization-required":
413
+ outcomes.set(name, { state: "authorization-required", authorization: { url: attachment.authorizationUrl } });
414
+ break;
385
415
  }
386
416
  }
387
- return authorization === undefined ? {} : { authorization };
388
- }
389
- async #setPending(workspaceId, name, pending, closeExisting = true) {
390
- const key = this.#pendingKey(workspaceId, name);
391
- const existing = this.#pending.get(key);
392
- this.#pending.set(key, pending);
393
- if (closeExisting && existing !== undefined && existing.connection !== pending.connection) {
394
- await existing.connection.close();
395
- }
396
- }
397
- async #clearPending(workspaceId, name, retained) {
398
- const key = this.#pendingKey(workspaceId, name);
399
- const pending = this.#pending.get(key);
400
- this.#pending.delete(key);
401
- if (pending === undefined || pending.connection === retained)
402
- return;
403
- try {
404
- await pending.connection.close();
405
- }
406
- catch (cause) {
407
- throw actionError("pending-authorization-close-failed", 500, "The MCP capability change committed, but its superseded authorization request did not close cleanly.", { workspaceId, name, committed: true }, cause);
408
- }
409
- }
410
- #snapshot(workspaceId) {
411
- const snapshot = this.#workspaces.get(workspaceId);
412
- if (snapshot === undefined) {
413
- throw actionError("workspace-not-hydrated", 409, "MCP capabilities have not been hydrated for this workspace.", { workspaceId, recovery: "Retry after workspace initialization completes." });
414
- }
415
- return snapshot;
416
- }
417
- #summary(alias, available, attachment) {
418
- const definition = available.definition;
419
- const base = {
420
- alias,
421
- source: available.source,
422
- transport: definition.transport,
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",
432
- enabledTools: definition.tools ?? null,
433
- read: definition.read ?? [],
434
- };
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")
442
- return base;
443
- const catalog = attachment.executor.catalog;
417
+ const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
444
418
  return {
445
- ...base,
446
- protocolVersion: catalog.protocolVersion,
447
- server: catalog.server ?? null,
448
- capabilities: catalog.capabilities,
449
- tools: catalog.tools.map(({ name: toolName }) => toolName).toSorted(),
450
- resources: catalog.resources.length,
451
- resourceTemplates: catalog.resourceTemplates.length,
452
- prompts: catalog.prompts.length,
419
+ runtimes,
420
+ documents: [],
421
+ outcomes,
422
+ snapshot: next,
423
+ commit: async () => {
424
+ this.#attachments.set(workerId, next);
425
+ const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
426
+ const pendingConnections = new Set([...this.#pending.values()].map(({ connection }) => connection));
427
+ const obsolete = [...previous.values()]
428
+ .flatMap((attachment) => attachmentConnection(attachment) ?? [])
429
+ .filter((connection) => !retained.has(connection) && !pendingConnections.has(connection));
430
+ for (const [name, pending] of consumedPending) {
431
+ this.#pending.delete(this.#pendingKey(workerId, name));
432
+ pending.releaseWorker();
433
+ }
434
+ for (const [name, attachment] of next) {
435
+ if (attachment.kind !== "authorization-required")
436
+ continue;
437
+ const key = this.#pendingKey(workerId, name);
438
+ const current = this.#pending.get(key);
439
+ if (current?.connection === attachment.connection)
440
+ continue;
441
+ this.#pending.set(key, {
442
+ definition: attachment.definition,
443
+ connection: attachment.connection,
444
+ authorizationUrl: attachment.authorizationUrl,
445
+ releaseWorker: this.#retain(workerId),
446
+ });
447
+ if (current !== undefined) {
448
+ current.releaseWorker();
449
+ if (!retained.has(current.connection))
450
+ obsolete.push(current.connection);
451
+ }
452
+ }
453
+ if (obsolete.length > 0) {
454
+ try {
455
+ await this.#closeOwned(obsolete);
456
+ }
457
+ catch (cause) {
458
+ throw actionError("obsolete-connection-close-failed", 500, "The MCP capability change committed, but an obsolete connection did not close cleanly.", { workerId, committed: true }, cause);
459
+ }
460
+ }
461
+ },
462
+ abort: async () => {
463
+ await this.#closeOwned(fresh.map(({ connection }) => connection));
464
+ },
453
465
  };
454
466
  }
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);
467
+ async #teardown(snapshot, identity) {
468
+ const { workerId } = identity;
469
+ const pending = [...this.#pending.keys()].filter((key) => key.startsWith(`${workerId}:`));
470
+ if (pending.length > 0) {
471
+ throw new Error(`MCP worker ${workerId} cannot cool with pending OAuth residency.`);
462
472
  }
463
- catch (cause) {
464
- throw actionError("configuration-invalid", 400, "Client MCP configuration is invalid.", { retryable: false }, cause);
465
- }
466
- }
467
- #list(workspaceId, overlay) {
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))
473
+ for (const [key, timer] of this.#refreshTimers) {
474
+ if (!key.startsWith(`${workerId}:`))
477
475
  continue;
478
- summaries.set(alias, this.#summary(alias, {
479
- definition,
480
- source: "client",
481
- enabled: false,
482
- }));
476
+ clearTimeout(timer);
477
+ this.#refreshTimers.delete(key);
483
478
  }
484
- return {
485
- servers: [...summaries]
486
- .toSorted(([left], [right]) => left.localeCompare(right))
487
- .map(([, summary]) => summary),
488
- };
489
- }
490
- #definition(params) {
491
- assertActionKeys(params, ["alias", "target", "options"]);
479
+ const attachments = snapshot ?? new Map();
480
+ const connections = [...attachments.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []);
481
+ this.#attachments.delete(workerId);
482
+ this.#retainWorker.delete(workerId);
483
+ this.#identities.delete(workerId);
484
+ await this.#closeOwned(connections);
485
+ }
486
+ // {§oauth-continuation} — the callback finishes the pending connection's
487
+ // authorization, prepares its attachment, and re-enables the alias through
488
+ // the coordinator, which consumes the prepared attachment on publication.
489
+ async #completeOAuth(identity, params) {
490
+ assertActionKeys(params, ["alias", "callbackUrl"]);
492
491
  const alias = requiredString(params, "alias");
493
- const target = requiredString(params, "target");
494
- try {
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));
506
- }
507
- catch (cause) {
508
- throw actionError("definition-invalid", 400, `MCP server '${alias}' has an invalid target or options.`, { alias, retryable: false }, cause);
509
- }
510
- }
511
- async #add(workspaceId, params) {
512
- return this.#serialize(workspaceId, async () => {
513
- const definition = this.#definition(params);
514
- const alias = definition.name;
515
- const snapshot = this.#snapshot(workspaceId);
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 });
518
- }
519
- const state = cloneState(snapshot.state);
520
- state.servers[alias] = {
521
- kind: "workspace",
522
- definition,
523
- enabled: true,
524
- };
525
- const result = await this.#applyState(workspaceId, state, {
526
- authorizationDisposition: "defer-mutation",
527
- preparationFailureDisposition: "reject",
528
- });
529
- if (result.authorization !== undefined) {
530
- await this.#setPending(workspaceId, alias, {
531
- operation: "add",
532
- expectedState: structuredClone(snapshot.state.servers[alias] ?? null),
533
- expectedDefinition: null,
534
- expectedEnabled: false,
535
- definition: { definition, source: "workspace" },
536
- connection: result.authorization.connection,
537
- authorizationUrl: result.authorization.authorizationUrl,
538
- });
539
- return {
540
- status: 202,
541
- authorization: { url: result.authorization.authorizationUrl },
542
- };
543
- }
544
- const committed = this.#snapshot(workspaceId);
545
- const attached = committed.attachments.get(alias);
546
- if (attached === undefined || attached.kind === "unavailable") {
547
- throw new Error("Committed MCP attachment is absent.");
548
- }
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) };
554
- });
555
- }
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
- }));
492
+ const callbackUrl = requiredString(params, "callbackUrl");
493
+ const key = this.#pendingKey(identity.workerId, alias);
494
+ const pending = this.#pending.get(key);
495
+ if (pending === undefined) {
496
+ // {§oauth-lifetime} — restart during pending authorization surfaces
497
+ // as a factual not-pending state, never a secret replay.
498
+ throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workerId: identity.workerId, alias, retryable: false });
595
499
  }
596
- catch (cause) {
597
- throw actionError("definition-invalid", 400, `MCP server '${definition.name}' has invalid enable options.`, { alias: definition.name, retryable: false }, cause);
500
+ const current = this.#attachments.get(identity.workerId)?.get(alias);
501
+ if (current === undefined || current.kind !== "authorization-required" || !sameDefinition(current.definition, pending.definition)) {
502
+ throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workerId: identity.workerId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
598
503
  }
599
- }
600
- async #setEnabled(workspaceId, params, enabled) {
601
- return this.#serialize(workspaceId, async () => {
602
- assertActionKeys(params, enabled ? ["alias", "overlay", "options"] : ["alias"]);
603
- const alias = requiredString(params, "alias");
604
- const snapshot = this.#snapshot(workspaceId);
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) };
635
- }
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]) } : {}),
643
- authorizationDisposition: "defer-mutation",
644
- preparationFailureDisposition: "reject",
645
- });
646
- if (result.authorization !== undefined) {
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
- },
656
- connection: result.authorization.connection,
657
- authorizationUrl: result.authorization.authorizationUrl,
658
- });
659
- return {
660
- status: 202,
661
- authorization: { url: result.authorization.authorizationUrl },
662
- };
663
- }
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
- };
673
- });
674
- }
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];
504
+ if (pending.prepared === undefined) {
505
+ try {
506
+ await pending.connection.finishAuthorization(callbackUrl);
507
+ const prepared = await this.#prepareAttachment(identity.workerId, pending.definition, pending.connection);
508
+ if (prepared.kind !== "active")
509
+ throw new Error("OAuth completion returned another authorization challenge.");
510
+ pending.prepared = prepared;
691
511
  }
692
- else {
693
- state.servers[alias] = {
694
- kind: "service",
695
- enabled: false,
696
- };
512
+ catch (cause) {
513
+ throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workerId: identity.workerId, alias, retryable: false }, cause);
697
514
  }
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) {
707
- const snapshot = this.#snapshot(workspaceId);
708
- const available = this.#available(snapshot.state).get(alias);
709
- const current = available?.definition ?? null;
710
- const expected = pending.expectedDefinition;
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.`, {
716
- workspaceId,
717
- alias,
718
- recovery: "Start authorization again from the server's current definition.",
719
- retryable: false,
720
- });
721
515
  }
722
- if (pending.operation === "add") {
723
- const state = cloneState(snapshot.state);
724
- state.servers[alias] = {
725
- kind: "workspace",
726
- definition: structuredClone(pending.definition.definition),
727
- enabled: true,
728
- };
729
- return state;
730
- }
731
- return this.#stateWithDefinition(snapshot.state, alias, pending.definition.definition, true);
732
- }
733
- async #completeOAuth(workspaceId, params) {
734
- return this.#serialize(workspaceId, async () => {
735
- assertActionKeys(params, ["alias", "callbackUrl"]);
736
- const alias = requiredString(params, "alias");
737
- const callbackUrl = requiredString(params, "callbackUrl");
738
- const key = this.#pendingKey(workspaceId, alias);
739
- const pending = this.#pending.get(key);
740
- if (pending === undefined) {
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 });
744
- }
745
- if (pending.prepared === undefined) {
746
- try {
747
- await pending.connection.finishAuthorization(callbackUrl);
748
- const prepared = await this.#prepareAttachment(workspaceId, pending.definition, pending.connection);
749
- if (prepared.kind !== "active") {
750
- throw new Error("OAuth completion returned another authorization challenge.");
751
- }
752
- pending.prepared = prepared;
753
- }
754
- catch (cause) {
755
- throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workspaceId, alias, retryable: false }, cause);
756
- }
757
- }
758
- const state = this.#oauthCompletionState(workspaceId, alias, pending);
759
- await this.#applyState(workspaceId, state, {
760
- force: new Set([alias]),
761
- prepared: new Map([[alias, pending.prepared]]),
762
- authorizationDisposition: "defer-mutation",
763
- preparationFailureDisposition: "reject",
764
- });
765
- this.#pending.delete(key);
766
- const committed = this.#snapshot(workspaceId);
767
- const attached = committed.attachments.get(alias);
768
- if (attached === undefined || attached.kind !== "active") {
769
- throw new Error("Authorized MCP attachment is absent.");
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) };
775
- });
516
+ const result = await this.#handleOrThrow().invoke("enable", { alias }, identity);
517
+ return result.body;
776
518
  }
777
- async #complete(workspaceId, params) {
519
+ async #complete(workerId, params) {
778
520
  assertActionKeys(params, ["server", "ref", "argument", "context"]);
779
521
  const server = requiredString(params, "server");
780
- const attachment = this.#snapshot(workspaceId).attachments.get(server);
522
+ const attachment = this.#attachments.get(workerId)?.get(server);
781
523
  if (attachment === undefined || attachment.kind !== "active") {
782
- throw actionError("server-not-connected", 409, `MCP server '${server}' is not connected in this workspace.`, { workspaceId, name: server, retryable: false });
524
+ throw actionError("server-not-connected", 409, `MCP server '${server}' is not connected for this worker.`, { workerId, name: server, retryable: false });
783
525
  }
784
526
  const ref = objectOf(params.ref);
785
527
  const argument = objectOf(params.argument);
@@ -792,57 +534,24 @@ export default class Module {
792
534
  ...(objectOf(params.context) === null ? {} : { context: params.context }),
793
535
  });
794
536
  }
795
- async #refreshCatalog(workspaceId, name) {
796
- const seam = this.#seam;
797
- if (seam === undefined)
798
- throw new Error("MCP module is not set up.");
799
- const snapshot = this.#workspaces.get(workspaceId);
800
- const attachment = snapshot?.attachments.get(name);
801
- if (snapshot === undefined || attachment === undefined || attachment.kind !== "active")
802
- return;
803
- const executor = new McpExecutor({ runtime: name, glyph: "🔌" }, attachment.connection, {
804
- tools: attachment.definition.tools ?? null,
805
- read: attachment.definition.read ?? [],
806
- }, this.#summaries.tools);
807
- const availability = await executor.requireAvailable();
808
- const refreshed = {
809
- ...attachment,
810
- executor,
811
- runtime: {
812
- namespaceOwner: OWNER,
813
- decl: runtimeDecl(name, serverSummary(name, executor.catalog, this.#summaries.servers.get(name)), this.#expanded.has(name)),
814
- executor,
815
- availability,
816
- scheme: new McpResources(name, attachment.connection, executor.catalog),
817
- },
818
- };
819
- const attachments = new Map(snapshot.attachments);
820
- attachments.set(name, refreshed);
821
- await seam.replaceWorkspaceCapabilities({
822
- workspaceId,
823
- namespaceOwner: OWNER,
824
- state: persistedState(snapshot.state),
825
- runtimes: [...attachments.values()].flatMap((candidate) => candidate.kind === "active" ? [candidate.runtime] : []),
826
- });
827
- this.#workspaces.set(workspaceId, {
828
- state: cloneState(snapshot.state),
829
- attachments,
830
- });
831
- }
832
- #scheduleCatalogRefresh(workspaceId, name, attempt = 0) {
537
+ // A live catalog change republishes the unchanged state; the dirty alias
538
+ // rebuilds its executor on the next preparation.
539
+ #scheduleCatalogRefresh(workerId, name, attempt = 0) {
833
540
  if (this.#closed)
834
541
  return;
835
- const key = this.#pendingKey(workspaceId, name);
542
+ const key = this.#pendingKey(workerId, name);
836
543
  if (this.#refreshTimers.has(key))
837
544
  return;
838
545
  const delay = Math.min(250 * (2 ** attempt), 5000);
839
546
  const timer = setTimeout(() => {
840
547
  this.#refreshTimers.delete(key);
841
- void this.#serialize(workspaceId, async () => {
842
- await this.#refreshCatalog(workspaceId, name);
843
- }).catch((error) => {
548
+ const identity = this.#identities.get(workerId);
549
+ if (identity === undefined || this.#closed)
550
+ return;
551
+ this.#dirty.add(key);
552
+ void this.#handleOrThrow().refresh(identity).catch((error) => {
844
553
  if (statusOf(error) === 409) {
845
- this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
554
+ this.#scheduleCatalogRefresh(workerId, name, attempt + 1);
846
555
  return;
847
556
  }
848
557
  console.error(`MCP server '${name}' capability refresh failed:`, error);
@@ -858,19 +567,14 @@ export default class Module {
858
567
  for (const timer of this.#refreshTimers.values())
859
568
  clearTimeout(timer);
860
569
  this.#refreshTimers.clear();
861
- const connections = new Set();
862
- for (const snapshot of this.#workspaces.values()) {
863
- for (const attachment of snapshot.attachments.values()) {
864
- const connection = attachmentConnection(attachment);
865
- if (connection !== undefined)
866
- connections.add(connection);
867
- }
868
- }
570
+ const closing = this.#closeOwned([...this.#connections]);
571
+ this.#attachments.clear();
869
572
  for (const pending of this.#pending.values())
870
- connections.add(pending.connection);
871
- this.#workspaces.clear();
573
+ pending.releaseWorker();
872
574
  this.#pending.clear();
873
- await closeConnections([...connections]);
575
+ this.#retainWorker.clear();
576
+ this.#identities.clear();
577
+ await closing;
874
578
  }
875
579
  }
876
580
  //# sourceMappingURL=Module.js.map