@plurnk/plurnk-mcp 1.23.0 → 1.25.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 +16 -36
- package/README.md +115 -131
- package/SPEC.md +189 -166
- package/dist/McpExecutor.d.ts +3 -3
- package/dist/McpExecutor.d.ts.map +1 -1
- package/dist/McpExecutor.js +18 -26
- package/dist/McpExecutor.js.map +1 -1
- package/dist/Module.d.ts +14 -13
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +191 -188
- package/dist/Module.js.map +1 -1
- package/dist/PluginConfiguration.d.ts +23 -0
- package/dist/PluginConfiguration.d.ts.map +1 -0
- package/dist/PluginConfiguration.js +34 -0
- package/dist/PluginConfiguration.js.map +1 -0
- package/dist/ToolPresentation.d.ts +1 -1
- package/dist/ToolPresentation.d.ts.map +1 -1
- package/dist/ToolPresentation.js +3 -7
- package/dist/ToolPresentation.js.map +1 -1
- package/dist/client.d.ts +8 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +96 -87
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +25 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +151 -310
- package/dist/config.js.map +1 -1
- package/dist/definition.d.ts +3 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +16 -0
- package/dist/definition.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +60 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +150 -0
- package/dist/registry.js.map +1 -0
- package/docs/mcp.md +53 -34
- package/package.json +6 -4
package/dist/Module.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
var _a;
|
|
2
1
|
// {§mcp-module} — the MCP family beneath the shared workspace Functionality
|
|
3
2
|
// coordinator ({§functionality-adapter}). This module owns MCP protocol truth:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// teardown. The coordinator owns the lifecycle, durable workspace state, atomic
|
|
3
|
+
// complete connection definitions, preparation with OAuth
|
|
4
|
+
// continuation, tool/resource publication, catalog refresh, and teardown. The coordinator owns the lifecycle, durable workspace state, atomic
|
|
7
5
|
// publication, and both the client and model projections.
|
|
8
6
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { Problems,
|
|
12
|
-
import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, } from "./client.js";
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
7
|
+
import { readDefinition } from "./definition.js";
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
import { Problems, } from "@plurnk/plurnk-contracts";
|
|
10
|
+
import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, McpRedirectError, } from "./client.js";
|
|
11
|
+
import { configuredDefinitions, connectTimeoutMs, expandedServerNames, retryDelayMs, retryPacing, registrySettings, serverSettings, } from "./config.js";
|
|
12
|
+
import { RegistryError, registryEntries, searchRegistry } from "./registry.js";
|
|
13
|
+
import McpExecutor, { runtimeDecl, runtimeServerSummary } from "./McpExecutor.js";
|
|
15
14
|
import McpResources from "./McpResources.js";
|
|
16
15
|
const OWNER = "@plurnk/plurnk-mcp";
|
|
17
16
|
const FAMILY = "mcp";
|
|
@@ -39,26 +38,45 @@ const actionError = (code, status, detail, extensions = {}, cause) => new Module
|
|
|
39
38
|
...extensions,
|
|
40
39
|
}), cause);
|
|
41
40
|
const errorsOf = (error) => error instanceof AggregateError ? [...error.errors] : [error];
|
|
42
|
-
|
|
41
|
+
// The first error of a type anywhere in a cause chain, aggregates included.
|
|
42
|
+
const causeOf = (error, type, seen = new Set()) => {
|
|
43
|
+
if (error === null || typeof error !== "object" || seen.has(error))
|
|
44
|
+
return undefined;
|
|
45
|
+
seen.add(error);
|
|
46
|
+
if (error instanceof type)
|
|
47
|
+
return error;
|
|
48
|
+
for (const inner of error instanceof AggregateError ? error.errors : []) {
|
|
49
|
+
const found = causeOf(inner, type, seen);
|
|
50
|
+
if (found !== undefined)
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
return causeOf(error.cause, type, seen);
|
|
54
|
+
};
|
|
55
|
+
const preparationError = (definition, authorization, cause, closeCause) => {
|
|
43
56
|
const completeCause = closeCause === undefined
|
|
44
57
|
? cause
|
|
45
58
|
: new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
|
|
46
59
|
// {§oauth-client-credentials} — a rejected grant is an authorization fact,
|
|
47
60
|
// never a generic unavailability.
|
|
48
|
-
if (
|
|
61
|
+
if (authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
|
|
49
62
|
return actionError("oauth-client-credentials-failed", 502, `MCP server '${definition.name}' rejected the client-credentials grant; check the configured client credentials and issuer.`, {
|
|
50
63
|
server: definition.name,
|
|
51
|
-
|
|
52
|
-
clientId:
|
|
53
|
-
...(
|
|
64
|
+
type: definition.type,
|
|
65
|
+
clientId: authorization.clientId,
|
|
66
|
+
...(authorization.issuer === undefined
|
|
54
67
|
? {}
|
|
55
|
-
: { issuer:
|
|
68
|
+
: { issuer: authorization.issuer }),
|
|
56
69
|
retryable: false,
|
|
57
70
|
}, completeCause);
|
|
58
71
|
}
|
|
72
|
+
// {§mcp-redirect-refused} — the operator corrects the endpoint; retrying cannot.
|
|
73
|
+
const redirect = causeOf(cause, McpRedirectError);
|
|
74
|
+
if (redirect !== undefined) {
|
|
75
|
+
return actionError("server-redirected", 502, redirect.message, { server: definition.name, url: redirect.url, ...(redirect.location === null ? {} : { location: redirect.location }), retryable: false }, completeCause);
|
|
76
|
+
}
|
|
59
77
|
return actionError("server-unavailable", 502, `Configured MCP server '${definition.name}' is unavailable.`, {
|
|
60
78
|
server: definition.name,
|
|
61
|
-
|
|
79
|
+
type: definition.type,
|
|
62
80
|
retryable: true,
|
|
63
81
|
}, completeCause);
|
|
64
82
|
};
|
|
@@ -92,7 +110,7 @@ const workspaceIdentityOf = (context) => {
|
|
|
92
110
|
throw new Error("MCP actions require a workspace-scoped context.");
|
|
93
111
|
return { workspaceId: context.workspaceId };
|
|
94
112
|
};
|
|
95
|
-
const
|
|
113
|
+
const sameBinding = (left, right) => isDeepStrictEqual([left.definition, left.context], [right.definition, right.context]);
|
|
96
114
|
const statusOf = (error) => {
|
|
97
115
|
if (typeof error !== "object" || error === null)
|
|
98
116
|
return null;
|
|
@@ -112,14 +130,14 @@ const catalogDetail = (executor) => {
|
|
|
112
130
|
prompts: catalog.prompts.length,
|
|
113
131
|
};
|
|
114
132
|
};
|
|
115
|
-
class Module {
|
|
133
|
+
export default class Module {
|
|
116
134
|
#env;
|
|
117
135
|
#workspaceEnvironment;
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
136
|
+
#plugins;
|
|
137
|
+
#configurationNotices = new Map();
|
|
138
|
+
#operatorEnvironment;
|
|
139
|
+
#stateDirectory;
|
|
140
|
+
#configurationDirectories;
|
|
123
141
|
// The committed attachments per workspace: the adapter's mirror of the snapshot
|
|
124
142
|
// the coordinator holds, for continuations and refresh.
|
|
125
143
|
#attachments = new Map();
|
|
@@ -131,43 +149,42 @@ class Module {
|
|
|
131
149
|
#retainWorkspace = new Map();
|
|
132
150
|
#handle;
|
|
133
151
|
#closed = false;
|
|
152
|
+
#stopping = null;
|
|
134
153
|
static init(options = {}) {
|
|
135
|
-
return new
|
|
154
|
+
return new Module(options.env ?? process.env);
|
|
136
155
|
}
|
|
137
156
|
constructor(environ) {
|
|
138
157
|
this.#env = environ;
|
|
139
|
-
this.#defaults = new Map(serviceDefinitions(environ).map((definition) => [definition.name, definition]));
|
|
140
|
-
this.#defaultEnabled = new Set(serviceEnabledNames(environ));
|
|
141
|
-
this.#summaries = summaryOverrides(environ);
|
|
142
|
-
this.#expanded = new Set(expandedServerNames(environ));
|
|
143
158
|
}
|
|
144
159
|
async setup(seam) {
|
|
160
|
+
this.#plugins = (workspaceId) => seam.readWorkspacePlugins(workspaceId);
|
|
145
161
|
this.#workspaceEnvironment = (workspaceId) => seam.readWorkspaceEnvironment(workspaceId);
|
|
146
|
-
this.#
|
|
162
|
+
this.#operatorEnvironment = () => seam.operatorEnvironment();
|
|
163
|
+
this.#stateDirectory = (workspaceId, owner) => seam.workspaceStateDirectory(workspaceId, owner);
|
|
164
|
+
this.#configurationDirectories = (workspaceId) => seam.workspaceConfigurationDirectories(workspaceId);
|
|
147
165
|
this.#handle = seam.registerFunctionalityAdapter({
|
|
148
166
|
family: FAMILY,
|
|
149
167
|
namespaceOwner: OWNER,
|
|
150
168
|
summary: "Manage MCP servers",
|
|
151
169
|
definitionSchema: MCP_DEFINITION,
|
|
152
|
-
example: { alias: "
|
|
170
|
+
example: { alias: "example-server", definition: { name: "example-server", type: "stdio", command: "npx", args: ["-y", "example-mcp-server@1.0.0"] } },
|
|
153
171
|
docsDir: fileURLToPath(new URL("..", import.meta.url)),
|
|
154
172
|
discovery: {
|
|
155
|
-
details: "`
|
|
173
|
+
details: "`query` searches the MCP Registry by server name; each candidate carries the exact definition to add.",
|
|
156
174
|
},
|
|
157
|
-
available:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
})),
|
|
162
|
-
discover: (query, identity, options) => this.#discover(query, identity, options),
|
|
163
|
-
admit: async (input, _identity, _caller, options) => this.#admit(input, options),
|
|
175
|
+
available: (identity) => this.#available(identity),
|
|
176
|
+
configurationNotices: ({ workspaceId }) => this.#configurationNotices.get(workspaceId) ?? [],
|
|
177
|
+
discover: (query) => this.#discover(query),
|
|
178
|
+
admit: (input) => this.#admit(input),
|
|
164
179
|
prepare: (preparation) => this.#prepare(preparation),
|
|
165
180
|
teardown: (snapshot, identity) => this.#teardown(snapshot, identity),
|
|
181
|
+
refreshIfChanged: (identity) => this.#refreshIfChanged(identity),
|
|
166
182
|
});
|
|
167
183
|
// Protocol continuations beneath the common grammar.
|
|
168
184
|
seam.registerModuleAction({
|
|
169
185
|
name: "workspace.mcp.oauth.complete",
|
|
170
186
|
scope: "workspace",
|
|
187
|
+
residency: "required",
|
|
171
188
|
inputSchema: actionInput({ alias: NONEMPTY_STRING, callbackUrl: NONEMPTY_STRING }, ["alias", "callbackUrl"]),
|
|
172
189
|
outputSchema: MUTATION_RESULT,
|
|
173
190
|
handler: (params, context) => this.#completeOAuth(workspaceIdentityOf(context), params),
|
|
@@ -175,6 +192,7 @@ class Module {
|
|
|
175
192
|
seam.registerModuleAction({
|
|
176
193
|
name: "workspace.mcp.complete",
|
|
177
194
|
scope: "workspace",
|
|
195
|
+
residency: "required",
|
|
178
196
|
inputSchema: actionInput({
|
|
179
197
|
server: NONEMPTY_STRING,
|
|
180
198
|
ref: OPEN_OBJECT,
|
|
@@ -185,6 +203,68 @@ class Module {
|
|
|
185
203
|
handler: (params, context) => this.#complete(workspaceIdentityOf(context).workspaceId, params),
|
|
186
204
|
});
|
|
187
205
|
}
|
|
206
|
+
async #available(identity) {
|
|
207
|
+
expandedServerNames(this.#env);
|
|
208
|
+
const { workspaceId } = identity;
|
|
209
|
+
const notices = [];
|
|
210
|
+
this.#configurationNotices.set(workspaceId, notices);
|
|
211
|
+
return configuredDefinitions(await this.#configurationDirectories(workspaceId), this.#env, {
|
|
212
|
+
...await this.#plugins(workspaceId), report: (notice) => notices.push(notice),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// {§functionality-hotload} — a server's definition is complete, so the coordinator's
|
|
216
|
+
// comparison of what it would prepare with what it published is the change test.
|
|
217
|
+
async #refreshIfChanged(identity) {
|
|
218
|
+
await this.#handleOrThrow().refresh(identity, { gate: "none", ifChanged: true });
|
|
219
|
+
}
|
|
220
|
+
// {§mcp-registry-discovery} Candidates are complete definitions, never installations.
|
|
221
|
+
async #discover(query) {
|
|
222
|
+
if (query.configuration !== undefined) {
|
|
223
|
+
throw actionError("configuration-unsupported", 400, "MCP discovery searches the MCP Registry by query; client configuration contributes nothing.", { retryable: false });
|
|
224
|
+
}
|
|
225
|
+
if (query.source !== undefined) {
|
|
226
|
+
throw actionError("source-unsupported", 400, "MCP discovery searches the MCP Registry by query; it does not install from a source URL.", {
|
|
227
|
+
source: query.source, retryable: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (query.query === undefined)
|
|
231
|
+
return [];
|
|
232
|
+
const { url, limit } = registrySettings(this.#env);
|
|
233
|
+
if (url === null) {
|
|
234
|
+
throw actionError("registry-not-configured", 501, "MCP registry search is off: the operator names no registry.", { query: query.query, retryable: false });
|
|
235
|
+
}
|
|
236
|
+
let servers;
|
|
237
|
+
try {
|
|
238
|
+
servers = await searchRegistry({ url, query: query.query, limit, timeoutMs: connectTimeoutMs(this.#env) });
|
|
239
|
+
}
|
|
240
|
+
catch (cause) {
|
|
241
|
+
throw actionError("discover-failed", 502, cause instanceof RegistryError ? cause.message : `The MCP Registry search for '${query.query}' failed.`, {
|
|
242
|
+
query: query.query, retryable: true,
|
|
243
|
+
}, cause);
|
|
244
|
+
}
|
|
245
|
+
return servers.flatMap(registryEntries).map((found) => ({
|
|
246
|
+
alias: found.alias,
|
|
247
|
+
summary: found.summary,
|
|
248
|
+
definition: readDefinition({ name: found.alias, ...structuredClone(found.entry) }),
|
|
249
|
+
provenance: { kind: "registry", source: url, reference: found.reference },
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
// {§mcp-server-definition} Live additions use the same schema as configured definitions.
|
|
253
|
+
async #admit(input) {
|
|
254
|
+
const params = objectOf(input) ?? {};
|
|
255
|
+
let definition;
|
|
256
|
+
try {
|
|
257
|
+
definition = readDefinition(params.definition);
|
|
258
|
+
}
|
|
259
|
+
catch (cause) {
|
|
260
|
+
throw actionError("definition-invalid", 400, "The MCP server definition is invalid.", { retryable: false }, cause);
|
|
261
|
+
}
|
|
262
|
+
const alias = typeof params.alias === "string" ? params.alias : definition.name;
|
|
263
|
+
if (alias !== definition.name) {
|
|
264
|
+
throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
|
|
265
|
+
}
|
|
266
|
+
return { alias, definition };
|
|
267
|
+
}
|
|
188
268
|
#assertOpen() {
|
|
189
269
|
if (this.#closed)
|
|
190
270
|
throw new Error("MCP module is closed.");
|
|
@@ -218,149 +298,56 @@ class Module {
|
|
|
218
298
|
if (errors.length > 0)
|
|
219
299
|
throw new AggregateError(errors, "MCP connection shutdown failed");
|
|
220
300
|
}
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
async #discover(query, identity, options = {}) {
|
|
301
|
+
// The attachment keeps the exact symbolic definition; resolved launch values never enter state.
|
|
302
|
+
async #prepareAttachment(workspaceId, binding, connection) {
|
|
303
|
+
const { definition, context } = binding;
|
|
225
304
|
this.#assertOpen();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let definitions;
|
|
229
|
-
try {
|
|
230
|
-
definitions = overlayServerDefinitions(structuredClone(query.configuration), this.#defaults);
|
|
231
|
-
}
|
|
232
|
-
catch (cause) {
|
|
233
|
-
throw actionError("configuration-invalid", 400, "The supplied MCP configuration is invalid.", { retryable: false }, cause);
|
|
234
|
-
}
|
|
235
|
-
for (const [name, definition] of definitions) {
|
|
236
|
-
candidates.push({
|
|
237
|
-
alias: name,
|
|
238
|
-
definition: _a.#launchOptions(definition, options),
|
|
239
|
-
provenance: { kind: "client-configuration", source: `PLURNK_MCP_${name.toUpperCase().replaceAll("-", "_")}` },
|
|
240
|
-
summary: `${definition.transport} ${definition.transport === "http" ? definition.url : definition.command}`,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (query.source !== undefined) {
|
|
245
|
-
// A URL is a Streamable HTTP target; anything else is a command line
|
|
246
|
-
// split on whitespace (exact paths with spaces are added, not probed).
|
|
247
|
-
const source = query.source;
|
|
248
|
-
const [command = "", ...args] = source.trim().split(/\s+/u);
|
|
249
|
-
const definition = _a.#launchOptions(/^https?:\/\//u.test(source)
|
|
250
|
-
? { name: "discovered", transport: "http", url: source }
|
|
251
|
-
: { name: "discovered", transport: "stdio", command, args }, options);
|
|
252
|
-
Validator.assertMcpServerDefinition(definition);
|
|
253
|
-
const environment = await this.#workspaceEnvironment(identity.workspaceId);
|
|
254
|
-
let directory;
|
|
255
|
-
const connection = new ServerConnection(definition, environment(this.#env), {
|
|
256
|
-
environment: environment(),
|
|
257
|
-
workingDirectory: async () => directory ??= await mkdtemp(join(await this.#workspaceDirectory(identity.workspaceId), "discover-")),
|
|
258
|
-
onCatalogChanged: () => undefined,
|
|
259
|
-
onInfrastructureError: () => undefined,
|
|
260
|
-
});
|
|
261
|
-
this.#connections.add(connection);
|
|
262
|
-
const executor = new McpExecutor({ runtime: "discovered", glyph: "🔌" }, connection, () => () => undefined);
|
|
263
|
-
let failure;
|
|
264
|
-
try {
|
|
265
|
-
await executor.requireAvailable();
|
|
266
|
-
const catalog = executor.catalog;
|
|
267
|
-
const name = catalog.server?.name?.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "discovered";
|
|
268
|
-
candidates.push({
|
|
269
|
-
alias: name,
|
|
270
|
-
definition: { ...definition, name },
|
|
271
|
-
provenance: { kind: "direct-target", source },
|
|
272
|
-
summary: serverSummary(name, catalog, undefined),
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
catch (cause) {
|
|
276
|
-
if (cause instanceof AuthorizationRequiredError) {
|
|
277
|
-
candidates.push({
|
|
278
|
-
alias: "discovered",
|
|
279
|
-
definition,
|
|
280
|
-
provenance: { kind: "direct-target", source },
|
|
281
|
-
summary: "The server requires authorization before it can be inspected; add it and complete the authorization.",
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
else {
|
|
285
|
-
failure = actionError("discover-failed", 502, `MCP target '${source}' could not be inspected.`, { source, retryable: true }, cause);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
try {
|
|
289
|
-
await this.#closeOwned([connection]);
|
|
290
|
-
if (directory !== undefined)
|
|
291
|
-
await rm(directory, { recursive: true, force: true });
|
|
292
|
-
}
|
|
293
|
-
catch (cause) {
|
|
294
|
-
if (failure !== undefined)
|
|
295
|
-
throw new AggregateError([failure, cause], "MCP discovery and cleanup failed.");
|
|
296
|
-
throw cause;
|
|
297
|
-
}
|
|
298
|
-
if (failure !== undefined)
|
|
299
|
-
throw failure;
|
|
300
|
-
}
|
|
301
|
-
if (query.query !== undefined && query.source === undefined && query.configuration === undefined) {
|
|
302
|
-
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 });
|
|
303
|
-
}
|
|
304
|
-
return candidates;
|
|
305
|
-
}
|
|
306
|
-
static #launchOptions(definition, options) {
|
|
307
|
-
if (options.env === undefined || Object.keys(options.env).length === 0)
|
|
308
|
-
return definition;
|
|
309
|
-
if (definition.transport !== "stdio")
|
|
310
|
-
throw actionError("env-transport", 400, "An HTTP MCP server has no local process environment.", { retryable: false });
|
|
311
|
-
return { ...definition, env: { ...definition.env, ...options.env } };
|
|
312
|
-
}
|
|
313
|
-
#admit(input, options = {}) {
|
|
314
|
-
const params = objectOf(input) ?? {};
|
|
315
|
-
let definition;
|
|
305
|
+
// {§mcp-server-settings} — the operator's settings for this alias; a bad one isolates this server.
|
|
306
|
+
let settings;
|
|
316
307
|
try {
|
|
317
|
-
|
|
308
|
+
settings = serverSettings(definition.name, this.#env);
|
|
318
309
|
}
|
|
319
310
|
catch (cause) {
|
|
320
|
-
throw actionError("
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (alias !== definition.name) {
|
|
324
|
-
throw actionError("alias-mismatch", 400, `Alias '${alias}' must equal the definition's name '${definition.name}'.`, { alias, name: definition.name, retryable: false });
|
|
311
|
+
throw actionError("server-settings-invalid", 422, `MCP server '${definition.name}' has invalid operator settings: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
312
|
+
server: definition.name, retryable: false,
|
|
313
|
+
}, cause);
|
|
325
314
|
}
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
async #prepareAttachment(workspaceId, definition, connection) {
|
|
329
|
-
this.#assertOpen();
|
|
330
|
-
const environment = await this.#workspaceEnvironment(workspaceId);
|
|
331
|
-
const candidate = connection ?? new ServerConnection(definition, environment(this.#env), {
|
|
332
|
-
environment: environment(),
|
|
333
|
-
workingDirectory: async () => {
|
|
334
|
-
const directory = join(await this.#workspaceDirectory(workspaceId), "servers", definition.name);
|
|
335
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
336
|
-
return directory;
|
|
337
|
-
},
|
|
338
|
-
onCatalogChanged: (error) => {
|
|
339
|
-
if (error !== null) {
|
|
340
|
-
console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (this.#closed)
|
|
344
|
-
return;
|
|
345
|
-
this.#dirty.set(this.#pendingKey(workspaceId, definition.name), Symbol());
|
|
346
|
-
this.#scheduleCatalogRefresh(workspaceId, definition.name);
|
|
347
|
-
},
|
|
348
|
-
onInfrastructureError: (error) => {
|
|
349
|
-
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
350
|
-
},
|
|
351
|
-
});
|
|
352
|
-
this.#connections.add(candidate);
|
|
353
|
-
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workspaceId), { tools: definition.tools ?? null, read: definition.read ?? [] }, this.#summaries.tools);
|
|
315
|
+
let candidate = connection;
|
|
354
316
|
try {
|
|
317
|
+
const environment = await this.#workspaceEnvironment(workspaceId);
|
|
318
|
+
this.#assertOpen();
|
|
319
|
+
// {§mcp-launch-environment} — a configured server inherits the operator's environment beneath the
|
|
320
|
+
// workspace layer, as every MCP client launches one; the model's command ceiling is not its base.
|
|
321
|
+
candidate ??= new ServerConnection(definition, environment(this.#env), {
|
|
322
|
+
...(definition.type === "stdio" && definition.cwd === undefined && context === undefined ? { cwd: await this.#stateDirectory(workspaceId, `${OWNER}/${definition.name}`) } : {}),
|
|
323
|
+
...(context === undefined ? {} : { plugin: context }),
|
|
324
|
+
environment: environment(this.#operatorEnvironment()),
|
|
325
|
+
onCatalogChanged: (error) => {
|
|
326
|
+
if (error !== null) {
|
|
327
|
+
console.error(`MCP server '${definition.name}' catalog refresh failed:`, error);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (this.#closed)
|
|
331
|
+
return;
|
|
332
|
+
this.#dirty.set(this.#pendingKey(workspaceId, definition.name), Symbol());
|
|
333
|
+
this.#scheduleCatalogRefresh(workspaceId, definition.name);
|
|
334
|
+
},
|
|
335
|
+
onInfrastructureError: (error) => {
|
|
336
|
+
console.error(`MCP server '${definition.name}' infrastructure failure:`, error);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
this.#connections.add(candidate);
|
|
340
|
+
const executor = new McpExecutor({ runtime: definition.name, glyph: "🔌" }, candidate, () => this.#retain(workspaceId), { tools: settings.tools });
|
|
355
341
|
const availability = await executor.requireAvailable();
|
|
356
342
|
return {
|
|
343
|
+
...(context === undefined ? {} : { context }),
|
|
357
344
|
kind: "active",
|
|
358
345
|
definition,
|
|
359
346
|
connection: candidate,
|
|
360
347
|
executor,
|
|
361
348
|
runtime: {
|
|
362
349
|
namespaceOwner: OWNER,
|
|
363
|
-
decl: runtimeDecl(definition.name, runtimeServerSummary(definition.name, executor.catalog, this.#
|
|
350
|
+
decl: runtimeDecl(definition.name, runtimeServerSummary(definition.name, executor.catalog), expandedServerNames(this.#env).includes(definition.name), executor.catalog.instructions),
|
|
364
351
|
executor,
|
|
365
352
|
availability,
|
|
366
353
|
scheme: new McpResources(definition.name, candidate, executor.catalog),
|
|
@@ -368,8 +355,9 @@ class Module {
|
|
|
368
355
|
};
|
|
369
356
|
}
|
|
370
357
|
catch (cause) {
|
|
371
|
-
if (cause instanceof AuthorizationRequiredError) {
|
|
358
|
+
if (cause instanceof AuthorizationRequiredError && candidate !== undefined) {
|
|
372
359
|
return {
|
|
360
|
+
...(context === undefined ? {} : { context }),
|
|
373
361
|
kind: "authorization-required",
|
|
374
362
|
definition,
|
|
375
363
|
connection: candidate,
|
|
@@ -377,7 +365,7 @@ class Module {
|
|
|
377
365
|
};
|
|
378
366
|
}
|
|
379
367
|
let closeCause;
|
|
380
|
-
if (connection === undefined) {
|
|
368
|
+
if (connection === undefined && candidate !== undefined) {
|
|
381
369
|
try {
|
|
382
370
|
await this.#closeOwned([candidate]);
|
|
383
371
|
}
|
|
@@ -385,7 +373,7 @@ class Module {
|
|
|
385
373
|
closeCause = error;
|
|
386
374
|
}
|
|
387
375
|
}
|
|
388
|
-
throw preparationError(definition, cause, closeCause);
|
|
376
|
+
throw preparationError(definition, definition.type === "streamable-http" ? definition.authorization : undefined, cause, closeCause);
|
|
389
377
|
}
|
|
390
378
|
}
|
|
391
379
|
// {§functionality-adapter} two-phase preparation: reuse unchanged live
|
|
@@ -400,7 +388,7 @@ class Module {
|
|
|
400
388
|
const previous = preparation.previous ?? new Map();
|
|
401
389
|
for (const [name, attachment] of previous) {
|
|
402
390
|
const nextDefinition = enabled.get(name);
|
|
403
|
-
if (nextDefinition === undefined || force === name || !
|
|
391
|
+
if (nextDefinition === undefined || force === name || !sameBinding(attachment, nextDefinition)) {
|
|
404
392
|
const activeRequests = attachmentConnection(attachment)?.activeRequests ?? 0;
|
|
405
393
|
if (activeRequests > 0)
|
|
406
394
|
throw actionError("server-busy", 409, `MCP server '${name}' has ${activeRequests} active request(s).`, { server: name, activeRequests, retryable: true });
|
|
@@ -413,15 +401,17 @@ class Module {
|
|
|
413
401
|
const refreshed = new Map();
|
|
414
402
|
try {
|
|
415
403
|
for (const [name, value] of enabled) {
|
|
404
|
+
preparation.progress(name);
|
|
416
405
|
const key = this.#pendingKey(workspaceId, name);
|
|
417
406
|
const invalidation = this.#dirty.get(key);
|
|
418
|
-
const
|
|
407
|
+
const binding = value;
|
|
408
|
+
const { definition } = binding;
|
|
419
409
|
const existing = previous.get(name);
|
|
420
410
|
const pending = this.#pending.get(this.#pendingKey(workspaceId, name));
|
|
421
411
|
if (existing !== undefined
|
|
422
412
|
&& force !== name
|
|
423
413
|
&& !this.#dirty.has(this.#pendingKey(workspaceId, name))
|
|
424
|
-
&&
|
|
414
|
+
&& sameBinding(existing, binding)
|
|
425
415
|
&& !(pending?.prepared !== undefined)) {
|
|
426
416
|
next.set(name, existing);
|
|
427
417
|
continue;
|
|
@@ -430,10 +420,10 @@ class Module {
|
|
|
430
420
|
const heldConnection = existing === undefined ? undefined : attachmentConnection(existing);
|
|
431
421
|
const catalogOnly = existing !== undefined
|
|
432
422
|
&& force !== name
|
|
433
|
-
&&
|
|
423
|
+
&& sameBinding(existing, binding)
|
|
434
424
|
&& !(pending?.prepared !== undefined)
|
|
435
425
|
&& heldConnection !== undefined;
|
|
436
|
-
if (pending?.prepared !== undefined &&
|
|
426
|
+
if (pending?.prepared !== undefined && sameBinding(pending, binding)) {
|
|
437
427
|
attachment = pending.prepared;
|
|
438
428
|
consumedPending.set(name, pending);
|
|
439
429
|
}
|
|
@@ -443,7 +433,7 @@ class Module {
|
|
|
443
433
|
// spawned, so neither abort nor commit has anything of this alias to close
|
|
444
434
|
// (#429).
|
|
445
435
|
try {
|
|
446
|
-
attachment = await this.#prepareAttachment(workspaceId,
|
|
436
|
+
attachment = await this.#prepareAttachment(workspaceId, binding, heldConnection);
|
|
447
437
|
}
|
|
448
438
|
catch (cause) {
|
|
449
439
|
this.#assertOpen();
|
|
@@ -453,14 +443,14 @@ class Module {
|
|
|
453
443
|
}
|
|
454
444
|
else {
|
|
455
445
|
try {
|
|
456
|
-
attachment = await this.#prepareAttachment(workspaceId,
|
|
446
|
+
attachment = await this.#prepareAttachment(workspaceId, binding);
|
|
457
447
|
}
|
|
458
448
|
catch (cause) {
|
|
459
449
|
this.#assertOpen();
|
|
460
450
|
if (failure === "reject")
|
|
461
451
|
throw cause;
|
|
462
|
-
const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, cause);
|
|
463
|
-
attachment = { kind: "unavailable",
|
|
452
|
+
const refusal = cause instanceof ModuleActionError ? cause : preparationError(definition, undefined, cause);
|
|
453
|
+
attachment = { ...binding, kind: "unavailable", problem: structuredClone(refusal.problem) };
|
|
464
454
|
console.error(`MCP server '${name}' unavailable: ${refusal.problem.detail}`, refusal.cause ?? refusal);
|
|
465
455
|
}
|
|
466
456
|
// Only a connection this attempt opened is the attempt's to close on abort.
|
|
@@ -473,7 +463,7 @@ class Module {
|
|
|
473
463
|
}
|
|
474
464
|
}
|
|
475
465
|
catch (cause) {
|
|
476
|
-
const cleanup = await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])));
|
|
466
|
+
const cleanup = [...await Promise.allSettled(fresh.map(({ connection }) => this.#closeOwned([connection])))];
|
|
477
467
|
const failures = cleanup.flatMap((result) => result.status === "rejected" ? errorsOf(result.reason) : []);
|
|
478
468
|
if (failures.length > 0)
|
|
479
469
|
throw new AggregateError([cause, ...failures], "MCP workspace preparation and cleanup failed.");
|
|
@@ -520,6 +510,18 @@ class Module {
|
|
|
520
510
|
this.#pending.delete(this.#pendingKey(workspaceId, name));
|
|
521
511
|
pending.releaseWorkspace();
|
|
522
512
|
}
|
|
513
|
+
// {§oauth-lifetime} Only the same pending connection can retain its authorization flow.
|
|
514
|
+
for (const [key, pending] of this.#pending) {
|
|
515
|
+
if (!key.startsWith(`${workspaceId}:`))
|
|
516
|
+
continue;
|
|
517
|
+
const current = next.get(key.slice(`${workspaceId}:`.length));
|
|
518
|
+
if (current?.kind === "authorization-required" && current.connection === pending.connection)
|
|
519
|
+
continue;
|
|
520
|
+
this.#pending.delete(key);
|
|
521
|
+
pending.releaseWorkspace();
|
|
522
|
+
if (!retained.has(pending.connection))
|
|
523
|
+
obsolete.push(pending.connection);
|
|
524
|
+
}
|
|
523
525
|
for (const [name, attachment] of next) {
|
|
524
526
|
if (attachment.kind !== "authorization-required")
|
|
525
527
|
continue;
|
|
@@ -529,6 +531,7 @@ class Module {
|
|
|
529
531
|
continue;
|
|
530
532
|
this.#pending.set(key, {
|
|
531
533
|
definition: attachment.definition,
|
|
534
|
+
...(attachment.context === undefined ? {} : { context: attachment.context }),
|
|
532
535
|
connection: attachment.connection,
|
|
533
536
|
authorizationUrl: attachment.authorizationUrl,
|
|
534
537
|
releaseWorkspace: this.#retain(workspaceId),
|
|
@@ -585,13 +588,13 @@ class Module {
|
|
|
585
588
|
throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workspaceId: identity.workspaceId, alias, retryable: false });
|
|
586
589
|
}
|
|
587
590
|
const current = this.#attachments.get(identity.workspaceId)?.get(alias);
|
|
588
|
-
if (current === undefined || current.kind !== "authorization-required" || !
|
|
591
|
+
if (current === undefined || current.kind !== "authorization-required" || !sameBinding(current, pending)) {
|
|
589
592
|
throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workspaceId: identity.workspaceId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
|
|
590
593
|
}
|
|
591
594
|
if (pending.prepared === undefined) {
|
|
592
595
|
try {
|
|
593
596
|
await pending.connection.finishAuthorization(callbackUrl);
|
|
594
|
-
const prepared = await this.#prepareAttachment(identity.workspaceId, pending
|
|
597
|
+
const prepared = await this.#prepareAttachment(identity.workspaceId, pending, pending.connection);
|
|
595
598
|
if (prepared.kind !== "active")
|
|
596
599
|
throw new Error("OAuth completion returned another authorization challenge.");
|
|
597
600
|
pending.prepared = prepared;
|
|
@@ -656,9 +659,11 @@ class Module {
|
|
|
656
659
|
clearTimeout(this.#refreshTimers.get(key));
|
|
657
660
|
this.#refreshTimers.delete(key);
|
|
658
661
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
+
stop() {
|
|
663
|
+
this.#stopping ??= this.#stop();
|
|
664
|
+
return this.#stopping;
|
|
665
|
+
}
|
|
666
|
+
async #stop() {
|
|
662
667
|
this.#closed = true;
|
|
663
668
|
for (const timer of this.#refreshTimers.values())
|
|
664
669
|
clearTimeout(timer);
|
|
@@ -674,6 +679,4 @@ class Module {
|
|
|
674
679
|
await closing;
|
|
675
680
|
}
|
|
676
681
|
}
|
|
677
|
-
_a = Module;
|
|
678
|
-
export default Module;
|
|
679
682
|
//# sourceMappingURL=Module.js.map
|