mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
feat(mcp): make sync opt-in and address review
Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes: - codex TOML read/validated with smol-toml: CRLF, inline tables and command-less tables no longer yield a duplicate [mcp_servers.x]; the new text is re-parsed before writing - null-prototype tables and own-key checks; unsafe names ignored at every level - servers switched off in their own CLI (codex/opencode/antigravity) are not copied - only CLIs that are installed or already have a config file take part - files receiving env/headers are left 0600; symlinked configs are written through - one apply at a time (409), unique tmp files cleaned on failure, failed status - routes set real HTTP status codes; api-reference section; format type single-sourced Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7616de13de
commit
4398dbfad0
@@ -15,6 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -386,7 +387,15 @@ const capabilitiesSchema = z
|
||||
.max(100)
|
||||
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||
.refine((v) => !v.split('/').includes('..'), 'must not contain ..'),
|
||||
format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json', 'antigravity-json']),
|
||||
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||
format: z.enum([
|
||||
'claude-json',
|
||||
'gemini-json',
|
||||
'codex-toml',
|
||||
'opencode-json',
|
||||
'antigravity-json',
|
||||
] as const satisfies readonly McpConfigFormat[]),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -90,6 +90,9 @@ export interface CliVariant {
|
||||
args: ArgSpec[];
|
||||
}
|
||||
|
||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
|
||||
export interface CliLaunch {
|
||||
params: Record<string, ParamSpec>;
|
||||
/**
|
||||
@@ -517,10 +520,7 @@ export interface CliCapabilities {
|
||||
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||
* skipped by sync rather than guessed at.
|
||||
*/
|
||||
mcpConfig?: {
|
||||
path: string;
|
||||
format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
};
|
||||
mcpConfig?: { path: string; format: McpConfigFormat };
|
||||
/**
|
||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||
|
||||
+275
-233
@@ -2,18 +2,29 @@
|
||||
* @fileoverview MCP server sync between the enabled agent CLIs.
|
||||
*
|
||||
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
|
||||
* names the file and the dialect). This module reads every enabled CLI's list into one neutral
|
||||
* shape, and adds any server a CLI is missing from the others.
|
||||
* names the file and the dialect). This module reads every participating CLI's list into one
|
||||
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
|
||||
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
|
||||
* own user config.
|
||||
*
|
||||
* Deliberately conservative:
|
||||
* - ADDITIVE only. A server already present under a name is never rewritten and nothing is
|
||||
* ever removed, so a sync cannot lose a hand-tuned entry. Same name with a different
|
||||
* definition is reported as a conflict and left alone.
|
||||
* - A file that does not parse (e.g. opencode JSONC with comments) is never written.
|
||||
* - Only the MCP table is touched; every other key in the file is preserved. JSON files are
|
||||
* re-read immediately before the write, and written via tmp+rename with the old file kept
|
||||
* as `<file>.codeman-bak`.
|
||||
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
|
||||
* does not understand) is never rewritten and nothing is ever removed. Same name with a
|
||||
* different definition is reported as a conflict and left alone.
|
||||
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
|
||||
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
|
||||
* switch it on in every other CLI.
|
||||
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
|
||||
* duplicate table) is never written, and a write is only made after the NEW text has been
|
||||
* parsed again and every added server comes back as intended.
|
||||
* - Only the MCP table is touched; every other key in the file is preserved. Files are
|
||||
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
|
||||
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
|
||||
* (overwritten by each sync).
|
||||
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
|
||||
* readable by its owner only.
|
||||
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
|
||||
* - Only one apply runs at a time.
|
||||
*
|
||||
* The result types never carry env values or headers: those commonly hold secrets and the
|
||||
* result is returned over HTTP.
|
||||
@@ -22,10 +33,13 @@
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
|
||||
export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
export type McpFormat = McpConfigFormat;
|
||||
|
||||
export interface McpServer {
|
||||
transport: 'stdio' | 'http' | 'sse';
|
||||
@@ -35,6 +49,8 @@ export interface McpServer {
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** Switched off in the CLI that defines it. Never propagated. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type McpServerMap = Record<string, McpServer>;
|
||||
@@ -44,13 +60,20 @@ export interface McpSyncTarget {
|
||||
label: string;
|
||||
path: string;
|
||||
format: McpFormat;
|
||||
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export interface McpSyncTargetResult {
|
||||
id: string;
|
||||
label: string;
|
||||
file: string;
|
||||
status: 'ok' | 'unreadable';
|
||||
/**
|
||||
* `absent`: not installed and no config file, so neither read nor created.
|
||||
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
|
||||
* `failed`: a read or write error (the file may be unchanged).
|
||||
*/
|
||||
status: 'ok' | 'absent' | 'unreadable' | 'failed';
|
||||
error?: string;
|
||||
servers: string[];
|
||||
/** Servers added (apply) or that would be added (plan). */
|
||||
@@ -62,12 +85,22 @@ export interface McpSyncTargetResult {
|
||||
export interface McpSyncResult {
|
||||
applied: boolean;
|
||||
targets: McpSyncTargetResult[];
|
||||
/** Names defined differently by different CLIs; left untouched. */
|
||||
/** Names defined differently by different CLIs; existing definitions are left untouched. */
|
||||
conflicts: string[];
|
||||
/** Names left out because the only definitions are switched off in their own CLI. */
|
||||
disabled: string[];
|
||||
/** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
|
||||
unsupported: string[];
|
||||
}
|
||||
|
||||
/** A second apply was requested while one was running. */
|
||||
export class McpSyncBusyError extends Error {
|
||||
constructor() {
|
||||
super('An MCP sync is already running');
|
||||
this.name = 'McpSyncBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -77,10 +110,20 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
|
||||
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
|
||||
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
|
||||
function dict<T>(): Record<string, T> {
|
||||
return Object.create(null) as Record<string, T>;
|
||||
}
|
||||
|
||||
/** Own, safe keys of an untrusted table. */
|
||||
function safeKeys(table: Record<string, unknown>): string[] {
|
||||
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
|
||||
}
|
||||
|
||||
function strMap(v: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(v)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, val] of Object.entries(v)) if (typeof val === 'string') out[k] = val;
|
||||
const out = dict<string>();
|
||||
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
@@ -97,15 +140,26 @@ function clean(s: McpServer): McpServer {
|
||||
if (s.cwd) out.cwd = s.cwd;
|
||||
if (s.url) out.url = s.url;
|
||||
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
|
||||
if (s.disabled) out.disabled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
|
||||
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
|
||||
function fingerprint(s: McpServer): string {
|
||||
const t = s.transport === 'stdio' ? 'stdio' : 'url';
|
||||
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
|
||||
}
|
||||
|
||||
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
|
||||
function fullIdentity(s: McpServer): string {
|
||||
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
|
||||
}
|
||||
|
||||
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON dialects
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -163,10 +217,17 @@ function toGemini(s: McpServer): Record<string, unknown> {
|
||||
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
|
||||
function fromAntigravity(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.disabled === true;
|
||||
if (typeof raw.serverUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers) });
|
||||
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: raw.command,
|
||||
args: strArr(raw.args),
|
||||
env: strMap(raw.env),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -181,13 +242,20 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||
|
||||
function fromOpencode(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.enabled === false;
|
||||
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers) });
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||
}
|
||||
if (raw.type === 'local') {
|
||||
const cmd = strArr(raw.command);
|
||||
if (!cmd?.length) return null;
|
||||
return clean({ transport: 'stdio', command: cmd[0], args: cmd.slice(1), env: strMap(raw.environment) });
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: cmd[0],
|
||||
args: cmd.slice(1),
|
||||
env: strMap(raw.environment),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -229,168 +297,13 @@ const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||
// Codex TOML (the `[mcp_servers.*]` tables only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TomlValue = string | string[] | Record<string, string> | boolean | number | null;
|
||||
|
||||
/** Parse one TOML value starting at `i`; returns the value and the index after it. */
|
||||
function parseTomlValue(src: string, start: number): [TomlValue, number] {
|
||||
let i = start;
|
||||
const ws = () => {
|
||||
while (i < src.length && /[ \t\r\n]/.test(src[i])) i++;
|
||||
};
|
||||
ws();
|
||||
const c = src[i];
|
||||
if (c === '"') {
|
||||
if (src.startsWith('"""', i)) {
|
||||
const end = src.indexOf('"""', i + 3);
|
||||
return [src.slice(i + 3, end < 0 ? src.length : end).replace(/^\n/, ''), end < 0 ? src.length : end + 3];
|
||||
}
|
||||
let out = '';
|
||||
i++;
|
||||
while (i < src.length && src[i] !== '"') {
|
||||
if (src[i] === '\\') {
|
||||
const n = src[i + 1];
|
||||
const map: Record<string, string> = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' };
|
||||
if (n === 'u') {
|
||||
out += String.fromCodePoint(parseInt(src.slice(i + 2, i + 6), 16));
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
out += map[n] ?? n;
|
||||
i += 2;
|
||||
} else out += src[i++];
|
||||
}
|
||||
return [out, i + 1];
|
||||
}
|
||||
if (c === "'") {
|
||||
const end = src.indexOf("'", i + 1);
|
||||
return [src.slice(i + 1, end < 0 ? src.length : end), end < 0 ? src.length : end + 1];
|
||||
}
|
||||
if (c === '[') {
|
||||
const arr: string[] = [];
|
||||
i++;
|
||||
for (;;) {
|
||||
ws();
|
||||
if (src[i] === '#') {
|
||||
while (i < src.length && src[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (src[i] === ']' || i >= src.length) return [arr, i + 1];
|
||||
if (src[i] === ',') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const [v, next] = parseTomlValue(src, i);
|
||||
if (typeof v === 'string') arr.push(v);
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
if (c === '{') {
|
||||
const obj: Record<string, string> = {};
|
||||
i++;
|
||||
for (;;) {
|
||||
ws();
|
||||
if (src[i] === '}' || i >= src.length) return [obj, i + 1];
|
||||
if (src[i] === ',') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const [k, afterKey] = parseTomlKey(src, i);
|
||||
i = afterKey;
|
||||
ws();
|
||||
if (src[i] === '=') i++;
|
||||
const [v, next] = parseTomlValue(src, i);
|
||||
if (typeof v === 'string') obj[k] = v;
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
const m = /^[^\s,\]}#]+/.exec(src.slice(i));
|
||||
const tok = m ? m[0] : '';
|
||||
const after = i + tok.length;
|
||||
if (tok === 'true') return [true, after];
|
||||
if (tok === 'false') return [false, after];
|
||||
const num = Number(tok);
|
||||
return [Number.isNaN(num) ? null : num, Math.max(after, i + 1)];
|
||||
}
|
||||
|
||||
function parseTomlKey(src: string, start: number): [string, number] {
|
||||
let i = start;
|
||||
while (src[i] === ' ' || src[i] === '\t') i++;
|
||||
if (src[i] === '"' || src[i] === "'") {
|
||||
const [v, next] = parseTomlValue(src, i);
|
||||
return [String(v), next];
|
||||
}
|
||||
const m = /^[A-Za-z0-9_-]+/.exec(src.slice(i));
|
||||
const key = m ? m[0] : '';
|
||||
return [key, i + Math.max(key.length, 1)];
|
||||
}
|
||||
|
||||
/** Split a table header like `mcp_servers."my.srv".env` into dotted key parts. */
|
||||
function parseTomlHeader(line: string): string[] | null {
|
||||
const m = /^\[([^[\]].*)\]\s*(#.*)?$/.exec(line.trim());
|
||||
if (!m) return null;
|
||||
const body = m[1];
|
||||
const parts: string[] = [];
|
||||
let i = 0;
|
||||
while (i < body.length) {
|
||||
while (body[i] === ' ') i++;
|
||||
const [k, next] = parseTomlKey(body, i);
|
||||
if (!k) return null;
|
||||
parts.push(k);
|
||||
i = next;
|
||||
while (body[i] === ' ') i++;
|
||||
if (body[i] === '.') i++;
|
||||
else if (i < body.length) return null;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Returns each `mcp_servers.<name>` table as `{ ...keys, env?: {...}, http_headers?: {...} }`. */
|
||||
function parseCodexTables(text: string): Record<string, Record<string, TomlValue>> {
|
||||
const out: Record<string, Record<string, TomlValue>> = {};
|
||||
let current: Record<string, TomlValue> | null = null;
|
||||
let sub: string | null = null;
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let n = 0; n < lines.length; n++) {
|
||||
const line = lines[n];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (trimmed.startsWith('[')) {
|
||||
current = null;
|
||||
sub = null;
|
||||
if (trimmed.startsWith('[[')) continue;
|
||||
const parts = parseTomlHeader(trimmed);
|
||||
if (parts && parts[0] === 'mcp_servers' && (parts.length === 2 || parts.length === 3)) {
|
||||
if (UNSAFE_NAMES.has(parts[1])) continue;
|
||||
current = out[parts[1]] ??= {};
|
||||
sub = parts.length === 3 ? parts[2] : null;
|
||||
if (sub && !isRecord(current[sub])) current[sub] = {};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
// key = value; a value may span lines (arrays), so feed the parser the remainder of the file.
|
||||
const eq = line.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const offset = lines.slice(0, n).reduce((a, l) => a + l.length + 1, 0);
|
||||
const [key, afterKey] = parseTomlKey(text, offset + (line.length - line.trimStart().length));
|
||||
const valueStart = text.indexOf('=', afterKey) + 1;
|
||||
const [value, end] = parseTomlValue(text, valueStart);
|
||||
// Skip the lines the value consumed.
|
||||
const consumed = text.slice(valueStart, end).split('\n').length - 1;
|
||||
n += consumed;
|
||||
if (sub) (current[sub] as Record<string, string>)[key] = typeof value === 'string' ? value : '';
|
||||
else current[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCodex(t: Record<string, TomlValue>): McpServer | null {
|
||||
function fromCodex(t: Record<string, unknown>): McpServer | null {
|
||||
const disabled = t.enabled === false;
|
||||
if (typeof t.url === 'string') {
|
||||
const headers = strMap(t.http_headers);
|
||||
return clean({ transport: 'http', url: t.url, headers });
|
||||
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
|
||||
}
|
||||
if (typeof t.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env) });
|
||||
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -422,30 +335,54 @@ function toCodexToml(name: string, s: McpServer): string {
|
||||
// Dialect entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse a config file's text (null = file absent) into servers. Throws if it cannot be read safely. */
|
||||
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
|
||||
const out: McpServerMap = {};
|
||||
if (text === null || !text.trim()) return out;
|
||||
export interface ParsedConfig {
|
||||
/** Servers this module understands. */
|
||||
servers: McpServerMap;
|
||||
/** Every name defined under the MCP table, in any shape: these are never appended over. */
|
||||
names: Set<string>;
|
||||
}
|
||||
|
||||
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
|
||||
if (text === null || !text.trim()) return dict<unknown>();
|
||||
if (format === 'codex-toml') {
|
||||
for (const [name, table] of Object.entries(parseCodexTables(text))) {
|
||||
if (UNSAFE_NAMES.has(name)) continue;
|
||||
const s = fromCodex(table);
|
||||
if (s) out[name] = s;
|
||||
}
|
||||
return out;
|
||||
const doc = parseToml(text);
|
||||
const table = doc.mcp_servers;
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new Error('"mcp_servers" is not a table');
|
||||
return table;
|
||||
}
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: unknown = JSON.parse(text);
|
||||
if (!isRecord(doc)) throw new Error('top level is not a JSON object');
|
||||
const table = doc[dialect.key];
|
||||
if (table === undefined) return out;
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`);
|
||||
for (const [name, raw] of Object.entries(table)) {
|
||||
if (UNSAFE_NAMES.has(name)) continue;
|
||||
const s = dialect.from(raw);
|
||||
if (s) out[name] = s;
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
|
||||
const table = mcpTable(format, text);
|
||||
const servers = dict<McpServer>();
|
||||
const names = new Set<string>();
|
||||
for (const name of safeKeys(table)) {
|
||||
names.add(name);
|
||||
const raw = table[name];
|
||||
const s =
|
||||
format === 'codex-toml'
|
||||
? isRecord(raw)
|
||||
? fromCodex(raw)
|
||||
: null
|
||||
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
|
||||
if (s) servers[name] = s;
|
||||
}
|
||||
return out;
|
||||
return { servers, names };
|
||||
}
|
||||
|
||||
/** The servers of a config file's text. */
|
||||
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
|
||||
return parseConfig(format, text).servers;
|
||||
}
|
||||
|
||||
/** Whether this dialect can express the server. */
|
||||
@@ -454,26 +391,58 @@ function canExpress(format: McpFormat, s: McpServer): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Add servers to a config file's text and return the new text. Existing names are never touched. */
|
||||
/**
|
||||
* Add servers to a config file's text and return the new text. A name already defined under the
|
||||
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
|
||||
* come back as intended, otherwise this throws and nothing should be written.
|
||||
*/
|
||||
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
|
||||
const names = Object.keys(add);
|
||||
const before = parseConfig(format, text);
|
||||
const todo = dict<McpServer>();
|
||||
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
|
||||
const names = Object.keys(todo);
|
||||
if (names.length === 0) return text ?? '';
|
||||
|
||||
let out: string;
|
||||
if (format === 'codex-toml') {
|
||||
const base = text ?? '';
|
||||
const sep = base.length === 0 ? '' : base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n';
|
||||
return base + sep + names.map((n) => toCodexToml(n, add[n])).join('\n');
|
||||
const eol = base.includes('\r\n') ? '\r\n' : '\n';
|
||||
const sep =
|
||||
base.length === 0
|
||||
? ''
|
||||
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
|
||||
? ''
|
||||
: base.endsWith('\n')
|
||||
? eol
|
||||
: eol + eol;
|
||||
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
|
||||
out = base + sep + blocks.join(eol);
|
||||
} else {
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: Record<string, unknown> =
|
||||
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
|
||||
const existing = doc[dialect.key];
|
||||
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||
for (const n of names) {
|
||||
const entry = dialect.to(todo[n]);
|
||||
if (entry) table[n] = entry;
|
||||
}
|
||||
doc[dialect.key] = table;
|
||||
out = JSON.stringify(doc, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// Re-read what we are about to write.
|
||||
const after = parseConfig(format, out);
|
||||
for (const n of before.names) {
|
||||
if (!after.names.has(n)) throw new Error(`refusing to write: "${n}" would be lost`);
|
||||
}
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: Record<string, unknown> =
|
||||
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
|
||||
const existing = doc[dialect.key];
|
||||
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||
for (const n of names) {
|
||||
if (n in table) continue;
|
||||
const entry = dialect.to(add[n]);
|
||||
if (entry) table[n] = entry;
|
||||
const got = after.servers[n];
|
||||
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
|
||||
throw new Error(`refusing to write: "${n}" does not read back as written`);
|
||||
}
|
||||
}
|
||||
doc[dialect.key] = table;
|
||||
return JSON.stringify(doc, null, 2) + '\n';
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -489,19 +458,56 @@ async function readText(file: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtomic(file: string, text: string): Promise<void> {
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
|
||||
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
|
||||
* readable by its owner only.
|
||||
*/
|
||||
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
|
||||
let target = file;
|
||||
try {
|
||||
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
|
||||
try {
|
||||
await fs.lstat(file);
|
||||
throw new Error('config path is a dangling symlink');
|
||||
} catch (inner) {
|
||||
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = 0o600;
|
||||
try {
|
||||
mode = (await fs.stat(file)).mode & 0o777;
|
||||
await fs.copyFile(file, `${file}.codeman-bak`);
|
||||
await fs.chmod(`${file}.codeman-bak`, 0o600);
|
||||
mode = (await fs.stat(target)).mode & 0o777;
|
||||
await fs.copyFile(target, `${target}.codeman-bak`);
|
||||
await fs.chmod(`${target}.codeman-bak`, 0o600);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
await fs.mkdir(dirname(file), { recursive: true });
|
||||
const tmp = `${file}.codeman-tmp-${process.pid}`;
|
||||
await fs.writeFile(tmp, text, { mode });
|
||||
await fs.rename(tmp, file);
|
||||
if (secret) mode &= ~0o077;
|
||||
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
await fs.writeFile(tmp, text, { mode });
|
||||
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
|
||||
await fs.chmod(tmp, mode);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmp).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpSyncOptions {
|
||||
@@ -510,15 +516,30 @@ export interface McpSyncOptions {
|
||||
home?: string;
|
||||
}
|
||||
|
||||
let applying = false;
|
||||
|
||||
/**
|
||||
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
|
||||
* order: when two CLIs define a name differently, the first one's definition is the one copied).
|
||||
* Throws `McpSyncBusyError` if another apply is running.
|
||||
*/
|
||||
export async function syncMcpServers(
|
||||
targets: McpSyncTarget[],
|
||||
opts: McpSyncOptions,
|
||||
unsupported: string[] = []
|
||||
): Promise<McpSyncResult> {
|
||||
if (opts.apply) {
|
||||
if (applying) throw new McpSyncBusyError();
|
||||
applying = true;
|
||||
}
|
||||
try {
|
||||
return await run(targets, opts, unsupported);
|
||||
} finally {
|
||||
if (opts.apply) applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
|
||||
const home = opts.home ?? homedir();
|
||||
const seen = new Set<string>();
|
||||
const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true)));
|
||||
@@ -534,36 +555,49 @@ export async function syncMcpServers(
|
||||
added: [],
|
||||
skipped: [],
|
||||
};
|
||||
return { t, file, res, servers: {} as McpServerMap };
|
||||
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
|
||||
});
|
||||
|
||||
for (const s of state) {
|
||||
try {
|
||||
s.servers = parseServers(s.t.format, await readText(s.file));
|
||||
s.res.servers = Object.keys(s.servers);
|
||||
if (!s.t.installed && !(await exists(s.file))) {
|
||||
s.res.status = 'absent';
|
||||
continue;
|
||||
}
|
||||
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||
s.servers = parsed.servers;
|
||||
s.names = parsed.names;
|
||||
s.res.servers = [...parsed.names];
|
||||
} catch (err) {
|
||||
s.res.status = 'unreadable';
|
||||
s.res.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Union, first definition wins; a later, different definition of the same name is a conflict.
|
||||
const union: McpServerMap = {};
|
||||
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
|
||||
const union = dict<McpServer>();
|
||||
const conflicts = new Set<string>();
|
||||
const switchedOff = new Set<string>();
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
for (const [name, def] of Object.entries(s.servers)) {
|
||||
for (const name of Object.keys(s.servers)) {
|
||||
const def = s.servers[name];
|
||||
if (def.disabled) {
|
||||
switchedOff.add(name);
|
||||
continue;
|
||||
}
|
||||
if (!(name in union)) union[name] = def;
|
||||
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
|
||||
}
|
||||
}
|
||||
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
|
||||
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
const add: McpServerMap = {};
|
||||
for (const [name, def] of Object.entries(union)) {
|
||||
if (name in s.servers) continue;
|
||||
if (canExpress(s.t.format, def)) add[name] = def;
|
||||
const add = dict<McpServer>();
|
||||
for (const name of Object.keys(union)) {
|
||||
if (s.names.has(name)) continue;
|
||||
if (canExpress(s.t.format, union[name])) add[name] = union[name];
|
||||
else s.res.skipped.push(name);
|
||||
}
|
||||
s.res.added = Object.keys(add);
|
||||
@@ -571,21 +605,29 @@ export async function syncMcpServers(
|
||||
try {
|
||||
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
|
||||
const fresh = await readText(s.file);
|
||||
const stillMissing: McpServerMap = {};
|
||||
const current = parseServers(s.t.format, fresh);
|
||||
for (const [n, d] of Object.entries(add)) if (!(n in current)) stillMissing[n] = d;
|
||||
if (Object.keys(stillMissing).length === 0) {
|
||||
const out = addServers(s.t.format, fresh, add);
|
||||
const current = parseConfig(s.t.format, fresh);
|
||||
const written = Object.keys(add).filter((n) => !current.names.has(n));
|
||||
if (written.length === 0) {
|
||||
s.res.added = [];
|
||||
continue;
|
||||
}
|
||||
await writeAtomic(s.file, addServers(s.t.format, fresh, stillMissing));
|
||||
s.res.added = Object.keys(stillMissing);
|
||||
const subset = dict<McpServer>();
|
||||
for (const n of written) subset[n] = add[n];
|
||||
await writeAtomic(s.file, out, carriesSecrets(subset));
|
||||
s.res.added = written;
|
||||
} catch (err) {
|
||||
s.res.status = 'unreadable';
|
||||
s.res.status = 'failed';
|
||||
s.res.error = err instanceof Error ? err.message : String(err);
|
||||
s.res.added = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), unsupported };
|
||||
return {
|
||||
applied: opts.apply,
|
||||
targets: state.map((s) => s.res),
|
||||
conflicts: [...conflicts].sort(),
|
||||
disabled,
|
||||
unsupported,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2475,13 +2475,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group" id="mcpSyncGroup">
|
||||
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="mcp server sync claude codex gemini opencode">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable MCP server sync</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync MCP servers across CLIs</span>
|
||||
<span class="set-row-desc">Copies each enabled CLI's MCP servers into the others' config files. Only adds missing servers; never edits or removes one. The previous file is kept as <code>.codeman-bak</code>.</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync).</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
|
||||
|
||||
@@ -416,6 +416,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
|
||||
document.getElementById('appSettingsMcpSync').checked = settings.mcpSyncEnabled === true;
|
||||
this.applyMcpSyncVisibility();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -1114,13 +1117,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updateCheck = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
|
||||
* shown disabled, because both endpoints would only answer 403. Called on open and from the
|
||||
* checkbox's own onchange (assigning .checked fires no change event).
|
||||
*/
|
||||
applyMcpSyncVisibility() {
|
||||
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
|
||||
const row = document.getElementById('mcpSyncActionRow');
|
||||
if (row) row.style.display = on ? '' : 'none';
|
||||
const out = this.$('mcpSyncResult');
|
||||
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html) => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; }
|
||||
};
|
||||
if (apply && !confirm('Add missing MCP servers to every enabled CLI\'s config file?')) return;
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
|
||||
show('Working…');
|
||||
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
|
||||
let body = null;
|
||||
@@ -1131,19 +1147,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const data = body.data;
|
||||
const rows = data.targets.map((t) => {
|
||||
if (t.status !== 'ok') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'unreadable')})</li>`;
|
||||
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
|
||||
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
|
||||
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
|
||||
const verb = data.applied ? 'added' : 'would add';
|
||||
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
|
||||
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
|
||||
return `<li><b>${escapeHtml(t.label)}</b> (${t.servers.length} servers): ${parts.join('; ')}</li>`;
|
||||
});
|
||||
const conflicts = data.conflicts.length
|
||||
? `<p>Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
||||
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const disabled = data.disabled?.length
|
||||
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const unsupported = data.unsupported?.length
|
||||
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
show(`<ul>${rows.join('')}</ul>${conflicts}${unsupported}`);
|
||||
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
|
||||
},
|
||||
|
||||
_setUpdateResult(html) {
|
||||
@@ -2191,6 +2212,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
/**
|
||||
* @fileoverview MCP server sync (src/mcp-sync.ts).
|
||||
*
|
||||
* GET /api/mcp-sync — dry run: per enabled CLI, which servers it has and which it would gain.
|
||||
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
|
||||
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
|
||||
*
|
||||
* Writes files in the SERVER user's home, so in multi-user mode it is admin only. Responses
|
||||
* carry server names only, never env values or headers.
|
||||
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
|
||||
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
|
||||
* is admin only. A second apply while one is running answers 409. Responses carry server names
|
||||
* only, never env values or headers.
|
||||
*
|
||||
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
||||
* or already has its config file; one that is enabled but absent from the machine is reported
|
||||
* `absent` and never created.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin } from '../route-helpers.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { enabledClis } from '../../config/cli-registry/registry.js';
|
||||
import { syncMcpServers, type McpSyncResult, type McpSyncTarget } from '../../mcp-sync.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
||||
import { McpSyncBusyError, syncMcpServers, type McpSyncResult, type McpSyncTarget } from '../../mcp-sync.js';
|
||||
|
||||
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
|
||||
export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.mcpSyncEnabled === true;
|
||||
}
|
||||
|
||||
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
|
||||
export function mcpSyncTargets(): McpSyncTarget[] {
|
||||
export async function mcpSyncTargets(): Promise<McpSyncTarget[]> {
|
||||
const availability = await probeStockCliAvailability();
|
||||
return enabledClis()
|
||||
.filter((e) => e.capabilities.mcpConfig)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((e) => ({ id: e.id, label: e.label, ...e.capabilities.mcpConfig! }));
|
||||
}
|
||||
|
||||
function gate(req: FastifyRequest): ApiResponse<never> | null {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
...e.capabilities.mcpConfig!,
|
||||
installed: isCliEntryInstalled(e, availability),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Enabled agent CLIs with no known MCP config file (sync cannot touch them). */
|
||||
@@ -37,16 +49,34 @@ export function mcpUnsupportedLabels(): string[] {
|
||||
.map((e) => e.label);
|
||||
}
|
||||
|
||||
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
if (!(await readMcpSyncEnabled())) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'MCP sync is disabled. Enable it in Settings first.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerMcpSyncRoutes(app: FastifyInstance): void {
|
||||
const run = async (req: FastifyRequest, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
||||
const denied = gate(req);
|
||||
if (denied) return denied;
|
||||
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
||||
const denied = await gate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
try {
|
||||
return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) };
|
||||
return { success: true, data: await syncMcpServers(await mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) };
|
||||
} catch (err) {
|
||||
if (err instanceof McpSyncBusyError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
app.get('/api/mcp-sync', (req) => run(req, false));
|
||||
app.post('/api/mcp-sync', (req) => run(req, true));
|
||||
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
|
||||
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
|
||||
}
|
||||
|
||||
@@ -1327,6 +1327,13 @@ export const SettingsUpdateSchema = z
|
||||
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
|
||||
*/
|
||||
cliManagementEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
|
||||
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
|
||||
* the server user's home (including any env values and headers on the servers), so it is
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
|
||||
Reference in New Issue
Block a user