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
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 62 additions & 4 deletions crates/codegraph-cli/src/installer/targets/kiro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use std::fs;
use std::path::PathBuf;

use serde_json::{json, Map};
use serde_json::{json, Map, Value};

use super::super::shared::{
mcp_server_config, read_config_file, read_json_file, remove_codegraph_from_mcp_servers,
Expand All @@ -35,6 +35,26 @@ fn steering_path(ctx: &InstallContext, loc: Location) -> PathBuf {
config_dir(ctx, loc).join("steering").join("codegraph.md")
}

/// Build the Kiro MCP entry with an explicit `--path`, mirroring Cursor.
///
/// Kiro's `initialize` carries no `rootUri`/`workspaceFolders` and does not
/// advertise `capabilities.roots`, so a bare global `serve --mcp` launched from
/// `$HOME` cannot discover the project and degrades to home safe mode. Pinning
/// `--path` fixes that: Local pins the concrete `ctx.cwd`; Global pins
/// `${workspaceFolder}`, which Kiro expands per workspace.
fn build_kiro_mcp_config(ctx: &InstallContext, loc: Location) -> Value {
let path_arg = match loc {
Location::Local => ctx.cwd.to_string_lossy().to_string(),
Location::Global => "${workspaceFolder}".to_string(),
};
let mut base = mcp_server_config();
if let Some(args) = base.get_mut("args").and_then(|a| a.as_array_mut()) {
args.push(json!("--path"));
args.push(json!(path_arg));
}
base
}

impl AgentTarget for KiroTarget {
fn id(&self) -> TargetId {
TargetId::Kiro
Expand Down Expand Up @@ -73,6 +93,8 @@ impl AgentTarget for KiroTarget {
"Restart Kiro for MCP changes to take effect.".to_string(),
"Kiro IDE: also enable MCP in Settings (search \"MCP\" → \"Enabled\"). Kiro CLI users can skip this step."
.to_string(),
"Prefer a project-level install: run `codegraph install --target=kiro --local` from each project root so the MCP entry pins that project's --path. A global install uses ${workspaceFolder}, which Kiro must expand per workspace."
.to_string(),
],
}
}
Expand Down Expand Up @@ -104,8 +126,9 @@ impl AgentTarget for KiroTarget {
// Ports kiroTarget.printConfig (kiro.ts:120).
fn print_config(&self, ctx: &InstallContext, loc: Location) -> String {
let target = mcp_json_path(ctx, loc);
let snippet =
to_upstream_json(&json!({ "mcpServers": { "codegraph": mcp_server_config() } }));
let snippet = to_upstream_json(
&json!({ "mcpServers": { "codegraph": build_kiro_mcp_config(ctx, loc) } }),
);
format!("# Add to {}\n\n{snippet}\n", target.display())
}

Expand All @@ -124,7 +147,7 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite {
if let Some(dir) = file.parent() {
let _ = fs::create_dir_all(dir);
}
let after = mcp_server_config();
let after = build_kiro_mcp_config(ctx, loc);
match read_config_file(&file) {
ConfigRead::Unparseable => FileWrite {
path: file,
Expand Down Expand Up @@ -198,4 +221,39 @@ mod tests {
assert!(local.ends_with(".kiro/skills"));
assert_eq!(local, PathBuf::from("/work/proj/.kiro/skills"));
}

#[test]
fn kiro_local_mcp_entry_pins_concrete_project_path() {
// Given a local Kiro install context
let ctx = ctx();

// When the MCP entry is built for the local location
let entry = build_kiro_mcp_config(&ctx, Location::Local);

// Then args end with --path pinned to the concrete cwd
let args = entry["args"].as_array().expect("args array");
assert_eq!(
args,
&vec![
json!("serve"),
json!("--mcp"),
json!("--path"),
json!("/work/proj"),
]
);
}

#[test]
fn kiro_global_mcp_entry_pins_workspace_folder_variable() {
// Given a global Kiro install context
let ctx = ctx();

// When the MCP entry is built for the global location
let entry = build_kiro_mcp_config(&ctx, Location::Global);

// Then args end with --path ${workspaceFolder} for per-workspace expansion
let args = entry["args"].as_array().expect("args array");
assert_eq!(args.last(), Some(&json!("${workspaceFolder}")));
assert!(args.contains(&json!("--path")));
}
}
124 changes: 112 additions & 12 deletions crates/codegraph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use codegraph_core::types::{ExtractionResult, FileRecord, Language, Node, NodeKi
use codegraph_extract::{detect_language, extract_source, ExtractOptions};
use codegraph_graph::graph::{GodotReach, GraphTraverser};
use codegraph_graph::query::{search_nodes, SearchOptions};
use codegraph_mcp::McpServer;
use codegraph_mcp::{McpServer, RunUntilAdoption};
use codegraph_resolve::ReferenceResolver;
use codegraph_store::queries::SearchResult;
use codegraph_store::Store;
Expand Down Expand Up @@ -797,6 +797,7 @@ fn cmd_init(path: Option<PathBuf>) -> Result<()> {
println!("Use \"codegraph index\" to re-index or \"codegraph sync\" to update");
return Ok(());
}
guard_indexable_root(&project)?;
fs::create_dir_all(codegraph_dir(&project))
.with_context(|| format!("creating {}", codegraph_dir(&project).display()))?;
let result = index_project(&project, true, false)?;
Expand All @@ -818,6 +819,7 @@ fn cmd_uninit(path: Option<PathBuf>, force: bool) -> Result<()> {

fn cmd_index(path: Option<PathBuf>, force: bool, quiet: bool, verbose: bool) -> Result<()> {
let project = resolve_required_project(path)?;
guard_indexable_root(&project)?;
if force {
remove_db_files(&project)?;
}
Expand Down Expand Up @@ -1120,22 +1122,76 @@ fn serve_direct(project: Option<PathBuf>, project_root: &Path, no_watch: bool) -
fn serve_direct_no_services(project: Option<PathBuf>, _project_root: &Path) -> Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut server =
McpServer::with_adoption_observer(project, Box::new(start_daemon_for_adopted_root));
server
.run(BufReader::new(stdin.lock()), stdout.lock())
.context("running MCP stdio server")
let mut server = McpServer::new(project);
match server
.run_until_adoption(BufReader::new(stdin.lock()), stdout.lock())
.context("running MCP stdio server until workspace adoption")?
{
RunUntilAdoption::Eof => Ok(()),
RunUntilAdoption::Adopted {
project_root,
reader,
} => serve_adopted_project(reader, stdout, project_root),
}
}

fn start_daemon_for_adopted_root(project_root: &Path) {
fn serve_adopted_project<R: BufRead, W: Write + Send + 'static>(
reader: R,
writer: W,
project_root: PathBuf,
) -> Result<()> {
let Some(socket_path) = start_daemon_for_adopted_root(&project_root) else {
let mut server = McpServer::new(Some(project_root));
return server
.run(reader, writer)
.context("running MCP stdio server for adopted project");
};

match codegraph_daemon::attach_to_daemon(&socket_path) {
Ok(client) if codegraph_daemon::verify_daemon_hello(&client.hello).is_none() => {}
Ok(_) => {
tracing::debug!("adopted project daemon version mismatch; serving direct");
let mut server = McpServer::new(Some(project_root));
return server
.run(reader, writer)
.context("running MCP stdio server for adopted project");
}
Err(err) => {
tracing::debug!(error = %err, "adopted project daemon preflight failed; serving direct");
let mut server = McpServer::new(Some(project_root));
return server
.run(reader, writer)
.context("running MCP stdio server for adopted project");
}
}

match codegraph_daemon::run_proxy(
&socket_path,
Some(codegraph_daemon::current_ppid()),
reader,
writer,
) {
Ok(codegraph_daemon::ProxyOutcome::Proxied) => Ok(()),
Ok(codegraph_daemon::ProxyOutcome::VersionMismatch) => Ok(()),
Err(err) => {
tracing::debug!(error = %err, "adopted project proxy attach failed");
Ok(())
}
}
}

fn start_daemon_for_adopted_root(project_root: &Path) -> Option<PathBuf> {
if daemon_opt_out() || is_daemon_internal() || !should_run_daemon_services(project_root) {
return;
return None;
}
if !codegraph_dir(project_root).is_dir() || daemon_already_running(project_root) {
return;
if !codegraph_dir(project_root).is_dir() {
return None;
}
if daemon_already_running(project_root) {
return Some(codegraph_daemon::daemon_socket_path(project_root));
}
let Ok(exe) = std::env::current_exe() else {
return;
return None;
};
match codegraph_daemon::spawn_detached_daemon(&exe, project_root) {
Ok(()) => {
Expand All @@ -1144,9 +1200,12 @@ fn start_daemon_for_adopted_root(project_root: &Path) {
"[CodeGraph MCP] Started shared daemon for adopted project root {}",
project_root.display()
);
let socket_path = codegraph_daemon::daemon_socket_path(project_root);
socket_path.exists().then_some(socket_path)
}
Err(err) => {
eprintln!("[CodeGraph MCP] Adopted project daemon start failed: {err}");
None
}
}
}
Expand All @@ -1159,6 +1218,16 @@ fn should_run_daemon_services(root: &Path) -> bool {
codegraph_watch::too_broad_root_reason(root).is_none()
}

fn guard_indexable_root(root: &Path) -> Result<()> {
if let Some(reason) = codegraph_watch::too_broad_root_reason(root) {
bail!(
"refusing to index {}: {reason}. Run `codegraph init`/`index` inside a specific project directory instead.",
root.display()
);
}
Ok(())
}

/// Spawn a ONE-SHOT background catch-up sync that absorbs edits made while the
/// server was down (upstream colby `catchUpSync`, #905). Returns an
/// `Arc<AtomicBool>` flipped to `true` when the background sync finishes, so a
Expand Down Expand Up @@ -1333,7 +1402,7 @@ pub fn select_serve_mode(

#[cfg(test)]
mod serve_mode_tests {
use super::{select_serve_mode, should_run_daemon_services, ServeMode};
use super::{guard_indexable_root, select_serve_mode, should_run_daemon_services, ServeMode};
use std::path::Path;
use std::sync::Mutex;

Expand Down Expand Up @@ -1380,6 +1449,37 @@ mod serve_mode_tests {
}
let _ = std::fs::remove_dir_all(&tmp);
}

#[test]
fn guard_indexable_root_rejects_home_and_root_allows_nested_project() {
let _lock = ENV_LOCK.lock().unwrap();
let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
let prev_home = std::env::var_os(home_key);

let tmp = std::env::temp_dir().join(format!("cg-guard-home-{}", std::process::id()));
let nested = tmp.join("workspace/proj");
std::fs::create_dir_all(&nested).unwrap();
std::env::set_var(home_key, &tmp);

assert!(
guard_indexable_root(&tmp).is_err(),
"$HOME must be refused as an index root"
);
assert!(
guard_indexable_root(Path::new("/")).is_err(),
"filesystem root must be refused as an index root"
);
assert!(
guard_indexable_root(&nested).is_ok(),
"a project nested under $HOME must be indexable"
);

match prev_home {
Some(v) => std::env::set_var(home_key, v),
None => std::env::remove_var(home_key),
}
let _ = std::fs::remove_dir_all(&tmp);
}
}

fn cmd_unlock(path: Option<PathBuf>) -> Result<()> {
Expand Down
2 changes: 1 addition & 1 deletion crates/codegraph-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ pub mod schemas;
pub mod server;

pub use engine::CodeGraphEngine;
pub use server::{initialize_result, McpServer};
pub use server::{initialize_result, McpServer, RunUntilAdoption};
Loading