refactor(v0.19): extract adapters/jsonmcp.rs shared MCP entry merge/remove

v0.19 agent's additional factorization that wasn't captured in the
initial branch commit. Extracts shared merge/remove-named helpers
for claude-code/cursor/zed into adapters/jsonmcp.rs (70 LOC). 3
adapters simplify significantly (-65/-68/-102 LOC each).

Also: #[allow(dead_code)] on Error::AdapterFailed (surfaced by
mount/detach orchestration; reserved for library consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-22 16:35:02 +08:00
parent d39abf1914
commit fa253d04cc
8 changed files with 122 additions and 250 deletions

View file

@ -1,19 +1,19 @@
//! Claude Code adapter — writes MCP server entry into
//! `~/.claude/settings.json`. Config shape merges under
//! `mcpServers.keisei` so we never clobber unrelated entries.
//! `~/.claude/settings.json` under `mcpServers.keisei`.
//!
//! Detection: `$CWD/.claude/settings.json` exists OR
//! Detection: `$CWD/.claude/settings.json` is a file OR
//! `$KEISEI_HOME/.claude` (or `$HOME/.claude`) is a directory.
//! `$KEISEI_HOME` overrides `$HOME` for tests.
use crate::adapter::ClientAdapter;
use crate::adapters::jsonmcp::{keisei_entry, load_json_or_empty, merge_named, remove_named};
use crate::brain::Brain;
use crate::error::Result;
use crate::fsx::write_atomic;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
pub const MCP_ENTRY_KEY: &str = "keisei";
pub const PARENT_KEY: &str = "mcpServers";
pub const ENTRY_KEY: &str = "keisei";
pub struct ClaudeCodeAdapter;
@ -56,7 +56,7 @@ impl ClientAdapter for ClaudeCodeAdapter {
std::fs::create_dir_all(parent)?;
}
let mut doc = load_json_or_empty(&cfg)?;
merge_mcp_entry(&mut doc, brain);
merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain));
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
@ -66,7 +66,7 @@ impl ClientAdapter for ClaudeCodeAdapter {
return Ok(());
}
let mut doc = load_json_or_empty(&cfg)?;
remove_mcp_entry(&mut doc);
remove_named(&mut doc, PARENT_KEY, ENTRY_KEY);
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
@ -74,54 +74,3 @@ impl ClientAdapter for ClaudeCodeAdapter {
self.user_config_dir().join("settings.json")
}
}
fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
if !cfg.is_file() {
return Ok(json!({}));
}
let raw = std::fs::read_to_string(cfg)?;
if raw.trim().is_empty() {
return Ok(json!({}));
}
Ok(serde_json::from_str(&raw)?)
}
fn merge_mcp_entry(doc: &mut Value, brain: &Brain) {
if !doc.is_object() {
*doc = json!({});
}
let obj = doc.as_object_mut().expect("doc is object after guard");
let servers = obj
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let entry = json!({
"command": brain.mcp_server_path().to_string_lossy(),
"args": [],
"env": {
"KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(),
"KEISEI_BRAIN_NAME": brain.name(),
}
});
servers
.as_object_mut()
.expect("servers is object")
.insert(MCP_ENTRY_KEY.to_string(), entry);
}
fn remove_mcp_entry(doc: &mut Value) {
if !doc.is_object() {
return;
}
let obj = doc.as_object_mut().expect("doc is object after guard");
if let Some(servers) = obj.get_mut("mcpServers") {
if let Some(map) = servers.as_object_mut() {
map.remove(MCP_ENTRY_KEY);
if map.is_empty() {
obj.remove("mcpServers");
}
}
}
}

View file

@ -1,27 +1,14 @@
//! Continue.dev adapter — writes MCP server entry into `~/.continue/`.
//!
//! Config path strategy [UNVERIFIED — see note]:
//! 1. If `~/.continue/config.yaml` exists → YAML mode
//! 2. Else if `~/.continue/config.json` exists → JSON mode
//! 3. Else if `~/.continue/` exists → create `config.yaml` fresh
//! 4. Else `detect()` returns false (graceful)
//!
//! Schema (both forms), under top-level `mcpServers`:
//! Picks YAML if `config.yaml` exists, JSON if `config.json` exists,
//! else defaults to fresh `config.yaml`. Detection fires on `~/.continue/`
//! dir. Schema [UNVERIFIED — list-form under `mcpServers`]:
//! ```yaml
//! mcpServers:
//! - name: keisei
//! command: /path/to/kei-mcp-server
//! command: ...
//! args: []
//! env:
//! KEISEI_BRAIN_ROOT: /Volumes/Brain1
//! ```
//!
//! NOTE: Continue's exact MCP/plugin schema is [UNVERIFIED] in this
//! session. The adapter uses a list-form `mcpServers` which matches the
//! v0.18 prototypes and the Continue `config.yaml` conventions observed
//! in the public docs. If the live schema diverges, update this module.
//!
//! Detach preserves all other config keys and all non-keisei servers.
use crate::adapter::ClientAdapter;
use crate::brain::Brain;
@ -63,7 +50,6 @@ impl ContinueAdapter {
} else if json.is_file() {
(Form::Json, json)
} else {
// Default for a fresh install: YAML (Continue's preferred form).
(Form::Yaml, yaml)
}
}
@ -109,9 +95,6 @@ impl ClientAdapter for ContinueAdapter {
}
}
/// Load doc as a generic `serde_json::Value`. YAML → Value via serde_yaml,
/// JSON → Value directly. Unifying on `Value` keeps the merge logic form-
/// independent.
fn load_doc(cfg: &std::path::Path, form: Form) -> Result<Value> {
if !cfg.is_file() {
return Ok(json!({}));
@ -120,14 +103,13 @@ fn load_doc(cfg: &std::path::Path, form: Form) -> Result<Value> {
if raw.trim().is_empty() {
return Ok(json!({}));
}
let parsed: Value = match form {
match form {
Form::Yaml => serde_yaml::from_str(&raw).map_err(|e| Error::ConfigParseError {
path: cfg.to_path_buf(),
reason: e.to_string(),
})?,
Form::Json => serde_json::from_str(&raw)?,
};
Ok(parsed)
}),
Form::Json => Ok(serde_json::from_str(&raw)?),
}
}
fn write_doc(cfg: &std::path::Path, form: Form, doc: &Value) -> Result<()> {

View file

@ -1,18 +1,19 @@
//! Cursor adapter — writes MCP server entry to Cursor's MCP config.
//! Cursor adapter — writes MCP entry under `mcpServers.keisei`.
//!
//! Config path: `$CWD/.cursor/mcp.json` if the project-local `.cursor/`
//! dir exists, else `~/.cursor/mcp.json`. Detection fires if either dir
//! exists. Schema [UNVERIFIED — matches Claude Desktop MCP convention]:
//! Path: `$CWD/.cursor/mcp.json` if the project-local `.cursor/` exists,
//! else `~/.cursor/mcp.json`. Detection fires on either dir. Schema
//! [UNVERIFIED — matches Claude Desktop MCP convention]:
//! `{ "mcpServers": { "keisei": { "command": "...", "args": [] } } }`.
use crate::adapter::ClientAdapter;
use crate::adapters::jsonmcp::{keisei_entry, load_json_or_empty, merge_named, remove_named};
use crate::brain::Brain;
use crate::error::Result;
use crate::fsx::write_atomic;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
pub const MCP_ENTRY_KEY: &str = "keisei";
pub const PARENT_KEY: &str = "mcpServers";
pub const ENTRY_KEY: &str = "keisei";
pub struct CursorAdapter;
@ -69,7 +70,7 @@ impl ClientAdapter for CursorAdapter {
std::fs::create_dir_all(parent)?;
}
let mut doc = load_json_or_empty(&cfg)?;
merge_entry(&mut doc, brain);
merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain));
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
@ -79,7 +80,7 @@ impl ClientAdapter for CursorAdapter {
return Ok(());
}
let mut doc = load_json_or_empty(&cfg)?;
remove_entry(&mut doc);
remove_named(&mut doc, PARENT_KEY, ENTRY_KEY);
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
@ -87,54 +88,3 @@ impl ClientAdapter for CursorAdapter {
self.pick_config_path()
}
}
fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
if !cfg.is_file() {
return Ok(json!({}));
}
let raw = std::fs::read_to_string(cfg)?;
if raw.trim().is_empty() {
return Ok(json!({}));
}
Ok(serde_json::from_str(&raw)?)
}
fn merge_entry(doc: &mut Value, brain: &Brain) {
if !doc.is_object() {
*doc = json!({});
}
let obj = doc.as_object_mut().expect("doc is object after guard");
let servers = obj
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let entry = json!({
"command": brain.mcp_server_path().to_string_lossy(),
"args": [],
"env": {
"KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(),
"KEISEI_BRAIN_NAME": brain.name(),
}
});
servers
.as_object_mut()
.expect("servers is object")
.insert(MCP_ENTRY_KEY.to_string(), entry);
}
fn remove_entry(doc: &mut Value) {
if !doc.is_object() {
return;
}
let obj = doc.as_object_mut().expect("doc is object after guard");
if let Some(servers) = obj.get_mut("mcpServers") {
if let Some(map) = servers.as_object_mut() {
map.remove(MCP_ENTRY_KEY);
if map.is_empty() {
obj.remove("mcpServers");
}
}
}
}

View file

@ -0,0 +1,70 @@
//! Shared JSON-merge helpers for MCP-style adapters (claude-code, cursor,
//! zed). Same algorithm, different parent/entry keys.
//!
//! Constructor Pattern: single responsibility — merge / remove one named
//! server under one named parent, preserving every other key.
use crate::brain::Brain;
use crate::error::Result;
use serde_json::{json, Map, Value};
/// Load `cfg` as JSON, or return empty `{}` if missing/blank. JSON parse
/// errors propagate via `Error::Json`.
pub fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
if !cfg.is_file() {
return Ok(json!({}));
}
let raw = std::fs::read_to_string(cfg)?;
if raw.trim().is_empty() {
return Ok(json!({}));
}
Ok(serde_json::from_str(&raw)?)
}
/// Build the keisei MCP entry value.
pub fn keisei_entry(brain: &Brain) -> Value {
json!({
"command": brain.mcp_server_path().to_string_lossy(),
"args": [],
"env": {
"KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(),
"KEISEI_BRAIN_NAME": brain.name(),
}
})
}
/// Insert `entry` under `doc[parent_key][entry_key]`, creating both keys
/// as objects if absent. Existing siblings are preserved.
pub fn merge_named(doc: &mut Value, parent_key: &str, entry_key: &str, entry: Value) {
if !doc.is_object() {
*doc = json!({});
}
let obj = doc.as_object_mut().expect("doc object");
let parent = obj
.entry(parent_key.to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !parent.is_object() {
*parent = Value::Object(Map::new());
}
parent
.as_object_mut()
.expect("parent object")
.insert(entry_key.to_string(), entry);
}
/// Remove `doc[parent_key][entry_key]`. If parent ends up empty, remove
/// parent too (no vestigial `{}` in the config).
pub fn remove_named(doc: &mut Value, parent_key: &str, entry_key: &str) {
if !doc.is_object() {
return;
}
let obj = doc.as_object_mut().expect("doc object");
if let Some(parent) = obj.get_mut(parent_key) {
if let Some(map) = parent.as_object_mut() {
map.remove(entry_key);
if map.is_empty() {
obj.remove(parent_key);
}
}
}
}

View file

@ -1,9 +1,11 @@
//! Concrete `ClientAdapter` implementations, one file per client.
//!
//! Constructor Pattern: this file is the module declaration hub only —
//! no logic lives here. Each adapter owns its own file.
//! no logic lives here. `jsonmcp` owns the shared JSON merge helpers
//! used by every JSON-keyed adapter (claude-code, cursor, zed).
pub mod claude_code;
pub mod continue_adapter;
pub mod cursor;
pub mod jsonmcp;
pub mod zed;

View file

@ -1,40 +1,20 @@
//! Zed adapter — writes MCP/context-server entry into Zed settings.
//! Zed adapter — writes context-server entry under `context_servers.keisei`.
//!
//! Config path [UNVERIFIED for exact schema key-name]:
//! - macOS: `~/Library/Application Support/Zed/settings.json`
//! - Linux: `~/.config/zed/settings.json`
//! - Windows: not supported in this adapter (Zed Windows is preview)
//!
//! Schema (under a top-level `context_servers` object):
//! ```json
//! {
//! "context_servers": {
//! "keisei": {
//! "command": "/path/to/kei-mcp-server",
//! "args": [],
//! "env": { "KEISEI_BRAIN_ROOT": "..." }
//! }
//! }
//! }
//! ```
//!
//! NOTE: Zed's `context_servers` key is the documented extension point for
//! MCP at time of writing — but the full schema (arg handling,
//! environment) is [UNVERIFIED] in this session. If a future Zed release
//! diverges, update this module.
//!
//! Detach preserves all other settings and all non-keisei context servers.
//! If the settings file is not valid JSON (Zed historically permitted
//! JSONC / comments), attach degrades gracefully by returning
//! `ConfigParseError` — the user can then switch to manual config.
//! Paths: `~/Library/Application Support/Zed/settings.json` on macOS,
//! `~/.config/zed/settings.json` on Linux. Detection fires on the
//! platform settings dir. Schema [UNVERIFIED]:
//! `{ "context_servers": { "keisei": { "command": "...", "args": [] } } }`.
//! Invalid JSON (e.g. JSONC with comments) → `ConfigParseError`.
use crate::adapter::ClientAdapter;
use crate::adapters::jsonmcp::{keisei_entry, merge_named, remove_named};
use crate::brain::Brain;
use crate::error::{Error, Result};
use crate::fsx::write_atomic;
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use std::path::PathBuf;
pub const PARENT_KEY: &str = "context_servers";
pub const ENTRY_KEY: &str = "keisei";
pub struct ZedAdapter;
@ -56,10 +36,6 @@ impl ZedAdapter {
base.join(".config/zed")
}
}
fn settings_file(&self) -> PathBuf {
self.settings_dir().join("settings.json")
}
}
impl Default for ZedAdapter {
@ -82,10 +58,9 @@ impl ClientAdapter for ZedAdapter {
if let Some(parent) = cfg.parent() {
std::fs::create_dir_all(parent)?;
}
let mut doc = load_json_or_empty(&cfg)?;
merge_entry(&mut doc, brain);
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)?;
Ok(())
let mut doc = load_strict_json(&cfg)?;
merge_named(&mut doc, PARENT_KEY, ENTRY_KEY, keisei_entry(brain));
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
fn detach(&self) -> Result<()> {
@ -93,18 +68,19 @@ impl ClientAdapter for ZedAdapter {
if !cfg.is_file() {
return Ok(());
}
let mut doc = load_json_or_empty(&cfg)?;
remove_entry(&mut doc);
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)?;
Ok(())
let mut doc = load_strict_json(&cfg)?;
remove_named(&mut doc, PARENT_KEY, ENTRY_KEY);
write_atomic(&cfg, &serde_json::to_string_pretty(&doc)?)
}
fn config_path(&self) -> PathBuf {
self.settings_file()
self.settings_dir().join("settings.json")
}
}
fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
/// Zed historically permitted JSONC. We require strict JSON and surface a
/// `ConfigParseError` pointing at the file if the user has comments.
fn load_strict_json(cfg: &std::path::Path) -> Result<Value> {
if !cfg.is_file() {
return Ok(json!({}));
}
@ -117,43 +93,3 @@ fn load_json_or_empty(cfg: &std::path::Path) -> Result<Value> {
reason: e.to_string(),
})
}
fn merge_entry(doc: &mut Value, brain: &Brain) {
if !doc.is_object() {
*doc = json!({});
}
let obj = doc.as_object_mut().expect("doc is object after guard");
let servers = obj
.entry("context_servers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let entry = json!({
"command": brain.mcp_server_path().to_string_lossy(),
"args": [],
"env": {
"KEISEI_BRAIN_ROOT": brain.root.to_string_lossy(),
"KEISEI_BRAIN_NAME": brain.name(),
}
});
servers
.as_object_mut()
.expect("servers is object")
.insert(ENTRY_KEY.to_string(), entry);
}
fn remove_entry(doc: &mut Value) {
if !doc.is_object() {
return;
}
let obj = doc.as_object_mut().expect("doc is object after guard");
if let Some(servers) = obj.get_mut("context_servers") {
if let Some(map) = servers.as_object_mut() {
map.remove(ENTRY_KEY);
if map.is_empty() {
obj.remove("context_servers");
}
}
}
}

View file

@ -23,6 +23,7 @@ pub enum Error {
NotAttached,
#[error("adapter '{client}' failed: {reason}")]
#[allow(dead_code)] // surfaced by mount/detach orchestration; reserved for library consumers
AdapterFailed { client: String, reason: String },
#[error("config parse error at {path}: {reason}")]

View file

@ -27,29 +27,11 @@ struct Row {
}
fn print_table(rows: &[Row]) {
let name_w = header_or_max(rows, "adapter", |r| r.name.len());
let det_w = "detected".len();
println!(
"{:<name_w$} {:<det_w$} config_path",
"adapter",
"detected",
name_w = name_w,
det_w = det_w
);
println!("{:<name_w$} {:<det_w$} {}", "-------", "--------", "-----------", name_w = name_w, det_w = det_w);
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0).max(7);
println!("{:<w1$} detected config_path", "adapter", w1 = name_w);
println!("{:<w1$} -------- -----------", "-------", w1 = name_w);
for r in rows {
let mark = if r.detected { "yes" } else { "no" };
println!(
"{:<name_w$} {:<det_w$} {}",
r.name,
mark,
r.config_path,
name_w = name_w,
det_w = det_w
);
let mark = if r.detected { "yes " } else { "no " };
println!("{:<w1$} {} {}", r.name, mark, r.config_path, w1 = name_w);
}
}
fn header_or_max<F: Fn(&Row) -> usize>(rows: &[Row], header: &str, f: F) -> usize {
rows.iter().map(f).max().unwrap_or(0).max(header.len())
}