ai-runtime-engine 1.2.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +602 -0
  2. package/dist/agents/admit.d.ts +69 -0
  3. package/dist/agents/admit.js +129 -0
  4. package/dist/agents/definition.d.ts +36 -0
  5. package/dist/agents/definition.js +9 -0
  6. package/dist/agents/envelope.d.ts +53 -0
  7. package/dist/agents/envelope.js +68 -0
  8. package/dist/agents/finding.d.ts +79 -0
  9. package/dist/agents/finding.js +80 -0
  10. package/dist/agents/task.d.ts +60 -0
  11. package/dist/agents/task.js +32 -0
  12. package/dist/agents/worker.d.ts +68 -0
  13. package/dist/agents/worker.js +256 -0
  14. package/dist/capabilities/capability.d.ts +117 -0
  15. package/dist/capabilities/capability.js +66 -0
  16. package/dist/capabilities/registry.d.ts +139 -0
  17. package/dist/capabilities/registry.js +413 -0
  18. package/dist/capabilities/vocabulary.d.ts +32 -0
  19. package/dist/capabilities/vocabulary.js +34 -0
  20. package/dist/cli/cli.js +56 -4
  21. package/dist/cli/commands/cleanup.js +29 -27
  22. package/dist/cli/commands/doctor.d.ts +14 -0
  23. package/dist/cli/commands/doctor.js +38 -8
  24. package/dist/cli/commands/executions.js +34 -25
  25. package/dist/cli/commands/info.d.ts +1 -0
  26. package/dist/cli/commands/info.js +11 -9
  27. package/dist/cli/commands/init.js +19 -0
  28. package/dist/cli/commands/inspect.d.ts +40 -1
  29. package/dist/cli/commands/inspect.js +157 -2
  30. package/dist/cli/commands/mcp.d.ts +45 -0
  31. package/dist/cli/commands/mcp.js +148 -0
  32. package/dist/cli/commands/route.js +21 -0
  33. package/dist/cli/commands/run.d.ts +2 -0
  34. package/dist/cli/commands/run.js +36 -4
  35. package/dist/cli/commands/skills.d.ts +2 -0
  36. package/dist/cli/commands/skills.js +29 -7
  37. package/dist/cli/interactive/ansi.d.ts +41 -0
  38. package/dist/cli/interactive/ansi.js +43 -0
  39. package/dist/cli/interactive/complete.d.ts +10 -0
  40. package/dist/cli/interactive/complete.js +19 -0
  41. package/dist/cli/interactive/repl.d.ts +3 -0
  42. package/dist/cli/interactive/repl.js +105 -16
  43. package/dist/cli/interactive/session.d.ts +12 -1
  44. package/dist/cli/interactive/session.js +83 -5
  45. package/dist/cli/render.d.ts +13 -0
  46. package/dist/cli/render.js +18 -0
  47. package/dist/cli/runtimeSession.d.ts +11 -0
  48. package/dist/cli/runtimeSession.js +17 -0
  49. package/dist/config/defaults.d.ts +3 -1
  50. package/dist/config/defaults.js +2 -0
  51. package/dist/config/schema.d.ts +1 -0
  52. package/dist/config/schema.js +2 -2
  53. package/dist/context/lossVerifier.d.ts +24 -0
  54. package/dist/context/lossVerifier.js +45 -0
  55. package/dist/context/summarize.d.ts +19 -0
  56. package/dist/context/summarize.js +53 -0
  57. package/dist/core/fallback/fallback.d.ts +8 -0
  58. package/dist/core/fallback/fallback.js +3 -1
  59. package/dist/core/router/executor.d.ts +6 -1
  60. package/dist/core/router/executor.js +9 -2
  61. package/dist/core/router/normalize.d.ts +2 -0
  62. package/dist/core/router/request.js +2 -0
  63. package/dist/core/router/router.d.ts +3 -0
  64. package/dist/core/router/router.js +7 -0
  65. package/dist/executions/execution.d.ts +13 -2
  66. package/dist/generation/generateAdapter.d.ts +14 -0
  67. package/dist/generation/generateAdapter.js +38 -0
  68. package/dist/generation/generateSkill.d.ts +26 -0
  69. package/dist/generation/generateSkill.js +51 -0
  70. package/dist/index.d.ts +44 -5
  71. package/dist/index.js +26 -2
  72. package/dist/mcp/client.d.ts +70 -0
  73. package/dist/mcp/client.js +221 -0
  74. package/dist/mcp/manager.d.ts +151 -0
  75. package/dist/mcp/manager.js +493 -0
  76. package/dist/mcp/protocol.d.ts +216 -0
  77. package/dist/mcp/protocol.js +149 -0
  78. package/dist/mcp/toolAdapter.d.ts +44 -0
  79. package/dist/mcp/toolAdapter.js +94 -0
  80. package/dist/mcp/transport.d.ts +109 -0
  81. package/dist/mcp/transport.js +383 -0
  82. package/dist/memory/embedders/hash.d.ts +12 -0
  83. package/dist/memory/embedders/hash.js +31 -0
  84. package/dist/memory/embedders/http.d.ts +25 -0
  85. package/dist/memory/embedders/http.js +48 -0
  86. package/dist/memory/memory.d.ts +19 -2
  87. package/dist/memory/memory.js +75 -11
  88. package/dist/memory/semantic.d.ts +17 -0
  89. package/dist/memory/semantic.js +29 -0
  90. package/dist/orchestration/budget.d.ts +30 -0
  91. package/dist/orchestration/budget.js +40 -0
  92. package/dist/orchestration/executor.d.ts +39 -1
  93. package/dist/orchestration/executor.js +64 -4
  94. package/dist/orchestration/orchestrator.d.ts +29 -1
  95. package/dist/orchestration/orchestrator.js +89 -8
  96. package/dist/orchestration/plan.d.ts +15 -1
  97. package/dist/orchestration/plan.js +23 -4
  98. package/dist/orchestration/planner.d.ts +19 -1
  99. package/dist/orchestration/planner.js +25 -5
  100. package/dist/plugin/ai.d.ts +4 -0
  101. package/dist/plugin/ai.js +9 -0
  102. package/dist/providers/httpClient.d.ts +25 -1
  103. package/dist/providers/httpClient.js +93 -0
  104. package/dist/providers/httpProvider.d.ts +1 -0
  105. package/dist/providers/httpProvider.js +67 -1
  106. package/dist/providers/mock/mockProvider.d.ts +3 -0
  107. package/dist/providers/mock/mockProvider.js +54 -0
  108. package/dist/providers/mock/scenarios.d.ts +7 -0
  109. package/dist/providers/provider.d.ts +6 -0
  110. package/dist/providers/wire/anthropicWire.js +34 -0
  111. package/dist/providers/wire/openaiWire.js +30 -0
  112. package/dist/providers/wire/types.d.ts +16 -0
  113. package/dist/runtime/config.js +50 -6
  114. package/dist/runtime/intent/aiClassifier.d.ts +19 -0
  115. package/dist/runtime/intent/aiClassifier.js +74 -0
  116. package/dist/runtime/models/modelProfile.d.ts +61 -0
  117. package/dist/runtime/models/modelProfile.js +139 -0
  118. package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
  119. package/dist/runtime/planning/deriveCapabilities.js +146 -0
  120. package/dist/runtime/policy.d.ts +10 -0
  121. package/dist/runtime/policy.js +9 -2
  122. package/dist/runtime/runtime.d.ts +173 -0
  123. package/dist/runtime/runtime.js +723 -50
  124. package/dist/runtime/types.d.ts +94 -2
  125. package/dist/skills/manifest.d.ts +3 -0
  126. package/dist/skills/manifest.js +24 -0
  127. package/dist/skills/registry.d.ts +16 -1
  128. package/dist/skills/registry.js +21 -1
  129. package/dist/skills/skill.d.ts +6 -1
  130. package/dist/store/area.d.ts +15 -1
  131. package/dist/store/area.js +19 -8
  132. package/dist/store/crypto.d.ts +21 -0
  133. package/dist/store/crypto.js +49 -0
  134. package/dist/store/paths.d.ts +5 -1
  135. package/dist/store/paths.js +6 -0
  136. package/dist/store/store.d.ts +15 -3
  137. package/dist/store/store.js +28 -7
  138. package/dist/telemetry/sinks/otlp.d.ts +31 -0
  139. package/dist/telemetry/sinks/otlp.js +76 -0
  140. package/dist/tools/builtins/filesystem.js +1 -0
  141. package/dist/tools/builtins/git.js +1 -0
  142. package/dist/tools/builtins/shell.js +1 -0
  143. package/dist/tools/permissions.d.ts +28 -0
  144. package/dist/tools/permissions.js +72 -0
  145. package/dist/tools/registry.d.ts +18 -2
  146. package/dist/tools/registry.js +22 -2
  147. package/dist/tools/tool.d.ts +4 -0
  148. package/dist/types.d.ts +11 -1
  149. package/dist/util/flatten.d.ts +11 -0
  150. package/dist/util/flatten.js +18 -0
  151. package/dist/util/semaphore.d.ts +19 -0
  152. package/dist/util/semaphore.js +60 -0
  153. package/package.json +24 -9
@@ -0,0 +1,151 @@
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 type { Area } from '../store/area.js';
19
+ import type { Clock } from '../util/clock.js';
20
+ import type { FetchLike } from '../providers/httpClient.js';
21
+ import { McpClient } from './client.js';
22
+ import type { McpTransport } from './transport.js';
23
+ import type { McpCallResult, NormalizedTool } from './protocol.js';
24
+ export type McpTransportKind = 'stdio' | 'http';
25
+ export interface McpServerConfig {
26
+ transport: McpTransportKind;
27
+ url?: string;
28
+ command?: string;
29
+ args?: string[];
30
+ /** Env-var NAME (never a value — the config schema rejects key-like strings). */
31
+ tokenEnv?: string;
32
+ enabled?: boolean;
33
+ timeoutMs?: number;
34
+ /** Reserved: per-server aliasing of this server's tools onto canonical capability ids. NOT consumed —
35
+ * use `runtime.capabilities.aliases.<curatedId>: mcp:<server>.<tool>`, which already claims a
36
+ * curated id for any provider, MCP included. */
37
+ capabilities?: Record<string, string[]>;
38
+ }
39
+ export type McpServerState = 'configured' | 'connecting' | 'connected' | 'degraded' | 'auth_failed' | 'unreachable' | 'disabled';
40
+ export interface McpServerStatus {
41
+ id: string;
42
+ transport: McpTransportKind;
43
+ /** `config` = declared in config.yaml; `dynamic` = added via `mcp add` (persisted in the store). */
44
+ origin: 'config' | 'dynamic';
45
+ state: McpServerState;
46
+ enabled: boolean;
47
+ /**
48
+ * Where it points, in a form that CANNOT carry a credential: an http server's ORIGIN (no path, no
49
+ * query — a URL query can hold a token) or a stdio server's bare command (no args — args can hold
50
+ * paths the operator considers sensitive).
51
+ */
52
+ endpoint?: string;
53
+ /** The env-var NAME and whether it currently resolves — NEVER the value. */
54
+ tokenEnv?: string;
55
+ tokenSet: boolean;
56
+ toolCount: number;
57
+ rejectedTools: number;
58
+ truncated: boolean;
59
+ protocolVersion?: string;
60
+ serverName?: string;
61
+ /** Redacted, bounded reason for the last failure. */
62
+ lastError?: string;
63
+ lastCheckedAt?: number;
64
+ }
65
+ /** A server id: the same shape the config schema enforces, so both origins are equally constrained. */
66
+ export declare const MCP_SERVER_ID_RE: RegExp;
67
+ export interface McpManagerOptions {
68
+ /** Servers declared in config (`mcp.servers`). */
69
+ servers?: Record<string, McpServerConfig>;
70
+ /** The store area for dynamic servers, enable-state, and the tool cache. Omit for stateless mode. */
71
+ store?: Area;
72
+ env?: NodeJS.ProcessEnv;
73
+ fetchImpl?: FetchLike;
74
+ clock?: Clock;
75
+ /** Per-server connect budget. */
76
+ connectTimeoutMs?: number;
77
+ /** Test seam: build a transport instead of spawning/fetching. */
78
+ transportFactory?: (id: string, cfg: McpServerConfig) => McpTransport;
79
+ /** Called after a successful discovery so the host can register tools + capabilities. */
80
+ onToolsDiscovered?: (serverId: string, tools: NormalizedTool[]) => void;
81
+ /** Called when a server is removed/disabled so the host can deregister its tools. */
82
+ onServerRemoved?: (serverId: string) => void;
83
+ }
84
+ export declare class McpManager {
85
+ private readonly opts;
86
+ private readonly entries;
87
+ private readonly env;
88
+ private readonly clock;
89
+ private readonly warnings;
90
+ /** Explicit operator enable/disable decisions — the only thing `state` persists (see writeState). */
91
+ private readonly overrides;
92
+ /** In-flight connect per server, so concurrent lazy callers share one handshake (never two children). */
93
+ private readonly connecting;
94
+ constructor(opts?: McpManagerOptions);
95
+ warningsList(): string[];
96
+ private readDynamic;
97
+ private readState;
98
+ /**
99
+ * Read a cached catalog. Every entry goes back through `normalizeToolDecl` — the SAME discovery gate a
100
+ * live declaration passes — because the file is untrusted input like any other: shape validation alone
101
+ * would let a tampered cache reintroduce an unsafe name or unclamped prompt text. Cached annotations
102
+ * are then forced to their fail-closed values: a stale `readOnlyHint` must not lower the permission
103
+ * gate, and a stale `destructiveHint: false` must not REMOVE confirmation. Only a live handshake can
104
+ * relax either.
105
+ */
106
+ private readCache;
107
+ private writeDynamic;
108
+ /**
109
+ * Persist only servers the operator has EXPLICITLY toggled. Snapshotting every entry would turn an
110
+ * unrelated `mcp add` into a stored `enabled: true` for every server — which then wins over a later
111
+ * `enabled: false` added to the config file, silently re-enabling something the operator turned off.
112
+ */
113
+ private writeState;
114
+ private buildTransport;
115
+ /** Connect + handshake + discover one server. Never throws — the state carries the outcome. */
116
+ private connect;
117
+ /** Connect every enabled server in parallel. Resilient: a failure is recorded, never thrown. */
118
+ connectAll(): Promise<McpServerStatus[]>;
119
+ /** Lazily (re)connect on use, honoring a capped backoff after repeated failures. */
120
+ ensureConnected(id: string): Promise<McpClient>;
121
+ /** Whether a server can be called right now (the adapter's honest availability check). */
122
+ usable(id: string): boolean;
123
+ call(id: string, tool: string, args: unknown, opts?: {
124
+ signal?: AbortSignal;
125
+ }): Promise<McpCallResult>;
126
+ /** The LIVE annotations for a tool (approval/permission input — never the cache). */
127
+ liveTool(id: string, tool: string): NormalizedTool | undefined;
128
+ tools(id: string): NormalizedTool[];
129
+ /** Add a dynamic server (persisted to the STORE — the runtime never edits config.yaml). */
130
+ addServer(id: string, cfg: McpServerConfig): Promise<McpServerStatus>;
131
+ /** Remove a DYNAMIC server. Config-declared servers must be removed from config.yaml by the operator. */
132
+ removeServer(id: string): {
133
+ ok: boolean;
134
+ reason?: string;
135
+ };
136
+ setEnabled(id: string, enabled: boolean): {
137
+ ok: boolean;
138
+ reason?: string;
139
+ };
140
+ /** Force an active check: connect + handshake + list + ping. */
141
+ test(id: string): Promise<McpServerStatus & {
142
+ pingMs?: number;
143
+ }>;
144
+ private statusOf;
145
+ list(): McpServerStatus[];
146
+ status(id: string): McpServerStatus | undefined;
147
+ diagnostics(id: string): string[];
148
+ ids(): string[];
149
+ /** Close every client (stdio children included). Idempotent; safe to call on exit. */
150
+ close(): Promise<void>;
151
+ }
@@ -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
+ }