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:
1 parent
189586612e
commit
2c1b593675
7 files changed
+422
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user