@plurnk/plurnk-mcp 1.6.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.defaults +31 -4
- package/README.md +161 -42
- package/SPEC.md +486 -28
- package/dist/McpExecutor.d.ts +28 -5
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +144 -32
- package/dist/McpExecutor.js.map +1 -1
- package/dist/McpResources.d.ts +2 -2
- package/dist/McpResources.d.ts.map +1 -1
- package/dist/McpResources.js +114 -24
- package/dist/McpResources.js.map +1 -1
- package/dist/Module.d.ts +30 -5
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +857 -36
- package/dist/Module.js.map +1 -1
- package/dist/ToolPresentation.d.ts +7 -0
- package/dist/ToolPresentation.d.ts.map +1 -0
- package/dist/ToolPresentation.js +224 -0
- package/dist/ToolPresentation.js.map +1 -0
- package/dist/capabilityMatrix.d.ts +23 -0
- package/dist/capabilityMatrix.d.ts.map +1 -0
- package/dist/capabilityMatrix.js +403 -0
- package/dist/capabilityMatrix.js.map +1 -0
- package/dist/client.d.ts +27 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +537 -91
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +14 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +257 -51
- package/dist/config.js.map +1 -1
- package/dist/extensionChannel.d.ts +25 -0
- package/dist/extensionChannel.d.ts.map +1 -0
- package/dist/extensionChannel.js +195 -0
- package/dist/extensionChannel.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/inputRequired.d.ts +36 -0
- package/dist/inputRequired.d.ts.map +1 -0
- package/dist/inputRequired.js +171 -0
- package/dist/inputRequired.js.map +1 -0
- package/dist/mcp-watchdog.mjs +106 -0
- package/dist/oauth.d.ts +28 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +149 -0
- package/dist/oauth.js.map +1 -0
- package/dist/protocol.d.ts +8 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +8 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocolHeaders.d.ts +4 -0
- package/dist/protocolHeaders.d.ts.map +1 -0
- package/dist/protocolHeaders.js +87 -0
- package/dist/protocolHeaders.js.map +1 -0
- package/dist/subscriptions.d.ts +15 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +188 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/tasks.d.ts +19 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +334 -0
- package/dist/tasks.js.map +1 -0
- package/package.json +12 -7
package/dist/Module.js
CHANGED
|
@@ -1,55 +1,876 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { Problems, Validator, } from "@plurnk/plurnk-contracts";
|
|
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";
|
|
4
5
|
import McpResources from "./McpResources.js";
|
|
6
|
+
const OWNER = "@plurnk/plurnk-mcp";
|
|
7
|
+
const STATE_VERSION = 1;
|
|
8
|
+
const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
|
|
9
|
+
class ModuleActionError extends Error {
|
|
10
|
+
problem;
|
|
11
|
+
constructor(problem, cause) {
|
|
12
|
+
super(problem.detail, { cause });
|
|
13
|
+
this.name = "ModuleActionError";
|
|
14
|
+
this.problem = problem;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const actionError = (code, status, detail, extensions = {}, cause) => new ModuleActionError(Problems.create("mcp:management", code, status, detail, {
|
|
18
|
+
stage: "mcp-management",
|
|
19
|
+
retryable: status === 409 || status >= 500,
|
|
20
|
+
...extensions,
|
|
21
|
+
}), cause);
|
|
22
|
+
const errorsOf = (error) => error instanceof AggregateError ? [...error.errors] : [error];
|
|
23
|
+
const preparationError = (definition, cause, closeCause) => {
|
|
24
|
+
const completeCause = closeCause === undefined
|
|
25
|
+
? cause
|
|
26
|
+
: new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
|
|
27
|
+
// {§oauth-client-credentials} — a rejected grant is an authorization fact,
|
|
28
|
+
// never a generic unavailability.
|
|
29
|
+
if (definition.authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
|
|
30
|
+
return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
|
|
31
|
+
server: definition.name,
|
|
32
|
+
transport: definition.transport,
|
|
33
|
+
clientId: definition.authorization.clientId,
|
|
34
|
+
...(definition.authorization.issuer === undefined
|
|
35
|
+
? {}
|
|
36
|
+
: { issuer: definition.authorization.issuer }),
|
|
37
|
+
retryable: false,
|
|
38
|
+
}, completeCause);
|
|
39
|
+
}
|
|
40
|
+
return actionError("server-unavailable", 502, `Configured MCP server '${definition.name}' is unavailable.`, {
|
|
41
|
+
server: definition.name,
|
|
42
|
+
transport: definition.transport,
|
|
43
|
+
retryable: true,
|
|
44
|
+
}, completeCause);
|
|
45
|
+
};
|
|
46
|
+
export const closeConnections = async (connections) => {
|
|
47
|
+
const results = await Promise.allSettled([...new Set(connections)].map((connection) => connection.close()));
|
|
48
|
+
const errors = results
|
|
49
|
+
.filter((result) => result.status === "rejected")
|
|
50
|
+
.flatMap((result) => errorsOf(result.reason));
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
throw new AggregateError(errors, "MCP connection shutdown failed");
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
56
|
+
? value
|
|
57
|
+
: null;
|
|
58
|
+
const assertExactKeys = (value, allowed, owner) => {
|
|
59
|
+
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
60
|
+
if (extras.length > 0)
|
|
61
|
+
throw new Error(`${owner} contains unsupported field(s): ${extras.join(", ")}.`);
|
|
62
|
+
};
|
|
63
|
+
const emptyState = () => ({ version: STATE_VERSION, servers: {} });
|
|
64
|
+
const parseState = (source) => {
|
|
65
|
+
if (source === null)
|
|
66
|
+
return emptyState();
|
|
67
|
+
const state = objectOf(source);
|
|
68
|
+
if (state === null)
|
|
69
|
+
throw new Error("MCP workspace state must be an object.");
|
|
70
|
+
assertExactKeys(state, ["version", "servers"], "MCP workspace state");
|
|
71
|
+
if (state.version !== STATE_VERSION) {
|
|
72
|
+
throw new Error(`MCP workspace state version must be ${STATE_VERSION}.`);
|
|
73
|
+
}
|
|
74
|
+
const servers = objectOf(state.servers);
|
|
75
|
+
if (servers === null)
|
|
76
|
+
throw new Error("MCP workspace state servers must be an object.");
|
|
77
|
+
const parsed = {};
|
|
78
|
+
for (const [name, raw] of Object.entries(servers).toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
79
|
+
const value = objectOf(raw);
|
|
80
|
+
if (value === null || (value.kind !== "service" && value.kind !== "workspace")) {
|
|
81
|
+
throw new Error(`MCP workspace server '${name}' has an invalid state.`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value.enabled !== "boolean") {
|
|
84
|
+
throw new Error(`MCP workspace server '${name}' must declare boolean enabledness.`);
|
|
85
|
+
}
|
|
86
|
+
if (value.kind === "service") {
|
|
87
|
+
assertExactKeys(value, ["kind", "enabled"], `MCP workspace server '${name}'`);
|
|
88
|
+
parsed[name] = { kind: "service", enabled: value.enabled };
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
assertExactKeys(value, ["kind", "definition", "enabled"], `MCP workspace server '${name}'`);
|
|
92
|
+
const definition = structuredClone(Validator.assertMcpServerDefinition(value.definition));
|
|
93
|
+
if (definition.name !== name) {
|
|
94
|
+
throw new Error(`MCP workspace server key '${name}' does not match definition '${definition.name}'.`);
|
|
95
|
+
}
|
|
96
|
+
parsed[name] = { kind: "workspace", definition, enabled: value.enabled };
|
|
97
|
+
}
|
|
98
|
+
return { version: STATE_VERSION, servers: parsed };
|
|
99
|
+
};
|
|
100
|
+
const persistedState = (state) => Object.keys(state.servers).length === 0 ? null : state;
|
|
101
|
+
const cloneState = (state) => parseState(structuredClone(state));
|
|
102
|
+
const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
103
|
+
const sameServerState = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
104
|
+
const requiredString = (params, field) => {
|
|
105
|
+
const value = params[field];
|
|
106
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
107
|
+
throw actionError("parameter-required", 400, `MCP action requires a non-empty '${field}' string.`, { field, retryable: false });
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
};
|
|
111
|
+
const assertActionKeys = (params, allowed) => {
|
|
112
|
+
const extras = Object.keys(params).filter((key) => !allowed.includes(key));
|
|
113
|
+
if (extras.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
throw actionError("parameters-invalid", 400, `MCP action contains unsupported parameter(s): ${extras.join(", ")}.`, { fields: extras, retryable: false });
|
|
116
|
+
};
|
|
117
|
+
const workspaceIdOf = (context) => {
|
|
118
|
+
if (context.scope !== "workspace") {
|
|
119
|
+
throw actionError("workspace-context-required", 500, "MCP action received no workspace context.");
|
|
120
|
+
}
|
|
121
|
+
return context.workspaceId;
|
|
122
|
+
};
|
|
123
|
+
const statusOf = (error) => {
|
|
124
|
+
const candidate = objectOf(error);
|
|
125
|
+
const direct = objectOf(candidate?.problem);
|
|
126
|
+
if (typeof direct?.status === "number")
|
|
127
|
+
return direct.status;
|
|
128
|
+
const result = objectOf(candidate?.result);
|
|
129
|
+
const problem = objectOf(result?.problem);
|
|
130
|
+
return typeof problem?.status === "number" ? problem.status : null;
|
|
131
|
+
};
|
|
5
132
|
export default class Module {
|
|
6
133
|
#env;
|
|
7
|
-
#
|
|
134
|
+
#summaries;
|
|
135
|
+
#expanded;
|
|
136
|
+
#defaults;
|
|
137
|
+
#defaultEnabled;
|
|
138
|
+
#workspaces = new Map();
|
|
139
|
+
// {§oauth-lifetime} — pending candidates are process-memory per
|
|
140
|
+
// (workspace, alias); a restart loses them by design.
|
|
141
|
+
#pending = new Map();
|
|
142
|
+
#locks = new Map();
|
|
143
|
+
#refreshTimers = new Map();
|
|
144
|
+
#seam;
|
|
145
|
+
#closed = false;
|
|
8
146
|
static init(options = {}) {
|
|
9
147
|
return new Module(options.env ?? process.env);
|
|
10
148
|
}
|
|
11
149
|
constructor(environ) {
|
|
12
150
|
this.#env = environ;
|
|
151
|
+
this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
|
|
152
|
+
this.#defaultEnabled = new Set(serviceEnabledNames(environ));
|
|
153
|
+
this.#summaries = summaryOverrides(environ);
|
|
154
|
+
this.#expanded = new Set(expandedServerNames(environ));
|
|
13
155
|
}
|
|
14
156
|
async setup(seam) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
157
|
+
this.#seam = seam;
|
|
158
|
+
seam.registerWorkspaceCapabilityProvider(OWNER, {
|
|
159
|
+
hydrate: async (workspaceId) => this.#serialize(workspaceId, async () => {
|
|
160
|
+
const state = parseState(await seam.readWorkspaceModuleState(workspaceId, OWNER));
|
|
161
|
+
await this.#applyState(workspaceId, state, {
|
|
162
|
+
authorizationDisposition: "publish-required",
|
|
163
|
+
preparationFailureDisposition: "publish-unavailable",
|
|
164
|
+
});
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
const action = (name, handler) => seam.registerModuleAction({ name, scope: "workspace", handler });
|
|
168
|
+
action("workspace.mcp.list", async (params, context) => {
|
|
169
|
+
assertActionKeys(params, ["overlay"]);
|
|
170
|
+
return this.#list(workspaceIdOf(context), params.overlay);
|
|
171
|
+
});
|
|
172
|
+
action("workspace.mcp.add", async (params, context) => this.#add(workspaceIdOf(context), params));
|
|
173
|
+
action("workspace.mcp.enable", async (params, context) => this.#setEnabled(workspaceIdOf(context), params, true));
|
|
174
|
+
action("workspace.mcp.disable", async (params, context) => this.#setEnabled(workspaceIdOf(context), params, false));
|
|
175
|
+
action("workspace.mcp.remove", async (params, context) => this.#remove(workspaceIdOf(context), params));
|
|
176
|
+
action("workspace.mcp.oauth.complete", async (params, context) => this.#completeOAuth(workspaceIdOf(context), params));
|
|
177
|
+
action("workspace.mcp.complete", async (params, context) => this.#complete(workspaceIdOf(context), params));
|
|
178
|
+
}
|
|
179
|
+
#pendingKey(workspaceId, name) {
|
|
180
|
+
return `${workspaceId}:${name}`;
|
|
181
|
+
}
|
|
182
|
+
async #serialize(workspaceId, run) {
|
|
183
|
+
const prior = this.#locks.get(workspaceId) ?? Promise.resolve();
|
|
184
|
+
let release = () => undefined;
|
|
185
|
+
const barrier = new Promise((resolve) => { release = resolve; });
|
|
186
|
+
const queued = prior.then(() => barrier, () => barrier);
|
|
187
|
+
this.#locks.set(workspaceId, queued);
|
|
188
|
+
await prior.catch(() => undefined);
|
|
189
|
+
try {
|
|
190
|
+
return await run();
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
release();
|
|
194
|
+
if (this.#locks.get(workspaceId) === queued)
|
|
195
|
+
this.#locks.delete(workspaceId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
#available(state) {
|
|
199
|
+
const available = new Map([...this.#defaults].map(([name, definition]) => [
|
|
200
|
+
name,
|
|
201
|
+
{
|
|
202
|
+
definition: structuredClone(definition),
|
|
203
|
+
source: "service",
|
|
204
|
+
enabled: this.#defaultEnabled.has(name),
|
|
205
|
+
},
|
|
206
|
+
]));
|
|
207
|
+
for (const [name, value] of Object.entries(state.servers)) {
|
|
208
|
+
if (value.kind === "service") {
|
|
209
|
+
const configured = available.get(name);
|
|
210
|
+
if (configured !== undefined)
|
|
211
|
+
available.set(name, { ...configured, enabled: value.enabled });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
available.set(name, {
|
|
215
|
+
definition: structuredClone(value.definition),
|
|
216
|
+
source: "workspace",
|
|
217
|
+
enabled: value.enabled,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return new Map([...available].toSorted(([left], [right]) => left.localeCompare(right)));
|
|
221
|
+
}
|
|
222
|
+
#enabled(state) {
|
|
223
|
+
return new Map([...this.#available(state)]
|
|
224
|
+
.filter(([, value]) => value.enabled)
|
|
225
|
+
.map(([name, { definition, source }]) => [name, { definition, source }]));
|
|
226
|
+
}
|
|
227
|
+
async #prepareAttachment(workspaceId, effective, connection) {
|
|
228
|
+
const definition = effective.definition;
|
|
229
|
+
const candidate = connection ?? new ServerConnection(definition, this.#env, {
|
|
230
|
+
onCatalogChanged: (error) => {
|
|
231
|
+
if (error !== null) {
|
|
232
|
+
console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.#scheduleCatalogRefresh(workspaceId, definition.name);
|
|
236
|
+
},
|
|
237
|
+
onInfrastructureError: (error) => {
|
|
238
|
+
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
|
|
242
|
+
try {
|
|
243
|
+
const availability = await executor.requireAvailable();
|
|
26
244
|
return {
|
|
27
|
-
|
|
28
|
-
|
|
245
|
+
kind: "active",
|
|
246
|
+
...effective,
|
|
247
|
+
connection: candidate,
|
|
29
248
|
executor,
|
|
30
|
-
|
|
31
|
-
|
|
249
|
+
runtime: {
|
|
250
|
+
namespaceOwner: OWNER,
|
|
251
|
+
decl: runtimeDecl(definition.name, serverSummary(definition.name, executor.catalog, this.#summaries.servers.get(definition.name)), this.#expanded.has(definition.name)),
|
|
252
|
+
executor,
|
|
253
|
+
availability,
|
|
254
|
+
scheme: new McpResources(definition.name, candidate, executor.catalog),
|
|
255
|
+
},
|
|
32
256
|
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
257
|
+
}
|
|
258
|
+
catch (cause) {
|
|
259
|
+
if (cause instanceof AuthorizationRequiredError) {
|
|
260
|
+
return {
|
|
261
|
+
kind: "authorization-required",
|
|
262
|
+
...effective,
|
|
263
|
+
connection: candidate,
|
|
264
|
+
authorizationUrl: cause.authorizationUrl,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
let closeCause;
|
|
268
|
+
try {
|
|
269
|
+
await candidate.close();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
closeCause = error;
|
|
273
|
+
}
|
|
274
|
+
throw preparationError(definition, cause, closeCause);
|
|
42
275
|
}
|
|
43
276
|
}
|
|
277
|
+
async #applyState(workspaceId, state, options) {
|
|
278
|
+
const seam = this.#seam;
|
|
279
|
+
if (seam === undefined)
|
|
280
|
+
throw new Error("MCP module is not set up.");
|
|
281
|
+
const current = this.#workspaces.get(workspaceId);
|
|
282
|
+
const effective = this.#enabled(state);
|
|
283
|
+
const force = options.force ?? new Set();
|
|
284
|
+
for (const [name, attachment] of current?.attachments ?? []) {
|
|
285
|
+
const next = effective.get(name);
|
|
286
|
+
if (next === undefined
|
|
287
|
+
|| force.has(name)
|
|
288
|
+
|| !sameDefinition(attachment.definition, next.definition))
|
|
289
|
+
attachmentConnection(attachment)?.assertReplaceable();
|
|
290
|
+
}
|
|
291
|
+
const next = new Map();
|
|
292
|
+
const fresh = [];
|
|
293
|
+
try {
|
|
294
|
+
for (const [name, definition] of effective) {
|
|
295
|
+
const existing = current?.attachments.get(name);
|
|
296
|
+
if (existing !== undefined
|
|
297
|
+
&& !force.has(name)
|
|
298
|
+
&& sameDefinition(existing.definition, definition.definition)) {
|
|
299
|
+
next.set(name, existing);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const supplied = options.prepared?.get(name);
|
|
303
|
+
let attachment;
|
|
304
|
+
try {
|
|
305
|
+
attachment = supplied ?? await this.#prepareAttachment(workspaceId, definition);
|
|
306
|
+
}
|
|
307
|
+
catch (cause) {
|
|
308
|
+
if (options.preparationFailureDisposition === "reject")
|
|
309
|
+
throw cause;
|
|
310
|
+
const failure = cause instanceof ModuleActionError
|
|
311
|
+
? cause
|
|
312
|
+
: preparationError(definition.definition, cause);
|
|
313
|
+
attachment = {
|
|
314
|
+
kind: "unavailable",
|
|
315
|
+
...definition,
|
|
316
|
+
problem: structuredClone(failure.problem),
|
|
317
|
+
};
|
|
318
|
+
console.error(`MCP server '${name}' unavailable during workspace hydration: ${failure.problem.detail}`, failure.cause ?? failure);
|
|
319
|
+
}
|
|
320
|
+
next.set(name, attachment);
|
|
321
|
+
if (attachment.kind !== "unavailable")
|
|
322
|
+
fresh.push(attachment);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (cause) {
|
|
326
|
+
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
|
|
327
|
+
const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
|
|
328
|
+
if (failures.length > 0) {
|
|
329
|
+
throw new AggregateError([cause, ...failures], "MCP workspace preparation and cleanup failed.");
|
|
330
|
+
}
|
|
331
|
+
throw cause;
|
|
332
|
+
}
|
|
333
|
+
const authorization = [...fresh].find((attachment) => attachment.kind === "authorization-required");
|
|
334
|
+
if (authorization !== undefined && options.authorizationDisposition === "defer-mutation") {
|
|
335
|
+
const discard = fresh.filter((attachment) => attachment !== authorization);
|
|
336
|
+
await closeConnections(discard.map(({ connection }) => connection));
|
|
337
|
+
return { authorization };
|
|
338
|
+
}
|
|
339
|
+
const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
|
|
340
|
+
try {
|
|
341
|
+
await seam.replaceWorkspaceCapabilities({
|
|
342
|
+
workspaceId,
|
|
343
|
+
namespaceOwner: OWNER,
|
|
344
|
+
state: persistedState(state),
|
|
345
|
+
runtimes,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch (cause) {
|
|
349
|
+
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection.close()));
|
|
350
|
+
const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
|
|
351
|
+
if (failures.length > 0) {
|
|
352
|
+
throw new AggregateError([cause, ...failures], "MCP workspace commit and cleanup failed.");
|
|
353
|
+
}
|
|
354
|
+
throw cause;
|
|
355
|
+
}
|
|
356
|
+
this.#workspaces.set(workspaceId, { state: cloneState(state), attachments: next });
|
|
357
|
+
const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
|
|
358
|
+
const obsolete = [...current?.attachments.values() ?? []]
|
|
359
|
+
.flatMap((attachment) => attachmentConnection(attachment) ?? [])
|
|
360
|
+
.filter((connection) => !retained.has(connection));
|
|
361
|
+
if (obsolete.length > 0) {
|
|
362
|
+
try {
|
|
363
|
+
await closeConnections(obsolete);
|
|
364
|
+
}
|
|
365
|
+
catch (cause) {
|
|
366
|
+
throw actionError("obsolete-connection-close-failed", 500, "The MCP capability change committed, but an obsolete connection did not close cleanly.", { workspaceId, committed: true }, cause);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (options.authorizationDisposition === "publish-required") {
|
|
370
|
+
for (const [name, attachment] of next) {
|
|
371
|
+
if (attachment.kind !== "authorization-required")
|
|
372
|
+
continue;
|
|
373
|
+
await this.#setPending(workspaceId, name, {
|
|
374
|
+
operation: "enable",
|
|
375
|
+
expectedState: structuredClone(state.servers[name] ?? null),
|
|
376
|
+
expectedDefinition: structuredClone(attachment.definition),
|
|
377
|
+
expectedEnabled: true,
|
|
378
|
+
definition: {
|
|
379
|
+
definition: structuredClone(attachment.definition),
|
|
380
|
+
source: attachment.source,
|
|
381
|
+
},
|
|
382
|
+
connection: attachment.connection,
|
|
383
|
+
authorizationUrl: attachment.authorizationUrl,
|
|
384
|
+
}, false);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return authorization === undefined ? {} : { authorization };
|
|
388
|
+
}
|
|
389
|
+
async #setPending(workspaceId, name, pending, closeExisting = true) {
|
|
390
|
+
const key = this.#pendingKey(workspaceId, name);
|
|
391
|
+
const existing = this.#pending.get(key);
|
|
392
|
+
this.#pending.set(key, pending);
|
|
393
|
+
if (closeExisting && existing !== undefined && existing.connection !== pending.connection) {
|
|
394
|
+
await existing.connection.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async #clearPending(workspaceId, name, retained) {
|
|
398
|
+
const key = this.#pendingKey(workspaceId, name);
|
|
399
|
+
const pending = this.#pending.get(key);
|
|
400
|
+
this.#pending.delete(key);
|
|
401
|
+
if (pending === undefined || pending.connection === retained)
|
|
402
|
+
return;
|
|
403
|
+
try {
|
|
404
|
+
await pending.connection.close();
|
|
405
|
+
}
|
|
406
|
+
catch (cause) {
|
|
407
|
+
throw actionError("pending-authorization-close-failed", 500, "The MCP capability change committed, but its superseded authorization request did not close cleanly.", { workspaceId, name, committed: true }, cause);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
#snapshot(workspaceId) {
|
|
411
|
+
const snapshot = this.#workspaces.get(workspaceId);
|
|
412
|
+
if (snapshot === undefined) {
|
|
413
|
+
throw actionError("workspace-not-hydrated", 409, "MCP capabilities have not been hydrated for this workspace.", { workspaceId, recovery: "Retry after workspace initialization completes." });
|
|
414
|
+
}
|
|
415
|
+
return snapshot;
|
|
416
|
+
}
|
|
417
|
+
#summary(alias, available, attachment) {
|
|
418
|
+
const definition = available.definition;
|
|
419
|
+
const base = {
|
|
420
|
+
alias,
|
|
421
|
+
source: available.source,
|
|
422
|
+
transport: definition.transport,
|
|
423
|
+
target: definition.transport === "http" ? definition.url : definition.command,
|
|
424
|
+
enabled: available.enabled,
|
|
425
|
+
state: available.enabled
|
|
426
|
+
? attachment?.kind === "active"
|
|
427
|
+
? "connected"
|
|
428
|
+
: attachment?.kind === "authorization-required"
|
|
429
|
+
? "authorization-required"
|
|
430
|
+
: "unavailable"
|
|
431
|
+
: "disabled",
|
|
432
|
+
enabledTools: definition.tools ?? null,
|
|
433
|
+
read: definition.read ?? [],
|
|
434
|
+
};
|
|
435
|
+
if (attachment?.kind === "authorization-required") {
|
|
436
|
+
return { ...base, authorization: { url: attachment.authorizationUrl } };
|
|
437
|
+
}
|
|
438
|
+
if (attachment?.kind === "unavailable") {
|
|
439
|
+
return { ...base, problem: structuredClone(attachment.problem) };
|
|
440
|
+
}
|
|
441
|
+
if (attachment?.kind !== "active")
|
|
442
|
+
return base;
|
|
443
|
+
const catalog = attachment.executor.catalog;
|
|
444
|
+
return {
|
|
445
|
+
...base,
|
|
446
|
+
protocolVersion: catalog.protocolVersion,
|
|
447
|
+
server: catalog.server ?? null,
|
|
448
|
+
capabilities: catalog.capabilities,
|
|
449
|
+
tools: catalog.tools.map(({ name: toolName }) => toolName).toSorted(),
|
|
450
|
+
resources: catalog.resources.length,
|
|
451
|
+
resourceTemplates: catalog.resourceTemplates.length,
|
|
452
|
+
prompts: catalog.prompts.length,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
#overlayDefinitions(state, value) {
|
|
456
|
+
if (value === undefined)
|
|
457
|
+
return new Map();
|
|
458
|
+
try {
|
|
459
|
+
const bases = new Map([...this.#available(state)]
|
|
460
|
+
.map(([alias, { definition }]) => [alias, definition]));
|
|
461
|
+
return overlayServerDefinitions(structuredClone(value), bases);
|
|
462
|
+
}
|
|
463
|
+
catch (cause) {
|
|
464
|
+
throw actionError("configuration-invalid", 400, "Client MCP configuration is invalid.", { retryable: false }, cause);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
#list(workspaceId, overlay) {
|
|
468
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
469
|
+
const available = this.#available(snapshot.state);
|
|
470
|
+
const configured = this.#overlayDefinitions(snapshot.state, overlay);
|
|
471
|
+
const summaries = new Map([...available].map(([alias, definition]) => [
|
|
472
|
+
alias,
|
|
473
|
+
this.#summary(alias, definition, snapshot.attachments.get(alias)),
|
|
474
|
+
]));
|
|
475
|
+
for (const [alias, definition] of configured) {
|
|
476
|
+
if (available.has(alias))
|
|
477
|
+
continue;
|
|
478
|
+
summaries.set(alias, this.#summary(alias, {
|
|
479
|
+
definition,
|
|
480
|
+
source: "client",
|
|
481
|
+
enabled: false,
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
servers: [...summaries]
|
|
486
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
487
|
+
.map(([, summary]) => summary),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
#definition(params) {
|
|
491
|
+
assertActionKeys(params, ["alias", "target", "options"]);
|
|
492
|
+
const alias = requiredString(params, "alias");
|
|
493
|
+
const target = requiredString(params, "target");
|
|
494
|
+
try {
|
|
495
|
+
const options = structuredClone(Validator.assertMcpServerOptions((params.options ?? {})));
|
|
496
|
+
const definition = /^https?:\/\//u.test(target)
|
|
497
|
+
? { name: alias, transport: "http", url: target, ...options }
|
|
498
|
+
: {
|
|
499
|
+
name: alias,
|
|
500
|
+
transport: "stdio",
|
|
501
|
+
command: target,
|
|
502
|
+
...options,
|
|
503
|
+
args: options.args ?? [],
|
|
504
|
+
};
|
|
505
|
+
return structuredClone(Validator.assertMcpServerDefinition(definition));
|
|
506
|
+
}
|
|
507
|
+
catch (cause) {
|
|
508
|
+
throw actionError("definition-invalid", 400, `MCP server '${alias}' has an invalid target or options.`, { alias, retryable: false }, cause);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async #add(workspaceId, params) {
|
|
512
|
+
return this.#serialize(workspaceId, async () => {
|
|
513
|
+
const definition = this.#definition(params);
|
|
514
|
+
const alias = definition.name;
|
|
515
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
516
|
+
if (this.#available(snapshot.state).has(alias)) {
|
|
517
|
+
throw actionError("server-exists", 409, `MCP server alias '${alias}' is already available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
518
|
+
}
|
|
519
|
+
const state = cloneState(snapshot.state);
|
|
520
|
+
state.servers[alias] = {
|
|
521
|
+
kind: "workspace",
|
|
522
|
+
definition,
|
|
523
|
+
enabled: true,
|
|
524
|
+
};
|
|
525
|
+
const result = await this.#applyState(workspaceId, state, {
|
|
526
|
+
authorizationDisposition: "defer-mutation",
|
|
527
|
+
preparationFailureDisposition: "reject",
|
|
528
|
+
});
|
|
529
|
+
if (result.authorization !== undefined) {
|
|
530
|
+
await this.#setPending(workspaceId, alias, {
|
|
531
|
+
operation: "add",
|
|
532
|
+
expectedState: structuredClone(snapshot.state.servers[alias] ?? null),
|
|
533
|
+
expectedDefinition: null,
|
|
534
|
+
expectedEnabled: false,
|
|
535
|
+
definition: { definition, source: "workspace" },
|
|
536
|
+
connection: result.authorization.connection,
|
|
537
|
+
authorizationUrl: result.authorization.authorizationUrl,
|
|
538
|
+
});
|
|
539
|
+
return {
|
|
540
|
+
status: 202,
|
|
541
|
+
authorization: { url: result.authorization.authorizationUrl },
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const committed = this.#snapshot(workspaceId);
|
|
545
|
+
const attached = committed.attachments.get(alias);
|
|
546
|
+
if (attached === undefined || attached.kind === "unavailable") {
|
|
547
|
+
throw new Error("Committed MCP attachment is absent.");
|
|
548
|
+
}
|
|
549
|
+
await this.#clearPending(workspaceId, alias, attached.connection);
|
|
550
|
+
const available = this.#available(committed.state).get(alias);
|
|
551
|
+
if (available === undefined)
|
|
552
|
+
throw new Error("Committed MCP definition is absent.");
|
|
553
|
+
return { status: 201, server: this.#summary(alias, available, attached) };
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
#stateWithEnabled(state, alias, available, enabled) {
|
|
557
|
+
const next = cloneState(state);
|
|
558
|
+
if (available.source === "service") {
|
|
559
|
+
next.servers[alias] = { kind: "service", enabled };
|
|
560
|
+
return next;
|
|
561
|
+
}
|
|
562
|
+
const current = next.servers[alias];
|
|
563
|
+
if (current?.kind !== "workspace") {
|
|
564
|
+
throw new Error(`Workspace MCP server '${alias}' has no owned definition.`);
|
|
565
|
+
}
|
|
566
|
+
next.servers[alias] = { ...current, enabled };
|
|
567
|
+
return next;
|
|
568
|
+
}
|
|
569
|
+
#stateWithDefinition(state, alias, definition, enabled) {
|
|
570
|
+
const next = cloneState(state);
|
|
571
|
+
next.servers[alias] = this.#definitionSource(alias, definition) === "service"
|
|
572
|
+
? { kind: "service", enabled }
|
|
573
|
+
: {
|
|
574
|
+
kind: "workspace",
|
|
575
|
+
definition: structuredClone(definition),
|
|
576
|
+
enabled,
|
|
577
|
+
};
|
|
578
|
+
return next;
|
|
579
|
+
}
|
|
580
|
+
#definitionSource(alias, definition) {
|
|
581
|
+
const service = this.#defaults.get(alias);
|
|
582
|
+
return service !== undefined && sameDefinition(service, definition)
|
|
583
|
+
? "service"
|
|
584
|
+
: "workspace";
|
|
585
|
+
}
|
|
586
|
+
#definitionWithOptions(definition, value) {
|
|
587
|
+
if (value === undefined)
|
|
588
|
+
return structuredClone(definition);
|
|
589
|
+
try {
|
|
590
|
+
const options = structuredClone(Validator.assertMcpServerOptions(value));
|
|
591
|
+
return structuredClone(Validator.assertMcpServerDefinition({
|
|
592
|
+
...definition,
|
|
593
|
+
...options,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
catch (cause) {
|
|
597
|
+
throw actionError("definition-invalid", 400, `MCP server '${definition.name}' has invalid enable options.`, { alias: definition.name, retryable: false }, cause);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async #setEnabled(workspaceId, params, enabled) {
|
|
601
|
+
return this.#serialize(workspaceId, async () => {
|
|
602
|
+
assertActionKeys(params, enabled ? ["alias", "overlay", "options"] : ["alias"]);
|
|
603
|
+
const alias = requiredString(params, "alias");
|
|
604
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
605
|
+
const current = this.#available(snapshot.state).get(alias);
|
|
606
|
+
const configured = enabled
|
|
607
|
+
? this.#overlayDefinitions(snapshot.state, params.overlay).get(alias)
|
|
608
|
+
: undefined;
|
|
609
|
+
if (current === undefined && configured === undefined) {
|
|
610
|
+
throw actionError("server-not-found", 404, `MCP server alias '${alias}' is not available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
611
|
+
}
|
|
612
|
+
if (current === undefined && !enabled)
|
|
613
|
+
throw new Error("Disabled MCP target is absent.");
|
|
614
|
+
const candidate = configured ?? current?.definition;
|
|
615
|
+
if (candidate === undefined)
|
|
616
|
+
throw new Error("Enabled MCP target is absent.");
|
|
617
|
+
const definition = enabled
|
|
618
|
+
? this.#definitionWithOptions(candidate, params.options)
|
|
619
|
+
: structuredClone(candidate);
|
|
620
|
+
const attachment = snapshot.attachments.get(alias);
|
|
621
|
+
const retryUnavailable = enabled
|
|
622
|
+
&& current?.enabled === true
|
|
623
|
+
&& sameDefinition(current.definition, definition)
|
|
624
|
+
&& attachment?.kind === "unavailable";
|
|
625
|
+
const definitionChanged = current === undefined
|
|
626
|
+
|| !sameDefinition(current.definition, definition);
|
|
627
|
+
if (current?.enabled === enabled && !definitionChanged && !retryUnavailable) {
|
|
628
|
+
if (attachment?.kind === "authorization-required") {
|
|
629
|
+
return {
|
|
630
|
+
status: 202,
|
|
631
|
+
authorization: { url: attachment.authorizationUrl },
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return { status: 200, server: this.#summary(alias, current, attachment) };
|
|
635
|
+
}
|
|
636
|
+
const state = retryUnavailable
|
|
637
|
+
? cloneState(snapshot.state)
|
|
638
|
+
: enabled
|
|
639
|
+
? this.#stateWithDefinition(snapshot.state, alias, definition, true)
|
|
640
|
+
: this.#stateWithEnabled(snapshot.state, alias, current, false);
|
|
641
|
+
const result = await this.#applyState(workspaceId, state, {
|
|
642
|
+
...(retryUnavailable ? { force: new Set([alias]) } : {}),
|
|
643
|
+
authorizationDisposition: "defer-mutation",
|
|
644
|
+
preparationFailureDisposition: "reject",
|
|
645
|
+
});
|
|
646
|
+
if (result.authorization !== undefined) {
|
|
647
|
+
await this.#setPending(workspaceId, alias, {
|
|
648
|
+
operation: "enable",
|
|
649
|
+
expectedState: structuredClone(snapshot.state.servers[alias] ?? null),
|
|
650
|
+
expectedDefinition: structuredClone(current?.definition ?? null),
|
|
651
|
+
expectedEnabled: current?.enabled ?? false,
|
|
652
|
+
definition: {
|
|
653
|
+
definition: structuredClone(definition),
|
|
654
|
+
source: this.#definitionSource(alias, definition),
|
|
655
|
+
},
|
|
656
|
+
connection: result.authorization.connection,
|
|
657
|
+
authorizationUrl: result.authorization.authorizationUrl,
|
|
658
|
+
});
|
|
659
|
+
return {
|
|
660
|
+
status: 202,
|
|
661
|
+
authorization: { url: result.authorization.authorizationUrl },
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
await this.#clearPending(workspaceId, alias);
|
|
665
|
+
const committed = this.#snapshot(workspaceId);
|
|
666
|
+
const committedDefinition = this.#available(committed.state).get(alias);
|
|
667
|
+
if (committedDefinition === undefined)
|
|
668
|
+
throw new Error("Committed MCP definition is absent.");
|
|
669
|
+
return {
|
|
670
|
+
status: 200,
|
|
671
|
+
server: this.#summary(alias, committedDefinition, committed.attachments.get(alias)),
|
|
672
|
+
};
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async #remove(workspaceId, params) {
|
|
676
|
+
return this.#serialize(workspaceId, async () => {
|
|
677
|
+
assertActionKeys(params, ["alias"]);
|
|
678
|
+
const alias = requiredString(params, "alias");
|
|
679
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
680
|
+
const available = this.#available(snapshot.state).get(alias);
|
|
681
|
+
if (available === undefined) {
|
|
682
|
+
throw actionError("server-not-found", 404, `MCP server alias '${alias}' is not available in this workspace.`, { workspaceId, alias, retryable: false });
|
|
683
|
+
}
|
|
684
|
+
if (available.source === "service") {
|
|
685
|
+
throw actionError("server-service-owned", 409, `MCP server alias '${alias}' is service-owned and can be disabled, not removed.`, { workspaceId, alias, recovery: `Use workspace.mcp.disable for '${alias}'.`, retryable: false });
|
|
686
|
+
}
|
|
687
|
+
const state = cloneState(snapshot.state);
|
|
688
|
+
const service = this.#defaults.get(alias);
|
|
689
|
+
if (service === undefined) {
|
|
690
|
+
delete state.servers[alias];
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
state.servers[alias] = {
|
|
694
|
+
kind: "service",
|
|
695
|
+
enabled: false,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
await this.#applyState(workspaceId, state, {
|
|
699
|
+
authorizationDisposition: "defer-mutation",
|
|
700
|
+
preparationFailureDisposition: "reject",
|
|
701
|
+
});
|
|
702
|
+
await this.#clearPending(workspaceId, alias);
|
|
703
|
+
return { status: 200, alias, removed: true };
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
#oauthCompletionState(workspaceId, alias, pending) {
|
|
707
|
+
const snapshot = this.#snapshot(workspaceId);
|
|
708
|
+
const available = this.#available(snapshot.state).get(alias);
|
|
709
|
+
const current = available?.definition ?? null;
|
|
710
|
+
const expected = pending.expectedDefinition;
|
|
711
|
+
if (!sameServerState(snapshot.state.servers[alias] ?? null, pending.expectedState)
|
|
712
|
+
|| (expected === null && current !== null)
|
|
713
|
+
|| (expected !== null && (current === null || !sameDefinition(expected, current)))
|
|
714
|
+
|| (available?.enabled ?? false) !== pending.expectedEnabled) {
|
|
715
|
+
throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, {
|
|
716
|
+
workspaceId,
|
|
717
|
+
alias,
|
|
718
|
+
recovery: "Start authorization again from the server's current definition.",
|
|
719
|
+
retryable: false,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (pending.operation === "add") {
|
|
723
|
+
const state = cloneState(snapshot.state);
|
|
724
|
+
state.servers[alias] = {
|
|
725
|
+
kind: "workspace",
|
|
726
|
+
definition: structuredClone(pending.definition.definition),
|
|
727
|
+
enabled: true,
|
|
728
|
+
};
|
|
729
|
+
return state;
|
|
730
|
+
}
|
|
731
|
+
return this.#stateWithDefinition(snapshot.state, alias, pending.definition.definition, true);
|
|
732
|
+
}
|
|
733
|
+
async #completeOAuth(workspaceId, params) {
|
|
734
|
+
return this.#serialize(workspaceId, async () => {
|
|
735
|
+
assertActionKeys(params, ["alias", "callbackUrl"]);
|
|
736
|
+
const alias = requiredString(params, "alias");
|
|
737
|
+
const callbackUrl = requiredString(params, "callbackUrl");
|
|
738
|
+
const key = this.#pendingKey(workspaceId, alias);
|
|
739
|
+
const pending = this.#pending.get(key);
|
|
740
|
+
if (pending === undefined) {
|
|
741
|
+
// {§oauth-lifetime} — restart during pending authorization
|
|
742
|
+
// surfaces as a factual not-pending state, never a secret replay.
|
|
743
|
+
throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workspaceId, alias, retryable: false });
|
|
744
|
+
}
|
|
745
|
+
if (pending.prepared === undefined) {
|
|
746
|
+
try {
|
|
747
|
+
await pending.connection.finishAuthorization(callbackUrl);
|
|
748
|
+
const prepared = await this.#prepareAttachment(workspaceId, pending.definition, pending.connection);
|
|
749
|
+
if (prepared.kind !== "active") {
|
|
750
|
+
throw new Error("OAuth completion returned another authorization challenge.");
|
|
751
|
+
}
|
|
752
|
+
pending.prepared = prepared;
|
|
753
|
+
}
|
|
754
|
+
catch (cause) {
|
|
755
|
+
throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workspaceId, alias, retryable: false }, cause);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const state = this.#oauthCompletionState(workspaceId, alias, pending);
|
|
759
|
+
await this.#applyState(workspaceId, state, {
|
|
760
|
+
force: new Set([alias]),
|
|
761
|
+
prepared: new Map([[alias, pending.prepared]]),
|
|
762
|
+
authorizationDisposition: "defer-mutation",
|
|
763
|
+
preparationFailureDisposition: "reject",
|
|
764
|
+
});
|
|
765
|
+
this.#pending.delete(key);
|
|
766
|
+
const committed = this.#snapshot(workspaceId);
|
|
767
|
+
const attached = committed.attachments.get(alias);
|
|
768
|
+
if (attached === undefined || attached.kind !== "active") {
|
|
769
|
+
throw new Error("Authorized MCP attachment is absent.");
|
|
770
|
+
}
|
|
771
|
+
const available = this.#available(committed.state).get(alias);
|
|
772
|
+
if (available === undefined)
|
|
773
|
+
throw new Error("Authorized MCP definition is absent.");
|
|
774
|
+
return { status: 200, server: this.#summary(alias, available, attached) };
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async #complete(workspaceId, params) {
|
|
778
|
+
assertActionKeys(params, ["server", "ref", "argument", "context"]);
|
|
779
|
+
const server = requiredString(params, "server");
|
|
780
|
+
const attachment = this.#snapshot(workspaceId).attachments.get(server);
|
|
781
|
+
if (attachment === undefined || attachment.kind !== "active") {
|
|
782
|
+
throw actionError("server-not-connected", 409, `MCP server '${server}' is not connected in this workspace.`, { workspaceId, name: server, retryable: false });
|
|
783
|
+
}
|
|
784
|
+
const ref = objectOf(params.ref);
|
|
785
|
+
const argument = objectOf(params.argument);
|
|
786
|
+
if (ref === null || argument === null) {
|
|
787
|
+
throw actionError("completion-parameters-invalid", 400, "MCP completion requires 'ref' and 'argument' objects.", { retryable: false });
|
|
788
|
+
}
|
|
789
|
+
return attachment.connection.complete({
|
|
790
|
+
ref: ref,
|
|
791
|
+
argument: argument,
|
|
792
|
+
...(objectOf(params.context) === null ? {} : { context: params.context }),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
async #refreshCatalog(workspaceId, name) {
|
|
796
|
+
const seam = this.#seam;
|
|
797
|
+
if (seam === undefined)
|
|
798
|
+
throw new Error("MCP module is not set up.");
|
|
799
|
+
const snapshot = this.#workspaces.get(workspaceId);
|
|
800
|
+
const attachment = snapshot?.attachments.get(name);
|
|
801
|
+
if (snapshot === undefined || attachment === undefined || attachment.kind !== "active")
|
|
802
|
+
return;
|
|
803
|
+
const executor = new McpExecutor({ runtime: name, glyph: "🔌" }, attachment.connection, {
|
|
804
|
+
tools: attachment.definition.tools ?? null,
|
|
805
|
+
read: attachment.definition.read ?? [],
|
|
806
|
+
}, this.#summaries.tools);
|
|
807
|
+
const availability = await executor.requireAvailable();
|
|
808
|
+
const refreshed = {
|
|
809
|
+
...attachment,
|
|
810
|
+
executor,
|
|
811
|
+
runtime: {
|
|
812
|
+
namespaceOwner: OWNER,
|
|
813
|
+
decl: runtimeDecl(name, serverSummary(name, executor.catalog, this.#summaries.servers.get(name)), this.#expanded.has(name)),
|
|
814
|
+
executor,
|
|
815
|
+
availability,
|
|
816
|
+
scheme: new McpResources(name, attachment.connection, executor.catalog),
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
const attachments = new Map(snapshot.attachments);
|
|
820
|
+
attachments.set(name, refreshed);
|
|
821
|
+
await seam.replaceWorkspaceCapabilities({
|
|
822
|
+
workspaceId,
|
|
823
|
+
namespaceOwner: OWNER,
|
|
824
|
+
state: persistedState(snapshot.state),
|
|
825
|
+
runtimes: [...attachments.values()].flatMap((candidate) => candidate.kind === "active" ? [candidate.runtime] : []),
|
|
826
|
+
});
|
|
827
|
+
this.#workspaces.set(workspaceId, {
|
|
828
|
+
state: cloneState(snapshot.state),
|
|
829
|
+
attachments,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
#scheduleCatalogRefresh(workspaceId, name, attempt = 0) {
|
|
833
|
+
if (this.#closed)
|
|
834
|
+
return;
|
|
835
|
+
const key = this.#pendingKey(workspaceId, name);
|
|
836
|
+
if (this.#refreshTimers.has(key))
|
|
837
|
+
return;
|
|
838
|
+
const delay = Math.min(250 * (2 ** attempt), 5000);
|
|
839
|
+
const timer = setTimeout(() => {
|
|
840
|
+
this.#refreshTimers.delete(key);
|
|
841
|
+
void this.#serialize(workspaceId, async () => {
|
|
842
|
+
await this.#refreshCatalog(workspaceId, name);
|
|
843
|
+
}).catch((error) => {
|
|
844
|
+
if (statusOf(error) === 409) {
|
|
845
|
+
this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
console.error(`MCP server '${name}' capability refresh failed:`, error);
|
|
849
|
+
});
|
|
850
|
+
}, delay);
|
|
851
|
+
timer.unref();
|
|
852
|
+
this.#refreshTimers.set(key, timer);
|
|
853
|
+
}
|
|
44
854
|
async close() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
855
|
+
if (this.#closed)
|
|
856
|
+
return;
|
|
857
|
+
this.#closed = true;
|
|
858
|
+
for (const timer of this.#refreshTimers.values())
|
|
859
|
+
clearTimeout(timer);
|
|
860
|
+
this.#refreshTimers.clear();
|
|
861
|
+
const connections = new Set();
|
|
862
|
+
for (const snapshot of this.#workspaces.values()) {
|
|
863
|
+
for (const attachment of snapshot.attachments.values()) {
|
|
864
|
+
const connection = attachmentConnection(attachment);
|
|
865
|
+
if (connection !== undefined)
|
|
866
|
+
connections.add(connection);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
for (const pending of this.#pending.values())
|
|
870
|
+
connections.add(pending.connection);
|
|
871
|
+
this.#workspaces.clear();
|
|
872
|
+
this.#pending.clear();
|
|
873
|
+
await closeConnections([...connections]);
|
|
53
874
|
}
|
|
54
875
|
}
|
|
55
876
|
//# sourceMappingURL=Module.js.map
|