@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.
- package/changelog.json +12 -0
- package/deploy/unraid/sanctuary-acceptance-contract.json +1 -1
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +8 -7
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +2 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/config.js +32 -2
- package/dist/heart/core.js +167 -115
- package/dist/heart/daemon/daemon.js +19 -9
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +88 -50
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +53 -15
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +6 -26
- package/dist/heart/frontend-approval-runtime.js +87 -13
- package/dist/heart/identity.js +17 -5
- package/dist/heart/session-events.js +8 -1
- package/dist/heart/tool-approval.js +64 -31
- package/dist/mind/pending.js +4 -4
- package/dist/repertoire/mcp-manager.js +365 -381
- package/dist/repertoire/mcp-tools.js +66 -25
- package/dist/repertoire/plugin-mcp.js +3 -3
- package/dist/repertoire/shell-sessions.js +8 -7
- package/dist/repertoire/tool-arguments.js +30 -8
- package/dist/repertoire/tools-session.js +30 -5
- package/dist/repertoire/tools-shell.js +30 -14
- package/dist/repertoire/tools-voice.js +6 -6
- package/dist/repertoire/tools.js +231 -205
- package/dist/senses/bluebubbles/index.js +4 -2
- package/dist/senses/cli.js +6 -5
- package/dist/senses/private-runtime.js +9 -3
- package/dist/senses/shared-turn.js +12 -9
- package/dist/senses/teams.js +15 -12
- package/dist/senses/telegram-approval-runtime.js +129 -20
- package/dist/senses/telegram-client.js +19 -2
- package/dist/senses/telegram.js +54 -15
- package/dist/senses/voice/twilio-phone.js +123 -173
- package/npm-shrinkwrap.json +2 -2
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,
|
|
48
|
-
|
|
82
|
+
for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
|
|
83
|
+
desired.set(name, { config: server, source: "builtin" });
|
|
49
84
|
}
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
88
|
+
return desired;
|
|
60
89
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
102
|
+
lifecycle = Promise.resolve();
|
|
103
|
+
shutdownPromise;
|
|
104
|
+
constructor() {
|
|
75
105
|
(0, runtime_1.emitNervesEvent)({
|
|
76
|
-
event: "mcp.manager_start",
|
|
77
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
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
|
|
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
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
let current = entry;
|
|
127
203
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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.
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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)(
|
|
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 (
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
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(
|
|
280
|
-
const
|
|
281
|
-
this.desiredGenerations.set(
|
|
282
|
-
|
|
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
|
-
|
|
287
|
-
resolvedConfig = { ...config, env: resolvedEnv };
|
|
371
|
+
config = { ...config, env: await this.resolveVaultEnv(config.env, desired.owner) };
|
|
288
372
|
}
|
|
289
|
-
catch (
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
return; // Skip this server, continue to next
|
|
379
|
+
this.desiredGenerations.delete(key);
|
|
380
|
+
return;
|
|
303
381
|
}
|
|
304
382
|
}
|
|
305
|
-
if (this.
|
|
383
|
+
if (this.shutdownPromise || this.desiredGenerations.get(key) !== desired.generation)
|
|
306
384
|
return;
|
|
307
|
-
const client = new mcp_client_1.McpClient(
|
|
308
|
-
const entry = {
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
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(
|
|
355
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
408
|
-
async recoverStaleTransport(name, reason) {
|
|
446
|
+
async recoverStaleTransport(entry, reason) {
|
|
409
447
|
(0, runtime_1.emitNervesEvent)({
|
|
410
|
-
level: "warn",
|
|
411
|
-
|
|
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(
|
|
451
|
+
await this.restartServer(entry);
|
|
417
452
|
}
|
|
418
453
|
}
|
|
419
454
|
exports.McpManager = McpManager;
|
|
420
455
|
let _sharedManager = null;
|
|
421
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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 (
|
|
480
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
498
|
-
_sharedManagerPromise = null;
|
|
480
|
+
/** Reset for testing only. */
|
|
481
|
+
async function resetSharedMcpManager() {
|
|
482
|
+
await shutdownSharedMcpManager();
|
|
499
483
|
}
|