diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index 53aaba5c9..e0f12e85d 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -201,6 +201,8 @@ Turn team hooks off on this machine with `TEAMAI_HOOKS_DISABLED=1`. ## MCP servers +Muse may save stdio servers without `type`. Pull and removal compare that form without rewriting personal servers or unchanged managed entries when another server changes. + ```yaml # mcp/mcp.yaml servers: diff --git a/docs/guide/zh-CN/sharing.md b/docs/guide/zh-CN/sharing.md index b4dd5ff71..ddc5c0290 100644 --- a/docs/guide/zh-CN/sharing.md +++ b/docs/guide/zh-CN/sharing.md @@ -201,6 +201,8 @@ sharing: ## MCP Server +Muse 保存 stdio server 时可能省略 `type`。pull 和移除操作会兼容这种格式;其他 server 发生变化时,个人 server 和未变更的受管条目保持原样。 + ```yaml # mcp/mcp.yaml servers: diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index f7843a6b8..62bfd4db7 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -186,6 +186,8 @@ reference to VAR (`--from-env`) and VAR is unset in this environment. Ask the user whether to set VAR in their shell or replace the reference with the command in the line; do not choose for them. +Muse may omit `type` when saving a stdio server. Pull and removal compare that form separately from the document they write, so a change to one server leaves personal servers and unchanged managed entries as saved. + ## "Did not write 's MCP servers to " / `withheld:` `pull` prints this, and `teamai mcp list` (`withheld:`) and `teamai doctor` diff --git a/src/__tests__/e2e/muse-support.test.ts b/src/__tests__/e2e/muse-support.test.ts index 030c85a85..50c6f0580 100644 --- a/src/__tests__/e2e/muse-support.test.ts +++ b/src/__tests__/e2e/muse-support.test.ts @@ -121,6 +121,31 @@ describe('Muse Code resource delivery', () => { if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); }); + it('preserves a personal Muse server through a writing pull and uninstall', async () => { + const cwd = fs.mkdtempSync(path.join(sandbox, 'preserve-work-')); + const home = makeHome('home-preserve'); + const file = path.join(home, '.config', 'muse', 'settings.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const init = await runCLI(['init', FAKE_URL, '--scope', 'user', '--role', 'dev', '--agent', 'muse', '--force'], cwd, home); + expect(init.code, init.output).toBe(0); + const inject = await runCLI(['mcp', 'inject'], cwd, home); + expect(inject.code, inject.output).toBe(0); + const settings = JSON.parse(fs.readFileSync(file, 'utf8')); + const personal = { command: 'personal-server', args: ['--local'], env: { B: 'two', A: 'one' } }; + settings.mcpServers.personal = personal; + delete settings.mcpServers.local; + fs.writeFileSync(file, JSON.stringify(settings)); + + const pull = await runCLI(['pull', '--force'], cwd, home); + expect(pull.code, pull.output).toBe(0); + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(after.mcpServers.local.command).toBe('node'); + expect(JSON.stringify(after.mcpServers.personal)).toBe(JSON.stringify(personal)); + const uninstall = await runCLI(['uninstall', '--agent', 'muse', '--force'], cwd, home); + expect(uninstall.code, uninstall.output).toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8')).mcpServers).toEqual({ personal }); + }, 120_000); + it('keeps Muse-normalized MCP entries removable through the real CLI', async () => { const workDir = path.join(sandbox, 'normalized-work'); fs.mkdirSync(workDir, { recursive: true }); diff --git a/src/__tests__/muse.test.ts b/src/__tests__/muse.test.ts index 6a0e41c9f..2b321c8f7 100644 --- a/src/__tests__/muse.test.ts +++ b/src/__tests__/muse.test.ts @@ -141,6 +141,30 @@ describe('Muse Code MCP', () => { await fse.remove(root); }); + it.each(['add', 'update', 'remove'])('preserves foreign Muse-saved entries when a pull must %s team servers', async (operation) => { + await fse.outputJson(settingsPath, { schema_version: 1 }); + await reconcileMcpForConfig(team, config); + const settings = await fse.readJson(settingsPath); + const foreign = { command: 'my-server', args: ['--local'], env: { B: 'two', A: 'one' } }; + settings.mcpServers.personal = foreign; + delete settings.mcpServers.local.type; + const savedLocal = structuredClone(settings.mcpServers.local); + if (operation === 'add') delete settings.mcpServers.remote; + if (operation === 'update') { + const file = path.join(root, 'repo', 'mcp', 'mcp.yaml'); + await fse.writeFile(file, (await fse.readFile(file, 'utf8')).replace('command: node', 'command: new-node')); + } + await fse.writeJson(settingsPath, settings); + + await reconcileMcpForConfig(team, config, { removeAll: operation === 'remove' }); + + const after = await fse.readJson(settingsPath); + expect(JSON.stringify(after.mcpServers.personal)).toBe(JSON.stringify(foreign)); + if (operation === 'add') expect(after.mcpServers.local).toEqual(savedLocal); + if (operation === 'update') expect(after.mcpServers.local.command).toBe('new-node'); + if (operation === 'remove') expect(after.mcpServers).not.toHaveProperty('local'); + }); + it('preserves a member-edited stdio server after Muse omits type', async () => { await fse.outputJson(settingsPath, { schema_version: 1 }); await reconcileMcpForConfig(team, config); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index b2c23b391..2eb988519 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -2638,9 +2638,10 @@ async function applyJson( } const existed = await pathExists(target.file); const previousData = structuredClone(doc.data); - normalizeMuseServers(target, doc.servers); + const comparable = { ...doc, servers: { ...doc.servers } }; + normalizeMuseServers(target, comparable.servers); - const ownedHere = owned.filter((record) => ownsJsonMcpEntry(doc, record.name, [record], allowBare)); + const ownedHere = owned.filter((record) => ownsJsonMcpEntry(comparable, record.name, [record], allowBare)); const ownedHash = new Map(ownedHere.map((r) => [r.name, r.hash])); let dirty = false; // A kept entry holds the value an earlier pull resolved (desiredMcpForTarget). @@ -2649,8 +2650,8 @@ async function applyJson( for (const [name, { entry, hash, resolvedValue }] of desired) { const existing = doc.servers[name]; // An entry with no record is teamai's when it equals a team render of `name` (#993), and is adopted. - const unrecorded = existing !== undefined && !ownsJsonMcpEntry(doc, name, owned, allowBare) && !options.force; - const owner = unrecorded ? await judge(name, existing) : 'teamai'; + const unrecorded = existing !== undefined && !ownsJsonMcpEntry(comparable, name, owned, allowBare) && !options.force; + const owner = unrecorded ? await judge(name, comparable.servers[name]) : 'teamai'; if (owner !== 'teamai') { changes.push(unrecordedServerKept(target, name, owner)); const previous = owned.find((record) => record.name === name); @@ -2667,7 +2668,7 @@ async function applyJson( delete doc.data[name]; dirty = true; } - if (existing !== undefined && (unrecorded ? entryHash(existing) : ownedHash.get(name)) === hash) { + if (existing !== undefined && (unrecorded ? entryHash(comparable.servers[name]) : ownedHash.get(name)) === hash) { if (unrecorded && options.dryRun) log.info(describeAdoptionPreview(target, name)); continue; } @@ -2680,18 +2681,18 @@ async function applyJson( for (const name of ownedNames) { if (desired.has(name)) continue; const previous = ownedHere.find((record) => record.name === name); - if (options.removeAll && previous && doc.servers[name] !== undefined && entryHash(doc.servers[name]) !== previous.hash) { + if (options.removeAll && previous && doc.servers[name] !== undefined && entryHash(comparable.servers[name]) !== previous.hash) { log.warn(`Kept MCP server ${name} in ${describeMcpLocation(target)}: you changed it since teamai wrote it. ` + 'Remove it there when you no longer need it.'); continue; } const kept = keep.get(name); - if (kept && ((ownsJsonMcpEntry(doc, name, owned, allowBare) && doc.servers[name] !== undefined) || isTeamaiBareCopy(doc, name, owned))) { + if (kept && ((ownsJsonMcpEntry(comparable, name, owned, allowBare) && doc.servers[name] !== undefined) || isTeamaiBareCopy(doc, name, owned))) { nextRecords.push(kept); holdsResolvedValue = true; continue; } - if (ownsJsonMcpEntry(doc, name, owned, allowBare) && doc.servers[name] !== undefined) { + if (ownsJsonMcpEntry(comparable, name, owned, allowBare) && doc.servers[name] !== undefined) { delete doc.servers[name]; dirty = true; } @@ -2706,7 +2707,7 @@ async function applyJson( // An unrecorded entry of a server the team deleted is teamai's when it equals a render of that // server from the team history (#993), and goes like any other server teamai no longer delivers. for (const [name, entry] of Object.entries(doc.servers)) { - if (desired.has(name) || ownedNames.has(name) || await judge(name, entry) !== 'teamai') continue; + if (desired.has(name) || ownedNames.has(name) || await judge(name, comparable.servers[name] ?? entry) !== 'teamai') continue; if (options.dryRun) log.info(describeRemovedServerPreview(target, name)); delete doc.servers[name]; dirty = true;