@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.
- package/.env.defaults +6 -4
- package/README.md +110 -45
- package/SPEC.md +274 -30
- package/dist/McpExecutor.d.ts +25 -4
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +105 -31
- 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 +649 -35
- package/dist/Module.js.map +1 -1
- package/dist/ToolPresentation.d.ts +5 -0
- package/dist/ToolPresentation.d.ts.map +1 -0
- package/dist/ToolPresentation.js +134 -0
- package/dist/ToolPresentation.js.map +1 -0
- package/dist/client.d.ts +25 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +478 -85
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +7 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +114 -45
- 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/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 +7 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +7 -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 +10 -6
package/dist/Module.js
CHANGED
|
@@ -1,55 +1,669 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
227
|
+
kind: "active",
|
|
228
|
+
...effective,
|
|
229
|
+
connection: candidate,
|
|
29
230
|
executor,
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|