library: Zed takes MCP servers, in its settings.json context_servers (lc on Discord)

lc on Discord ("agent未识别到zed"): the MCP server editor's agent chips
had no Zed. magpie already edits Zed's settings.json for its model, and
Zed reads its MCP servers from context_servers in that same file, so Zed
is now an MCP target like the other agents.

What Zed takes, from its own source (zed-industries/zed 2c99f547):
- crates/settings_content/src/project.rs ContextServerSettingsContent is
  untagged: a command as command/args/env (enabled, remote, timeout
  beside it), a url as url/headers (enabled, timeout, oauth), or an
  extension's entry as settings. docs/src/ai/mcp.md writes the same.
- crates/context_server/src/transport/http.rs only POSTs (and DELETEs):
  streamable HTTP, no SSE. So an SSE server is refused for Zed and its
  chip is greyed with the existing reason (AgentView.NoSSE).
- stdio_transport.rs hands command.envs over as written and http.rs sends
  headers as written: no ${NAME}. Zed is refNone, so a server naming a
  variable is refused and its chip greyed (AgentView.NoEnvRefs).

Changes:
- targets.go: "zed" gets mcpFile{agent.zed's settings.json, fmtZed}; the
  path is agent.zed's for every OS (~/.config/zed, $XDG_CONFIG_HOME/zed,
  %APPDATA%\Zed, $MAGPIE_ZED_CONFIG_DIR).
- mcp.go: fmtZed under "context_servers", encoded as Cursor/Desktop are;
  magpie owns url/headers/command/args/env only, so enabled, remote,
  timeout and oauth the user sets on magpie's entry stay. The file is
  JSONC and edited in place (edit.SetJSON/DelJSON), so comments, other
  settings and the user's own servers are kept.
- mcp.go, sync.go: an entry Zed's extensions configure (settings, no
  command or url) is never written over: a library server by that name
  is a Problem for Zed (errZedExtension), asking for another name.
- envref.go: a comment citing why Zed has no syntax.
- docs/subsystems/library.md: Zed's file, format, no SSE, no ${NAME},
  the extension guard, and the new tests.

Tests:
- internal/library/zed_mcp_test.go TestZedMCP, on a Zed-style JSONC
  settings.json with comments, trailing commas, a user stdio server
  (enabled:false, timeout), a user remote server and an extension's
  entry, at the default path and under MAGPIE_ZED_CONFIG_DIR: found
  servers, the page's chips, written entries, SSE/${NAME}/extension
  refusals, user keys kept through an edit, uncheck and remove leaving
  only the user's, comments kept, a Sync rewriting nothing.
- internal/gui/tests/library-zed-mcp.test.cjs: Zed's chip on every row
  and in the editor, a click gives it the server, greyed with the reason
  for SSE and ${NAME}; Chromium and WebKit, en/zh/zh-TW/ja/de, 1100 and
  560px, no sideways scroll. No new strings.

Broken by hand, each went red and was restored: no zed target, no
extension guard, SSE allowed, enabled owned by magpie, wrong key (Go);
Zed without noSSE (GUI).

Run (temp HOME, Go caches pinned): gofmt -l internal/ (clean); go vet
-tags nogui ./... on darwin, GOOS=linux and GOOS=windows; go vet
./internal/library ./internal/agent; go build ./..., GOOS=linux go build
-tags nogui ./..., GOOS=windows go build ./...; go test -tags nogui ./...
(all ok); node --test library-zed-mcp.test.cjs (22/22, both engines).

Not verified: a real Zed reading the written file (the live
~/.config/zed was never touched); Linux and Windows beyond vet/build;
Zed found in WSL (not in ownFolder, so no MCP target there); a
project's .zed/settings.json (not added).
This commit is contained in:
yetone committed 2026-10-10 00:37:32 +08:00
1 parent 189586612e
commit 2c1b593675
7 files changed
+422 -8

No files matched your search

+5 -4
View File
@@ -35,10 +35,11 @@ agents RTK, a CLI that cuts shell output down before the model reads it.
- magpie takes away only what `Applied` or its mark says it wrote. A folder of the user's with a skill's name is never touched. The user's own fields in an MCP entry are kept.
- An edit never silently loses. When an agent's copy of a skill and the library both changed, the newest edit wins, and the losing one is kept with the backups.
- A failure for one agent is a `Problem` in the result. It doesn't stop the others.
- A server an agent can't reach is not written there, and `mcpFile.supports` says why. For example, Claude Desktop takes no remote server, and Codex, Goose, dsh, Grok and Command Code take no SSE.
- A server's headers (remote) or environment (command) can name a variable as `${NAME}` (#1250). `library.json` keeps the placeholder, and `refsOf` gives each MCP format the agent's own syntax, with the agent's source cited beside it: `${NAME}` for most, `{env:NAME}` for OpenCode and MiMo, `${env:NAME}` for Cursor, and Codex's own keys (`bearer_token_env_var`, `env_http_headers`, `env_vars`). Goose gets `${NAME}` plus `env_keys`. An agent with no syntax (Claude Desktop, Antigravity, Kimi, Cline, ZCode, dsh, Devin, Alma, Pi with pi-mcp-extension) is never written the value: `refsProblem` makes `supports` refuse the server, a magpie entry already there is taken out, and `AgentView.NoEnvRefs` greys its chip with the reason. Codex and Goose's extra keys are magpie's only while the server names a variable there (`refKeys`), so the user's own `env_vars` or `env_keys` are kept. `CheckServers` expands the variable from magpie's own environment, and one that isn't set is `novar`, naming it.
- A server an agent can't reach is not written there, and `mcpFile.supports` says why. For example, Claude Desktop takes no remote server, and Codex, Goose, dsh, Grok, Command Code and Zed take no SSE.
- A server's headers (remote) or environment (command) can name a variable as `${NAME}` (#1250). `library.json` keeps the placeholder, and `refsOf` gives each MCP format the agent's own syntax, with the agent's source cited beside it: `${NAME}` for most, `{env:NAME}` for OpenCode and MiMo, `${env:NAME}` for Cursor, and Codex's own keys (`bearer_token_env_var`, `env_http_headers`, `env_vars`). Goose gets `${NAME}` plus `env_keys`. An agent with no syntax (Claude Desktop, Antigravity, Kimi, Cline, ZCode, Zed, dsh, Devin, Alma, Pi with pi-mcp-extension) is never written the value: `refsProblem` makes `supports` refuse the server, a magpie entry already there is taken out, and `AgentView.NoEnvRefs` greys its chip with the reason. Codex and Goose's extra keys are magpie's only while the server names a variable there (`refKeys`), so the user's own `env_vars` or `env_keys` are kept. `CheckServers` expands the variable from magpie's own environment, and one that isn't set is `novar`, naming it.
- WorkBuddy's servers go in `$WORKBUDDY_CONFIG_DIR/mcp.json` (default `~/.workbuddy`), and CodeBuddy Code's go in the first of `.mcp.json`, `mcp.json` and `~/.codebuddy.json` that exists, as `resolveMcpFilePath` reads them (#1266). WorkBuddy connects a server only after the user has switched it on once in its own MCP settings. It keeps that approval in `mcp-approvals.json`, keyed by the command, args and env names or the URL's origin, so changing those asks again. magpie never writes the approval, and the servers page says so when a server is given to WorkBuddy.
- Alma's servers go in `~/.config/alma/mcp.json` on every system (its home, not its data folder), as its own MCP settings write them: `command`/`args`/`env`, or `url`/`headers` with `transport: "sse"` only for SSE (#1292). Alma reads the file as it starts and when its MCP settings refresh, so a server given to it while it runs reaches it after one of those.
- Zed's servers go in `context_servers` of the settings.json magpie sets its model in (`agent.zed`: `~/.config/zed` on macOS, `$XDG_CONFIG_HOME/zed` on Linux, `%APPDATA%\Zed` on Windows, or `$MAGPIE_ZED_CONFIG_DIR`), as `command`/`args`/`env` or `url`/`headers` (`ContextServerSettingsContent`, zed-industries/zed 2c99f547). The file is JSONC: it is edited in place, so the user's comments, other settings and own servers stay, and `enabled`, `remote`, `timeout` and `oauth` in magpie's entry are the user's. Zed speaks streamable HTTP only (its `http.rs` only POSTs), so no SSE. An entry Zed's extensions configure (`settings` and no command or url) is never written over: a server by that name is refused for Zed (`mcpFile.foreign`), and asks for another name. Zed reloads the file as it changes.
- An agent with no place for an item is refused by `Takes`, never recorded as given it. The refusal lists the agents here that take that item (`takenBy`), and says where it goes instead (`noPlace`): OpenChamber reads OpenCode's global AGENTS.md, skills folder and opencode.json (its server's `shared.js`), so it gets them from OpenCode; Claude Desktop's chat keeps its instructions in its own settings, with the Claude account, and its Code tab reads Claude Code's.
- Removing RTK never uses `rtk init --uninstall`. That command deletes Gemini's `GEMINI.md` whole and removes Claude Code, OpenCode and Cursor together.
- CC Switch's own skills folder is only ever read.
@@ -47,9 +48,9 @@ agents RTK, a CLI that cuts shell output down before the model reads it.
## Verification
```sh
go test -tags nogui ./internal/library -run 'TestCheckSkillsFindsNewOnes|TestNewBase|TestTargets|TestServer|TestImportServer|TestCheck|TestInstructions|TestInstructionSets|TestSkill|TestRemoveFoundSkill|TestInstallSet|TestInstallLists|TestEverySkillAgents|TestEveryServerAgents|TestSomeServersAgents|TestRemoveServers|TestRTK|TestEnvRefs|TestAgentMCPFiles|TestCodeBuddyMCPFile'
go test -tags nogui ./internal/library -run 'TestCheckSkillsFindsNewOnes|TestNewBase|TestTargets|TestServer|TestImportServer|TestCheck|TestInstructions|TestInstructionSets|TestSkill|TestRemoveFoundSkill|TestInstallSet|TestInstallLists|TestEverySkillAgents|TestEveryServerAgents|TestSomeServersAgents|TestRemoveServers|TestRTK|TestEnvRefs|TestAgentMCPFiles|TestCodeBuddyMCPFile|TestZedMCP'
go test -tags nogui ./internal/library
node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs internal/gui/tests/library-rtk-nohook.test.cjs internal/gui/tests/library-servers-bulk.test.cjs internal/gui/tests/library-env-refs.test.cjs internal/gui/tests/library-workbuddy-trust.test.cjs internal/gui/tests/skill-new-in-repo.test.cjs internal/gui/tests/remove-found-skill.test.cjs
node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs internal/gui/tests/library-rtk-nohook.test.cjs internal/gui/tests/library-servers-bulk.test.cjs internal/gui/tests/library-env-refs.test.cjs internal/gui/tests/library-zed-mcp.test.cjs internal/gui/tests/library-workbuddy-trust.test.cjs internal/gui/tests/skill-new-in-repo.test.cjs internal/gui/tests/remove-found-skill.test.cjs
```
The package's `TestMain` runs in a home of its own (`testenv`), never the
+161
View File
@@ -0,0 +1,161 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// lc on Discord: Zed wasn't among the agents an MCP server could be given
// to. magpie gives Zed servers in its settings.json (context_servers), so
// Zed's chip is on every server's row and in the editor, and a click gives
// it the server. Zed speaks a command or streamable HTTP, not SSE, and
// takes a header as written, not ${NAME}: its chip is greyed for those
// with the reason, on the row and in the editor.
// In en, zh, zh-TW, ja and de, at 1100px and 560px: no sideways scroll.
// No backend: the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const HOME = "/Users/emo";
const agent = (id, name, more = {}) => ({ id, name, icon: "", skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json`, ...more });
const lib = (servers) => ({
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
agents: [
agent("claude", "Claude Code"),
// as library.Read gives it: no skills folder, its settings.json for MCP
agent("zed", "Zed", { skills: "", mcp: `${HOME}/.config/zed/settings.json`, noSSE: true, noEnvRefs: true }),
],
instructions: { agents: [] }, servers, skills: [], foundServers: [], projects: [], foundSkills: [], problems: [],
});
let I18N;
async function loadI18N() {
const src = await fs.readFile(path.join(assets, "i18n.js"), "utf8");
I18N = new Function(src.slice(0, src.indexOf("\n};") + 3) + "; return I18N;")();
}
function tr(lang, key, vars = {}) {
let s = key;
if (lang !== "en") {
assert.equal(typeof I18N[lang][key], "string", `${lang} has no ${JSON.stringify(key)}`);
s = I18N[lang][key];
}
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
}
function server(lang, state, posts) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/library") return json(lib(state.servers));
if (url.pathname === "/api/library/mcp/check") return json({ servers: Object.fromEntries(req.postDataJSON().names.map((n) => [n, { state: "ok", tools: 2 }])) });
if (url.pathname === "/api/library/servers/agents" && req.method() === "POST") {
const body = req.postDataJSON();
posts.push(body);
state.servers = state.servers.map((x) => (x.name === body.name ? { ...x, agents: body.agents } : x));
return json({ ...lib(state.servers), result: { changed: ["zed"] } });
}
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
if (url.pathname.startsWith("/api/")) return json({});
if (url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const sideways = (page) => page.evaluate(() => {
const over = [];
const w = document.documentElement.clientWidth;
if (document.documentElement.scrollWidth > w) over.push("page " + document.documentElement.scrollWidth);
for (const e of document.querySelectorAll("#view-library .lib-server, #view-library .lib-agents, #modal .lib-editor, #modal .lib-agents")) {
const r = e.getBoundingClientRect();
if (r.width && (r.right > w + 0.5 || r.left < -0.5)) over.push((e.className || e.tagName) + " " + Math.round(r.left) + "–" + Math.round(r.right));
}
return over;
});
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": Zed takes MCP servers", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
await loadI18N();
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) {
for (const width of [1100, 560]) {
await t.test(`${lang} ${width}px`, async () => {
const w = (key, vars) => tr(lang, key, vars);
const state = {
servers: [
{ name: "fs", transport: "stdio", command: "fs-mcp", args: [], env: { ROOT: "/tmp" }, agents: ["claude", "zed"] },
{ name: "web", transport: "http", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, agents: ["claude"] },
{ name: "old", transport: "sse", url: "https://example.com/sse", agents: ["claude"] },
{ name: "gh", transport: "http", url: "https://example.com/gh", headers: { Authorization: "Bearer ${GH_TOKEN}" }, agents: ["claude"] },
],
};
const posts = [], errors = [];
const ctx = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: "reduce" });
await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "mcp"); } catch {} });
const page = await ctx.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, state, posts));
await page.goto("http://magpie.test/");
await page.locator('button[data-view="library"]').click();
const v = page.locator("#view-library");
await v.locator(".lib-server").first().waitFor();
const row = (name) => v.locator(".lib-server").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name) }) });
const zed = (name) => row(name).locator('.lib-ag[data-agent="zed"]');
const noSSE = w("{agent} can't reach a server over SSE — only a command or streamable HTTP", { agent: "Zed" });
const noRef = w("{agent} can't read {ref} from its settings — given this server, the token would be written there as plain text", { agent: "Zed", ref: "${NAME}" });
// every row has Zed's chip: lit where Zed has it, greyed with the
// reason where it can't take it
assert.equal(await zed("fs").getAttribute("aria-pressed"), "true");
assert.equal(await zed("fs").getAttribute("aria-disabled"), null);
assert.equal(await zed("web").getAttribute("aria-pressed"), "false");
assert.equal(await zed("web").getAttribute("aria-disabled"), null, "a streamable HTTP server was held from Zed");
assert.equal(await zed("old").getAttribute("aria-disabled"), "true");
assert.equal(await zed("old").getAttribute("title"), noSSE);
assert.equal(await zed("gh").getAttribute("aria-disabled"), "true");
assert.equal(await zed("gh").getAttribute("title"), noRef);
assert.deepEqual(await sideways(page), [], "sideways scroll on the page");
// a click gives web to Zed
await zed("web").click();
for (let i = 0; !posts.length && i < 100; i++) await page.waitForTimeout(20);
assert.deepEqual(posts[0], { name: "web", agents: ["claude", "zed"] });
assert.equal(await zed("web").getAttribute("aria-pressed"), "true");
// a greyed chip sends nothing
await zed("old").click({ force: true });
await page.waitForTimeout(150);
assert.equal(posts.length, 1, "a click on Zed's greyed chip gave it an SSE server");
// the editor names Zed, and greys it for SSE
await row("old").locator(".sub").click();
const sheet = page.locator("#modal .lib-editor");
await sheet.waitFor();
const chip = sheet.locator('.lib-ag[data-agent="zed"]');
assert.equal((await chip.locator(".n").textContent()).trim(), "Zed");
assert.equal(await chip.getAttribute("aria-disabled"), "true");
assert.equal(await chip.getAttribute("title"), noSSE);
assert.deepEqual(await sideways(page), [], "sideways scroll in the editor");
await page.keyboard.press("Escape");
await sheet.waitFor({ state: "detached" });
await row("fs").locator(".sub").click();
await sheet.waitFor();
assert.equal(await chip.getAttribute("aria-pressed"), "true");
assert.equal(await chip.getAttribute("aria-disabled"), null);
assert.deepEqual(errors, []);
await ctx.close();
});
}
}
});
}
+3
View File
@@ -166,6 +166,9 @@ func (f *mcpFile) refsOf() envSyntax {
// and env on (kimi_cli/cli/__init__.py, fastmcp mcp_config.py). Cline's
// settings are plain strings (@cline/core 0.0.90). ZCode expands only
// its plugins' servers, not mcp.servers (zcode.cjs createTransport).
// Zed hands a command its env and a url its headers as written
// (zed-industries/zed 2c99f547: crates/context_server/src/transport/
// stdio_transport.rs command.envs, http.rs build_request).
// Alma
// 0.4.164 JSON.parses mcp.json and hands a command its env and a url
// its headers as written (out/main/index.js createStdioTransport,
+34 -4
View File
@@ -141,6 +141,9 @@ const (
// fmtAlma is Alma's ~/.config/alma/mcp.json: a url is streamable HTTP
// (SSE when that fails) unless transport says sse, a command stdio
fmtAlma
// fmtZed is Zed's settings.json context_servers: command/args/env, or
// url/headers, which Zed speaks streamable HTTP to
fmtZed
)
// mcpFile is the file an agent keeps its user-wide MCP servers in.
@@ -180,6 +183,8 @@ func (f *mcpFile) key() string {
return "extensions"
case fmtZCode:
return "mcp.servers"
case fmtZed:
return "context_servers"
}
return "mcpServers"
}
@@ -189,7 +194,7 @@ func (f *mcpFile) supports(s *Server) error {
if f.Format == fmtDesktop && s.Remote() {
return errNoRemote
}
if s.Transport == "sse" && (f.Format == fmtCodex || f.Format == fmtGoose || f.Format == fmtDsh || f.Format == fmtPiNative || f.Format == fmtGrok || f.Format == fmtCommandCode) {
if s.Transport == "sse" && (f.Format == fmtCodex || f.Format == fmtGoose || f.Format == fmtDsh || f.Format == fmtPiNative || f.Format == fmtGrok || f.Format == fmtCommandCode || f.Format == fmtZed) {
return errNoSSE
}
if f.Format == fmtDsh && s.Name != "" && !dshServerName.MatchString(s.Name) {
@@ -206,6 +211,23 @@ func (f *mcpFile) supports(s *Server) error {
// backslash in it, or …/npx.cmd.
var windowsPath = regexp.MustCompile(`^[A-Za-z]:[\\/]|\\|(?i)\.(?:exe|cmd|bat|ps1)$`)
// foreign says why an entry by the server's name that magpie can't read as
// a server is one it mustn't write over, or nil: Zed's server from an
// extension is its "settings" alone, which a command written beside it
// would take the place of, and taking the server off Zed then would take
// the extension's settings with it.
func (f *mcpFile) foreign(old map[string]any) error {
if f.Format != fmtZed || old == nil {
return nil
}
if _, ok := f.decode("", old); ok {
return nil
}
return errZedExtension
}
var errZedExtension = errors.New("Zed has a server by this name from one of its extensions, which magpie leaves as it is: give the server another name to give it to Zed")
// errNoRemote is what the page says of an app that reaches only a server
// it runs itself (Claude Desktop, whose remote ones are its Connectors).
var errNoRemote = errors.New("no-remote")
@@ -213,7 +235,8 @@ var errNoRemote = errors.New("no-remote")
// errNoSSE is what the page says of an agent that can't reach a server
// over SSE (Codex, Goose, DeepSeek Harness, Pi's own MCP, Grok Build — its
// type = "sse" is taken, but it speaks streamable HTTP to it all the same,
// Command Code, whose own add refuses sse).
// Command Code, whose own add refuses sse, Zed, whose only remote transport
// POSTs to the url: crates/context_server/src/transport/http.rs).
var errNoSSE = errors.New("no-sse")
// ordered is a JSON object that keeps its keys in the order given, so an
@@ -366,7 +389,12 @@ func (f *mcpFile) encode(s *Server) ordered {
optional("environment", s.Env)
}
add("enabled", true)
case fmtCursor, fmtDesktop:
case fmtCursor, fmtDesktop, fmtZed:
// Zed's own Add Local Server and Add Remote Server write these keys
// (docs/src/ai/mcp.md); its settings read a command as the Stdio
// form and a url as the Http one, with no "source" or "type"
// (crates/settings_content/src/project.rs
// ContextServerSettingsContent, zed-industries/zed 2c99f547)
if s.Remote() {
add("url", s.URL)
optional("headers", s.Headers)
@@ -748,7 +776,7 @@ func (f *mcpFile) decode(name string, m map[string]any) (*Server, bool) {
} else {
local(str(m, "command"), m["args"], m["env"])
}
default: // Claude Code, Cursor, Copilot, Crush, ZCode, omp, Grok
default: // Claude Code, Cursor, Copilot, Crush, ZCode, omp, Grok, Zed
t := str(m, "type")
if u := str(m, "url"); u != "" {
if t != "sse" {
@@ -898,6 +926,8 @@ var owned = map[mcpFormat][]string{
// the flat shape's keys go when magpie writes the entry again
fmtCline: {"transport", "type", "transportType", "url", "headers", "command", "args", "cwd", "env"},
fmtCommandCode: {"transport", "type", "url", "headers", "command", "args", "env"},
// enabled, remote, timeout and oauth are the user's
fmtZed: {"url", "headers", "command", "args", "env"},
// a url agy read in place of serverUrl goes when magpie writes one
fmtAntigravity: {"type", "serverUrl", "url", "headers", "command", "args", "env"},
+4
View File
@@ -151,6 +151,10 @@ func (l *Library) syncMCP(t *Target, b *backups, res *Result) {
}
s, _ = t.MCP.side(s)
old := entries[s.Name]
if err := t.MCP.foreign(old); err != nil {
res.fail(id, "mcp:"+s.Name, err)
continue
}
if old != nil {
if cur, ok := t.MCP.current(s.Name, old, s); ok && cur.same(s) && t.MCP.has(s) && !t.MCP.behind(s, old) {
mine = append(mine, s.Name)
+8
View File
@@ -323,6 +323,14 @@ func targetOf(a *agent.Agent) *Target {
t.Instructions = filepath.Join(a.Dir, "ATOMCODE.md")
t.MCP = &mcpFile{Path: filepath.Join(a.Dir, "mcp.json"), Format: fmtOmp}
t.Skills = filepath.Join(a.Dir, "skills")
case "zed":
// Zed reads its MCP servers from context_servers in the settings.json
// magpie sets its model in (~/.config/zed, $XDG_CONFIG_HOME/zed,
// %APPDATA%\Zed or $MAGPIE_ZED_CONFIG_DIR: agent.zed), and reloads
// the file as it changes (crates/settings_content/src/project.rs
// ProjectSettingsContent.context_servers, zed-industries/zed
// 2c99f547; lc on Discord)
t.MCP = &mcpFile{Path: a.Path, Format: fmtZed}
case "claude-desktop":
// Claude Desktop reads only commands from its file: a remote server
// is added in its own Connectors settings. In its 3p mode (magpie's
+207
View File
@@ -0,0 +1,207 @@
package library
import (
"encoding/json"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/tidwall/jsonc"
)
// lc on Discord: Zed wasn't among the agents an MCP server could be given
// to. Zed reads them from context_servers in its settings.json (JSONC),
// the file magpie sets its model in: a command as command/args/env, a url
// as url/headers, which it speaks streamable HTTP to (zed-industries/zed
// 2c99f547, crates/settings_content/src/project.rs
// ContextServerSettingsContent). The user's comments, other settings, own
// servers and an extension's server are kept; unchecking Zed takes out
// only magpie's entries.
func TestZedMCP(t *testing.T) {
user := `// Zed settings
//
// For information on how to configure Zed, see the Zed
// documentation: https://zed.dev/docs/configuring-zed
{
"theme": "One Dark", // my theme
/* the servers I added in Zed */
"context_servers": {
"mine": {
"enabled": false,
"command": "uvx",
"args": ["mine"],
"env": {},
"timeout": 120,
},
"remote-mine": {
"url": "https://mine.example.com/mcp",
"headers": { "Authorization": "Bearer <token>" }
},
// installed from Zed's extensions
"mcp-server-github": {
"settings": { "github_personal_access_token": "ghp_x" }
},
},
"buffer_font_size": 15,
}
`
for _, c := range []struct {
name string
dir func(h string) string // Zed's settings folder; nil: the default
}{
{name: "default"},
{name: "MAGPIE_ZED_CONFIG_DIR", dir: func(h string) string { return filepath.Join(h, "zedg") }},
} {
t.Run(c.name, func(t *testing.T) {
h := sandbox(t)
dir := filepath.Join(h, ".config", "zed")
if runtime.GOOS == "windows" {
dir = filepath.Join(h, "AppData", "Roaming", "Zed")
}
if c.dir != nil {
dir = c.dir(h)
t.Setenv("MAGPIE_ZED_CONFIG_DIR", dir)
}
p := filepath.Join(dir, "settings.json")
write(t, p, user)
tg := targetByID("zed")
if tg == nil || tg.MCP == nil || tg.MCP.Path != p {
t.Fatalf("Zed has no MCP target at %s: %+v", p, tg)
}
if id, err := Takes("zed", "mcp"); id != "zed" || err != nil {
t.Errorf("Takes: %q %v", id, err)
}
parse := func() map[string]map[string]any {
t.Helper()
var doc map[string]any
if err := json.Unmarshal(jsonc.ToJSON([]byte(read(t, p))), &doc); err != nil {
t.Fatalf("%v:\n%s", err, read(t, p))
}
out := map[string]map[string]any{}
all, _ := doc["context_servers"].(map[string]any)
for k, v := range all {
out[k], _ = v.(map[string]any)
}
return out
}
// the user's own are there to bring in; the extension's isn't one
l, err := load()
if err != nil {
t.Fatal(err)
}
found := map[string]*Server{}
for _, f := range foundServers(l) {
if slices.Contains(f.Server.Agents, "zed") {
found[f.Server.Name] = f.Server
}
}
if s := found["mine"]; s == nil || s.Transport != "stdio" || s.Command != "uvx" {
t.Errorf("mine: %+v", s)
}
if s := found["remote-mine"]; s == nil || s.Transport != "http" || s.URL != "https://mine.example.com/mcp" {
t.Errorf("remote-mine: %+v", s)
}
if found["mcp-server-github"] != nil {
t.Errorf("an extension's server was found as one to bring in")
}
// the page: Zed's chip, greyed for SSE and for ${NAME}
v, err := Read(nil)
if err != nil {
t.Fatal(err)
}
i := slices.IndexFunc(v.Agents, func(a AgentView) bool { return a.ID == "zed" })
if i < 0 || v.Agents[i].MCP != p || !v.Agents[i].NoSSE || !v.Agents[i].NoEnvRefs || v.Agents[i].NoRemote {
t.Errorf("Zed on the page: %+v", v.Agents)
}
agents := []string{"zed"}
fs := Server{Name: "fs", Transport: "stdio", Command: "npx", Args: []string{"-y", "@mcp/fs"}, Env: map[string]string{"K": "V"}, Agents: agents}
web := Server{Name: "web", Transport: "http", URL: "https://example.com/mcp", Headers: map[string]string{"Authorization": "Bearer x"}, Agents: agents}
ok(t)(SaveServer("", fs))
ok(t)(SaveServer("", web))
for _, refused := range []struct {
s Server
why string
}{
{Server{Name: "old", Transport: "sse", URL: "https://example.com/sse", Agents: agents}, errNoSSE.Error()},
{Server{Name: "gh", Transport: "http", URL: "https://example.com/gh", Headers: map[string]string{"Authorization": "Bearer ${GH_TOKEN}"}, Agents: agents}, errNoEnvRef.Error()},
{Server{Name: "mcp-server-github", Transport: "stdio", Command: "github-mcp", Agents: agents}, errZedExtension.Error()},
} {
res, err := SaveServer("", refused.s)
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(res.Problems, func(p Problem) bool { return p.What == "mcp:"+refused.s.Name && p.Error == refused.why }) {
t.Errorf("%s: %+v", refused.s.Name, res.Problems)
}
}
got := parse()
for name, want := range map[string]map[string]any{
"fs": {"command": "npx", "args": []any{"-y", "@mcp/fs"}, "env": map[string]any{"K": "V"}},
"web": {"url": "https://example.com/mcp", "headers": map[string]any{"Authorization": "Bearer x"}},
// the user's, as they were
"mine": {"enabled": false, "command": "uvx", "args": []any{"mine"}, "env": map[string]any{}, "timeout": float64(120)},
"remote-mine": {"url": "https://mine.example.com/mcp", "headers": map[string]any{"Authorization": "Bearer <token>"}},
"mcp-server-github": {"settings": map[string]any{"github_personal_access_token": "ghp_x"}},
} {
g, _ := json.Marshal(got[name])
w, _ := json.Marshal(want)
if string(g) != string(w) {
t.Errorf("%s:\n got %s\nwant %s", name, g, w)
}
}
for _, name := range []string{"old", "gh"} {
if got[name] != nil {
t.Errorf("%s was written: %v", name, got[name])
}
}
back, err := tg.MCP.read()
if err != nil {
t.Fatal(err)
}
for _, want := range []Server{fs, web} {
if s := back[want.Name]; s == nil || !s.same(&want) {
t.Errorf("%s read back as %+v", want.Name, s)
}
}
// what Zed can't take is no problem once it isn't given to Zed
ok(t)(RemoveServers([]string{"old", "gh", "mcp-server-github"}))
if parse()["mcp-server-github"]["settings"] == nil {
t.Errorf("the extension's server: %v", parse()["mcp-server-github"])
}
before := read(t, p)
ok(t)(Sync())
if read(t, p) != before {
t.Errorf("a sync rewrote servers that are as the library has them:\n%s", read(t, p))
}
// a key the user gives magpie's entry in Zed stays
text := strings.Replace(read(t, p), `"command": "npx"`, `"enabled": false, "command": "npx"`, 1)
write(t, p, text)
fs.Args = []string{"-y", "@mcp/fs", "/tmp"}
ok(t)(SaveServer("fs", fs))
if g := parse()["fs"]; g["enabled"] != false || len(g["args"].([]any)) != 3 {
t.Errorf("fs after an edit: %v", g)
}
// unchecking Zed takes out only magpie's
ok(t)(ServerAgents("fs", nil))
ok(t)(RemoveServer("web"))
got = parse()
if len(got) != 3 || got["mine"] == nil || got["remote-mine"] == nil || got["mcp-server-github"]["settings"] == nil {
t.Errorf("after taking magpie's out:\n%s", read(t, p))
}
for _, keep := range []string{"// Zed settings", "// my theme", "/* the servers I added in Zed */", "// installed from Zed's extensions", `"theme": "One Dark"`, `"buffer_font_size": 15`, `"timeout": 120`} {
if !strings.Contains(read(t, p), keep) {
t.Errorf("lost %s:\n%s", keep, read(t, p))
}
}
})
}
}