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:
Devvyn
2026-10-02 21:15:31 +08:00
co-authored by Claude Sonnet 5.5
parent 7616de13de
commit 4398dbfad0
15 changed files with 772 additions and 306 deletions
+10 -3
View File
@@ -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>
+26 -4
View File
@@ -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,