@plurnk/plurnk-mcp 1.23.0 → 1.25.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,17 +1,16 @@
1
- var _a;
2
1
  // {§mcp-module} — the MCP family beneath the shared workspace Functionality
3
2
  // coordinator ({§functionality-adapter}). This module owns MCP protocol truth:
4
- // definitions from the POSIX cascade, inert discovery, connection preparation
5
- // with OAuth continuation, tool/resource publication, catalog refresh, and
6
- // teardown. The coordinator owns the lifecycle, durable workspace state, atomic
3
+ // complete connection definitions, preparation with OAuth
4
+ // continuation, tool/resource publication, catalog refresh, and teardown. The coordinator owns the lifecycle, durable workspace state, atomic
7
5
  // publication, and both the client and model projections.
8
6
  import { fileURLToPath } from "node:url";
9
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
10
- import { join } from "node:path";
11
- import { Problems, Validator, } from "@plurnk/plurnk-contracts";
12
- import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, } from "./client.js";
13
- import { expandedServerNames, overlayServerDefinitions, retryDelayMs, retryPacing, serviceDefinitions, serviceEnabledNames, summaryOverrides, } from "./config.js";
14
- import McpExecutor, { runtimeDecl, runtimeServerSummary, serverSummary } from "./McpExecutor.js";
7
+ import { readDefinition } from "./definition.js";
8
+ import { isDeepStrictEqual } from "node:util";
9
+ import { Problems, } from "@plurnk/plurnk-contracts";
10
+ import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, McpRedirectError, } from "./client.js";
11
+ import { configuredDefinitions, connectTimeoutMs, expandedServerNames, retryDelayMs, retryPacing, registrySettings, serverSettings, } from "./config.js";
12
+ import { RegistryError, registryEntries, searchRegistry } from "./registry.js";
13
+ import McpExecutor, { runtimeDecl, runtimeServerSummary } from "./McpExecutor.js";
15
14
  import McpResources from "./McpResources.js";
16
15
  const OWNER = "@plurnk/plurnk-mcp";
17
16
  const FAMILY = "mcp";
@@ -39,26 +38,45 @@ const actionError = (code, status, detail, extensions = {}, cause) => new Module
39
38
  ...extensions,
40
39
  }), cause);
41
40
  const errorsOf = (error) => error instanceof AggregateError ? [...error.errors] : [error];
42
- const preparationError = (definition, cause, closeCause) => {
41
+ // The first error of a type anywhere in a cause chain, aggregates included.
42
+ const causeOf = (error, type, seen = new Set()) => {
43
+ if (error === null || typeof error !== "object" || seen.has(error))
44
+ return undefined;
45
+ seen.add(error);
46
+ if (error instanceof type)
47
+ return error;
48
+ for (const inner of error instanceof AggregateError ? error.errors : []) {
49
+ const found = causeOf(inner, type, seen);
50
+ if (found !== undefined)
51
+ return found;
52
+ }
53
+ return causeOf(error.cause, type, seen);
54
+ };
55
+ const preparationError = (definition, authorization, cause, closeCause) => {
43
56
  const completeCause = closeCause === undefined
44
57
  ? cause
45
58
  : new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
46
59
  // {§oauth-client-credentials} — a rejected grant is an authorization fact,
47
60
  // never a generic unavailability.
48
- if (definition.authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
61
+ if (authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
49
62
  return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
50
63
  server: definition.name,
51
- transport: definition.transport,
52
- clientId: definition.authorization.clientId,
53
- ...(definition.authorization.issuer === undefined
64
+ type: definition.type,
65
+ clientId: authorization.clientId,
66
+ ...(authorization.issuer === undefined
54
67
  ? {}
55
- : { issuer: definition.authorization.issuer }),
68
+ : { issuer: authorization.issuer }),
56
69
  retryable: false,
57
70
  }, completeCause);
58
71
  }
72
+ // {§mcp-redirect-refused} — the operator corrects the endpoint; retrying cannot.
73
+ const redirect = causeOf(cause, McpRedirectError);
74
+ if (redirect !== undefined) {
75
+ return actionError("server-redirected", 502, redirect.message, { server: definition.name, url: redirect.url, ...(redirect.location === null ? {} : { location: redirect.location }), retryable: false }, completeCause);
76
+ }
59
77
  return actionError("server-unavailable", 502, `Configured MCP server '${definition.name}' is unavailable.`, {
60
78
  server: definition.name,
61
- transport: definition.transport,
79
+ type: definition.type,
62
80
  retryable: true,
63
81
  }, completeCause);
64
82
  };
@@ -92,7 +110,7 @@ const workspaceIdentityOf = (context) => {
92
110
  throw new Error("MCP actions require a workspace-scoped context.");
93
111
  return { workspaceId: context.workspaceId };
94
112
  };
95
- const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
113
+ const sameBinding = (left, right) => isDeepStrictEqual([left.definition, left.context], [right.definition, right.context]);
96
114
  const statusOf = (error) => {
97
115
  if (typeof error !== "object" || error === null)
98
116
  return null;
@@ -112,14 +130,14 @@ const catalogDetail = (executor) => {
112
130
  prompts: catalog.prompts.length,
113
131
  };
114
132
  };
115
- class Module {
133
+ export default class Module {
116
134
  #env;
117
135
  #workspaceEnvironment;
118
- #workspaceDirectory;
119
- #summaries;
120
- #expanded;
121
- #defaults;
122
- #defaultEnabled;
136
+ #plugins;
137
+ #configurationNotices = new Map();
138
+ #operatorEnvironment;
139
+ #stateDirectory;
140
+ #configurationDirectories;
123
141
  // The committed attachments per workspace: the adapter's mirror of the snapshot
124
142
  // the coordinator holds, for continuations and refresh.
125
143
  #attachments = new Map();
@@ -131,43 +149,42 @@ class Module {
131
149
  #retainWorkspace = new Map();
132
150
  #handle;
133
151
  #closed = false;
152
+ #stopping = null;
134
153
  static init(options = {}) {
135
- return new _a(options.env ?? process.env);
154
+ return new Module(options.env ?? process.env);
136
155
  }
137
156
  constructor(environ) {
138
157
  this.#env = environ;
139
- this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
140
- this.#defaultEnabled = new Set(serviceEnabledNames(environ));
141
- this.#summaries = summaryOverrides(environ);
142
- this.#expanded = new Set(expandedServerNames(environ));
143
158
  }
144
159
  async setup(seam) {
160
+ this.#plugins = (workspaceId) => seam.readWorkspacePlugins(workspaceId);
145
161
  this.#workspaceEnvironment = (workspaceId) => seam.readWorkspaceEnvironment(workspaceId);
146
- this.#workspaceDirectory = (workspaceId) => seam.workspaceStateDirectory(workspaceId, OWNER);
162
+ this.#operatorEnvironment = () => seam.operatorEnvironment();
163
+ this.#stateDirectory = (workspaceId, owner) => seam.workspaceStateDirectory(workspaceId, owner);
164
+ this.#configurationDirectories = (workspaceId) => seam.workspaceConfigurationDirectories(workspaceId);
147
165
  this.#handle = seam.registerFunctionalityAdapter({
148
166
  family: FAMILY,
149
167
  namespaceOwner: OWNER,
150
168
  summary: "Manage MCP servers",
151
169
  definitionSchema: MCP_DEFINITION,
152
- example: { alias: "files", definition: { name: "files", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/absolute/project/path"] } },
170
+ example: { alias: "example-server", definition: { name: "example-server", type: "stdio", command: "npx", args: ["-y", "example-mcp-server@1.0.0"] } },
153
171
  docsDir: fileURLToPath(new URL("..", import.meta.url)),
154
172
  discovery: {
155
- details: "`source` is one MCP server URL or command line; the server is inspected without being attached, and the candidate carries the exact definition to add.",
173
+ details: "`query` searches the MCP Registry by server name; each candidate carries the exact definition to add.",
156
174
  },
157
- available: async () => [...this.#defaults].map(([name, definition]) => ({
158
- alias: name,
159
- definition: structuredClone(definition),
160
- enabled: this.#defaultEnabled.has(name),
161
- })),
162
- discover: (query, identity, options) => this.#discover(query, identity, options),
163
- admit: async (input, _identity, _caller, options) => this.#admit(input, options),
175
+ available: (identity) => this.#available(identity),
176
+ configurationNotices: ({ workspaceId }) => this.#configurationNotices.get(workspaceId) ?? [],
177
+ discover: (query) => this.#discover(query),
178
+ admit: (input) => this.#admit(input),
164
179
  prepare: (preparation) => this.#prepare(preparation),
165
180
  teardown: (snapshot, identity) => this.#teardown(snapshot, identity),
181
+ refreshIfChanged: (identity) => this.#refreshIfChanged(identity),
166
182
  });
167
183
  // Protocol continuations beneath the common grammar.
168
184
  seam.registerModuleAction({
169
185
  name: "workspace.mcp.oauth.complete",
170
186
  scope: "workspace",
187
+ residency: "required",
171
188
  inputSchema: actionInput({ alias: NONEMPTY_STRING, callbackUrl: NONEMPTY_STRING }, ["alias", "callbackUrl"]),
172
189
  outputSchema: MUTATION_RESULT,
173
190
  handler: (params, context) => this.#completeOAuth(workspaceIdentityOf(context), params),
@@ -175,6 +192,7 @@ class Module {
175
192
  seam.registerModuleAction({
176
193
  name: "workspace.mcp.complete",
177
194
  scope: "workspace",
195
+ residency: "required",
178
196
  inputSchema: actionInput({
179
197
  server: NONEMPTY_STRING,
180
198
  ref: OPEN_OBJECT,
@@ -185,6 +203,68 @@ class Module {
185
203
  handler: (params, context) => this.#complete(workspaceIdentityOf(context).workspaceId, params),
186
204
  });
187
205
  }
206
+ async #available(identity) {
207
+ expandedServerNames(this.#env);
208
+ const { workspaceId } = identity;
209
+ const notices = [];
210
+ this.#configurationNotices.set(workspaceId, notices);
211
+ return configuredDefinitions(await this.#configurationDirectories(workspaceId), this.#env, {
212
+ ...await this.#plugins(workspaceId), report: (notice) => notices.push(notice),
213
+ });
214
+ }
215
+ // {§functionality-hotload} — a server's definition is complete, so the coordinator's
216
+ // comparison of what it would prepare with what it published is the change test.
217
+ async #refreshIfChanged(identity) {
218
+ await this.#handleOrThrow().refresh(identity, { gate: "none", ifChanged: true });
219
+ }
220
+ // {§mcp-registry-discovery} Candidates are complete definitions, never installations.
221
+ async #discover(query) {
222
+ if (query.configuration !== undefined) {
223
+ throw actionError("configuration-unsupported", 400, "MCP discovery searches the MCP Registry by query; client configuration contributes nothing.", { retryable: false });
224
+ }
225
+ if (query.source !== undefined) {
226
+ throw actionError("source-unsupported", 400, "MCP discovery searches the MCP Registry by query; it does not install from a source URL.", {
227
+ source: query.source, retryable: false,
228
+ });
229
+ }
230
+ if (query.query === undefined)
231
+ return [];
232
+ const { url, limit } = registrySettings(this.#env);
233
+ if (url === null) {
234
+ throw actionError("registry-not-configured", 501, "MCP registry search is off: the operator names no registry.", { query: query.query, retryable: false });
235
+ }
236
+ let servers;
237
+ try {
238
+ servers = await searchRegistry({ url, query: query.query, limit, timeoutMs: connectTimeoutMs(this.#env) });
239
+ }
240
+ catch (cause) {
241
+ throw actionError("discover-failed", 502, cause instanceof RegistryError ? cause.message : `The MCP Registry search for '${query.query}' failed.`, {
242
+ query: query.query, retryable: true,
243
+ }, cause);
244
+ }
245
+ return servers.flatMap(registryEntries).map((found) => ({
246
+ alias: found.alias,
247
+ summary: found.summary,
248
+ definition: readDefinition({ name: found.alias, ...structuredClone(found.entry) }),
249
+ provenance: { kind: "registry", source: url, reference: found.reference },
250
+ }));
251
+ }
252
+ // {§mcp-server-definition} Live additions use the same schema as configured definitions.
253
+ async #admit(input) {
254
+ const params = objectOf(input) ?? {};
255
+ let definition;
256
+ try {
257
+ definition = readDefinition(params.definition);
258
+ }
259
+ catch (cause) {
260
+ throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { retryable: false }, cause);
261
+ }
262
+ const alias = typeof params.alias === "string" ? params.alias : definition.name;
263
+ if (alias !== definition.name) {
264
+ throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
265
+ }
266
+ return { alias, definition };
267
+ }
188
268
  #assertOpen() {
189
269
  if (this.#closed)
190
270
  throw new Error("MCP module is closed.");
@@ -218,149 +298,56 @@ class Module {
218
298
  if (errors.length > 0)
219
299
  throw new AggregateError(errors, "MCP connection shutdown failed");
220
300
  }
221
- // {§mcp-discovery} — inert: a direct target is probed and disconnected;
222
- // caller configuration is parsed into candidates; registry search waits for
223
- // a configured downstream registry.
224
- async #discover(query, identity, options = {}) {
301
+ // The attachment keeps the exact symbolic definition; resolved launch values never enter state.
302
+ async #prepareAttachment(workspaceId, binding, connection) {
303
+ const { definition, context } = binding;
225
304
  this.#assertOpen();
226
- const candidates = [];
227
- if (query.configuration !== undefined) {
228
- let definitions;
229
- try {
230
- definitions = overlayServerDefinitions(structuredClone(query.configuration), this.#defaults);
231
- }
232
- catch (cause) {
233
- throw actionError("configuration-invalid", 400, "The supplied MCP configuration is invalid.", { retryable: false }, cause);
234
- }
235
- for (const [name, definition] of definitions) {
236
- candidates.push({
237
- alias: name,
238
- definition: _a.#launchOptions(definition, options),
239
- provenance: { kind: "client-configuration", source: `PLURNK_MCP_${name.toUpperCase().replaceAll("-", "_")}` },
240
- summary: `${definition.transport} ${definition.transport === "http" ? definition.url : definition.command}`,
241
- });
242
- }
243
- }
244
- if (query.source !== undefined) {
245
- // A URL is a Streamable HTTP target; anything else is a command line
246
- // split on whitespace (exact paths with spaces are added, not probed).
247
- const source = query.source;
248
- const [command = "", ...args] = source.trim().split(/\s+/u);
249
- const definition = _a.#launchOptions(/^https?:\/\//u.test(source)
250
- ? { name: "discovered", transport: "http", url: source }
251
- : { name: "discovered", transport: "stdio", command, args }, options);
252
- Validator.assertMcpServerDefinition(definition);
253
- const environment = await this.#workspaceEnvironment(identity.workspaceId);
254
- let directory;
255
- const connection = new ServerConnection(definition, environment(this.#env), {
256
- environment: environment(),
257
- workingDirectory: async () => directory ??= await mkdtemp(join(await this.#workspaceDirectory(identity.workspaceId), "discover-")),
258
- onCatalogChanged: () => undefined,
259
- onInfrastructureError: () => undefined,
260
- });
261
- this.#connections.add(connection);
262
- const executor = new McpExecutor({ runtime: "discovered", glyph: "🔌" }, connection, () => () => undefined);
263
- let failure;
264
- try {
265
- await executor.requireAvailable();
266
- const catalog = executor.catalog;
267
- const name = catalog.server?.name?.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "discovered";
268
- candidates.push({
269
- alias: name,
270
- definition: { ...definition, name },
271
- provenance: { kind: "direct-target", source },
272
- summary: serverSummary(name, catalog, undefined),
273
- });
274
- }
275
- catch (cause) {
276
- if (cause instanceof AuthorizationRequiredError) {
277
- candidates.push({
278
- alias: "discovered",
279
- definition,
280
- provenance: { kind: "direct-target", source },
281
- summary: "The server requires authorization before it can be inspected; add it and complete the authorization.",
282
- });
283
- }
284
- else {
285
- failure = actionError("discover-failed", 502, `MCP target '${source}' could not be inspected.`, { source, retryable: true }, cause);
286
- }
287
- }
288
- try {
289
- await this.#closeOwned([connection]);
290
- if (directory !== undefined)
291
- await rm(directory, { recursive: true, force: true });
292
- }
293
- catch (cause) {
294
- if (failure !== undefined)
295
- throw new AggregateError([failure, cause], "MCP discovery and cleanup failed.");
296
- throw cause;
297
- }
298
- if (failure !== undefined)
299
- throw failure;
300
- }
301
- if (query.query !== undefined && query.source === undefined && query.configuration === undefined) {
302
- 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 });
303
- }
304
- return candidates;
305
- }
306
- static #launchOptions(definition, options) {
307
- if (options.env === undefined || Object.keys(options.env).length === 0)
308
- return definition;
309
- if (definition.transport !== "stdio")
310
- throw actionError("env-transport", 400, "An HTTP MCP server has no local process environment.", { retryable: false });
311
- return { ...definition, env: { ...definition.env, ...options.env } };
312
- }
313
- #admit(input, options = {}) {
314
- const params = objectOf(input) ?? {};
315
- let definition;
305
+ // {§mcp-server-settings} — the operator's settings for this alias; a bad one isolates this server.
306
+ let settings;
316
307
  try {
317
- definition = structuredClone(Validator.assertMcpServerDefinition(structuredClone(params.definition)));
308
+ settings = serverSettings(definition.name, this.#env);
318
309
  }
319
310
  catch (cause) {
320
- throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { retryable: false }, cause);
321
- }
322
- const alias = typeof params.alias === "string" ? params.alias : definition.name;
323
- if (alias !== definition.name) {
324
- throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
311
+ throw actionError("server-settings-invalid", 422, `MCP server '${definition.name}' has invalid operator settings: ${cause instanceof Error ? cause.message : String(cause)}`, {
312
+ server: definition.name, retryable: false,
313
+ }, cause);
325
314
  }
326
- return { alias, definition: _a.#launchOptions(definition, options) };
327
- }
328
- async #prepareAttachment(workspaceId, definition, connection) {
329
- this.#assertOpen();
330
- const environment = await this.#workspaceEnvironment(workspaceId);
331
- const candidate = connection ?? new ServerConnection(definition, environment(this.#env), {
332
- environment: environment(),
333
- workingDirectory: async () => {
334
- const directory = join(await this.#workspaceDirectory(workspaceId), "servers", definition.name);
335
- await mkdir(directory, { recursive: true, mode: 0o700 });
336
- return directory;
337
- },
338
- onCatalogChanged: (error) => {
339
- if (error !== null) {
340
- console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
341
- return;
342
- }
343
- if (this.#closed)
344
- return;
345
- this.#dirty.set(this.#pendingKey(workspaceId, definition.name), Symbol());
346
- this.#scheduleCatalogRefresh(workspaceId, definition.name);
347
- },
348
- onInfrastructureError: (error) => {
349
- console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
350
- },
351
- });
352
- this.#connections.add(candidate);
353
- const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workspaceId), { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
315
+ let candidate = connection;
354
316
  try {
317
+ const environment = await this.#workspaceEnvironment(workspaceId);
318
+ this.#assertOpen();
319
+ // {§mcp-launch-environment} — a configured server inherits the operator's environment beneath the
320
+ // workspace layer, as every MCP client launches one; the model's command ceiling is not its base.
321
+ candidate ??= new ServerConnection(definition, environment(this.#env), {
322
+ ...(definition.type === "stdio" && definition.cwd === undefined && context === undefined ? { cwd: await this.#stateDirectory(workspaceId, `${OWNER}/${definition.name}`) } : {}),
323
+ ...(context === undefined ? {} : { plugin: context }),
324
+ environment: environment(this.#operatorEnvironment()),
325
+ onCatalogChanged: (error) => {
326
+ if (error !== null) {
327
+ console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
328
+ return;
329
+ }
330
+ if (this.#closed)
331
+ return;
332
+ this.#dirty.set(this.#pendingKey(workspaceId, definition.name), Symbol());
333
+ this.#scheduleCatalogRefresh(workspaceId, definition.name);
334
+ },
335
+ onInfrastructureError: (error) => {
336
+ console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
337
+ },
338
+ });
339
+ this.#connections.add(candidate);
340
+ const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workspaceId), { tools: settings.tools });
355
341
  const availability = await executor.requireAvailable();
356
342
  return {
343
+ ...(context === undefined ? {} : { context }),
357
344
  kind: "active",
358
345
  definition,
359
346
  connection: candidate,
360
347
  executor,
361
348
  runtime: {
362
349
  namespaceOwner: OWNER,
363
- decl: runtimeDecl(definition.name, runtimeServerSummary(definition.name, executor.catalog, this.#summaries.servers.get(definition.name)), this.#expanded.has(definition.name), executor.catalog.instructions),
350
+ decl: runtimeDecl(definition.name, runtimeServerSummary(definition.name, executor.catalog), expandedServerNames(this.#env).includes(definition.name), executor.catalog.instructions),
364
351
  executor,
365
352
  availability,
366
353
  scheme: new McpResources(definition.name, candidate, executor.catalog),
@@ -368,8 +355,9 @@ class Module {
368
355
  };
369
356
  }
370
357
  catch (cause) {
371
- if (cause instanceof AuthorizationRequiredError) {
358
+ if (cause instanceof AuthorizationRequiredError && candidate !== undefined) {
372
359
  return {
360
+ ...(context === undefined ? {} : { context }),
373
361
  kind: "authorization-required",
374
362
  definition,
375
363
  connection: candidate,
@@ -377,7 +365,7 @@ class Module {
377
365
  };
378
366
  }
379
367
  let closeCause;
380
- if (connection === undefined) {
368
+ if (connection === undefined && candidate !== undefined) {
381
369
  try {
382
370
  await this.#closeOwned([candidate]);
383
371
  }
@@ -385,7 +373,7 @@ class Module {
385
373
  closeCause = error;
386
374
  }
387
375
  }
388
- throw preparationError(definition, cause, closeCause);
376
+ throw preparationError(definition, definition.type === "streamable-http" ? definition.authorization : undefined, cause, closeCause);
389
377
  }
390
378
  }
391
379
  // {§functionality-adapter} two-phase preparation: reuse unchanged live
@@ -400,7 +388,7 @@ class Module {
400
388
  const previous = preparation.previous ?? new Map();
401
389
  for (const [name, attachment] of previous) {
402
390
  const nextDefinition = enabled.get(name);
403
- if (nextDefinition === undefined || force === name || !sameDefinition(attachment.definition, nextDefinition)) {
391
+ if (nextDefinition === undefined || force === name || !sameBinding(attachment, nextDefinition)) {
404
392
  const activeRequests = attachmentConnection(attachment)?.activeRequests ?? 0;
405
393
  if (activeRequests > 0)
406
394
  throw actionError("server-busy", 409, `MCP server '${name}' has ${activeRequests} active request(s).`, { server: name, activeRequests, retryable: true });
@@ -413,15 +401,17 @@ class Module {
413
401
  const refreshed = new Map();
414
402
  try {
415
403
  for (const [name, value] of enabled) {
404
+ preparation.progress(name);
416
405
  const key = this.#pendingKey(workspaceId, name);
417
406
  const invalidation = this.#dirty.get(key);
418
- const definition = value;
407
+ const binding = value;
408
+ const { definition } = binding;
419
409
  const existing = previous.get(name);
420
410
  const pending = this.#pending.get(this.#pendingKey(workspaceId, name));
421
411
  if (existing !== undefined
422
412
  && force !== name
423
413
  && !this.#dirty.has(this.#pendingKey(workspaceId, name))
424
- && sameDefinition(existing.definition, definition)
414
+ && sameBinding(existing, binding)
425
415
  && !(pending?.prepared !== undefined)) {
426
416
  next.set(name, existing);
427
417
  continue;
@@ -430,10 +420,10 @@ class Module {
430
420
  const heldConnection = existing === undefined ? undefined : attachmentConnection(existing);
431
421
  const catalogOnly = existing !== undefined
432
422
  && force !== name
433
- && sameDefinition(existing.definition, definition)
423
+ && sameBinding(existing, binding)
434
424
  && !(pending?.prepared !== undefined)
435
425
  && heldConnection !== undefined;
436
- if (pending?.prepared !== undefined && sameDefinition(pending.definition, definition)) {
426
+ if (pending?.prepared !== undefined && sameBinding(pending, binding)) {
437
427
  attachment = pending.prepared;
438
428
  consumedPending.set(name, pending);
439
429
  }
@@ -443,7 +433,7 @@ class Module {
443
433
  // spawned, so neither abort nor commit has anything of this alias to close
444
434
  // (#429).
445
435
  try {
446
- attachment = await this.#prepareAttachment(workspaceId, definition, heldConnection);
436
+ attachment = await this.#prepareAttachment(workspaceId, binding, heldConnection);
447
437
  }
448
438
  catch (cause) {
449
439
  this.#assertOpen();
@@ -453,14 +443,14 @@ class Module {
453
443
  }
454
444
  else {
455
445
  try {
456
- attachment = await this.#prepareAttachment(workspaceId, definition);
446
+ attachment = await this.#prepareAttachment(workspaceId, binding);
457
447
  }
458
448
  catch (cause) {
459
449
  this.#assertOpen();
460
450
  if (failure === "reject")
461
451
  throw cause;
462
- const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, cause);
463
- attachment = { kind: "unavailable", definition, problem: structuredClone(refusal.problem) };
452
+ const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, undefined, cause);
453
+ attachment = { ...binding, kind: "unavailable", problem: structuredClone(refusal.problem) };
464
454
  console.error(`MCP server '${name}' unavailable: ${refusal.problem.detail}`, refusal.cause ?? refusal);
465
455
  }
466
456
  // Only a connection this attempt opened is the attempt's to close on abort.
@@ -473,7 +463,7 @@ class Module {
473
463
  }
474
464
  }
475
465
  catch (cause) {
476
- const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
466
+ const cleanup = [...await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])))];
477
467
  const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
478
468
  if (failures.length > 0)
479
469
  throw new AggregateError([cause, ...failures], "MCP workspace preparation and cleanup failed.");
@@ -520,6 +510,18 @@ class Module {
520
510
  this.#pending.delete(this.#pendingKey(workspaceId, name));
521
511
  pending.releaseWorkspace();
522
512
  }
513
+ // {§oauth-lifetime} Only the same pending connection can retain its authorization flow.
514
+ for (const [key, pending] of this.#pending) {
515
+ if (!key.startsWith(`${workspaceId}:`))
516
+ continue;
517
+ const current = next.get(key.slice(`${workspaceId}:`.length));
518
+ if (current?.kind === "authorization-required" && current.connection === pending.connection)
519
+ continue;
520
+ this.#pending.delete(key);
521
+ pending.releaseWorkspace();
522
+ if (!retained.has(pending.connection))
523
+ obsolete.push(pending.connection);
524
+ }
523
525
  for (const [name, attachment] of next) {
524
526
  if (attachment.kind !== "authorization-required")
525
527
  continue;
@@ -529,6 +531,7 @@ class Module {
529
531
  continue;
530
532
  this.#pending.set(key, {
531
533
  definition: attachment.definition,
534
+ ...(attachment.context === undefined ? {} : { context: attachment.context }),
532
535
  connection: attachment.connection,
533
536
  authorizationUrl: attachment.authorizationUrl,
534
537
  releaseWorkspace: this.#retain(workspaceId),
@@ -585,13 +588,13 @@ class Module {
585
588
  throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workspaceId: identity.workspaceId, alias, retryable: false });
586
589
  }
587
590
  const current = this.#attachments.get(identity.workspaceId)?.get(alias);
588
- if (current === undefined || current.kind !== "authorization-required" || !sameDefinition(current.definition, pending.definition)) {
591
+ if (current === undefined || current.kind !== "authorization-required" || !sameBinding(current, pending)) {
589
592
  throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workspaceId: identity.workspaceId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
590
593
  }
591
594
  if (pending.prepared === undefined) {
592
595
  try {
593
596
  await pending.connection.finishAuthorization(callbackUrl);
594
- const prepared = await this.#prepareAttachment(identity.workspaceId, pending.definition, pending.connection);
597
+ const prepared = await this.#prepareAttachment(identity.workspaceId, pending, pending.connection);
595
598
  if (prepared.kind !== "active")
596
599
  throw new Error("OAuth completion returned another authorization challenge.");
597
600
  pending.prepared = prepared;
@@ -656,9 +659,11 @@ class Module {
656
659
  clearTimeout(this.#refreshTimers.get(key));
657
660
  this.#refreshTimers.delete(key);
658
661
  }
659
- async close() {
660
- if (this.#closed)
661
- return;
662
+ stop() {
663
+ this.#stopping ??= this.#stop();
664
+ return this.#stopping;
665
+ }
666
+ async #stop() {
662
667
  this.#closed = true;
663
668
  for (const timer of this.#refreshTimers.values())
664
669
  clearTimeout(timer);
@@ -674,6 +679,4 @@ class Module {
674
679
  await closing;
675
680
  }
676
681
  }
677
- _a = Module;
678
- export default Module;
679
682
  //# sourceMappingURL=Module.js.map