Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/zh-CN/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ sharing:

## MCP Server

Muse 保存 stdio server 时可能省略 `type`。pull 和移除操作会兼容这种格式;其他 server 发生变化时,个人 server 和未变更的受管条目保持原样。

```yaml
# mcp/mcp.yaml
servers:
Expand Down
2 changes: 2 additions & 0 deletions skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>'s MCP servers to <file>" / `withheld:`

`pull` prints this, and `teamai mcp list` (`withheld:`) and `teamai doctor`
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/e2e/muse-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/muse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 10 additions & 9 deletions src/mcp-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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);
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
Loading