Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
4 changes: 4 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ target: "goose", feature: "ignore", entry: "**/.gooseignore" },
// Goose lifecycle hooks plugin (.agents/plugins/<name>/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
{
Expand Down
1 change: 1 addition & 0 deletions src/e2e/e2e-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down
235 changes: 235 additions & 0 deletions src/features/mcp/goose-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>> {
const parsed = load(content);
if (!isRecord(parsed) || !isRecord(parsed.extensions)) return {};
return parsed.extensions as Record<string, Record<string, unknown>>;
}

describe("GooseMcp", () => {
let testDir: string;
let cleanup: () => Promise<void>;

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);
});
});
});
Loading
Loading