Skip to content

Commit 2eacb18

Browse files
authored
feat: setup-mcp config targets + codex client (4.2.0) (#40)
## Summary Fixes `setup-mcp` so it writes MCP server config to the files clients actually read, adds Codex support, refreshes dependencies, and cuts **4.2.0**. Net diff vs `main` represents the full 4.2.0 release (rolls up the unreleased 4.1.1 version-prep commit). ## Root Cause / Context `setup-mcp` wrote the `gather-step` block to `.claude/settings.json` under `mcpServers`. Claude Code does **not** read server definitions from `settings.json` (only toggles like `enableAllProjectMcpServers`), so the registered server never appeared in the client — it was silently ignored. The `serve` vs `mcp serve` args were never the problem (both route to the same dispatch); the target file was. ## Key Decisions - **Per-client targets that match each client's real config model:** - Claude `--scope local` → project-scoped `.mcp.json` - Claude `--scope global` → user-scoped `~/.claude.json` - New `--client codex` → `~/.codex/config.toml` (scope ignored; Codex has one global config). Default client stays `claude`. - **`toml_edit` for Codex** so merges preserve existing servers, other keys, and comments, and render the idiomatic `[mcp_servers.gather-step]` section rather than an inline table. - **Dependency bumps are SemVer-compatible only** (no majors); intentionally exact-pinned crates left untouched; `gix` major skipped. ## Files Changed - `crates/gather-step-cli/src/commands/setup_mcp.rs` — `--client` flag, path resolution, JSON + TOML writers - `crates/gather-step-cli/src/commands/init.rs` — pass `client` through auto-setup - `crates/gather-step-cli/tests/cli_setup_mcp.rs` — JSON + Codex TOML coverage - `Cargo.toml` / `crates/*/Cargo.toml` / `Cargo.lock` — `toml_edit` dep, compatible bumps, version 4.2.0 - `website/` — astro 6.4.2, version 4.2.0, changelog entry, landing-page stamps, MCP-clients doc fix ## Test Plan - [x] `cargo test -p gather-step --test cli_setup_mcp` — 8 pass (JSON + TOML writers, merge-preservation, malformed-input) - [x] `cargo build --workspace` clean; `cargo clippy -p gather-step --tests` clean - [x] Functional smoke test: `--scope local` writes `.mcp.json`; `--client codex` writes a clean `[mcp_servers.gather-step]` block - [ ] Full CI suite ## Follow-ups - `release-notes/` is legacy (stops at v3.1.0); canonical changelog is `website/.../changelog.md`. Consider removing the stale dir. - 4.0.6 / 4.1.1 were never tagged; this PR rolls them into 4.2.0. No tag is pushed here — tagging `v4.2.0` will trigger `release.yml`.
2 parents ba4b482 + bb88393 commit 2eacb18

17 files changed

Lines changed: 404 additions & 183 deletions

File tree

Cargo.lock

Lines changed: 142 additions & 126 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ members = [
1414
]
1515

1616
[workspace.package]
17-
version = "4.1.1"
17+
version = "4.2.0"
1818
authors = ["JJ Adonis"]
1919
edition = "2024"
2020
rust-version = "1.94.1"
@@ -24,15 +24,15 @@ homepage = "https://github.com/thedoublejay/gather-step"
2424
description = "High-performance multi-repo codebase intelligence engine"
2525

2626
[workspace.dependencies]
27-
gather-step = { path = "crates/gather-step-cli", version = "4.1.1" }
28-
gather-step-analysis = { path = "crates/gather-step-analysis", version = "4.1.1" }
29-
gather-step-core = { path = "crates/gather-step-core", version = "4.1.1" }
30-
gather-step-deploy = { path = "crates/gather-step-deploy", version = "4.1.1" }
31-
gather-step-git = { path = "crates/gather-step-git", version = "4.1.1" }
32-
gather-step-mcp = { path = "crates/gather-step-mcp", version = "4.1.1" }
33-
gather-step-output = { path = "crates/gather-step-output", version = "4.1.1" }
34-
gather-step-parser = { path = "crates/gather-step-parser", version = "4.1.1" }
35-
gather-step-storage = { path = "crates/gather-step-storage", version = "4.1.1" }
27+
gather-step = { path = "crates/gather-step-cli", version = "4.2.0" }
28+
gather-step-analysis = { path = "crates/gather-step-analysis", version = "4.2.0" }
29+
gather-step-core = { path = "crates/gather-step-core", version = "4.2.0" }
30+
gather-step-deploy = { path = "crates/gather-step-deploy", version = "4.2.0" }
31+
gather-step-git = { path = "crates/gather-step-git", version = "4.2.0" }
32+
gather-step-mcp = { path = "crates/gather-step-mcp", version = "4.2.0" }
33+
gather-step-output = { path = "crates/gather-step-output", version = "4.2.0" }
34+
gather-step-parser = { path = "crates/gather-step-parser", version = "4.2.0" }
35+
gather-step-storage = { path = "crates/gather-step-storage", version = "4.2.0" }
3636

3737
tree-sitter = "=0.26.8"
3838
tree-sitter-typescript = "0.23.2"
@@ -56,9 +56,10 @@ console = "0.16.3"
5656
comfy-table = "7.2.2"
5757
chrono = { version = "0.4.44", features = ["serde"] }
5858
serde = { version = "1.0.228", features = ["derive"] }
59-
serde_json = "1.0.149"
59+
serde_json = "1.0.150"
6060
serde_norway = "0.9.42"
6161
toml = "1.1.2"
62+
toml_edit = "0.25.12"
6263
bitcode = "0.6.9"
6364
blake3 = "1.8.5"
6465
dirs = "6"
@@ -69,19 +70,19 @@ globset = "0.4.18"
6970
rustc-hash = "2.1.2"
7071
hashbrown = "=0.17.0"
7172
parking_lot = "=0.12.5"
72-
tokio = { version = "1.52.2", features = ["macros", "rt-multi-thread", "sync", "time", "signal", "net", "io-util"] }
73+
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "sync", "time", "signal", "net", "io-util"] }
7374
tokio-util = "0.7.18"
7475
notify = "=9.0.0-rc.4"
7576
gix = { version = "0.83", default-features = true }
7677
regex = "1.12.3"
7778
simdutf8 = "=0.1.5"
78-
similar = "3.1.0"
79+
similar = "3.1.1"
7980
lru = "=0.18.0"
80-
quick_cache = "0.6.21"
81+
quick_cache = "0.6.22"
8182
smallvec = { version = "=1.15.1", features = ["serde"] }
8283
camino = "1.2.2"
8384
aho-corasick = "1.1.4"
84-
memchr = "2.8.0"
85+
memchr = "2.8.1"
8586
libc = "0.2"
8687
dhat = "=0.3.3"
8788
regex-automata = { version = "0.4.14", default-features = false, features = ["std", "syntax", "meta", "nfa", "dfa", "hybrid", "perf", "unicode"] }

crates/gather-step-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ serde.workspace = true
4646
thiserror.workspace = true
4747
serde_json.workspace = true
4848
serde_norway.workspace = true
49+
toml_edit.workspace = true
4950
tokio.workspace = true
5051
tokio-util.workspace = true
5152
tracing.workspace = true

crates/gather-step-cli/src/commands/init.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ async fn run_non_interactive(app: &AppContext, args: InitArgs) -> Result<()> {
107107
generate::run_summary_pair(app)?;
108108
}
109109
if let Some(scope) = args.setup_mcp {
110-
setup_mcp::run(app, setup_mcp::SetupMcpArgs { scope })?;
110+
setup_mcp::run(
111+
app,
112+
setup_mcp::SetupMcpArgs {
113+
client: setup_mcp::McpClient::Claude,
114+
scope,
115+
},
116+
)?;
111117
}
112118
if args.watch && !args.no_watch {
113119
emit_setup_complete(&output);
@@ -193,7 +199,13 @@ async fn run_wizard(app: &AppContext, args: InitArgs) -> Result<()> {
193199
generate::run_summary_pair(app)?;
194200
}
195201
if let Some(scope) = scope {
196-
setup_mcp::run(app, setup_mcp::SetupMcpArgs { scope })?;
202+
setup_mcp::run(
203+
app,
204+
setup_mcp::SetupMcpArgs {
205+
client: setup_mcp::McpClient::Claude,
206+
scope,
207+
},
208+
)?;
197209
}
198210
emit_setup_complete(&output);
199211
if do_watch {

crates/gather-step-cli/src/commands/setup_mcp.rs

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use anyhow::{Context, Result};
77
use clap::{Args, ValueEnum};
88
use serde::Serialize;
99
use serde_json::{Map, Value, json};
10+
use toml_edit::{Array, DocumentMut, Item, Table, value};
1011

1112
use crate::app::AppContext;
1213

@@ -17,15 +18,27 @@ pub enum McpScope {
1718
Local,
1819
}
1920

21+
#[derive(Debug, Clone, Copy, ValueEnum, Serialize)]
22+
#[serde(rename_all = "kebab-case")]
23+
pub enum McpClient {
24+
Claude,
25+
Codex,
26+
}
27+
2028
#[derive(Debug, Clone, Copy, Args)]
2129
pub struct SetupMcpArgs {
30+
/// MCP client to configure.
31+
#[arg(long, value_enum, default_value = "claude")]
32+
pub client: McpClient,
33+
/// Configuration scope. Ignored for Codex, whose config is always global.
2234
#[arg(long, value_enum, default_value = "local")]
2335
pub scope: McpScope,
2436
}
2537

2638
#[derive(Debug, Serialize)]
2739
struct SetupMcpOutput {
2840
event: &'static str,
41+
client: McpClient,
2942
scope: McpScope,
3043
settings_path: String,
3144
path_resolution: PathResolution,
@@ -41,22 +54,22 @@ enum PathResolution {
4154
}
4255

4356
pub fn run(app: &AppContext, args: SetupMcpArgs) -> Result<()> {
44-
let settings_path = match args.scope {
45-
McpScope::Local => app.workspace_path.join(".claude/settings.json"),
46-
McpScope::Global => home_dir()
47-
.context("cannot resolve HOME")?
48-
.join(".claude/settings.json"),
49-
};
57+
let settings_path = resolve_settings_path(args.client, args.scope, &app.workspace_path)?;
5058
let command_path = find_command_on_path("gather-step");
5159
let path_resolution = if command_path.is_some() {
5260
PathResolution::Ok
5361
} else {
5462
PathResolution::NotFound
5563
};
56-
write_settings(&settings_path, &app.workspace_path)?;
64+
65+
match args.client {
66+
McpClient::Claude => write_settings(&settings_path, &app.workspace_path)?,
67+
McpClient::Codex => write_codex_config(&settings_path, &app.workspace_path)?,
68+
}
5769

5870
let payload = SetupMcpOutput {
5971
event: "setup_mcp_completed",
72+
client: args.client,
6073
scope: args.scope,
6174
settings_path: settings_path.display().to_string(),
6275
path_resolution,
@@ -73,6 +86,28 @@ pub fn run(app: &AppContext, args: SetupMcpArgs) -> Result<()> {
7386
Ok(())
7487
}
7588

89+
/// Resolve the config file the chosen client actually reads MCP server
90+
/// definitions from.
91+
///
92+
/// Claude Code does not read `mcpServers` out of `settings.json`: project scope
93+
/// lives in `.mcp.json` at the workspace root and user scope in `~/.claude.json`.
94+
/// Codex reads a single global `~/.codex/config.toml`, so scope does not apply.
95+
fn resolve_settings_path(client: McpClient, scope: McpScope, workspace: &Path) -> Result<PathBuf> {
96+
match client {
97+
McpClient::Claude => match scope {
98+
McpScope::Local => Ok(workspace.join(".mcp.json")),
99+
McpScope::Global => Ok(home_dir()
100+
.context("cannot resolve HOME")?
101+
.join(".claude.json")),
102+
},
103+
McpClient::Codex => Ok(home_dir()
104+
.context("cannot resolve HOME")?
105+
.join(".codex/config.toml")),
106+
}
107+
}
108+
109+
/// Merge a workspace-pinned `gather-step` entry into a JSON `mcpServers` map,
110+
/// preserving every other key. Used for Claude's `.mcp.json` and `~/.claude.json`.
76111
pub fn write_settings(path: &Path, workspace: &Path) -> Result<()> {
77112
if let Some(parent) = path.parent() {
78113
std::fs::create_dir_all(parent)
@@ -112,6 +147,51 @@ pub fn write_settings(path: &Path, workspace: &Path) -> Result<()> {
112147
Ok(())
113148
}
114149

150+
/// Merge a workspace-pinned `gather-step` entry into a Codex `config.toml`,
151+
/// preserving existing servers, other tables, comments, and formatting.
152+
pub fn write_codex_config(path: &Path, workspace: &Path) -> Result<()> {
153+
if let Some(parent) = path.parent() {
154+
std::fs::create_dir_all(parent)
155+
.with_context(|| format!("creating {}", parent.display()))?;
156+
}
157+
158+
let mut doc = if path.exists() {
159+
let body =
160+
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
161+
body.parse::<DocumentMut>()
162+
.with_context(|| format!("parsing {}", path.display()))?
163+
} else {
164+
DocumentMut::new()
165+
};
166+
167+
let workspace_str = workspace
168+
.to_str()
169+
.context("workspace path is not valid UTF-8")?;
170+
let mut args = Array::new();
171+
args.push("--workspace");
172+
args.push(workspace_str);
173+
args.push("serve");
174+
175+
let mut server = Table::new();
176+
server.insert("command", value("gather-step"));
177+
server.insert("args", value(args));
178+
179+
// Keep `mcp_servers` an implicit table so the entry renders as the
180+
// idiomatic `[mcp_servers.gather-step]` section rather than an inline table.
181+
if doc.get("mcp_servers").is_none() {
182+
let mut servers = Table::new();
183+
servers.set_implicit(true);
184+
doc.insert("mcp_servers", Item::Table(servers));
185+
}
186+
let servers = doc["mcp_servers"]
187+
.as_table_mut()
188+
.context("mcp_servers is not a table")?;
189+
servers.insert("gather-step", Item::Table(server));
190+
191+
std::fs::write(path, doc.to_string()).with_context(|| format!("writing {}", path.display()))?;
192+
Ok(())
193+
}
194+
115195
fn home_dir() -> Option<PathBuf> {
116196
env::var_os("HOME").map(PathBuf::from)
117197
}

crates/gather-step-cli/src/commands/status.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,13 @@ fn mcp_state(app: &AppContext) -> &'static str {
110110
}
111111

112112
fn mcp_state_with_home(app: &AppContext, home: Option<&std::ffi::OsStr>) -> &'static str {
113-
let local = app.workspace_path.join(".claude/settings.json");
113+
let local = app.workspace_path.join(".mcp.json");
114114
if json_has_gather_step(&local) {
115115
return "configured: local";
116116
}
117117

118118
if let Some(home) = home {
119-
let global = std::path::PathBuf::from(home).join(".claude/settings.json");
119+
let global = std::path::PathBuf::from(home).join(".claude.json");
120120
if json_has_gather_step(&global) {
121121
return "configured: global";
122122
}
@@ -418,7 +418,7 @@ mod tests {
418418
#[test]
419419
fn mcp_state_reports_local_configuration_first() {
420420
let temp = tempfile::tempdir().expect("temp dir");
421-
write_settings(&temp.path().join(".claude/settings.json"));
421+
write_settings(&temp.path().join(".mcp.json"));
422422

423423
assert_eq!(
424424
mcp_state_with_home(&app_for(temp.path()), None),
@@ -430,7 +430,7 @@ mod tests {
430430
fn mcp_state_reports_global_configuration() {
431431
let workspace = tempfile::tempdir().expect("workspace");
432432
let home = tempfile::tempdir().expect("home");
433-
write_settings(&home.path().join(".claude/settings.json"));
433+
write_settings(&home.path().join(".claude.json"));
434434

435435
assert_eq!(
436436
mcp_state_with_home(&app_for(workspace.path()), Some(home.path().as_os_str())),

crates/gather-step-cli/tests/cli_commands.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ fn setup_mcp_local_writes_workspace_settings() {
119119

120120
let output = run_ok(temp.path(), &["setup-mcp", "--scope", "local"]);
121121
let stdout = String::from_utf8_lossy(&output.stdout);
122-
let settings_path = temp.path().join(".claude/settings.json");
122+
let settings_path = temp.path().join(".mcp.json");
123123
let settings = fs::read_to_string(&settings_path).expect("settings file should be written");
124124
let value: Value = serde_json::from_str(&settings).expect("settings json");
125125

@@ -174,7 +174,7 @@ fn setup_mcp_json_reports_missing_path_resolution() {
174174
}
175175

176176
#[test]
177-
fn setup_mcp_global_writes_home_claude_settings() {
177+
fn setup_mcp_global_writes_home_claude_json() {
178178
let workspace = TempDir::new("setup-mcp-global-workspace");
179179
let home = TempDir::new("setup-mcp-global-home");
180180

@@ -194,7 +194,7 @@ fn setup_mcp_global_writes_home_claude_settings() {
194194
String::from_utf8_lossy(&output.stdout),
195195
String::from_utf8_lossy(&output.stderr)
196196
);
197-
let settings_path = home.path().join(".claude/settings.json");
197+
let settings_path = home.path().join(".claude.json");
198198
let settings = fs::read_to_string(&settings_path).expect("settings file should be written");
199199
let value: Value = serde_json::from_str(&settings).expect("settings json");
200200
assert_eq!(value["mcpServers"]["gather-step"]["command"], "gather-step");

0 commit comments

Comments
 (0)