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.
Files changed (51) hide show
  1. package/README.md +210 -157
  2. package/dist/cli.js +40 -0
  3. package/dist/commands/mcp.js +35 -0
  4. package/dist/commands/profile.js +35 -2
  5. package/dist/mcp/server.js +51 -5
  6. package/dist/services/agent-config-service.d.ts +4 -2
  7. package/dist/services/agent-config-service.js +50 -15
  8. package/dist/services/agent-converter-service.d.ts +21 -0
  9. package/dist/services/agent-converter-service.js +182 -0
  10. package/dist/services/credential-redaction-service.d.ts +13 -0
  11. package/dist/services/credential-redaction-service.js +89 -0
  12. package/dist/services/credential-resolution-service.d.ts +11 -0
  13. package/dist/services/credential-resolution-service.js +69 -0
  14. package/dist/services/mcp-preflight-service.d.ts +3 -2
  15. package/dist/services/mcp-preflight-service.js +159 -5
  16. package/dist/services/plugin-install-service.d.ts +43 -0
  17. package/dist/services/plugin-install-service.js +379 -21
  18. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  19. package/dist/services/portable-mcp-classifier.js +116 -0
  20. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  21. package/dist/services/portable-profile-pack-service.js +264 -0
  22. package/dist/services/profile-export-service.d.ts +15 -3
  23. package/dist/services/profile-export-service.js +10 -57
  24. package/dist/services/profile-import-service.d.ts +9 -1
  25. package/dist/services/profile-import-service.js +265 -10
  26. package/dist/services/profile-service.js +11 -0
  27. package/dist/services/runtime-detector.d.ts +9 -0
  28. package/dist/services/runtime-detector.js +130 -0
  29. package/dist/services/skill-paths.d.ts +2 -0
  30. package/dist/services/skill-paths.js +14 -0
  31. package/dist/services/sync/agent-reader.d.ts +9 -0
  32. package/dist/services/sync/agent-reader.js +177 -35
  33. package/dist/services/sync/claude-writer.js +0 -6
  34. package/dist/services/sync/codex-writer.d.ts +1 -0
  35. package/dist/services/sync/codex-writer.js +21 -8
  36. package/dist/services/sync/gemini-writer.js +5 -7
  37. package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
  38. package/dist/services/sync/plugin-skill-reader.js +142 -1
  39. package/dist/services/sync-service.js +1 -1
  40. package/dist/services/update-check-service.d.ts +33 -0
  41. package/dist/services/update-check-service.js +128 -0
  42. package/dist/types.d.ts +47 -0
  43. package/dist/ui/routes.js +35 -8
  44. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  45. package/dist/web/assets/index-gN83hZYA.js +65 -0
  46. package/dist/web/favicon-light.svg +13 -0
  47. package/dist/web/favicon.svg +13 -0
  48. package/dist/web/index.html +7 -2
  49. package/package.json +5 -1
  50. package/dist/web/assets/index-BCkorugl.css +0 -1
  51. package/dist/web/assets/index-sGnTMhkX.js +0 -16
@@ -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
- name: args.name,
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. Returns the URL to open in a browser. If already running, returns the existing URL.',
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
- return JSON.stringify({ url: uiServerInstance.url, status: 'already_running' });
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
- return JSON.stringify({ url: uiServerInstance.url, status: 'started' });
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: AgentMcpEntry;
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((servers) => {
41
- servers[key] = entry;
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((servers) => {
59
- delete servers[key];
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 servers = { ...current.mcpServers };
134
- mutate(servers);
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(servers);
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(servers) {
182
+ function buildCodexMcpToml(state) {
160
183
  const lines = [];
161
- for (const [name, entry] of Object.entries(servers)) {
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: AgentMcpEntry;
17
+ entry?: AgentMcpEntry;
18
+ remoteEntry?: PortableRemoteMcpMetadata;
18
19
  }): Promise<McpPreflightResult>;
19
20
  }
20
21
  interface McpPreflightDependencies {