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
37 changes: 37 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,43 @@ You can control which individual tools from an MCP server are enabled or disable
- `enabledTools`: An array of tool names that should be explicitly enabled for this server.
- `disabledTools`: An array of tool names that should be explicitly disabled for this server.

### Codex-specific: pass shell env vars to MCP servers (`envVars`)

Codex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format.

This is distinct from `env` (which is a literal `{name: value}` map) — `envVars` is a list of names whose **values come from the user's environment at runtime**. Both fields may coexist on the same server.

```json
{
"mcpServers": {
"pal": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/BeehiveInnovations/pal-mcp-server.git",
"pal-mcp-server"
],
"envVars": ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
}
}
}
```

Generated `~/.codex/config.toml`:

```toml
[mcp_servers.pal]
type = "stdio"
command = "uvx"
args = ["--from", "git+https://github.com/BeehiveInnovations/pal-mcp-server.git", "pal-mcp-server"]
env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
```

- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).
- 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 codex documentation for the exact resolution rule.

## `.rulesync/.aiignore` or `.rulesyncignore`

Rulesync supports a single ignore list that can live in either location below:
Expand Down
37 changes: 37 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,43 @@ You can control which individual tools from an MCP server are enabled or disable
- `enabledTools`: An array of tool names that should be explicitly enabled for this server.
- `disabledTools`: An array of tool names that should be explicitly disabled for this server.

### Codex-specific: pass shell env vars to MCP servers (`envVars`)

Codex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format.

This is distinct from `env` (which is a literal `{name: value}` map) — `envVars` is a list of names whose **values come from the user's environment at runtime**. Both fields may coexist on the same server.

```json
{
"mcpServers": {
"pal": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/BeehiveInnovations/pal-mcp-server.git",
"pal-mcp-server"
],
"envVars": ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
}
}
}
```

Generated `~/.codex/config.toml`:

```toml
[mcp_servers.pal]
type = "stdio"
command = "uvx"
args = ["--from", "git+https://github.com/BeehiveInnovations/pal-mcp-server.git", "pal-mcp-server"]
env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
```

- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).
- 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 codex documentation for the exact resolution rule.

## `.rulesync/.aiignore` or `.rulesyncignore`

Rulesync supports a single ignore list that can live in either location below:
Expand Down
13 changes: 12 additions & 1 deletion src/features/mcp/cline-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,22 @@ export class ClineMcp extends ToolMcp {
rulesyncMcp,
validate = true,
}: ToolMcpFromRulesyncMcpParams): ClineMcp {
// Preserve top-level fields ($schema, config, etc.) from the source
// JSON, but use getMcpServers() (not getJson().mcpServers) so
// rulesync-only fields and codex-only fields (`envVars`) are stripped
// before writing the cline config.
const json = rulesyncMcp.getJson();
const fileContent = JSON.stringify(
{ ...json, mcpServers: rulesyncMcp.getMcpServers() },
null,
2,
);

return new ClineMcp({
outputRoot,
relativeDirPath: this.getSettablePaths().relativeDirPath,
relativeFilePath: this.getSettablePaths().relativeFilePath,
fileContent: rulesyncMcp.getFileContent(),
fileContent,
validate,
});
}
Expand Down
96 changes: 96 additions & 0 deletions src/features/mcp/codexcli-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,102 @@ fontSize = 14
expect(mcpServers["my-server"].disabled_tools).toEqual(["delete"]);
});

it("should rename source envVars (camelCase) to codex env_vars (snake_case)", async () => {
// The source schema uses `envVars` (camelCase) for consistency with
// `enabledTools`/`disabledTools`. The codex generator renames it to
// `env_vars` (snake_case) to match codex's native config.toml format.
// Source:
// "pal": { ..., "envVars": ["OPENAI_API_KEY", "JIRA_PERSONAL_TOKEN"] }
// Output:
// [mcp_servers.pal]
// env_vars = ["OPENAI_API_KEY", "JIRA_PERSONAL_TOKEN"]
const jsonData = {
mcpServers: {
pal: {
type: "stdio",
command: "uvx",
args: ["pal-mcp-server"],
envVars: ["OPENAI_API_KEY", "JIRA_PERSONAL_TOKEN"],
},
},
};
const rulesyncMcp = new RulesyncMcp({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: ".mcp.json",
fileContent: JSON.stringify(jsonData),
});

const codexcliMcp = await CodexcliMcp.fromRulesyncMcp({
outputRoot: testDir,
rulesyncMcp,
global: true,
});

const mcpServers = codexcliMcp.getToml().mcp_servers as any;
// Output uses snake_case (codex native).
expect(mcpServers.pal.env_vars).toEqual(["OPENAI_API_KEY", "JIRA_PERSONAL_TOKEN"]);
// Source key (camelCase) must NOT appear in codex output.
expect(mcpServers.pal.envVars).toBeUndefined();
// Defensive: other fields survive.
expect(mcpServers.pal.command).toBe("uvx");
expect(mcpServers.pal.args).toEqual(["pal-mcp-server"]);
});

it("should coexist envVars and env on the same server", async () => {
// `envVars` (list of names inherited from shell) and `env` (literal
// name→value map) are distinct concepts. Both must serialize correctly
// on the same server.
const jsonData = {
mcpServers: {
pal: {
type: "stdio",
command: "uvx",
args: ["pal-mcp-server"],
envVars: ["OPENAI_API_KEY"],
env: { LOG_LEVEL: "debug" },
},
},
};
const rulesyncMcp = new RulesyncMcp({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: ".mcp.json",
fileContent: JSON.stringify(jsonData),
});

const codexcliMcp = await CodexcliMcp.fromRulesyncMcp({
outputRoot: testDir,
rulesyncMcp,
global: true,
});

const server = (codexcliMcp.getToml().mcp_servers as any).pal;
expect(server.env_vars).toEqual(["OPENAI_API_KEY"]);
expect(server.env).toEqual({ LOG_LEVEL: "debug" });
});

it("should round-trip envVars through codex import", async () => {
// codex config.toml → toRulesyncMcp() → rulesync representation must
// expose `envVars` in source schema form (camelCase).
const tomlContent = `[mcp_servers.pal]
type = "stdio"
command = "uvx"
args = ["pal-mcp-server"]
env_vars = ["OPENAI_API_KEY"]
`;
const codexcliMcp = new CodexcliMcp({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: tomlContent,
});

const rulesyncMcp = codexcliMcp.toRulesyncMcp();
const json = JSON.parse(rulesyncMcp.getFileContent());

expect(json.mcpServers.pal.envVars).toEqual(["OPENAI_API_KEY"]);
expect(json.mcpServers.pal.env_vars).toBeUndefined();
});

it("should convert enabledTools/disabledTools for multiple servers", async () => {
const jsonData = {
mcpServers: {
Expand Down
11 changes: 11 additions & 0 deletions src/features/mcp/codexcli-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ function convertFromCodexFormat(codexMcp: Record<string, unknown>): McpServers {
converted["enabledTools"] = value;
} else if (key === "disabled_tools") {
converted["disabledTools"] = value;
} else if (key === "env_vars") {
// codex stores env-var passthrough names in snake_case (`env_vars`);
// the rulesync source schema uses camelCase (`envVars`) for
// consistency with `enabledTools`/`disabledTools`/etc.
converted["envVars"] = value;
} else {
converted[key] = value;
}
Expand All @@ -58,6 +63,12 @@ function convertToCodexFormat(mcpServers: McpServers): Record<string, unknown> {
converted["enabled_tools"] = value;
} else if (key === "disabledTools") {
converted["disabled_tools"] = value;
} else if (key === "envVars") {
// Rename camelCase source `envVars` → snake_case `env_vars`
// for codex's native config.toml format. See `enabledTools`
// precedent above. `envVars` itself is stripped from
// getMcpServers() so non-codex tools never receive it.
converted["env_vars"] = value;
} else {
converted[key] = value;
}
Expand Down
23 changes: 21 additions & 2 deletions src/features/mcp/cursor-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,9 +954,28 @@ describe("CursorMcp", () => {
validate: true,
});

expect(cursorMcp.getJson()).toEqual({
mcpServers: rulesyncMcpData.mcpServers,
// `targets` is a rulesync-only field and is stripped from non-codex
// tool outputs by `RulesyncMcp.getMcpServers()`. The cursor output
// must contain everything else but not `targets`.
const json = cursorMcp.getJson() as any;
expect(json.mcpServers["complex-server"]).toEqual({
command: "node",
args: ["complex-server.js", "--port", "3000"],
env: {
NODE_ENV: "production",
DEBUG: "mcp:*",
},
});
expect(json.mcpServers["python-server"]).toEqual({
command: "python",
args: ["python-server.py"],
env: {
PYTHONPATH: "/usr/local/lib/python3.9/site-packages",
},
});
// `targets` should be stripped.
expect(json.mcpServers["complex-server"].targets).toBeUndefined();
expect(json.mcpServers["python-server"].targets).toBeUndefined();
});

it("should use custom outputRoot when provided", async () => {
Expand Down
6 changes: 4 additions & 2 deletions src/features/mcp/cursor-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ export class CursorMcp extends ToolMcp {
);
}

const rulesyncJson = rulesyncMcp.getJson();
const mcpServers = isMcpServers(rulesyncJson.mcpServers) ? rulesyncJson.mcpServers : {};
// Use getMcpServers() (not getJson()) so rulesync-only fields and
// codex-only fields (`envVars`) are stripped before writing the
// cursor config.
const mcpServers = rulesyncMcp.getMcpServers();
const transformedServers = convertEnvToCursorFormat(mcpServers);

const cursorConfig = { ...json, mcpServers: transformedServers };
Expand Down
7 changes: 5 additions & 2 deletions src/features/mcp/factorydroid-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@ export class FactorydroidMcp extends ToolMcp {
rulesyncMcp,
validate = true,
}: ToolMcpFromRulesyncMcpParams): FactorydroidMcp {
const json = rulesyncMcp.getJson();
// Use getMcpServers() (not getJson()) so rulesync-only fields and
// codex-only fields (`envVars`) are stripped before writing the
// Factory Droid config.
const mcpServers = rulesyncMcp.getMcpServers();

// Factory Droid uses standard MCP format without transformations
const factorydroidConfig = {
mcpServers: json.mcpServers || {},
mcpServers,
};

const fileContent = JSON.stringify(factorydroidConfig, null, 2);
Expand Down
35 changes: 35 additions & 0 deletions src/features/mcp/geminicli-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,41 @@ describe("GeminiCliMcp", () => {
});

describe("fromRulesyncMcp", () => {
it("should strip codex-only envVars from gemini output", async () => {
// Regression test: prior to migrating fromRulesyncMcp to
// `getMcpServers()`, the gemini generator read mcpServers from
// `rulesyncMcp.getJson()` (unfiltered), causing codex-only fields like
// `envVars` to leak into ~/.gemini/settings.json. Strip must apply
// here so the field is absent from gemini output.
const jsonData = {
mcpServers: {
pal: {
type: "stdio",
command: "uvx",
args: ["pal-mcp-server"],
envVars: ["OPENAI_API_KEY"],
},
},
};
const rulesyncMcp = new RulesyncMcp({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: ".mcp.json",
fileContent: JSON.stringify(jsonData),
});

const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({
outputRoot: testDir,
rulesyncMcp,
});

const json = geminiCliMcp.getJson() as any;
expect(json.mcpServers.pal).toBeDefined();
expect(json.mcpServers.pal.envVars).toBeUndefined();
// Defensive: other fields survive.
expect(json.mcpServers.pal.command).toBe("uvx");
expect(json.mcpServers.pal.args).toEqual(["pal-mcp-server"]);
});

it("should create instance from RulesyncMcp with default parameters", async () => {
const jsonData = {
mcpServers: {
Expand Down
5 changes: 4 additions & 1 deletion src/features/mcp/geminicli-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ export class GeminiCliMcp extends ToolMcp {
JSON.stringify({ mcpServers: {} }, null, 2),
);
const json = JSON.parse(fileContent);
const newJson = { ...json, mcpServers: rulesyncMcp.getJson().mcpServers };
// Use getMcpServers() (not getJson()) so rulesync-only fields and
// codex-only fields (`envVars`) are stripped before writing the
// gemini settings file.
const newJson = { ...json, mcpServers: rulesyncMcp.getMcpServers() };

return new GeminiCliMcp({
outputRoot,
Expand Down
13 changes: 12 additions & 1 deletion src/features/mcp/junie-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,22 @@ export class JunieMcp extends ToolMcp {
rulesyncMcp,
validate = true,
}: ToolMcpFromRulesyncMcpParams): JunieMcp {
// Preserve top-level fields ($schema, etc.) from the source JSON, but
// use getMcpServers() (not getJson().mcpServers) so rulesync-only
// fields and codex-only fields (`envVars`) are stripped before
// writing the junie config.
const json = rulesyncMcp.getJson();
const fileContent = JSON.stringify(
{ ...json, mcpServers: rulesyncMcp.getMcpServers() },
null,
2,
);

return new JunieMcp({
outputRoot,
relativeDirPath: this.getSettablePaths().relativeDirPath,
relativeFilePath: this.getSettablePaths().relativeFilePath,
fileContent: rulesyncMcp.getFileContent(),
fileContent,
validate,
});
}
Expand Down
6 changes: 4 additions & 2 deletions src/features/mcp/rovodev-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ export class RovodevMcp extends ToolMcp {
);
const json = parseRovodevMcpJson(fileContent, paths.relativeDirPath, paths.relativeFilePath);

const rulesyncJson = rulesyncMcp.getJson();
const mcpServers = isMcpServers(rulesyncJson.mcpServers) ? rulesyncJson.mcpServers : {};
// Use getMcpServers() (not getJson()) so rulesync-only fields and
// codex-only fields (`envVars`) are stripped before writing the
// rovodev config.
const mcpServers = rulesyncMcp.getMcpServers();

const rovodevConfig = { ...json, mcpServers };

Expand Down
Loading
Loading