diff --git a/docs/subsystems/library.md b/docs/subsystems/library.md index d4aafeaf..0c0f7e82 100644 --- a/docs/subsystems/library.md +++ b/docs/subsystems/library.md @@ -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 diff --git a/internal/gui/tests/library-zed-mcp.test.cjs b/internal/gui/tests/library-zed-mcp.test.cjs new file mode 100644 index 00000000..4050f362 --- /dev/null +++ b/internal/gui/tests/library-zed-mcp.test.cjs @@ -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(); + }); + } + } + }); +} diff --git a/internal/library/envref.go b/internal/library/envref.go index 95b97342..20fb5352 100644 --- a/internal/library/envref.go +++ b/internal/library/envref.go @@ -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, diff --git a/internal/library/mcp.go b/internal/library/mcp.go index f5e99070..4c29b7fa 100644 --- a/internal/library/mcp.go +++ b/internal/library/mcp.go @@ -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"}, diff --git a/internal/library/sync.go b/internal/library/sync.go index c40e5c68..da188654 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -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) diff --git a/internal/library/targets.go b/internal/library/targets.go index 929d3655..193603af 100644 --- a/internal/library/targets.go +++ b/internal/library/targets.go @@ -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 diff --git a/internal/library/zed_mcp_test.go b/internal/library/zed_mcp_test.go new file mode 100644 index 00000000..64154e7d --- /dev/null +++ b/internal/library/zed_mcp_test.go @@ -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 " } + }, + // 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 "}}, + "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)) + } + } + }) + } +}