@plurnk/plurnk-mcp 1.7.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
@@ -1,12 +1,21 @@
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
- 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
+ });
18
+ const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
10
19
  class ModuleActionError extends Error {
11
20
  problem;
12
21
  constructor(problem, cause) {
@@ -21,36 +30,20 @@ const actionError = (code, status, detail, extensions = {}, cause) => new Module
21
30
  ...extensions,
22
31
  }), cause);
23
32
  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
33
  const preparationError = (definition, cause, closeCause) => {
45
34
  const completeCause = closeCause === undefined
46
35
  ? cause
47
36
  : 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.`, {
37
+ // {§oauth-client-credentials} — a rejected grant is an authorization fact,
38
+ // never a generic unavailability.
39
+ if (definition.authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
40
+ return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
50
41
  server: definition.name,
51
42
  transport: definition.transport,
52
- requiredRevision: MCP_PROTOCOL_VERSION,
53
- requiredMethod: "server/discover",
43
+ clientId: definition.authorization.clientId,
44
+ ...(definition.authorization.issuer === undefined
45
+ ? {}
46
+ : { issuer: definition.authorization.issuer }),
54
47
  retryable: false,
55
48
  }, completeCause);
56
49
  }
@@ -72,78 +65,60 @@ export const closeConnections = async (connections) => {
72
65
  const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
73
66
  ? value
74
67
  : null;
75
- const assertExactKeys = (value, allowed, owner) => {
76
- const extras = Object.keys(value).filter((key) => !allowed.includes(key));
77
- if (extras.length > 0)
78
- throw new Error(`${owner} contains unsupported field(s): ${extras.join(", ")}.`);
79
- };
80
- const emptyState = () => ({ version: STATE_VERSION, servers: {} });
81
- const parseState = (source) => {
82
- if (source === null)
83
- return emptyState();
84
- const state = objectOf(source);
85
- if (state === null)
86
- throw new Error("MCP workspace state must be an object.");
87
- assertExactKeys(state, ["version", "servers"], "MCP workspace state");
88
- if (state.version !== STATE_VERSION) {
89
- throw new Error(`MCP workspace state version must be ${STATE_VERSION}.`);
90
- }
91
- const servers = objectOf(state.servers);
92
- if (servers === null)
93
- throw new Error("MCP workspace state servers must be an object.");
94
- const parsed = {};
95
- for (const [name, raw] of Object.entries(servers).toSorted(([left], [right]) => left.localeCompare(right))) {
96
- const value = objectOf(raw);
97
- if (value === null || (value.kind !== "attached" && value.kind !== "detached")) {
98
- throw new Error(`MCP workspace server '${name}' has an invalid state.`);
99
- }
100
- if (value.kind === "detached") {
101
- assertExactKeys(value, ["kind"], `MCP workspace server '${name}'`);
102
- parsed[name] = { kind: "detached" };
103
- continue;
104
- }
105
- assertExactKeys(value, ["kind", "definition"], `MCP workspace server '${name}'`);
106
- const definition = structuredClone(Validator.assertMcpServerDefinition(value.definition));
107
- if (definition.name !== name) {
108
- throw new Error(`MCP workspace server key '${name}' does not match definition '${definition.name}'.`);
109
- }
110
- parsed[name] = { kind: "attached", definition };
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 });
111
72
  }
112
- return { version: STATE_VERSION, servers: parsed };
113
73
  };
114
- const persistedState = (state) => Object.keys(state.servers).length === 0 ? null : state;
115
- const cloneState = (state) => parseState(structuredClone(state));
116
- const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
117
- const requiredString = (params, field) => {
118
- const value = params[field];
74
+ const requiredString = (params, key) => {
75
+ const value = params[key];
119
76
  if (typeof value !== "string" || value.length === 0) {
120
- 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 });
121
78
  }
122
79
  return value;
123
80
  };
124
- const workspaceIdOf = (context) => {
125
- if (context.scope !== "workspace") {
126
- throw actionError("workspace-context-required", 500, "MCP action received no workspace context.");
127
- }
128
- 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 };
129
85
  };
86
+ const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
130
87
  const statusOf = (error) => {
131
- const candidate = objectOf(error);
132
- const direct = objectOf(candidate?.problem);
133
- if (typeof direct?.status === "number")
134
- return direct.status;
135
- const result = objectOf(candidate?.result);
136
- const problem = objectOf(result?.problem);
88
+ if (typeof error !== "object" || error === null)
89
+ return null;
90
+ const problem = error.problem
91
+ ?? error.result?.problem;
137
92
  return typeof problem?.status === "number" ? problem.status : null;
138
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
+ };
139
106
  export default class Module {
140
107
  #env;
108
+ #summaries;
109
+ #expanded;
141
110
  #defaults;
142
- #workspaces = new Map();
111
+ #defaultEnabled;
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();
143
116
  #pending = new Map();
144
- #locks = new Map();
117
+ #dirty = new Set();
118
+ #connections = new Set();
145
119
  #refreshTimers = new Map();
146
- #seam;
120
+ #retainWorker = new Map();
121
+ #handle;
147
122
  #closed = false;
148
123
  static init(options = {}) {
149
124
  return new Module(options.env ?? process.env);
@@ -151,86 +126,192 @@ export default class Module {
151
126
  constructor(environ) {
152
127
  this.#env = environ;
153
128
  this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
129
+ this.#defaultEnabled = new Set(serviceEnabledNames(environ));
130
+ this.#summaries = summaryOverrides(environ);
131
+ this.#expanded = new Set(expandedServerNames(environ));
154
132
  }
155
133
  async setup(seam) {
156
- this.#seam = seam;
157
- seam.registerWorkspaceCapabilityProvider(OWNER, {
158
- hydrate: async (workspaceId) => this.#serialize(workspaceId, async () => {
159
- const state = parseState(await seam.readWorkspaceModuleState(workspaceId, OWNER));
160
- await this.#applyState(workspaceId, state, {
161
- authorizationDisposition: "publish-required",
162
- });
163
- }),
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),
156
+ });
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),
164
168
  });
165
- 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));
171
- action("workspace.mcp.oauth.complete", async (params, context) => this.#completeOAuth(workspaceIdOf(context), params));
172
- action("workspace.mcp.complete", async (params, context) => this.#complete(workspaceIdOf(context), params));
173
169
  }
174
- #pendingKey(workspaceId, name) {
175
- return `${workspaceId}:${name}`;
170
+ #assertOpen() {
171
+ if (this.#closed)
172
+ throw new Error("MCP module is closed.");
176
173
  }
177
- async #serialize(workspaceId, run) {
178
- const prior = this.#locks.get(workspaceId) ?? Promise.resolve();
179
- let release = () => undefined;
180
- const barrier = new Promise((resolve) => { release = resolve; });
181
- const queued = prior.then(() => barrier, () => barrier);
182
- this.#locks.set(workspaceId, queued);
183
- await prior.catch(() => undefined);
184
- try {
185
- return await run();
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.`);
186
186
  }
187
- finally {
188
- release();
189
- if (this.#locks.get(workspaceId) === queued)
190
- this.#locks.delete(workspaceId);
187
+ return retain();
188
+ }
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]);
196
+ continue;
197
+ }
198
+ errors.push(...errorsOf(result.reason));
191
199
  }
192
- }
193
- #effective(state) {
194
- const effective = new Map([...this.#defaults].map(([name, definition]) => [
195
- name,
196
- { definition: structuredClone(definition), source: "service" },
197
- ]));
198
- 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",
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}`,
205
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,
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 });
271
+ }
272
+ return candidates;
273
+ }
274
+ #admit(input) {
275
+ const params = objectOf(input) ?? {};
276
+ let definition;
277
+ try {
278
+ definition = structuredClone(Validator.assertMcpServerDefinition(structuredClone(params.definition)));
206
279
  }
207
- return new Map([...effective].toSorted(([left], [right]) => left.localeCompare(right)));
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 };
208
288
  }
209
- async #prepareAttachment(workspaceId, effective, connection) {
210
- const definition = effective.definition;
289
+ async #prepareAttachment(workerId, definition, connection) {
290
+ this.#assertOpen();
211
291
  const candidate = connection ?? new ServerConnection(definition, this.#env, {
212
292
  onCatalogChanged: (error) => {
213
293
  if (error !== null) {
214
294
  console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
215
295
  return;
216
296
  }
217
- this.#scheduleCatalogRefresh(workspaceId, definition.name);
297
+ this.#scheduleCatalogRefresh(workerId, definition.name);
218
298
  },
219
299
  onInfrastructureError: (error) => {
220
300
  console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
221
301
  },
222
302
  });
223
- const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] });
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);
224
305
  try {
225
306
  const availability = await executor.requireAvailable();
226
307
  return {
227
308
  kind: "active",
228
- ...effective,
309
+ definition,
229
310
  connection: candidate,
230
311
  executor,
231
312
  runtime: {
232
313
  namespaceOwner: OWNER,
233
- decl: runtimeDecl(definition.name),
314
+ decl: runtimeDecl(definition.name, serverSummary(definition.name, executor.catalog, this.#summaries.servers.get(definition.name)), this.#expanded.has(definition.name)),
234
315
  executor,
235
316
  availability,
236
317
  scheme: new McpResources(definition.name, candidate, executor.catalog),
@@ -241,14 +322,14 @@ export default class Module {
241
322
  if (cause instanceof AuthorizationRequiredError) {
242
323
  return {
243
324
  kind: "authorization-required",
244
- ...effective,
325
+ definition,
245
326
  connection: candidate,
246
327
  authorizationUrl: cause.authorizationUrl,
247
328
  };
248
329
  }
249
330
  let closeCause;
250
331
  try {
251
- await candidate.close();
332
+ await this.#closeOwned([candidate]);
252
333
  }
253
334
  catch (error) {
254
335
  closeCause = error;
@@ -256,325 +337,191 @@ export default class Module {
256
337
  throw preparationError(definition, cause, closeCause);
257
338
  }
258
339
  }
259
- async #applyState(workspaceId, state, options) {
260
- const seam = this.#seam;
261
- if (seam === undefined)
262
- throw new Error("MCP module is not set up.");
263
- const current = this.#workspaces.get(workspaceId);
264
- const effective = this.#effective(state);
265
- const force = options.force ?? new Set();
266
- for (const [name, attachment] of current?.attachments ?? []) {
267
- const next = effective.get(name);
268
- if (next === undefined
269
- || force.has(name)
270
- || !sameDefinition(attachment.definition, next.definition))
271
- attachment.connection.assertReplaceable();
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)) {
353
+ attachmentConnection(attachment)?.assertReplaceable();
354
+ }
272
355
  }
273
356
  const next = new Map();
357
+ const outcomes = new Map();
274
358
  const fresh = [];
359
+ const consumedPending = new Map();
275
360
  try {
276
- for (const [name, definition] of effective) {
277
- 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));
278
365
  if (existing !== undefined
279
- && !force.has(name)
280
- && 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)) {
281
370
  next.set(name, existing);
282
371
  continue;
283
372
  }
284
- const supplied = options.prepared?.get(name);
285
- const attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
373
+ let attachment;
374
+ if (pending?.prepared !== undefined && sameDefinition(pending.definition, definition)) {
375
+ attachment = pending.prepared;
376
+ consumedPending.set(name, pending);
377
+ }
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);
392
+ }
286
393
  next.set(name, attachment);
287
- fresh.push(attachment);
288
- }
289
- }
290
- catch (cause) {
291
- const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
292
- const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
293
- if (failures.length > 0) {
294
- throw new AggregateError([cause, ...failures], "MCP workspace preparation and cleanup failed.");
394
+ this.#dirty.delete(this.#pendingKey(workerId, name));
295
395
  }
296
- throw cause;
297
- }
298
- const authorization = [...fresh].find((attachment) => attachment.kind === "authorization-required");
299
- if (authorization !== undefined && options.authorizationDisposition === "defer-mutation") {
300
- const discard = fresh.filter((attachment) => attachment !== authorization);
301
- await closeConnections(discard.map(({ connection }) => connection));
302
- return { authorization };
303
- }
304
- const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
305
- try {
306
- await seam.replaceWorkspaceCapabilities({
307
- workspaceId,
308
- namespaceOwner: OWNER,
309
- state: persistedState(state),
310
- runtimes,
311
- });
312
396
  }
313
397
  catch (cause) {
314
- const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
398
+ const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
315
399
  const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
316
- if (failures.length > 0) {
317
- throw new AggregateError([cause, ...failures], "MCP workspace commit and cleanup failed.");
318
- }
400
+ if (failures.length > 0)
401
+ throw new AggregateError([cause, ...failures], "MCP worker preparation and cleanup failed.");
319
402
  throw cause;
320
403
  }
321
- this.#workspaces.set(workspaceId, { state: cloneState(state), attachments: next });
322
- const retained = new Set([...next.values()].map(({ connection }) => connection));
323
- const obsolete = [...current?.attachments.values() ?? []]
324
- .map(({ connection }) => connection)
325
- .filter((connection) => !retained.has(connection));
326
- if (obsolete.length > 0) {
327
- try {
328
- await closeConnections(obsolete);
329
- }
330
- catch (cause) {
331
- throw actionError("obsolete-connection-close-failed", 500, "The MCP capability change committed, but an obsolete connection did not close cleanly.", { workspaceId, committed: true }, cause);
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;
332
415
  }
333
416
  }
334
- if (options.authorizationDisposition === "publish-required") {
335
- for (const [name, attachment] of next) {
336
- if (attachment.kind !== "authorization-required")
337
- continue;
338
- await this.#setPending(workspaceId, name, {
339
- operation: "reconnect",
340
- expectedDefinition: structuredClone(attachment.definition),
341
- definition: {
342
- definition: structuredClone(attachment.definition),
343
- source: attachment.source,
344
- },
345
- connection: attachment.connection,
346
- authorizationUrl: attachment.authorizationUrl,
347
- }, false);
348
- }
349
- }
350
- return authorization === undefined ? {} : { authorization };
351
- }
352
- async #setPending(workspaceId, name, pending, closeExisting = true) {
353
- const key = this.#pendingKey(workspaceId, name);
354
- const existing = this.#pending.get(key);
355
- this.#pending.set(key, pending);
356
- if (closeExisting && existing !== undefined && existing.connection !== pending.connection) {
357
- await existing.connection.close();
358
- }
359
- }
360
- async #clearPending(workspaceId, name, retained) {
361
- const key = this.#pendingKey(workspaceId, name);
362
- const pending = this.#pending.get(key);
363
- this.#pending.delete(key);
364
- if (pending === undefined || pending.connection === retained)
365
- return;
366
- try {
367
- await pending.connection.close();
368
- }
369
- catch (cause) {
370
- 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);
371
- }
372
- }
373
- #snapshot(workspaceId) {
374
- const snapshot = this.#workspaces.get(workspaceId);
375
- if (snapshot === undefined) {
376
- throw actionError("workspace-not-hydrated", 409, "MCP capabilities have not been hydrated for this workspace.", { workspaceId, recovery: "Retry after workspace initialization completes." });
377
- }
378
- return snapshot;
379
- }
380
- #summary(name, attachment) {
381
- const definition = attachment.definition;
382
- const base = {
383
- name,
384
- source: attachment.source,
385
- transport: definition.transport,
386
- state: attachment.kind === "active" ? "connected" : "authorization-required",
387
- enabledTools: definition.tools ?? null,
388
- read: definition.read ?? [],
389
- };
390
- if (attachment.kind !== "active")
391
- return base;
392
- const catalog = attachment.executor.catalog;
393
- return {
394
- ...base,
395
- protocolVersion: catalog.protocolVersion,
396
- server: catalog.server ?? null,
397
- capabilities: catalog.capabilities,
398
- tools: catalog.tools.map(({ name: toolName }) => toolName).toSorted(),
399
- resources: catalog.resources.length,
400
- resourceTemplates: catalog.resourceTemplates.length,
401
- prompts: catalog.prompts.length,
402
- };
403
- }
404
- #list(workspaceId) {
405
- const snapshot = this.#snapshot(workspaceId);
417
+ const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
406
418
  return {
407
- servers: [...snapshot.attachments]
408
- .toSorted(([left], [right]) => left.localeCompare(right))
409
- .map(([name, attachment]) => this.#summary(name, attachment)),
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
+ },
410
465
  };
411
466
  }
412
- #definition(params) {
413
- try {
414
- return structuredClone(Validator.assertMcpServerDefinition(params.server));
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.`);
415
472
  }
416
- catch (cause) {
417
- throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { field: "server", retryable: false }, cause);
473
+ for (const [key, timer] of this.#refreshTimers) {
474
+ if (!key.startsWith(`${workerId}:`))
475
+ continue;
476
+ clearTimeout(timer);
477
+ this.#refreshTimers.delete(key);
418
478
  }
419
- }
420
- async #change(operation, workspaceId, params) {
421
- return this.#serialize(workspaceId, async () => {
422
- const definition = this.#definition(params);
423
- 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 });
430
- }
431
- const state = cloneState(snapshot.state);
432
- state.servers[definition.name] = {
433
- kind: "attached",
434
- definition,
435
- };
436
- const result = await this.#applyState(workspaceId, state, {
437
- force: new Set([definition.name]),
438
- authorizationDisposition: "defer-mutation",
439
- });
440
- 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,
446
- definition: { definition, source: "workspace" },
447
- connection: result.authorization.connection,
448
- authorizationUrl: result.authorization.authorizationUrl,
449
- });
450
- return {
451
- status: 202,
452
- authorization: { url: result.authorization.authorizationUrl },
453
- };
454
- }
455
- const attached = this.#snapshot(workspaceId).attachments.get(definition.name);
456
- if (attached === undefined)
457
- 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
- }
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 };
482
- });
483
- }
484
- async #reconnect(workspaceId, params) {
485
- return this.#serialize(workspaceId, async () => {
486
- const name = requiredString(params, "name");
487
- 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 });
491
- }
492
- const result = await this.#applyState(workspaceId, snapshot.state, {
493
- force: new Set([name]),
494
- authorizationDisposition: "defer-mutation",
495
- });
496
- if (result.authorization !== undefined) {
497
- await this.#setPending(workspaceId, name, {
498
- operation: "reconnect",
499
- expectedDefinition: structuredClone(definition.definition),
500
- definition,
501
- connection: result.authorization.connection,
502
- authorizationUrl: result.authorization.authorizationUrl,
503
- });
504
- return {
505
- status: 202,
506
- authorization: { url: result.authorization.authorizationUrl },
507
- };
508
- }
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) };
514
- });
515
- }
516
- #oauthCompletionState(workspaceId, name, pending) {
517
- const snapshot = this.#snapshot(workspaceId);
518
- const current = this.#effective(snapshot.state).get(name)?.definition ?? null;
519
- 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.`, {
523
- workspaceId,
524
- name,
525
- recovery: "Start authorization again from the server's current definition.",
526
- retryable: false,
527
- });
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"]);
491
+ const alias = requiredString(params, "alias");
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 });
528
499
  }
529
- const state = cloneState(snapshot.state);
530
- if (pending.operation === "attach" || pending.operation === "replace") {
531
- state.servers[name] = {
532
- kind: "attached",
533
- definition: structuredClone(pending.definition.definition),
534
- };
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 });
535
503
  }
536
- return state;
537
- }
538
- async #completeOAuth(workspaceId, params) {
539
- return this.#serialize(workspaceId, async () => {
540
- const name = requiredString(params, "name");
541
- const callbackUrl = requiredString(params, "callbackUrl");
542
- const key = this.#pendingKey(workspaceId, name);
543
- const pending = this.#pending.get(key);
544
- if (pending === undefined) {
545
- throw actionError("oauth-not-pending", 404, `MCP server '${name}' has no pending OAuth authorization.`, { workspaceId, name, retryable: false });
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;
546
511
  }
547
- if (pending.prepared === undefined) {
548
- try {
549
- await pending.connection.finishAuthorization(callbackUrl);
550
- const prepared = await this.#prepareAttachment(workspaceId, pending.definition, pending.connection);
551
- if (prepared.kind !== "active") {
552
- throw new Error("OAuth completion returned another authorization challenge.");
553
- }
554
- pending.prepared = prepared;
555
- }
556
- catch (cause) {
557
- throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${name}' could not be completed.`, { workspaceId, name, retryable: false }, cause);
558
- }
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);
559
514
  }
560
- const state = this.#oauthCompletionState(workspaceId, name, pending);
561
- await this.#applyState(workspaceId, state, {
562
- force: new Set([name]),
563
- prepared: new Map([[name, pending.prepared]]),
564
- authorizationDisposition: "defer-mutation",
565
- });
566
- this.#pending.delete(key);
567
- const attached = this.#snapshot(workspaceId).attachments.get(name);
568
- if (attached === undefined)
569
- throw new Error("Authorized MCP attachment is absent.");
570
- return { status: 200, server: this.#summary(name, attached) };
571
- });
515
+ }
516
+ const result = await this.#handleOrThrow().invoke("enable", { alias }, identity);
517
+ return result.body;
572
518
  }
573
- async #complete(workspaceId, params) {
519
+ async #complete(workerId, params) {
520
+ assertActionKeys(params, ["server", "ref", "argument", "context"]);
574
521
  const server = requiredString(params, "server");
575
- const attachment = this.#snapshot(workspaceId).attachments.get(server);
522
+ const attachment = this.#attachments.get(workerId)?.get(server);
576
523
  if (attachment === undefined || attachment.kind !== "active") {
577
- 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 });
578
525
  }
579
526
  const ref = objectOf(params.ref);
580
527
  const argument = objectOf(params.argument);
@@ -587,57 +534,24 @@ export default class Module {
587
534
  ...(objectOf(params.context) === null ? {} : { context: params.context }),
588
535
  });
589
536
  }
590
- async #refreshCatalog(workspaceId, name) {
591
- const seam = this.#seam;
592
- if (seam === undefined)
593
- throw new Error("MCP module is not set up.");
594
- const snapshot = this.#workspaces.get(workspaceId);
595
- const attachment = snapshot?.attachments.get(name);
596
- if (snapshot === undefined || attachment === undefined || attachment.kind !== "active")
597
- return;
598
- const executor = new McpExecutor({ runtime: name, glyph: "🔌" }, attachment.connection, {
599
- tools: attachment.definition.tools ?? null,
600
- read: attachment.definition.read ?? [],
601
- });
602
- const availability = await executor.requireAvailable();
603
- const refreshed = {
604
- ...attachment,
605
- executor,
606
- runtime: {
607
- namespaceOwner: OWNER,
608
- decl: runtimeDecl(name),
609
- executor,
610
- availability,
611
- scheme: new McpResources(name, attachment.connection, executor.catalog),
612
- },
613
- };
614
- const attachments = new Map(snapshot.attachments);
615
- attachments.set(name, refreshed);
616
- await seam.replaceWorkspaceCapabilities({
617
- workspaceId,
618
- namespaceOwner: OWNER,
619
- state: persistedState(snapshot.state),
620
- runtimes: [...attachments.values()].flatMap((candidate) => candidate.kind === "active" ? [candidate.runtime] : []),
621
- });
622
- this.#workspaces.set(workspaceId, {
623
- state: cloneState(snapshot.state),
624
- attachments,
625
- });
626
- }
627
- #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) {
628
540
  if (this.#closed)
629
541
  return;
630
- const key = this.#pendingKey(workspaceId, name);
542
+ const key = this.#pendingKey(workerId, name);
631
543
  if (this.#refreshTimers.has(key))
632
544
  return;
633
545
  const delay = Math.min(250 * (2 ** attempt), 5000);
634
546
  const timer = setTimeout(() => {
635
547
  this.#refreshTimers.delete(key);
636
- void this.#serialize(workspaceId, async () => {
637
- await this.#refreshCatalog(workspaceId, name);
638
- }).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) => {
639
553
  if (statusOf(error) === 409) {
640
- this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
554
+ this.#scheduleCatalogRefresh(workerId, name, attempt + 1);
641
555
  return;
642
556
  }
643
557
  console.error(`MCP server '${name}' capability refresh failed:`, error);
@@ -653,17 +567,14 @@ export default class Module {
653
567
  for (const timer of this.#refreshTimers.values())
654
568
  clearTimeout(timer);
655
569
  this.#refreshTimers.clear();
656
- const connections = new Set();
657
- for (const snapshot of this.#workspaces.values()) {
658
- for (const attachment of snapshot.attachments.values()) {
659
- connections.add(attachment.connection);
660
- }
661
- }
570
+ const closing = this.#closeOwned([...this.#connections]);
571
+ this.#attachments.clear();
662
572
  for (const pending of this.#pending.values())
663
- connections.add(pending.connection);
664
- this.#workspaces.clear();
573
+ pending.releaseWorker();
665
574
  this.#pending.clear();
666
- await closeConnections([...connections]);
575
+ this.#retainWorker.clear();
576
+ this.#identities.clear();
577
+ await closing;
667
578
  }
668
579
  }
669
580
  //# sourceMappingURL=Module.js.map