brainctl 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -157
- package/dist/cli.js +40 -0
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/profile.js +35 -2
- package/dist/mcp/server.js +51 -5
- package/dist/services/agent-config-service.d.ts +4 -2
- package/dist/services/agent-config-service.js +50 -15
- package/dist/services/agent-converter-service.d.ts +21 -0
- package/dist/services/agent-converter-service.js +182 -0
- package/dist/services/credential-redaction-service.d.ts +13 -0
- package/dist/services/credential-redaction-service.js +89 -0
- package/dist/services/credential-resolution-service.d.ts +11 -0
- package/dist/services/credential-resolution-service.js +69 -0
- package/dist/services/mcp-preflight-service.d.ts +3 -2
- package/dist/services/mcp-preflight-service.js +159 -5
- package/dist/services/plugin-install-service.d.ts +43 -0
- package/dist/services/plugin-install-service.js +379 -21
- package/dist/services/portable-mcp-classifier.d.ts +12 -0
- package/dist/services/portable-mcp-classifier.js +116 -0
- package/dist/services/portable-profile-pack-service.d.ts +26 -0
- package/dist/services/portable-profile-pack-service.js +264 -0
- package/dist/services/profile-export-service.d.ts +15 -3
- package/dist/services/profile-export-service.js +10 -57
- package/dist/services/profile-import-service.d.ts +9 -1
- package/dist/services/profile-import-service.js +265 -10
- package/dist/services/profile-service.js +11 -0
- package/dist/services/runtime-detector.d.ts +9 -0
- package/dist/services/runtime-detector.js +130 -0
- package/dist/services/skill-paths.d.ts +2 -0
- package/dist/services/skill-paths.js +14 -0
- package/dist/services/sync/agent-reader.d.ts +9 -0
- package/dist/services/sync/agent-reader.js +177 -35
- package/dist/services/sync/claude-writer.js +0 -6
- package/dist/services/sync/codex-writer.d.ts +1 -0
- package/dist/services/sync/codex-writer.js +21 -8
- package/dist/services/sync/gemini-writer.js +5 -7
- package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
- package/dist/services/sync/plugin-skill-reader.js +142 -1
- package/dist/services/sync-service.js +1 -1
- package/dist/services/update-check-service.d.ts +33 -0
- package/dist/services/update-check-service.js +128 -0
- package/dist/types.d.ts +47 -0
- package/dist/ui/routes.js +35 -8
- package/dist/web/assets/index-Cdb5hbxM.css +1 -0
- package/dist/web/assets/index-gN83hZYA.js +65 -0
- package/dist/web/favicon-light.svg +13 -0
- package/dist/web/favicon.svg +13 -0
- package/dist/web/index.html +7 -2
- package/package.json +5 -1
- package/dist/web/assets/index-BCkorugl.css +0 -1
- package/dist/web/assets/index-sGnTMhkX.js +0 -16
package/dist/mcp/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { FastMCP } from 'fastmcp';
|
|
@@ -288,14 +289,20 @@ export function createMcpServer(options = {}) {
|
|
|
288
289
|
name: 'brainctl_export_profile',
|
|
289
290
|
description: 'Export a profile as a portable tarball. Packages the profile config and bundled MCP source code for sharing.',
|
|
290
291
|
parameters: z.object({
|
|
291
|
-
name: z.string().describe('Profile name to export'),
|
|
292
|
+
name: z.string().optional().describe('Profile name to export'),
|
|
293
|
+
agent: z.enum(['claude', 'codex', 'gemini']).optional().describe('Pack a live agent config instead of a saved profile'),
|
|
292
294
|
output_path: z.string().optional().describe('Output file path (defaults to <name>.tar.gz in cwd)'),
|
|
293
295
|
}),
|
|
294
296
|
execute: async (args) => {
|
|
297
|
+
if (!args.name && !args.agent) {
|
|
298
|
+
return JSON.stringify({ error: 'Provide name or agent.' }, null, 2);
|
|
299
|
+
}
|
|
295
300
|
const exportService = createProfileExportService();
|
|
296
301
|
const result = await exportService.execute({
|
|
297
302
|
cwd,
|
|
298
|
-
|
|
303
|
+
source: args.agent
|
|
304
|
+
? { source: 'agent', agent: args.agent, cwd }
|
|
305
|
+
: { source: 'profile', name: args.name },
|
|
299
306
|
outputPath: args.output_path,
|
|
300
307
|
});
|
|
301
308
|
return JSON.stringify(result, null, 2);
|
|
@@ -307,6 +314,7 @@ export function createMcpServer(options = {}) {
|
|
|
307
314
|
parameters: z.object({
|
|
308
315
|
archive_path: z.string().describe('Path to the profile tarball'),
|
|
309
316
|
force: z.boolean().default(false).describe('Overwrite existing profile if it exists'),
|
|
317
|
+
credentials: z.record(z.string(), z.string()).optional().describe('Credential values keyed by placeholder name'),
|
|
310
318
|
}),
|
|
311
319
|
execute: async (args) => {
|
|
312
320
|
const importService = createProfileImportService();
|
|
@@ -314,6 +322,7 @@ export function createMcpServer(options = {}) {
|
|
|
314
322
|
cwd,
|
|
315
323
|
archivePath: args.archive_path,
|
|
316
324
|
force: args.force,
|
|
325
|
+
credentials: args.credentials,
|
|
317
326
|
});
|
|
318
327
|
return JSON.stringify(result, null, 2);
|
|
319
328
|
},
|
|
@@ -321,17 +330,33 @@ export function createMcpServer(options = {}) {
|
|
|
321
330
|
let uiServerInstance = null;
|
|
322
331
|
server.addTool({
|
|
323
332
|
name: 'brainctl_open_ui',
|
|
324
|
-
description: 'Start the brainctl web dashboard
|
|
333
|
+
description: 'Start the brainctl web dashboard and open it in the default browser. Returns the URL. If already running, reopens the existing URL in the browser.',
|
|
325
334
|
parameters: z.object({
|
|
326
335
|
port: z.number().default(3333).describe('Port number for the UI server'),
|
|
336
|
+
openBrowser: z
|
|
337
|
+
.boolean()
|
|
338
|
+
.default(true)
|
|
339
|
+
.describe('Whether to launch the default browser at the UI URL'),
|
|
327
340
|
}),
|
|
328
341
|
execute: async (args) => {
|
|
329
342
|
if (uiServerInstance) {
|
|
330
|
-
|
|
343
|
+
if (args.openBrowser)
|
|
344
|
+
openInBrowser(uiServerInstance.url);
|
|
345
|
+
return JSON.stringify({
|
|
346
|
+
url: uiServerInstance.url,
|
|
347
|
+
status: 'already_running',
|
|
348
|
+
browserOpened: args.openBrowser,
|
|
349
|
+
});
|
|
331
350
|
}
|
|
332
351
|
try {
|
|
333
352
|
uiServerInstance = await startUiServer({ cwd, port: args.port });
|
|
334
|
-
|
|
353
|
+
if (args.openBrowser)
|
|
354
|
+
openInBrowser(uiServerInstance.url);
|
|
355
|
+
return JSON.stringify({
|
|
356
|
+
url: uiServerInstance.url,
|
|
357
|
+
status: 'started',
|
|
358
|
+
browserOpened: args.openBrowser,
|
|
359
|
+
});
|
|
335
360
|
}
|
|
336
361
|
catch (err) {
|
|
337
362
|
return JSON.stringify({ error: err.message, status: 'failed' });
|
|
@@ -400,3 +425,24 @@ export async function startMcpServer(options = {}) {
|
|
|
400
425
|
const server = createMcpServer(options);
|
|
401
426
|
await server.start({ transportType: 'stdio' });
|
|
402
427
|
}
|
|
428
|
+
function openInBrowser(url) {
|
|
429
|
+
const platform = process.platform;
|
|
430
|
+
const { command, args } = platform === 'darwin'
|
|
431
|
+
? { command: 'open', args: [url] }
|
|
432
|
+
: platform === 'win32'
|
|
433
|
+
? { command: 'cmd', args: ['/c', 'start', '""', url] }
|
|
434
|
+
: { command: 'xdg-open', args: [url] };
|
|
435
|
+
try {
|
|
436
|
+
const child = spawn(command, args, {
|
|
437
|
+
detached: true,
|
|
438
|
+
stdio: 'ignore',
|
|
439
|
+
});
|
|
440
|
+
child.on('error', () => {
|
|
441
|
+
// Browser open is best-effort; swallow errors so the MCP call still succeeds.
|
|
442
|
+
});
|
|
443
|
+
child.unref();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// ignore
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentName } from '../types.js';
|
|
2
|
-
import { type AgentLiveConfig, type AgentMcpEntry } from './sync/agent-reader.js';
|
|
2
|
+
import { type AgentLiveConfig, type AgentMcpEntry, type PortableRemoteMcpMetadata } from './sync/agent-reader.js';
|
|
3
3
|
import { type McpPreflightService } from './mcp-preflight-service.js';
|
|
4
4
|
import { type SkillPreflightService } from './skill-preflight-service.js';
|
|
5
5
|
export type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './sync/agent-reader.js';
|
|
6
|
+
export type { PortableRemoteMcpMetadata } from './sync/agent-reader.js';
|
|
6
7
|
export interface AgentConfigService {
|
|
7
8
|
readAll(options: {
|
|
8
9
|
cwd: string;
|
|
@@ -11,7 +12,8 @@ export interface AgentConfigService {
|
|
|
11
12
|
cwd: string;
|
|
12
13
|
agent: AgentName;
|
|
13
14
|
key: string;
|
|
14
|
-
entry
|
|
15
|
+
entry?: AgentMcpEntry;
|
|
16
|
+
remoteEntry?: PortableRemoteMcpMetadata;
|
|
15
17
|
}): Promise<void>;
|
|
16
18
|
removeMcp(options: {
|
|
17
19
|
cwd: string;
|
|
@@ -25,25 +25,32 @@ export function createAgentConfigService(dependencies = {}) {
|
|
|
25
25
|
return results;
|
|
26
26
|
},
|
|
27
27
|
async addMcp(options) {
|
|
28
|
-
const { cwd, agent, key, entry } = options;
|
|
29
|
-
const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry });
|
|
28
|
+
const { cwd, agent, key, entry, remoteEntry } = options;
|
|
29
|
+
const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry, remoteEntry });
|
|
30
30
|
const firstError = preflight.checks.find((check) => check.status === 'error');
|
|
31
31
|
if (firstError) {
|
|
32
32
|
throw new ValidationError(`MCP "${key}" cannot be added to ${agent}: ${firstError.message}`);
|
|
33
33
|
}
|
|
34
34
|
if (agent === 'claude') {
|
|
35
35
|
await mutateClaudeConfig(cwd, (servers) => {
|
|
36
|
-
servers[key] = toClaudeEntry(entry);
|
|
36
|
+
servers[key] = remoteEntry ? toClaudeRemoteEntry(remoteEntry) : toClaudeEntry(entry);
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
else if (agent === 'codex') {
|
|
40
|
-
await mutateCodexConfig((
|
|
41
|
-
|
|
40
|
+
await mutateCodexConfig(cwd, (state) => {
|
|
41
|
+
delete state.mcpServers[key];
|
|
42
|
+
delete state.remoteMcpServers[key];
|
|
43
|
+
if (remoteEntry) {
|
|
44
|
+
state.remoteMcpServers[key] = remoteEntry;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
state.mcpServers[key] = entry;
|
|
48
|
+
}
|
|
42
49
|
});
|
|
43
50
|
}
|
|
44
51
|
else if (agent === 'gemini') {
|
|
45
52
|
await mutateGeminiConfig(cwd, (servers) => {
|
|
46
|
-
servers[key] = toGeminiEntry(entry);
|
|
53
|
+
servers[key] = remoteEntry ? toGeminiRemoteEntry(remoteEntry) : toGeminiEntry(entry);
|
|
47
54
|
});
|
|
48
55
|
}
|
|
49
56
|
},
|
|
@@ -55,8 +62,9 @@ export function createAgentConfigService(dependencies = {}) {
|
|
|
55
62
|
});
|
|
56
63
|
}
|
|
57
64
|
else if (agent === 'codex') {
|
|
58
|
-
await mutateCodexConfig((
|
|
59
|
-
delete
|
|
65
|
+
await mutateCodexConfig(cwd, (state) => {
|
|
66
|
+
delete state.mcpServers[key];
|
|
67
|
+
delete state.remoteMcpServers[key];
|
|
60
68
|
});
|
|
61
69
|
}
|
|
62
70
|
else if (agent === 'gemini') {
|
|
@@ -100,6 +108,11 @@ async function mutateClaudeConfig(cwd, mutate) {
|
|
|
100
108
|
catch {
|
|
101
109
|
// fresh config
|
|
102
110
|
}
|
|
111
|
+
// Apply mutation to user-scoped (top-level) MCPs
|
|
112
|
+
const userServers = (existing.mcpServers ?? {});
|
|
113
|
+
mutate(userServers);
|
|
114
|
+
existing.mcpServers = userServers;
|
|
115
|
+
// Apply mutation to project-scoped MCPs
|
|
103
116
|
const projects = (existing.projects ?? {});
|
|
104
117
|
const projectConfig = projects[cwd] ?? {};
|
|
105
118
|
const servers = (projectConfig.mcpServers ?? {});
|
|
@@ -117,8 +130,15 @@ function toClaudeEntry(entry) {
|
|
|
117
130
|
...(entry.env ? { env: entry.env } : {}),
|
|
118
131
|
};
|
|
119
132
|
}
|
|
133
|
+
function toClaudeRemoteEntry(entry) {
|
|
134
|
+
return {
|
|
135
|
+
type: entry.transport === 'sse' ? 'sse' : 'http',
|
|
136
|
+
url: entry.url,
|
|
137
|
+
...(entry.headers ? { headers: entry.headers } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
120
140
|
/* ---- Codex: TOML with [mcp_servers.*] ---- */
|
|
121
|
-
async function mutateCodexConfig(mutate) {
|
|
141
|
+
async function mutateCodexConfig(cwd, mutate) {
|
|
122
142
|
const configPath = path.join(homedir(), '.codex', 'config.toml');
|
|
123
143
|
let existingContent = '';
|
|
124
144
|
try {
|
|
@@ -129,12 +149,15 @@ async function mutateCodexConfig(mutate) {
|
|
|
129
149
|
// fresh config
|
|
130
150
|
}
|
|
131
151
|
// Read current servers via reader
|
|
132
|
-
const current = await readers.codex.read({ cwd
|
|
133
|
-
const
|
|
134
|
-
|
|
152
|
+
const current = await readers.codex.read({ cwd });
|
|
153
|
+
const state = {
|
|
154
|
+
mcpServers: { ...current.mcpServers },
|
|
155
|
+
remoteMcpServers: { ...current.remoteMcpServers },
|
|
156
|
+
};
|
|
157
|
+
mutate(state);
|
|
135
158
|
// Rebuild: preserve non-mcp content + new mcp sections
|
|
136
159
|
const nonMcp = stripCodexMcpSections(existingContent).trim();
|
|
137
|
-
const mcpToml = buildCodexMcpToml(
|
|
160
|
+
const mcpToml = buildCodexMcpToml(state);
|
|
138
161
|
const final = nonMcp.length > 0 ? `${nonMcp}\n\n${mcpToml}` : mcpToml;
|
|
139
162
|
await mkdir(path.dirname(configPath), { recursive: true });
|
|
140
163
|
await atomicWrite(configPath, final + '\n');
|
|
@@ -156,9 +179,9 @@ function stripCodexMcpSections(content) {
|
|
|
156
179
|
}
|
|
157
180
|
return result.join('\n');
|
|
158
181
|
}
|
|
159
|
-
function buildCodexMcpToml(
|
|
182
|
+
function buildCodexMcpToml(state) {
|
|
160
183
|
const lines = [];
|
|
161
|
-
for (const [name, entry] of Object.entries(
|
|
184
|
+
for (const [name, entry] of Object.entries(state.mcpServers)) {
|
|
162
185
|
lines.push(`[mcp_servers.${name}]`);
|
|
163
186
|
lines.push(`command = ${tomlStr(entry.command)}`);
|
|
164
187
|
if (entry.args && entry.args.length > 0) {
|
|
@@ -173,6 +196,11 @@ function buildCodexMcpToml(servers) {
|
|
|
173
196
|
}
|
|
174
197
|
lines.push('');
|
|
175
198
|
}
|
|
199
|
+
for (const [name, entry] of Object.entries(state.remoteMcpServers)) {
|
|
200
|
+
lines.push(`[mcp_servers.${name}]`);
|
|
201
|
+
lines.push(`url = ${tomlStr(entry.url)}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
}
|
|
176
204
|
return lines.join('\n').trim();
|
|
177
205
|
}
|
|
178
206
|
function tomlStr(value) {
|
|
@@ -202,6 +230,13 @@ function toGeminiEntry(entry) {
|
|
|
202
230
|
...(entry.env ? { env: entry.env } : {}),
|
|
203
231
|
};
|
|
204
232
|
}
|
|
233
|
+
function toGeminiRemoteEntry(entry) {
|
|
234
|
+
return {
|
|
235
|
+
...(entry.transport === 'http' ? { httpUrl: entry.url } : { url: entry.url }),
|
|
236
|
+
...(entry.headers ? { headers: entry.headers } : {}),
|
|
237
|
+
...(entry.env ? { env: entry.env } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
205
240
|
/* ---- Shared helpers ---- */
|
|
206
241
|
async function backupFile(filePath) {
|
|
207
242
|
const backupPath = `${filePath}.bak.${formatTimestamp()}`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ParsedClaudeAgent {
|
|
2
|
+
frontmatter: Record<string, unknown>;
|
|
3
|
+
body: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ParsedCodexAgent {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
developerInstructions: string;
|
|
9
|
+
sandboxMode?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
extra: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseMarkdownWithFrontmatter(source: string): ParsedClaudeAgent;
|
|
14
|
+
export declare function serializeMarkdownWithFrontmatter(frontmatter: Record<string, unknown>, body: string): string;
|
|
15
|
+
export declare function claudeAgentMdToCodexToml(source: string): string;
|
|
16
|
+
export declare function codexAgentTomlToClaudeMd(source: string): string;
|
|
17
|
+
export declare function claudeCommandMdToCodexSkill(source: string): {
|
|
18
|
+
frontmatter: Record<string, unknown>;
|
|
19
|
+
skillMarkdown: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function parseCodexAgentToml(source: string): ParsedCodexAgent;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
3
|
+
export function parseMarkdownWithFrontmatter(source) {
|
|
4
|
+
const match = source.match(FRONTMATTER_RE);
|
|
5
|
+
if (!match) {
|
|
6
|
+
return { frontmatter: {}, body: source };
|
|
7
|
+
}
|
|
8
|
+
const parsed = YAML.parse(match[1]);
|
|
9
|
+
return {
|
|
10
|
+
frontmatter: parsed ?? {},
|
|
11
|
+
body: match[2],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function serializeMarkdownWithFrontmatter(frontmatter, body) {
|
|
15
|
+
const keys = Object.keys(frontmatter);
|
|
16
|
+
if (keys.length === 0)
|
|
17
|
+
return body;
|
|
18
|
+
const yaml = YAML.stringify(frontmatter).trimEnd();
|
|
19
|
+
const normalizedBody = body.startsWith('\n') ? body : `\n${body}`;
|
|
20
|
+
return `---\n${yaml}\n---${normalizedBody}`;
|
|
21
|
+
}
|
|
22
|
+
export function claudeAgentMdToCodexToml(source) {
|
|
23
|
+
const { frontmatter, body } = parseMarkdownWithFrontmatter(source);
|
|
24
|
+
const name = String(frontmatter.name ?? '').trim();
|
|
25
|
+
const description = String(frontmatter.description ?? '').trim();
|
|
26
|
+
if (!name)
|
|
27
|
+
throw new Error('Claude agent is missing required "name" frontmatter field.');
|
|
28
|
+
if (!description)
|
|
29
|
+
throw new Error('Claude agent is missing required "description" frontmatter field.');
|
|
30
|
+
const sandboxMode = inferSandboxModeFromClaudeTools(frontmatter.tools);
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(`name = ${tomlString(name)}`);
|
|
33
|
+
lines.push(`description = ${tomlString(description)}`);
|
|
34
|
+
lines.push(`developer_instructions = ${tomlMultiline(body.trim())}`);
|
|
35
|
+
if (sandboxMode)
|
|
36
|
+
lines.push(`sandbox_mode = ${tomlString(sandboxMode)}`);
|
|
37
|
+
return lines.join('\n') + '\n';
|
|
38
|
+
}
|
|
39
|
+
export function codexAgentTomlToClaudeMd(source) {
|
|
40
|
+
const parsed = parseCodexAgentToml(source);
|
|
41
|
+
const frontmatter = {
|
|
42
|
+
name: parsed.name,
|
|
43
|
+
description: parsed.description,
|
|
44
|
+
};
|
|
45
|
+
if (parsed.sandboxMode) {
|
|
46
|
+
frontmatter.tools = claudeToolsFromSandboxMode(parsed.sandboxMode);
|
|
47
|
+
}
|
|
48
|
+
return serializeMarkdownWithFrontmatter(frontmatter, `\n${parsed.developerInstructions.trim()}\n`);
|
|
49
|
+
}
|
|
50
|
+
export function claudeCommandMdToCodexSkill(source) {
|
|
51
|
+
const { frontmatter, body } = parseMarkdownWithFrontmatter(source);
|
|
52
|
+
const description = String(frontmatter.description ?? '').trim();
|
|
53
|
+
if (!description) {
|
|
54
|
+
throw new Error('Claude command is missing required "description" frontmatter field.');
|
|
55
|
+
}
|
|
56
|
+
const skillFrontmatter = {
|
|
57
|
+
description,
|
|
58
|
+
};
|
|
59
|
+
if (frontmatter['argument-hint']) {
|
|
60
|
+
skillFrontmatter['argument-hint'] = frontmatter['argument-hint'];
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
frontmatter: skillFrontmatter,
|
|
64
|
+
skillMarkdown: serializeMarkdownWithFrontmatter(skillFrontmatter, body.startsWith('\n') ? body : `\n${body}`),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function inferSandboxModeFromClaudeTools(rawTools) {
|
|
68
|
+
const tools = normalizeClaudeToolList(rawTools);
|
|
69
|
+
if (tools.length === 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
const mutating = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
72
|
+
return tools.some((tool) => mutating.has(tool)) ? 'workspace-write' : 'read-only';
|
|
73
|
+
}
|
|
74
|
+
function normalizeClaudeToolList(raw) {
|
|
75
|
+
if (Array.isArray(raw))
|
|
76
|
+
return raw.map((item) => String(item).trim()).filter(Boolean);
|
|
77
|
+
if (typeof raw === 'string') {
|
|
78
|
+
return raw.split(',').map((item) => item.trim()).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
function claudeToolsFromSandboxMode(sandboxMode) {
|
|
83
|
+
const readOnlyTools = 'Glob, Grep, LS, Read, NotebookRead, WebFetch, WebSearch';
|
|
84
|
+
if (sandboxMode === 'read-only')
|
|
85
|
+
return readOnlyTools;
|
|
86
|
+
return `${readOnlyTools}, Edit, Write, Bash`;
|
|
87
|
+
}
|
|
88
|
+
export function parseCodexAgentToml(source) {
|
|
89
|
+
const lines = source.split('\n');
|
|
90
|
+
const simple = new Map();
|
|
91
|
+
const extra = {};
|
|
92
|
+
let i = 0;
|
|
93
|
+
while (i < lines.length) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
const trimmed = line.trim();
|
|
96
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/^\[/.test(trimmed)) {
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
|
|
105
|
+
if (!kvMatch) {
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const [, key, rawValue] = kvMatch;
|
|
110
|
+
if (rawValue.startsWith('"""')) {
|
|
111
|
+
const { value, nextIndex } = readTomlMultiline(lines, i, rawValue);
|
|
112
|
+
simple.set(key, value);
|
|
113
|
+
i = nextIndex;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
simple.set(key, parseTomlScalar(rawValue));
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
const name = simple.get('name');
|
|
120
|
+
const description = simple.get('description');
|
|
121
|
+
const developerInstructions = simple.get('developer_instructions');
|
|
122
|
+
if (!name)
|
|
123
|
+
throw new Error('Codex agent is missing required "name" field.');
|
|
124
|
+
if (!description)
|
|
125
|
+
throw new Error('Codex agent is missing required "description" field.');
|
|
126
|
+
if (!developerInstructions) {
|
|
127
|
+
throw new Error('Codex agent is missing required "developer_instructions" field.');
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
description,
|
|
132
|
+
developerInstructions,
|
|
133
|
+
sandboxMode: simple.get('sandbox_mode'),
|
|
134
|
+
model: simple.get('model'),
|
|
135
|
+
extra,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function readTomlMultiline(lines, startIndex, firstLine) {
|
|
139
|
+
const afterOpen = firstLine.slice(3);
|
|
140
|
+
if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
|
|
141
|
+
return { value: unescapeTomlString(afterOpen.slice(0, -3)), nextIndex: startIndex + 1 };
|
|
142
|
+
}
|
|
143
|
+
const collected = [];
|
|
144
|
+
if (afterOpen.length > 0)
|
|
145
|
+
collected.push(afterOpen);
|
|
146
|
+
let i = startIndex + 1;
|
|
147
|
+
while (i < lines.length) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
const closeIdx = line.indexOf('"""');
|
|
150
|
+
if (closeIdx === -1) {
|
|
151
|
+
collected.push(line);
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (closeIdx > 0)
|
|
156
|
+
collected.push(line.slice(0, closeIdx));
|
|
157
|
+
return { value: unescapeTomlString(collected.join('\n').replace(/^\n/, '')), nextIndex: i + 1 };
|
|
158
|
+
}
|
|
159
|
+
throw new Error('Unterminated TOML multiline string.');
|
|
160
|
+
}
|
|
161
|
+
function parseTomlScalar(raw) {
|
|
162
|
+
const trimmed = raw.trim();
|
|
163
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
164
|
+
return unescapeTomlString(trimmed.slice(1, -1));
|
|
165
|
+
}
|
|
166
|
+
return trimmed;
|
|
167
|
+
}
|
|
168
|
+
function unescapeTomlString(value) {
|
|
169
|
+
return value
|
|
170
|
+
.replace(/\\\\/g, '\\')
|
|
171
|
+
.replace(/\\"/g, '"')
|
|
172
|
+
.replace(/\\n/g, '\n')
|
|
173
|
+
.replace(/\\r/g, '\r')
|
|
174
|
+
.replace(/\\t/g, '\t');
|
|
175
|
+
}
|
|
176
|
+
function tomlString(value) {
|
|
177
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
178
|
+
}
|
|
179
|
+
function tomlMultiline(value) {
|
|
180
|
+
const escaped = value.replace(/"""/g, '\\"\\"\\"');
|
|
181
|
+
return `"""\n${escaped}\n"""`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpServerConfig, PortableCredentialSpec } from '../types.js';
|
|
2
|
+
export interface CredentialRedactionResult<T extends McpServerConfig> {
|
|
3
|
+
redacted: T;
|
|
4
|
+
credentials: PortableCredentialSpec[];
|
|
5
|
+
}
|
|
6
|
+
export declare function redactPortableMcpCredentials<T extends McpServerConfig>(config: T): CredentialRedactionResult<T>;
|
|
7
|
+
interface CredentialAccumulator {
|
|
8
|
+
key: string;
|
|
9
|
+
required: true;
|
|
10
|
+
descriptions: Set<string>;
|
|
11
|
+
}
|
|
12
|
+
export declare function finalizePortableCredentialSpecs(credentialsByKey: Map<string, CredentialAccumulator>): PortableCredentialSpec[];
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export function redactPortableMcpCredentials(config) {
|
|
2
|
+
const credentialsByKey = new Map();
|
|
3
|
+
const redactedEnv = redactStringMap(config.env, 'env', credentialsByKey);
|
|
4
|
+
if (config.kind === 'remote') {
|
|
5
|
+
const redactedHeaders = redactStringMap(config.headers, 'header', credentialsByKey);
|
|
6
|
+
return {
|
|
7
|
+
redacted: {
|
|
8
|
+
...config,
|
|
9
|
+
...(redactedEnv ? { env: redactedEnv } : {}),
|
|
10
|
+
...(redactedHeaders ? { headers: redactedHeaders } : {}),
|
|
11
|
+
},
|
|
12
|
+
credentials: finalizePortableCredentialSpecs(credentialsByKey),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
redacted: {
|
|
17
|
+
...config,
|
|
18
|
+
...(redactedEnv ? { env: redactedEnv } : {}),
|
|
19
|
+
},
|
|
20
|
+
credentials: finalizePortableCredentialSpecs(credentialsByKey),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function redactStringMap(values, source, credentialsByKey) {
|
|
24
|
+
if (!values) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const redacted = {};
|
|
28
|
+
for (const [key, value] of Object.entries(values)) {
|
|
29
|
+
if (!shouldRedact(key)) {
|
|
30
|
+
redacted[key] = value;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const credentialKey = normalizeCredentialKey(key);
|
|
34
|
+
addCredentialSpec(credentialsByKey, credentialKey, source, key);
|
|
35
|
+
redacted[key] = isCredentialPlaceholder(value)
|
|
36
|
+
? value
|
|
37
|
+
: `\${credentials.${credentialKey}}`;
|
|
38
|
+
}
|
|
39
|
+
return redacted;
|
|
40
|
+
}
|
|
41
|
+
function shouldRedact(key) {
|
|
42
|
+
const tokens = tokenizeCredentialKey(key);
|
|
43
|
+
if (tokens.length === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return (tokens[tokens.length - 1] === 'authorization' ||
|
|
47
|
+
tokens[tokens.length - 1] === 'password' ||
|
|
48
|
+
tokens[tokens.length - 1] === 'secret' ||
|
|
49
|
+
tokens[tokens.length - 1] === 'token' ||
|
|
50
|
+
(tokens[tokens.length - 1] === 'key' &&
|
|
51
|
+
(tokens.includes('api') || (tokens.includes('auth') && tokens.includes('key')))));
|
|
52
|
+
}
|
|
53
|
+
function normalizeCredentialKey(key) {
|
|
54
|
+
return tokenizeCredentialKey(key).join('_');
|
|
55
|
+
}
|
|
56
|
+
function tokenizeCredentialKey(key) {
|
|
57
|
+
return key
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/([A-Z]+)([A-Z][a-z0-9])/g, '$1 $2')
|
|
60
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.split(/[^a-z0-9]+/g)
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
function isCredentialPlaceholder(value) {
|
|
66
|
+
return /^\$\{credentials\.[^}]+\}$/.test(value) || /^(Bearer|Token)\s+\$\{credentials\.[^}]+\}$/i.test(value);
|
|
67
|
+
}
|
|
68
|
+
function addCredentialSpec(credentialsByKey, credentialKey, source, originalKey) {
|
|
69
|
+
const description = source === 'env' ? `Environment variable ${originalKey}` : `Header ${originalKey}`;
|
|
70
|
+
const existing = credentialsByKey.get(credentialKey);
|
|
71
|
+
if (existing) {
|
|
72
|
+
existing.descriptions.add(description);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
credentialsByKey.set(credentialKey, {
|
|
76
|
+
key: credentialKey,
|
|
77
|
+
required: true,
|
|
78
|
+
descriptions: new Set([description]),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function finalizePortableCredentialSpecs(credentialsByKey) {
|
|
82
|
+
return Array.from(credentialsByKey.values())
|
|
83
|
+
.sort((left, right) => left.key.localeCompare(right.key))
|
|
84
|
+
.map((entry) => ({
|
|
85
|
+
key: entry.key,
|
|
86
|
+
required: entry.required,
|
|
87
|
+
description: `${Array.from(entry.descriptions).sort().join('; ')} required for MCP access`,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServerConfig, PortableCredentialPlaceholder, PortableCredentialSpec } from '../types.js';
|
|
2
|
+
export interface CredentialResolutionResult<T extends McpServerConfig> {
|
|
3
|
+
resolved: T;
|
|
4
|
+
missing: PortableCredentialSpec[];
|
|
5
|
+
}
|
|
6
|
+
export declare function resolvePortableMcpCredentials<T extends McpServerConfig>(config: T, options?: {
|
|
7
|
+
credentials?: Record<string, string>;
|
|
8
|
+
credentialSpecs?: PortableCredentialSpec[];
|
|
9
|
+
environment?: Record<string, string | undefined>;
|
|
10
|
+
}): CredentialResolutionResult<T>;
|
|
11
|
+
export declare function toPortableCredentialPlaceholder(key: string): PortableCredentialPlaceholder;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function resolvePortableMcpCredentials(config, options = {}) {
|
|
2
|
+
const specs = new Map((options.credentialSpecs ?? []).map((spec) => [spec.key, spec]));
|
|
3
|
+
const missing = new Map();
|
|
4
|
+
const env = resolveStringMap(config.env, specs, missing, options.credentials, options.environment);
|
|
5
|
+
if (config.kind === 'remote') {
|
|
6
|
+
const headers = resolveStringMap(config.headers, specs, missing, options.credentials, options.environment);
|
|
7
|
+
return {
|
|
8
|
+
resolved: {
|
|
9
|
+
...config,
|
|
10
|
+
...(env ? { env } : {}),
|
|
11
|
+
...(headers ? { headers } : {}),
|
|
12
|
+
},
|
|
13
|
+
missing: Array.from(missing.values()),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
resolved: {
|
|
18
|
+
...config,
|
|
19
|
+
...(env ? { env } : {}),
|
|
20
|
+
},
|
|
21
|
+
missing: Array.from(missing.values()),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function resolveStringMap(values, specs, missing, credentials, environment) {
|
|
25
|
+
if (!values) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const resolved = {};
|
|
29
|
+
for (const [key, value] of Object.entries(values)) {
|
|
30
|
+
const placeholder = parseCredentialPlaceholder(value);
|
|
31
|
+
if (!placeholder) {
|
|
32
|
+
resolved[key] = value;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const resolvedValue = credentials?.[placeholder.key] ?? environment?.[placeholder.key];
|
|
36
|
+
if (typeof resolvedValue === 'string' && resolvedValue.length > 0) {
|
|
37
|
+
resolved[key] = placeholder.prefix ? `${placeholder.prefix} ${resolvedValue}` : resolvedValue;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const spec = specs.get(placeholder.key) ?? {
|
|
41
|
+
key: placeholder.key,
|
|
42
|
+
required: true,
|
|
43
|
+
description: `Credential ${placeholder.key} is required`,
|
|
44
|
+
};
|
|
45
|
+
if (spec.required) {
|
|
46
|
+
missing.set(spec.key, spec);
|
|
47
|
+
}
|
|
48
|
+
resolved[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
function parseCredentialPlaceholder(value) {
|
|
53
|
+
const bareMatch = value.match(/^\$\{credentials\.([^}]+)\}$/);
|
|
54
|
+
if (bareMatch) {
|
|
55
|
+
return { key: bareMatch[1] };
|
|
56
|
+
}
|
|
57
|
+
const prefixedMatch = value.match(/^(Bearer|Token)\s+\$\{credentials\.([^}]+)\}$/i);
|
|
58
|
+
if (prefixedMatch) {
|
|
59
|
+
const prefix = prefixedMatch[1].toLowerCase() === 'bearer' ? 'Bearer' : 'Token';
|
|
60
|
+
return {
|
|
61
|
+
key: prefixedMatch[2],
|
|
62
|
+
prefix,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export function toPortableCredentialPlaceholder(key) {
|
|
68
|
+
return `\${credentials.${key}}`;
|
|
69
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentName } from '../types.js';
|
|
2
|
-
import type { AgentMcpEntry } from './agent-config-service.js';
|
|
2
|
+
import type { AgentMcpEntry, PortableRemoteMcpMetadata } from './agent-config-service.js';
|
|
3
3
|
export interface McpPreflightCheck {
|
|
4
4
|
label: string;
|
|
5
5
|
status: 'ok' | 'warn' | 'error';
|
|
@@ -14,7 +14,8 @@ export interface McpPreflightService {
|
|
|
14
14
|
cwd: string;
|
|
15
15
|
agent: AgentName;
|
|
16
16
|
key: string;
|
|
17
|
-
entry
|
|
17
|
+
entry?: AgentMcpEntry;
|
|
18
|
+
remoteEntry?: PortableRemoteMcpMetadata;
|
|
18
19
|
}): Promise<McpPreflightResult>;
|
|
19
20
|
}
|
|
20
21
|
interface McpPreflightDependencies {
|