@ouro.bot/cli 0.1.0-alpha.810 → 0.1.0-alpha.811

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 (38) hide show
  1. package/changelog.json +12 -0
  2. package/deploy/unraid/sanctuary-acceptance-contract.json +1 -1
  3. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +8 -7
  4. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  5. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +1 -1
  6. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +2 -2
  7. package/deploy/unraid/sanctuary.xml +1 -1
  8. package/dist/heart/config.js +32 -2
  9. package/dist/heart/core.js +167 -115
  10. package/dist/heart/daemon/daemon.js +19 -9
  11. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +88 -50
  12. package/dist/heart/daemon/sanctuary-acceptance-harness.js +53 -15
  13. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +6 -26
  14. package/dist/heart/frontend-approval-runtime.js +87 -13
  15. package/dist/heart/identity.js +17 -5
  16. package/dist/heart/session-events.js +8 -1
  17. package/dist/heart/tool-approval.js +64 -31
  18. package/dist/mind/pending.js +4 -4
  19. package/dist/repertoire/mcp-manager.js +365 -381
  20. package/dist/repertoire/mcp-tools.js +66 -25
  21. package/dist/repertoire/plugin-mcp.js +3 -3
  22. package/dist/repertoire/shell-sessions.js +8 -7
  23. package/dist/repertoire/tool-arguments.js +30 -8
  24. package/dist/repertoire/tools-session.js +30 -5
  25. package/dist/repertoire/tools-shell.js +30 -14
  26. package/dist/repertoire/tools-voice.js +6 -6
  27. package/dist/repertoire/tools.js +231 -205
  28. package/dist/senses/bluebubbles/index.js +4 -2
  29. package/dist/senses/cli.js +6 -5
  30. package/dist/senses/private-runtime.js +9 -3
  31. package/dist/senses/shared-turn.js +12 -9
  32. package/dist/senses/teams.js +15 -12
  33. package/dist/senses/telegram-approval-runtime.js +129 -20
  34. package/dist/senses/telegram-client.js +19 -2
  35. package/dist/senses/telegram.js +54 -15
  36. package/dist/senses/voice/twilio-phone.js +123 -173
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
@@ -1,257 +1,350 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.McpManager = void 0;
4
37
  exports.getSharedMcpManager = getSharedMcpManager;
5
38
  exports.releaseRuntimeMcpServers = releaseRuntimeMcpServers;
6
39
  exports.shutdownSharedMcpManager = shutdownSharedMcpManager;
7
40
  exports.resetSharedMcpManager = resetSharedMcpManager;
41
+ const path = __importStar(require("node:path"));
8
42
  const mcp_client_1 = require("./mcp-client");
9
43
  const identity_1 = require("../heart/identity");
10
44
  const runtime_1 = require("../nerves/runtime");
11
45
  const credential_access_1 = require("./credential-access");
12
46
  const plugin_mcp_1 = require("./plugin-mcp");
47
+ const tool_arguments_1 = require("./tool-arguments");
48
+ const mcp_tools_1 = require("./mcp-tools");
13
49
  const MAX_RESTART_RETRIES = 5;
14
50
  const RESTART_DELAY_MS = 1000;
15
- /**
16
- * Merge builtin (agent.json mcpServers) + plugin-declared (.mcp.json) servers.
17
- *
18
- * Builtin wins on name collision. Returns the merged config map plus a
19
- * `pluginOrigins` map (server-name → plugin-id) for tools-surfacing namespace.
20
- *
21
- * Shared by `getSharedMcpManager()` (initial start) and `McpManager.reconcile()`
22
- * (re-read on each turn). Both code paths MUST use the same merge logic — if
23
- * reconcile reads only builtin, plugin servers get classified as "removed"
24
- * on the second turn and torn down. See alpha.635 fix.
25
- *
26
- * `runtimeServers` are per-turn, per-agent overrides (e.g. Workbench's
27
- * `ouro_workbench`) supplied as PARAMETER data for the current turn — never read
28
- * from module state. They merge with the HIGHEST precedence (after builtin), so
29
- * a stale `agent.json` entry loses to the live runtime path. Because they are a
30
- * parameter, a turn that omits them produces a merged set WITHOUT them, and
31
- * `reconcile()` then tears the runtime server down — this is the no-leak
32
- * invariant that keeps the runtime MCP from bleeding into a different agent's
33
- * concurrent turn on the shared daemon.
34
- */
35
- function buildMergedServerConfig(runtimeServers) {
36
- const config = (0, identity_1.loadAgentConfig)();
37
- const builtinServers = config.mcpServers ?? {};
38
- const pluginServers = (0, plugin_mcp_1.listPluginMcpServers)();
39
- const mergedServers = {};
40
- const pluginOrigins = {};
41
- for (const p of pluginServers) {
42
- if (builtinServers[p.serverName] !== undefined)
43
- continue;
44
- mergedServers[p.serverName] = (0, plugin_mcp_1.pluginMcpServerToConfig)(p);
45
- pluginOrigins[p.serverName] = p.pluginId;
51
+ function isOwner(value) {
52
+ return typeof value === "object" && value !== null
53
+ && "agentName" in value && typeof value.agentName === "string" && value.agentName.length > 0
54
+ && "agentRoot" in value && typeof value.agentRoot === "string" && path.isAbsolute(value.agentRoot);
55
+ }
56
+ function captureOwner(owner) {
57
+ if (!isOwner(owner)) {
58
+ (0, runtime_1.emitNervesEvent)({
59
+ level: "warn", event: "mcp.owner_invalid", component: "repertoire",
60
+ message: "MCP requires an explicit owner", meta: { reason: "agent name and absolute root are required" },
61
+ });
62
+ throw new mcp_tools_1.McpCallRejectedError("MCP requires an explicit owner");
63
+ }
64
+ return Object.freeze({ agentName: owner.agentName, agentRoot: owner.agentRoot });
65
+ }
66
+ function sameOwner(left, right) {
67
+ return left.agentName === right.agentName && left.agentRoot === right.agentRoot;
68
+ }
69
+ function serverKey(owner, name) {
70
+ return JSON.stringify([owner.agentName, owner.agentRoot, name]);
71
+ }
72
+ function buildMergedServerConfig(owner, runtimeServers) {
73
+ const config = (0, identity_1.loadAgentConfig)(owner);
74
+ const desired = new Map();
75
+ for (const plugin of (0, plugin_mcp_1.listPluginMcpServers)(undefined, owner)) {
76
+ if (desired.has(plugin.serverName))
77
+ throw new mcp_tools_1.McpCallRejectedError(`MCP server name collision: ${plugin.serverName}`);
78
+ desired.set(plugin.serverName, {
79
+ config: (0, plugin_mcp_1.pluginMcpServerToConfig)(plugin), source: "plugin", pluginId: plugin.pluginId,
80
+ });
46
81
  }
47
- for (const [name, cfg] of Object.entries(builtinServers)) {
48
- mergedServers[name] = cfg;
82
+ for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
83
+ desired.set(name, { config: server, source: "builtin" });
49
84
  }
50
- // Runtime overrides win over both plugin and builtin (highest precedence).
51
- // They are NOT recorded in pluginOrigins, so they surface as builtin-style
52
- // (un-namespaced) tools — matching how an agent.json mcpServers entry would.
53
- if (runtimeServers) {
54
- for (const [name, cfg] of Object.entries(runtimeServers)) {
55
- mergedServers[name] = cfg;
56
- delete pluginOrigins[name];
57
- }
85
+ for (const [name, server] of Object.entries(runtimeServers ?? {})) {
86
+ desired.set(name, { config: server, source: "runtime" });
58
87
  }
59
- return { mergedServers, pluginOrigins };
88
+ return desired;
60
89
  }
61
- function sameMcpServerConfig(left, right) {
62
- const leftEnv = Object.entries(left.env ?? {}).sort();
63
- const rightEnv = Object.entries(right.env ?? {}).sort();
64
- return left.command === right.command
65
- && JSON.stringify(left.args ?? []) === JSON.stringify(right.args ?? [])
66
- && JSON.stringify(leftEnv) === JSON.stringify(rightEnv)
67
- && (left.cwd ?? "") === (right.cwd ?? "");
90
+ function configurationDigest(owner, desired) {
91
+ return (0, tool_arguments_1.digestJson)({
92
+ agentName: owner.agentName, agentRoot: owner.agentRoot,
93
+ source: desired.source, pluginId: desired.pluginId ?? null,
94
+ command: desired.config.command, args: desired.config.args ?? [],
95
+ env: desired.config.env ?? {}, cwd: desired.config.cwd ?? "",
96
+ });
68
97
  }
69
98
  class McpManager {
70
99
  servers = new Map();
71
100
  desiredGenerations = new Map();
72
101
  nextGeneration = 0;
73
- shuttingDown = false;
74
- async start(servers, pluginOrigins = {}, ownerAgent) {
102
+ lifecycle = Promise.resolve();
103
+ shutdownPromise;
104
+ constructor() {
75
105
  (0, runtime_1.emitNervesEvent)({
76
- event: "mcp.manager_start",
77
- component: "repertoire",
78
- message: "starting MCP manager",
79
- meta: {
80
- serverCount: Object.keys(servers).length,
81
- pluginServerCount: Object.keys(pluginOrigins).length,
82
- },
106
+ event: "mcp.manager_start", component: "repertoire",
107
+ message: "starting MCP manager", meta: { serverCount: 0 },
83
108
  });
84
- const entries = Object.entries(servers);
85
- for (const [name, config] of entries) {
86
- await this.connectServer(name, config, pluginOrigins[name], ownerAgent);
87
- }
88
109
  }
89
- listAllTools() {
90
- const result = [];
91
- for (const [name, entry] of this.servers) {
92
- result.push({ server: name, tools: entry.cachedTools, pluginId: entry.pluginId });
93
- }
110
+ // One lifecycle tail; external tool and canary results do not hold it.
111
+ enqueue(operation) {
112
+ const result = this.lifecycle.then(operation);
113
+ this.lifecycle = result.then(() => undefined, () => undefined);
94
114
  return result;
95
115
  }
96
- async callTool(server, tool, args) {
97
- let entry = this.servers.get(server);
98
- if (!entry) {
99
- throw new Error(`Unknown server: ${server}`);
100
- }
101
- if (!entry.client.isConnected()) {
102
- await this.recoverStaleTransport(server, "pre-call disconnected");
103
- entry = this.servers.get(server);
104
- if (!entry?.client.isConnected()) {
105
- throw new Error(`Server "${server}" is disconnected`);
116
+ enqueueBackground(operation) {
117
+ void this.enqueue(operation).catch((error) => {
118
+ (0, runtime_1.emitNervesEvent)({
119
+ level: "error", event: "mcp.lifecycle_error", component: "repertoire",
120
+ message: "MCP background lifecycle operation failed",
121
+ meta: { reason: error instanceof Error ? error.message : String(error) },
122
+ });
123
+ });
124
+ }
125
+ async start(ownerInput, servers, pluginOrigins = {}) {
126
+ const owner = captureOwner(ownerInput);
127
+ const desired = new Map(Object.entries(structuredClone(servers)).map(([name, config]) => [
128
+ name, { config, source: pluginOrigins[name] ? "plugin" : "builtin", pluginId: pluginOrigins[name] },
129
+ ]));
130
+ return this.enqueue(async () => {
131
+ if (this.shutdownPromise)
132
+ return null;
133
+ await this.applyDesired(owner, desired);
134
+ return this.shutdownPromise ? null : this.freezeView(owner);
135
+ });
136
+ }
137
+ async reconcile(ownerInput, runtimeServers) {
138
+ const owner = captureOwner(ownerInput);
139
+ const runtime = runtimeServers ? structuredClone(runtimeServers) : undefined;
140
+ return this.enqueue(async () => {
141
+ if (this.shutdownPromise)
142
+ return null;
143
+ try {
144
+ const desired = buildMergedServerConfig(owner, runtime);
145
+ await this.applyDesired(owner, desired);
146
+ return this.shutdownPromise || desired.size === 0 ? null : this.freezeView(owner);
106
147
  }
107
- }
148
+ catch (error) {
149
+ (0, runtime_1.emitNervesEvent)({
150
+ level: "warn", event: "mcp.reconcile_error", component: "repertoire",
151
+ message: "failed to reconcile MCP servers",
152
+ meta: { reason: error instanceof Error ? error.message : String(error) },
153
+ });
154
+ for (const entry of this.ownedEntries(owner))
155
+ this.removeEntry(entry);
156
+ return null;
157
+ }
158
+ });
159
+ }
160
+ listAllTools(owner) {
161
+ captureOwner(owner);
162
+ return this.ownedEntries(owner).filter((entry) => this.isCurrentEntry(entry)).map((entry) => ({
163
+ server: entry.name, tools: structuredClone(entry.cachedTools), pluginId: entry.pluginId,
164
+ }));
165
+ }
166
+ async callTool(binding, args, ownerInput) {
167
+ const owner = captureOwner(ownerInput);
168
+ const call = await this.enqueue(async () => {
169
+ let entry = this.currentBinding(binding, owner);
170
+ if (!entry.client.isConnected()) {
171
+ await this.recoverStaleTransport(entry, "pre-call disconnected");
172
+ entry = this.currentBinding(binding, owner);
173
+ if (!entry.client.isConnected())
174
+ throw new mcp_tools_1.McpCallRejectedError(`Server "${entry.name}" is disconnected`);
175
+ }
176
+ return { entry, result: entry.client.callTool(binding.rawName, args) };
177
+ });
108
178
  try {
109
- return await entry.client.callTool(tool, args);
179
+ return await call.result;
110
180
  }
111
181
  catch (error) {
112
- if (!(0, mcp_client_1.isMcpTransportError)(error)) {
182
+ if (!(0, mcp_client_1.isMcpTransportError)(error))
113
183
  throw error;
114
- }
115
184
  const reason = error instanceof Error ? error.message : String(error);
116
- await this.recoverStaleTransport(server, reason);
117
- const recovered = this.servers.get(server);
118
- if (!recovered?.client.isConnected()) {
119
- throw new Error(`Server "${server}" is disconnected after recovery: ${reason}`);
120
- }
121
- return recovered.client.callTool(tool, args);
185
+ // Recovery prepares later calls; the server may already have performed this one.
186
+ this.enqueueBackground(async () => {
187
+ if (this.isCurrentEntry(call.entry))
188
+ await this.recoverStaleTransport(call.entry, reason);
189
+ });
190
+ throw error;
122
191
  }
123
192
  }
124
- async runCanaries() {
193
+ async validateToolBinding(binding, ownerInput) {
194
+ const owner = captureOwner(ownerInput);
195
+ await this.enqueue(() => { this.currentBinding(binding, owner); });
196
+ }
197
+ async runCanaries(ownerInput) {
198
+ const owner = captureOwner(ownerInput);
199
+ const entries = await this.enqueue(() => this.ownedEntries(owner));
125
200
  const results = [];
126
- for (const [server, entry] of [...this.servers]) {
201
+ for (const entry of entries) {
202
+ let current = entry;
127
203
  try {
128
- if (!entry.client.isConnected()) {
129
- await this.recoverStaleTransport(server, "canary disconnected");
130
- }
131
- const current = this.servers.get(server);
132
- if (!current?.client.isConnected()) {
133
- results.push({ server, ok: false, detail: "disconnected after recovery attempt" });
204
+ const refresh = await this.enqueue(async () => {
205
+ if (!this.isCurrentEntry(entry))
206
+ return null;
207
+ if (!entry.client.isConnected())
208
+ await this.recoverStaleTransport(entry, "canary disconnected");
209
+ const recovered = this.servers.get(serverKey(owner, entry.name));
210
+ if (!recovered || !this.isCurrentEntry(recovered) || !recovered.client.isConnected())
211
+ return null;
212
+ current = recovered;
213
+ return { result: recovered.client.refreshTools() };
214
+ });
215
+ if (!refresh) {
216
+ results.push({ server: entry.name, ok: false, detail: "disconnected after recovery attempt" });
134
217
  continue;
135
218
  }
136
- const tools = await current.client.refreshTools();
137
- current.cachedTools = tools;
138
- current.consecutiveFailures = 0;
139
- results.push({ server, ok: true, detail: `${tools.length} tools listed` });
219
+ const tools = await refresh.result;
220
+ const updated = await this.enqueue(() => {
221
+ if (!this.isCurrentEntry(current))
222
+ return false;
223
+ current.cachedTools = structuredClone(tools);
224
+ current.consecutiveFailures = 0;
225
+ return true;
226
+ });
227
+ results.push({
228
+ server: entry.name, ok: updated,
229
+ detail: updated ? `${tools.length} tools listed` : "server changed during canary",
230
+ });
140
231
  }
141
232
  catch (error) {
142
233
  const reason = error instanceof Error ? error.message : String(error);
143
234
  if ((0, mcp_client_1.isMcpTransportError)(error)) {
144
- await this.recoverStaleTransport(server, reason);
235
+ await this.enqueue(async () => {
236
+ if (this.isCurrentEntry(current))
237
+ await this.recoverStaleTransport(current, reason);
238
+ });
145
239
  }
146
- results.push({ server, ok: false, detail: reason });
240
+ results.push({ server: entry.name, ok: false, detail: reason });
147
241
  }
148
242
  }
149
243
  return results;
150
244
  }
151
- /** Re-read agent config AND enabled-plugin .mcp.json files, then connect new
152
- * servers / disconnect removed ones. Must include plugin-declared servers
153
- * in the desired set — otherwise plugin servers (e.g. mcp__desk__*) are
154
- * treated as "removed" on every call and get torn down between turns.
155
- *
156
- * `runtimeServers` are the current turn's per-agent overrides. They MUST be
157
- * passed on every reconcile for the agent that owns them, otherwise the
158
- * runtime server (e.g. ouro_workbench) is classified as "removed" and torn
159
- * down — which is exactly the desired no-leak behavior for a turn that omits
160
- * them. */
161
- async reconcile(runtimeServers) {
162
- try {
163
- const { mergedServers, pluginOrigins } = buildMergedServerConfig(runtimeServers);
164
- const ownerAgent = (0, identity_1.getAgentName)();
165
- const currentNames = new Set(this.servers.keys());
166
- const desiredNames = new Set(Object.keys(mergedServers));
167
- // Connect new servers
168
- for (const [name, cfg] of Object.entries(mergedServers)) {
169
- const current = this.servers.get(name);
170
- const pluginId = pluginOrigins[name];
171
- if (!current) {
172
- (0, runtime_1.emitNervesEvent)({
173
- event: "mcp.server_added",
174
- component: "repertoire",
175
- message: `connecting new MCP server: ${name}`,
176
- meta: { server: name, command: cfg.command },
177
- });
178
- await this.connectServer(name, cfg, pluginId, ownerAgent);
179
- }
180
- else if (!sameMcpServerConfig(current.config, cfg)
181
- || current.pluginId !== pluginId
182
- || current.ownerAgent !== ownerAgent) {
183
- (0, runtime_1.emitNervesEvent)({
184
- event: "mcp.server_changed",
185
- component: "repertoire",
186
- message: `reconnecting changed MCP server: ${name}`,
187
- meta: { server: name, command: cfg.command },
188
- });
189
- current.client.shutdown();
190
- this.servers.delete(name);
191
- await this.connectServer(name, cfg, pluginId, ownerAgent);
192
- }
193
- }
194
- // Disconnect removed servers
195
- for (const name of currentNames) {
196
- if (!desiredNames.has(name)) {
197
- (0, runtime_1.emitNervesEvent)({
198
- event: "mcp.server_removed",
199
- component: "repertoire",
200
- message: `disconnecting removed MCP server: ${name}`,
201
- meta: { server: name },
202
- });
203
- const entry = this.servers.get(name);
204
- /* v8 ignore next -- defensive: name comes from this.servers.keys() this same tick, so entry is always present; the guard only protects against an awaited connectServer crash-handler racing a delete @preserve */
205
- if (entry)
206
- entry.client.shutdown();
207
- this.servers.delete(name);
208
- this.desiredGenerations.delete(name);
209
- }
210
- }
211
- return true;
212
- }
213
- catch (error) {
245
+ shutdown() {
246
+ if (this.shutdownPromise)
247
+ return this.shutdownPromise;
248
+ // Publish closing before queued cleanup so pending network work cannot revive clients.
249
+ this.shutdownPromise = this.enqueue(() => {
214
250
  (0, runtime_1.emitNervesEvent)({
215
- level: "warn",
216
- event: "mcp.reconcile_error",
217
- component: "repertoire",
218
- message: "failed to reconcile MCP servers",
219
- meta: { reason: error instanceof Error ? error.message : String(error) },
251
+ event: "mcp.manager_end", component: "repertoire",
252
+ message: "shutting down MCP manager", meta: { serverCount: this.servers.size },
220
253
  });
221
- this.shutdown();
254
+ for (const entry of [...this.servers.values()])
255
+ this.removeEntry(entry);
256
+ this.desiredGenerations.clear();
257
+ });
258
+ return this.shutdownPromise;
259
+ }
260
+ ownedEntries(owner) {
261
+ return [...this.servers.values()].filter((entry) => sameOwner(entry.owner, owner));
262
+ }
263
+ isCurrentEntry(entry) {
264
+ const key = serverKey(entry.owner, entry.name);
265
+ return !this.shutdownPromise && this.servers.get(key) === entry
266
+ && this.desiredGenerations.get(key) === entry.generation;
267
+ }
268
+ freezeView(owner) {
269
+ const entries = this.ownedEntries(owner).filter((entry) => this.isCurrentEntry(entry)).map((entry) => Object.freeze({
270
+ server: entry.name, source: entry.source, pluginId: entry.pluginId,
271
+ configDigest: entry.configDigest, generation: entry.generation,
272
+ tools: (0, tool_arguments_1.freezeToolValue)(structuredClone(entry.cachedTools)),
273
+ }));
274
+ return Object.freeze({ manager: this, owner, entries: Object.freeze(entries) });
275
+ }
276
+ matchesBinding(entry, binding, owner) {
277
+ if (binding.manager !== this || !sameOwner(binding, owner) || !sameOwner(entry.owner, owner)
278
+ || !this.isCurrentEntry(entry) || entry.name !== binding.server
279
+ || entry.source !== binding.source || entry.pluginId !== binding.pluginId
280
+ || entry.configDigest !== binding.configDigest || entry.generation !== binding.generation)
281
+ return false;
282
+ const tools = entry.cachedTools.filter((tool) => tool.name === binding.rawName);
283
+ if (tools.length !== 1)
222
284
  return false;
285
+ const schema = (0, mcp_tools_1.mcpToolSchema)({ server: entry.name, pluginId: entry.pluginId }, tools[0]);
286
+ return schema.function.name === binding.surfacedName && (0, tool_arguments_1.digestJson)(schema) === binding.schemaDigest;
287
+ }
288
+ currentBinding(binding, owner) {
289
+ const entry = this.servers.get(serverKey(owner, binding.server));
290
+ if (!entry || !this.matchesBinding(entry, binding, owner)) {
291
+ (0, runtime_1.emitNervesEvent)({
292
+ level: "warn", event: "mcp.tool_rejected", component: "repertoire",
293
+ message: "MCP tool binding is no longer current", meta: { reason: "stale or unavailable owned tool" },
294
+ });
295
+ throw new mcp_tools_1.McpCallRejectedError("MCP tool binding is stale or unavailable");
223
296
  }
297
+ return entry;
224
298
  }
225
- shutdown() {
226
- this.shuttingDown = true;
227
- // `_end` (not `_stop`) to pair with `mcp.manager_start` under the
228
- // nerves audit start/end pairing rule.
229
- (0, runtime_1.emitNervesEvent)({
230
- event: "mcp.manager_end",
231
- component: "repertoire",
232
- message: "shutting down MCP manager",
233
- meta: { serverCount: this.servers.size },
234
- });
235
- for (const [, entry] of this.servers) {
236
- entry.client.shutdown();
299
+ removeEntry(entry) {
300
+ const key = serverKey(entry.owner, entry.name);
301
+ this.desiredGenerations.delete(key);
302
+ if (entry.restartTimer)
303
+ clearTimeout(entry.restartTimer);
304
+ entry.client.shutdown();
305
+ this.servers.delete(key);
306
+ }
307
+ async applyDesired(owner, desired) {
308
+ for (const [name, requested] of desired) {
309
+ if (this.shutdownPromise)
310
+ return;
311
+ const descriptor = { ...requested, config: structuredClone(requested.config) };
312
+ const configDigest = configurationDigest(owner, descriptor);
313
+ const current = this.servers.get(serverKey(owner, name));
314
+ if (current && this.isCurrentEntry(current) && current.configDigest === configDigest)
315
+ continue;
316
+ if (current) {
317
+ (0, runtime_1.emitNervesEvent)({
318
+ event: "mcp.server_changed", component: "repertoire",
319
+ message: `reconnecting changed MCP server: ${name}`, meta: { server: name },
320
+ });
321
+ this.removeEntry(current);
322
+ }
323
+ else {
324
+ (0, runtime_1.emitNervesEvent)({
325
+ event: "mcp.server_added", component: "repertoire",
326
+ message: `connecting new MCP server: ${name}`, meta: { server: name },
327
+ });
328
+ }
329
+ await this.connectServer({
330
+ ...descriptor, owner, name, configDigest, generation: ++this.nextGeneration, consecutiveFailures: 0,
331
+ });
332
+ }
333
+ for (const entry of this.ownedEntries(owner)) {
334
+ if (desired.has(entry.name))
335
+ continue;
336
+ (0, runtime_1.emitNervesEvent)({
337
+ event: "mcp.server_removed", component: "repertoire",
338
+ message: `disconnecting removed MCP server: ${entry.name}`, meta: { server: entry.name },
339
+ });
340
+ this.removeEntry(entry);
237
341
  }
238
- this.servers.clear();
239
- this.desiredGenerations.clear();
240
342
  }
241
- /**
242
- * Resolve `vault:DOMAIN/FIELD` references in server env config.
243
- * Returns resolved env or throws with a descriptive error.
244
- */
245
- async resolveVaultEnv(_serverName, env, ownerAgent) {
343
+ async resolveVaultEnv(env, owner) {
246
344
  const resolved = { ...env };
247
- // Short-circuit: only spin up a credential store if at least one env value
248
- // actually requests vault resolution. Plugin MCP servers commonly ship with
249
- // `env: {}` or pure-string envs, and we shouldn't pay the credential-store
250
- // boot cost (or fail in test envs that have no vault) for those cases.
251
- const hasVaultRef = Object.values(resolved).some((v) => /^vault:/.test(v));
252
- if (!hasVaultRef)
345
+ if (!Object.values(resolved).some((value) => /^vault:/.test(value)))
253
346
  return resolved;
254
- const store = (0, credential_access_1.getCredentialStore)(ownerAgent);
347
+ const store = (0, credential_access_1.getCredentialStore)(owner.agentName);
255
348
  for (const [key, value] of Object.entries(resolved)) {
256
349
  const match = value.match(/^vault:([^/]+)\/(.+)$/);
257
350
  if (!match)
@@ -260,240 +353,131 @@ class McpManager {
260
353
  try {
261
354
  resolved[key] = await store.getRawSecret(domain, field);
262
355
  }
263
- catch (err) {
264
- /* v8 ignore next -- reason @preserve */
265
- const reason = err instanceof Error ? err.message : String(err);
266
- // Classify the error for actionable messaging
267
- let classification = "vault unreachable";
268
- if (reason.includes("no credential found")) {
269
- classification = "item not found";
270
- }
271
- else if (reason.includes("field") && reason.includes("not found")) {
272
- classification = "field empty";
273
- }
356
+ catch (error) {
357
+ const reason = error instanceof Error ? error.message : String(error);
358
+ const classification = reason.includes("no credential found") ? "item not found"
359
+ : reason.includes("field") && reason.includes("not found") ? "field empty" : "vault unreachable";
274
360
  throw new Error(`vault:${domain}/${field} could not be resolved: ${classification}`);
275
361
  }
276
362
  }
277
363
  return resolved;
278
364
  }
279
- async connectServer(name, config, pluginId, ownerAgent) {
280
- const generation = ++this.nextGeneration;
281
- this.desiredGenerations.set(name, generation);
282
- // Resolve vault: references in env before spawning
283
- let resolvedConfig = config;
365
+ async connectServer(desired) {
366
+ const key = serverKey(desired.owner, desired.name);
367
+ this.desiredGenerations.set(key, desired.generation);
368
+ let config = desired.config;
284
369
  if (config.env) {
285
370
  try {
286
- const resolvedEnv = await this.resolveVaultEnv(name, config.env, ownerAgent);
287
- resolvedConfig = { ...config, env: resolvedEnv };
371
+ config = { ...config, env: await this.resolveVaultEnv(config.env, desired.owner) };
288
372
  }
289
- catch (err) {
290
- /* v8 ignore next -- reason @preserve */
291
- const reason = err instanceof Error ? err.message : String(err);
373
+ catch (error) {
374
+ const reason = error instanceof Error ? error.message : String(error);
292
375
  (0, runtime_1.emitNervesEvent)({
293
- level: "error",
294
- event: "mcp.vault_resolve_error",
295
- component: "repertoire",
296
- message: `skipping MCP server "${name}": ${reason}`,
297
- meta: { server: name, reason },
376
+ level: "error", event: "mcp.vault_resolve_error", component: "repertoire",
377
+ message: `skipping MCP server "${desired.name}": ${reason}`, meta: { server: desired.name, reason },
298
378
  });
299
- if (this.desiredGenerations.get(name) === generation) {
300
- this.desiredGenerations.delete(name);
301
- }
302
- return; // Skip this server, continue to next
379
+ this.desiredGenerations.delete(key);
380
+ return;
303
381
  }
304
382
  }
305
- if (this.shuttingDown || this.desiredGenerations.get(name) !== generation)
383
+ if (this.shutdownPromise || this.desiredGenerations.get(key) !== desired.generation)
306
384
  return;
307
- const client = new mcp_client_1.McpClient(resolvedConfig);
308
- const entry = {
309
- name,
310
- config,
311
- client,
312
- cachedTools: [],
313
- consecutiveFailures: 0,
314
- pluginId,
315
- ownerAgent,
316
- generation,
317
- };
318
- this.servers.set(name, entry);
319
- client.onClose(() => {
320
- if (this.shuttingDown)
321
- return;
322
- if (this.servers.get(name)?.client !== client)
323
- return;
324
- this.handleServerCrash(name, client);
325
- });
385
+ const client = new mcp_client_1.McpClient(config);
386
+ const entry = { ...desired, client, cachedTools: [] };
387
+ this.servers.set(key, entry);
388
+ client.onClose(() => { this.enqueueBackground(() => this.handleServerCrash(entry)); });
326
389
  try {
327
390
  await client.connect();
391
+ if (!this.isCurrentEntry(entry)) {
392
+ client.shutdown();
393
+ return;
394
+ }
328
395
  const tools = await client.listTools();
329
- if (this.shuttingDown
330
- || this.desiredGenerations.get(name) !== generation
331
- || this.servers.get(name)?.client !== client) {
396
+ if (!this.isCurrentEntry(entry)) {
332
397
  client.shutdown();
333
398
  return;
334
399
  }
335
- entry.cachedTools = tools;
336
- entry.consecutiveFailures = 0;
400
+ entry.cachedTools = structuredClone(tools);
337
401
  }
338
402
  catch (error) {
339
403
  const reason = error instanceof Error ? error.message : String(error);
340
404
  (0, runtime_1.emitNervesEvent)({
341
- level: "error",
342
- event: "mcp.connect_error",
343
- component: "repertoire",
344
- message: `failed to connect MCP server "${name}" (command: ${config.command}). Check that the command exists and is properly configured. Reason: ${reason}`,
345
- meta: {
346
- server: name,
347
- command: config.command,
348
- args: config.args,
349
- reason,
350
- },
405
+ level: "error", event: "mcp.connect_error", component: "repertoire",
406
+ message: `failed to connect MCP server "${desired.name}"`,
407
+ meta: { server: desired.name, reason },
351
408
  });
352
409
  }
353
410
  }
354
- handleServerCrash(name, crashedClient) {
355
- const entry = this.servers.get(name);
356
- /* v8 ignore next -- defensive: entry removed or replaced between close event and handler @preserve */
357
- if (!entry || entry.client !== crashedClient)
411
+ handleServerCrash(entry) {
412
+ if (!this.isCurrentEntry(entry) || entry.restartTimer)
358
413
  return;
359
414
  entry.consecutiveFailures++;
360
415
  if (entry.consecutiveFailures > MAX_RESTART_RETRIES) {
361
416
  (0, runtime_1.emitNervesEvent)({
362
- level: "error",
363
- event: "mcp.connect_error",
364
- component: "repertoire",
365
- message: `MCP server "${name}" exceeded max restart retries (${MAX_RESTART_RETRIES}). Giving up — check that "${entry.config.command}" exists and is properly configured in agent.json mcpServers.`,
366
- meta: { server: name, command: entry.config.command, failures: entry.consecutiveFailures },
417
+ level: "error", event: "mcp.connect_error", component: "repertoire",
418
+ message: `MCP server "${entry.name}" exceeded max restart retries`,
419
+ meta: { server: entry.name, failures: entry.consecutiveFailures },
367
420
  });
368
421
  return;
369
422
  }
370
423
  (0, runtime_1.emitNervesEvent)({
371
- level: "warn",
372
- event: "mcp.server_restart",
373
- component: "repertoire",
374
- message: `restarting crashed MCP server: ${name}`,
375
- meta: { server: name, attempt: entry.consecutiveFailures },
424
+ level: "warn", event: "mcp.server_restart", component: "repertoire",
425
+ message: `restarting crashed MCP server: ${entry.name}`,
426
+ meta: { server: entry.name, attempt: entry.consecutiveFailures },
376
427
  });
377
- /* v8 ignore start -- timer callback: covered by mcp-manager.test.ts via fake timers but v8 can't trace @preserve */
378
- setTimeout(() => {
379
- if (this.shuttingDown)
380
- return;
381
- if (this.servers.get(name)?.client !== crashedClient)
382
- return;
383
- this.restartServer(name, crashedClient).catch(() => {
384
- // Error handling is inside restartServer
428
+ entry.restartTimer = setTimeout(() => {
429
+ this.enqueueBackground(async () => {
430
+ if (!this.isCurrentEntry(entry))
431
+ return;
432
+ delete entry.restartTimer;
433
+ await this.restartServer(entry);
385
434
  });
386
435
  }, RESTART_DELAY_MS);
387
- /* v8 ignore stop */
388
436
  }
389
- /* v8 ignore start -- called from timer callback: covered by mcp-manager.test.ts via fake timers but v8 can't trace @preserve */
390
- async restartServer(name, expectedClient) {
391
- const entry = this.servers.get(name);
392
- if (!entry)
393
- return;
394
- if (expectedClient && entry.client !== expectedClient)
395
- return;
396
- // Remove old entry and reconnect
397
- this.servers.delete(name);
398
- this.desiredGenerations.delete(name);
399
- entry.client.shutdown();
400
- await this.connectServer(name, entry.config, entry.pluginId, entry.ownerAgent);
401
- // Preserve failure count
402
- const newEntry = this.servers.get(name);
403
- if (newEntry) {
404
- newEntry.consecutiveFailures = entry.consecutiveFailures;
405
- }
437
+ async restartServer(entry) {
438
+ this.removeEntry(entry);
439
+ await this.connectServer({
440
+ owner: entry.owner, name: entry.name, config: entry.config,
441
+ source: entry.source, pluginId: entry.pluginId,
442
+ configDigest: entry.configDigest, generation: entry.generation,
443
+ consecutiveFailures: entry.consecutiveFailures,
444
+ });
406
445
  }
407
- /* v8 ignore stop */
408
- async recoverStaleTransport(name, reason) {
446
+ async recoverStaleTransport(entry, reason) {
409
447
  (0, runtime_1.emitNervesEvent)({
410
- level: "warn",
411
- event: "mcp.transport_recovery",
412
- component: "repertoire",
413
- message: `recovering stale MCP transport: ${name}`,
414
- meta: { server: name, reason },
448
+ level: "warn", event: "mcp.transport_recovery", component: "repertoire",
449
+ message: `recovering stale MCP transport: ${entry.name}`, meta: { server: entry.name, reason },
415
450
  });
416
- await this.restartServer(name);
451
+ await this.restartServer(entry);
417
452
  }
418
453
  }
419
454
  exports.McpManager = McpManager;
420
455
  let _sharedManager = null;
421
- let _sharedManagerPromise = null;
422
- /**
423
- * Get or create a shared McpManager instance from the agent's config.
424
- * Returns null if no mcpServers are configured.
425
- * Safe to call from multiple senses — will only create one instance.
426
- *
427
- * `options.runtimeServers` are the current turn's per-agent MCP overrides (e.g.
428
- * Workbench's `ouro_workbench`). They are PARAMETER data for this call only —
429
- * passed into the merge for both the initial `start()` and every subsequent
430
- * `reconcile()`, and never persisted as module state. A call that omits them
431
- * reconciles to a set WITHOUT them, tearing any prior runtime server down — the
432
- * no-leak invariant for the shared multi-agent daemon.
433
- */
456
+ /** Every caller applies its own desired state and freezes its own view in the shared manager's lifecycle tail. */
434
457
  async function getSharedMcpManager(options) {
435
- const runtimeServers = options?.runtimeServers;
436
- // If manager exists, reconcile to pick up config changes (new/removed servers)
437
- // AND this turn's runtime overrides. Passing runtimeServers per-call is what
438
- // scopes the runtime MCP to the active agent's turn.
439
- if (_sharedManager) {
440
- const reconciled = await _sharedManager.reconcile(runtimeServers);
441
- if (!reconciled) {
442
- _sharedManager = null;
443
- return null;
444
- }
445
- return _sharedManager;
458
+ if (!isOwner(options)) {
459
+ (0, runtime_1.emitNervesEvent)({
460
+ level: "warn", event: "mcp.owner_invalid", component: "repertoire",
461
+ message: "MCP requires an explicit owner", meta: { reason: "agent name and absolute root are required" },
462
+ });
463
+ return null;
446
464
  }
447
- /* v8 ignore next -- race guard: deduplicates concurrent initialization calls @preserve */
448
- if (_sharedManagerPromise)
449
- return _sharedManagerPromise;
450
- // Always re-check config — agent may have added servers since last call
451
- _sharedManagerPromise = (async () => {
452
- try {
453
- const { mergedServers, pluginOrigins } = buildMergedServerConfig(runtimeServers);
454
- if (Object.keys(mergedServers).length === 0)
455
- return null;
456
- const manager = new McpManager();
457
- await manager.start(mergedServers, pluginOrigins, (0, identity_1.getAgentName)());
458
- _sharedManager = manager;
459
- return manager;
460
- }
461
- catch (error) {
462
- (0, runtime_1.emitNervesEvent)({
463
- level: "error",
464
- event: "mcp.manager_start",
465
- component: "repertoire",
466
- message: "failed to initialize shared MCP manager",
467
- /* v8 ignore next -- both branches tested: Error in wiring test, non-Error is defensive @preserve */
468
- meta: { reason: error instanceof Error ? error.message : String(error) },
469
- });
470
- return null;
471
- }
472
- finally {
473
- _sharedManagerPromise = null;
474
- }
475
- })();
476
- return _sharedManagerPromise;
465
+ const manager = _sharedManager ??= new McpManager();
466
+ return manager.reconcile(options, options.runtimeServers);
477
467
  }
478
- async function releaseRuntimeMcpServers() {
479
- if (!_sharedManager)
480
- return;
481
- if (!await _sharedManager.reconcile()) {
482
- _sharedManager = null;
483
- }
468
+ async function releaseRuntimeMcpServers(owner) {
469
+ if (_sharedManager)
470
+ await _sharedManager.reconcile(owner);
484
471
  }
485
- /**
486
- * Shut down the shared MCP manager and clear the singleton.
487
- * Called during daemon/agent shutdown.
488
- */
489
- function shutdownSharedMcpManager() {
490
- if (_sharedManager) {
491
- _sharedManager.shutdown();
472
+ async function shutdownSharedMcpManager() {
473
+ const manager = _sharedManager;
474
+ if (!manager)
475
+ return;
476
+ await manager.shutdown();
477
+ if (_sharedManager === manager)
492
478
  _sharedManager = null;
493
- }
494
479
  }
495
- /** Reset for testing only */
496
- function resetSharedMcpManager() {
497
- _sharedManager = null;
498
- _sharedManagerPromise = null;
480
+ /** Reset for testing only. */
481
+ async function resetSharedMcpManager() {
482
+ await shutdownSharedMcpManager();
499
483
  }