ai-runtime-engine 1.3.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +578 -0
- package/dist/agents/admit.d.ts +69 -0
- package/dist/agents/admit.js +129 -0
- package/dist/agents/definition.d.ts +36 -0
- package/dist/agents/definition.js +9 -0
- package/dist/agents/envelope.d.ts +53 -0
- package/dist/agents/envelope.js +68 -0
- package/dist/agents/finding.d.ts +79 -0
- package/dist/agents/finding.js +80 -0
- package/dist/agents/task.d.ts +60 -0
- package/dist/agents/task.js +32 -0
- package/dist/agents/worker.d.ts +68 -0
- package/dist/agents/worker.js +256 -0
- package/dist/capabilities/capability.d.ts +117 -0
- package/dist/capabilities/capability.js +66 -0
- package/dist/capabilities/registry.d.ts +139 -0
- package/dist/capabilities/registry.js +413 -0
- package/dist/capabilities/vocabulary.d.ts +32 -0
- package/dist/capabilities/vocabulary.js +34 -0
- package/dist/cli/cli.js +55 -4
- package/dist/cli/commands/cleanup.js +29 -27
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +38 -8
- package/dist/cli/commands/executions.js +34 -25
- package/dist/cli/commands/info.d.ts +1 -0
- package/dist/cli/commands/info.js +11 -9
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/inspect.d.ts +40 -1
- package/dist/cli/commands/inspect.js +157 -2
- package/dist/cli/commands/mcp.d.ts +45 -0
- package/dist/cli/commands/mcp.js +148 -0
- package/dist/cli/commands/route.js +21 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/skills.d.ts +2 -0
- package/dist/cli/commands/skills.js +29 -7
- package/dist/cli/interactive/ansi.d.ts +41 -0
- package/dist/cli/interactive/ansi.js +43 -0
- package/dist/cli/interactive/complete.d.ts +10 -0
- package/dist/cli/interactive/complete.js +19 -0
- package/dist/cli/interactive/repl.d.ts +3 -0
- package/dist/cli/interactive/repl.js +91 -13
- package/dist/cli/interactive/session.d.ts +8 -0
- package/dist/cli/interactive/session.js +73 -2
- package/dist/cli/render.d.ts +7 -0
- package/dist/cli/render.js +10 -0
- package/dist/cli/runtimeSession.d.ts +11 -0
- package/dist/cli/runtimeSession.js +17 -0
- package/dist/config/defaults.d.ts +3 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +2 -2
- package/dist/context/lossVerifier.d.ts +24 -0
- package/dist/context/lossVerifier.js +45 -0
- package/dist/context/summarize.d.ts +19 -0
- package/dist/context/summarize.js +53 -0
- package/dist/core/fallback/fallback.d.ts +5 -0
- package/dist/core/fallback/fallback.js +3 -1
- package/dist/core/router/router.d.ts +3 -0
- package/dist/core/router/router.js +1 -0
- package/dist/executions/execution.d.ts +13 -2
- package/dist/generation/generateAdapter.d.ts +14 -0
- package/dist/generation/generateAdapter.js +38 -0
- package/dist/generation/generateSkill.d.ts +26 -0
- package/dist/generation/generateSkill.js +51 -0
- package/dist/index.d.ts +43 -4
- package/dist/index.js +26 -2
- package/dist/mcp/client.d.ts +70 -0
- package/dist/mcp/client.js +221 -0
- package/dist/mcp/manager.d.ts +151 -0
- package/dist/mcp/manager.js +493 -0
- package/dist/mcp/protocol.d.ts +216 -0
- package/dist/mcp/protocol.js +149 -0
- package/dist/mcp/toolAdapter.d.ts +44 -0
- package/dist/mcp/toolAdapter.js +94 -0
- package/dist/mcp/transport.d.ts +109 -0
- package/dist/mcp/transport.js +383 -0
- package/dist/memory/embedders/hash.d.ts +12 -0
- package/dist/memory/embedders/hash.js +31 -0
- package/dist/memory/embedders/http.d.ts +25 -0
- package/dist/memory/embedders/http.js +48 -0
- package/dist/memory/memory.d.ts +19 -2
- package/dist/memory/memory.js +75 -11
- package/dist/memory/semantic.d.ts +17 -0
- package/dist/memory/semantic.js +29 -0
- package/dist/orchestration/budget.d.ts +30 -0
- package/dist/orchestration/budget.js +40 -0
- package/dist/orchestration/executor.d.ts +39 -1
- package/dist/orchestration/executor.js +64 -4
- package/dist/orchestration/orchestrator.d.ts +29 -1
- package/dist/orchestration/orchestrator.js +89 -8
- package/dist/orchestration/plan.d.ts +15 -1
- package/dist/orchestration/plan.js +23 -4
- package/dist/orchestration/planner.d.ts +19 -1
- package/dist/orchestration/planner.js +25 -5
- package/dist/plugin/ai.d.ts +4 -0
- package/dist/plugin/ai.js +9 -0
- package/dist/runtime/config.js +50 -6
- package/dist/runtime/intent/aiClassifier.d.ts +19 -0
- package/dist/runtime/intent/aiClassifier.js +74 -0
- package/dist/runtime/models/modelProfile.d.ts +61 -0
- package/dist/runtime/models/modelProfile.js +139 -0
- package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
- package/dist/runtime/planning/deriveCapabilities.js +146 -0
- package/dist/runtime/policy.d.ts +10 -0
- package/dist/runtime/policy.js +9 -2
- package/dist/runtime/runtime.d.ts +173 -0
- package/dist/runtime/runtime.js +705 -50
- package/dist/runtime/types.d.ts +88 -2
- package/dist/skills/manifest.d.ts +3 -0
- package/dist/skills/manifest.js +24 -0
- package/dist/skills/registry.d.ts +16 -1
- package/dist/skills/registry.js +21 -1
- package/dist/skills/skill.d.ts +6 -1
- package/dist/store/area.d.ts +15 -1
- package/dist/store/area.js +19 -8
- package/dist/store/crypto.d.ts +21 -0
- package/dist/store/crypto.js +49 -0
- package/dist/store/paths.d.ts +5 -1
- package/dist/store/paths.js +6 -0
- package/dist/store/store.d.ts +15 -3
- package/dist/store/store.js +28 -7
- package/dist/telemetry/sinks/otlp.d.ts +31 -0
- package/dist/telemetry/sinks/otlp.js +76 -0
- package/dist/tools/builtins/filesystem.js +1 -0
- package/dist/tools/builtins/git.js +1 -0
- package/dist/tools/builtins/shell.js +1 -0
- package/dist/tools/permissions.d.ts +28 -0
- package/dist/tools/permissions.js +72 -0
- package/dist/tools/registry.d.ts +18 -2
- package/dist/tools/registry.js +22 -2
- package/dist/tools/tool.d.ts +4 -0
- package/dist/types.d.ts +5 -1
- package/dist/util/flatten.d.ts +11 -0
- package/dist/util/flatten.js +18 -0
- package/dist/util/semaphore.d.ts +19 -0
- package/dist/util/semaphore.js +60 -0
- package/package.json +24 -9
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server lifecycle (Phase 3.2) — connect, discover, cache, enable/disable/remove, lazy reconnect.
|
|
3
|
+
*
|
|
4
|
+
* State machine: `configured` → `connecting` → { `connected` | `auth_failed` | `unreachable` }; a failed
|
|
5
|
+
* call marks `degraded` (the next use retries with capped backoff); the operator can `disabled` any
|
|
6
|
+
* server. There is NO background daemon or keepalive — reconnection is lazy, on next use, matching this
|
|
7
|
+
* codebase's no-scheduler style.
|
|
8
|
+
*
|
|
9
|
+
* SECURITY:
|
|
10
|
+
* - Credentials are resolved from an env-var NAME through `Credential` (which `registerSecret`s the
|
|
11
|
+
* value so every egress is scrubbed) and are handed straight to the transport. The value is never
|
|
12
|
+
* stored on the manager, never in `McpServerStatus`, and never persisted.
|
|
13
|
+
* - Every persisted file is re-validated with strict zod ON READ: a tampered or corrupt store is
|
|
14
|
+
* discarded, not trusted (a checksum only proves integrity, not provenance).
|
|
15
|
+
* - Approval/permission decisions use LIVE handshake annotations only; the tool cache exists to make
|
|
16
|
+
* the catalog available offline and never to lower a gate.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { AIError } from '../core/fallback/errors.js';
|
|
20
|
+
import { redactString } from '../security/redact.js';
|
|
21
|
+
import { Credential } from '../security/credentials.js';
|
|
22
|
+
import { systemClock } from '../util/clock.js';
|
|
23
|
+
import { McpClient } from './client.js';
|
|
24
|
+
import { HttpTransport, StdioTransport } from './transport.js';
|
|
25
|
+
import { clampText, normalizeToolDecl } from './protocol.js';
|
|
26
|
+
import { KEY_LIKE } from '../config/schema.js';
|
|
27
|
+
/** A server id: the same shape the config schema enforces, so both origins are equally constrained. */
|
|
28
|
+
export const MCP_SERVER_ID_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/;
|
|
29
|
+
/**
|
|
30
|
+
* Persisted, secret-free records — strictly re-validated on read, and to the SAME standard as the config
|
|
31
|
+
* file. A weaker store schema would be a privilege gap: a forged `servers` file could otherwise name a
|
|
32
|
+
* credential env var and an arbitrary URL, and the manager would dutifully send the token there.
|
|
33
|
+
*/
|
|
34
|
+
const persistedServer = z
|
|
35
|
+
.object({
|
|
36
|
+
transport: z.enum(['stdio', 'http']),
|
|
37
|
+
url: z.string().url().optional(),
|
|
38
|
+
command: z.string().optional(),
|
|
39
|
+
args: z.array(z.string()).optional(),
|
|
40
|
+
tokenEnv: z
|
|
41
|
+
.string()
|
|
42
|
+
.refine((v) => !KEY_LIKE.test(v), { message: 'tokenEnv must be an env-var NAME, not a token value' })
|
|
43
|
+
.optional(),
|
|
44
|
+
enabled: z.boolean().optional(),
|
|
45
|
+
timeoutMs: z.number().optional(),
|
|
46
|
+
capabilities: z.record(z.string(), z.array(z.string())).optional(),
|
|
47
|
+
})
|
|
48
|
+
.strict()
|
|
49
|
+
.refine((sv) => (sv.transport === 'stdio' ? Boolean(sv.command) && !sv.url : Boolean(sv.url) && !sv.command), {
|
|
50
|
+
message: "transport 'stdio' requires command (and no url); transport 'http' requires url (and no command)",
|
|
51
|
+
});
|
|
52
|
+
const persistedServers = z.record(z.string().regex(MCP_SERVER_ID_RE), persistedServer);
|
|
53
|
+
const persistedState = z.record(z.string().regex(MCP_SERVER_ID_RE), z.object({ enabled: z.boolean().optional() }).strict());
|
|
54
|
+
const cachedTools = z
|
|
55
|
+
.object({
|
|
56
|
+
at: z.number(),
|
|
57
|
+
tools: z.array(z.unknown()),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
const BACKOFF_MS = [0, 1_000, 5_000, 30_000];
|
|
61
|
+
/** A displayable, credential-free rendering of where a server points (see `McpServerStatus.endpoint`). */
|
|
62
|
+
function safeEndpoint(cfg) {
|
|
63
|
+
if (cfg.transport === 'http') {
|
|
64
|
+
if (!cfg.url)
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
return new URL(cfg.url).origin;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined; // an unparseable url is never echoed back
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return cfg.command ? cfg.command.split(/[\\/]/).pop() : undefined;
|
|
74
|
+
}
|
|
75
|
+
export class McpManager {
|
|
76
|
+
opts;
|
|
77
|
+
entries = new Map();
|
|
78
|
+
env;
|
|
79
|
+
clock;
|
|
80
|
+
warnings = [];
|
|
81
|
+
/** Explicit operator enable/disable decisions — the only thing `state` persists (see writeState). */
|
|
82
|
+
overrides = new Map();
|
|
83
|
+
/** In-flight connect per server, so concurrent lazy callers share one handshake (never two children). */
|
|
84
|
+
connecting = new Map();
|
|
85
|
+
constructor(opts = {}) {
|
|
86
|
+
this.opts = opts;
|
|
87
|
+
this.env = opts.env ?? process.env;
|
|
88
|
+
this.clock = opts.clock ?? systemClock;
|
|
89
|
+
// Config-declared servers first, then dynamic ones from the store (config wins on id collision).
|
|
90
|
+
for (const [id, cfg] of Object.entries(opts.servers ?? {})) {
|
|
91
|
+
this.entries.set(id, { id, cfg, origin: 'config', state: cfg.enabled === false ? 'disabled' : 'configured', tools: [], rejected: 0, truncated: false, failures: 0 });
|
|
92
|
+
}
|
|
93
|
+
for (const [id, cfg] of Object.entries(this.readDynamic())) {
|
|
94
|
+
if (this.entries.has(id)) {
|
|
95
|
+
this.warnings.push(`MCP server '${id}' is declared in config; the stored copy was ignored`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
this.entries.set(id, { id, cfg, origin: 'dynamic', state: cfg.enabled === false ? 'disabled' : 'configured', tools: [], rejected: 0, truncated: false, failures: 0 });
|
|
99
|
+
}
|
|
100
|
+
// Operator enable/disable overrides apply to both origins.
|
|
101
|
+
for (const [id, st] of Object.entries(this.readState())) {
|
|
102
|
+
if (st.enabled === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
this.overrides.set(id, st.enabled);
|
|
105
|
+
const e = this.entries.get(id);
|
|
106
|
+
if (!e)
|
|
107
|
+
continue;
|
|
108
|
+
if (st.enabled === false)
|
|
109
|
+
e.state = 'disabled';
|
|
110
|
+
else if (e.state === 'disabled')
|
|
111
|
+
e.state = 'configured';
|
|
112
|
+
}
|
|
113
|
+
// Seed the cached catalog so `capabilities`/planning work before (or without) a connection.
|
|
114
|
+
for (const e of this.entries.values()) {
|
|
115
|
+
if (e.state === 'disabled')
|
|
116
|
+
continue;
|
|
117
|
+
const cached = this.readCache(e.id);
|
|
118
|
+
if (cached)
|
|
119
|
+
e.tools = cached;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
warningsList() {
|
|
123
|
+
return [...this.warnings];
|
|
124
|
+
}
|
|
125
|
+
// ── store IO (every read strictly validated; invalid ⇒ discarded) ──────────
|
|
126
|
+
readDynamic() {
|
|
127
|
+
const raw = this.opts.store?.tryReadJson('servers');
|
|
128
|
+
if (raw === undefined)
|
|
129
|
+
return {};
|
|
130
|
+
const parsed = persistedServers.safeParse(raw);
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
this.warnings.push('stored MCP servers file was invalid and has been ignored');
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
return parsed.data;
|
|
136
|
+
}
|
|
137
|
+
readState() {
|
|
138
|
+
const raw = this.opts.store?.tryReadJson('state');
|
|
139
|
+
if (raw === undefined)
|
|
140
|
+
return {};
|
|
141
|
+
const parsed = persistedState.safeParse(raw);
|
|
142
|
+
if (!parsed.success) {
|
|
143
|
+
this.warnings.push('stored MCP state file was invalid and has been ignored');
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
return parsed.data;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Read a cached catalog. Every entry goes back through `normalizeToolDecl` — the SAME discovery gate a
|
|
150
|
+
* live declaration passes — because the file is untrusted input like any other: shape validation alone
|
|
151
|
+
* would let a tampered cache reintroduce an unsafe name or unclamped prompt text. Cached annotations
|
|
152
|
+
* are then forced to their fail-closed values: a stale `readOnlyHint` must not lower the permission
|
|
153
|
+
* gate, and a stale `destructiveHint: false` must not REMOVE confirmation. Only a live handshake can
|
|
154
|
+
* relax either.
|
|
155
|
+
*/
|
|
156
|
+
readCache(id) {
|
|
157
|
+
const raw = this.opts.store?.tryReadJson(`tools-${id}`);
|
|
158
|
+
if (raw === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
const parsed = cachedTools.safeParse(raw);
|
|
161
|
+
if (!parsed.success) {
|
|
162
|
+
this.warnings.push(`cached tool list for '${id}' was invalid and has been ignored`);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const out = [];
|
|
166
|
+
let rejected = 0;
|
|
167
|
+
for (const decl of parsed.data.tools) {
|
|
168
|
+
const norm = normalizeToolDecl(decl);
|
|
169
|
+
if (!norm) {
|
|
170
|
+
rejected += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
out.push({ ...norm, readOnly: false, destructive: true });
|
|
174
|
+
}
|
|
175
|
+
if (rejected)
|
|
176
|
+
this.warnings.push(`${rejected} cached tool declaration(s) for '${id}' failed validation and were dropped`);
|
|
177
|
+
return out.length ? out : undefined;
|
|
178
|
+
}
|
|
179
|
+
writeDynamic() {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const e of this.entries.values())
|
|
182
|
+
if (e.origin === 'dynamic')
|
|
183
|
+
out[e.id] = e.cfg;
|
|
184
|
+
this.opts.store?.writeJson('servers', out);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Persist only servers the operator has EXPLICITLY toggled. Snapshotting every entry would turn an
|
|
188
|
+
* unrelated `mcp add` into a stored `enabled: true` for every server — which then wins over a later
|
|
189
|
+
* `enabled: false` added to the config file, silently re-enabling something the operator turned off.
|
|
190
|
+
*/
|
|
191
|
+
writeState() {
|
|
192
|
+
const out = {};
|
|
193
|
+
for (const [id, enabled] of this.overrides)
|
|
194
|
+
out[id] = { enabled };
|
|
195
|
+
this.opts.store?.writeJson('state', out);
|
|
196
|
+
}
|
|
197
|
+
// ── connection ─────────────────────────────────────────────────────────────
|
|
198
|
+
buildTransport(e) {
|
|
199
|
+
if (this.opts.transportFactory)
|
|
200
|
+
return this.opts.transportFactory(e.id, e.cfg);
|
|
201
|
+
const cred = e.cfg.tokenEnv ? new Credential(e.cfg.tokenEnv, this.env) : undefined;
|
|
202
|
+
if (e.cfg.transport === 'http') {
|
|
203
|
+
if (!e.cfg.url)
|
|
204
|
+
throw new AIError(`MCP server '${e.id}' has transport http but no url`, { category: 'CONFIG' });
|
|
205
|
+
const token = cred?.use();
|
|
206
|
+
return new HttpTransport({ url: e.cfg.url, ...(token ? { token } : {}), ...(this.opts.fetchImpl ? { fetchImpl: this.opts.fetchImpl } : {}), ...(e.cfg.timeoutMs ? { timeoutMs: e.cfg.timeoutMs } : {}) });
|
|
207
|
+
}
|
|
208
|
+
if (!e.cfg.command)
|
|
209
|
+
throw new AIError(`MCP server '${e.id}' has transport stdio but no command`, { category: 'CONFIG' });
|
|
210
|
+
const token = cred?.use();
|
|
211
|
+
return new StdioTransport({
|
|
212
|
+
command: e.cfg.command,
|
|
213
|
+
...(e.cfg.args ? { args: e.cfg.args } : {}),
|
|
214
|
+
...(e.cfg.tokenEnv && token ? { tokenEnvName: e.cfg.tokenEnv, tokenValue: token } : {}),
|
|
215
|
+
env: this.env,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Connect + handshake + discover one server. Never throws — the state carries the outcome. */
|
|
219
|
+
async connect(e) {
|
|
220
|
+
if (e.state === 'disabled')
|
|
221
|
+
return;
|
|
222
|
+
e.state = 'connecting';
|
|
223
|
+
// Declared OUTSIDE the try: a handshake that throws must still be closed, or a spawned stdio child
|
|
224
|
+
// keeps running (and holding its transport) for the life of the process.
|
|
225
|
+
let client;
|
|
226
|
+
// Whatever the entry already holds is superseded by this attempt — close it, or `mcp test` on a
|
|
227
|
+
// connected stdio server abandons a live child every time.
|
|
228
|
+
const previous = e.client;
|
|
229
|
+
delete e.client;
|
|
230
|
+
if (previous)
|
|
231
|
+
await previous.close().catch(() => { });
|
|
232
|
+
try {
|
|
233
|
+
const transport = this.buildTransport(e);
|
|
234
|
+
client = new McpClient(transport, { serverId: e.id, clock: this.clock, timeoutMs: e.cfg.timeoutMs ?? this.opts.connectTimeoutMs ?? 30_000 });
|
|
235
|
+
// Held even on failure so `mcp <id>` can still show WHY (the stderr ring lives on the transport).
|
|
236
|
+
e.lastDiagnostics = () => client?.diagnostics() ?? [];
|
|
237
|
+
const hs = await client.initialize();
|
|
238
|
+
const list = await client.listTools();
|
|
239
|
+
e.client = client;
|
|
240
|
+
e.tools = list.tools;
|
|
241
|
+
e.rejected = list.rejected;
|
|
242
|
+
e.truncated = list.truncated;
|
|
243
|
+
e.protocolVersion = hs.protocolVersion;
|
|
244
|
+
if (hs.serverName)
|
|
245
|
+
e.serverName = hs.serverName;
|
|
246
|
+
e.state = hs.versionMismatch ? 'degraded' : 'connected';
|
|
247
|
+
if (hs.versionMismatch)
|
|
248
|
+
e.lastError = clampText(`server speaks protocol ${hs.protocolVersion}; this client speaks a different version`, 200);
|
|
249
|
+
else
|
|
250
|
+
delete e.lastError;
|
|
251
|
+
e.failures = 0;
|
|
252
|
+
e.lastCheckedAt = this.clock.now();
|
|
253
|
+
// Caching is a CONVENIENCE. A failed write (read-only home, full disk, bad id) must not be
|
|
254
|
+
// reported as an unreachable server and must not tear down a working connection.
|
|
255
|
+
try {
|
|
256
|
+
this.opts.store?.writeJson(`tools-${e.id}`, { at: this.clock.now(), tools: e.tools });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
this.warnings.push(`could not cache the tool list for '${e.id}' (the connection is unaffected)`);
|
|
260
|
+
}
|
|
261
|
+
this.opts.onToolsDiscovered?.(e.id, e.tools);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
e.failures += 1;
|
|
265
|
+
e.lastCheckedAt = this.clock.now();
|
|
266
|
+
const category = err instanceof AIError ? err.category : 'UNKNOWN';
|
|
267
|
+
e.state = category === 'AUTHENTICATION' ? 'auth_failed' : 'unreachable';
|
|
268
|
+
e.lastError = clampText(redactString(err instanceof Error ? err.message : String(err)), 200);
|
|
269
|
+
await client?.close().catch(() => { }); // closes the half-open connection whether or not it was adopted
|
|
270
|
+
delete e.client;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Connect every enabled server in parallel. Resilient: a failure is recorded, never thrown. */
|
|
274
|
+
async connectAll() {
|
|
275
|
+
await Promise.all([...this.entries.values()]
|
|
276
|
+
.filter((e) => e.state !== 'disabled')
|
|
277
|
+
.map((e) => {
|
|
278
|
+
const inFlight = this.connecting.get(e.id);
|
|
279
|
+
if (inFlight)
|
|
280
|
+
return inFlight;
|
|
281
|
+
const attempt = this.connect(e).finally(() => this.connecting.delete(e.id));
|
|
282
|
+
this.connecting.set(e.id, attempt);
|
|
283
|
+
return attempt;
|
|
284
|
+
}));
|
|
285
|
+
return this.list();
|
|
286
|
+
}
|
|
287
|
+
/** Lazily (re)connect on use, honoring a capped backoff after repeated failures. */
|
|
288
|
+
async ensureConnected(id) {
|
|
289
|
+
const e = this.entries.get(id);
|
|
290
|
+
if (!e)
|
|
291
|
+
throw new AIError(`no MCP server '${id}'`, { category: 'CONFIG' });
|
|
292
|
+
if (e.state === 'disabled')
|
|
293
|
+
throw new AIError(`MCP server '${id}' is disabled`, { category: 'CONFIG' });
|
|
294
|
+
if (e.client && (e.state === 'connected' || e.state === 'degraded'))
|
|
295
|
+
return e.client;
|
|
296
|
+
const waited = this.clock.now() - (e.lastCheckedAt ?? 0);
|
|
297
|
+
const backoff = BACKOFF_MS[Math.min(e.failures, BACKOFF_MS.length - 1)];
|
|
298
|
+
if (e.failures > 0 && waited < backoff) {
|
|
299
|
+
throw new AIError(`MCP server '${id}' is ${e.state}${e.lastError ? ` (${e.lastError})` : ''}`, { category: 'NETWORK', retryable: true });
|
|
300
|
+
}
|
|
301
|
+
// Concurrent callers (a parallel wave of tool steps) must share ONE handshake: two overlapping
|
|
302
|
+
// connects would spawn two children and abandon whichever lost the assignment race.
|
|
303
|
+
const inFlight = this.connecting.get(id);
|
|
304
|
+
if (inFlight)
|
|
305
|
+
await inFlight;
|
|
306
|
+
else {
|
|
307
|
+
const attempt = this.connect(e).finally(() => this.connecting.delete(id));
|
|
308
|
+
this.connecting.set(id, attempt);
|
|
309
|
+
await attempt;
|
|
310
|
+
}
|
|
311
|
+
if (!e.client)
|
|
312
|
+
throw new AIError(`MCP server '${id}' is ${e.state}${e.lastError ? ` (${e.lastError})` : ''}`, { category: e.state === 'auth_failed' ? 'AUTHENTICATION' : 'NETWORK', retryable: e.state !== 'auth_failed' });
|
|
313
|
+
return e.client;
|
|
314
|
+
}
|
|
315
|
+
/** Whether a server can be called right now (the adapter's honest availability check). */
|
|
316
|
+
usable(id) {
|
|
317
|
+
const e = this.entries.get(id);
|
|
318
|
+
if (!e || e.state === 'disabled')
|
|
319
|
+
return false;
|
|
320
|
+
return e.state !== 'auth_failed';
|
|
321
|
+
}
|
|
322
|
+
async call(id, tool, args, opts = {}) {
|
|
323
|
+
const client = await this.ensureConnected(id);
|
|
324
|
+
try {
|
|
325
|
+
return await client.callTool(tool, args, opts);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
const e = this.entries.get(id);
|
|
329
|
+
// A well-formed JSON-RPC error (bad params, unknown method) or a caller's own cancellation says
|
|
330
|
+
// nothing about the CONNECTION — tearing it down would kill and respawn the child on every such
|
|
331
|
+
// call, and reset the failure counter each time so the capped backoff never engaged.
|
|
332
|
+
const category = err instanceof AIError ? err.category : 'UNKNOWN';
|
|
333
|
+
const connectionFailed = category === 'NETWORK' || (category === 'TIMEOUT' && !opts.signal?.aborted);
|
|
334
|
+
// A dropped HTTP session is only recoverable by a fresh handshake, so it forces the client out
|
|
335
|
+
// from ANY state — including `degraded`, where the state guard below would otherwise keep reusing
|
|
336
|
+
// a session the server has already forgotten, forever.
|
|
337
|
+
if (e && e.client?.sessionExpired()) {
|
|
338
|
+
e.state = 'degraded';
|
|
339
|
+
e.lastError = 'the server invalidated our session; reconnecting on next use';
|
|
340
|
+
const stale = e.client;
|
|
341
|
+
delete e.client;
|
|
342
|
+
await stale.close().catch(() => { });
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
if (e && connectionFailed && e.state === 'connected') {
|
|
346
|
+
e.state = 'degraded'; // a broken transport schedules a lazy reconnect on next use
|
|
347
|
+
e.failures += 1; // count it, so repeated breakage actually engages the backoff
|
|
348
|
+
e.lastCheckedAt = this.clock.now();
|
|
349
|
+
e.lastError = clampText(redactString(err instanceof Error ? err.message : String(err)), 200);
|
|
350
|
+
await e.client?.close().catch(() => { });
|
|
351
|
+
delete e.client;
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** The LIVE annotations for a tool (approval/permission input — never the cache). */
|
|
357
|
+
liveTool(id, tool) {
|
|
358
|
+
const e = this.entries.get(id);
|
|
359
|
+
if (!e || (e.state !== 'connected' && e.state !== 'degraded'))
|
|
360
|
+
return undefined;
|
|
361
|
+
return e.tools.find((t) => t.name === tool);
|
|
362
|
+
}
|
|
363
|
+
tools(id) {
|
|
364
|
+
return [...(this.entries.get(id)?.tools ?? [])];
|
|
365
|
+
}
|
|
366
|
+
// ── operator actions ───────────────────────────────────────────────────────
|
|
367
|
+
/** Add a dynamic server (persisted to the STORE — the runtime never edits config.yaml). */
|
|
368
|
+
async addServer(id, cfg) {
|
|
369
|
+
// The config path enforces this shape; the dynamic path must too. A dotted id (`demo.evil`) would
|
|
370
|
+
// otherwise generate tool ids inside ANOTHER server's namespace and mis-scope deregistration, and a
|
|
371
|
+
// store-illegal id would throw from deep inside the store on write.
|
|
372
|
+
if (!MCP_SERVER_ID_RE.test(id))
|
|
373
|
+
throw new AIError(`invalid MCP server id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
|
|
374
|
+
if (cfg.tokenEnv && KEY_LIKE.test(cfg.tokenEnv))
|
|
375
|
+
throw new AIError('tokenEnv must be an env-var NAME, not a token value', { category: 'CONFIG' });
|
|
376
|
+
if (cfg.transport === 'http' ? !cfg.url || cfg.command : !cfg.command || cfg.url) {
|
|
377
|
+
throw new AIError("transport 'stdio' requires command (and no url); transport 'http' requires url (and no command)", { category: 'CONFIG' });
|
|
378
|
+
}
|
|
379
|
+
if (this.entries.has(id))
|
|
380
|
+
throw new AIError(`MCP server '${id}' already exists`, { category: 'CONFIG' });
|
|
381
|
+
const e = { id, cfg, origin: 'dynamic', state: 'configured', tools: [], rejected: 0, truncated: false, failures: 0 };
|
|
382
|
+
this.entries.set(id, e);
|
|
383
|
+
this.writeDynamic();
|
|
384
|
+
await this.connect(e);
|
|
385
|
+
return this.statusOf(e);
|
|
386
|
+
}
|
|
387
|
+
/** Remove a DYNAMIC server. Config-declared servers must be removed from config.yaml by the operator. */
|
|
388
|
+
removeServer(id) {
|
|
389
|
+
const e = this.entries.get(id);
|
|
390
|
+
if (!e)
|
|
391
|
+
return { ok: false, reason: `no MCP server '${id}'` };
|
|
392
|
+
if (e.origin === 'config')
|
|
393
|
+
return { ok: false, reason: `'${id}' is declared in your config file — remove it there` };
|
|
394
|
+
void e.client?.close().catch(() => { });
|
|
395
|
+
this.entries.delete(id);
|
|
396
|
+
this.overrides.delete(id);
|
|
397
|
+
// Store cleanup is best-effort: a failure here must not abort the removal half-way and leave a
|
|
398
|
+
// zombie entry that is gone from memory but still persisted.
|
|
399
|
+
try {
|
|
400
|
+
this.opts.store?.remove(`tools-${id}`);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
this.warnings.push(`could not remove the cached tool list for '${id}'`);
|
|
404
|
+
}
|
|
405
|
+
this.writeDynamic();
|
|
406
|
+
this.writeState();
|
|
407
|
+
this.opts.onServerRemoved?.(id);
|
|
408
|
+
return { ok: true };
|
|
409
|
+
}
|
|
410
|
+
setEnabled(id, enabled) {
|
|
411
|
+
const e = this.entries.get(id);
|
|
412
|
+
if (!e)
|
|
413
|
+
return { ok: false, reason: `no MCP server '${id}'` };
|
|
414
|
+
this.overrides.set(id, enabled);
|
|
415
|
+
if (!enabled) {
|
|
416
|
+
e.state = 'disabled';
|
|
417
|
+
void e.client?.close().catch(() => { });
|
|
418
|
+
delete e.client;
|
|
419
|
+
this.opts.onServerRemoved?.(id);
|
|
420
|
+
}
|
|
421
|
+
else if (e.state === 'disabled') {
|
|
422
|
+
e.state = 'configured';
|
|
423
|
+
e.failures = 0;
|
|
424
|
+
// Disabling DEREGISTERED the tools, so re-enabling must put them back. Waiting for a reconnect is
|
|
425
|
+
// not enough: `connectMcp` is latched per process, so nothing would ever re-register them. The
|
|
426
|
+
// entry's declarations are re-gated to their fail-closed values — only a live handshake relaxes.
|
|
427
|
+
if (e.tools.length) {
|
|
428
|
+
e.tools = e.tools.map((t) => ({ ...t, readOnly: false, destructive: true }));
|
|
429
|
+
this.opts.onToolsDiscovered?.(id, e.tools);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
this.writeState();
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
|
435
|
+
/** Force an active check: connect + handshake + list + ping. */
|
|
436
|
+
async test(id) {
|
|
437
|
+
const e = this.entries.get(id);
|
|
438
|
+
if (!e)
|
|
439
|
+
throw new AIError(`no MCP server '${id}'`, { category: 'CONFIG' });
|
|
440
|
+
if (e.state === 'disabled')
|
|
441
|
+
return this.statusOf(e);
|
|
442
|
+
e.failures = 0;
|
|
443
|
+
await this.connect(e);
|
|
444
|
+
const ping = e.client ? await e.client.ping() : undefined;
|
|
445
|
+
return { ...this.statusOf(e), ...(ping ? { pingMs: ping.latencyMs } : {}) };
|
|
446
|
+
}
|
|
447
|
+
statusOf(e) {
|
|
448
|
+
const tokenSet = e.cfg.tokenEnv ? Boolean(new Credential(e.cfg.tokenEnv, this.env).hasKey) : false;
|
|
449
|
+
return {
|
|
450
|
+
id: e.id,
|
|
451
|
+
transport: e.cfg.transport,
|
|
452
|
+
origin: e.origin,
|
|
453
|
+
state: e.state,
|
|
454
|
+
enabled: e.state !== 'disabled',
|
|
455
|
+
...(safeEndpoint(e.cfg) ? { endpoint: safeEndpoint(e.cfg) } : {}),
|
|
456
|
+
...(e.cfg.tokenEnv ? { tokenEnv: e.cfg.tokenEnv } : {}),
|
|
457
|
+
tokenSet,
|
|
458
|
+
toolCount: e.tools.length,
|
|
459
|
+
rejectedTools: e.rejected,
|
|
460
|
+
truncated: e.truncated,
|
|
461
|
+
...(e.protocolVersion ? { protocolVersion: e.protocolVersion } : {}),
|
|
462
|
+
...(e.serverName ? { serverName: e.serverName } : {}),
|
|
463
|
+
...(e.lastError ? { lastError: e.lastError } : {}),
|
|
464
|
+
...(e.lastCheckedAt ? { lastCheckedAt: e.lastCheckedAt } : {}),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
list() {
|
|
468
|
+
return [...this.entries.values()].map((e) => this.statusOf(e)).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
469
|
+
}
|
|
470
|
+
status(id) {
|
|
471
|
+
const e = this.entries.get(id);
|
|
472
|
+
return e ? this.statusOf(e) : undefined;
|
|
473
|
+
}
|
|
474
|
+
diagnostics(id) {
|
|
475
|
+
const e = this.entries.get(id);
|
|
476
|
+
// `e.client` is unset on a failed connect — exactly when the operator needs the stderr ring most.
|
|
477
|
+
if (e && !e.client && e.lastDiagnostics)
|
|
478
|
+
return e.lastDiagnostics();
|
|
479
|
+
return this.entries.get(id)?.client?.diagnostics() ?? [];
|
|
480
|
+
}
|
|
481
|
+
ids() {
|
|
482
|
+
return [...this.entries.keys()];
|
|
483
|
+
}
|
|
484
|
+
/** Close every client (stdio children included). Idempotent; safe to call on exit. */
|
|
485
|
+
async close() {
|
|
486
|
+
await Promise.all([...this.entries.values()].map(async (e) => {
|
|
487
|
+
const c = e.client;
|
|
488
|
+
delete e.client;
|
|
489
|
+
if (c)
|
|
490
|
+
await c.close().catch(() => { });
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
}
|