@dev-mainsequence/codex-plugin 0.4.2 → 0.7.9
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/.codex-plugin/plugin.json +3 -3
- package/.mcp.json +2 -1
- package/README.md +30 -4
- package/assets/mainsequence-mark.svg +453 -0
- package/build-info.json +3 -3
- package/dist/hosts/discovery.js +138 -24
- package/dist/hosts/messaging.js +63 -0
- package/dist/hosts/panel/panel.html +225 -125
- package/dist/hosts/workspace.js +107 -0
- package/dist/panel-server.js +115 -27
- package/dist/vendor/devtools/index.js +41 -1
- package/dist/vendor/devtools/package.json +1 -1
- package/dist/vendor/devtools/shared/actions.js +36 -7
- package/dist/vendor/devtools/shared/agentAssets.js +38 -0
- package/dist/vendor/devtools/shared/agentDelivery.js +477 -0
- package/dist/vendor/devtools/shared/agentIcon.js +43 -0
- package/dist/vendor/devtools/shared/agents.js +13 -6
- package/dist/vendor/devtools/shared/arguments.js +16 -9
- package/dist/vendor/devtools/shared/deployments.js +214 -0
- package/dist/vendor/devtools/shared/mcp.js +124 -25
- package/dist/vendor/devtools/shared/mcpTransport.js +64 -21
- package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +8 -3
- package/dist/vendor/devtools/shared/projectTools.js +437 -0
- package/dist/vendor/devtools/shared/repositories.js +2 -2
- package/dist/vendor/devtools/shared/session.js +46 -15
- package/dist/vendor/devtools/shared/sharedTransport.js +506 -0
- package/dist/vendor/devtools/shared/stateDirectory.js +44 -0
- package/dist/vendor/devtools/shared/workingDirectory.js +13 -0
- package/dist/vendor/devtools/shared/workspaceSettings.js +96 -0
- package/docs/agents-tools-panel.md +471 -12
- package/docs/conversational-mcp.md +15 -2
- package/package.json +3 -3
- package/skills/mainsequence-mcp/SKILL.md +18 -3
- package/skills/ms/assets/mainsequence-mark.svg +453 -0
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.McpTransport = void 0;
|
|
3
|
+
exports.McpTransport = exports.describeSeconds = void 0;
|
|
4
|
+
exports.describeMcpMessage = describeMcpMessage;
|
|
5
|
+
exports.failureReason = failureReason;
|
|
4
6
|
exports.readMcpResponse = readMcpResponse;
|
|
5
7
|
exports.sendProjectMcp = sendProjectMcp;
|
|
6
8
|
const models_1 = require("./models.js");
|
|
9
|
+
const projectTools_1 = require("./projectTools.js");
|
|
10
|
+
const safeText = (value, max) => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').trim().slice(0, max);
|
|
11
|
+
/** `initialize`, `tools/list`, `tools/call crm_contacts_list`: what a message asked the server for. */
|
|
12
|
+
function describeMcpMessage(message) {
|
|
13
|
+
const method = typeof message.method === 'string' ? message.method : 'request';
|
|
14
|
+
const name = method === 'tools/call' && message.params && typeof message.params === 'object' ? message.params.name : undefined;
|
|
15
|
+
return typeof name === 'string' && name ? `${method} ${safeText(name, 120)}` : method;
|
|
16
|
+
}
|
|
17
|
+
/** "0.3", "1", "120": a budget in seconds, as a person reads it. */
|
|
18
|
+
const describeSeconds = (ms) => (ms / 1000).toFixed(ms % 1000 ? 1 : 0);
|
|
19
|
+
exports.describeSeconds = describeSeconds;
|
|
20
|
+
/** Why a request failed before any response arrived: the system error code (ECONNREFUSED, ENOTFOUND,
|
|
21
|
+
* ECONNRESET, a certificate code) when there is one. Free-text error messages never reach a host:
|
|
22
|
+
* they can carry addresses and credentials. */
|
|
23
|
+
function failureReason(error) {
|
|
24
|
+
const code = error?.cause?.code ?? error?.code;
|
|
25
|
+
return typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,40}$/.test(code) ? code : '';
|
|
26
|
+
}
|
|
7
27
|
/** The platform's stateless Streamable HTTP profile; no legacy SSE endpoint. */
|
|
8
28
|
class McpTransport {
|
|
9
29
|
send;
|
|
30
|
+
initialized;
|
|
10
31
|
sequence = 0;
|
|
11
|
-
initialized = false
|
|
12
|
-
constructor(send) {
|
|
32
|
+
constructor(send, initialized = false) {
|
|
13
33
|
this.send = send;
|
|
34
|
+
this.initialized = initialized;
|
|
14
35
|
}
|
|
15
36
|
async initialize() {
|
|
16
37
|
const result = await this.request('initialize', {
|
|
@@ -23,19 +44,23 @@ class McpTransport {
|
|
|
23
44
|
await this.send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
24
45
|
this.initialized = true;
|
|
25
46
|
}
|
|
26
|
-
async request(method, params) {
|
|
47
|
+
async request(method, params, options) {
|
|
27
48
|
if (method !== 'initialize' && !this.initialized)
|
|
28
49
|
throw new models_1.ToolingError('API_ERROR', 'The MCP connection has not initialized.');
|
|
29
50
|
const id = ++this.sequence;
|
|
30
|
-
const
|
|
51
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
52
|
+
const response = await this.send(message, options);
|
|
31
53
|
if (!response || response.jsonrpc !== '2.0' || response.id !== id) {
|
|
32
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response.');
|
|
54
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response to ' + describeMcpMessage(message) + '.');
|
|
55
|
+
}
|
|
56
|
+
if (response.error) {
|
|
57
|
+
// The server's own words, bounded; the dispatcher redacts credentials before any host sees them.
|
|
58
|
+
const detail = safeText(response.error.message, 300);
|
|
59
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint rejected ' + describeMcpMessage(message)
|
|
60
|
+
+ (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + (detail ? ': ' + detail : '.'));
|
|
33
61
|
}
|
|
34
|
-
if (response.error)
|
|
35
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint rejected ' + method
|
|
36
|
-
+ (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + '.');
|
|
37
62
|
if (!Object.hasOwn(response, 'result'))
|
|
38
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP response has no result.');
|
|
63
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP response to ' + describeMcpMessage(message) + ' has no result.');
|
|
39
64
|
return response.result;
|
|
40
65
|
}
|
|
41
66
|
async tools() {
|
|
@@ -78,29 +103,31 @@ class McpTransport {
|
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
exports.McpTransport = McpTransport;
|
|
81
|
-
/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation.
|
|
82
|
-
|
|
106
|
+
/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation. `target` names the
|
|
107
|
+
* endpoint in errors ("project MCP https://crm.example"), so a person or a model sees which server
|
|
108
|
+
* answered which request with which status. */
|
|
109
|
+
async function readMcpResponse(response, message, target = 'MCP endpoint') {
|
|
83
110
|
if (response.headers.has('mcp-session-id')) {
|
|
84
111
|
await response.body?.cancel();
|
|
85
112
|
throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Stateful MCP sessions are outside the platform profile.');
|
|
86
113
|
}
|
|
87
114
|
if (!response.ok) {
|
|
88
115
|
await response.body?.cancel();
|
|
89
|
-
throw new models_1.ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR', 'The
|
|
116
|
+
throw new models_1.ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR', 'The ' + target + ' returned HTTP ' + response.status + ' for ' + describeMcpMessage(message) + '.');
|
|
90
117
|
}
|
|
91
118
|
if (!Object.hasOwn(message, 'id')) {
|
|
92
119
|
await response.body?.cancel();
|
|
93
120
|
if (response.status !== 202 && response.status !== 204)
|
|
94
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
121
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' did not acknowledge ' + describeMcpMessage(message) + ' (HTTP ' + response.status + ').');
|
|
95
122
|
return undefined;
|
|
96
123
|
}
|
|
97
124
|
const kind = response.headers.get('content-type')?.split(';')[0].trim();
|
|
98
125
|
if (kind !== 'application/json' && kind !== 'text/event-stream') {
|
|
99
126
|
await response.body?.cancel();
|
|
100
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
127
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an unsupported content type for ' + describeMcpMessage(message) + '.');
|
|
101
128
|
}
|
|
102
129
|
if (!response.body)
|
|
103
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
130
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an empty response to ' + describeMcpMessage(message) + '.');
|
|
104
131
|
const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
105
132
|
let size = 0, text = '';
|
|
106
133
|
const parse = (raw) => {
|
|
@@ -108,7 +135,7 @@ async function readMcpResponse(response, message) {
|
|
|
108
135
|
return JSON.parse(raw);
|
|
109
136
|
}
|
|
110
137
|
catch {
|
|
111
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
138
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned invalid JSON for ' + describeMcpMessage(message) + '.');
|
|
112
139
|
}
|
|
113
140
|
};
|
|
114
141
|
try {
|
|
@@ -143,32 +170,48 @@ async function readMcpResponse(response, message) {
|
|
|
143
170
|
text += decoder.decode();
|
|
144
171
|
if (kind === 'application/json')
|
|
145
172
|
return parse(text);
|
|
146
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP stream ended before the response arrived. The call was not retried.');
|
|
173
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP stream from the ' + target + ' ended before the response to ' + describeMcpMessage(message) + ' arrived. The call was not retried.');
|
|
147
174
|
}
|
|
148
175
|
finally {
|
|
149
176
|
await reader.cancel().catch(() => { });
|
|
150
177
|
reader.releaseLock();
|
|
151
178
|
}
|
|
152
179
|
}
|
|
180
|
+
/** One request to a project's MCP endpoint with the release credential. The budget is the project
|
|
181
|
+
* request timeout (default 120 s: a scaled-to-zero release has taken over a minute to answer
|
|
182
|
+
* `initialize`), or the caller's own for a tool that advertises a longer run. A timeout and a
|
|
183
|
+
* connection failure keep the same code, `BACKEND_UNREACHABLE`, and say which they were. */
|
|
153
184
|
async function sendProjectMcp(url, token, message, options) {
|
|
154
185
|
options.signal?.throwIfAborted();
|
|
155
186
|
const controller = new AbortController(), abort = () => controller.abort();
|
|
156
187
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
157
|
-
const
|
|
188
|
+
const budget = options.timeoutMs ?? projectTools_1.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS;
|
|
189
|
+
let timedOut = false;
|
|
190
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, budget);
|
|
191
|
+
const origin = (() => { try {
|
|
192
|
+
return new URL(url).origin;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return url;
|
|
196
|
+
} })();
|
|
158
197
|
try {
|
|
159
198
|
return await readMcpResponse(await (options.fetch ?? fetch)(url, {
|
|
160
199
|
method: 'POST', redirect: 'error', signal: controller.signal,
|
|
200
|
+
...(['initialize', 'notifications/initialized', 'tools/list'].includes(String(message.method)) ? { cache: 'force-cache' } : {}),
|
|
161
201
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
|
|
162
202
|
Authorization: 'Bearer ' + token, 'MCP-Protocol-Version': '2025-11-25' },
|
|
163
203
|
body: JSON.stringify(message),
|
|
164
|
-
}), message);
|
|
204
|
+
}), message, 'project MCP ' + origin);
|
|
165
205
|
}
|
|
166
206
|
catch (error) {
|
|
167
207
|
if (options.signal?.aborted)
|
|
168
208
|
throw new models_1.ToolingError('CANCELLED', 'The MCP request was cancelled. Cancellation does not undo a remote operation.');
|
|
169
209
|
if (error instanceof models_1.ToolingError)
|
|
170
210
|
throw error;
|
|
171
|
-
|
|
211
|
+
if (timedOut)
|
|
212
|
+
throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' timed out after ' + (0, exports.describeSeconds)(budget) + ' s. It was not retried.');
|
|
213
|
+
const reason = failureReason(error);
|
|
214
|
+
throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' failed before a response arrived' + (reason ? ' (' + reason + ')' : '') + '. It was not retried.');
|
|
172
215
|
}
|
|
173
216
|
finally {
|
|
174
217
|
clearTimeout(timer);
|
|
@@ -139,6 +139,8 @@ async function namedDirectory(parent, name, uid) {
|
|
|
139
139
|
/** Stable, independent branch checkouts. No reset, stash, force checkout, deletion or automatic pull. */
|
|
140
140
|
async function setupOrganizationProject(input, options) {
|
|
141
141
|
const { session, signal, state } = options;
|
|
142
|
+
if (input.instructionsFile !== undefined && !['AGENTS.md', 'CLAUDE.md'].includes(input.instructionsFile))
|
|
143
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose AGENTS.md or CLAUDE.md instructions.');
|
|
142
144
|
const backend = (0, session_1.requireBackend)(input.backend), environmentUid = (0, organizationProjects_1.projectUid)(input.environmentUid), uid = (0, organizationProjects_1.projectUid)(input.projectUid);
|
|
143
145
|
const env = (0, projects_1.projectEnvironment)({ backend }, options.env);
|
|
144
146
|
// Apply the same environment to nested Git helpers, including context and .env setup.
|
|
@@ -283,13 +285,16 @@ async function setupOrganizationProject(input, options) {
|
|
|
283
285
|
if (await run(row.path, ['symbolic-ref', '--short', 'HEAD']) !== row.branch)
|
|
284
286
|
throw new models_1.ToolingError('INVALID_GIT_CONTEXT', 'The checkout did not select the project branch.');
|
|
285
287
|
}
|
|
286
|
-
const
|
|
288
|
+
const instructionsFile = input.instructionsFile ?? 'CLAUDE.md';
|
|
289
|
+
if (!['AGENTS.md', 'CLAUDE.md'].includes(instructionsFile))
|
|
290
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose AGENTS.md or CLAUDE.md instructions.');
|
|
291
|
+
const instructions = path.join(root, instructionsFile);
|
|
287
292
|
if (!await exists(instructions)) {
|
|
288
293
|
const legacyInstructions = path.join(container, 'CLAUDE.md');
|
|
289
|
-
const content = prior?.schemaVersion === 1 && await exists(legacyInstructions)
|
|
294
|
+
const content = instructionsFile === 'CLAUDE.md' && prior?.schemaVersion === 1 && await exists(legacyInstructions)
|
|
290
295
|
? await fs.readFile(legacyInstructions, 'utf8')
|
|
291
296
|
: '# MainSequence workspace\n\nThe project repositories and their assigned branches are listed in `.mainsequence-project.json`.\nThe MainSequence plugin adds each repository to this Claude session with `/add-dir`.\nWork inside the appropriate repository directory; each checkout has its own Git history and instructions.\n';
|
|
292
|
-
await fs.writeFile(instructions, content, { flag: 'wx' });
|
|
297
|
+
await fs.writeFile(instructions, instructionsFile === 'AGENTS.md' ? content.replace('The MainSequence plugin adds each repository to this Claude session with `/add-dir`.', 'Open the repository paths listed in the manifest in your editor. Grant access to each checkout before working in it.') : content, { flag: 'wx' });
|
|
293
298
|
}
|
|
294
299
|
await atomicJson(manifestPath, { ...manifest, status: 'ready' });
|
|
295
300
|
await atomicJson(path.join(root, '.mainsequence-project.json'), { ...manifest, status: 'ready' });
|
|
@@ -0,0 +1,437 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.projectToolCatalogPath = exports.projectToolConnectionLabel = exports.isProjectToolName = exports.MAX_PROJECT_TOOL_TIMEOUT_MS = exports.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS = exports.PROJECT_TOOL_TIMEOUT_HINT = exports.MAX_PROJECT_TOOLS_PER_CONNECTION = exports.PROJECT_TOOL_PREFIX = void 0;
|
|
37
|
+
exports.projectToolName = projectToolName;
|
|
38
|
+
exports.projectToolConnection = projectToolConnection;
|
|
39
|
+
exports.projectToolDefinition = projectToolDefinition;
|
|
40
|
+
exports.projectToolDefinitions = projectToolDefinitions;
|
|
41
|
+
exports.projectToolBinding = projectToolBinding;
|
|
42
|
+
exports.projectToolRegistration = projectToolRegistration;
|
|
43
|
+
exports.projectToolResult = projectToolResult;
|
|
44
|
+
exports.projectToolTimeoutMs = projectToolTimeoutMs;
|
|
45
|
+
exports.readProjectToolCatalog = readProjectToolCatalog;
|
|
46
|
+
exports.readProjectToolCatalogFile = readProjectToolCatalogFile;
|
|
47
|
+
exports.createProjectToolCatalog = createProjectToolCatalog;
|
|
48
|
+
const fs = __importStar(require("node:fs"));
|
|
49
|
+
const fsp = __importStar(require("node:fs/promises"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
51
|
+
const node_crypto_1 = require("node:crypto");
|
|
52
|
+
const models_1 = require("./models.js");
|
|
53
|
+
const stateDirectory_1 = require("./stateDirectory.js");
|
|
54
|
+
exports.PROJECT_TOOL_PREFIX = 'ms_';
|
|
55
|
+
exports.MAX_PROJECT_TOOLS_PER_CONNECTION = 80;
|
|
56
|
+
/** A project may advertise how long one of its tools can take; the client honours it up to the cap. */
|
|
57
|
+
exports.PROJECT_TOOL_TIMEOUT_HINT = 'mainsequence.ai/timeout-seconds/v1';
|
|
58
|
+
exports.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS = 120_000;
|
|
59
|
+
exports.MAX_PROJECT_TOOL_TIMEOUT_MS = 300_000;
|
|
60
|
+
const CATALOG_FILE = 'project-tool-catalog.json';
|
|
61
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
62
|
+
const UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
63
|
+
const HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'];
|
|
64
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
|
+
const text = (value, max = 300) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
66
|
+
const clean = (value, max) => value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, max);
|
|
67
|
+
/** `ms_` + the remote name (unsafe characters replaced, cut to 32) + `_` + FNV-1a 32-bit of
|
|
68
|
+
* `backend:environment:release:tool`. The hash binds the saved connection and environment; the
|
|
69
|
+
* prefix keeps the remote name readable. The cut is 32 because Codex qualifies the name as
|
|
70
|
+
* `mcp__mainsequence__<name>` under a 64-character limit; for remote names up to 32 characters
|
|
71
|
+
* the result is the name Claude Desktop has registered since 0.4.13. */
|
|
72
|
+
function projectToolName(backend, environmentUid, releaseUid, tool) {
|
|
73
|
+
const value = `${backend}:${environmentUid}:${releaseUid}:${tool}`;
|
|
74
|
+
let hash = 2166136261;
|
|
75
|
+
for (let i = 0; i < value.length; i++)
|
|
76
|
+
hash = Math.imul(hash ^ value.charCodeAt(i), 16777619) >>> 0;
|
|
77
|
+
return `${exports.PROJECT_TOOL_PREFIX}${String(tool).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32)}_${hash.toString(16)}`;
|
|
78
|
+
}
|
|
79
|
+
const isProjectToolName = (name) => typeof name === 'string' && name.startsWith(exports.PROJECT_TOOL_PREFIX);
|
|
80
|
+
exports.isProjectToolName = isProjectToolName;
|
|
81
|
+
function hints(value) {
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const flag of HINTS)
|
|
84
|
+
if (object(value) && typeof value[flag] === 'boolean')
|
|
85
|
+
result[flag] = value[flag];
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/** Only what a host needs to register and call a tool: name, description, object schema and the four hints. */
|
|
89
|
+
function normalizeTool(value) {
|
|
90
|
+
if (!object(value) || typeof value.name !== 'string' || !value.name || !object(value.inputSchema))
|
|
91
|
+
return;
|
|
92
|
+
return { name: value.name, ...(typeof value.description === 'string' ? { description: value.description } : {}), inputSchema: value.inputSchema, annotations: hints(value.annotations) };
|
|
93
|
+
}
|
|
94
|
+
const normalizeTools = (tools) => (Array.isArray(tools) ? tools : []).map(normalizeTool).filter((tool) => Boolean(tool)).slice(0, exports.MAX_PROJECT_TOOLS_PER_CONNECTION);
|
|
95
|
+
/** The release's own name, else its repository's name, else the release UID. */
|
|
96
|
+
const projectToolConnectionLabel = (connection) => text(connection.name) || text(connection.display_name) || connection.resource_release_uid;
|
|
97
|
+
exports.projectToolConnectionLabel = projectToolConnectionLabel;
|
|
98
|
+
/** The catalog record of a saved connection and its current tools. */
|
|
99
|
+
function projectToolConnection(connection, tools = []) {
|
|
100
|
+
return { backend: connection.backend, releaseUid: connection.resource_release_uid.toLowerCase(), environmentUid: connection.mcp_connection.organization_environment_uid.toLowerCase(),
|
|
101
|
+
environmentName: text(connection.mcp_connection.organization_environment_name), name: (0, exports.projectToolConnectionLabel)(connection), tools: normalizeTools(tools) };
|
|
102
|
+
}
|
|
103
|
+
const source = (connection) => 'mcp_connection' in connection ? projectToolConnection(connection) : connection;
|
|
104
|
+
/** The host-neutral definition of one remote tool: `MainSequence <connection> [<environment>]: <description>`,
|
|
105
|
+
* an object schema (`type: 'object'` added when the server omitted it) and the four boolean hints,
|
|
106
|
+
* open-world by default. */
|
|
107
|
+
function projectToolDefinition(connection, tool) {
|
|
108
|
+
const c = source(connection), label = c.name || c.releaseUid;
|
|
109
|
+
const schema = object(tool.inputSchema) ? tool.inputSchema : {};
|
|
110
|
+
return { name: projectToolName(c.backend, c.environmentUid, c.releaseUid, tool.name), title: `${label}: ${tool.name}`,
|
|
111
|
+
description: clean(`MainSequence ${label} [${c.environmentName || c.environmentUid}]: ${tool.description || tool.name}`, 2000),
|
|
112
|
+
inputSchema: schema.type === 'object' ? schema : { type: 'object', ...schema }, annotations: { openWorldHint: true, ...hints(tool.annotations) } };
|
|
113
|
+
}
|
|
114
|
+
/** Up to 80 definitions per connection, one per distinct name. */
|
|
115
|
+
function projectToolDefinitions(connection, tools) {
|
|
116
|
+
const seen = new Set(), definitions = [];
|
|
117
|
+
for (const tool of normalizeTools(tools ?? ('tools' in connection ? connection.tools : []))) {
|
|
118
|
+
const definition = projectToolDefinition(connection, tool);
|
|
119
|
+
if (seen.has(definition.name))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(definition.name);
|
|
122
|
+
definitions.push(definition);
|
|
123
|
+
}
|
|
124
|
+
return definitions;
|
|
125
|
+
}
|
|
126
|
+
/** What a host sends back to `mcp-call` for a registered tool. */
|
|
127
|
+
function projectToolBinding(connection, tool) {
|
|
128
|
+
const c = source(connection);
|
|
129
|
+
return { backend: c.backend, environmentUid: c.environmentUid, environmentName: c.environmentName, releaseUid: c.releaseUid, connection: c.name || c.releaseUid, tool: typeof tool === 'string' ? tool : tool.name };
|
|
130
|
+
}
|
|
131
|
+
const inScope = (connection, scope) => (!scope?.backend || connection.backend === scope.backend) && (!scope?.environmentUid || connection.environmentUid === scope.environmentUid.toLowerCase());
|
|
132
|
+
/** Definitions and bindings for every tool of the catalog's connections in scope. */
|
|
133
|
+
function projectToolRegistration(catalog, scope) {
|
|
134
|
+
const definitions = [], bindings = new Map();
|
|
135
|
+
for (const connection of catalog?.connections ?? []) {
|
|
136
|
+
if (!inScope(connection, scope))
|
|
137
|
+
continue;
|
|
138
|
+
for (const tool of connection.tools.slice(0, exports.MAX_PROJECT_TOOLS_PER_CONNECTION)) {
|
|
139
|
+
const definition = projectToolDefinition(connection, tool);
|
|
140
|
+
if (bindings.has(definition.name))
|
|
141
|
+
continue;
|
|
142
|
+
bindings.set(definition.name, projectToolBinding(connection, tool));
|
|
143
|
+
definitions.push(definition);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { definitions, bindings };
|
|
147
|
+
}
|
|
148
|
+
/** The host-neutral result of a forwarded `mcp-call`: the remote content blocks, then one JSON text
|
|
149
|
+
* block with `summary`, `effects`, `error` and `structuredContent` when the call failed, returned
|
|
150
|
+
* structured content or no blocks at all; `isError` on any non-success, including `MCP_TOOL_ERROR`,
|
|
151
|
+
* whose original remote content is preserved. A failure without structured content carries that
|
|
152
|
+
* JSON as `structuredContent`, so a host that reads only structured output still sees the error. */
|
|
153
|
+
function projectToolResult(result) {
|
|
154
|
+
const data = object(result.data) ? result.data : undefined;
|
|
155
|
+
const blocks = Array.isArray(data?.content) ? data.content.filter((block) => object(block) && typeof block.type === 'string') : [];
|
|
156
|
+
const failed = result.status !== 'success', structured = data?.structuredContent;
|
|
157
|
+
const extra = { summary: result.summary, ...(result.effects ? { effects: result.effects } : {}), ...(result.error ? { error: result.error } : {}), ...(structured !== undefined ? { structuredContent: structured } : {}) };
|
|
158
|
+
const content = [...blocks, ...(!blocks.length || failed || structured !== undefined ? [{ type: 'text', text: JSON.stringify(extra) }] : [])];
|
|
159
|
+
return { ...(failed ? { isError: true } : {}), content, ...(structured !== undefined ? { structuredContent: structured } : failed ? { structuredContent: extra } : {}) };
|
|
160
|
+
}
|
|
161
|
+
/** The HTTP budget for one `tools/call`: the default, or the tool's advertised seconds when larger, capped. */
|
|
162
|
+
function projectToolTimeoutMs(tool, defaultMs = exports.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS) {
|
|
163
|
+
const hint = object(tool?._meta) ? tool._meta[exports.PROJECT_TOOL_TIMEOUT_HINT] : undefined;
|
|
164
|
+
if (typeof hint !== 'number' || !Number.isFinite(hint) || hint <= 0)
|
|
165
|
+
return defaultMs;
|
|
166
|
+
return Math.max(defaultMs, Math.min(Math.round(hint * 1000), exports.MAX_PROJECT_TOOL_TIMEOUT_MS));
|
|
167
|
+
}
|
|
168
|
+
function success(result) {
|
|
169
|
+
if (result.status !== 'success')
|
|
170
|
+
throw new models_1.ToolingError(result.error?.code ?? 'API_ERROR', result.summary);
|
|
171
|
+
if (result.data === undefined)
|
|
172
|
+
throw new models_1.ToolingError('API_ERROR', 'MainSequence returned no data.');
|
|
173
|
+
return result.data;
|
|
174
|
+
}
|
|
175
|
+
/** Every saved connection in scope and its current tools: `mcp-list` (read-only), then one `mcp-tools`
|
|
176
|
+
* per connection, two at a time (the shared transport's per-origin limit). A connection whose catalog
|
|
177
|
+
* cannot be read now keeps its `previous` tools and is named in `unavailable`; a removed connection
|
|
178
|
+
* loses them; connections outside the scope are carried over from `previous` unchanged. No account
|
|
179
|
+
* snapshot is attached: a registered tool is bound to a saved connection, and the session for that
|
|
180
|
+
* connection's backend is verified when the tool is called. */
|
|
181
|
+
async function readProjectToolCatalog(dispatch, options = {}) {
|
|
182
|
+
const status = options.session ?? success(await dispatch({ action: 'auth-status', ...(options.backend ? { backend: options.backend } : {}) }));
|
|
183
|
+
if (!status.authenticated || !status.backend)
|
|
184
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to register saved project MCP tools.');
|
|
185
|
+
const backend = status.backend, environmentUid = options.environmentUid?.toLowerCase();
|
|
186
|
+
if (environmentUid !== undefined && !UUID.test(environmentUid))
|
|
187
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'The environment must be an organization environment UUID.');
|
|
188
|
+
const scope = { backend, environmentUid }, environment = environmentUid ? { environmentUid } : {};
|
|
189
|
+
const rows = success(await dispatch({ action: 'mcp-list', backend, readOnly: true, ...environment }));
|
|
190
|
+
const previous = options.previous?.connections ?? [];
|
|
191
|
+
const connections = previous.filter(connection => !inScope(connection, scope)), unavailable = [];
|
|
192
|
+
let retained = 0;
|
|
193
|
+
const pending = [...rows];
|
|
194
|
+
await Promise.all(Array.from({ length: Math.min(2, pending.length) }, async () => {
|
|
195
|
+
while (pending.length) {
|
|
196
|
+
options.signal?.throwIfAborted();
|
|
197
|
+
const row = pending.shift();
|
|
198
|
+
try {
|
|
199
|
+
const output = success(await dispatch({ action: 'mcp-tools', backend, releaseUid: row.resource_release_uid, ...environment }));
|
|
200
|
+
const current = output.connection;
|
|
201
|
+
connections.push(projectToolConnection({ ...row, name: row.name ?? current?.name, display_name: row.display_name ?? current?.display_name,
|
|
202
|
+
mcp_connection: { ...row.mcp_connection, organization_environment_name: row.mcp_connection.organization_environment_name ?? current?.mcp_connection?.organization_environment_name } }, output.tools));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
206
|
+
throw error;
|
|
207
|
+
options.signal?.throwIfAborted();
|
|
208
|
+
const earlier = previous.find(connection => connection.backend === backend && connection.releaseUid === row.resource_release_uid.toLowerCase());
|
|
209
|
+
if (earlier) {
|
|
210
|
+
connections.push(earlier);
|
|
211
|
+
retained += earlier.tools.length;
|
|
212
|
+
}
|
|
213
|
+
unavailable.push((0, exports.projectToolConnectionLabel)(row));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}));
|
|
217
|
+
connections.sort((a, b) => a.backend.localeCompare(b.backend) || a.environmentUid.localeCompare(b.environmentUid) || a.releaseUid.localeCompare(b.releaseUid));
|
|
218
|
+
return { catalog: { version: 1, updatedAt: new Date().toISOString(), connections }, backend, unavailable, retained };
|
|
219
|
+
}
|
|
220
|
+
function connectionRecord(value) {
|
|
221
|
+
if (!object(value) || typeof value.backend !== 'string' || !UUID.test(String(value.releaseUid)) || !UUID.test(String(value.environmentUid)) || !Array.isArray(value.tools))
|
|
222
|
+
return;
|
|
223
|
+
return { backend: value.backend, releaseUid: String(value.releaseUid).toLowerCase(), environmentUid: String(value.environmentUid).toLowerCase(),
|
|
224
|
+
environmentName: text(value.environmentName), name: text(value.name), tools: normalizeTools(value.tools) };
|
|
225
|
+
}
|
|
226
|
+
/** Beside `mcp-connections.json`, shared by every host on the machine. */
|
|
227
|
+
const projectToolCatalogPath = (env = process.env) => path.join((0, stateDirectory_1.mainSequenceStateDirectory)(env), CATALOG_FILE);
|
|
228
|
+
exports.projectToolCatalogPath = projectToolCatalogPath;
|
|
229
|
+
/** A missing or damaged cache is an empty catalog, never an error. */
|
|
230
|
+
function readProjectToolCatalogFile(file) {
|
|
231
|
+
try {
|
|
232
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
233
|
+
if (!object(raw) || raw.version !== 1 || !Array.isArray(raw.connections))
|
|
234
|
+
return null;
|
|
235
|
+
return { version: 1, updatedAt: text(raw.updatedAt, 40), connections: raw.connections.map(connectionRecord).filter((item) => Boolean(item)) };
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const sleep = (ms, signal) => new Promise(resolve => {
|
|
242
|
+
const timer = setTimeout(done, ms);
|
|
243
|
+
function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
|
|
244
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
245
|
+
});
|
|
246
|
+
/** The file-backed catalog: read at once from the shared cache so a host can register tools before
|
|
247
|
+
* any network request, refreshed under a lock so one process reads the catalogs at a time while the
|
|
248
|
+
* others pick its result up from the file, and reloaded when another process wrote it. */
|
|
249
|
+
function createProjectToolCatalog(options) {
|
|
250
|
+
const file = options.file ?? (0, exports.projectToolCatalogPath)(options.env), staleMs = options.staleMs ?? 15 * 60_000;
|
|
251
|
+
let catalog = readProjectToolCatalogFile(file), loadedAt = mtime(), running = null;
|
|
252
|
+
let current = null;
|
|
253
|
+
// The scope of the last refresh: the current account's backend (learned from auth-status), so a
|
|
254
|
+
// listing shows this host's connections even when another backend's are cached beside them.
|
|
255
|
+
let known;
|
|
256
|
+
const state = { unavailable: [], retained: 0, error: null };
|
|
257
|
+
function mtime() { try {
|
|
258
|
+
return fs.statSync(file).mtimeMs;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return 0;
|
|
262
|
+
} }
|
|
263
|
+
/** Another process may have refreshed the shared cache since this one read it. */
|
|
264
|
+
function reload() {
|
|
265
|
+
const now = mtime();
|
|
266
|
+
if (now === loadedAt)
|
|
267
|
+
return;
|
|
268
|
+
catalog = readProjectToolCatalogFile(file);
|
|
269
|
+
loadedAt = now;
|
|
270
|
+
}
|
|
271
|
+
const registration = (scope) => projectToolRegistration(catalog, scope ?? known);
|
|
272
|
+
async function write(next) {
|
|
273
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
274
|
+
const temporary = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
275
|
+
try {
|
|
276
|
+
await fsp.writeFile(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
277
|
+
await fsp.rename(temporary, file);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await fsp.rm(temporary, { force: true });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function adopt(next, scope) {
|
|
284
|
+
const before = JSON.stringify(registration().definitions);
|
|
285
|
+
if (next) {
|
|
286
|
+
catalog = next;
|
|
287
|
+
loadedAt = mtime();
|
|
288
|
+
}
|
|
289
|
+
if (scope)
|
|
290
|
+
known = scope;
|
|
291
|
+
if (JSON.stringify(registration().definitions) !== before)
|
|
292
|
+
options.notify?.();
|
|
293
|
+
}
|
|
294
|
+
/** One process refreshes at a time. A live lock means another process is refreshing: without a
|
|
295
|
+
* wait the caller reuses its result from the file; with one it waits for the lock up to `waitMs`.
|
|
296
|
+
* An interrupted refresh from a closed host must not block every later one (stale after 10 min). */
|
|
297
|
+
async function lock(waitMs, signal) {
|
|
298
|
+
const name = file + '.lock', deadline = Date.now() + waitMs;
|
|
299
|
+
for (let attempt = 0; attempt < 10_000; attempt++) {
|
|
300
|
+
signal.throwIfAborted();
|
|
301
|
+
try {
|
|
302
|
+
const handle = await fsp.open(name, 'wx', 0o600);
|
|
303
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
|
|
304
|
+
return async () => { await handle.close(); await fsp.rm(name, { force: true }); };
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error?.code === 'ENOENT') {
|
|
308
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (error?.code !== 'EEXIST')
|
|
312
|
+
return null;
|
|
313
|
+
let stale;
|
|
314
|
+
try {
|
|
315
|
+
stale = Date.now() - (await fsp.stat(name)).mtimeMs >= LOCK_STALE_MS;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (stale) {
|
|
321
|
+
await fsp.rm(name, { force: true });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (Date.now() >= deadline)
|
|
325
|
+
return null;
|
|
326
|
+
await sleep(250, signal);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
file,
|
|
333
|
+
/** Definitions for the host's tool list, from the newest shared cache. */
|
|
334
|
+
list(scope) { reload(); return registration(scope).definitions; },
|
|
335
|
+
binding(name) { reload(); return registration({}).bindings.get(name); },
|
|
336
|
+
state(scope) {
|
|
337
|
+
reload();
|
|
338
|
+
const selected = scope ?? known;
|
|
339
|
+
return { count: registration(scope).definitions.length,
|
|
340
|
+
connections: (catalog?.connections ?? []).filter(connection => inScope(connection, selected)).map(connection => ({ backend: connection.backend, releaseUid: connection.releaseUid,
|
|
341
|
+
environmentUid: connection.environmentUid, environmentName: connection.environmentName, name: connection.name, tools: connection.tools.length })),
|
|
342
|
+
updatedAt: catalog?.updatedAt || null, refreshing: running !== null, unavailable: state.unavailable, retained: state.retained, error: state.error };
|
|
343
|
+
},
|
|
344
|
+
/** The state plus definitions and bindings, as the `mcp-catalog` action returns them. */
|
|
345
|
+
view(scope) {
|
|
346
|
+
const { definitions, bindings } = registration(scope);
|
|
347
|
+
return { ...this.state(scope), definitions, bindings: Object.fromEntries(bindings) };
|
|
348
|
+
},
|
|
349
|
+
/** Read every saved connection's current catalog. Without `force`, a fresh cache is kept. Resolves
|
|
350
|
+
* true when this process read and wrote the catalog; the state carries why it did not. */
|
|
351
|
+
refresh(force = false, extra = {}) {
|
|
352
|
+
if (options.disabled)
|
|
353
|
+
return Promise.resolve(false);
|
|
354
|
+
if (running)
|
|
355
|
+
return running;
|
|
356
|
+
reload();
|
|
357
|
+
const controller = new AbortController();
|
|
358
|
+
current = { controller, locked: false };
|
|
359
|
+
running = (async () => {
|
|
360
|
+
const dispatch = options.dispatch(controller.signal), scope = extra.scope;
|
|
361
|
+
const fresh = () => Boolean(catalog && Date.parse(catalog.updatedAt) > Date.now() - staleMs);
|
|
362
|
+
try {
|
|
363
|
+
// A fresh cache with a known scope needs nothing; otherwise one local session read tells
|
|
364
|
+
// whether there is a session to read catalogs with, and which backend the listing is for.
|
|
365
|
+
if (!force && (known || scope) && fresh()) {
|
|
366
|
+
if (scope)
|
|
367
|
+
adopt(null, scope);
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const session = success(await dispatch({ action: 'auth-status', ...(scope?.backend ? { backend: scope.backend } : {}) }));
|
|
371
|
+
if (session.backend)
|
|
372
|
+
adopt(null, scope ?? { backend: session.backend });
|
|
373
|
+
if (!session.authenticated || !session.backend)
|
|
374
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to register saved project MCP tools.');
|
|
375
|
+
if (!force && fresh())
|
|
376
|
+
return false;
|
|
377
|
+
const release = await lock(extra.waitMs ?? 0, controller.signal);
|
|
378
|
+
if (!release) {
|
|
379
|
+
if (extra.waitMs)
|
|
380
|
+
state.error = { code: 'PROJECT_BUSY', message: 'Another MainSequence process is refreshing the project tool catalog. The registered tools come from its last result; refresh again later.' };
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
current.locked = true;
|
|
384
|
+
try {
|
|
385
|
+
reload();
|
|
386
|
+
const read = await readProjectToolCatalog(dispatch, { ...scope, backend: session.backend, previous: catalog, signal: controller.signal, session });
|
|
387
|
+
await write(read.catalog);
|
|
388
|
+
state.unavailable = read.unavailable;
|
|
389
|
+
state.retained = read.retained;
|
|
390
|
+
state.error = null;
|
|
391
|
+
adopt(read.catalog, scope ?? { backend: read.backend });
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
current.locked = false;
|
|
396
|
+
await release();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
state.error = { code: error instanceof models_1.ToolingError ? error.code : 'INTERNAL_ERROR', message: error instanceof models_1.ToolingError ? error.message : 'The saved project MCP tools could not be refreshed.' };
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
})().finally(() => { running = null; current = null; });
|
|
404
|
+
return running;
|
|
405
|
+
},
|
|
406
|
+
/** A host process is ending: stop its refresh and free the shared lock for the next one at once. */
|
|
407
|
+
dispose() {
|
|
408
|
+
if (!current)
|
|
409
|
+
return;
|
|
410
|
+
current.controller.abort();
|
|
411
|
+
if (current.locked) {
|
|
412
|
+
try {
|
|
413
|
+
fs.rmSync(file + '.lock', { force: true });
|
|
414
|
+
}
|
|
415
|
+
catch { /* the stale-lock rule still applies */ }
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
/** A catalog just read explicitly for one saved release replaces that connection's tools without another round trip. */
|
|
419
|
+
async update(releaseUid, tools, backend) {
|
|
420
|
+
if (options.disabled)
|
|
421
|
+
return;
|
|
422
|
+
reload();
|
|
423
|
+
const index = catalog?.connections.findIndex(item => item.releaseUid === releaseUid.toLowerCase() && (!backend || item.backend === backend)) ?? -1;
|
|
424
|
+
if (!catalog || index < 0) {
|
|
425
|
+
void this.refresh(true);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const connections = catalog.connections.map((item, i) => i === index ? { ...item, tools: normalizeTools(tools) } : item);
|
|
429
|
+
const next = { version: 1, updatedAt: new Date().toISOString(), connections };
|
|
430
|
+
try {
|
|
431
|
+
await write(next);
|
|
432
|
+
adopt(next);
|
|
433
|
+
}
|
|
434
|
+
catch { /* the in-memory tools are already current */ }
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|