@dev-mainsequence/codex-plugin 0.4.2 → 0.7.10
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 +250 -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 +523 -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
|
@@ -5,6 +5,7 @@ exports.parseArguments = parseArguments;
|
|
|
5
5
|
exports.tokenizeCommand = tokenizeCommand;
|
|
6
6
|
const node_fs_1 = require("node:fs");
|
|
7
7
|
const models_1 = require("./models.js");
|
|
8
|
+
const workingDirectory_1 = require("./workingDirectory.js");
|
|
8
9
|
exports.HELP = `MainSequence developer tooling (Node 22+)
|
|
9
10
|
|
|
10
11
|
ms-devtools login|logout|refresh-token|auth-status|repositories [--backend URL]
|
|
@@ -13,6 +14,7 @@ ms-devtools mcp-discover [--search TEXT] [--limit N] [--offset N] [--backend URL
|
|
|
13
14
|
ms-devtools mcp-list [--backend URL]
|
|
14
15
|
ms-devtools mcp-add|mcp-remove|mcp-tools --uid RELEASE_UUID [--backend URL]
|
|
15
16
|
ms-devtools mcp-call --uid RELEASE_UUID --tool NAME [--arguments-file JSON_FILE] [--backend URL]
|
|
17
|
+
ms-devtools mcp-catalog [--refresh] [--backend URL]
|
|
16
18
|
ms-devtools status|resolve [--path ROOT] [--type python|vite_react] [--backend URL]
|
|
17
19
|
ms-devtools sync [--path ROOT]
|
|
18
20
|
ms-devtools sdk-status|update-sdk|update-agents|install-skills|install|env-cleanup [--path ROOT]
|
|
@@ -27,13 +29,16 @@ skills-sync requires MainSequence MCP and reports unsupported.
|
|
|
27
29
|
open-folder and signed-terminal require a host with those native capabilities.
|
|
28
30
|
In Codex desktop, type /ms, select the ms slash-menu entry, then append COMMAND ... .
|
|
29
31
|
The trusted plugin hook executes the selected command before inference.
|
|
32
|
+
mcp-catalog lists the saved connections' tools as hosts register them (ms_<tool>_<hash>) from the
|
|
33
|
+
shared catalog; --refresh re-reads every connection's catalog first. A cold project MCP can take
|
|
34
|
+
over a minute to answer.
|
|
30
35
|
`;
|
|
31
|
-
function parseArguments(argv, cwd
|
|
36
|
+
function parseArguments(argv, cwd) {
|
|
32
37
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h')
|
|
33
38
|
return { json: argv.includes('--json'), help: true };
|
|
34
39
|
const action = argv[0];
|
|
35
40
|
const flags = {};
|
|
36
|
-
const boolean = new Set(['--json', '--check', '--dry-run', '--install']);
|
|
41
|
+
const boolean = new Set(['--json', '--check', '--dry-run', '--install', '--refresh']);
|
|
37
42
|
const valued = new Set(['--backend', '--path', '--type', '--uid', '--branch', '--base-dir', '--search', '--limit', '--offset', '--tool', '--arguments-file']);
|
|
38
43
|
for (let i = 1; i < argv.length; i++) {
|
|
39
44
|
const key = argv[i];
|
|
@@ -61,16 +66,17 @@ function parseArguments(argv, cwd = process.cwd()) {
|
|
|
61
66
|
: action === 'mcp-list' ? global
|
|
62
67
|
: ['mcp-add', 'mcp-remove', 'mcp-tools'].includes(action) ? [...global, '--uid']
|
|
63
68
|
: action === 'mcp-call' ? [...global, '--uid', '--tool', '--arguments-file']
|
|
64
|
-
:
|
|
65
|
-
:
|
|
66
|
-
: action === '
|
|
67
|
-
: [...global,
|
|
69
|
+
: action === 'mcp-catalog' ? [...global, '--refresh']
|
|
70
|
+
: ['login', 'logout', 'refresh-token', 'repositories'].includes(action) ? global
|
|
71
|
+
: action === 'auth-status' ? [...global, '--check']
|
|
72
|
+
: action === 'setup' ? [...global, '--uid', '--branch', '--base-dir', '--install']
|
|
73
|
+
: [...global, ...project, ...(['update-sdk', 'install-skills'].includes(action) ? ['--dry-run'] : [])];
|
|
68
74
|
if (Object.keys(flags).some(key => !allowed.includes(key)))
|
|
69
75
|
throw new models_1.ToolingError('INVALID_REQUEST', 'This option does not apply to the selected action.');
|
|
70
76
|
const backend = typeof flags['--backend'] === 'string' ? flags['--backend'] : undefined;
|
|
71
77
|
const common = { backend };
|
|
72
78
|
let request;
|
|
73
|
-
if (['mcp-discover', 'mcp-list', 'mcp-add', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(action)) {
|
|
79
|
+
if (['mcp-discover', 'mcp-list', 'mcp-add', 'mcp-remove', 'mcp-tools', 'mcp-call', 'mcp-catalog'].includes(action)) {
|
|
74
80
|
let args = {};
|
|
75
81
|
if (typeof flags['--arguments-file'] === 'string') {
|
|
76
82
|
try {
|
|
@@ -88,7 +94,8 @@ function parseArguments(argv, cwd = process.cwd()) {
|
|
|
88
94
|
...(flags['--search'] !== undefined ? { search: flags['--search'] } : {}),
|
|
89
95
|
...(flags['--limit'] !== undefined ? { limit: Number(flags['--limit']) } : {}),
|
|
90
96
|
...(flags['--offset'] !== undefined ? { offset: Number(flags['--offset']) } : {}),
|
|
91
|
-
...(action === 'mcp-call' ? { tool: flags['--tool'], arguments: args } : {})
|
|
97
|
+
...(action === 'mcp-call' ? { tool: flags['--tool'], arguments: args } : {}),
|
|
98
|
+
...(action === 'mcp-catalog' && flags['--refresh'] ? { refresh: 'force' } : {}) };
|
|
92
99
|
}
|
|
93
100
|
else if (['login', 'logout', 'refresh-token', 'repositories'].includes(action))
|
|
94
101
|
request = { action, ...common };
|
|
@@ -97,7 +104,7 @@ function parseArguments(argv, cwd = process.cwd()) {
|
|
|
97
104
|
else if (action === 'setup')
|
|
98
105
|
request = { action, ...common, repositoryUid: flags['--uid'], branch: flags['--branch'], baseDir: flags['--base-dir'], install: Boolean(flags['--install']) };
|
|
99
106
|
else
|
|
100
|
-
request = { action, ...common, root: flags['--path'] || cwd, projectType: flags['--type'], dryRun: Boolean(flags['--dry-run']) };
|
|
107
|
+
request = { action, ...common, root: flags['--path'] || cwd || (0, workingDirectory_1.currentDirectory)(), projectType: flags['--type'], dryRun: Boolean(flags['--dry-run']) };
|
|
101
108
|
return { request, json: Boolean(flags['--json']) };
|
|
102
109
|
}
|
|
103
110
|
/** Tokenize explicit hook commands without evaluating a shell, substitutions, or expansions. */
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HIDDEN_DEPLOYMENT = exports.DEPLOYMENT_ACTIONS = void 0;
|
|
4
|
+
exports.workflowRun = workflowRun;
|
|
5
|
+
exports.deploymentRun = deploymentRun;
|
|
6
|
+
exports.createDeployments = createDeployments;
|
|
7
|
+
const models_1 = require("./models.js");
|
|
8
|
+
const session_1 = require("./session.js");
|
|
9
|
+
const accountScope_1 = require("./accountScope.js");
|
|
10
|
+
exports.DEPLOYMENT_ACTIONS = new Set(['deployment-history', 'deployment-run', 'deployment-logs']);
|
|
11
|
+
/** Command Center's wording for a run that answers 404: it never says whether the record exists. */
|
|
12
|
+
exports.HIDDEN_DEPLOYMENT = "You can't see this deployment. A deployment is shown to the people who can see its release or Job.";
|
|
13
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
14
|
+
const ACTIVE = new Set(['pending', 'queued', 'running']);
|
|
15
|
+
const RUN_PATH = /^\/api\/v1\/(?:deployment-runs\/[a-f0-9-]{36}\/(?:logs\/)?|deployment-history\/[a-f0-9-]{36}\/)$/;
|
|
16
|
+
function uuid(value, message) {
|
|
17
|
+
if (typeof value !== 'string' || !UUID.test(value))
|
|
18
|
+
throw new models_1.ToolingError('INVALID_REQUEST', message);
|
|
19
|
+
return value.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
function integer(value, minimum, maximum, fallback, message) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return fallback;
|
|
24
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
25
|
+
throw new models_1.ToolingError('INVALID_REQUEST', message);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const text = (value, limit = 4000) => typeof value === 'string'
|
|
29
|
+
? value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, limit) : '';
|
|
30
|
+
const optionalUuid = (value) => typeof value === 'string' && UUID.test(value) ? value.toLowerCase() : null;
|
|
31
|
+
function when(value) {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
const time = Date.parse(value);
|
|
35
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
36
|
+
}
|
|
37
|
+
function duration(start, end, now) {
|
|
38
|
+
if (!start)
|
|
39
|
+
return null;
|
|
40
|
+
const from = Date.parse(start), to = end ? Date.parse(end) : now;
|
|
41
|
+
return Number.isFinite(from) && Number.isFinite(to) && to >= from ? to - from : null;
|
|
42
|
+
}
|
|
43
|
+
function errorText(value) {
|
|
44
|
+
if (!value)
|
|
45
|
+
return '';
|
|
46
|
+
if (typeof value === 'string')
|
|
47
|
+
return text(value, 2000);
|
|
48
|
+
if (typeof value === 'object') {
|
|
49
|
+
const row = value;
|
|
50
|
+
const detail = [row.detail, row.message, row.reason].find(item => typeof item === 'string' && item.trim());
|
|
51
|
+
const code = typeof row.code === 'string' ? row.code : '';
|
|
52
|
+
if (detail || code)
|
|
53
|
+
return text([code, detail].filter(Boolean).join(': '), 2000);
|
|
54
|
+
try {
|
|
55
|
+
return text(JSON.stringify(value), 1000);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return 'Error details unavailable.';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return text(String(value), 500);
|
|
62
|
+
}
|
|
63
|
+
const earliest = (values) => values.filter((value) => !!value).sort()[0] ?? null;
|
|
64
|
+
const latest = (values) => values.filter((value) => !!value).sort().at(-1) ?? null;
|
|
65
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
66
|
+
const rec = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
67
|
+
/** An ADR-074 workflow run: the sealed step graph in plan order and the runs it owns. */
|
|
68
|
+
function workflowRun(row, now) {
|
|
69
|
+
const run = rec(row);
|
|
70
|
+
const uid = optionalUuid(run.uid);
|
|
71
|
+
if (!uid)
|
|
72
|
+
return null;
|
|
73
|
+
const steps = list(run.steps).slice(0, 60).map(item => {
|
|
74
|
+
const step = rec(item);
|
|
75
|
+
const startedAt = when(step.dispatch_started_at) ?? when(step.dispatch_authorized_at), finishedAt = when(step.finished_at);
|
|
76
|
+
const key = text(step.step_key, 200);
|
|
77
|
+
return { id: key, key, operation: text(step.operation, 100), resourceKey: text(step.resource_key, 200), state: text(step.state, 40) || 'unknown',
|
|
78
|
+
reason: text(step.reason, 500), needs: list(step.needs).slice(0, 40).map(need => text(need, 200)), deploymentRunUid: optionalUuid(step.deployment_run_uid),
|
|
79
|
+
startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now), error: errorText(step.error) };
|
|
80
|
+
});
|
|
81
|
+
const createdAt = when(run.creation_date), finishedAt = when(run.finished_at);
|
|
82
|
+
const startedAt = earliest([createdAt, ...steps.map(step => step.startedAt)]) ?? createdAt;
|
|
83
|
+
const deploymentRuns = list(run.deployment_runs).slice(0, 40).flatMap(item => {
|
|
84
|
+
const owned = rec(item), ownedUid = optionalUuid(owned.uid);
|
|
85
|
+
return ownedUid ? [{ uid: ownedUid, targetLabel: text(owned.target_label, 200), targetType: text(owned.target_type, 60), state: text(owned.state, 40) || 'unknown',
|
|
86
|
+
workflowStepKeys: list(owned.workflow_step_keys).slice(0, 40).map(key => text(key, 200)),
|
|
87
|
+
createdAt: when(owned.created_at), startedAt: when(owned.started_at), finishedAt: when(owned.finished_at) }] : [];
|
|
88
|
+
});
|
|
89
|
+
return { kind: 'workflow_run', uid, state: text(run.state, 40) || 'unknown', reason: text(run.reason, 500), workflowPath: text(run.workflow_path, 300),
|
|
90
|
+
repositoryUid: optionalUuid(run.code_repository_uid), repositoryName: text(run.code_repository_name, 200),
|
|
91
|
+
branchUid: optionalUuid(run.code_repository_branch_uid), branchName: text(run.repository_branch, 200),
|
|
92
|
+
commit: text(run.commit_sha, 40).slice(0, 7), createdAt, startedAt, finishedAt: finishedAt ?? (ACTIVE.has(text(run.state, 40)) ? null : latest(steps.map(step => step.finishedAt))),
|
|
93
|
+
durationMs: duration(startedAt, finishedAt ?? (ACTIVE.has(text(run.state, 40)) ? null : latest(steps.map(step => step.finishedAt))), now),
|
|
94
|
+
steps, deploymentRuns, error: errorText(run.error) };
|
|
95
|
+
}
|
|
96
|
+
/** A deployment run: its pipeline stages in `sequence` order and, when included, the workflow run that started it. */
|
|
97
|
+
function deploymentRun(row, now) {
|
|
98
|
+
const run = rec(row);
|
|
99
|
+
const uid = optionalUuid(run.uid);
|
|
100
|
+
if (!uid)
|
|
101
|
+
return null;
|
|
102
|
+
const target = rec(run.target), repository = rec(target.code_repository), branch = rec(target.code_repository_branch);
|
|
103
|
+
const steps = list(rec(run.pipeline).steps).map(rec).filter(step => Number.isFinite(Number(step.sequence)))
|
|
104
|
+
.sort((a, b) => Number(a.sequence) - Number(b.sequence)).slice(0, 60).map(step => {
|
|
105
|
+
const startedAt = when(step.started_at), finishedAt = when(step.finished_at);
|
|
106
|
+
const stepUid = optionalUuid(step.uid), key = text(step.key, 200);
|
|
107
|
+
return { id: stepUid ?? key, uid: stepUid, sequence: Number(step.sequence), key, name: text(step.name, 200), kind: text(step.kind, 60),
|
|
108
|
+
state: text(step.state, 40) || 'unknown', outcome: text(step.outcome, 100), reason: text(step.reason ?? step.skip_reason, 500),
|
|
109
|
+
startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now), error: errorText(step.error) };
|
|
110
|
+
});
|
|
111
|
+
const createdAt = when(run.created_at), finishedAt = when(run.finished_at);
|
|
112
|
+
const startedAt = when(run.started_at) ?? earliest(steps.map(step => step.startedAt)) ?? createdAt;
|
|
113
|
+
const workflow = run.workflow_run ? workflowRun(run.workflow_run, now) : null;
|
|
114
|
+
return { kind: 'deployment_run', uid, state: text(run.state, 40) || 'unknown', outcome: text(run.outcome, 100), operation: text(run.operation, 60), source: text(run.source, 60),
|
|
115
|
+
targetLabel: text(target.display_name || target.name, 200) || text(run.target_type, 60), targetType: text(run.target_type, 60),
|
|
116
|
+
repositoryUid: optionalUuid(repository.uid), repositoryName: text(repository.name, 200),
|
|
117
|
+
branchUid: optionalUuid(run.code_repository_branch_uid) ?? optionalUuid(branch.uid), branchName: text(branch.repository_branch, 200),
|
|
118
|
+
commit: text(run.commit_sha, 40).slice(0, 7), createdAt, startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now),
|
|
119
|
+
currentStepKey: text(rec(run.pipeline).current_step_key, 200), steps,
|
|
120
|
+
workflowStepKeys: list(run.workflow_step_keys).slice(0, 40).map(key => text(key, 200)), workflowRun: workflow,
|
|
121
|
+
logsAvailable: rec(run.logs).state === 'available' || typeof rec(run.logs).url === 'string', error: errorText(run.error) };
|
|
122
|
+
}
|
|
123
|
+
function historyEntry(row, now) {
|
|
124
|
+
const kind = rec(row).kind;
|
|
125
|
+
return kind === 'workflow_run' ? workflowRun(row, now) : kind === 'deployment_run' ? deploymentRun(row, now) : null;
|
|
126
|
+
}
|
|
127
|
+
function logEntry(row) {
|
|
128
|
+
const entry = rec(row);
|
|
129
|
+
return { sequence: Number.isFinite(Number(entry.sequence)) ? Number(entry.sequence) : null, timestamp: when(entry.timestamp), stepUid: optionalUuid(entry.step_uid),
|
|
130
|
+
source: text(entry.source, 100), stream: text(entry.stream, 20), level: text(entry.level, 20), text: text(entry.text, 4000) };
|
|
131
|
+
}
|
|
132
|
+
/** A read-only bridge over the public session service; the saved credentials never leave it. */
|
|
133
|
+
function createDeployments(options) {
|
|
134
|
+
const transport = options.fetch ?? fetch;
|
|
135
|
+
const session = (0, session_1.createSessionService)(options.host, { env: options.env, signal: options.signal, requestTimeoutMs: 30_000, sessionLock: options.sessionLock,
|
|
136
|
+
fetch: async (input, init) => {
|
|
137
|
+
const response = await transport(input, init);
|
|
138
|
+
// A run the reader may not see answers 404. Describe it without asserting existence,
|
|
139
|
+
// adapting only canonical run responses inside the authenticated transport.
|
|
140
|
+
if (response.status === 404 && RUN_PATH.test(new URL(String(input)).pathname)) {
|
|
141
|
+
await response.body?.cancel().catch(() => { });
|
|
142
|
+
return new Response(JSON.stringify({ hidden: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
143
|
+
}
|
|
144
|
+
return response;
|
|
145
|
+
} });
|
|
146
|
+
async function dispatch(input) {
|
|
147
|
+
const action = input?.action || 'unknown';
|
|
148
|
+
try {
|
|
149
|
+
const backend = (0, session_1.requireBackend)(input.backend);
|
|
150
|
+
const scoped = await (0, accountScope_1.bindSessionAccount)(session, backend, input.expectedAccount);
|
|
151
|
+
const now = Date.now(), at = new Date(now).toISOString();
|
|
152
|
+
let data, summary;
|
|
153
|
+
if (input.action === 'deployment-history') {
|
|
154
|
+
const environmentUid = uuid(input.environmentUid, 'Choose an organization environment first.');
|
|
155
|
+
const limit = integer(input.limit, 1, 100, 25, 'Request between 1 and 100 deployment history entries.');
|
|
156
|
+
const offset = integer(input.offset, 0, 1_000_000, 0, 'The deployment history offset is invalid.');
|
|
157
|
+
const branchUid = input.codeRepositoryBranchUid === undefined ? '' : uuid(input.codeRepositoryBranchUid, 'Choose a repository branch from the current environment.');
|
|
158
|
+
const page = await scoped.request(backend, 'GET', `/api/v1/deployment-history/?organization_environment_uid=${environmentUid}&limit=${limit}&offset=${offset}${branchUid ? `&code_repository_branch_uid=${branchUid}` : ''}`);
|
|
159
|
+
if (!page || !Array.isArray(page.results) || !Number.isInteger(page.count) || page.count < 0)
|
|
160
|
+
throw new models_1.ToolingError('API_ERROR', 'The deployment history is unavailable.');
|
|
161
|
+
const results = page.results.map((row) => historyEntry(row, now)).filter(Boolean);
|
|
162
|
+
data = { environmentUid, branchUid: branchUid || null, count: page.count, limit, offset, now: at, results };
|
|
163
|
+
summary = `${page.count} deployment history ${page.count === 1 ? 'entry' : 'entries'}.`;
|
|
164
|
+
}
|
|
165
|
+
else if (input.action === 'deployment-run') {
|
|
166
|
+
const runUid = uuid(input.runUid, 'Choose a deployment or workflow run.');
|
|
167
|
+
const kind = input.kind === 'workflow_run' ? 'workflow_run' : 'deployment_run';
|
|
168
|
+
const row = await scoped.request(backend, 'GET', kind === 'workflow_run'
|
|
169
|
+
? `/api/v1/deployment-history/${runUid}/` : `/api/v1/deployment-runs/${runUid}/?include=workflow_run`);
|
|
170
|
+
if (rec(row).hidden === true) {
|
|
171
|
+
data = { hidden: true, uid: runUid, kind, now: at };
|
|
172
|
+
summary = exports.HIDDEN_DEPLOYMENT;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const run = kind === 'workflow_run' ? workflowRun(row, now) : deploymentRun(row, now);
|
|
176
|
+
if (!run || run.uid !== runUid)
|
|
177
|
+
throw new models_1.ToolingError('API_ERROR', 'The deployment run record is unavailable.');
|
|
178
|
+
data = { hidden: false, uid: runUid, kind, run, now: at };
|
|
179
|
+
summary = `${kind === 'workflow_run' ? 'Workflow run' : 'Deployment'} ${run.state}.`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (input.action === 'deployment-logs') {
|
|
183
|
+
const runUid = uuid(input.runUid, 'Choose a deployment run.');
|
|
184
|
+
const stepUid = input.stepUid === undefined ? '' : uuid(input.stepUid, 'Choose a stage of this deployment run.');
|
|
185
|
+
const limit = integer(input.limit, 1, 200, 200, 'Request between 1 and 200 log entries.');
|
|
186
|
+
if (input.cursor !== undefined && (typeof input.cursor !== 'string' || !/^[A-Za-z0-9_.:=~-]{1,4000}$/.test(input.cursor)))
|
|
187
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Use a log cursor returned by the previous page.');
|
|
188
|
+
const cursor = input.cursor || '';
|
|
189
|
+
const page = await scoped.request(backend, 'GET', `/api/v1/deployment-runs/${runUid}/logs/?limit=${limit}${stepUid ? `&step_uid=${stepUid}` : ''}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
|
|
190
|
+
if (rec(page).hidden === true) {
|
|
191
|
+
data = { hidden: true, uid: runUid, now: at };
|
|
192
|
+
summary = exports.HIDDEN_DEPLOYMENT;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
if (!page || !Array.isArray(page.entries))
|
|
196
|
+
throw new models_1.ToolingError('API_ERROR', 'The deployment logs are unavailable.');
|
|
197
|
+
const entries = page.entries.slice(0, limit).map(logEntry);
|
|
198
|
+
data = { hidden: false, runUid, stepUid: stepUid || null, cursor: cursor || null, nextCursor: typeof page.next_cursor === 'string' && page.next_cursor ? page.next_cursor.slice(0, 4000) : null,
|
|
199
|
+
complete: page.complete === true, entries, now: at };
|
|
200
|
+
summary = `${entries.length} log ${entries.length === 1 ? 'entry' : 'entries'}.`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown deployment action.');
|
|
205
|
+
await scoped.status(backend, true);
|
|
206
|
+
return JSON.parse(session.sanitize(JSON.stringify({ schemaVersion: 1, action, status: 'success', summary, data })));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const failure = options.signal?.aborted ? new models_1.ToolingError('CANCELLED', 'The read was cancelled.') : error instanceof models_1.ToolingError ? error : new models_1.ToolingError('API_ERROR', 'Deployment information is unavailable.');
|
|
210
|
+
return { schemaVersion: 1, action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure', summary: failure.message, error: { code: failure.code, message: failure.message } };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { dispatch };
|
|
214
|
+
}
|
|
@@ -34,15 +34,36 @@ 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");
|
|
44
46
|
const repositoryModels_1 = require("./repositoryModels.js");
|
|
45
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
|
+
}
|
|
46
67
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
47
68
|
function uuid(value) {
|
|
48
69
|
if (typeof value !== 'string' || !UUID.test(value)) {
|
|
@@ -92,20 +113,21 @@ function connection(backend, row) {
|
|
|
92
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) } };
|
|
93
114
|
}
|
|
94
115
|
function storagePath(env) {
|
|
95
|
-
|
|
96
|
-
: process.platform === 'win32' ? path.join(env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence');
|
|
97
|
-
return path.join(dir, 'mcp-connections.json');
|
|
116
|
+
return path.join((0, stateDirectory_1.mainSequenceStateDirectory)(env), 'mcp-connections.json');
|
|
98
117
|
}
|
|
99
|
-
/** Only endpoint metadata is persisted.
|
|
118
|
+
/** Only endpoint metadata is persisted. A bounded runtime may reuse an in-memory grant. */
|
|
100
119
|
class McpConnections {
|
|
101
|
-
|
|
120
|
+
originalSession;
|
|
102
121
|
options;
|
|
103
122
|
file;
|
|
104
123
|
secrets = new Set();
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
session;
|
|
125
|
+
checkAccount;
|
|
126
|
+
constructor(originalSession, options = {}) {
|
|
127
|
+
this.originalSession = originalSession;
|
|
107
128
|
this.options = options;
|
|
108
129
|
this.file = options.connectionsPath ?? storagePath(options.env ?? process.env);
|
|
130
|
+
this.session = originalSession;
|
|
109
131
|
}
|
|
110
132
|
sanitize(value) {
|
|
111
133
|
if (typeof value === 'string') {
|
|
@@ -137,7 +159,7 @@ class McpConnections {
|
|
|
137
159
|
}
|
|
138
160
|
async update(change) {
|
|
139
161
|
this.options.signal?.throwIfAborted();
|
|
140
|
-
await this.
|
|
162
|
+
await this.checkAccount?.();
|
|
141
163
|
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
142
164
|
const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
|
|
143
165
|
let handle;
|
|
@@ -175,6 +197,16 @@ class McpConnections {
|
|
|
175
197
|
throw new models_1.ToolingError('API_ERROR', 'The backend returned a different release.');
|
|
176
198
|
return value;
|
|
177
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
|
+
}
|
|
178
210
|
async open(value) {
|
|
179
211
|
const grant = await this.session.request(value.backend, 'GET', '/api/v1/resource-releases/' + value.resource_release_uid + '/exchange-launch/');
|
|
180
212
|
if (grant?.mode !== 'token' || grant.release_kind !== 'fastapi' || typeof grant.token !== 'string' || !grant.token || /[\r\n]/.test(grant.token)) {
|
|
@@ -184,14 +216,9 @@ class McpConnections {
|
|
|
184
216
|
if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
|
|
185
217
|
throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
|
|
186
218
|
}
|
|
187
|
-
const client =
|
|
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
|
-
});
|
|
219
|
+
const client = this.client(value, grant.token);
|
|
193
220
|
await client.initialize();
|
|
194
|
-
return client;
|
|
221
|
+
return { client, token: grant.token, expiresAt: credentialExpiry(grant.token) };
|
|
195
222
|
}
|
|
196
223
|
/** The release's name and repository from the release directory and its branch. Whatever
|
|
197
224
|
* cannot be read is left out: labels never make a working MCP unavailable. */
|
|
@@ -244,7 +271,7 @@ class McpConnections {
|
|
|
244
271
|
this.options.signal?.throwIfAborted();
|
|
245
272
|
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
246
273
|
throw error;
|
|
247
|
-
await this.
|
|
274
|
+
await this.checkAccount?.();
|
|
248
275
|
// Unavailable optional names must not hide usable endpoints.
|
|
249
276
|
}
|
|
250
277
|
}
|
|
@@ -284,9 +311,54 @@ class McpConnections {
|
|
|
284
311
|
}
|
|
285
312
|
async dispatch(request, inputBackend) {
|
|
286
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;
|
|
287
317
|
try {
|
|
288
318
|
const backend = (0, session_1.requireBackend)(inputBackend);
|
|
289
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
|
+
}
|
|
290
362
|
if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean'
|
|
291
363
|
|| request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') {
|
|
292
364
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.');
|
|
@@ -361,7 +433,8 @@ class McpConnections {
|
|
|
361
433
|
summary = 'The saved connection was removed.';
|
|
362
434
|
}
|
|
363
435
|
else {
|
|
364
|
-
let value = await this.current(backend, releaseUid);
|
|
436
|
+
let value = cached?.value ?? await this.current(backend, releaseUid);
|
|
437
|
+
let savedSnapshot = savedIdentity(value);
|
|
365
438
|
requireEnvironment(value);
|
|
366
439
|
if (request.action !== 'mcp-add') {
|
|
367
440
|
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
|
|
@@ -369,10 +442,15 @@ class McpConnections {
|
|
|
369
442
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
|
|
370
443
|
if (saved.mcp_connection.url !== value.mcp_connection.url)
|
|
371
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);
|
|
372
446
|
value = this.labelled(value, labels(saved));
|
|
373
447
|
}
|
|
374
|
-
const
|
|
375
|
-
const
|
|
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();
|
|
376
454
|
if (request.action === 'mcp-add') {
|
|
377
455
|
value = this.labelled(value, await this.resolveLabels(value));
|
|
378
456
|
await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
|
|
@@ -390,27 +468,48 @@ class McpConnections {
|
|
|
390
468
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Tool arguments must be a JSON object.');
|
|
391
469
|
this.options.signal?.throwIfAborted();
|
|
392
470
|
callStarted = true;
|
|
393
|
-
|
|
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) });
|
|
394
473
|
if (!output || !Array.isArray(output.content))
|
|
395
474
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool returned an invalid result.');
|
|
396
475
|
resultData = output;
|
|
397
|
-
if (output.isError)
|
|
398
|
-
|
|
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
|
+
}
|
|
399
480
|
data = output;
|
|
400
481
|
summary = 'The MCP tool completed.';
|
|
401
482
|
}
|
|
402
483
|
else
|
|
403
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
|
+
}
|
|
404
496
|
}
|
|
405
497
|
}
|
|
406
|
-
await this.
|
|
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
|
+
}
|
|
407
504
|
return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
|
|
408
505
|
}
|
|
409
506
|
catch (error) {
|
|
410
507
|
const failure = this.options.signal?.aborted ? new models_1.ToolingError('CANCELLED', 'The MCP action was cancelled.')
|
|
411
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);
|
|
412
511
|
return this.sanitize({ schemaVersion: 1, action: request.action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure',
|
|
413
|
-
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 }),
|
|
414
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.' } } : {}) });
|
|
415
514
|
}
|
|
416
515
|
finally {
|