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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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.<name>.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 `<project>/.agents/plugins/<name>/.mcp.json` (and `~/.agents/plugins/<name>/.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": { "<name>": { "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

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 @@ -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 | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
9 changes: 7 additions & 2 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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.<name>.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 `<project>/.agents/plugins/<name>/.mcp.json` (and `~/.agents/plugins/<name>/.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": { "<name>": { "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

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 @@ -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 | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
11 changes: 11 additions & 0 deletions src/constants/goose-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>/.agents/plugins/<name>/.mcp.json` and user scope
// `~/.agents/plugins/<name>/.mcp.json`, using the Claude-style manifest shape
// `{ "mcpServers": { "<name>": { 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/<name>/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
Expand Down
2 changes: 2 additions & 0 deletions src/e2e/e2e-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down Expand Up @@ -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 }) => {
Expand Down
145 changes: 133 additions & 12 deletions src/features/mcp/goose-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>> {
const parsed = load(content);
Expand All @@ -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"] });
});
});

Expand Down
Loading
Loading