@dev-mainsequence/codex-plugin 0.3.0 → 0.4.2
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 +6 -5
- package/.mcp.json +16 -0
- package/README.md +23 -11
- package/build-info.json +3 -3
- package/dist/hosts/codexPresentation.js +1 -0
- package/dist/hosts/discovery.js +138 -0
- package/dist/hosts/panel/panel.html +165 -0
- package/dist/panel-server.js +135 -0
- package/dist/vendor/devtools/index.js +5 -1
- package/dist/vendor/devtools/package.json +1 -1
- package/dist/vendor/devtools/shared/accountScope.js +19 -0
- package/dist/vendor/devtools/shared/actions.js +11 -3
- package/dist/vendor/devtools/shared/agents.js +5 -1
- package/dist/vendor/devtools/shared/environments.js +38 -0
- package/dist/vendor/devtools/shared/mcp.js +123 -31
- package/dist/vendor/devtools/shared/mcpTransport.js +15 -2
- package/dist/vendor/devtools/shared/organizationProjects.js +2 -13
- package/dist/vendor/devtools/shared/session.js +96 -8
- package/docs/agents-tools-panel.md +63 -0
- package/docs/codex-verification.md +37 -0
- package/docs/conversational-mcp.md +1 -1
- package/package.json +9 -5
- package/skills/mainsequence-mcp/SKILL.md +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateExpectedAccount = validateExpectedAccount;
|
|
4
|
+
exports.bindSessionAccount = bindSessionAccount;
|
|
5
|
+
const models_1 = require("./models.js");
|
|
6
|
+
function validateExpectedAccount(value) {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 300 || /[\r\n\0]/.test(value)) {
|
|
8
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Use the account returned by verified session status.');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Opt-in account scope for domain operations; never silently weaken a custom transport. */
|
|
12
|
+
async function bindSessionAccount(session, backend, expectedAccount) {
|
|
13
|
+
if (expectedAccount === undefined)
|
|
14
|
+
return session;
|
|
15
|
+
validateExpectedAccount(expectedAccount);
|
|
16
|
+
if (!session.withAccount)
|
|
17
|
+
throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'This session transport does not support account-bound operations. Upgrade its session implementation.');
|
|
18
|
+
return session.withAccount(backend, expectedAccount);
|
|
19
|
+
}
|
|
@@ -10,6 +10,7 @@ const repositories_1 = require("./repositories.js");
|
|
|
10
10
|
const projects_1 = require("./projects.js");
|
|
11
11
|
const organizationProjects_1 = require("./organizationProjects.js");
|
|
12
12
|
const organizationProjectWorkspace_1 = require("./organizationProjectWorkspace.js");
|
|
13
|
+
const accountScope_1 = require("./accountScope.js");
|
|
13
14
|
function createDevTools(options) {
|
|
14
15
|
const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, ...sessionOptions } = options;
|
|
15
16
|
const session = suppliedSession ?? new session_1.MachineSessionService(host, sessionOptions);
|
|
@@ -23,7 +24,8 @@ function createDevTools(options) {
|
|
|
23
24
|
if (action.startsWith('organization-')) {
|
|
24
25
|
const input = request;
|
|
25
26
|
const backend = await (0, repositories_1.resolveBackend)(request.backend, undefined, env);
|
|
26
|
-
const
|
|
27
|
+
const scoped = await (0, accountScope_1.bindSessionAccount)(session, backend, input.expectedAccount);
|
|
28
|
+
const client = new organizationProjects_1.OrganizationProjectClient(scoped, backend, options.signal);
|
|
27
29
|
let data;
|
|
28
30
|
if (input.action === 'organization-environments')
|
|
29
31
|
data = await client.environments();
|
|
@@ -33,14 +35,18 @@ function createDevTools(options) {
|
|
|
33
35
|
data = await client.getProject(input.environmentUid, input.projectUid);
|
|
34
36
|
else if (input.action === 'organization-project-link') {
|
|
35
37
|
const url = await client.link(input);
|
|
38
|
+
if (input.expectedAccount !== undefined)
|
|
39
|
+
await scoped.status(backend, true);
|
|
36
40
|
if (!await host.openBrowser(url))
|
|
37
41
|
throw new models_1.ToolingError('BROWSER_FAILED', 'The resource link could not open.');
|
|
38
42
|
data = { url };
|
|
39
43
|
}
|
|
40
44
|
else if (input.action === 'organization-project-setup')
|
|
41
|
-
data = await (0, organizationProjectWorkspace_1.setupOrganizationProject)({ ...input, backend }, { host, session, env, signal: options.signal, state });
|
|
45
|
+
data = await (0, organizationProjectWorkspace_1.setupOrganizationProject)({ ...input, backend }, { host, session: scoped, env, signal: options.signal, state });
|
|
42
46
|
else
|
|
43
47
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown organization project action.');
|
|
48
|
+
if (input.expectedAccount !== undefined)
|
|
49
|
+
await scoped.status(backend, true);
|
|
44
50
|
return safeResult({ schemaVersion: 1, action, status: 'success', summary: input.action === 'organization-project-setup' ? 'Project checkouts are prepared on their assigned branches. Claude must attach them when its workspace session starts.' : 'Projects loaded.', data });
|
|
45
51
|
}
|
|
46
52
|
if ((0, agents_1.isAgentAction)(action)) {
|
|
@@ -49,7 +55,9 @@ function createDevTools(options) {
|
|
|
49
55
|
}
|
|
50
56
|
if (action.startsWith('mcp-')) {
|
|
51
57
|
const backend = await (0, repositories_1.resolveBackend)(request.backend, process.cwd(), env);
|
|
52
|
-
|
|
58
|
+
const input = request, scoped = await (0, accountScope_1.bindSessionAccount)(session, backend, input.expectedAccount);
|
|
59
|
+
return safeResult(await new mcp_1.McpConnections(scoped, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs,
|
|
60
|
+
checkAccount: input.expectedAccount === undefined ? undefined : () => scoped.status(backend, true).then(() => { }) }).dispatch(input, backend));
|
|
53
61
|
}
|
|
54
62
|
let data;
|
|
55
63
|
if (['login', 'logout', 'refresh-token', 'auth-status', 'repositories'].includes(action)) {
|
|
@@ -54,12 +54,16 @@ class AgentClient {
|
|
|
54
54
|
if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) {
|
|
55
55
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.');
|
|
56
56
|
}
|
|
57
|
+
// The built-in session binds every request/retry to the selected account. Legacy
|
|
58
|
+
// custom agent transports retain their existing account-check contract.
|
|
59
|
+
const session = input.expectedAccount !== undefined && this.session.withAccount
|
|
60
|
+
? await this.session.withAccount(backend, input.expectedAccount) : this.session;
|
|
57
61
|
const timeouts = new Map();
|
|
58
62
|
const client = new mcpTransport_1.McpTransport(message => {
|
|
59
63
|
const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now());
|
|
60
64
|
if (remaining !== undefined && remaining <= 0)
|
|
61
65
|
throw new models_1.ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.');
|
|
62
|
-
return
|
|
66
|
+
return session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes(message.params?.name), remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) }
|
|
63
67
|
: message.method === 'tools/call' && timeouts.has(message.params?.name) ? { timeoutMs: timeouts.get(message.params.name) } : undefined);
|
|
64
68
|
});
|
|
65
69
|
await client.initialize();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listEnvironments = listEnvironments;
|
|
4
|
+
const models_1 = require("./models.js");
|
|
5
|
+
const session_1 = require("./session.js");
|
|
6
|
+
/** One bounded directory reader for all hosts and domain clients. No settings writes. */
|
|
7
|
+
async function listEnvironments(session, inputBackend, signal) {
|
|
8
|
+
const backend = (0, session_1.requireBackend)(inputBackend), items = [], seen = new Set();
|
|
9
|
+
let expectedCount;
|
|
10
|
+
for (let offset = 0;;) {
|
|
11
|
+
signal?.throwIfAborted();
|
|
12
|
+
const page = await session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const rows = Array.isArray(page) ? page : page?.results;
|
|
15
|
+
const count = Array.isArray(page) ? page.length : page?.count;
|
|
16
|
+
if (!Array.isArray(rows) || !Number.isSafeInteger(count) || count < 0 || count > 10_000 || offset + rows.length > count
|
|
17
|
+
|| (expectedCount !== undefined && expectedCount !== count) || (offset > 0 && Array.isArray(page))) {
|
|
18
|
+
throw new models_1.ToolingError('API_ERROR', 'The environment pagination is invalid or changed. Refresh the directory.');
|
|
19
|
+
}
|
|
20
|
+
expectedCount = count;
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
if (typeof row?.uid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uid)
|
|
23
|
+
|| typeof row.name !== 'string' || !row.name.trim() || /[\r\n\0]/.test(row.name)) {
|
|
24
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid organization environment.');
|
|
25
|
+
}
|
|
26
|
+
const uid = row.uid.toLowerCase();
|
|
27
|
+
if (seen.has(uid))
|
|
28
|
+
throw new models_1.ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.');
|
|
29
|
+
seen.add(uid);
|
|
30
|
+
items.push({ uid, name: row.name.trim(), isProduction: row.is_production === true });
|
|
31
|
+
}
|
|
32
|
+
offset += rows.length;
|
|
33
|
+
if (offset === count)
|
|
34
|
+
return items;
|
|
35
|
+
if (!rows.length)
|
|
36
|
+
throw new models_1.ToolingError('API_ERROR', 'Environment pagination stopped before completion.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -41,12 +41,22 @@ 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 repositoryModels_1 = require("./repositoryModels.js");
|
|
45
|
+
const environments_1 = require("./environments.js");
|
|
46
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
47
|
function uuid(value) {
|
|
45
|
-
if (typeof value !== 'string' ||
|
|
48
|
+
if (typeof value !== 'string' || !UUID.test(value)) {
|
|
46
49
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.');
|
|
47
50
|
}
|
|
48
51
|
return value.toLowerCase();
|
|
49
52
|
}
|
|
53
|
+
/** Optional labels: an absent or malformed value is dropped, never an error. */
|
|
54
|
+
function labels(row) {
|
|
55
|
+
const text = (value) => typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value) ? value.trim() : undefined;
|
|
56
|
+
const name = text(row?.name), display = text(row?.display_name);
|
|
57
|
+
const branch = typeof row?.code_repository_branch_uid === 'string' && UUID.test(row.code_repository_branch_uid) ? row.code_repository_branch_uid.toLowerCase() : undefined;
|
|
58
|
+
return { ...(name ? { name } : {}), ...(display ? { display_name: display } : {}), ...(branch ? { code_repository_branch_uid: branch } : {}) };
|
|
59
|
+
}
|
|
50
60
|
function endpoint(value, metadata = false) {
|
|
51
61
|
if (typeof value !== 'string')
|
|
52
62
|
throw new models_1.ToolingError('API_ERROR', 'The backend returned no MCP endpoint.');
|
|
@@ -76,8 +86,7 @@ function connection(backend, row) {
|
|
|
76
86
|
if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1
|
|
77
87
|
|| metadataQuery.get('resource_release_uid') !== releaseUid)
|
|
78
88
|
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 } : {}),
|
|
89
|
+
return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid, ...labels(row),
|
|
81
90
|
mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl,
|
|
82
91
|
organization_environment_uid: uuid(c.organization_environment_uid),
|
|
83
92
|
...(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) } };
|
|
@@ -128,6 +137,7 @@ class McpConnections {
|
|
|
128
137
|
}
|
|
129
138
|
async update(change) {
|
|
130
139
|
this.options.signal?.throwIfAborted();
|
|
140
|
+
await this.options.checkAccount?.();
|
|
131
141
|
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
132
142
|
const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
|
|
133
143
|
let handle;
|
|
@@ -174,15 +184,113 @@ class McpConnections {
|
|
|
174
184
|
if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
|
|
175
185
|
throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
|
|
176
186
|
}
|
|
177
|
-
const client = new mcpTransport_1.McpTransport(
|
|
187
|
+
const client = new mcpTransport_1.McpTransport(async (message) => {
|
|
188
|
+
await this.options.checkAccount?.();
|
|
189
|
+
const result = await (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, grant.token, message, this.options);
|
|
190
|
+
await this.options.checkAccount?.();
|
|
191
|
+
return result;
|
|
192
|
+
});
|
|
178
193
|
await client.initialize();
|
|
179
194
|
return client;
|
|
180
195
|
}
|
|
196
|
+
/** The release's name and repository from the release directory and its branch. Whatever
|
|
197
|
+
* cannot be read is left out: labels never make a working MCP unavailable. */
|
|
198
|
+
async resolveLabels(value) {
|
|
199
|
+
const found = {};
|
|
200
|
+
const read = async (apiPath) => {
|
|
201
|
+
try {
|
|
202
|
+
return await this.session.request(value.backend, 'GET', apiPath, undefined, true);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
this.options.signal?.throwIfAborted();
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
// The release detail carries no name or repository; the directory projection does.
|
|
210
|
+
const page = await read('/api/v1/resource-releases/?search=' + encodeURIComponent(value.resource_release_uid) + '&limit=100');
|
|
211
|
+
const row = Array.isArray(page?.results) ? page.results.find((item) => typeof item?.uid === 'string' && item.uid.toLowerCase() === value.resource_release_uid) : undefined;
|
|
212
|
+
if (row)
|
|
213
|
+
Object.assign(found, labels({ name: row.name, code_repository_branch_uid: row.code_repository_branch_uid }));
|
|
214
|
+
const branchUid = found.code_repository_branch_uid ?? value.code_repository_branch_uid;
|
|
215
|
+
if (branchUid) {
|
|
216
|
+
const branch = await read('/api/v1/code-repository-branches/' + encodeURIComponent(branchUid) + '/');
|
|
217
|
+
try {
|
|
218
|
+
const parsed = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(branch, 'CodeRepositoryBranch response');
|
|
219
|
+
if (parsed.uid.toLowerCase() === branchUid)
|
|
220
|
+
Object.assign(found, labels({ display_name: parsed.code_repository_name, code_repository_branch_uid: branchUid }));
|
|
221
|
+
}
|
|
222
|
+
catch { /* an unreadable branch leaves the repository name out */ }
|
|
223
|
+
}
|
|
224
|
+
return found;
|
|
225
|
+
}
|
|
226
|
+
labelled(value, found) {
|
|
227
|
+
return connection(value.backend, { ...value, ...found });
|
|
228
|
+
}
|
|
229
|
+
/** Optional discovery labels, deduplicated by branch. Never writes connection metadata. */
|
|
230
|
+
async discoveryNames(rows) {
|
|
231
|
+
const pending = [...new Set(rows.filter(row => !row.display_name).flatMap(row => row.code_repository_branch_uid ? [row.code_repository_branch_uid] : []))];
|
|
232
|
+
const names = new Map();
|
|
233
|
+
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
|
234
|
+
while (pending.length) {
|
|
235
|
+
this.options.signal?.throwIfAborted();
|
|
236
|
+
const uid = pending.shift();
|
|
237
|
+
try {
|
|
238
|
+
const raw = await this.session.request(rows[0].backend, 'GET', '/api/v1/code-repository-branches/' + uid + '/');
|
|
239
|
+
const branch = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(raw, 'CodeRepositoryBranch response');
|
|
240
|
+
if (branch.uid.toLowerCase() === uid)
|
|
241
|
+
names.set(uid, branch.code_repository_name);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
this.options.signal?.throwIfAborted();
|
|
245
|
+
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
246
|
+
throw error;
|
|
247
|
+
await this.options.checkAccount?.();
|
|
248
|
+
// Unavailable optional names must not hide usable endpoints.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
return rows.map(row => !row.display_name && row.code_repository_branch_uid && names.has(row.code_repository_branch_uid)
|
|
253
|
+
? this.labelled(row, { display_name: names.get(row.code_repository_branch_uid) }) : row);
|
|
254
|
+
}
|
|
255
|
+
/** Records saved before labels were stored are completed once, when the backend answers. */
|
|
256
|
+
async backfill(rows) {
|
|
257
|
+
const pending = rows.filter(row => !row.display_name);
|
|
258
|
+
if (!pending.length)
|
|
259
|
+
return rows;
|
|
260
|
+
const found = new Map();
|
|
261
|
+
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
|
262
|
+
while (pending.length) {
|
|
263
|
+
const row = pending.shift();
|
|
264
|
+
const resolved = await this.resolveLabels(row);
|
|
265
|
+
const added = Object.entries(resolved).filter(([key, item]) => item !== row[key]);
|
|
266
|
+
if (added.length)
|
|
267
|
+
found.set(row.backend + ' ' + row.resource_release_uid, Object.fromEntries(added));
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
if (!found.size)
|
|
271
|
+
return rows;
|
|
272
|
+
const key = (row) => row.backend + ' ' + row.resource_release_uid;
|
|
273
|
+
const merge = (row) => found.has(key(row)) ? this.labelled(row, { ...found.get(key(row)) }) : row;
|
|
274
|
+
try {
|
|
275
|
+
await this.update(all => all.map(merge));
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
// A held lock or a failed write keeps the listing itself available.
|
|
279
|
+
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
280
|
+
throw error;
|
|
281
|
+
this.options.signal?.throwIfAborted();
|
|
282
|
+
}
|
|
283
|
+
return rows.map(merge);
|
|
284
|
+
}
|
|
181
285
|
async dispatch(request, inputBackend) {
|
|
182
286
|
let callStarted = false, resultData;
|
|
183
287
|
try {
|
|
184
288
|
const backend = (0, session_1.requireBackend)(inputBackend);
|
|
185
289
|
this.options.signal?.throwIfAborted();
|
|
290
|
+
if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean'
|
|
291
|
+
|| request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') {
|
|
292
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.');
|
|
293
|
+
}
|
|
186
294
|
const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid);
|
|
187
295
|
const belongs = (value) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid;
|
|
188
296
|
const requireEnvironment = (value) => {
|
|
@@ -191,31 +299,7 @@ class McpConnections {
|
|
|
191
299
|
};
|
|
192
300
|
let data, summary;
|
|
193
301
|
if (request.action === 'mcp-environments') {
|
|
194
|
-
|
|
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()];
|
|
302
|
+
data = await (0, environments_1.listEnvironments)(this.session, backend, this.options.signal);
|
|
219
303
|
summary = 'Organization environments available to your MainSequence account.';
|
|
220
304
|
}
|
|
221
305
|
else if (request.action === 'mcp-discover') {
|
|
@@ -255,10 +339,15 @@ class McpConnections {
|
|
|
255
339
|
const values = [...matching.values()], offset = request.offset ?? 0;
|
|
256
340
|
data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) };
|
|
257
341
|
}
|
|
342
|
+
if (request.enrichNames) {
|
|
343
|
+
const page = data;
|
|
344
|
+
data = { ...page, results: await this.discoveryNames(page.results) };
|
|
345
|
+
}
|
|
258
346
|
summary = 'Available project MCP endpoints; discovery does not save a connection.';
|
|
259
347
|
}
|
|
260
348
|
else if (request.action === 'mcp-list') {
|
|
261
|
-
|
|
349
|
+
const saved = (await this.read()).filter(row => row.backend === backend && belongs(row));
|
|
350
|
+
data = request.readOnly ? saved : await this.backfill(saved);
|
|
262
351
|
summary = 'Saved MCP connections for this backend. Availability is checked when used.';
|
|
263
352
|
}
|
|
264
353
|
else {
|
|
@@ -272,7 +361,7 @@ class McpConnections {
|
|
|
272
361
|
summary = 'The saved connection was removed.';
|
|
273
362
|
}
|
|
274
363
|
else {
|
|
275
|
-
|
|
364
|
+
let value = await this.current(backend, releaseUid);
|
|
276
365
|
requireEnvironment(value);
|
|
277
366
|
if (request.action !== 'mcp-add') {
|
|
278
367
|
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
|
|
@@ -280,10 +369,12 @@ class McpConnections {
|
|
|
280
369
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
|
|
281
370
|
if (saved.mcp_connection.url !== value.mcp_connection.url)
|
|
282
371
|
throw new models_1.ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.');
|
|
372
|
+
value = this.labelled(value, labels(saved));
|
|
283
373
|
}
|
|
284
374
|
const client = await this.open(value);
|
|
285
375
|
const tools = await client.tools();
|
|
286
376
|
if (request.action === 'mcp-add') {
|
|
377
|
+
value = this.labelled(value, await this.resolveLabels(value));
|
|
287
378
|
await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
|
|
288
379
|
data = { connection: value, tools };
|
|
289
380
|
summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.';
|
|
@@ -312,6 +403,7 @@ class McpConnections {
|
|
|
312
403
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown MCP action.');
|
|
313
404
|
}
|
|
314
405
|
}
|
|
406
|
+
await this.options.checkAccount?.();
|
|
315
407
|
return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
|
|
316
408
|
}
|
|
317
409
|
catch (error) {
|
|
@@ -46,11 +46,24 @@ class McpTransport {
|
|
|
46
46
|
if (!Array.isArray(page?.tools))
|
|
47
47
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog is invalid.');
|
|
48
48
|
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)) {
|
|
49
|
+
if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema) || names.has(tool.name)) {
|
|
50
50
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog contains an invalid or duplicate tool.');
|
|
51
51
|
}
|
|
52
|
+
const normalized = { ...tool };
|
|
53
|
+
if (typeof normalized.description !== 'string')
|
|
54
|
+
delete normalized.description;
|
|
55
|
+
for (const field of ['annotations', '_meta'])
|
|
56
|
+
if (!normalized[field] || typeof normalized[field] !== 'object' || Array.isArray(normalized[field]))
|
|
57
|
+
delete normalized[field];
|
|
58
|
+
if (normalized.annotations) {
|
|
59
|
+
normalized.annotations = { ...normalized.annotations };
|
|
60
|
+
for (const flag of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
|
|
61
|
+
if (typeof normalized.annotations[flag] !== 'boolean')
|
|
62
|
+
delete normalized.annotations[flag];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
names.add(tool.name);
|
|
53
|
-
tools.push(
|
|
66
|
+
tools.push(normalized);
|
|
54
67
|
}
|
|
55
68
|
cursor = page.nextCursor;
|
|
56
69
|
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) {
|
|
@@ -6,6 +6,7 @@ const models_1 = require("./models.js");
|
|
|
6
6
|
const session_1 = require("./session.js");
|
|
7
7
|
const repositoryModels_1 = require("./repositoryModels.js");
|
|
8
8
|
const repositoryClient_1 = require("./repositoryClient.js");
|
|
9
|
+
const environments_1 = require("./environments.js");
|
|
9
10
|
function projectUid(value, label = 'UID') {
|
|
10
11
|
if (typeof value !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value))
|
|
11
12
|
throw new models_1.ToolingError('INVALID_REQUEST', `${label} must be a UUID.`);
|
|
@@ -58,19 +59,7 @@ class OrganizationProjectClient {
|
|
|
58
59
|
}
|
|
59
60
|
async get(apiPath) { this.signal?.throwIfAborted(); return this.session.request(this.backend, 'GET', apiPath); }
|
|
60
61
|
async environments() {
|
|
61
|
-
|
|
62
|
-
for (let offset = 0;;) {
|
|
63
|
-
const payload = pageValues(await this.get(`/api/v1/organization-environments/?limit=100&offset=${offset}`));
|
|
64
|
-
const rows = payload.results.map(row => ({ uid: projectUid(object(row).uid), name: line(row.name, 'environment name') }));
|
|
65
|
-
if (rows.some(row => items.some(item => item.uid === row.uid)))
|
|
66
|
-
throw new models_1.ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.');
|
|
67
|
-
items.push(...rows);
|
|
68
|
-
offset += rows.length;
|
|
69
|
-
if (offset >= payload.count)
|
|
70
|
-
return items;
|
|
71
|
-
if (!rows.length)
|
|
72
|
-
throw new models_1.ToolingError('API_ERROR', 'Environment pagination stopped before completion.');
|
|
73
|
-
}
|
|
62
|
+
return (await (0, environments_1.listEnvironments)(this.session, this.backend, this.signal)).map(({ uid, name }) => ({ uid, name }));
|
|
74
63
|
}
|
|
75
64
|
async list(environment, options = {}) {
|
|
76
65
|
const environmentUid = projectUid(environment, 'Environment UID'), limit = options.limit ?? 25, offset = options.offset ?? 0;
|
|
@@ -44,6 +44,10 @@ const http = __importStar(require("http"));
|
|
|
44
44
|
const config_1 = require("./config.js");
|
|
45
45
|
const machineSession_1 = require("./machineSession.js");
|
|
46
46
|
const models_1 = require("./models.js");
|
|
47
|
+
const accountScope_1 = require("./accountScope.js");
|
|
48
|
+
class AccountChangedError extends models_1.ToolingError {
|
|
49
|
+
constructor() { super('AUTH_REJECTED', 'The MainSequence account or session changed. Refresh the current view before continuing.'); }
|
|
50
|
+
}
|
|
47
51
|
function requireBackend(input) {
|
|
48
52
|
const backend = (0, config_1.normalizeBackendUrl)(input);
|
|
49
53
|
let url;
|
|
@@ -101,6 +105,7 @@ class MachineSessionService {
|
|
|
101
105
|
env;
|
|
102
106
|
renewals = new Map();
|
|
103
107
|
secrets = new Set();
|
|
108
|
+
verifiedAccounts = new Map();
|
|
104
109
|
constructor(host, options = {}) {
|
|
105
110
|
this.host = host;
|
|
106
111
|
this.options = options;
|
|
@@ -213,8 +218,9 @@ class MachineSessionService {
|
|
|
213
218
|
this.options.signal?.removeEventListener('abort', abort);
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
|
-
renew(backend) {
|
|
217
|
-
const
|
|
221
|
+
renew(backend, expectedAccount) {
|
|
222
|
+
const renewalKey = JSON.stringify([backend, expectedAccount]);
|
|
223
|
+
const running = this.renewals.get(renewalKey);
|
|
218
224
|
if (running)
|
|
219
225
|
return running;
|
|
220
226
|
const task = (async () => {
|
|
@@ -222,6 +228,8 @@ class MachineSessionService {
|
|
|
222
228
|
const previous = await this.saved(backend);
|
|
223
229
|
if (!previous?.refresh)
|
|
224
230
|
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.');
|
|
231
|
+
if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount)
|
|
232
|
+
throw this.accountChanged();
|
|
225
233
|
let data;
|
|
226
234
|
try {
|
|
227
235
|
data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh });
|
|
@@ -232,11 +240,20 @@ class MachineSessionService {
|
|
|
232
240
|
throw error;
|
|
233
241
|
}
|
|
234
242
|
const session = this.remember(tokenPair(data, previous));
|
|
243
|
+
if (expectedAccount !== undefined) {
|
|
244
|
+
const current = await this.saved(backend);
|
|
245
|
+
if (!current || current.access !== previous.access || current.refresh !== previous.refresh)
|
|
246
|
+
throw this.accountChanged();
|
|
247
|
+
}
|
|
248
|
+
// Refresh may rotate the old token. Keep the new pair even if the subsequent
|
|
249
|
+
// profile check is unavailable; no domain request runs before that check passes.
|
|
235
250
|
await this.persist(backend, session);
|
|
251
|
+
if (expectedAccount !== undefined)
|
|
252
|
+
await this.verifyAccount(backend, session, expectedAccount);
|
|
236
253
|
return session;
|
|
237
254
|
})();
|
|
238
|
-
this.renewals.set(
|
|
239
|
-
void task.finally(() => this.renewals.delete(
|
|
255
|
+
this.renewals.set(renewalKey, task);
|
|
256
|
+
void task.finally(() => this.renewals.delete(renewalKey)).catch(() => { });
|
|
240
257
|
return task;
|
|
241
258
|
}
|
|
242
259
|
async persist(backend, session) {
|
|
@@ -281,32 +298,103 @@ class MachineSessionService {
|
|
|
281
298
|
};
|
|
282
299
|
}
|
|
283
300
|
async request(input, method, apiPath, body, retrySafe = method === 'GET', options) {
|
|
301
|
+
return this.accountRequest(input, method, apiPath, body, retrySafe, options);
|
|
302
|
+
}
|
|
303
|
+
accountChanged() {
|
|
304
|
+
return new AccountChangedError();
|
|
305
|
+
}
|
|
306
|
+
recordAccount(backend, session, profile, expectedAccount) {
|
|
307
|
+
const username = profile?.user?.username || profile?.username;
|
|
308
|
+
if (username !== expectedAccount) {
|
|
309
|
+
this.verifiedAccounts.delete(JSON.stringify([backend, session.access]));
|
|
310
|
+
throw this.accountChanged();
|
|
311
|
+
}
|
|
312
|
+
if (this.verifiedAccounts.size >= 32)
|
|
313
|
+
this.verifiedAccounts.delete(this.verifiedAccounts.keys().next().value);
|
|
314
|
+
this.verifiedAccounts.set(JSON.stringify([backend, session.access]), username);
|
|
315
|
+
}
|
|
316
|
+
async verifyAccount(backend, session, expectedAccount) {
|
|
317
|
+
if (this.verifiedAccounts.get(JSON.stringify([backend, session.access])) === expectedAccount)
|
|
318
|
+
return;
|
|
319
|
+
this.recordAccount(backend, session, await this.http(backend, 'GET', '/api/v1/users/me/', undefined, session.access), expectedAccount);
|
|
320
|
+
}
|
|
321
|
+
/** The same selected credential is verified and used. A 401 retry must verify its new credential too. */
|
|
322
|
+
async withAccount(input, expectedAccount) {
|
|
323
|
+
const backend = requireBackend(input);
|
|
324
|
+
(0, accountScope_1.validateExpectedAccount)(expectedAccount);
|
|
325
|
+
const target = (value) => { if (requireBackend(value) !== backend)
|
|
326
|
+
throw new models_1.ToolingError('BACKEND_MISMATCH', 'The request belongs to another backend.'); };
|
|
327
|
+
const unsupported = async () => { throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Account-bound operations cannot change authentication.'); };
|
|
328
|
+
const scoped = {
|
|
329
|
+
request: (value, method, apiPath, body, retrySafe = method === 'GET', options) => {
|
|
330
|
+
target(value);
|
|
331
|
+
return this.accountRequest(backend, method, apiPath, body, retrySafe, options, expectedAccount);
|
|
332
|
+
},
|
|
333
|
+
status: async (value) => {
|
|
334
|
+
target(value);
|
|
335
|
+
await this.accountRequest(backend, 'GET', '/api/v1/users/me/', undefined, true, undefined, expectedAccount);
|
|
336
|
+
const current = await this.selected(backend);
|
|
337
|
+
await this.verifyAccount(backend, current.session, expectedAccount);
|
|
338
|
+
const status = await this.status(backend);
|
|
339
|
+
if (!status.authenticated)
|
|
340
|
+
throw this.accountChanged();
|
|
341
|
+
return { ...status, username: expectedAccount, checked: true };
|
|
342
|
+
},
|
|
343
|
+
login: unsupported, logout: unsupported, refresh: unsupported,
|
|
344
|
+
sanitize: text => this.sanitize(text),
|
|
345
|
+
};
|
|
346
|
+
await scoped.status(backend, true);
|
|
347
|
+
return scoped;
|
|
348
|
+
}
|
|
349
|
+
async accountRequest(input, method, apiPath, body, retrySafe, options, expectedAccount) {
|
|
284
350
|
if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000))
|
|
285
351
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.');
|
|
286
352
|
const backend = requireBackend(input);
|
|
287
353
|
let { session, source } = await this.selected(backend);
|
|
288
354
|
if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)) <= Date.now() + 30_000)) {
|
|
289
355
|
if (source === 'machine')
|
|
290
|
-
session = await this.renew(backend);
|
|
356
|
+
session = await this.renew(backend, expectedAccount);
|
|
291
357
|
else if (!session.access)
|
|
292
358
|
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.');
|
|
293
359
|
}
|
|
360
|
+
const send = async (selected) => {
|
|
361
|
+
const checkSelected = async () => {
|
|
362
|
+
if (expectedAccount === undefined)
|
|
363
|
+
return;
|
|
364
|
+
const current = await this.selected(backend);
|
|
365
|
+
if (current.source !== source || current.session.access !== selected.access || current.session.refresh !== selected.refresh)
|
|
366
|
+
throw this.accountChanged();
|
|
367
|
+
};
|
|
368
|
+
if (expectedAccount !== undefined && apiPath !== '/api/v1/users/me/')
|
|
369
|
+
await this.verifyAccount(backend, selected, expectedAccount);
|
|
370
|
+
await checkSelected();
|
|
371
|
+
const value = await this.http(backend, method, apiPath, body, selected.access, options?.timeoutMs);
|
|
372
|
+
if (expectedAccount !== undefined && apiPath === '/api/v1/users/me/')
|
|
373
|
+
this.recordAccount(backend, selected, value, expectedAccount);
|
|
374
|
+
await checkSelected();
|
|
375
|
+
return value;
|
|
376
|
+
};
|
|
294
377
|
try {
|
|
295
|
-
return await
|
|
378
|
+
return await send(session);
|
|
296
379
|
}
|
|
297
380
|
catch (error) {
|
|
298
381
|
if (!(error instanceof models_1.ToolingError) || error.code !== 'AUTH_REJECTED')
|
|
299
382
|
throw error;
|
|
383
|
+
// Scope failures are not expired-token failures and must never trigger a replay.
|
|
384
|
+
if (error instanceof AccountChangedError)
|
|
385
|
+
throw error;
|
|
300
386
|
if (source === 'environment')
|
|
301
387
|
throw refusedCredentials('environment', backend, session.access);
|
|
302
388
|
if (!retrySafe)
|
|
303
389
|
throw error;
|
|
304
390
|
const latest = await this.saved(backend);
|
|
305
|
-
const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend);
|
|
391
|
+
const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount);
|
|
306
392
|
try {
|
|
307
|
-
return await
|
|
393
|
+
return await send(retry);
|
|
308
394
|
}
|
|
309
395
|
catch (retryError) {
|
|
396
|
+
if (retryError instanceof AccountChangedError)
|
|
397
|
+
throw retryError;
|
|
310
398
|
if (retryError instanceof models_1.ToolingError && retryError.code === 'AUTH_REJECTED')
|
|
311
399
|
throw refusedCredentials('machine', backend, retry.refresh || retry.access);
|
|
312
400
|
throw retryError;
|