mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).
M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.
M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.
M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.
Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
settings to turn MCP sync on first" instead of calling the routes; the 403 message also
says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
the route imported them, so no churn.
Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
/**
|
|
* @fileoverview MCP server sync (src/mcp-sync.ts).
|
|
*
|
|
* 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.
|
|
*
|
|
* 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, headers or file content (a parse failure is reported by position).
|
|
*
|
|
* 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. Its file is located with this process's env (the env the CLIs
|
|
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
|
*/
|
|
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
import {
|
|
ApiErrorCode,
|
|
createErrorResponse,
|
|
getErrorMessage,
|
|
type ApiResponse,
|
|
type McpSyncResult,
|
|
} from '../../types.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 { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
|
import { McpSyncBusyError, syncMcpServers, 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(availability: Record<string, boolean>): McpSyncTarget[] {
|
|
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!,
|
|
installed: isCliEntryInstalled(e, availability),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
|
|
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
|
|
*/
|
|
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
|
|
return enabledClis()
|
|
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
|
|
.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. Turn on "Enable MCP server sync" in Settings and save first.'
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function registerMcpSyncRoutes(app: FastifyInstance): void {
|
|
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 {
|
|
const availability = await probeStockCliAvailability();
|
|
const targets = mcpSyncTargets(availability);
|
|
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
|
|
return { success: true, data };
|
|
} 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, reply) => run(req, reply, false));
|
|
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
|
|
}
|