@alisio/core 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/application.d.ts +225 -0
- package/dist/application.js +950 -0
- package/dist/config.d.ts +481 -0
- package/dist/config.js +592 -0
- package/dist/core/compaction.d.ts +136 -0
- package/dist/core/compaction.js +404 -0
- package/dist/core/contracts.d.ts +147 -0
- package/dist/core/contracts.js +1 -0
- package/dist/core/registry.d.ts +8 -0
- package/dist/core/registry.js +54 -0
- package/dist/core/runner.d.ts +160 -0
- package/dist/core/runner.js +625 -0
- package/dist/extensions/registry.d.ts +28 -0
- package/dist/extensions/registry.js +58 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +28 -0
- package/dist/integrations/herdr.d.ts +34 -0
- package/dist/integrations/herdr.js +174 -0
- package/dist/mcp/connector.d.ts +65 -0
- package/dist/mcp/connector.js +423 -0
- package/dist/plugins/host.d.ts +169 -0
- package/dist/plugins/host.js +585 -0
- package/dist/plugins/install.d.ts +100 -0
- package/dist/plugins/install.js +322 -0
- package/dist/providers/openai-compatible.d.ts +14 -0
- package/dist/providers/openai-compatible.js +228 -0
- package/dist/providers/registry.d.ts +35 -0
- package/dist/providers/registry.js +74 -0
- package/dist/providers/routing.d.ts +11 -0
- package/dist/providers/routing.js +41 -0
- package/dist/providers/settings.d.ts +30 -0
- package/dist/providers/settings.js +80 -0
- package/dist/resources/context.d.ts +44 -0
- package/dist/resources/context.js +171 -0
- package/dist/resources/prompts.d.ts +77 -0
- package/dist/resources/prompts.js +182 -0
- package/dist/resources/skills.d.ts +82 -0
- package/dist/resources/skills.js +214 -0
- package/dist/runtime/fs.d.ts +9 -0
- package/dist/runtime/fs.js +73 -0
- package/dist/runtime/modules.d.ts +16 -0
- package/dist/runtime/modules.js +83 -0
- package/dist/runtime/paths.d.ts +4 -0
- package/dist/runtime/paths.js +42 -0
- package/dist/runtime/process.d.ts +25 -0
- package/dist/runtime/process.js +122 -0
- package/dist/runtime/sqlite.d.ts +3 -0
- package/dist/runtime/sqlite.js +85 -0
- package/dist/runtime/store.d.ts +38 -0
- package/dist/runtime/store.js +259 -0
- package/dist/sessions/children.d.ts +55 -0
- package/dist/sessions/children.js +233 -0
- package/dist/startup/index.d.ts +41 -0
- package/dist/startup/index.js +105 -0
- package/dist/startup/mascot.d.ts +6 -0
- package/dist/startup/mascot.js +30 -0
- package/dist/startup/screen.d.ts +18 -0
- package/dist/startup/screen.js +117 -0
- package/dist/startup/text.d.ts +19 -0
- package/dist/startup/text.js +68 -0
- package/dist/tools/execute.d.ts +16 -0
- package/dist/tools/execute.js +96 -0
- package/dist/tools/search.d.ts +69 -0
- package/dist/tools/search.js +232 -0
- package/dist/tools/standard.d.ts +22 -0
- package/dist/tools/standard.js +419 -0
- package/dist/tools/webfetch.d.ts +26 -0
- package/dist/tools/webfetch.js +119 -0
- package/dist/trust.d.ts +34 -0
- package/dist/trust.js +113 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +26 -0
- package/package.json +62 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { textResult } from "@alisio/sdk";
|
|
3
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
5
|
+
import { objectSchema } from "../tools/standard.js";
|
|
6
|
+
import { loadVersion } from "../version.js";
|
|
7
|
+
const TOOL_NAME_LIMIT = 64;
|
|
8
|
+
/**
|
|
9
|
+
* Per-server client close cap used ONLY by the terminal `close()`: the MCP SDK's stdio close can
|
|
10
|
+
* take ~4s worst case (stdin end → 2s → SIGTERM → 2s → SIGKILL) when a server ignores
|
|
11
|
+
* termination, and exit must never wait for that ladder. Runtime `disconnect()` keeps its
|
|
12
|
+
* unbounded semantics so interactive reconnect flows stay unchanged.
|
|
13
|
+
*/
|
|
14
|
+
const CLOSE_SERVER_TIMEOUT_MS = 800;
|
|
15
|
+
/** Overall cap for `close()` (every connected server in parallel plus bridge unregistration). */
|
|
16
|
+
const CLOSE_TOTAL_TIMEOUT_MS = 1_500;
|
|
17
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
const semanticSegment = (value, fallback) => value
|
|
19
|
+
.normalize("NFKD")
|
|
20
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
23
|
+
.replace(/^_+|_+$/g, "") || fallback;
|
|
24
|
+
const shortHash = (server, tool) => createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);
|
|
25
|
+
const withHash = (base, server, tool) => {
|
|
26
|
+
const suffix = `_${shortHash(server, tool)}`;
|
|
27
|
+
const stem = base.slice(0, TOOL_NAME_LIMIT - suffix.length).replace(/_+$/g, "");
|
|
28
|
+
return `${stem || "mcp_tool"}${suffix}`;
|
|
29
|
+
};
|
|
30
|
+
/** Deterministic provider-safe names while retaining the exact source mapping in the connector. */
|
|
31
|
+
export function semanticMcpToolNames(server, tools, configuredServers, unavailable = new Set()) {
|
|
32
|
+
if (new Set(tools).size !== tools.length)
|
|
33
|
+
throw new Error(`MCP server "${server}" returned duplicate tool names`);
|
|
34
|
+
const serverPart = semanticSegment(server, "server");
|
|
35
|
+
const serverCollision = configuredServers.filter((candidate) => semanticSegment(candidate, "server") === serverPart)
|
|
36
|
+
.length > 1;
|
|
37
|
+
const toolParts = tools.map((tool) => semanticSegment(tool, "tool"));
|
|
38
|
+
const collisions = new Set(toolParts.filter((part, index) => toolParts.indexOf(part) !== index));
|
|
39
|
+
return tools.map((tool, index) => {
|
|
40
|
+
const base = `mcp_${serverPart}_${toolParts[index]}`;
|
|
41
|
+
const needsHash = base.length > TOOL_NAME_LIMIT ||
|
|
42
|
+
serverCollision ||
|
|
43
|
+
collisions.has(toolParts[index]) ||
|
|
44
|
+
unavailable.has(base);
|
|
45
|
+
const result = needsHash ? withHash(base, server, tool) : base;
|
|
46
|
+
if (unavailable.has(result))
|
|
47
|
+
throw new Error(`MCP tool name collision could not be resolved for ${server}/${tool}`);
|
|
48
|
+
return result;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const readable = (value, limit = 160) => value
|
|
52
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim()
|
|
55
|
+
.slice(0, limit);
|
|
56
|
+
const safeFailure = (error) => {
|
|
57
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
58
|
+
if (message.includes("timeout") || message.includes("timed out"))
|
|
59
|
+
return "Connection timed out";
|
|
60
|
+
if (message.includes("abort"))
|
|
61
|
+
return "Connection cancelled";
|
|
62
|
+
if (message.includes("auth") || message.includes("token") || message.includes("401"))
|
|
63
|
+
return "Authentication is required or was rejected";
|
|
64
|
+
if (message.includes("enoent"))
|
|
65
|
+
return "Server command could not be started";
|
|
66
|
+
return "Connection failed; inspect the server separately for details";
|
|
67
|
+
};
|
|
68
|
+
export class McpConnector {
|
|
69
|
+
servers;
|
|
70
|
+
registry;
|
|
71
|
+
workspace;
|
|
72
|
+
availability;
|
|
73
|
+
sources;
|
|
74
|
+
clients = new Map();
|
|
75
|
+
statuses = new Map();
|
|
76
|
+
diagnostics = new Map();
|
|
77
|
+
catalogs = new Map();
|
|
78
|
+
details = new Map();
|
|
79
|
+
bridgeUndo = [];
|
|
80
|
+
constructor(servers, registry, workspace, availability = "allowed", sources = {}) {
|
|
81
|
+
this.servers = servers;
|
|
82
|
+
this.registry = registry;
|
|
83
|
+
this.workspace = workspace;
|
|
84
|
+
this.availability = availability;
|
|
85
|
+
this.sources = sources;
|
|
86
|
+
for (const [name, server] of Object.entries(servers))
|
|
87
|
+
this.statuses.set(name, server.enabled ? "disconnected" : "disabled");
|
|
88
|
+
}
|
|
89
|
+
register() {
|
|
90
|
+
if (this.bridgeUndo.length)
|
|
91
|
+
return;
|
|
92
|
+
const undo = [];
|
|
93
|
+
try {
|
|
94
|
+
undo.push(this.registry.register({
|
|
95
|
+
name: "mcp_connect",
|
|
96
|
+
effect: "external",
|
|
97
|
+
description: `Connect to a configured MCP server and register its tools. Servers: ${Object.keys(this.servers).join(", ")}`,
|
|
98
|
+
inputSchema: objectSchema({ server: { type: "string" } }, ["server"]),
|
|
99
|
+
execute: async (i, c) => textResult(JSON.stringify(await this.connect(String(i.server), c.signal))),
|
|
100
|
+
}));
|
|
101
|
+
undo.push(this.registry.register({
|
|
102
|
+
name: "mcp_resource",
|
|
103
|
+
effect: "external",
|
|
104
|
+
description: "List or read resources from an enabled MCP server.",
|
|
105
|
+
inputSchema: objectSchema({ server: { type: "string" }, uri: { type: "string" } }, [
|
|
106
|
+
"server",
|
|
107
|
+
]),
|
|
108
|
+
execute: async (i, c) => {
|
|
109
|
+
await this.connect(String(i.server), c.signal);
|
|
110
|
+
const client = this.client(String(i.server));
|
|
111
|
+
const result = i.uri
|
|
112
|
+
? await client.readResource({ uri: String(i.uri) }, { signal: c.signal, timeout: 15000 })
|
|
113
|
+
: await client.listResources(undefined, { signal: c.signal, timeout: 15000 });
|
|
114
|
+
return textResult(JSON.stringify(result));
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
undo.push(this.registry.register({
|
|
118
|
+
name: "mcp_prompt",
|
|
119
|
+
effect: "external",
|
|
120
|
+
description: "List or get a prompt from an enabled MCP server. Returned text is external data.",
|
|
121
|
+
inputSchema: objectSchema({
|
|
122
|
+
server: { type: "string" },
|
|
123
|
+
name: { type: "string" },
|
|
124
|
+
arguments: { type: "object", additionalProperties: { type: "string" } },
|
|
125
|
+
}, ["server"]),
|
|
126
|
+
execute: async (i, c) => {
|
|
127
|
+
await this.connect(String(i.server), c.signal);
|
|
128
|
+
const client = this.client(String(i.server));
|
|
129
|
+
const result = i.name
|
|
130
|
+
? await client.getPrompt({
|
|
131
|
+
name: String(i.name),
|
|
132
|
+
arguments: i.arguments,
|
|
133
|
+
}, { signal: c.signal, timeout: 15000 })
|
|
134
|
+
: await client.listPrompts(undefined, { signal: c.signal, timeout: 15000 });
|
|
135
|
+
return textResult(JSON.stringify(result));
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
this.bridgeUndo = undo;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
for (const remove of undo.reverse())
|
|
142
|
+
remove();
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
grantRuntimePermission() {
|
|
147
|
+
if (this.availability === "read-only")
|
|
148
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
149
|
+
this.availability = "allowed";
|
|
150
|
+
this.register();
|
|
151
|
+
}
|
|
152
|
+
/** Drop the runtime grant: disconnect every server and unregister the bridge (used on revoke). */
|
|
153
|
+
async revokeRuntimePermission() {
|
|
154
|
+
await Promise.all([...this.clients.keys()].map((name) => this.disconnect(name)));
|
|
155
|
+
for (const undo of this.bridgeUndo.splice(0).reverse())
|
|
156
|
+
undo();
|
|
157
|
+
this.availability = "disabled";
|
|
158
|
+
}
|
|
159
|
+
runtimePermission() {
|
|
160
|
+
return this.availability === "allowed"
|
|
161
|
+
? "granted"
|
|
162
|
+
: this.availability === "read-only"
|
|
163
|
+
? "read-only"
|
|
164
|
+
: "not-granted";
|
|
165
|
+
}
|
|
166
|
+
assertAvailable() {
|
|
167
|
+
if (this.availability === "read-only")
|
|
168
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
169
|
+
if (this.availability === "disabled")
|
|
170
|
+
throw new Error("MCP is configured but disabled; restart with --allow-mcp");
|
|
171
|
+
}
|
|
172
|
+
client(name) {
|
|
173
|
+
const item = this.clients.get(name);
|
|
174
|
+
if (!item)
|
|
175
|
+
throw new Error("MCP server disconnected; reconnect explicitly");
|
|
176
|
+
return item.client;
|
|
177
|
+
}
|
|
178
|
+
list() {
|
|
179
|
+
return Object.keys(this.servers)
|
|
180
|
+
.map((name) => this.info(name))
|
|
181
|
+
.sort((a, b) => a.source.kind.localeCompare(b.source.kind) || a.name.localeCompare(b.name));
|
|
182
|
+
}
|
|
183
|
+
info(name) {
|
|
184
|
+
const config = this.servers[name];
|
|
185
|
+
if (!config)
|
|
186
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
187
|
+
const detail = this.details.get(name);
|
|
188
|
+
return {
|
|
189
|
+
name,
|
|
190
|
+
displayName: detail?.displayName ?? name,
|
|
191
|
+
source: this.sources[name] ?? { kind: "builtin", form: "canonical" },
|
|
192
|
+
status: this.statuses.get(name) ?? (config.enabled ? "disconnected" : "disabled"),
|
|
193
|
+
enabled: config.enabled,
|
|
194
|
+
runtimePermission: this.runtimePermission(),
|
|
195
|
+
transport: config.transport,
|
|
196
|
+
...(config.transport === "stdio"
|
|
197
|
+
? { command: config.command, args: [...config.args] }
|
|
198
|
+
: { url: config.url }),
|
|
199
|
+
capabilities: [...(detail?.capabilities ?? [])],
|
|
200
|
+
counts: {
|
|
201
|
+
tools: this.catalogs.get(name)?.length ?? 0,
|
|
202
|
+
resources: detail?.resources ?? 0,
|
|
203
|
+
prompts: detail?.prompts ?? 0,
|
|
204
|
+
},
|
|
205
|
+
...(this.diagnostics.has(name) ? { diagnostic: this.diagnostics.get(name) } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
tools(name) {
|
|
209
|
+
this.info(name);
|
|
210
|
+
return (this.catalogs.get(name) ?? []).map((tool) => ({
|
|
211
|
+
...tool,
|
|
212
|
+
...(tool.annotations ? { annotations: { ...tool.annotations } } : {}),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
async connect(name, signal) {
|
|
216
|
+
this.assertAvailable();
|
|
217
|
+
const config = this.servers[name];
|
|
218
|
+
if (!config)
|
|
219
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
220
|
+
if (!config.enabled)
|
|
221
|
+
throw new Error(`MCP server "${name}" is disabled; enable it in /mcp`);
|
|
222
|
+
if (this.clients.has(name))
|
|
223
|
+
return this.registeredNames(name);
|
|
224
|
+
if (config.transport === "http" &&
|
|
225
|
+
config.bearerTokenEnv &&
|
|
226
|
+
!process.env[config.bearerTokenEnv]) {
|
|
227
|
+
this.statuses.set(name, "needs-authentication");
|
|
228
|
+
this.diagnostics.set(name, `Set the ${config.bearerTokenEnv} environment variable, then reconnect`);
|
|
229
|
+
throw new Error(`MCP server "${name}" needs authentication; set ${config.bearerTokenEnv}`);
|
|
230
|
+
}
|
|
231
|
+
this.statuses.set(name, "connecting");
|
|
232
|
+
this.diagnostics.delete(name);
|
|
233
|
+
const client = new Client({ name: "alisio", version: loadVersion(import.meta.url) }, { capabilities: {} });
|
|
234
|
+
const undo = [];
|
|
235
|
+
const generation = Symbol(name);
|
|
236
|
+
const transport = config.transport === "stdio"
|
|
237
|
+
? new StdioClientTransport((() => {
|
|
238
|
+
const env = Object.fromEntries(["PATH", "HOME", "SystemRoot", ...config.envAllow].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
239
|
+
Object.assign(env, config.env);
|
|
240
|
+
return {
|
|
241
|
+
command: config.command,
|
|
242
|
+
args: config.args,
|
|
243
|
+
cwd: this.workspace,
|
|
244
|
+
env,
|
|
245
|
+
stderr: "pipe",
|
|
246
|
+
maxBufferSize: 1_048_576,
|
|
247
|
+
};
|
|
248
|
+
})())
|
|
249
|
+
: new StreamableHTTPClientTransport(new URL(config.url), {
|
|
250
|
+
reconnectionOptions: {
|
|
251
|
+
maxRetries: 0,
|
|
252
|
+
maxReconnectionDelay: 1000,
|
|
253
|
+
initialReconnectionDelay: 100,
|
|
254
|
+
reconnectionDelayGrowFactor: 1,
|
|
255
|
+
},
|
|
256
|
+
...(config.bearerTokenEnv
|
|
257
|
+
? {
|
|
258
|
+
authProvider: {
|
|
259
|
+
token: async () => process.env[config.bearerTokenEnv],
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
await client.connect(transport, { signal, timeout: 15000 });
|
|
266
|
+
if (transport instanceof StdioClientTransport)
|
|
267
|
+
transport.stderr?.on("data", () => { });
|
|
268
|
+
const serverCapabilities = client.getServerCapabilities() ?? {};
|
|
269
|
+
const capabilities = Object.keys(serverCapabilities).sort();
|
|
270
|
+
const tools = serverCapabilities.tools
|
|
271
|
+
? (await client.listTools(undefined, { signal, timeout: 15000 })).tools
|
|
272
|
+
: [];
|
|
273
|
+
if (tools.length > 100)
|
|
274
|
+
throw new Error("MCP server exposes more than 100 tools; narrow its configuration");
|
|
275
|
+
const resources = serverCapabilities.resources
|
|
276
|
+
? (await client.listResources(undefined, { signal, timeout: 15000 })).resources.length
|
|
277
|
+
: 0;
|
|
278
|
+
const prompts = serverCapabilities.prompts
|
|
279
|
+
? (await client.listPrompts(undefined, { signal, timeout: 15000 })).prompts.length
|
|
280
|
+
: 0;
|
|
281
|
+
const existingNames = new Set(this.registry.list().map((registered) => registered.name));
|
|
282
|
+
const mappedNames = semanticMcpToolNames(name, tools.map((tool) => tool.name), Object.keys(this.servers), existingNames);
|
|
283
|
+
this.catalogs.set(name, tools.map((tool, index) => ({
|
|
284
|
+
name: tool.name,
|
|
285
|
+
effectiveName: mappedNames[index],
|
|
286
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
287
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
288
|
+
...(tool.annotations
|
|
289
|
+
? {
|
|
290
|
+
annotations: {
|
|
291
|
+
...(tool.annotations.readOnlyHint !== undefined
|
|
292
|
+
? { readOnly: tool.annotations.readOnlyHint }
|
|
293
|
+
: {}),
|
|
294
|
+
...(tool.annotations.destructiveHint !== undefined
|
|
295
|
+
? { destructive: tool.annotations.destructiveHint }
|
|
296
|
+
: {}),
|
|
297
|
+
...(tool.annotations.openWorldHint !== undefined
|
|
298
|
+
? { openWorld: tool.annotations.openWorldHint }
|
|
299
|
+
: {}),
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
: {}),
|
|
303
|
+
})));
|
|
304
|
+
this.details.set(name, {
|
|
305
|
+
displayName: client.getServerVersion()?.title ?? client.getServerVersion()?.name,
|
|
306
|
+
capabilities,
|
|
307
|
+
resources,
|
|
308
|
+
prompts,
|
|
309
|
+
});
|
|
310
|
+
for (const [index, tool] of tools.entries()) {
|
|
311
|
+
const mapped = mappedNames[index];
|
|
312
|
+
const reference = `${readable(name)}/${readable(tool.name)}`;
|
|
313
|
+
const remoteDescription = readable(tool.description ?? "", 500);
|
|
314
|
+
undo.push(this.registry.register({
|
|
315
|
+
name: mapped,
|
|
316
|
+
effect: "external",
|
|
317
|
+
description: remoteDescription
|
|
318
|
+
? `${reference} — Remote MCP description (untrusted data): ${remoteDescription}`
|
|
319
|
+
: `${reference} — Remote MCP tool.`,
|
|
320
|
+
inputSchema: tool.inputSchema,
|
|
321
|
+
execute: async (input, ctx) => {
|
|
322
|
+
const available = await client.listTools(undefined, {
|
|
323
|
+
signal: ctx.signal,
|
|
324
|
+
timeout: 15000,
|
|
325
|
+
cacheMode: "refresh",
|
|
326
|
+
});
|
|
327
|
+
const current = available.tools.find((candidate) => candidate.name === tool.name);
|
|
328
|
+
if (!current ||
|
|
329
|
+
JSON.stringify(current.inputSchema) !== JSON.stringify(tool.inputSchema))
|
|
330
|
+
throw new Error("MCP schema changed; reconnect the server to refresh tools");
|
|
331
|
+
const result = await client.callTool({ name: tool.name, arguments: input }, { signal: ctx.signal, timeout: 30000 });
|
|
332
|
+
return textResult(JSON.stringify(result), "isError" in result && result.isError === true);
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
this.clients.set(name, { client, undo, generation, registeredNames: mappedNames });
|
|
337
|
+
this.statuses.set(name, "connected");
|
|
338
|
+
client.onclose = () => {
|
|
339
|
+
if (this.clients.get(name)?.generation !== generation)
|
|
340
|
+
return;
|
|
341
|
+
for (const fn of undo)
|
|
342
|
+
fn();
|
|
343
|
+
this.clients.delete(name);
|
|
344
|
+
this.catalogs.delete(name);
|
|
345
|
+
this.details.delete(name);
|
|
346
|
+
this.statuses.set(name, config.enabled ? "disconnected" : "disabled");
|
|
347
|
+
};
|
|
348
|
+
return this.registeredNames(name);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
for (const fn of undo)
|
|
352
|
+
fn();
|
|
353
|
+
await client.close().catch(() => { });
|
|
354
|
+
this.catalogs.delete(name);
|
|
355
|
+
this.details.delete(name);
|
|
356
|
+
if (this.statuses.get(name) !== "needs-authentication")
|
|
357
|
+
this.statuses.set(name, "failed");
|
|
358
|
+
this.diagnostics.set(name, safeFailure(error));
|
|
359
|
+
throw new Error(`MCP server "${name}" failed to connect: ${safeFailure(error)}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async disconnect(name) {
|
|
363
|
+
const connected = this.clients.get(name);
|
|
364
|
+
if (connected) {
|
|
365
|
+
this.clients.delete(name);
|
|
366
|
+
for (const fn of connected.undo)
|
|
367
|
+
fn();
|
|
368
|
+
await connected.client.close().catch(() => { });
|
|
369
|
+
}
|
|
370
|
+
this.catalogs.delete(name);
|
|
371
|
+
this.details.delete(name);
|
|
372
|
+
this.diagnostics.delete(name);
|
|
373
|
+
const server = this.servers[name];
|
|
374
|
+
if (!server)
|
|
375
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
376
|
+
this.statuses.set(name, server.enabled ? "disconnected" : "disabled");
|
|
377
|
+
}
|
|
378
|
+
async reconnect(name, signal) {
|
|
379
|
+
this.assertAvailable();
|
|
380
|
+
await this.disconnect(name);
|
|
381
|
+
return this.connect(name, signal);
|
|
382
|
+
}
|
|
383
|
+
async setEnabled(name, enabled, signal) {
|
|
384
|
+
const server = this.servers[name];
|
|
385
|
+
if (!server)
|
|
386
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
387
|
+
server.enabled = enabled;
|
|
388
|
+
if (!enabled)
|
|
389
|
+
return this.disconnect(name);
|
|
390
|
+
this.statuses.set(name, "disconnected");
|
|
391
|
+
if (signal)
|
|
392
|
+
await this.connect(name, signal);
|
|
393
|
+
}
|
|
394
|
+
registeredNames(name) {
|
|
395
|
+
return [...(this.clients.get(name)?.registeredNames ?? [])];
|
|
396
|
+
}
|
|
397
|
+
async close() {
|
|
398
|
+
const servers = [...this.clients.keys()];
|
|
399
|
+
// Exit latency is user-facing: every server is disconnected in parallel, each client close
|
|
400
|
+
// raced against a short cap, and the whole phase against an overall cap. An abandoned close
|
|
401
|
+
// keeps running in the background and the SDK still SIGKILLs an unresponsive server; the
|
|
402
|
+
// registry cleanup runs synchronously inside `disconnect`, so tools are always unregistered.
|
|
403
|
+
await Promise.race([
|
|
404
|
+
(async () => {
|
|
405
|
+
await Promise.all(servers.map((name) => Promise.race([this.disconnect(name), delay(CLOSE_SERVER_TIMEOUT_MS)])));
|
|
406
|
+
for (const undo of this.bridgeUndo.splice(0).reverse())
|
|
407
|
+
undo();
|
|
408
|
+
})(),
|
|
409
|
+
delay(CLOSE_TOTAL_TIMEOUT_MS),
|
|
410
|
+
]);
|
|
411
|
+
// A raced `disconnect` may have been abandoned before its final status cleanup (it flips
|
|
412
|
+
// the status only after `client.close()` resolves): the client entry is already gone, so
|
|
413
|
+
// settle the status here for exit without touching runtime disconnect semantics.
|
|
414
|
+
for (const name of servers) {
|
|
415
|
+
if (this.clients.has(name) || this.statuses.get(name) !== "connected")
|
|
416
|
+
continue;
|
|
417
|
+
const server = this.servers[name];
|
|
418
|
+
if (!server)
|
|
419
|
+
continue;
|
|
420
|
+
this.statuses.set(name, server.enabled ? "disconnected" : "disabled");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { AskQuestionsRequest, AskQuestionsResult, CommandContext, CommandOptions, CompletionRequest, Message, PanelProvider, Plugin, PluginAPI, PluginMetadata, RunEvent, SelectRequest } from "@alisio/sdk";
|
|
2
|
+
import type { HookFailure, RunnerExtensions } from "../core/contracts.ts";
|
|
3
|
+
import type { ToolRegistry } from "../core/registry.ts";
|
|
4
|
+
import { ExtensionRegistry } from "../extensions/registry.ts";
|
|
5
|
+
import type { ProviderRegistry } from "../providers/registry.ts";
|
|
6
|
+
export declare const pluginPrefix: (id: string) => string;
|
|
7
|
+
export interface PluginHostOptions {
|
|
8
|
+
/** Timeout for compaction and session-start hooks. */
|
|
9
|
+
hookTimeoutMs?: number;
|
|
10
|
+
/** Timeout for session-end hooks (for example an automatic summary). */
|
|
11
|
+
sessionEndTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class PluginHost implements RunnerExtensions {
|
|
14
|
+
private registry;
|
|
15
|
+
private state;
|
|
16
|
+
private options;
|
|
17
|
+
private providers?;
|
|
18
|
+
commands: Map<string, (args: string, context?: CommandContext) => Promise<string>>;
|
|
19
|
+
commandInfo: Map<string, CommandOptions & {
|
|
20
|
+
plugin: string;
|
|
21
|
+
builtin: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
/** Ids of active built-in plugins. */
|
|
24
|
+
builtins: Set<string>;
|
|
25
|
+
/** Status entries contributed through `ui.status`, keyed `plugin:key`. */
|
|
26
|
+
status: Map<string, {
|
|
27
|
+
plugin: string;
|
|
28
|
+
text: string;
|
|
29
|
+
detail?: string;
|
|
30
|
+
}>;
|
|
31
|
+
onStatusChange?: () => void;
|
|
32
|
+
/** Providers for typed extension points (mascot, startup-screen, ...). */
|
|
33
|
+
readonly extensions: ExtensionRegistry;
|
|
34
|
+
/** Loaded plugins that did not come from the built-in registry. */
|
|
35
|
+
externalCount: number;
|
|
36
|
+
private compactionHooks;
|
|
37
|
+
private startHandlers;
|
|
38
|
+
private endHandlers;
|
|
39
|
+
private fieldOwners;
|
|
40
|
+
private completer?;
|
|
41
|
+
private modelsImpl?;
|
|
42
|
+
observers: Set<(event: Readonly<RunEvent>) => void>;
|
|
43
|
+
contexts: Array<() => Promise<string>>;
|
|
44
|
+
/** Skill directories registered by plugins, retaining trusted registration ownership. */
|
|
45
|
+
skillSources: Array<{
|
|
46
|
+
plugin: string;
|
|
47
|
+
name: string;
|
|
48
|
+
dir: string;
|
|
49
|
+
}>;
|
|
50
|
+
/** Prompt template directories registered by plugins, with their plugin id. */
|
|
51
|
+
promptSources: Array<{
|
|
52
|
+
plugin: string;
|
|
53
|
+
dir: string;
|
|
54
|
+
}>;
|
|
55
|
+
/** Agent definition directories registered by plugins, with their plugin id. */
|
|
56
|
+
agentSources: Array<{
|
|
57
|
+
plugin: string;
|
|
58
|
+
dir: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Tree panels contributed by plugins, keyed `plugin:id`. */
|
|
61
|
+
panels: Map<string, {
|
|
62
|
+
plugin: string;
|
|
63
|
+
provider: PanelProvider;
|
|
64
|
+
}>;
|
|
65
|
+
private sessionsImpl?;
|
|
66
|
+
private uiImpl?;
|
|
67
|
+
/** Binds the child session service once the runner exists. */
|
|
68
|
+
setSessions(sessions: PluginAPI["sessions"]): void;
|
|
69
|
+
/** Binds interactive UI services (the TUI); without them select/askQuestions resolve undefined. */
|
|
70
|
+
setInteractiveUI(ui: {
|
|
71
|
+
select(request: SelectRequest): Promise<string | undefined>;
|
|
72
|
+
askQuestions(request: AskQuestionsRequest): Promise<AskQuestionsResult>;
|
|
73
|
+
open(sessionId: string): boolean;
|
|
74
|
+
}): void;
|
|
75
|
+
get sessions(): PluginAPI["sessions"];
|
|
76
|
+
/**
|
|
77
|
+
* Live accessor for core standard tools (e.g. `ask_user_question`), which are not "a plugin"
|
|
78
|
+
* with an id and so cannot use the per-plugin `ui` wrapper built in `activate()`. Bound lazily
|
|
79
|
+
* (reads `this.uiImpl` at call time) since `registerStandard` runs before the TUI calls
|
|
80
|
+
* `setInteractiveUI`. `interactive()` reflects whether ANY interactive UI is bound at all,
|
|
81
|
+
* never which session is asking, so child sessions under an interactive root TUI pass too.
|
|
82
|
+
*/
|
|
83
|
+
get ui(): {
|
|
84
|
+
interactive(): boolean;
|
|
85
|
+
askQuestions(request: AskQuestionsRequest): Promise<AskQuestionsResult>;
|
|
86
|
+
};
|
|
87
|
+
get promptRoots(): string[];
|
|
88
|
+
get skillRoots(): string[];
|
|
89
|
+
private loaded;
|
|
90
|
+
constructor(registry: ToolRegistry, state: {
|
|
91
|
+
getState(plugin: string, key: string): unknown;
|
|
92
|
+
setState(plugin: string, key: string, value: unknown): void;
|
|
93
|
+
}, options?: PluginHostOptions, providers?: ProviderRegistry | undefined);
|
|
94
|
+
/**
|
|
95
|
+
* Live-update host-enforced hook timeouts so the NEXT hook call honors them without restarting.
|
|
96
|
+
* Keeps the same options object reference the constructors of new hooks read at call time.
|
|
97
|
+
*/
|
|
98
|
+
applyTimeoutSettings(patch: Partial<PluginHostOptions>): void;
|
|
99
|
+
/** Binds the provider-agnostic completion service once a provider exists. */
|
|
100
|
+
setCompleter(fn: (request: CompletionRequest & {
|
|
101
|
+
signal: AbortSignal;
|
|
102
|
+
}) => Promise<string>): void;
|
|
103
|
+
/** Binds credential-free model discovery/resolution after provider startup is complete. */
|
|
104
|
+
setModels(models: PluginAPI["models"]): void;
|
|
105
|
+
/**
|
|
106
|
+
* Loads an explicitly trusted plugin: a file/directory path, or an npm package name resolved
|
|
107
|
+
* from `from` (project) upwards and then global roots. Never called for untrusted input.
|
|
108
|
+
*/
|
|
109
|
+
load(spec: string, options?: {
|
|
110
|
+
from?: string;
|
|
111
|
+
globalRoots?: string[];
|
|
112
|
+
}): Promise<void>;
|
|
113
|
+
/** Imports and validates a trusted plugin without running setup, for cataloging disabled entries. */
|
|
114
|
+
inspect(spec: string, options?: {
|
|
115
|
+
from?: string;
|
|
116
|
+
globalRoots?: string[];
|
|
117
|
+
}): Promise<{
|
|
118
|
+
plugin: Plugin;
|
|
119
|
+
base: string;
|
|
120
|
+
}>;
|
|
121
|
+
private validate;
|
|
122
|
+
/**
|
|
123
|
+
* `builtin` is reserved for first-party plugins from the built-in registry: their tools and
|
|
124
|
+
* commands keep unprefixed names and may declare the `internal` effect.
|
|
125
|
+
*/
|
|
126
|
+
activate(plugin: Plugin, base: string, options?: {
|
|
127
|
+
builtin?: boolean;
|
|
128
|
+
}): Promise<void>;
|
|
129
|
+
/** Runs one hook with a host-enforced timeout; the hook receives an abort signal. */
|
|
130
|
+
private guarded;
|
|
131
|
+
beforeCompact(input: Parameters<RunnerExtensions["beforeCompact"]>[0]): Promise<{
|
|
132
|
+
instructions: string[];
|
|
133
|
+
fields: Record<string, {
|
|
134
|
+
source: string;
|
|
135
|
+
description: string;
|
|
136
|
+
}>;
|
|
137
|
+
failures: HookFailure[];
|
|
138
|
+
}>;
|
|
139
|
+
afterCompact(input: Parameters<RunnerExtensions["afterCompact"]>[0]): Promise<{
|
|
140
|
+
inject: string[];
|
|
141
|
+
reports: Record<string, Record<string, unknown>>;
|
|
142
|
+
failures: HookFailure[];
|
|
143
|
+
}>;
|
|
144
|
+
sessionStart(input: Parameters<RunnerExtensions["sessionStart"]>[0]): Promise<{
|
|
145
|
+
inject: {
|
|
146
|
+
source: string;
|
|
147
|
+
text: string;
|
|
148
|
+
}[];
|
|
149
|
+
failures: HookFailure[];
|
|
150
|
+
}>;
|
|
151
|
+
/** Session end hooks run concurrently and are bounded by one host timeout. */
|
|
152
|
+
sessionEnd(input: {
|
|
153
|
+
sessionId: string;
|
|
154
|
+
model: string;
|
|
155
|
+
workspace: string;
|
|
156
|
+
reason: "clear" | "exit";
|
|
157
|
+
messages: readonly Message[];
|
|
158
|
+
}): Promise<{
|
|
159
|
+
failures: HookFailure[];
|
|
160
|
+
}>;
|
|
161
|
+
/** Loaded plugins (id, version, built-in flag), ordered by id. */
|
|
162
|
+
metadata(): PluginMetadata[];
|
|
163
|
+
get hasSessionEndHooks(): boolean;
|
|
164
|
+
/** Conservative ownership check used to protect a live session from plugin removal. */
|
|
165
|
+
ownsSessionResources(plugin: string): boolean;
|
|
166
|
+
emit(event: RunEvent): void;
|
|
167
|
+
close(): Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
export declare function discoverPlugins(root: string): Promise<string[]>;
|