@plurnk/plurnk-mcp 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -22
- package/SPEC.md +103 -69
- package/dist/McpExecutor.d.ts +25 -2
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +9 -3
- package/dist/McpExecutor.js.map +1 -1
- package/dist/Module.d.ts +68 -11
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +362 -658
- package/dist/Module.js.map +1 -1
- package/dist/capabilityMatrix.js +2 -2
- package/dist/capabilityMatrix.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -1
- package/dist/client.js.map +1 -1
- package/dist/extensionChannel.d.ts.map +1 -1
- package/dist/extensionChannel.js +18 -4
- package/dist/extensionChannel.js.map +1 -1
- package/package.json +4 -4
package/dist/Module.js
CHANGED
|
@@ -4,7 +4,17 @@ import { expandedServerNames, overlayServerDefinitions, serviceDefinitions, serv
|
|
|
4
4
|
import McpExecutor, { runtimeDecl, serverSummary } from "./McpExecutor.js";
|
|
5
5
|
import McpResources from "./McpResources.js";
|
|
6
6
|
const OWNER = "@plurnk/plurnk-mcp";
|
|
7
|
-
const
|
|
7
|
+
const FAMILY = "mcp";
|
|
8
|
+
const NONEMPTY_STRING = { type: "string", minLength: 1 };
|
|
9
|
+
const OPEN_OBJECT = { type: "object", additionalProperties: true };
|
|
10
|
+
const MCP_DEFINITION = { $ref: "https://schemas.plurnk.xyz/v0/McpServerDefinition.json" };
|
|
11
|
+
const MUTATION_RESULT = { $ref: "https://schemas.plurnk.xyz/v0/FunctionalityMutationResult.json" };
|
|
12
|
+
const actionInput = (properties, required = []) => ({
|
|
13
|
+
type: "object",
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
properties,
|
|
16
|
+
...(required.length === 0 ? {} : { required: [...required] }),
|
|
17
|
+
});
|
|
8
18
|
const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
|
|
9
19
|
class ModuleActionError extends Error {
|
|
10
20
|
problem;
|
|
@@ -55,93 +65,60 @@ export const closeConnections = async (connections) => {
|
|
|
55
65
|
const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
56
66
|
? value
|
|
57
67
|
: null;
|
|
58
|
-
const
|
|
59
|
-
const extras = Object.keys(
|
|
60
|
-
if (extras.length > 0)
|
|
61
|
-
throw
|
|
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 };
|
|
68
|
+
const assertActionKeys = (params, allowed) => {
|
|
69
|
+
const extras = Object.keys(params).filter((key) => !allowed.includes(key));
|
|
70
|
+
if (extras.length > 0) {
|
|
71
|
+
throw actionError("parameters-invalid", 400, `Unsupported parameter(s): ${extras.join(", ")}.`, { retryable: false });
|
|
97
72
|
}
|
|
98
|
-
return { version: STATE_VERSION, servers: parsed };
|
|
99
73
|
};
|
|
100
|
-
const
|
|
101
|
-
const
|
|
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];
|
|
74
|
+
const requiredString = (params, key) => {
|
|
75
|
+
const value = params[key];
|
|
106
76
|
if (typeof value !== "string" || value.length === 0) {
|
|
107
|
-
throw actionError("
|
|
77
|
+
throw actionError("parameters-invalid", 400, `'${key}' must be a non-empty string.`, { field: key, retryable: false });
|
|
108
78
|
}
|
|
109
79
|
return value;
|
|
110
80
|
};
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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;
|
|
81
|
+
const workerIdentityOf = (context) => {
|
|
82
|
+
if (context.scope !== "worker")
|
|
83
|
+
throw new Error("MCP actions require a worker-scoped context.");
|
|
84
|
+
return { workspaceId: context.workspaceId, workerId: context.workerId };
|
|
122
85
|
};
|
|
86
|
+
const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
123
87
|
const statusOf = (error) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const result = objectOf(candidate?.result);
|
|
129
|
-
const problem = objectOf(result?.problem);
|
|
88
|
+
if (typeof error !== "object" || error === null)
|
|
89
|
+
return null;
|
|
90
|
+
const problem = error.problem
|
|
91
|
+
?? error.result?.problem;
|
|
130
92
|
return typeof problem?.status === "number" ? problem.status : null;
|
|
131
93
|
};
|
|
94
|
+
const catalogDetail = (executor) => {
|
|
95
|
+
const catalog = executor.catalog;
|
|
96
|
+
return {
|
|
97
|
+
protocolVersion: catalog.protocolVersion,
|
|
98
|
+
server: catalog.server ?? null,
|
|
99
|
+
capabilities: catalog.capabilities,
|
|
100
|
+
tools: catalog.tools.map(({ name }) => name).toSorted(),
|
|
101
|
+
resources: catalog.resources.length,
|
|
102
|
+
resourceTemplates: catalog.resourceTemplates.length,
|
|
103
|
+
prompts: catalog.prompts.length,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
132
106
|
export default class Module {
|
|
133
107
|
#env;
|
|
134
108
|
#summaries;
|
|
135
109
|
#expanded;
|
|
136
110
|
#defaults;
|
|
137
111
|
#defaultEnabled;
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
112
|
+
// The committed attachments per Worker: the adapter's mirror of the snapshot
|
|
113
|
+
// the coordinator holds, for continuations and refresh.
|
|
114
|
+
#attachments = new Map();
|
|
115
|
+
#identities = new Map();
|
|
141
116
|
#pending = new Map();
|
|
142
|
-
#
|
|
117
|
+
#dirty = new Set();
|
|
118
|
+
#connections = new Set();
|
|
143
119
|
#refreshTimers = new Map();
|
|
144
|
-
#
|
|
120
|
+
#retainWorker = new Map();
|
|
121
|
+
#handle;
|
|
145
122
|
#closed = false;
|
|
146
123
|
static init(options = {}) {
|
|
147
124
|
return new Module(options.env ?? process.env);
|
|
@@ -154,96 +131,182 @@ export default class Module {
|
|
|
154
131
|
this.#expanded = new Set(expandedServerNames(environ));
|
|
155
132
|
}
|
|
156
133
|
async setup(seam) {
|
|
157
|
-
this.#
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
134
|
+
this.#handle = seam.registerFunctionalityAdapter({
|
|
135
|
+
family: FAMILY,
|
|
136
|
+
namespaceOwner: OWNER,
|
|
137
|
+
summary: "Manage this Worker's MCP server attachments: list, discover, add, enable, disable, remove.",
|
|
138
|
+
definitionSchema: MCP_DEFINITION,
|
|
139
|
+
available: async () => [...this.#defaults].map(([name, definition]) => ({
|
|
140
|
+
alias: name,
|
|
141
|
+
definition: structuredClone(definition),
|
|
142
|
+
enabled: this.#defaultEnabled.has(name),
|
|
143
|
+
})),
|
|
144
|
+
discover: (query) => this.#discover(query),
|
|
145
|
+
admit: async (input) => this.#admit(input),
|
|
146
|
+
prepare: (preparation) => this.#prepare(preparation),
|
|
147
|
+
teardown: (snapshot, identity) => this.#teardown(snapshot, identity),
|
|
148
|
+
});
|
|
149
|
+
// Protocol continuations beneath the common grammar.
|
|
150
|
+
seam.registerModuleAction({
|
|
151
|
+
name: "worker.mcp.oauth.complete",
|
|
152
|
+
scope: "worker",
|
|
153
|
+
inputSchema: actionInput({ alias: NONEMPTY_STRING, callbackUrl: NONEMPTY_STRING }, ["alias", "callbackUrl"]),
|
|
154
|
+
outputSchema: MUTATION_RESULT,
|
|
155
|
+
handler: (params, context) => this.#completeOAuth(workerIdentityOf(context), params),
|
|
166
156
|
});
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
157
|
+
seam.registerModuleAction({
|
|
158
|
+
name: "worker.mcp.complete",
|
|
159
|
+
scope: "worker",
|
|
160
|
+
inputSchema: actionInput({
|
|
161
|
+
server: NONEMPTY_STRING,
|
|
162
|
+
ref: OPEN_OBJECT,
|
|
163
|
+
argument: OPEN_OBJECT,
|
|
164
|
+
context: OPEN_OBJECT,
|
|
165
|
+
}, ["server", "ref", "argument"]),
|
|
166
|
+
outputSchema: OPEN_OBJECT,
|
|
167
|
+
handler: (params, context) => this.#complete(workerIdentityOf(context).workerId, params),
|
|
171
168
|
});
|
|
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
169
|
}
|
|
179
|
-
#
|
|
180
|
-
|
|
170
|
+
#assertOpen() {
|
|
171
|
+
if (this.#closed)
|
|
172
|
+
throw new Error("MCP module is closed.");
|
|
181
173
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (this.#locks.get(workspaceId) === queued)
|
|
195
|
-
this.#locks.delete(workspaceId);
|
|
174
|
+
#handleOrThrow() {
|
|
175
|
+
if (this.#handle === undefined)
|
|
176
|
+
throw new Error("MCP module is not set up.");
|
|
177
|
+
return this.#handle;
|
|
178
|
+
}
|
|
179
|
+
#pendingKey(workerId, name) {
|
|
180
|
+
return `${workerId}:${name}`;
|
|
181
|
+
}
|
|
182
|
+
#retain(workerId) {
|
|
183
|
+
const retain = this.#retainWorker.get(workerId);
|
|
184
|
+
if (retain === undefined) {
|
|
185
|
+
throw new Error(`MCP worker ${workerId} has no Functionality residency context.`);
|
|
196
186
|
}
|
|
187
|
+
return retain();
|
|
197
188
|
}
|
|
198
|
-
#
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 });
|
|
189
|
+
async #closeOwned(connections) {
|
|
190
|
+
const owned = [...new Set(connections)];
|
|
191
|
+
const results = await Promise.allSettled(owned.map((connection) => connection.close()));
|
|
192
|
+
const errors = [];
|
|
193
|
+
for (const [index, result] of results.entries()) {
|
|
194
|
+
if (result.status === "fulfilled") {
|
|
195
|
+
this.#connections.delete(owned[index]);
|
|
212
196
|
continue;
|
|
213
197
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
198
|
+
errors.push(...errorsOf(result.reason));
|
|
199
|
+
}
|
|
200
|
+
if (errors.length > 0)
|
|
201
|
+
throw new AggregateError(errors, "MCP connection shutdown failed");
|
|
202
|
+
}
|
|
203
|
+
// {§mcp-discovery} — inert: a direct target is probed and disconnected;
|
|
204
|
+
// caller configuration is parsed into candidates; registry search waits for
|
|
205
|
+
// a configured downstream registry.
|
|
206
|
+
async #discover(query) {
|
|
207
|
+
this.#assertOpen();
|
|
208
|
+
const candidates = [];
|
|
209
|
+
if (query.configuration !== undefined) {
|
|
210
|
+
let definitions;
|
|
211
|
+
try {
|
|
212
|
+
definitions = overlayServerDefinitions(structuredClone(query.configuration), this.#defaults);
|
|
213
|
+
}
|
|
214
|
+
catch (cause) {
|
|
215
|
+
throw actionError("configuration-invalid", 400, "The supplied MCP configuration is invalid.", { retryable: false }, cause);
|
|
216
|
+
}
|
|
217
|
+
for (const [name, definition] of definitions) {
|
|
218
|
+
candidates.push({
|
|
219
|
+
alias: name,
|
|
220
|
+
definition,
|
|
221
|
+
provenance: { kind: "client-configuration", source: `PLURNK_MCP_${name.toUpperCase().replaceAll("-", "_")}` },
|
|
222
|
+
summary: `${definition.transport} ${definition.transport === "http" ? definition.url : definition.command}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (query.source !== undefined) {
|
|
227
|
+
// A URL is a Streamable HTTP target; anything else is a command line
|
|
228
|
+
// split on whitespace (exact paths with spaces are added, not probed).
|
|
229
|
+
const source = query.source;
|
|
230
|
+
const [command = "", ...args] = source.trim().split(/\s+/u);
|
|
231
|
+
const definition = /^https?:\/\//u.test(source)
|
|
232
|
+
? { name: "discovered", transport: "http", url: source }
|
|
233
|
+
: { name: "discovered", transport: "stdio", command, args };
|
|
234
|
+
Validator.assertMcpServerDefinition(definition);
|
|
235
|
+
const connection = new ServerConnection(definition, this.#env, {
|
|
236
|
+
onCatalogChanged: () => undefined,
|
|
237
|
+
onInfrastructureError: () => undefined,
|
|
218
238
|
});
|
|
239
|
+
this.#connections.add(connection);
|
|
240
|
+
const executor = new McpExecutor({ runtime: "discovered", glyph: "🔌" }, connection, () => () => undefined);
|
|
241
|
+
try {
|
|
242
|
+
await executor.requireAvailable();
|
|
243
|
+
const catalog = executor.catalog;
|
|
244
|
+
const name = catalog.server?.name?.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "discovered";
|
|
245
|
+
candidates.push({
|
|
246
|
+
alias: name,
|
|
247
|
+
definition: { ...definition, name },
|
|
248
|
+
provenance: { kind: "direct-target", source },
|
|
249
|
+
summary: serverSummary(name, catalog, undefined),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (cause) {
|
|
253
|
+
if (cause instanceof AuthorizationRequiredError) {
|
|
254
|
+
candidates.push({
|
|
255
|
+
alias: "discovered",
|
|
256
|
+
definition,
|
|
257
|
+
provenance: { kind: "direct-target", source },
|
|
258
|
+
summary: "The server requires authorization before it can be inspected; add it and complete the authorization.",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
throw actionError("discover-failed", 502, `MCP target '${source}' could not be inspected.`, { source, retryable: true }, cause);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await this.#closeOwned([connection]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (query.query !== undefined && query.source === undefined && query.configuration === undefined) {
|
|
270
|
+
throw actionError("registry-not-configured", 501, "MCP registry search requires a configured downstream registry; none is configured.", { query: query.query, recovery: "Discover an explicit source (URL or command) or configure a registry.", retryable: false });
|
|
219
271
|
}
|
|
220
|
-
return
|
|
272
|
+
return candidates;
|
|
221
273
|
}
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
#admit(input) {
|
|
275
|
+
const params = objectOf(input) ?? {};
|
|
276
|
+
let definition;
|
|
277
|
+
try {
|
|
278
|
+
definition = structuredClone(Validator.assertMcpServerDefinition(structuredClone(params.definition)));
|
|
279
|
+
}
|
|
280
|
+
catch (cause) {
|
|
281
|
+
throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { retryable: false }, cause);
|
|
282
|
+
}
|
|
283
|
+
const alias = typeof params.alias === "string" ? params.alias : definition.name;
|
|
284
|
+
if (alias !== definition.name) {
|
|
285
|
+
throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
|
|
286
|
+
}
|
|
287
|
+
return { alias, definition };
|
|
226
288
|
}
|
|
227
|
-
async #prepareAttachment(
|
|
228
|
-
|
|
289
|
+
async #prepareAttachment(workerId, definition, connection) {
|
|
290
|
+
this.#assertOpen();
|
|
229
291
|
const candidate = connection ?? new ServerConnection(definition, this.#env, {
|
|
230
292
|
onCatalogChanged: (error) => {
|
|
231
293
|
if (error !== null) {
|
|
232
294
|
console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
|
|
233
295
|
return;
|
|
234
296
|
}
|
|
235
|
-
this.#scheduleCatalogRefresh(
|
|
297
|
+
this.#scheduleCatalogRefresh(workerId, definition.name);
|
|
236
298
|
},
|
|
237
299
|
onInfrastructureError: (error) => {
|
|
238
300
|
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
239
301
|
},
|
|
240
302
|
});
|
|
241
|
-
|
|
303
|
+
this.#connections.add(candidate);
|
|
304
|
+
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workerId), { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
|
|
242
305
|
try {
|
|
243
306
|
const availability = await executor.requireAvailable();
|
|
244
307
|
return {
|
|
245
308
|
kind: "active",
|
|
246
|
-
|
|
309
|
+
definition,
|
|
247
310
|
connection: candidate,
|
|
248
311
|
executor,
|
|
249
312
|
runtime: {
|
|
@@ -259,14 +322,14 @@ export default class Module {
|
|
|
259
322
|
if (cause instanceof AuthorizationRequiredError) {
|
|
260
323
|
return {
|
|
261
324
|
kind: "authorization-required",
|
|
262
|
-
|
|
325
|
+
definition,
|
|
263
326
|
connection: candidate,
|
|
264
327
|
authorizationUrl: cause.authorizationUrl,
|
|
265
328
|
};
|
|
266
329
|
}
|
|
267
330
|
let closeCause;
|
|
268
331
|
try {
|
|
269
|
-
await candidate
|
|
332
|
+
await this.#closeOwned([candidate]);
|
|
270
333
|
}
|
|
271
334
|
catch (error) {
|
|
272
335
|
closeCause = error;
|
|
@@ -274,512 +337,191 @@ export default class Module {
|
|
|
274
337
|
throw preparationError(definition, cause, closeCause);
|
|
275
338
|
}
|
|
276
339
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
340
|
+
// {§functionality-adapter} two-phase preparation: reuse unchanged live
|
|
341
|
+
// attachments, prepare the rest, and hand the coordinator runtimes and
|
|
342
|
+
// outcomes; commit adopts the set and closes what it no longer uses, abort
|
|
343
|
+
// closes only what this attempt opened.
|
|
344
|
+
async #prepare(preparation) {
|
|
345
|
+
this.#assertOpen();
|
|
346
|
+
const { workspaceId, workerId, enabled, failure, force } = preparation;
|
|
347
|
+
this.#identities.set(workerId, { workspaceId, workerId });
|
|
348
|
+
this.#retainWorker.set(workerId, preparation.retain);
|
|
349
|
+
const previous = preparation.previous ?? new Map();
|
|
350
|
+
for (const [name, attachment] of previous) {
|
|
351
|
+
const nextDefinition = enabled.get(name);
|
|
352
|
+
if (nextDefinition === undefined || force === name || !sameDefinition(attachment.definition, nextDefinition)) {
|
|
289
353
|
attachmentConnection(attachment)?.assertReplaceable();
|
|
354
|
+
}
|
|
290
355
|
}
|
|
291
356
|
const next = new Map();
|
|
357
|
+
const outcomes = new Map();
|
|
292
358
|
const fresh = [];
|
|
359
|
+
const consumedPending = new Map();
|
|
293
360
|
try {
|
|
294
|
-
for (const [name,
|
|
295
|
-
const
|
|
361
|
+
for (const [name, value] of enabled) {
|
|
362
|
+
const definition = value;
|
|
363
|
+
const existing = previous.get(name);
|
|
364
|
+
const pending = this.#pending.get(this.#pendingKey(workerId, name));
|
|
296
365
|
if (existing !== undefined
|
|
297
|
-
&&
|
|
298
|
-
&&
|
|
366
|
+
&& force !== name
|
|
367
|
+
&& !this.#dirty.has(this.#pendingKey(workerId, name))
|
|
368
|
+
&& sameDefinition(existing.definition, definition)
|
|
369
|
+
&& !(pending?.prepared !== undefined)) {
|
|
299
370
|
next.set(name, existing);
|
|
300
371
|
continue;
|
|
301
372
|
}
|
|
302
|
-
const supplied = options.prepared?.get(name);
|
|
303
373
|
let attachment;
|
|
304
|
-
|
|
305
|
-
attachment =
|
|
374
|
+
if (pending?.prepared !== undefined && sameDefinition(pending.definition, definition)) {
|
|
375
|
+
attachment = pending.prepared;
|
|
376
|
+
consumedPending.set(name, pending);
|
|
306
377
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
problem: structuredClone(
|
|
317
|
-
|
|
318
|
-
|
|
378
|
+
else {
|
|
379
|
+
try {
|
|
380
|
+
attachment = await this.#prepareAttachment(workerId, definition);
|
|
381
|
+
}
|
|
382
|
+
catch (cause) {
|
|
383
|
+
this.#assertOpen();
|
|
384
|
+
if (failure === "reject")
|
|
385
|
+
throw cause;
|
|
386
|
+
const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, cause);
|
|
387
|
+
attachment = { kind: "unavailable", definition, problem: structuredClone(refusal.problem) };
|
|
388
|
+
console.error(`MCP server '${name}' unavailable: ${refusal.problem.detail}`, refusal.cause ?? refusal);
|
|
389
|
+
}
|
|
390
|
+
if (attachment.kind !== "unavailable")
|
|
391
|
+
fresh.push(attachment);
|
|
319
392
|
}
|
|
320
393
|
next.set(name, attachment);
|
|
321
|
-
|
|
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.");
|
|
394
|
+
this.#dirty.delete(this.#pendingKey(workerId, name));
|
|
330
395
|
}
|
|
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
396
|
}
|
|
348
397
|
catch (cause) {
|
|
349
|
-
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection
|
|
398
|
+
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
|
|
350
399
|
const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
|
|
351
|
-
if (failures.length > 0)
|
|
352
|
-
throw new AggregateError([cause, ...failures], "MCP
|
|
353
|
-
}
|
|
400
|
+
if (failures.length > 0)
|
|
401
|
+
throw new AggregateError([cause, ...failures], "MCP worker preparation and cleanup failed.");
|
|
354
402
|
throw cause;
|
|
355
403
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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);
|
|
404
|
+
for (const [name, attachment] of next) {
|
|
405
|
+
switch (attachment.kind) {
|
|
406
|
+
case "active":
|
|
407
|
+
outcomes.set(name, { state: "active", detail: catalogDetail(attachment.executor) });
|
|
408
|
+
break;
|
|
409
|
+
case "unavailable":
|
|
410
|
+
outcomes.set(name, { state: "unavailable", problem: attachment.problem });
|
|
411
|
+
break;
|
|
412
|
+
case "authorization-required":
|
|
413
|
+
outcomes.set(name, { state: "authorization-required", authorization: { url: attachment.authorizationUrl } });
|
|
414
|
+
break;
|
|
385
415
|
}
|
|
386
416
|
}
|
|
387
|
-
|
|
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;
|
|
417
|
+
const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
|
|
444
418
|
return {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
419
|
+
runtimes,
|
|
420
|
+
documents: [],
|
|
421
|
+
outcomes,
|
|
422
|
+
snapshot: next,
|
|
423
|
+
commit: async () => {
|
|
424
|
+
this.#attachments.set(workerId, next);
|
|
425
|
+
const retained = new Set([...next.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []));
|
|
426
|
+
const pendingConnections = new Set([...this.#pending.values()].map(({ connection }) => connection));
|
|
427
|
+
const obsolete = [...previous.values()]
|
|
428
|
+
.flatMap((attachment) => attachmentConnection(attachment) ?? [])
|
|
429
|
+
.filter((connection) => !retained.has(connection) && !pendingConnections.has(connection));
|
|
430
|
+
for (const [name, pending] of consumedPending) {
|
|
431
|
+
this.#pending.delete(this.#pendingKey(workerId, name));
|
|
432
|
+
pending.releaseWorker();
|
|
433
|
+
}
|
|
434
|
+
for (const [name, attachment] of next) {
|
|
435
|
+
if (attachment.kind !== "authorization-required")
|
|
436
|
+
continue;
|
|
437
|
+
const key = this.#pendingKey(workerId, name);
|
|
438
|
+
const current = this.#pending.get(key);
|
|
439
|
+
if (current?.connection === attachment.connection)
|
|
440
|
+
continue;
|
|
441
|
+
this.#pending.set(key, {
|
|
442
|
+
definition: attachment.definition,
|
|
443
|
+
connection: attachment.connection,
|
|
444
|
+
authorizationUrl: attachment.authorizationUrl,
|
|
445
|
+
releaseWorker: this.#retain(workerId),
|
|
446
|
+
});
|
|
447
|
+
if (current !== undefined) {
|
|
448
|
+
current.releaseWorker();
|
|
449
|
+
if (!retained.has(current.connection))
|
|
450
|
+
obsolete.push(current.connection);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (obsolete.length > 0) {
|
|
454
|
+
try {
|
|
455
|
+
await this.#closeOwned(obsolete);
|
|
456
|
+
}
|
|
457
|
+
catch (cause) {
|
|
458
|
+
throw actionError("obsolete-connection-close-failed", 500, "The MCP capability change committed, but an obsolete connection did not close cleanly.", { workerId, committed: true }, cause);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
abort: async () => {
|
|
463
|
+
await this.#closeOwned(fresh.map(({ connection }) => connection));
|
|
464
|
+
},
|
|
453
465
|
};
|
|
454
466
|
}
|
|
455
|
-
#
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
.map(([alias, { definition }]) => [alias, definition]));
|
|
461
|
-
return overlayServerDefinitions(structuredClone(value), bases);
|
|
467
|
+
async #teardown(snapshot, identity) {
|
|
468
|
+
const { workerId } = identity;
|
|
469
|
+
const pending = [...this.#pending.keys()].filter((key) => key.startsWith(`${workerId}:`));
|
|
470
|
+
if (pending.length > 0) {
|
|
471
|
+
throw new Error(`MCP worker ${workerId} cannot cool with pending OAuth residency.`);
|
|
462
472
|
}
|
|
463
|
-
|
|
464
|
-
|
|
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))
|
|
473
|
+
for (const [key, timer] of this.#refreshTimers) {
|
|
474
|
+
if (!key.startsWith(`${workerId}:`))
|
|
477
475
|
continue;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
source: "client",
|
|
481
|
-
enabled: false,
|
|
482
|
-
}));
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
this.#refreshTimers.delete(key);
|
|
483
478
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
479
|
+
const attachments = snapshot ?? new Map();
|
|
480
|
+
const connections = [...attachments.values()].flatMap((attachment) => attachmentConnection(attachment) ?? []);
|
|
481
|
+
this.#attachments.delete(workerId);
|
|
482
|
+
this.#retainWorker.delete(workerId);
|
|
483
|
+
this.#identities.delete(workerId);
|
|
484
|
+
await this.#closeOwned(connections);
|
|
485
|
+
}
|
|
486
|
+
// {§oauth-continuation} — the callback finishes the pending connection's
|
|
487
|
+
// authorization, prepares its attachment, and re-enables the alias through
|
|
488
|
+
// the coordinator, which consumes the prepared attachment on publication.
|
|
489
|
+
async #completeOAuth(identity, params) {
|
|
490
|
+
assertActionKeys(params, ["alias", "callbackUrl"]);
|
|
492
491
|
const alias = requiredString(params, "alias");
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
}));
|
|
492
|
+
const callbackUrl = requiredString(params, "callbackUrl");
|
|
493
|
+
const key = this.#pendingKey(identity.workerId, alias);
|
|
494
|
+
const pending = this.#pending.get(key);
|
|
495
|
+
if (pending === undefined) {
|
|
496
|
+
// {§oauth-lifetime} — restart during pending authorization surfaces
|
|
497
|
+
// as a factual not-pending state, never a secret replay.
|
|
498
|
+
throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workerId: identity.workerId, alias, retryable: false });
|
|
595
499
|
}
|
|
596
|
-
|
|
597
|
-
|
|
500
|
+
const current = this.#attachments.get(identity.workerId)?.get(alias);
|
|
501
|
+
if (current === undefined || current.kind !== "authorization-required" || !sameDefinition(current.definition, pending.definition)) {
|
|
502
|
+
throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workerId: identity.workerId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
|
|
598
503
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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];
|
|
504
|
+
if (pending.prepared === undefined) {
|
|
505
|
+
try {
|
|
506
|
+
await pending.connection.finishAuthorization(callbackUrl);
|
|
507
|
+
const prepared = await this.#prepareAttachment(identity.workerId, pending.definition, pending.connection);
|
|
508
|
+
if (prepared.kind !== "active")
|
|
509
|
+
throw new Error("OAuth completion returned another authorization challenge.");
|
|
510
|
+
pending.prepared = prepared;
|
|
691
511
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
kind: "service",
|
|
695
|
-
enabled: false,
|
|
696
|
-
};
|
|
512
|
+
catch (cause) {
|
|
513
|
+
throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workerId: identity.workerId, alias, retryable: false }, cause);
|
|
697
514
|
}
|
|
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
515
|
}
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
});
|
|
516
|
+
const result = await this.#handleOrThrow().invoke("enable", { alias }, identity);
|
|
517
|
+
return result.body;
|
|
776
518
|
}
|
|
777
|
-
async #complete(
|
|
519
|
+
async #complete(workerId, params) {
|
|
778
520
|
assertActionKeys(params, ["server", "ref", "argument", "context"]);
|
|
779
521
|
const server = requiredString(params, "server");
|
|
780
|
-
const attachment = this.#
|
|
522
|
+
const attachment = this.#attachments.get(workerId)?.get(server);
|
|
781
523
|
if (attachment === undefined || attachment.kind !== "active") {
|
|
782
|
-
throw actionError("server-not-connected", 409, `MCP server '${server}' is not connected
|
|
524
|
+
throw actionError("server-not-connected", 409, `MCP server '${server}' is not connected for this worker.`, { workerId, name: server, retryable: false });
|
|
783
525
|
}
|
|
784
526
|
const ref = objectOf(params.ref);
|
|
785
527
|
const argument = objectOf(params.argument);
|
|
@@ -792,57 +534,24 @@ export default class Module {
|
|
|
792
534
|
...(objectOf(params.context) === null ? {} : { context: params.context }),
|
|
793
535
|
});
|
|
794
536
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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) {
|
|
537
|
+
// A live catalog change republishes the unchanged state; the dirty alias
|
|
538
|
+
// rebuilds its executor on the next preparation.
|
|
539
|
+
#scheduleCatalogRefresh(workerId, name, attempt = 0) {
|
|
833
540
|
if (this.#closed)
|
|
834
541
|
return;
|
|
835
|
-
const key = this.#pendingKey(
|
|
542
|
+
const key = this.#pendingKey(workerId, name);
|
|
836
543
|
if (this.#refreshTimers.has(key))
|
|
837
544
|
return;
|
|
838
545
|
const delay = Math.min(250 * (2 ** attempt), 5000);
|
|
839
546
|
const timer = setTimeout(() => {
|
|
840
547
|
this.#refreshTimers.delete(key);
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
548
|
+
const identity = this.#identities.get(workerId);
|
|
549
|
+
if (identity === undefined || this.#closed)
|
|
550
|
+
return;
|
|
551
|
+
this.#dirty.add(key);
|
|
552
|
+
void this.#handleOrThrow().refresh(identity).catch((error) => {
|
|
844
553
|
if (statusOf(error) === 409) {
|
|
845
|
-
this.#scheduleCatalogRefresh(
|
|
554
|
+
this.#scheduleCatalogRefresh(workerId, name, attempt + 1);
|
|
846
555
|
return;
|
|
847
556
|
}
|
|
848
557
|
console.error(`MCP server '${name}' capability refresh failed:`, error);
|
|
@@ -858,19 +567,14 @@ export default class Module {
|
|
|
858
567
|
for (const timer of this.#refreshTimers.values())
|
|
859
568
|
clearTimeout(timer);
|
|
860
569
|
this.#refreshTimers.clear();
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
for (const attachment of snapshot.attachments.values()) {
|
|
864
|
-
const connection = attachmentConnection(attachment);
|
|
865
|
-
if (connection !== undefined)
|
|
866
|
-
connections.add(connection);
|
|
867
|
-
}
|
|
868
|
-
}
|
|
570
|
+
const closing = this.#closeOwned([...this.#connections]);
|
|
571
|
+
this.#attachments.clear();
|
|
869
572
|
for (const pending of this.#pending.values())
|
|
870
|
-
|
|
871
|
-
this.#workspaces.clear();
|
|
573
|
+
pending.releaseWorker();
|
|
872
574
|
this.#pending.clear();
|
|
873
|
-
|
|
575
|
+
this.#retainWorker.clear();
|
|
576
|
+
this.#identities.clear();
|
|
577
|
+
await closing;
|
|
874
578
|
}
|
|
875
579
|
}
|
|
876
580
|
//# sourceMappingURL=Module.js.map
|