@plurnk/plurnk-mcp 1.7.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/.env.defaults +25 -0
- package/README.md +85 -27
- package/SPEC.md +317 -69
- package/dist/McpExecutor.d.ts +28 -3
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +50 -6
- 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 +393 -482
- package/dist/Module.js.map +1 -1
- package/dist/ToolPresentation.d.ts +3 -1
- package/dist/ToolPresentation.d.ts.map +1 -1
- package/dist/ToolPresentation.js +145 -55
- package/dist/ToolPresentation.js.map +1 -1
- 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 +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +105 -43
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +8 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +169 -32
- package/dist/config.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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-watchdog.mjs +106 -0
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/package.json +6 -5
package/dist/Module.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { Problems, Validator, } from "@plurnk/plurnk-contracts";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import McpExecutor, { runtimeDecl } from "./McpExecutor.js";
|
|
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";
|
|
6
5
|
import McpResources from "./McpResources.js";
|
|
7
|
-
import { MCP_PROTOCOL_VERSION } from "./protocol.js";
|
|
8
6
|
const OWNER = "@plurnk/plurnk-mcp";
|
|
9
|
-
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
|
+
});
|
|
18
|
+
const attachmentConnection = (attachment) => attachment.kind === "unavailable" ? undefined : attachment.connection;
|
|
10
19
|
class ModuleActionError extends Error {
|
|
11
20
|
problem;
|
|
12
21
|
constructor(problem, cause) {
|
|
@@ -21,36 +30,20 @@ const actionError = (code, status, detail, extensions = {}, cause) => new Module
|
|
|
21
30
|
...extensions,
|
|
22
31
|
}), cause);
|
|
23
32
|
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
33
|
const preparationError = (definition, cause, closeCause) => {
|
|
45
34
|
const completeCause = closeCause === undefined
|
|
46
35
|
? cause
|
|
47
36
|
: new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
// {§oauth-client-credentials} — a rejected grant is an authorization fact,
|
|
38
|
+
// never a generic unavailability.
|
|
39
|
+
if (definition.authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
|
|
40
|
+
return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
|
|
50
41
|
server: definition.name,
|
|
51
42
|
transport: definition.transport,
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
clientId: definition.authorization.clientId,
|
|
44
|
+
...(definition.authorization.issuer === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { issuer: definition.authorization.issuer }),
|
|
54
47
|
retryable: false,
|
|
55
48
|
}, completeCause);
|
|
56
49
|
}
|
|
@@ -72,78 +65,60 @@ export const closeConnections = async (connections) => {
|
|
|
72
65
|
const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
73
66
|
? value
|
|
74
67
|
: null;
|
|
75
|
-
const
|
|
76
|
-
const extras = Object.keys(
|
|
77
|
-
if (extras.length > 0)
|
|
78
|
-
throw
|
|
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 };
|
|
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 });
|
|
111
72
|
}
|
|
112
|
-
return { version: STATE_VERSION, servers: parsed };
|
|
113
73
|
};
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
117
|
-
const requiredString = (params, field) => {
|
|
118
|
-
const value = params[field];
|
|
74
|
+
const requiredString = (params, key) => {
|
|
75
|
+
const value = params[key];
|
|
119
76
|
if (typeof value !== "string" || value.length === 0) {
|
|
120
|
-
throw actionError("
|
|
77
|
+
throw actionError("parameters-invalid", 400, `'${key}' must be a non-empty string.`, { field: key, retryable: false });
|
|
121
78
|
}
|
|
122
79
|
return value;
|
|
123
80
|
};
|
|
124
|
-
const
|
|
125
|
-
if (context.scope !== "
|
|
126
|
-
throw
|
|
127
|
-
}
|
|
128
|
-
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 };
|
|
129
85
|
};
|
|
86
|
+
const sameDefinition = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
130
87
|
const statusOf = (error) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const result = objectOf(candidate?.result);
|
|
136
|
-
const problem = objectOf(result?.problem);
|
|
88
|
+
if (typeof error !== "object" || error === null)
|
|
89
|
+
return null;
|
|
90
|
+
const problem = error.problem
|
|
91
|
+
?? error.result?.problem;
|
|
137
92
|
return typeof problem?.status === "number" ? problem.status : null;
|
|
138
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
|
+
};
|
|
139
106
|
export default class Module {
|
|
140
107
|
#env;
|
|
108
|
+
#summaries;
|
|
109
|
+
#expanded;
|
|
141
110
|
#defaults;
|
|
142
|
-
#
|
|
111
|
+
#defaultEnabled;
|
|
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();
|
|
143
116
|
#pending = new Map();
|
|
144
|
-
#
|
|
117
|
+
#dirty = new Set();
|
|
118
|
+
#connections = new Set();
|
|
145
119
|
#refreshTimers = new Map();
|
|
146
|
-
#
|
|
120
|
+
#retainWorker = new Map();
|
|
121
|
+
#handle;
|
|
147
122
|
#closed = false;
|
|
148
123
|
static init(options = {}) {
|
|
149
124
|
return new Module(options.env ?? process.env);
|
|
@@ -151,86 +126,192 @@ export default class Module {
|
|
|
151
126
|
constructor(environ) {
|
|
152
127
|
this.#env = environ;
|
|
153
128
|
this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
|
|
129
|
+
this.#defaultEnabled = new Set(serviceEnabledNames(environ));
|
|
130
|
+
this.#summaries = summaryOverrides(environ);
|
|
131
|
+
this.#expanded = new Set(expandedServerNames(environ));
|
|
154
132
|
}
|
|
155
133
|
async setup(seam) {
|
|
156
|
-
this.#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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),
|
|
156
|
+
});
|
|
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),
|
|
164
168
|
});
|
|
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
169
|
}
|
|
174
|
-
#
|
|
175
|
-
|
|
170
|
+
#assertOpen() {
|
|
171
|
+
if (this.#closed)
|
|
172
|
+
throw new Error("MCP module is closed.");
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.`);
|
|
186
186
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
return retain();
|
|
188
|
+
}
|
|
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]);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
errors.push(...errorsOf(result.reason));
|
|
191
199
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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}`,
|
|
205
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,
|
|
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 });
|
|
271
|
+
}
|
|
272
|
+
return candidates;
|
|
273
|
+
}
|
|
274
|
+
#admit(input) {
|
|
275
|
+
const params = objectOf(input) ?? {};
|
|
276
|
+
let definition;
|
|
277
|
+
try {
|
|
278
|
+
definition = structuredClone(Validator.assertMcpServerDefinition(structuredClone(params.definition)));
|
|
206
279
|
}
|
|
207
|
-
|
|
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 };
|
|
208
288
|
}
|
|
209
|
-
async #prepareAttachment(
|
|
210
|
-
|
|
289
|
+
async #prepareAttachment(workerId, definition, connection) {
|
|
290
|
+
this.#assertOpen();
|
|
211
291
|
const candidate = connection ?? new ServerConnection(definition, this.#env, {
|
|
212
292
|
onCatalogChanged: (error) => {
|
|
213
293
|
if (error !== null) {
|
|
214
294
|
console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
|
|
215
295
|
return;
|
|
216
296
|
}
|
|
217
|
-
this.#scheduleCatalogRefresh(
|
|
297
|
+
this.#scheduleCatalogRefresh(workerId, definition.name);
|
|
218
298
|
},
|
|
219
299
|
onInfrastructureError: (error) => {
|
|
220
300
|
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
221
301
|
},
|
|
222
302
|
});
|
|
223
|
-
|
|
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);
|
|
224
305
|
try {
|
|
225
306
|
const availability = await executor.requireAvailable();
|
|
226
307
|
return {
|
|
227
308
|
kind: "active",
|
|
228
|
-
|
|
309
|
+
definition,
|
|
229
310
|
connection: candidate,
|
|
230
311
|
executor,
|
|
231
312
|
runtime: {
|
|
232
313
|
namespaceOwner: OWNER,
|
|
233
|
-
decl: runtimeDecl(definition.name),
|
|
314
|
+
decl: runtimeDecl(definition.name, serverSummary(definition.name, executor.catalog, this.#summaries.servers.get(definition.name)), this.#expanded.has(definition.name)),
|
|
234
315
|
executor,
|
|
235
316
|
availability,
|
|
236
317
|
scheme: new McpResources(definition.name, candidate, executor.catalog),
|
|
@@ -241,14 +322,14 @@ export default class Module {
|
|
|
241
322
|
if (cause instanceof AuthorizationRequiredError) {
|
|
242
323
|
return {
|
|
243
324
|
kind: "authorization-required",
|
|
244
|
-
|
|
325
|
+
definition,
|
|
245
326
|
connection: candidate,
|
|
246
327
|
authorizationUrl: cause.authorizationUrl,
|
|
247
328
|
};
|
|
248
329
|
}
|
|
249
330
|
let closeCause;
|
|
250
331
|
try {
|
|
251
|
-
await candidate
|
|
332
|
+
await this.#closeOwned([candidate]);
|
|
252
333
|
}
|
|
253
334
|
catch (error) {
|
|
254
335
|
closeCause = error;
|
|
@@ -256,325 +337,191 @@ export default class Module {
|
|
|
256
337
|
throw preparationError(definition, cause, closeCause);
|
|
257
338
|
}
|
|
258
339
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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)) {
|
|
353
|
+
attachmentConnection(attachment)?.assertReplaceable();
|
|
354
|
+
}
|
|
272
355
|
}
|
|
273
356
|
const next = new Map();
|
|
357
|
+
const outcomes = new Map();
|
|
274
358
|
const fresh = [];
|
|
359
|
+
const consumedPending = new Map();
|
|
275
360
|
try {
|
|
276
|
-
for (const [name,
|
|
277
|
-
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));
|
|
278
365
|
if (existing !== undefined
|
|
279
|
-
&&
|
|
280
|
-
&&
|
|
366
|
+
&& force !== name
|
|
367
|
+
&& !this.#dirty.has(this.#pendingKey(workerId, name))
|
|
368
|
+
&& sameDefinition(existing.definition, definition)
|
|
369
|
+
&& !(pending?.prepared !== undefined)) {
|
|
281
370
|
next.set(name, existing);
|
|
282
371
|
continue;
|
|
283
372
|
}
|
|
284
|
-
|
|
285
|
-
|
|
373
|
+
let attachment;
|
|
374
|
+
if (pending?.prepared !== undefined && sameDefinition(pending.definition, definition)) {
|
|
375
|
+
attachment = pending.prepared;
|
|
376
|
+
consumedPending.set(name, pending);
|
|
377
|
+
}
|
|
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);
|
|
392
|
+
}
|
|
286
393
|
next.set(name, attachment);
|
|
287
|
-
|
|
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.");
|
|
394
|
+
this.#dirty.delete(this.#pendingKey(workerId, name));
|
|
295
395
|
}
|
|
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
396
|
}
|
|
313
397
|
catch (cause) {
|
|
314
|
-
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => connection
|
|
398
|
+
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
|
|
315
399
|
const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
|
|
316
|
-
if (failures.length > 0)
|
|
317
|
-
throw new AggregateError([cause, ...failures], "MCP
|
|
318
|
-
}
|
|
400
|
+
if (failures.length > 0)
|
|
401
|
+
throw new AggregateError([cause, ...failures], "MCP worker preparation and cleanup failed.");
|
|
319
402
|
throw cause;
|
|
320
403
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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;
|
|
332
415
|
}
|
|
333
416
|
}
|
|
334
|
-
|
|
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);
|
|
417
|
+
const runtimes = [...next.values()].flatMap((attachment) => attachment.kind === "active" ? [attachment.runtime] : []);
|
|
406
418
|
return {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
},
|
|
410
465
|
};
|
|
411
466
|
}
|
|
412
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
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.`);
|
|
415
472
|
}
|
|
416
|
-
|
|
417
|
-
|
|
473
|
+
for (const [key, timer] of this.#refreshTimers) {
|
|
474
|
+
if (!key.startsWith(`${workerId}:`))
|
|
475
|
+
continue;
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
this.#refreshTimers.delete(key);
|
|
418
478
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
});
|
|
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"]);
|
|
491
|
+
const alias = requiredString(params, "alias");
|
|
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 });
|
|
528
499
|
}
|
|
529
|
-
const
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
kind: "attached",
|
|
533
|
-
definition: structuredClone(pending.definition.definition),
|
|
534
|
-
};
|
|
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 });
|
|
535
503
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 });
|
|
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;
|
|
546
511
|
}
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
}
|
|
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);
|
|
559
514
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
});
|
|
515
|
+
}
|
|
516
|
+
const result = await this.#handleOrThrow().invoke("enable", { alias }, identity);
|
|
517
|
+
return result.body;
|
|
572
518
|
}
|
|
573
|
-
async #complete(
|
|
519
|
+
async #complete(workerId, params) {
|
|
520
|
+
assertActionKeys(params, ["server", "ref", "argument", "context"]);
|
|
574
521
|
const server = requiredString(params, "server");
|
|
575
|
-
const attachment = this.#
|
|
522
|
+
const attachment = this.#attachments.get(workerId)?.get(server);
|
|
576
523
|
if (attachment === undefined || attachment.kind !== "active") {
|
|
577
|
-
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 });
|
|
578
525
|
}
|
|
579
526
|
const ref = objectOf(params.ref);
|
|
580
527
|
const argument = objectOf(params.argument);
|
|
@@ -587,57 +534,24 @@ export default class Module {
|
|
|
587
534
|
...(objectOf(params.context) === null ? {} : { context: params.context }),
|
|
588
535
|
});
|
|
589
536
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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) {
|
|
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) {
|
|
628
540
|
if (this.#closed)
|
|
629
541
|
return;
|
|
630
|
-
const key = this.#pendingKey(
|
|
542
|
+
const key = this.#pendingKey(workerId, name);
|
|
631
543
|
if (this.#refreshTimers.has(key))
|
|
632
544
|
return;
|
|
633
545
|
const delay = Math.min(250 * (2 ** attempt), 5000);
|
|
634
546
|
const timer = setTimeout(() => {
|
|
635
547
|
this.#refreshTimers.delete(key);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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) => {
|
|
639
553
|
if (statusOf(error) === 409) {
|
|
640
|
-
this.#scheduleCatalogRefresh(
|
|
554
|
+
this.#scheduleCatalogRefresh(workerId, name, attempt + 1);
|
|
641
555
|
return;
|
|
642
556
|
}
|
|
643
557
|
console.error(`MCP server '${name}' capability refresh failed:`, error);
|
|
@@ -653,17 +567,14 @@ export default class Module {
|
|
|
653
567
|
for (const timer of this.#refreshTimers.values())
|
|
654
568
|
clearTimeout(timer);
|
|
655
569
|
this.#refreshTimers.clear();
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
for (const attachment of snapshot.attachments.values()) {
|
|
659
|
-
connections.add(attachment.connection);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
570
|
+
const closing = this.#closeOwned([...this.#connections]);
|
|
571
|
+
this.#attachments.clear();
|
|
662
572
|
for (const pending of this.#pending.values())
|
|
663
|
-
|
|
664
|
-
this.#workspaces.clear();
|
|
573
|
+
pending.releaseWorker();
|
|
665
574
|
this.#pending.clear();
|
|
666
|
-
|
|
575
|
+
this.#retainWorker.clear();
|
|
576
|
+
this.#identities.clear();
|
|
577
|
+
await closing;
|
|
667
578
|
}
|
|
668
579
|
}
|
|
669
580
|
//# sourceMappingURL=Module.js.map
|