@dev-mainsequence/codex-plugin 0.3.0 → 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.
Files changed (39) hide show
  1. package/.codex-plugin/plugin.json +6 -5
  2. package/.mcp.json +17 -0
  3. package/README.md +49 -11
  4. package/assets/mainsequence-mark.svg +453 -0
  5. package/build-info.json +3 -3
  6. package/dist/hosts/codexPresentation.js +1 -0
  7. package/dist/hosts/discovery.js +252 -0
  8. package/dist/hosts/messaging.js +63 -0
  9. package/dist/hosts/panel/panel.html +265 -0
  10. package/dist/hosts/workspace.js +107 -0
  11. package/dist/panel-server.js +223 -0
  12. package/dist/vendor/devtools/index.js +45 -1
  13. package/dist/vendor/devtools/package.json +1 -1
  14. package/dist/vendor/devtools/shared/accountScope.js +19 -0
  15. package/dist/vendor/devtools/shared/actions.js +44 -7
  16. package/dist/vendor/devtools/shared/agentAssets.js +38 -0
  17. package/dist/vendor/devtools/shared/agentDelivery.js +477 -0
  18. package/dist/vendor/devtools/shared/agentIcon.js +43 -0
  19. package/dist/vendor/devtools/shared/agents.js +18 -7
  20. package/dist/vendor/devtools/shared/arguments.js +16 -9
  21. package/dist/vendor/devtools/shared/deployments.js +214 -0
  22. package/dist/vendor/devtools/shared/environments.js +38 -0
  23. package/dist/vendor/devtools/shared/mcp.js +237 -46
  24. package/dist/vendor/devtools/shared/mcpTransport.js +79 -23
  25. package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +8 -3
  26. package/dist/vendor/devtools/shared/organizationProjects.js +2 -13
  27. package/dist/vendor/devtools/shared/projectTools.js +437 -0
  28. package/dist/vendor/devtools/shared/repositories.js +2 -2
  29. package/dist/vendor/devtools/shared/session.js +133 -14
  30. package/dist/vendor/devtools/shared/sharedTransport.js +506 -0
  31. package/dist/vendor/devtools/shared/stateDirectory.js +44 -0
  32. package/dist/vendor/devtools/shared/workingDirectory.js +13 -0
  33. package/dist/vendor/devtools/shared/workspaceSettings.js +96 -0
  34. package/docs/agents-tools-panel.md +522 -0
  35. package/docs/codex-verification.md +37 -0
  36. package/docs/conversational-mcp.md +16 -3
  37. package/package.json +9 -5
  38. package/skills/mainsequence-mcp/SKILL.md +19 -4
  39. package/skills/ms/assets/mainsequence-mark.svg +453 -0
@@ -34,19 +34,50 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.McpConnections = void 0;
37
+ exports.createMcpConnectionCache = createMcpConnectionCache;
37
38
  const fs = __importStar(require("node:fs/promises"));
38
39
  const path = __importStar(require("node:path"));
39
- const os = __importStar(require("node:os"));
40
40
  const node_crypto_1 = require("node:crypto");
41
41
  const models_1 = require("./models.js");
42
42
  const session_1 = require("./session.js");
43
43
  const mcpTransport_1 = require("./mcpTransport.js");
44
+ const stateDirectory_1 = require("./stateDirectory.js");
45
+ const projectTools_1 = require("./projectTools.js");
46
+ const repositoryModels_1 = require("./repositoryModels.js");
47
+ const environments_1 = require("./environments.js");
48
+ const accountScope_1 = require("./accountScope.js");
49
+ const caches = new WeakMap();
50
+ function createMcpConnectionCache() {
51
+ const cache = Object.freeze({ kind: 'mainsequence-mcp-cache' });
52
+ caches.set(cache, new Map());
53
+ return cache;
54
+ }
55
+ const cacheKey = (backend, uid) => JSON.stringify([backend, uid]);
56
+ const savedIdentity = (value) => JSON.stringify([value.backend, value.resource_release_uid, value.mcp_connection]);
57
+ function credentialExpiry(token) {
58
+ // This is only a conservative cache lifetime hint; the server verifies the JWT.
59
+ try {
60
+ const exp = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp;
61
+ return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : 0;
62
+ }
63
+ catch {
64
+ return 0;
65
+ }
66
+ }
67
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
68
  function uuid(value) {
45
- if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
69
+ if (typeof value !== 'string' || !UUID.test(value)) {
46
70
  throw new models_1.ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.');
47
71
  }
48
72
  return value.toLowerCase();
49
73
  }
74
+ /** Optional labels: an absent or malformed value is dropped, never an error. */
75
+ function labels(row) {
76
+ const text = (value) => typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value) ? value.trim() : undefined;
77
+ const name = text(row?.name), display = text(row?.display_name);
78
+ const branch = typeof row?.code_repository_branch_uid === 'string' && UUID.test(row.code_repository_branch_uid) ? row.code_repository_branch_uid.toLowerCase() : undefined;
79
+ return { ...(name ? { name } : {}), ...(display ? { display_name: display } : {}), ...(branch ? { code_repository_branch_uid: branch } : {}) };
80
+ }
50
81
  function endpoint(value, metadata = false) {
51
82
  if (typeof value !== 'string')
52
83
  throw new models_1.ToolingError('API_ERROR', 'The backend returned no MCP endpoint.');
@@ -76,27 +107,27 @@ function connection(backend, row) {
76
107
  if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1
77
108
  || metadataQuery.get('resource_release_uid') !== releaseUid)
78
109
  throw new models_1.ToolingError('API_ERROR', 'The MCP metadata targets another release.');
79
- return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid,
80
- ...(typeof row.name === 'string' && row.name.trim() ? { name: row.name } : {}),
110
+ return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid, ...labels(row),
81
111
  mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl,
82
112
  organization_environment_uid: uuid(c.organization_environment_uid),
83
113
  ...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } };
84
114
  }
85
115
  function storagePath(env) {
86
- const dir = process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI')
87
- : process.platform === 'win32' ? path.join(env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence');
88
- return path.join(dir, 'mcp-connections.json');
116
+ return path.join((0, stateDirectory_1.mainSequenceStateDirectory)(env), 'mcp-connections.json');
89
117
  }
90
- /** Only endpoint metadata is persisted. Release credentials exist in one invocation's memory. */
118
+ /** Only endpoint metadata is persisted. A bounded runtime may reuse an in-memory grant. */
91
119
  class McpConnections {
92
- session;
120
+ originalSession;
93
121
  options;
94
122
  file;
95
123
  secrets = new Set();
96
- constructor(session, options = {}) {
97
- this.session = session;
124
+ session;
125
+ checkAccount;
126
+ constructor(originalSession, options = {}) {
127
+ this.originalSession = originalSession;
98
128
  this.options = options;
99
129
  this.file = options.connectionsPath ?? storagePath(options.env ?? process.env);
130
+ this.session = originalSession;
100
131
  }
101
132
  sanitize(value) {
102
133
  if (typeof value === 'string') {
@@ -128,6 +159,7 @@ class McpConnections {
128
159
  }
129
160
  async update(change) {
130
161
  this.options.signal?.throwIfAborted();
162
+ await this.checkAccount?.();
131
163
  await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
132
164
  const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
133
165
  let handle;
@@ -165,6 +197,16 @@ class McpConnections {
165
197
  throw new models_1.ToolingError('API_ERROR', 'The backend returned a different release.');
166
198
  return value;
167
199
  }
200
+ get projectTimeoutMs() { return this.options.projectRequestTimeoutMs ?? projectTools_1.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS; }
201
+ client(value, token, initialized = false) {
202
+ this.secrets.add(token);
203
+ return new mcpTransport_1.McpTransport(async (message, request) => {
204
+ await this.checkAccount?.();
205
+ const result = await (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, token, message, { fetch: this.options.fetch, signal: this.options.signal, timeoutMs: request?.timeoutMs ?? this.projectTimeoutMs });
206
+ await this.checkAccount?.();
207
+ return result;
208
+ }, initialized);
209
+ }
168
210
  async open(value) {
169
211
  const grant = await this.session.request(value.backend, 'GET', '/api/v1/resource-releases/' + value.resource_release_uid + '/exchange-launch/');
170
212
  if (grant?.mode !== 'token' || grant.release_kind !== 'fastapi' || typeof grant.token !== 'string' || !grant.token || /[\r\n]/.test(grant.token)) {
@@ -174,15 +216,153 @@ class McpConnections {
174
216
  if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
175
217
  throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
176
218
  }
177
- const client = new mcpTransport_1.McpTransport(message => (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, grant.token, message, this.options));
219
+ const client = this.client(value, grant.token);
178
220
  await client.initialize();
179
- return client;
221
+ return { client, token: grant.token, expiresAt: credentialExpiry(grant.token) };
222
+ }
223
+ /** The release's name and repository from the release directory and its branch. Whatever
224
+ * cannot be read is left out: labels never make a working MCP unavailable. */
225
+ async resolveLabels(value) {
226
+ const found = {};
227
+ const read = async (apiPath) => {
228
+ try {
229
+ return await this.session.request(value.backend, 'GET', apiPath, undefined, true);
230
+ }
231
+ catch {
232
+ this.options.signal?.throwIfAborted();
233
+ return undefined;
234
+ }
235
+ };
236
+ // The release detail carries no name or repository; the directory projection does.
237
+ const page = await read('/api/v1/resource-releases/?search=' + encodeURIComponent(value.resource_release_uid) + '&limit=100');
238
+ const row = Array.isArray(page?.results) ? page.results.find((item) => typeof item?.uid === 'string' && item.uid.toLowerCase() === value.resource_release_uid) : undefined;
239
+ if (row)
240
+ Object.assign(found, labels({ name: row.name, code_repository_branch_uid: row.code_repository_branch_uid }));
241
+ const branchUid = found.code_repository_branch_uid ?? value.code_repository_branch_uid;
242
+ if (branchUid) {
243
+ const branch = await read('/api/v1/code-repository-branches/' + encodeURIComponent(branchUid) + '/');
244
+ try {
245
+ const parsed = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(branch, 'CodeRepositoryBranch response');
246
+ if (parsed.uid.toLowerCase() === branchUid)
247
+ Object.assign(found, labels({ display_name: parsed.code_repository_name, code_repository_branch_uid: branchUid }));
248
+ }
249
+ catch { /* an unreadable branch leaves the repository name out */ }
250
+ }
251
+ return found;
252
+ }
253
+ labelled(value, found) {
254
+ return connection(value.backend, { ...value, ...found });
255
+ }
256
+ /** Optional discovery labels, deduplicated by branch. Never writes connection metadata. */
257
+ async discoveryNames(rows) {
258
+ const pending = [...new Set(rows.filter(row => !row.display_name).flatMap(row => row.code_repository_branch_uid ? [row.code_repository_branch_uid] : []))];
259
+ const names = new Map();
260
+ await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
261
+ while (pending.length) {
262
+ this.options.signal?.throwIfAborted();
263
+ const uid = pending.shift();
264
+ try {
265
+ const raw = await this.session.request(rows[0].backend, 'GET', '/api/v1/code-repository-branches/' + uid + '/');
266
+ const branch = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(raw, 'CodeRepositoryBranch response');
267
+ if (branch.uid.toLowerCase() === uid)
268
+ names.set(uid, branch.code_repository_name);
269
+ }
270
+ catch (error) {
271
+ this.options.signal?.throwIfAborted();
272
+ if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
273
+ throw error;
274
+ await this.checkAccount?.();
275
+ // Unavailable optional names must not hide usable endpoints.
276
+ }
277
+ }
278
+ }));
279
+ return rows.map(row => !row.display_name && row.code_repository_branch_uid && names.has(row.code_repository_branch_uid)
280
+ ? this.labelled(row, { display_name: names.get(row.code_repository_branch_uid) }) : row);
281
+ }
282
+ /** Records saved before labels were stored are completed once, when the backend answers. */
283
+ async backfill(rows) {
284
+ const pending = rows.filter(row => !row.display_name);
285
+ if (!pending.length)
286
+ return rows;
287
+ const found = new Map();
288
+ await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
289
+ while (pending.length) {
290
+ const row = pending.shift();
291
+ const resolved = await this.resolveLabels(row);
292
+ const added = Object.entries(resolved).filter(([key, item]) => item !== row[key]);
293
+ if (added.length)
294
+ found.set(row.backend + ' ' + row.resource_release_uid, Object.fromEntries(added));
295
+ }
296
+ }));
297
+ if (!found.size)
298
+ return rows;
299
+ const key = (row) => row.backend + ' ' + row.resource_release_uid;
300
+ const merge = (row) => found.has(key(row)) ? this.labelled(row, { ...found.get(key(row)) }) : row;
301
+ try {
302
+ await this.update(all => all.map(merge));
303
+ }
304
+ catch (error) {
305
+ // A held lock or a failed write keeps the listing itself available.
306
+ if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
307
+ throw error;
308
+ this.options.signal?.throwIfAborted();
309
+ }
310
+ return rows.map(merge);
180
311
  }
181
312
  async dispatch(request, inputBackend) {
182
313
  let callStarted = false, resultData;
314
+ let key, reused = false;
315
+ let retained;
316
+ const cache = this.options.cache ? caches.get(this.options.cache) : undefined;
183
317
  try {
184
318
  const backend = (0, session_1.requireBackend)(inputBackend);
185
319
  this.options.signal?.throwIfAborted();
320
+ if (request.action === 'mcp-catalog' || request.action === 'mcp-tool-call')
321
+ throw new models_1.ToolingError('INVALID_REQUEST', 'mcp-catalog and mcp-tool-call are dispatched by createDevTools(), not by the connection client.');
322
+ let cached, selected;
323
+ if ('releaseUid' in request) {
324
+ key = cacheKey(backend, uuid(request.releaseUid));
325
+ if (cache && request.action === 'mcp-remove')
326
+ cache.delete(key);
327
+ else if (cache) {
328
+ for (const [id, item] of cache)
329
+ if (item.expiresAt <= Date.now())
330
+ cache.delete(id);
331
+ selected = await this.originalSession.selectionIdentity?.(backend);
332
+ cached = cache.get(key);
333
+ if (cached && (cached.identity !== selected?.key || cached.account !== request.expectedAccount || !['mcp-tools', 'mcp-call'].includes(request.action))) {
334
+ cache.delete(key);
335
+ cached = undefined;
336
+ }
337
+ if (cached) {
338
+ const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === request.releaseUid);
339
+ if (!saved || savedIdentity(saved) !== cached.savedIdentity) {
340
+ cache.delete(key);
341
+ cached = undefined;
342
+ }
343
+ }
344
+ }
345
+ }
346
+ reused = Boolean(cached);
347
+ if (cached) {
348
+ const identity = cached.identity;
349
+ this.checkAccount = async () => {
350
+ this.options.signal?.throwIfAborted();
351
+ if ((await this.originalSession.selectionIdentity?.(backend))?.key !== identity)
352
+ throw new models_1.ToolingError('AUTH_REJECTED', 'The shared MainSequence login changed. Refresh the current view.');
353
+ };
354
+ await this.checkAccount();
355
+ }
356
+ else {
357
+ this.session = await (0, accountScope_1.bindSessionAccount)(this.originalSession, backend, request.expectedAccount);
358
+ this.checkAccount = request.expectedAccount === undefined ? undefined : (fresh = false) => this.session.status(backend, fresh).then(() => { });
359
+ if (cache && key)
360
+ selected = await this.originalSession.selectionIdentity?.(backend);
361
+ }
362
+ if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean'
363
+ || request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') {
364
+ throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.');
365
+ }
186
366
  const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid);
187
367
  const belongs = (value) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid;
188
368
  const requireEnvironment = (value) => {
@@ -191,31 +371,7 @@ class McpConnections {
191
371
  };
192
372
  let data, summary;
193
373
  if (request.action === 'mcp-environments') {
194
- const environments = new Map();
195
- for (let offset = 0;; offset += 100) {
196
- this.options.signal?.throwIfAborted();
197
- if (offset >= 10000)
198
- throw new models_1.ToolingError('API_ERROR', 'The environment list is too large.');
199
- const page = await this.session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
200
- const items = Array.isArray(page) ? page : page?.results;
201
- if (!Array.isArray(items))
202
- throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid environment list.');
203
- for (const row of items) {
204
- if (typeof row?.name !== 'string' || !row.name.trim())
205
- throw new models_1.ToolingError('API_ERROR', 'An organization environment has no name.');
206
- const uid = uuid(row.uid);
207
- environments.set(uid, { uid, name: row.name.trim(), isProduction: row.is_production === true });
208
- }
209
- if (Array.isArray(page))
210
- break;
211
- if (!Number.isInteger(page.count) || page.count < 0)
212
- throw new models_1.ToolingError('API_ERROR', 'The environment pagination is invalid.');
213
- if (offset + 100 >= page.count)
214
- break;
215
- if (!items.length)
216
- throw new models_1.ToolingError('API_ERROR', 'The environment directory changed; refresh it.');
217
- }
218
- data = [...environments.values()];
374
+ data = await (0, environments_1.listEnvironments)(this.session, backend, this.options.signal);
219
375
  summary = 'Organization environments available to your MainSequence account.';
220
376
  }
221
377
  else if (request.action === 'mcp-discover') {
@@ -255,10 +411,15 @@ class McpConnections {
255
411
  const values = [...matching.values()], offset = request.offset ?? 0;
256
412
  data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) };
257
413
  }
414
+ if (request.enrichNames) {
415
+ const page = data;
416
+ data = { ...page, results: await this.discoveryNames(page.results) };
417
+ }
258
418
  summary = 'Available project MCP endpoints; discovery does not save a connection.';
259
419
  }
260
420
  else if (request.action === 'mcp-list') {
261
- data = (await this.read()).filter(row => row.backend === backend && belongs(row));
421
+ const saved = (await this.read()).filter(row => row.backend === backend && belongs(row));
422
+ data = request.readOnly ? saved : await this.backfill(saved);
262
423
  summary = 'Saved MCP connections for this backend. Availability is checked when used.';
263
424
  }
264
425
  else {
@@ -272,7 +433,8 @@ class McpConnections {
272
433
  summary = 'The saved connection was removed.';
273
434
  }
274
435
  else {
275
- const value = await this.current(backend, releaseUid);
436
+ let value = cached?.value ?? await this.current(backend, releaseUid);
437
+ let savedSnapshot = savedIdentity(value);
276
438
  requireEnvironment(value);
277
439
  if (request.action !== 'mcp-add') {
278
440
  const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
@@ -280,10 +442,17 @@ class McpConnections {
280
442
  throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
281
443
  if (saved.mcp_connection.url !== value.mcp_connection.url)
282
444
  throw new models_1.ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.');
445
+ savedSnapshot = savedIdentity(saved);
446
+ value = this.labelled(value, labels(saved));
283
447
  }
284
- const client = await this.open(value);
285
- const tools = await client.tools();
448
+ const opened = cached ? { client: this.client(value, cached.token, true), token: cached.token, expiresAt: cached.expiresAt } : await this.open(value);
449
+ const client = opened.client;
450
+ // An explicit catalog read always refreshes tools. A following call reuses it.
451
+ let tools = cached && request.action === 'mcp-call' ? cached.tools : await client.tools();
452
+ if (cached && request.action === 'mcp-call' && !tools.some(tool => tool.name === request.tool))
453
+ tools = await client.tools();
286
454
  if (request.action === 'mcp-add') {
455
+ value = this.labelled(value, await this.resolveLabels(value));
287
456
  await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
288
457
  data = { connection: value, tools };
289
458
  summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.';
@@ -299,26 +468,48 @@ class McpConnections {
299
468
  throw new models_1.ToolingError('INVALID_REQUEST', 'Tool arguments must be a JSON object.');
300
469
  this.options.signal?.throwIfAborted();
301
470
  callStarted = true;
302
- const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments });
471
+ // A tool that advertises a longer run (`_meta["mainsequence.ai/timeout-seconds/v1"]`) gets it, capped at 300 s.
472
+ const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments }, { timeoutMs: (0, projectTools_1.projectToolTimeoutMs)(tools.find(tool => tool.name === request.tool), this.projectTimeoutMs) });
303
473
  if (!output || !Array.isArray(output.content))
304
474
  throw new models_1.ToolingError('API_ERROR', 'The MCP tool returned an invalid result.');
305
475
  resultData = output;
306
- if (output.isError)
307
- throw new models_1.ToolingError('API_ERROR', 'The MCP tool reported an error. Inspect its result before retrying.');
476
+ if (output.isError) {
477
+ await this.checkAccount?.(!reused);
478
+ throw new models_1.ToolingError('MCP_TOOL_ERROR', 'The connected MCP tool returned an error. Its original content and structuredContent are included in the result.');
479
+ }
308
480
  data = output;
309
481
  summary = 'The MCP tool completed.';
310
482
  }
311
483
  else
312
484
  throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown MCP action.');
485
+ if (cache && key && selected && (await this.originalSession.selectionIdentity?.(backend))?.key === selected.key) {
486
+ // Check local selection again before retaining a grant; never extend a lease
487
+ // on a warm call. Unknown/expired credential lifetimes are not cached.
488
+ // A cold setup may renew the same account while resolving labels or
489
+ // checking access. The account-bound checks below remain authoritative;
490
+ // skip retaining an old identity instead of failing an already saved connection.
491
+ const expiresAt = cached?.expiresAt ?? Math.min(Date.now() + 60_000, opened.expiresAt - 5_000, (selected.expiresAt ?? Infinity) - 5_000);
492
+ if (expiresAt > Date.now()) {
493
+ retained = { value, token: opened.token, tools, identity: selected.key, account: request.expectedAccount, expiresAt, savedIdentity: savedSnapshot };
494
+ }
495
+ }
313
496
  }
314
497
  }
498
+ await this.checkAccount?.(!reused);
499
+ if (cache && key && retained && retained.expiresAt > Date.now()) {
500
+ while (cache.size >= 32 && !cache.has(key))
501
+ cache.delete(cache.keys().next().value);
502
+ cache.set(key, retained);
503
+ }
315
504
  return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
316
505
  }
317
506
  catch (error) {
318
507
  const failure = this.options.signal?.aborted ? new models_1.ToolingError('CANCELLED', 'The MCP action was cancelled.')
319
508
  : error instanceof models_1.ToolingError ? error : new models_1.ToolingError('INTERNAL_ERROR', 'The MCP action could not complete.');
509
+ if (cache && key && failure.code !== 'MCP_TOOL_ERROR')
510
+ cache.delete(key);
320
511
  return this.sanitize({ schemaVersion: 1, action: request.action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure',
321
- summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined ? {} : { data: resultData }),
512
+ summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined || ['AUTH_REJECTED', 'NOT_AUTHENTICATED'].includes(failure.code) ? {} : { data: resultData }),
322
513
  ...(callStarted ? { effects: { state: 'partial-or-uncertain', message: 'The tool may have changed remote state. No automatic retry was attempted; verify the outcome before repeating it.' } } : {}) });
323
514
  }
324
515
  finally {
@@ -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 response = await this.send({ jsonrpc: '2.0', id, method, params });
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() {
@@ -46,11 +71,24 @@ class McpTransport {
46
71
  if (!Array.isArray(page?.tools))
47
72
  throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog is invalid.');
48
73
  for (const tool of page.tools) {
49
- if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || names.has(tool.name)) {
74
+ if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema) || names.has(tool.name)) {
50
75
  throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog contains an invalid or duplicate tool.');
51
76
  }
77
+ const normalized = { ...tool };
78
+ if (typeof normalized.description !== 'string')
79
+ delete normalized.description;
80
+ for (const field of ['annotations', '_meta'])
81
+ if (!normalized[field] || typeof normalized[field] !== 'object' || Array.isArray(normalized[field]))
82
+ delete normalized[field];
83
+ if (normalized.annotations) {
84
+ normalized.annotations = { ...normalized.annotations };
85
+ for (const flag of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
86
+ if (typeof normalized.annotations[flag] !== 'boolean')
87
+ delete normalized.annotations[flag];
88
+ }
89
+ }
52
90
  names.add(tool.name);
53
- tools.push(tool);
91
+ tools.push(normalized);
54
92
  }
55
93
  cursor = page.nextCursor;
56
94
  if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) {
@@ -65,29 +103,31 @@ class McpTransport {
65
103
  }
66
104
  }
67
105
  exports.McpTransport = McpTransport;
68
- /** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation. */
69
- async function readMcpResponse(response, message) {
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') {
70
110
  if (response.headers.has('mcp-session-id')) {
71
111
  await response.body?.cancel();
72
112
  throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Stateful MCP sessions are outside the platform profile.');
73
113
  }
74
114
  if (!response.ok) {
75
115
  await response.body?.cancel();
76
- throw new models_1.ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR', 'The MCP endpoint returned HTTP ' + response.status + '.');
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) + '.');
77
117
  }
78
118
  if (!Object.hasOwn(message, 'id')) {
79
119
  await response.body?.cancel();
80
120
  if (response.status !== 202 && response.status !== 204)
81
- throw new models_1.ToolingError('API_ERROR', 'The MCP notification was not acknowledged.');
121
+ throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' did not acknowledge ' + describeMcpMessage(message) + ' (HTTP ' + response.status + ').');
82
122
  return undefined;
83
123
  }
84
124
  const kind = response.headers.get('content-type')?.split(';')[0].trim();
85
125
  if (kind !== 'application/json' && kind !== 'text/event-stream') {
86
126
  await response.body?.cancel();
87
- throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an unsupported content type.');
127
+ throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an unsupported content type for ' + describeMcpMessage(message) + '.');
88
128
  }
89
129
  if (!response.body)
90
- throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an empty response.');
130
+ throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an empty response to ' + describeMcpMessage(message) + '.');
91
131
  const reader = response.body.getReader(), decoder = new TextDecoder();
92
132
  let size = 0, text = '';
93
133
  const parse = (raw) => {
@@ -95,7 +135,7 @@ async function readMcpResponse(response, message) {
95
135
  return JSON.parse(raw);
96
136
  }
97
137
  catch {
98
- throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned invalid JSON.');
138
+ throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned invalid JSON for ' + describeMcpMessage(message) + '.');
99
139
  }
100
140
  };
101
141
  try {
@@ -130,32 +170,48 @@ async function readMcpResponse(response, message) {
130
170
  text += decoder.decode();
131
171
  if (kind === 'application/json')
132
172
  return parse(text);
133
- 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.');
134
174
  }
135
175
  finally {
136
176
  await reader.cancel().catch(() => { });
137
177
  reader.releaseLock();
138
178
  }
139
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. */
140
184
  async function sendProjectMcp(url, token, message, options) {
141
185
  options.signal?.throwIfAborted();
142
186
  const controller = new AbortController(), abort = () => controller.abort();
143
187
  options.signal?.addEventListener('abort', abort, { once: true });
144
- const timer = setTimeout(abort, options.requestTimeoutMs ?? 60_000);
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
+ } })();
145
197
  try {
146
198
  return await readMcpResponse(await (options.fetch ?? fetch)(url, {
147
199
  method: 'POST', redirect: 'error', signal: controller.signal,
200
+ ...(['initialize', 'notifications/initialized', 'tools/list'].includes(String(message.method)) ? { cache: 'force-cache' } : {}),
148
201
  headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
149
202
  Authorization: 'Bearer ' + token, 'MCP-Protocol-Version': '2025-11-25' },
150
203
  body: JSON.stringify(message),
151
- }), message);
204
+ }), message, 'project MCP ' + origin);
152
205
  }
153
206
  catch (error) {
154
207
  if (options.signal?.aborted)
155
208
  throw new models_1.ToolingError('CANCELLED', 'The MCP request was cancelled. Cancellation does not undo a remote operation.');
156
209
  if (error instanceof models_1.ToolingError)
157
210
  throw error;
158
- throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The MCP request failed or timed out. It was not retried.');
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.');
159
215
  }
160
216
  finally {
161
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 instructions = path.join(root, 'CLAUDE.md');
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' });