@plurnk/plurnk-mcp 1.6.1 → 1.7.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.
Files changed (60) hide show
  1. package/.env.defaults +6 -4
  2. package/README.md +110 -45
  3. package/SPEC.md +274 -30
  4. package/dist/McpExecutor.d.ts +25 -4
  5. package/dist/McpExecutor.d.ts.map +1 -1
  6. package/dist/McpExecutor.js +105 -31
  7. package/dist/McpExecutor.js.map +1 -1
  8. package/dist/McpResources.d.ts +2 -2
  9. package/dist/McpResources.d.ts.map +1 -1
  10. package/dist/McpResources.js +114 -24
  11. package/dist/McpResources.js.map +1 -1
  12. package/dist/Module.d.ts +30 -5
  13. package/dist/Module.d.ts.map +1 -1
  14. package/dist/Module.js +649 -35
  15. package/dist/Module.js.map +1 -1
  16. package/dist/ToolPresentation.d.ts +5 -0
  17. package/dist/ToolPresentation.d.ts.map +1 -0
  18. package/dist/ToolPresentation.js +134 -0
  19. package/dist/ToolPresentation.js.map +1 -0
  20. package/dist/client.d.ts +25 -7
  21. package/dist/client.d.ts.map +1 -1
  22. package/dist/client.js +478 -85
  23. package/dist/client.js.map +1 -1
  24. package/dist/config.d.ts +7 -13
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +114 -45
  27. package/dist/config.js.map +1 -1
  28. package/dist/extensionChannel.d.ts +25 -0
  29. package/dist/extensionChannel.d.ts.map +1 -0
  30. package/dist/extensionChannel.js +195 -0
  31. package/dist/extensionChannel.js.map +1 -0
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/inputRequired.d.ts +36 -0
  37. package/dist/inputRequired.d.ts.map +1 -0
  38. package/dist/inputRequired.js +171 -0
  39. package/dist/inputRequired.js.map +1 -0
  40. package/dist/oauth.d.ts +28 -0
  41. package/dist/oauth.d.ts.map +1 -0
  42. package/dist/oauth.js +149 -0
  43. package/dist/oauth.js.map +1 -0
  44. package/dist/protocol.d.ts +7 -0
  45. package/dist/protocol.d.ts.map +1 -0
  46. package/dist/protocol.js +7 -0
  47. package/dist/protocol.js.map +1 -0
  48. package/dist/protocolHeaders.d.ts +4 -0
  49. package/dist/protocolHeaders.d.ts.map +1 -0
  50. package/dist/protocolHeaders.js +87 -0
  51. package/dist/protocolHeaders.js.map +1 -0
  52. package/dist/subscriptions.d.ts +15 -0
  53. package/dist/subscriptions.d.ts.map +1 -0
  54. package/dist/subscriptions.js +188 -0
  55. package/dist/subscriptions.js.map +1 -0
  56. package/dist/tasks.d.ts +19 -0
  57. package/dist/tasks.d.ts.map +1 -0
  58. package/dist/tasks.js +334 -0
  59. package/dist/tasks.js.map +1 -0
  60. package/package.json +10 -6
package/dist/Module.js CHANGED
@@ -1,55 +1,669 @@
1
- import ServerConnection from "./client.js";
2
- import { serverConfig, serverNames } from "./config.js";
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";
3
5
  import McpExecutor, { runtimeDecl } from "./McpExecutor.js";
4
6
  import McpResources from "./McpResources.js";
7
+ import { MCP_PROTOCOL_VERSION } from "./protocol.js";
8
+ const OWNER = "@plurnk/plurnk-mcp";
9
+ const STATE_VERSION = 1;
10
+ class ModuleActionError extends Error {
11
+ problem;
12
+ constructor(problem, cause) {
13
+ super(problem.detail, { cause });
14
+ this.name = "ModuleActionError";
15
+ this.problem = problem;
16
+ }
17
+ }
18
+ const actionError = (code, status, detail, extensions = {}, cause) => new ModuleActionError(Problems.create("mcp:management", code, status, detail, {
19
+ stage: "mcp-management",
20
+ retryable: status === 409 || status >= 500,
21
+ ...extensions,
22
+ }), cause);
23
+ 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
+ const preparationError = (definition, cause, closeCause) => {
45
+ const completeCause = closeCause === undefined
46
+ ? cause
47
+ : 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.`, {
50
+ server: definition.name,
51
+ transport: definition.transport,
52
+ requiredRevision: MCP_PROTOCOL_VERSION,
53
+ requiredMethod: "server/discover",
54
+ retryable: false,
55
+ }, completeCause);
56
+ }
57
+ return actionError("server-unavailable", 502, `Configured MCP server '${definition.name}' is unavailable.`, {
58
+ server: definition.name,
59
+ transport: definition.transport,
60
+ retryable: true,
61
+ }, completeCause);
62
+ };
63
+ export const closeConnections = async (connections) => {
64
+ const results = await Promise.allSettled([...new Set(connections)].map((connection) => connection.close()));
65
+ const errors = results
66
+ .filter((result) => result.status === "rejected")
67
+ .flatMap((result) => errorsOf(result.reason));
68
+ if (errors.length > 0) {
69
+ throw new AggregateError(errors, "MCP connection shutdown failed");
70
+ }
71
+ };
72
+ const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
73
+ ? value
74
+ : 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 };
111
+ }
112
+ return { version: STATE_VERSION, servers: parsed };
113
+ };
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];
119
+ if (typeof value !== "string" || value.length === 0) {
120
+ throw actionError("parameter-required", 400, `MCP action requires a non-empty '${field}' string.`, { field, retryable: false });
121
+ }
122
+ return value;
123
+ };
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;
129
+ };
130
+ 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);
137
+ return typeof problem?.status === "number" ? problem.status : null;
138
+ };
5
139
  export default class Module {
6
140
  #env;
7
- #connections = new Map();
141
+ #defaults;
142
+ #workspaces = new Map();
143
+ #pending = new Map();
144
+ #locks = new Map();
145
+ #refreshTimers = new Map();
146
+ #seam;
147
+ #closed = false;
8
148
  static init(options = {}) {
9
149
  return new Module(options.env ?? process.env);
10
150
  }
11
151
  constructor(environ) {
12
152
  this.#env = environ;
153
+ this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
13
154
  }
14
155
  async setup(seam) {
15
- const results = await Promise.allSettled(serverNames(this.#env).map(async (name) => {
16
- const config = serverConfig(name, this.#env);
17
- if (config === null)
18
- throw new Error(`MCP server '${name}' disappeared during configuration.`);
19
- const connection = new ServerConnection(config, this.#env);
20
- this.#connections.set(name, connection);
21
- const decl = runtimeDecl(name);
22
- const executor = new McpExecutor({
23
- runtime: name,
24
- glyph: decl.glyph ?? "",
25
- }, connection);
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
+ }),
164
+ });
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
+ }
174
+ #pendingKey(workspaceId, name) {
175
+ return `${workspaceId}:${name}`;
176
+ }
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();
186
+ }
187
+ finally {
188
+ release();
189
+ if (this.#locks.get(workspaceId) === queued)
190
+ this.#locks.delete(workspaceId);
191
+ }
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",
205
+ });
206
+ }
207
+ return new Map([...effective].toSorted(([left], [right]) => left.localeCompare(right)));
208
+ }
209
+ async #prepareAttachment(workspaceId, effective, connection) {
210
+ const definition = effective.definition;
211
+ const candidate = connection ?? new ServerConnection(definition, this.#env, {
212
+ onCatalogChanged: (error) => {
213
+ if (error !== null) {
214
+ console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
215
+ return;
216
+ }
217
+ this.#scheduleCatalogRefresh(workspaceId, definition.name);
218
+ },
219
+ onInfrastructureError: (error) => {
220
+ console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
221
+ },
222
+ });
223
+ const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] });
224
+ try {
225
+ const availability = await executor.requireAvailable();
26
226
  return {
27
- namespaceOwner: "@plurnk/plurnk-mcp",
28
- decl,
227
+ kind: "active",
228
+ ...effective,
229
+ connection: candidate,
29
230
  executor,
30
- availability: await executor.probe(),
31
- scheme: new McpResources(name, connection),
231
+ runtime: {
232
+ namespaceOwner: OWNER,
233
+ decl: runtimeDecl(definition.name),
234
+ executor,
235
+ availability,
236
+ scheme: new McpResources(definition.name, candidate, executor.catalog),
237
+ },
238
+ };
239
+ }
240
+ catch (cause) {
241
+ if (cause instanceof AuthorizationRequiredError) {
242
+ return {
243
+ kind: "authorization-required",
244
+ ...effective,
245
+ connection: candidate,
246
+ authorizationUrl: cause.authorizationUrl,
247
+ };
248
+ }
249
+ let closeCause;
250
+ try {
251
+ await candidate.close();
252
+ }
253
+ catch (error) {
254
+ closeCause = error;
255
+ }
256
+ throw preparationError(definition, cause, closeCause);
257
+ }
258
+ }
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();
272
+ }
273
+ const next = new Map();
274
+ const fresh = [];
275
+ try {
276
+ for (const [name, definition] of effective) {
277
+ const existing = current?.attachments.get(name);
278
+ if (existing !== undefined
279
+ && !force.has(name)
280
+ && sameDefinition(existing.definition, definition.definition)) {
281
+ next.set(name, existing);
282
+ continue;
283
+ }
284
+ const supplied = options.prepared?.get(name);
285
+ const attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
286
+ 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.");
295
+ }
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
+ }
313
+ catch (cause) {
314
+ const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
315
+ 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
+ }
319
+ throw cause;
320
+ }
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);
332
+ }
333
+ }
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);
406
+ return {
407
+ servers: [...snapshot.attachments]
408
+ .toSorted(([left], [right]) => left.localeCompare(right))
409
+ .map(([name, attachment]) => this.#summary(name, attachment)),
410
+ };
411
+ }
412
+ #definition(params) {
413
+ try {
414
+ return structuredClone(Validator.assertMcpServerDefinition(params.server));
415
+ }
416
+ catch (cause) {
417
+ throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { field: "server", retryable: false }, cause);
418
+ }
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,
32
435
  };
33
- }));
34
- const errors = results
35
- .filter((result) => result.status === "rejected")
36
- .map((result) => result.reason);
37
- if (errors.length > 0)
38
- throw new AggregateError(errors, "MCP module setup failed");
39
- const registrations = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
40
- for (const registration of registrations) {
41
- await seam.registerRuntime(registration);
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
+ });
528
+ }
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
+ };
535
+ }
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 });
546
+ }
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
+ }
559
+ }
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
+ });
572
+ }
573
+ async #complete(workspaceId, params) {
574
+ const server = requiredString(params, "server");
575
+ const attachment = this.#snapshot(workspaceId).attachments.get(server);
576
+ 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 });
42
578
  }
579
+ const ref = objectOf(params.ref);
580
+ const argument = objectOf(params.argument);
581
+ if (ref === null || argument === null) {
582
+ throw actionError("completion-parameters-invalid", 400, "MCP completion requires 'ref' and 'argument' objects.", { retryable: false });
583
+ }
584
+ return attachment.connection.complete({
585
+ ref: ref,
586
+ argument: argument,
587
+ ...(objectOf(params.context) === null ? {} : { context: params.context }),
588
+ });
589
+ }
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) {
628
+ if (this.#closed)
629
+ return;
630
+ const key = this.#pendingKey(workspaceId, name);
631
+ if (this.#refreshTimers.has(key))
632
+ return;
633
+ const delay = Math.min(250 * (2 ** attempt), 5000);
634
+ const timer = setTimeout(() => {
635
+ this.#refreshTimers.delete(key);
636
+ void this.#serialize(workspaceId, async () => {
637
+ await this.#refreshCatalog(workspaceId, name);
638
+ }).catch((error) => {
639
+ if (statusOf(error) === 409) {
640
+ this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
641
+ return;
642
+ }
643
+ console.error(`MCP server '${name}' capability refresh failed:`, error);
644
+ });
645
+ }, delay);
646
+ timer.unref();
647
+ this.#refreshTimers.set(key, timer);
43
648
  }
44
649
  async close() {
45
- const connections = [...this.#connections.values()];
46
- this.#connections.clear();
47
- const results = await Promise.allSettled(connections.map((connection) => connection.close()));
48
- const errors = results
49
- .filter((result) => result.status === "rejected")
50
- .map((result) => result.reason);
51
- if (errors.length > 0)
52
- throw new AggregateError(errors, "MCP connection shutdown failed");
650
+ if (this.#closed)
651
+ return;
652
+ this.#closed = true;
653
+ for (const timer of this.#refreshTimers.values())
654
+ clearTimeout(timer);
655
+ 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
+ }
662
+ for (const pending of this.#pending.values())
663
+ connections.add(pending.connection);
664
+ this.#workspaces.clear();
665
+ this.#pending.clear();
666
+ await closeConnections([...connections]);
53
667
  }
54
668
  }
55
669
  //# sourceMappingURL=Module.js.map