diff --git a/README.md b/README.md index ce9918c5e..8c272af78 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Codex CLI | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Gemini CLI ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Goose | ✅ | ✅ | | | | | ✅ | | +| Goose | ✅ | ✅ | ✅ | | | | ✅ | | | GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | ✅ | | ✅ | | ✅ | | ✅ | | | Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 0729df610..1900c88e8 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -555,6 +555,10 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] - Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`. - Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule. +### Goose-specific: MCP servers as `extensions` (global only) + +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/). + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index bf0dc69c6..a4223a86e 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | | +| Goose | goose | ✅ 🌏 | ✅ | 🌏 | | | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 0729df610..1900c88e8 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -555,6 +555,10 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] - Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`. - Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule. +### Goose-specific: MCP servers as `extensions` (global only) + +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/). + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index bf0dc69c6..a4223a86e 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | | +| Goose | goose | ✅ 🌏 | ✅ | 🌏 | | | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 082408904..ade11461c 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -201,6 +201,9 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "goose", feature: "ignore", entry: "**/.gooseignore" }, // Goose lifecycle hooks plugin (.agents/plugins//hooks/hooks.json) { target: "goose", feature: "hooks", entry: "**/.agents/plugins/" }, + // Goose reads MCP "extensions" only from the global user config + // (`~/.config/goose/config.yaml`), which lives under the home directory and is + // not gitignored at the project level (mirrors Cline's global-only MCP). // GitHub Copilot { diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index a792442d4..b8a26d7b8 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -292,6 +292,7 @@ describe("E2E: mcp (global mode)", () => { { target: "claudecode", outputPath: ".claude.json" }, { target: "cursor", outputPath: join(".cursor", "mcp.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, { target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") }, { target: "codexcli", outputPath: join(".codex", "config.toml") }, { target: "copilotcli", outputPath: join(".copilot", "mcp-config.json") }, diff --git a/src/features/mcp/goose-mcp.test.ts b/src/features/mcp/goose-mcp.test.ts new file mode 100644 index 000000000..610fa57e4 --- /dev/null +++ b/src/features/mcp/goose-mcp.test.ts @@ -0,0 +1,235 @@ +import { join } from "node:path"; + +import { load } from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { isRecord } from "../../utils/type-guards.js"; +import { GooseMcp } from "./goose-mcp.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; + +const GOOSE_DIR = join(".config", "goose"); +const GOOSE_FILE = "config.yaml"; + +function getExtensions(content: string): Record> { + const parsed = load(content); + if (!isRecord(parsed) || !isRecord(parsed.extensions)) return {}; + return parsed.extensions as Record>; +} + +describe("GooseMcp", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("targets ~/.config/goose/config.yaml", () => { + const paths = GooseMcp.getSettablePaths(); + expect(paths.relativeDirPath).toBe(GOOSE_DIR); + expect(paths.relativeFilePath).toBe(GOOSE_FILE); + }); + }); + + describe("isDeletable", () => { + it("is never deletable (shared config file)", () => { + const mcp = new GooseMcp({ + relativeDirPath: GOOSE_DIR, + relativeFilePath: GOOSE_FILE, + fileContent: "", + validate: false, + }); + expect(mcp.isDeletable()).toBe(false); + }); + }); + + describe("global-only", () => { + it("fromRulesyncMcp throws without global", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ mcpServers: {} }), + }); + await expect(GooseMcp.fromRulesyncMcp({ rulesyncMcp, global: false })).rejects.toThrow( + /global-only/, + ); + }); + + it("fromFile throws without global", async () => { + await expect(GooseMcp.fromFile({ outputRoot: testDir, global: false })).rejects.toThrow( + /global-only/, + ); + }); + }); + + describe("fromRulesyncMcp", () => { + it("converts a stdio server to a Goose extension", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + fetch: { + command: "uvx", + args: ["mcp-server-fetch"], + env: { TOKEN: "x" }, + }, + }, + }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + const ext = getExtensions(mcp.getFileContent()).fetch; + + expect(ext).toMatchObject({ + name: "fetch", + type: "stdio", + cmd: "uvx", + args: ["mcp-server-fetch"], + envs: { TOKEN: "x" }, + enabled: true, + }); + }); + + it("converts a remote http server to a streamable_http extension", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + remote: { + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + }, + }, + }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + const ext = getExtensions(mcp.getFileContent()).remote; + + expect(ext).toMatchObject({ + name: "remote", + type: "streamable_http", + uri: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + enabled: true, + }); + expect(ext?.cmd).toBeUndefined(); + }); + + it("maps disabled to enabled: false", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { off: { command: "x", disabled: true } }, + }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + expect(getExtensions(mcp.getFileContent()).off?.enabled).toBe(false); + }); + + it("preserves other config.yaml keys when merging", async () => { + const dir = join(testDir, GOOSE_DIR); + await ensureDir(dir); + await writeFileContent( + join(dir, GOOSE_FILE), + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\n", + ); + + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ mcpServers: { fetch: { command: "uvx" } } }), + }); + + const mcp = await GooseMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + const parsed = load(mcp.getFileContent()); + + expect(isRecord(parsed) && parsed.GOOSE_MODEL).toBe("gpt-4o"); + expect(isRecord(parsed) && parsed.GOOSE_PROVIDER).toBe("openai"); + expect(getExtensions(mcp.getFileContent()).fetch?.cmd).toBe("uvx"); + }); + }); + + describe("toRulesyncMcp round-trip", () => { + it("converts Goose extensions back to canonical servers", async () => { + const dir = join(testDir, GOOSE_DIR); + await ensureDir(dir); + await writeFileContent( + join(dir, GOOSE_FILE), + [ + "extensions:", + " fetch:", + " name: fetch", + " type: stdio", + " cmd: uvx", + " args: [mcp-server-fetch]", + " enabled: true", + " remote:", + " name: remote", + " type: streamable_http", + " uri: https://example.com/mcp", + " enabled: false", + "", + ].join("\n"), + ); + + const mcp = await GooseMcp.fromFile({ outputRoot: testDir, global: true }); + const rulesync = mcp.toRulesyncMcp(); + const servers = JSON.parse(rulesync.getFileContent()).mcpServers; + + expect(servers.fetch).toMatchObject({ + type: "stdio", + command: "uvx", + args: ["mcp-server-fetch"], + }); + expect(servers.remote).toMatchObject({ + type: "http", + url: "https://example.com/mcp", + disabled: true, + }); + }); + }); + + describe("forDeletion", () => { + it("returns a non-deletable instance", () => { + const mcp = GooseMcp.forDeletion({ + outputRoot: testDir, + relativeDirPath: GOOSE_DIR, + relativeFilePath: GOOSE_FILE, + global: true, + }); + expect(mcp).toBeInstanceOf(GooseMcp); + expect(mcp.isDeletable()).toBe(false); + }); + }); +}); diff --git a/src/features/mcp/goose-mcp.ts b/src/features/mcp/goose-mcp.ts new file mode 100644 index 000000000..bbacf53ce --- /dev/null +++ b/src/features/mcp/goose-mcp.ts @@ -0,0 +1,281 @@ +import { join } from "node:path"; + +import { dump, load } from "js-yaml"; + +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 { PROTOTYPE_POLLUTION_KEYS } from "../../utils/prototype-pollution.js"; +import { isRecord, isStringArray } from "../../utils/type-guards.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; +import { + ToolMcp, + ToolMcpForDeletionParams, + ToolMcpFromFileParams, + ToolMcpFromRulesyncMcpParams, + ToolMcpParams, + ToolMcpSettablePaths, +} from "./tool-mcp.js"; + +const GOOSE_GLOBAL_ONLY_MESSAGE = + "Goose MCP is global-only; use --global to sync ~/.config/goose/config.yaml"; + +function parseGooseConfig( + fileContent: string, + relativeDirPath: string, + relativeFilePath: string, +): Record { + const configPath = join(relativeDirPath, relativeFilePath); + let parsed: unknown; + try { + parsed = load(fileContent); + } catch (error) { + throw new Error(`Failed to parse Goose config at ${configPath}: ${formatError(error)}`, { + cause: error, + }); + } + // An empty config.yaml parses to undefined/null; treat it as an empty object. + if (parsed === undefined || parsed === null) { + return {}; + } + if (!isRecord(parsed)) { + throw new Error(`Failed to parse Goose config at ${configPath}: expected a YAML mapping`); + } + return parsed; +} + +/** + * Resolves the rulesync canonical transport for a server (`type` or `transport`). + */ +function canonicalTransport(config: Record): string | undefined { + if (typeof config.type === "string") return config.type; + if (typeof config.transport === "string") return config.transport; + return undefined; +} + +/** + * Converts rulesync canonical MCP servers into Goose `extensions:` entries. + * + * Goose uses a non-standard schema: `name`, `type` (`stdio` | `streamable_http` + * | `sse` | `builtin`), `cmd`/`args`/`envs` for stdio, `uri`/`headers` for + * remote, plus `enabled` and `timeout`. + */ +function convertToGooseFormat(mcpServers: McpServers): Record> { + const extensions: Record> = {}; + + for (const [name, config] of Object.entries(mcpServers)) { + if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue; + + const command = config.command; + const url = + (typeof config.url === "string" ? config.url : undefined) ?? + (typeof config.httpUrl === "string" ? config.httpUrl : undefined); + const transport = canonicalTransport(config); + + let gooseType: string; + if (command !== undefined) { + gooseType = "stdio"; + } else if (url !== undefined) { + gooseType = transport === "sse" ? "sse" : "streamable_http"; + } else { + gooseType = transport === "builtin" ? "builtin" : "stdio"; + } + + const ext: Record = { name, type: gooseType }; + + if (gooseType === "stdio") { + // `command` may be a string or an array; Goose `cmd` is a single + // executable, so an array's tail is folded into `args`. + if (Array.isArray(command)) { + if (typeof command[0] === "string") ext.cmd = 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) ext.args = [...rest, ...args]; + } else if (typeof command === "string") { + ext.cmd = command; + if (isStringArray(config.args)) ext.args = config.args; + } + if (isRecord(config.env)) ext.envs = config.env; + } else if (gooseType === "sse" || gooseType === "streamable_http") { + if (url !== undefined) ext.uri = url; + if (isRecord(config.headers)) ext.headers = config.headers; + } + + ext.enabled = config.disabled !== true; + + const timeout = + typeof config.timeout === "number" + ? config.timeout + : typeof config.networkTimeout === "number" + ? config.networkTimeout + : undefined; + if (timeout !== undefined) ext.timeout = timeout; + + extensions[name] = ext; + } + + return extensions; +} + +/** + * Converts Goose `extensions:` entries back into rulesync canonical MCP servers. + * + * Goose's schema is a lossy projection of the canonical model, so import + * normalizes rather than perfectly round-trips: Goose has a single `uri` field, + * so both `url` and the Claude-specific `httpUrl` alias come back as `url`; and + * the `streamable_http` type maps back to canonical `http`. These are the + * canonical/preferred forms, so re-generating produces an equivalent config. + */ +function convertFromGooseFormat(extensions: Record): McpServers { + const result: McpServers = {}; + + for (const [name, ext] of Object.entries(extensions)) { + if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(ext)) continue; + + const server: Record = {}; + const type = typeof ext.type === "string" ? ext.type : undefined; + if (type === "sse") { + server.type = "sse"; + } else if (type === "streamable_http") { + server.type = "http"; + } else if (type === "stdio") { + server.type = "stdio"; + } + // `builtin` has no rulesync canonical equivalent, so its type is dropped. + + if (typeof ext.cmd === "string") server.command = ext.cmd; + if (isStringArray(ext.args)) server.args = ext.args; + if (isRecord(ext.envs)) server.env = ext.envs; + if (typeof ext.uri === "string") server.url = ext.uri; + if (isRecord(ext.headers)) server.headers = ext.headers; + if (ext.enabled === false) server.disabled = true; + if (typeof ext.timeout === "number") server.timeout = ext.timeout; + + result[name] = server; + } + + 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. + * + * @see https://block.github.io/goose/docs/getting-started/using-extensions/ + */ +export class GooseMcp extends ToolMcp { + private readonly config: Record; + + constructor(params: ToolMcpParams) { + super(params); + if (this.fileContent !== undefined) { + this.config = parseGooseConfig(this.fileContent, this.relativeDirPath, this.relativeFilePath); + } else { + this.config = {}; + } + } + + getConfig(): Record { + return this.config; + } + + 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; + } + + static getSettablePaths(_options?: { global?: boolean }): ToolMcpSettablePaths { + return { + relativeDirPath: join(".config", "goose"), + relativeFilePath: "config.yaml", + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + 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)) ?? ""; + + return new GooseMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + global, + }); + } + + static async fromRulesyncMcp({ + outputRoot = process.cwd(), + rulesyncMcp, + validate = true, + global = false, + }: ToolMcpFromRulesyncMcpParams): Promise { + if (!global) { + throw new Error(GOOSE_GLOBAL_ONLY_MESSAGE); + } + const paths = this.getSettablePaths({ global }); + + const fileContent = await readOrInitializeFileContent( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + "", + ); + const config = parseGooseConfig(fileContent, paths.relativeDirPath, paths.relativeFilePath); + + // Merge the `extensions:` block into the shared config, preserving other + // keys (model, provider, ...). + const merged = { ...config, extensions: convertToGooseFormat(rulesyncMcp.getMcpServers()) }; + + return new GooseMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: dump(merged), + validate, + global, + }); + } + + toRulesyncMcp(): RulesyncMcp { + const extensions = isRecord(this.config.extensions) ? this.config.extensions : {}; + const mcpServers = convertFromGooseFormat(extensions); + return this.toRulesyncMcpDefault({ + fileContent: JSON.stringify({ mcpServers }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolMcpForDeletionParams): GooseMcp { + return new GooseMcp({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + global, + }); + } +} diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index ed149171f..488394a3f 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -21,6 +21,7 @@ import { DeepagentsMcp } from "./deepagents-mcp.js"; import { DevinMcp } from "./devin-mcp.js"; import { FactorydroidMcp } from "./factorydroid-mcp.js"; import { GeminiCliMcp } from "./geminicli-mcp.js"; +import { GooseMcp } from "./goose-mcp.js"; import { JunieMcp } from "./junie-mcp.js"; import { KiloMcp } from "./kilo-mcp.js"; import { KiroMcp } from "./kiro-mcp.js"; @@ -57,6 +58,7 @@ const mcpProcessorToolTargetTuple = [ "deepagents", "factorydroid", "geminicli", + "goose", "kilo", "kiro", "junie", @@ -279,6 +281,21 @@ 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. + // https://block.github.io/goose/docs/getting-started/using-extensions/ + class: GooseMcp, + meta: { + supportsProject: false, + supportsGlobal: true, + supportsEnabledTools: false, + supportsDisabledTools: false, + }, + }, + ], [ "kilo", {