diff --git a/.gitignore b/.gitignore index b6b51be66..e02ba0d35 100644 --- a/.gitignore +++ b/.gitignore @@ -341,6 +341,7 @@ rulesync.local.jsonc **/.cursor/mcp.json **/.deepagents/.mcp.json **/.factory/mcp.json +**/.agents/plugins/rulesync/.mcp.json **/.grok/config.toml **/.kiro/settings/mcp.json **/.junie/mcp/mcp.json diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 6931fcfc5..8bc059d1d 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -674,9 +674,14 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] > **Grok CLI note:** MCP servers are written to a `[mcp_servers.]` table in `.grok/config.toml` (project) / `~/.grok/config.toml` (global, via `--global`). The file is treated as shared Grok config: Rulesync only replaces the `mcp_servers` key and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal `env` table (it does not support the `env_vars` runtime-passthrough list) and has no per-server tool allow/deny lists, so the only field rename is `disabled` (rulesync) ⇄ `enabled = false` (grok); an active server simply omits `enabled`. Servers with no environment variables are emitted without a dangling `[mcp_servers..env]` table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning. -### Goose-specific: MCP servers as `extensions` (global only) +### Goose-specific: MCP servers as `extensions` (global) and open-plugin manifest (project) -Goose configures MCP servers as **extensions** in the shared user config `~/.config/goose/config.yaml`, which is **global only** (Goose has no project-scoped MCP location), so they are generated with `--global`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/). +Goose configures MCP servers in two locations depending on scope: + +- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. This location supports **both stdio and remote** (http/sse) servers. +- **Project:** Goose v1.39.0+ discovers MCP extensions in **open plugins** at `/.agents/plugins//.mcp.json` (and `~/.agents/plugins//.mcp.json` at user scope). Rulesync emits `.agents/plugins/rulesync/.mcp.json`, reusing the same `.agents/plugins/rulesync/` tree already used for Goose hooks. The manifest uses the **Claude-style** `{ "mcpServers": { "": { "command", "args", "env", "cwd" } } }` shape. This manifest is **stdio-only** — it cannot express `url`/`headers`, so **remote (http/sse) servers are skipped with a warning** in project mode; sync them with `--global` to `~/.config/goose/config.yaml` instead. The `.mcp.json` manifest is owned by Rulesync and is deleted when no servers remain. + +See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/) and [open-plugins MCP PR #9471](https://github.com/block/goose/pull/9471). ### Goose-specific: commands and subagents as recipes diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index f828ba027..2193b5ad1 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -13,7 +13,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | +| Goose | goose | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | | Hermes Agent | hermesagent | ✅ | | 🌏 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | | Grok CLI | grokcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 6931fcfc5..8bc059d1d 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -674,9 +674,14 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] > **Grok CLI note:** MCP servers are written to a `[mcp_servers.]` table in `.grok/config.toml` (project) / `~/.grok/config.toml` (global, via `--global`). The file is treated as shared Grok config: Rulesync only replaces the `mcp_servers` key and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal `env` table (it does not support the `env_vars` runtime-passthrough list) and has no per-server tool allow/deny lists, so the only field rename is `disabled` (rulesync) ⇄ `enabled = false` (grok); an active server simply omits `enabled`. Servers with no environment variables are emitted without a dangling `[mcp_servers..env]` table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning. -### Goose-specific: MCP servers as `extensions` (global only) +### Goose-specific: MCP servers as `extensions` (global) and open-plugin manifest (project) -Goose configures MCP servers as **extensions** in the shared user config `~/.config/goose/config.yaml`, which is **global only** (Goose has no project-scoped MCP location), so they are generated with `--global`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/). +Goose configures MCP servers in two locations depending on scope: + +- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. This location supports **both stdio and remote** (http/sse) servers. +- **Project:** Goose v1.39.0+ discovers MCP extensions in **open plugins** at `/.agents/plugins//.mcp.json` (and `~/.agents/plugins//.mcp.json` at user scope). Rulesync emits `.agents/plugins/rulesync/.mcp.json`, reusing the same `.agents/plugins/rulesync/` tree already used for Goose hooks. The manifest uses the **Claude-style** `{ "mcpServers": { "": { "command", "args", "env", "cwd" } } }` shape. This manifest is **stdio-only** — it cannot express `url`/`headers`, so **remote (http/sse) servers are skipped with a warning** in project mode; sync them with `--global` to `~/.config/goose/config.yaml` instead. The `.mcp.json` manifest is owned by Rulesync and is deleted when no servers remain. + +See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/) and [open-plugins MCP PR #9471](https://github.com/block/goose/pull/9471). ### Goose-specific: commands and subagents as recipes diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index f828ba027..2193b5ad1 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -13,7 +13,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | +| Goose | goose | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | | Hermes Agent | hermesagent | ✅ | | 🌏 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | | Grok CLI | grokcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/src/constants/goose-paths.ts b/src/constants/goose-paths.ts index 570bc9836..fda504603 100644 --- a/src/constants/goose-paths.ts +++ b/src/constants/goose-paths.ts @@ -11,6 +11,17 @@ export const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml"; export const GOOSE_HOOKS_DIR_PATH = join(".agents", "plugins", "rulesync", "hooks"); export const GOOSE_HOOKS_FILE_NAME = "hooks.json"; +// Goose v1.39.0 (2026-06-25) discovers MCP extensions in open plugins at +// project scope `/.agents/plugins//.mcp.json` and user scope +// `~/.agents/plugins//.mcp.json`, using the Claude-style manifest shape +// `{ "mcpServers": { "": { command, args, env, cwd } } }`. The manifest is +// stdio-only (no `url`/`headers`). rulesync reuses the same `.agents/plugins/rulesync/` +// tree already used for Goose hooks. +// @see https://github.com/block/goose/pull/9471 +// @see https://github.com/block/goose/releases/tag/v1.39.0 +export const GOOSE_PLUGIN_MCP_DIR_PATH = join(".agents", "plugins", "rulesync"); +export const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json"; + // Goose discovers skills under `.goose/skills//SKILL.md`, each a directory // containing a SKILL.md with `name`+`description` frontmatter. rulesync emits // only this Goose-specific project path; Goose's portable global skills location diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index ecd104b1a..bdb397092 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -34,6 +34,7 @@ describe("E2E: mcp", () => { { target: "opencode", outputPath: "opencode.jsonc" }, { target: "deepagents", outputPath: join(".deepagents", ".mcp.json") }, { target: "factorydroid", outputPath: join(".factory", "mcp.json") }, + { target: "goose", outputPath: join(".agents", "plugins", "rulesync", ".mcp.json") }, { target: "kilo", outputPath: "kilo.jsonc" }, { target: "roo", outputPath: join(".roo", "mcp.json") }, { target: "kiro", outputPath: join(".kiro", "settings", "mcp.json") }, @@ -118,6 +119,7 @@ describe("E2E: mcp", () => { { target: "kiro", orphanPath: join(".kiro", "settings", "mcp.json") }, { target: "junie", orphanPath: join(".junie", "mcp", "mcp.json") }, { target: "devin", orphanPath: join(".windsurf", "mcp_config.json") }, + { target: "goose", orphanPath: join(".agents", "plugins", "rulesync", ".mcp.json") }, ])( "should fail in check mode when delete would remove an orphan $target mcp file", async ({ target, orphanPath }) => { diff --git a/src/features/mcp/goose-mcp.test.ts b/src/features/mcp/goose-mcp.test.ts index e939b8868..585128253 100644 --- a/src/features/mcp/goose-mcp.test.ts +++ b/src/features/mcp/goose-mcp.test.ts @@ -11,6 +11,8 @@ import { RulesyncMcp } from "./rulesync-mcp.js"; const GOOSE_DIR = join(".config", "goose"); const GOOSE_FILE = "config.yaml"; +const GOOSE_PLUGIN_DIR = join(".agents", "plugins", "rulesync"); +const GOOSE_PLUGIN_FILE = ".mcp.json"; function getExtensions(content: string): Record> { const parsed = load(content); @@ -33,41 +35,160 @@ describe("GooseMcp", () => { }); describe("getSettablePaths", () => { - it("targets ~/.config/goose/config.yaml", () => { - const paths = GooseMcp.getSettablePaths(); + it("targets ~/.config/goose/config.yaml in global mode", () => { + const paths = GooseMcp.getSettablePaths({ global: true }); expect(paths.relativeDirPath).toBe(GOOSE_DIR); expect(paths.relativeFilePath).toBe(GOOSE_FILE); }); + + it("targets the open-plugin .mcp.json manifest in project mode", () => { + const paths = GooseMcp.getSettablePaths({ global: false }); + expect(paths.relativeDirPath).toBe(GOOSE_PLUGIN_DIR); + expect(paths.relativeFilePath).toBe(GOOSE_PLUGIN_FILE); + }); }); describe("isDeletable", () => { - it("is never deletable (shared config file)", () => { + it("is not deletable in global mode (shared config file)", () => { const mcp = new GooseMcp({ relativeDirPath: GOOSE_DIR, relativeFilePath: GOOSE_FILE, fileContent: "", validate: false, + global: true, }); expect(mcp.isDeletable()).toBe(false); }); + + it("is deletable in project mode (rulesync-owned manifest)", () => { + const mcp = new GooseMcp({ + relativeDirPath: GOOSE_PLUGIN_DIR, + relativeFilePath: GOOSE_PLUGIN_FILE, + fileContent: "", + validate: false, + global: false, + }); + expect(mcp.isDeletable()).toBe(true); + }); }); - describe("global-only", () => { - it("fromRulesyncMcp throws without global", async () => { + describe("fromRulesyncMcp (project mode)", () => { + it("emits a Claude-style stdio server to .agents/plugins/rulesync/.mcp.json", async () => { const rulesyncMcp = new RulesyncMcp({ relativeDirPath: ".rulesync", relativeFilePath: ".mcp.json", - fileContent: JSON.stringify({ mcpServers: {} }), + fileContent: JSON.stringify({ + mcpServers: { + fetch: { + command: "uvx", + args: ["mcp-server-fetch"], + env: { TOKEN: "x" }, + cwd: "/srv/fetch", + }, + }, + }), }); - await expect(GooseMcp.fromRulesyncMcp({ rulesyncMcp, global: false })).rejects.toThrow( - /global-only/, - ); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: false, + }); + + expect(mcp.getRelativeDirPath()).toBe(GOOSE_PLUGIN_DIR); + expect(mcp.getRelativeFilePath()).toBe(GOOSE_PLUGIN_FILE); + const parsed = JSON.parse(mcp.getFileContent()); + expect(parsed.mcpServers.fetch).toEqual({ + command: "uvx", + args: ["mcp-server-fetch"], + env: { TOKEN: "x" }, + cwd: "/srv/fetch", + }); + }); + + it("folds an array command's tail into args", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { fetch: { command: ["uvx", "mcp-server-fetch"], args: ["--flag"] } }, + }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: false, + }); + const parsed = JSON.parse(mcp.getFileContent()); + expect(parsed.mcpServers.fetch).toEqual({ + command: "uvx", + args: ["mcp-server-fetch", "--flag"], + }); + }); + + it("skips remote (http/sse) servers with a warning", async () => { + const warn = vi.fn(); + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + local: { command: "uvx", args: ["mcp-server-fetch"] }, + remote: { type: "http", url: "https://example.com/mcp" }, + sse: { type: "sse", url: "https://example.com/sse" }, + }, + }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: false, + logger: { warn } as never, + }); + const parsed = JSON.parse(mcp.getFileContent()); + + expect(Object.keys(parsed.mcpServers)).toEqual(["local"]); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("stdio-only")); + // Assert on the resolved Goose server type rather than the server name, so + // the test stays meaningful even if the sample server names change. + expect(warn).toHaveBeenCalledWith(expect.stringContaining("(streamable_http)")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("(sse)")); }); - it("fromFile throws without global", async () => { - await expect(GooseMcp.fromFile({ outputRoot: testDir, global: false })).rejects.toThrow( - /global-only/, + it("strips prototype-pollution keys from a server's env in project mode", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: + '{"mcpServers":{"fetch":{"command":"uvx",' + + '"env":{"__proto__":"polluted","constructor":"polluted","TOKEN":"safe"}}}}', + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: false, + }); + const parsed = JSON.parse(mcp.getFileContent()); + expect(parsed.mcpServers.fetch.env).toEqual({ TOKEN: "safe" }); + }); + + it("round-trips a project manifest back to canonical servers", async () => { + const dir = join(testDir, GOOSE_PLUGIN_DIR); + await ensureDir(dir); + await writeFileContent( + join(dir, GOOSE_PLUGIN_FILE), + JSON.stringify({ + mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"] } }, + }), ); + + const mcp = await GooseMcp.fromFile({ outputRoot: testDir, global: false }); + const servers = JSON.parse(mcp.toRulesyncMcp().getFileContent()).mcpServers; + expect(servers.fetch).toEqual({ command: "uvx", args: ["mcp-server-fetch"] }); }); }); diff --git a/src/features/mcp/goose-mcp.ts b/src/features/mcp/goose-mcp.ts index 986d0a338..5c0775337 100644 --- a/src/features/mcp/goose-mcp.ts +++ b/src/features/mcp/goose-mcp.ts @@ -2,11 +2,18 @@ import { join } from "node:path"; import { dump, load } from "js-yaml"; -import { GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME } from "../../constants/goose-paths.js"; +import { + GOOSE_GLOBAL_DIR, + GOOSE_MCP_FILE_NAME, + GOOSE_PLUGIN_MCP_DIR_PATH, + GOOSE_PLUGIN_MCP_FILE_NAME, +} from "../../constants/goose-paths.js"; import { ValidationResult } from "../../types/ai-file.js"; import { McpServers } from "../../types/mcp.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; +import type { Logger } from "../../utils/logger.js"; +import { warnWithFallback } from "../../utils/logger.js"; import { omitPrototypePollutionKeys, PROTOTYPE_POLLUTION_KEYS, @@ -22,8 +29,7 @@ import { ToolMcpSettablePaths, } from "./tool-mcp.js"; -const GOOSE_GLOBAL_ONLY_MESSAGE = - "Goose MCP is global-only; use --global to sync ~/.config/goose/config.yaml"; +const GOOSE_PLUGIN_MCP_RELATIVE_PATH = join(GOOSE_PLUGIN_MCP_DIR_PATH, GOOSE_PLUGIN_MCP_FILE_NAME); function parseGooseConfig( fileContent: string, @@ -199,26 +205,113 @@ function convertFromGooseFormat(extensions: Record): McpServers return result; } +/** + * Builds a Claude-style stdio server entry (`command`/`args`/`env`/`cwd`) for the + * open-plugins `.mcp.json` manifest. + */ +function buildGoosePluginStdioServer(config: Record): Record { + const server: Record = {}; + + const command = config.command; + // `command` may be a string or an array; the Claude-style manifest uses a + // single `command` plus an `args` array, so an array's tail folds into `args`. + if (Array.isArray(command)) { + if (typeof command[0] === "string") server.command = command[0]; + const rest = command.slice(1).filter((c): c is string => typeof c === "string"); + const args = isStringArray(config.args) ? config.args : []; + if (rest.length > 0 || args.length > 0) server.args = [...rest, ...args]; + } else if (typeof command === "string") { + server.command = command; + if (isStringArray(config.args)) server.args = config.args; + } + + if (isPlainObject(config.env)) server.env = omitPrototypePollutionKeys(config.env); + if (typeof config.cwd === "string") server.cwd = config.cwd; + + return server; +} + +/** + * Converts rulesync canonical MCP servers into the Claude-style `mcpServers` map + * used by Goose open-plugin manifests (`.agents/plugins/rulesync/.mcp.json`). + * + * The open-plugins manifest is **stdio-only** (no `url`/`headers`), so remote + * (http/sse/streamable_http) and `builtin` servers cannot be represented. They + * are skipped with a warning rather than silently dropped — remote servers stay + * global-config-only (sync them with `--global` to `~/.config/goose/config.yaml`). + */ +function convertToGoosePluginMcpServers( + mcpServers: McpServers, + logger: Logger | undefined, +): Record> { + const result: Record> = {}; + + for (const [name, config] of Object.entries(mcpServers)) { + if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue; + + const url = resolveGooseUrl(config); + const gooseType = resolveGooseType(config, url); + if (gooseType !== "stdio") { + warnWithFallback( + logger, + `Goose open-plugin MCP manifest (${GOOSE_PLUGIN_MCP_RELATIVE_PATH}) is stdio-only; ` + + `skipping "${name}" (${gooseType}). Sync it with --global to ~/.config/goose/config.yaml instead.`, + ); + continue; + } + + result[name] = buildGoosePluginStdioServer(config); + } + + return result; +} + /** * Goose MCP servers. * - * Goose configures MCP servers as "extensions" in the shared user config file - * `~/.config/goose/config.yaml` (global only — Goose has no project-scoped MCP - * location). That file also holds other Goose settings (model, provider, ...), - * so generation merges the `extensions:` block into the existing config instead - * of overwriting it, and the file is never deleted. + * Goose configures MCP servers in two locations: + * + * - **Global** (`--global`): "extensions" in the shared user config + * `~/.config/goose/config.yaml`. That file also holds other Goose settings + * (model, provider, ...), so generation merges the `extensions:` block into the + * existing config instead of overwriting it, and the file is never deleted. + * This location supports both stdio and remote (http/sse) servers. + * - **Project**: a stdio-only open-plugin manifest at + * `.agents/plugins/rulesync/.mcp.json` (Goose v1.39.0+), reusing the same + * `.agents/plugins/rulesync/` tree as Goose hooks. The manifest uses the + * Claude-style `{ "mcpServers": { "": { command, args, env, cwd } } }` + * shape and cannot express `url`/`headers`, so remote servers are skipped with + * a warning in project mode (use `--global` to sync them instead). * * @see https://block.github.io/goose/docs/getting-started/using-extensions/ + * @see https://github.com/block/goose/pull/9471 */ export class GooseMcp extends ToolMcp { private readonly config: Record; constructor(params: ToolMcpParams) { super(params); - if (this.fileContent !== undefined) { + if (this.fileContent === undefined) { + this.config = {}; + } else if (params.global) { + // Global config.yaml is YAML. this.config = parseGooseConfig(this.fileContent, this.relativeDirPath, this.relativeFilePath); } else { - this.config = {}; + // Project `.mcp.json` is a Claude-style JSON manifest. An empty string + // (e.g. from `forDeletion`) is treated as an empty manifest. + this.config = this.fileContent ? this.parsePluginManifest(this.fileContent) : {}; + } + } + + private parsePluginManifest(fileContent: string): Record { + try { + const parsed: unknown = JSON.parse(fileContent); + return isRecord(parsed) ? parsed : {}; + } catch (error) { + throw new Error( + `Failed to parse Goose MCP manifest at ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(error)}`, + { cause: error }, + ); } } @@ -227,15 +320,22 @@ export class GooseMcp extends ToolMcp { } override isDeletable(): boolean { - // config.yaml holds other Goose settings, so it must never be removed - // wholesale; clearing MCP happens via an in-place merge instead. - return false; + // Global config.yaml holds other Goose settings, so it must never be removed + // wholesale; clearing MCP happens via an in-place merge instead. The project + // `.mcp.json` is a rulesync-owned manifest and can be safely deleted. + return !this.global; } - static getSettablePaths(_options?: { global?: boolean }): ToolMcpSettablePaths { + static getSettablePaths({ global = false }: { global?: boolean } = {}): ToolMcpSettablePaths { + if (global) { + return { + relativeDirPath: GOOSE_GLOBAL_DIR, + relativeFilePath: GOOSE_MCP_FILE_NAME, + }; + } return { - relativeDirPath: GOOSE_GLOBAL_DIR, - relativeFilePath: GOOSE_MCP_FILE_NAME, + relativeDirPath: GOOSE_PLUGIN_MCP_DIR_PATH, + relativeFilePath: GOOSE_PLUGIN_MCP_FILE_NAME, }; } @@ -244,12 +344,11 @@ export class GooseMcp extends ToolMcp { validate = true, global = false, }: ToolMcpFromFileParams): Promise { - if (!global) { - throw new Error(GOOSE_GLOBAL_ONLY_MESSAGE); - } const paths = this.getSettablePaths({ global }); const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); - const fileContent = (await readFileContentOrNull(filePath)) ?? ""; + const fileContent = + (await readFileContentOrNull(filePath)) ?? + (global ? "" : JSON.stringify({ mcpServers: {} }, null, 2)); return new GooseMcp({ outputRoot, @@ -266,11 +365,23 @@ export class GooseMcp extends ToolMcp { rulesyncMcp, validate = true, global = false, + logger, }: ToolMcpFromRulesyncMcpParams): Promise { + const paths = this.getSettablePaths({ global }); + if (!global) { - throw new Error(GOOSE_GLOBAL_ONLY_MESSAGE); + // Project: emit a stdio-only Claude-style `.mcp.json` manifest. Remote + // servers are skipped with a warning (handled in the converter). + const mcpServers = convertToGoosePluginMcpServers(rulesyncMcp.getMcpServers(), logger); + return new GooseMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: JSON.stringify({ mcpServers }, null, 2), + validate, + global, + }); } - const paths = this.getSettablePaths({ global }); const fileContent = await readOrInitializeFileContent( join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), @@ -293,6 +404,13 @@ export class GooseMcp extends ToolMcp { } toRulesyncMcp(): RulesyncMcp { + if (!this.global) { + // Project manifest is already Claude-style canonical `mcpServers`. + const mcpServers = isRecord(this.config.mcpServers) ? this.config.mcpServers : {}; + return this.toRulesyncMcpDefault({ + fileContent: JSON.stringify({ mcpServers }, null, 2), + }); + } const extensions = isRecord(this.config.extensions) ? this.config.extensions : {}; const mcpServers = convertFromGooseFormat(extensions); return this.toRulesyncMcpDefault({ diff --git a/src/features/mcp/mcp-processor.test.ts b/src/features/mcp/mcp-processor.test.ts index 8f9d17ed2..4767c4a0e 100644 --- a/src/features/mcp/mcp-processor.test.ts +++ b/src/features/mcp/mcp-processor.test.ts @@ -563,6 +563,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); @@ -598,6 +599,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: true, + logger: expect.any(Object), }); }); @@ -632,6 +634,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); @@ -666,6 +669,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); @@ -700,6 +704,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); @@ -735,6 +740,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: true, + logger: expect.any(Object), }); }); @@ -769,6 +775,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); @@ -802,6 +809,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: true, + logger: expect.any(Object), }); }); @@ -836,6 +844,7 @@ describe("McpProcessor", () => { outputRoot: testDir, rulesyncMcp, global: false, + logger: expect.any(Object), }); }); diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index 1cd80e3df..09c2cbe0a 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -251,12 +251,15 @@ export const toolMcpFactories = new Map( [ "goose", { - // Goose reads MCP servers as "extensions" only from the global user config - // `~/.config/goose/config.yaml`; it has no project-scoped MCP location. + // Goose reads MCP servers as "extensions" from the global user config + // `~/.config/goose/config.yaml`, and (since v1.39.0) discovers stdio-only + // MCP extensions in open plugins at project scope + // `.agents/plugins/rulesync/.mcp.json` (Claude-style `mcpServers`). // https://block.github.io/goose/docs/getting-started/using-extensions/ + // https://github.com/block/goose/pull/9471 class: GooseMcp, meta: { - supportsProject: false, + supportsProject: true, supportsGlobal: true, supportsEnabledTools: false, supportsDisabledTools: false, @@ -636,6 +639,7 @@ export class McpProcessor extends FeatureProcessor { outputRoot: this.outputRoot, rulesyncMcp: filteredRulesyncMcp, global: this.global, + logger: this.logger, }); }), ); diff --git a/src/features/mcp/tool-mcp.ts b/src/features/mcp/tool-mcp.ts index 5467b5411..9595963d0 100644 --- a/src/features/mcp/tool-mcp.ts +++ b/src/features/mcp/tool-mcp.ts @@ -15,6 +15,7 @@ export type ToolMcpFromRulesyncMcpParams = Omit< "fileContent" | "relativeFilePath" | "relativeDirPath" > & { rulesyncMcp: RulesyncMcp; + logger?: Logger; }; export type ToolMcpFromFileParams = Pick<