@dev-mainsequence/codex-plugin 0.0.0-stage → 0.3.0
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 +21 -0
- package/LICENSE +21 -0
- package/NOTICE +13 -0
- package/README.md +143 -2
- package/assets/mainsequence-icon.png +0 -0
- package/assets/mainsequence-icon.svg +454 -0
- package/build-info.json +10 -0
- package/dist/hosts/codex.js +62 -0
- package/dist/hosts/codexPresentation.js +164 -0
- package/dist/package.json +1 -0
- package/dist/runner.js +71 -0
- package/dist/vendor/devtools/LICENSE +21 -0
- package/dist/vendor/devtools/NOTICE +12 -0
- package/dist/vendor/devtools/index.js +68 -0
- package/dist/vendor/devtools/package.json +5 -0
- package/dist/vendor/devtools/shared/actions.js +156 -0
- package/dist/vendor/devtools/shared/agentReadiness.js +71 -0
- package/dist/vendor/devtools/shared/agents.js +236 -0
- package/dist/vendor/devtools/shared/arguments.js +142 -0
- package/dist/vendor/devtools/shared/cli.js +144 -0
- package/dist/vendor/devtools/shared/config.js +64 -0
- package/dist/vendor/devtools/shared/devcontainerJsonc.js +153 -0
- package/dist/vendor/devtools/shared/envfile.js +128 -0
- package/dist/vendor/devtools/shared/hostSession.js +324 -0
- package/dist/vendor/devtools/shared/machineSession.js +338 -0
- package/dist/vendor/devtools/shared/mcp.js +329 -0
- package/dist/vendor/devtools/shared/mcpTransport.js +164 -0
- package/dist/vendor/devtools/shared/models.js +12 -0
- package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +300 -0
- package/dist/vendor/devtools/shared/organizationProjects.js +145 -0
- package/dist/vendor/devtools/shared/process.js +61 -0
- package/dist/vendor/devtools/shared/projectOperations.js +279 -0
- package/dist/vendor/devtools/shared/projects.js +390 -0
- package/dist/vendor/devtools/shared/pythonProjectStatus.js +86 -0
- package/dist/vendor/devtools/shared/pythonSdkStatus.js +26 -0
- package/dist/vendor/devtools/shared/repositories.js +215 -0
- package/dist/vendor/devtools/shared/repositoryClient.js +108 -0
- package/dist/vendor/devtools/shared/repositoryIdentity.js +116 -0
- package/dist/vendor/devtools/shared/repositoryModels.js +72 -0
- package/dist/vendor/devtools/shared/session.js +444 -0
- package/docs/adr/0002-conversational-project-mcp-client.md +59 -0
- package/docs/codex-verification.md +124 -0
- package/docs/conversational-mcp.md +54 -0
- package/hooks/hooks.json +17 -0
- package/package.json +46 -4
- package/skills/mainsequence-mcp/SKILL.md +40 -0
- package/skills/ms/SKILL.md +8 -0
- package/skills/ms/agents/openai.yaml +7 -0
- package/skills/ms/assets/mainsequence-icon.png +0 -0
- package/skills/ms/assets/mainsequence-icon.svg +454 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The developer's Main Sequence session on this machine.
|
|
4
|
+
*
|
|
5
|
+
* One session per backend lives in the operating system credential store. The Main Sequence Python
|
|
6
|
+
* CLI and the Command Center SDK CLI read and write the same record, so a login made here, or with
|
|
7
|
+
* either CLI, serves all three. The record and the way each store is reached must stay equal to
|
|
8
|
+
* theirs.
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.ForeignEntryError = exports.CredentialStoreError = exports.SESSION_SERVICE = void 0;
|
|
45
|
+
exports.openCredentialStore = openCredentialStore;
|
|
46
|
+
exports.sessionAccount = sessionAccount;
|
|
47
|
+
exports.readSession = readSession;
|
|
48
|
+
exports.saveSession = saveSession;
|
|
49
|
+
exports.clearSession = clearSession;
|
|
50
|
+
const child_process_1 = require("child_process");
|
|
51
|
+
const crypto = __importStar(require("crypto"));
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const config_1 = require("./config.js");
|
|
55
|
+
exports.SESSION_SERVICE = 'MainSequenceCLI.auth';
|
|
56
|
+
const SESSION_RECORD_VERSION = 1;
|
|
57
|
+
// Old versions of the Python CLI wrote one account for every backend.
|
|
58
|
+
const UNSCOPED_ACCOUNT = 'default';
|
|
59
|
+
const SECURITY_PROGRAM = '/usr/bin/security';
|
|
60
|
+
const SECURITY_ITEM_NOT_FOUND = 44;
|
|
61
|
+
const STORE_TIMEOUT_MS = 10_000;
|
|
62
|
+
// `security -i` reads one command per line into a 4,096-character buffer and cuts a longer line:
|
|
63
|
+
// the first part runs with a truncated secret and the rest runs as another command.
|
|
64
|
+
const SECURITY_STDIN_LINE_LIMIT = 4000;
|
|
65
|
+
const SAFE_ENTRY_NAME = /^[A-Za-z0-9._@-]+$/;
|
|
66
|
+
// The comment attribute of every Keychain entry the Main Sequence tools write. Attributes are
|
|
67
|
+
// readable without consent, the secret is not: the mark tells, before the secret is asked for, that
|
|
68
|
+
// `security` wrote the entry and may read it without a dialog.
|
|
69
|
+
const SECURITY_ENTRY_MARK = 'MainSequenceCLI.session.v1';
|
|
70
|
+
const SECURITY_COMMENT_ATTRIBUTE = /^\s*"icmt"<blob>="([^"]*)"\s*$/m;
|
|
71
|
+
// The Python CLI keeps its Secret Service item under this attribute and replaces only an item
|
|
72
|
+
// that carries it. Writing the same attributes makes every tool replace one item in place.
|
|
73
|
+
const SECRET_SERVICE_APPLICATION = 'Python keyring library';
|
|
74
|
+
/** The credential store exists and could not be read or written. */
|
|
75
|
+
class CredentialStoreError extends Error {
|
|
76
|
+
constructor(message) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = 'CredentialStoreError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.CredentialStoreError = CredentialStoreError;
|
|
82
|
+
/** The entry was written by a program this one may not ask for it without a consent dialog. */
|
|
83
|
+
class ForeignEntryError extends CredentialStoreError {
|
|
84
|
+
constructor() {
|
|
85
|
+
super('The Keychain entry was written by another version of the Main Sequence tools. Asking for it would show a consent dialog in every process, so it is not read.');
|
|
86
|
+
this.name = 'ForeignEntryError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.ForeignEntryError = ForeignEntryError;
|
|
90
|
+
function runProgram(run, program, args, input) {
|
|
91
|
+
const result = run(program, args, {
|
|
92
|
+
input: input ?? '',
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
timeout: STORE_TIMEOUT_MS,
|
|
95
|
+
windowsHide: true
|
|
96
|
+
});
|
|
97
|
+
if (result.error) {
|
|
98
|
+
throw new CredentialStoreError(result.error.code === 'ETIMEDOUT'
|
|
99
|
+
? `The credential store did not answer within ${STORE_TIMEOUT_MS / 1000} seconds. It was waiting for the user: the entry belongs to another program, or the store is locked.`
|
|
100
|
+
: `The credential store program could not be run (${result.error.code || result.error.name}).`);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
function decodeSecuritySecret(output) {
|
|
105
|
+
// `security -w` prints printable ASCII as it is and anything else as hexadecimal. The record is
|
|
106
|
+
// JSON, so a value made only of hexadecimal digits is the second form.
|
|
107
|
+
const value = output.endsWith('\n') ? output.slice(0, -1) : output;
|
|
108
|
+
if (value && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value)) {
|
|
109
|
+
const decoded = Buffer.from(value, 'hex').toString('utf8');
|
|
110
|
+
if (!decoded.includes('�'))
|
|
111
|
+
return decoded;
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The login Keychain through Apple's `security` program, which is one program for every runtime.
|
|
117
|
+
*
|
|
118
|
+
* Asking `security` for the secret of an entry that another program wrote shows a consent dialog,
|
|
119
|
+
* in every process that starts. The secret is therefore asked for only when the entry carries the
|
|
120
|
+
* tools' mark. Any other entry is left alone until a login replaces it.
|
|
121
|
+
*/
|
|
122
|
+
function createMacOsKeychain(run) {
|
|
123
|
+
const readable = new Set();
|
|
124
|
+
const key = (service, account) => `${service}\n${account}`;
|
|
125
|
+
function remove(service, account) {
|
|
126
|
+
const result = runProgram(run, SECURITY_PROGRAM, ['delete-generic-password', '-s', service, '-a', account]);
|
|
127
|
+
readable.delete(key(service, account));
|
|
128
|
+
if (result.status === SECURITY_ITEM_NOT_FOUND)
|
|
129
|
+
return false;
|
|
130
|
+
if (result.status !== 0) {
|
|
131
|
+
throw new CredentialStoreError(`The Keychain entry could not be deleted (security exit ${result.status}).`);
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
name: 'macOS Keychain',
|
|
137
|
+
read(service, account) {
|
|
138
|
+
readable.delete(key(service, account));
|
|
139
|
+
// Attributes only: this shows no dialog, whoever wrote the entry.
|
|
140
|
+
const inspected = runProgram(run, SECURITY_PROGRAM, ['find-generic-password', '-s', service, '-a', account]);
|
|
141
|
+
if (inspected.status === SECURITY_ITEM_NOT_FOUND)
|
|
142
|
+
return null;
|
|
143
|
+
if (inspected.status !== 0) {
|
|
144
|
+
throw new CredentialStoreError(`The Keychain entry could not be inspected (security exit ${inspected.status}).`);
|
|
145
|
+
}
|
|
146
|
+
if (SECURITY_COMMENT_ATTRIBUTE.exec(inspected.stdout)?.[1] !== SECURITY_ENTRY_MARK) {
|
|
147
|
+
throw new ForeignEntryError();
|
|
148
|
+
}
|
|
149
|
+
const result = runProgram(run, SECURITY_PROGRAM, ['find-generic-password', '-s', service, '-a', account, '-w']);
|
|
150
|
+
if (result.status === SECURITY_ITEM_NOT_FOUND)
|
|
151
|
+
return null;
|
|
152
|
+
if (result.status !== 0) {
|
|
153
|
+
throw new CredentialStoreError(`The Keychain entry could not be read (security exit ${result.status}).`);
|
|
154
|
+
}
|
|
155
|
+
readable.add(key(service, account));
|
|
156
|
+
return decodeSecuritySecret(result.stdout);
|
|
157
|
+
},
|
|
158
|
+
write(service, account, secret) {
|
|
159
|
+
if (!SAFE_ENTRY_NAME.test(service) || !SAFE_ENTRY_NAME.test(account)) {
|
|
160
|
+
throw new CredentialStoreError('Unsupported Keychain entry name.');
|
|
161
|
+
}
|
|
162
|
+
const line = `add-generic-password -U -s ${service} -a ${account} -j ${SECURITY_ENTRY_MARK} -X ${Buffer.from(secret, 'utf8').toString('hex')}\n`;
|
|
163
|
+
if (line.length > SECURITY_STDIN_LINE_LIMIT) {
|
|
164
|
+
throw new CredentialStoreError('The session record is too long to store through the Keychain helper.');
|
|
165
|
+
}
|
|
166
|
+
// An entry this process read is updated in place, so another process never finds it
|
|
167
|
+
// missing. Any other entry is removed first: updating one that another program created
|
|
168
|
+
// would wait for the user's consent, and removing it does not.
|
|
169
|
+
if (readable.has(key(service, account))) {
|
|
170
|
+
try {
|
|
171
|
+
if (runProgram(run, SECURITY_PROGRAM, ['-i'], line).status === 0)
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Fall through to remove and add.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
remove(service, account);
|
|
179
|
+
// Never report this call's output: on a parse error it echoes the line.
|
|
180
|
+
const result = runProgram(run, SECURITY_PROGRAM, ['-i'], line);
|
|
181
|
+
if (result.status !== 0) {
|
|
182
|
+
throw new CredentialStoreError(`The Keychain entry could not be written (security exit ${result.status}).`);
|
|
183
|
+
}
|
|
184
|
+
readable.add(key(service, account));
|
|
185
|
+
},
|
|
186
|
+
remove
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Secret Service through `secret-tool`, the program the desktop's libsecret ships. */
|
|
190
|
+
function createSecretService(run, program) {
|
|
191
|
+
function call(args, input) {
|
|
192
|
+
const result = runProgram(run, program, args, input);
|
|
193
|
+
// `secret-tool` exits 1 both for "no such item" and for an unreachable service. Only the
|
|
194
|
+
// second one says something on standard error.
|
|
195
|
+
const complaint = (result.stderr || '').trim().split('\n')[0] || '';
|
|
196
|
+
if (result.status !== 0 && complaint) {
|
|
197
|
+
throw new CredentialStoreError(`Secret Service is not available: ${complaint}`);
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
name: 'Secret Service',
|
|
203
|
+
read(service, account) {
|
|
204
|
+
// The exact item the tools write, and then any item another program stored for the entry.
|
|
205
|
+
for (const extra of [['application', SECRET_SERVICE_APPLICATION], []]) {
|
|
206
|
+
const result = call(['lookup', 'service', service, 'username', account, ...extra]);
|
|
207
|
+
if (result.status === 0 && result.stdout)
|
|
208
|
+
return result.stdout;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
},
|
|
212
|
+
write(service, account, secret) {
|
|
213
|
+
const result = call([
|
|
214
|
+
'store',
|
|
215
|
+
`--label=Password for '${account}' on '${service}'`,
|
|
216
|
+
'service',
|
|
217
|
+
service,
|
|
218
|
+
'username',
|
|
219
|
+
account,
|
|
220
|
+
'application',
|
|
221
|
+
SECRET_SERVICE_APPLICATION
|
|
222
|
+
], secret);
|
|
223
|
+
if (result.status !== 0) {
|
|
224
|
+
throw new CredentialStoreError(`The Secret Service entry could not be written (secret-tool exit ${result.status}).`);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
remove(service, account) {
|
|
228
|
+
return call(['clear', 'service', service, 'username', account]).status === 0;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function isExecutableFile(candidate) {
|
|
233
|
+
try {
|
|
234
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* This system's credential store, or null when the extension can reach none. macOS and Linux each
|
|
243
|
+
* use one named store, reached through a program of the system, so every runtime on the machine
|
|
244
|
+
* sees the same entry.
|
|
245
|
+
*/
|
|
246
|
+
function openCredentialStore(options = {}) {
|
|
247
|
+
const platform = options.platform ?? process.platform;
|
|
248
|
+
const env = options.env ?? process.env;
|
|
249
|
+
const run = options.run ?? child_process_1.spawnSync;
|
|
250
|
+
const isExecutable = options.isExecutable ?? isExecutableFile;
|
|
251
|
+
if (platform === 'darwin') {
|
|
252
|
+
return isExecutable(SECURITY_PROGRAM) ? createMacOsKeychain(run) : null;
|
|
253
|
+
}
|
|
254
|
+
if (platform === 'linux') {
|
|
255
|
+
for (const directory of (env.PATH || '').split(path.delimiter)) {
|
|
256
|
+
if (!directory)
|
|
257
|
+
continue;
|
|
258
|
+
const candidate = path.join(directory, 'secret-tool');
|
|
259
|
+
if (isExecutable(candidate))
|
|
260
|
+
return createSecretService(run, candidate);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
function requireBackend(backend) {
|
|
266
|
+
const normalized = (0, config_1.normalizeBackendUrl)(backend);
|
|
267
|
+
if (!normalized)
|
|
268
|
+
throw new Error('MainSequence backend URL is required.');
|
|
269
|
+
return normalized;
|
|
270
|
+
}
|
|
271
|
+
/** The account that holds the session of one backend. Every tool computes it the same way. */
|
|
272
|
+
function sessionAccount(backend) {
|
|
273
|
+
const digest = crypto.createHash('sha256').update(requireBackend(backend), 'utf8').digest('hex');
|
|
274
|
+
return `${UNSCOPED_ACCOUNT}.${digest.slice(0, 16)}`;
|
|
275
|
+
}
|
|
276
|
+
function parseRecord(raw, backend) {
|
|
277
|
+
let record;
|
|
278
|
+
try {
|
|
279
|
+
record = JSON.parse(raw);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new CredentialStoreError('The saved session record is not valid.');
|
|
283
|
+
}
|
|
284
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
285
|
+
throw new CredentialStoreError('The saved session record is not valid.');
|
|
286
|
+
}
|
|
287
|
+
// `v` is written, not checked: the Python and Command Center CLIs read a record by its fields,
|
|
288
|
+
// and the three readers must accept the same records.
|
|
289
|
+
// A record written before the backend was part of it carries none.
|
|
290
|
+
const recordedBackend = typeof record.backend === 'string' ? record.backend.trim() : '';
|
|
291
|
+
if (recordedBackend && (0, config_1.normalizeBackendUrl)(recordedBackend) !== requireBackend(backend))
|
|
292
|
+
return null;
|
|
293
|
+
const session = {
|
|
294
|
+
username: typeof record.username === 'string' ? record.username : '',
|
|
295
|
+
access: typeof record.access === 'string' ? record.access : '',
|
|
296
|
+
refresh: typeof record.refresh === 'string' ? record.refresh : ''
|
|
297
|
+
};
|
|
298
|
+
return session.access || session.refresh ? session : null;
|
|
299
|
+
}
|
|
300
|
+
/** Read the saved session of one backend, or null. */
|
|
301
|
+
function readSession(store, backend) {
|
|
302
|
+
for (const account of [sessionAccount(backend), UNSCOPED_ACCOUNT]) {
|
|
303
|
+
const raw = store.read(exports.SESSION_SERVICE, account);
|
|
304
|
+
if (!raw)
|
|
305
|
+
continue;
|
|
306
|
+
const session = parseRecord(raw, backend);
|
|
307
|
+
if (session)
|
|
308
|
+
return session;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
/** Save the session of one backend. */
|
|
313
|
+
function saveSession(store, backend, session) {
|
|
314
|
+
const json = JSON.stringify({
|
|
315
|
+
v: SESSION_RECORD_VERSION,
|
|
316
|
+
backend: requireBackend(backend),
|
|
317
|
+
username: session.username || '',
|
|
318
|
+
access: session.access || '',
|
|
319
|
+
refresh: session.refresh || ''
|
|
320
|
+
});
|
|
321
|
+
// ASCII only, as the Python CLI writes it, so every store returns the same bytes.
|
|
322
|
+
const ascii = json.replace(/[\u007f-]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
323
|
+
store.write(exports.SESSION_SERVICE, sessionAccount(backend), ascii);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Remove the saved session of one backend. Missing state is success.
|
|
327
|
+
*
|
|
328
|
+
* The legacy unscoped entry goes too, unread, as the Python and Command Center CLIs remove it:
|
|
329
|
+
* deleting needs no consent, while reading an entry another program wrote is refused on macOS
|
|
330
|
+
* and would make the logout fail.
|
|
331
|
+
*/
|
|
332
|
+
function clearSession(store, backend) {
|
|
333
|
+
let removed = false;
|
|
334
|
+
for (const account of [sessionAccount(backend), UNSCOPED_ACCOUNT]) {
|
|
335
|
+
removed = store.remove(exports.SESSION_SERVICE, account) || removed;
|
|
336
|
+
}
|
|
337
|
+
return removed;
|
|
338
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.McpConnections = void 0;
|
|
37
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const os = __importStar(require("node:os"));
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
const models_1 = require("./models.js");
|
|
42
|
+
const session_1 = require("./session.js");
|
|
43
|
+
const mcpTransport_1 = require("./mcpTransport.js");
|
|
44
|
+
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)) {
|
|
46
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.');
|
|
47
|
+
}
|
|
48
|
+
return value.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
function endpoint(value, metadata = false) {
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned no MCP endpoint.');
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(value);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid MCP endpoint.');
|
|
59
|
+
}
|
|
60
|
+
if (url.username || url.password || (!metadata && url.search) || url.hash || /[\r\n\0]/.test(value)
|
|
61
|
+
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
62
|
+
throw new models_1.ToolingError('API_ERROR', 'An HTTPS endpoint, or loopback HTTP endpoint for local development, is required.');
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function connection(backend, row) {
|
|
67
|
+
const c = row?.mcp_connection;
|
|
68
|
+
if (!c || c.transport !== 'streamable-http') {
|
|
69
|
+
throw new models_1.ToolingError('API_ERROR', 'The selected release has no active platform MCP connection.');
|
|
70
|
+
}
|
|
71
|
+
const url = endpoint(c.url);
|
|
72
|
+
if (new URL(url).pathname !== '/mcp')
|
|
73
|
+
throw new models_1.ToolingError('API_ERROR', 'The release returned a non-canonical MCP endpoint.');
|
|
74
|
+
const releaseUid = uuid(row.resource_release_uid ?? row.uid), metadataUrl = endpoint(c.resource_metadata_url, true);
|
|
75
|
+
const metadataQuery = new URL(metadataUrl).searchParams;
|
|
76
|
+
if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1
|
|
77
|
+
|| metadataQuery.get('resource_release_uid') !== releaseUid)
|
|
78
|
+
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 } : {}),
|
|
81
|
+
mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl,
|
|
82
|
+
organization_environment_uid: uuid(c.organization_environment_uid),
|
|
83
|
+
...(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
|
+
}
|
|
85
|
+
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');
|
|
89
|
+
}
|
|
90
|
+
/** Only endpoint metadata is persisted. Release credentials exist in one invocation's memory. */
|
|
91
|
+
class McpConnections {
|
|
92
|
+
session;
|
|
93
|
+
options;
|
|
94
|
+
file;
|
|
95
|
+
secrets = new Set();
|
|
96
|
+
constructor(session, options = {}) {
|
|
97
|
+
this.session = session;
|
|
98
|
+
this.options = options;
|
|
99
|
+
this.file = options.connectionsPath ?? storagePath(options.env ?? process.env);
|
|
100
|
+
}
|
|
101
|
+
sanitize(value) {
|
|
102
|
+
if (typeof value === 'string') {
|
|
103
|
+
for (const secret of this.secrets)
|
|
104
|
+
value = value.split(secret).join('[redacted]');
|
|
105
|
+
return this.session.sanitize?.(value) ?? value;
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value))
|
|
108
|
+
return value.map(item => this.sanitize(item));
|
|
109
|
+
if (value && typeof value === 'object')
|
|
110
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [this.sanitize(key), this.sanitize(item)]));
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
async read() {
|
|
114
|
+
try {
|
|
115
|
+
const stat = await fs.lstat(this.file);
|
|
116
|
+
if (!stat.isFile() || stat.size > 2_000_000)
|
|
117
|
+
throw new Error('invalid store');
|
|
118
|
+
const rows = JSON.parse(await fs.readFile(this.file, 'utf8'));
|
|
119
|
+
if (!Array.isArray(rows))
|
|
120
|
+
throw new Error('invalid store');
|
|
121
|
+
return rows.map(row => connection(row.backend, row));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.code === 'ENOENT')
|
|
125
|
+
return [];
|
|
126
|
+
throw new models_1.ToolingError('STORE_ERROR', 'Saved MCP connections could not be read. The existing file has been kept.');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async update(change) {
|
|
130
|
+
this.options.signal?.throwIfAborted();
|
|
131
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
132
|
+
const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
|
|
133
|
+
let handle;
|
|
134
|
+
try {
|
|
135
|
+
handle = await fs.open(lock, 'wx', 0o600);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new models_1.ToolingError('PROJECT_BUSY', 'Another action holds the saved MCP connection lock. After an interrupted action, verify it has stopped before removing ' + lock + '.');
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid }));
|
|
142
|
+
const rows = change(await this.read());
|
|
143
|
+
this.options.signal?.throwIfAborted();
|
|
144
|
+
const serialized = JSON.stringify(rows, null, 2) + '\n';
|
|
145
|
+
if (Buffer.byteLength(serialized) > 2_000_000)
|
|
146
|
+
throw new models_1.ToolingError('PERSISTENCE_FAILED', 'The saved MCP connections exceed the client limit.');
|
|
147
|
+
await fs.writeFile(temporary, serialized, { flag: 'wx', mode: 0o600 });
|
|
148
|
+
await fs.rename(temporary, this.file);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof models_1.ToolingError)
|
|
152
|
+
throw error;
|
|
153
|
+
throw new models_1.ToolingError('PERSISTENCE_FAILED', 'The MCP connections could not be saved.');
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await fs.rm(temporary, { force: true });
|
|
157
|
+
await handle.close();
|
|
158
|
+
await fs.unlink(lock);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async current(backend, releaseUid) {
|
|
162
|
+
const row = await this.session.request(backend, 'GET', '/api/v1/resource-releases/' + releaseUid + '/');
|
|
163
|
+
const value = connection(backend, row);
|
|
164
|
+
if (value.resource_release_uid !== releaseUid)
|
|
165
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned a different release.');
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
async open(value) {
|
|
169
|
+
const grant = await this.session.request(value.backend, 'GET', '/api/v1/resource-releases/' + value.resource_release_uid + '/exchange-launch/');
|
|
170
|
+
if (grant?.mode !== 'token' || grant.release_kind !== 'fastapi' || typeof grant.token !== 'string' || !grant.token || /[\r\n]/.test(grant.token)) {
|
|
171
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned no usable FastAPI release credential.');
|
|
172
|
+
}
|
|
173
|
+
this.secrets.add(grant.token);
|
|
174
|
+
if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
|
|
175
|
+
throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
|
|
176
|
+
}
|
|
177
|
+
const client = new mcpTransport_1.McpTransport(message => (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, grant.token, message, this.options));
|
|
178
|
+
await client.initialize();
|
|
179
|
+
return client;
|
|
180
|
+
}
|
|
181
|
+
async dispatch(request, inputBackend) {
|
|
182
|
+
let callStarted = false, resultData;
|
|
183
|
+
try {
|
|
184
|
+
const backend = (0, session_1.requireBackend)(inputBackend);
|
|
185
|
+
this.options.signal?.throwIfAborted();
|
|
186
|
+
const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid);
|
|
187
|
+
const belongs = (value) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid;
|
|
188
|
+
const requireEnvironment = (value) => {
|
|
189
|
+
if (!belongs(value))
|
|
190
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'This MCP belongs to a different organization environment. Select that environment first.');
|
|
191
|
+
};
|
|
192
|
+
let data, summary;
|
|
193
|
+
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()];
|
|
219
|
+
summary = 'Organization environments available to your MainSequence account.';
|
|
220
|
+
}
|
|
221
|
+
else if (request.action === 'mcp-discover') {
|
|
222
|
+
if ((request.search !== undefined && typeof request.search !== 'string')
|
|
223
|
+
|| (request.limit !== undefined && (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100))
|
|
224
|
+
|| (request.offset !== undefined && (!Number.isInteger(request.offset) || request.offset < 0))) {
|
|
225
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery requires a text search, a limit from 1 to 100, and a nonnegative offset.');
|
|
226
|
+
}
|
|
227
|
+
const client = new mcpTransport_1.McpTransport(message => this.session.request(backend, 'POST', '/mcp', message, true));
|
|
228
|
+
await client.initialize();
|
|
229
|
+
const query = async (offset, limit) => {
|
|
230
|
+
const output = await client.request('tools/call', { name: 'resource_release.mcp_endpoints',
|
|
231
|
+
arguments: { ...(request.search !== undefined ? { search: request.search } : {}), limit, offset } });
|
|
232
|
+
if (output?.isError)
|
|
233
|
+
throw new models_1.ToolingError('API_ERROR', 'Platform MCP could not list available project endpoints.');
|
|
234
|
+
const page = output?.structuredContent;
|
|
235
|
+
if (!Number.isInteger(page?.count) || page.count < 0 || !Array.isArray(page?.results))
|
|
236
|
+
throw new models_1.ToolingError('API_ERROR', 'Platform MCP returned an invalid directory.');
|
|
237
|
+
return { count: page.count, results: page.results.map((row) => connection(backend, row)) };
|
|
238
|
+
};
|
|
239
|
+
if (!environmentUid)
|
|
240
|
+
data = await query(request.offset ?? 0, request.limit ?? 100);
|
|
241
|
+
else {
|
|
242
|
+
// The existing platform MCP has no environment filter. Scan its pages
|
|
243
|
+
// before paginating the matching releases; never show an incomplete page as complete.
|
|
244
|
+
const matching = new Map();
|
|
245
|
+
for (let offset = 0;; offset += 100) {
|
|
246
|
+
this.options.signal?.throwIfAborted();
|
|
247
|
+
if (offset >= 10000)
|
|
248
|
+
throw new models_1.ToolingError('API_ERROR', 'MCP discovery is too large; narrow the search.');
|
|
249
|
+
const page = await query(offset, 100);
|
|
250
|
+
for (const value of page.results.filter(belongs))
|
|
251
|
+
matching.set(value.resource_release_uid, value);
|
|
252
|
+
if (offset + 100 >= page.count)
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const values = [...matching.values()], offset = request.offset ?? 0;
|
|
256
|
+
data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) };
|
|
257
|
+
}
|
|
258
|
+
summary = 'Available project MCP endpoints; discovery does not save a connection.';
|
|
259
|
+
}
|
|
260
|
+
else if (request.action === 'mcp-list') {
|
|
261
|
+
data = (await this.read()).filter(row => row.backend === backend && belongs(row));
|
|
262
|
+
summary = 'Saved MCP connections for this backend. Availability is checked when used.';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const releaseUid = uuid(request.releaseUid);
|
|
266
|
+
if (request.action === 'mcp-remove') {
|
|
267
|
+
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
|
|
268
|
+
if (saved)
|
|
269
|
+
requireEnvironment(saved);
|
|
270
|
+
await this.update(rows => rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)));
|
|
271
|
+
data = { resource_release_uid: releaseUid };
|
|
272
|
+
summary = 'The saved connection was removed.';
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
const value = await this.current(backend, releaseUid);
|
|
276
|
+
requireEnvironment(value);
|
|
277
|
+
if (request.action !== 'mcp-add') {
|
|
278
|
+
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
|
|
279
|
+
if (!saved)
|
|
280
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
|
|
281
|
+
if (saved.mcp_connection.url !== value.mcp_connection.url)
|
|
282
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.');
|
|
283
|
+
}
|
|
284
|
+
const client = await this.open(value);
|
|
285
|
+
const tools = await client.tools();
|
|
286
|
+
if (request.action === 'mcp-add') {
|
|
287
|
+
await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
|
|
288
|
+
data = { connection: value, tools };
|
|
289
|
+
summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.';
|
|
290
|
+
}
|
|
291
|
+
else if (request.action === 'mcp-tools') {
|
|
292
|
+
data = { connection: value, tools };
|
|
293
|
+
summary = 'Current tools from the selected MCP.';
|
|
294
|
+
}
|
|
295
|
+
else if (request.action === 'mcp-call') {
|
|
296
|
+
if (typeof request.tool !== 'string' || !tools.some(tool => tool.name === request.tool))
|
|
297
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'The selected tool is absent from the current MCP catalog.');
|
|
298
|
+
if (!request.arguments || typeof request.arguments !== 'object' || Array.isArray(request.arguments))
|
|
299
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Tool arguments must be a JSON object.');
|
|
300
|
+
this.options.signal?.throwIfAborted();
|
|
301
|
+
callStarted = true;
|
|
302
|
+
const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments });
|
|
303
|
+
if (!output || !Array.isArray(output.content))
|
|
304
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP tool returned an invalid result.');
|
|
305
|
+
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.');
|
|
308
|
+
data = output;
|
|
309
|
+
summary = 'The MCP tool completed.';
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown MCP action.');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
const failure = this.options.signal?.aborted ? new models_1.ToolingError('CANCELLED', 'The MCP action was cancelled.')
|
|
319
|
+
: error instanceof models_1.ToolingError ? error : new models_1.ToolingError('INTERNAL_ERROR', 'The MCP action could not complete.');
|
|
320
|
+
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 }),
|
|
322
|
+
...(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
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
this.secrets.clear();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
exports.McpConnections = McpConnections;
|