Skip to content

Commit a2782ec

Browse files
userFRMclaude
andauthored
feat: set_project_root MCP tool — switch project within a running session (v0.8.2) (#86)
Users who start Claude Code from their home directory had no way to point the RPG server at a specific project without restarting. The MCP server's project_root was captured once at spawn and never updated; `cd` in the session's shell only moved the Bash cwd, not the server. New `set_project_root(path)` tool: - Accepts tilde-expanded paths ("~/kairos-engine", "/abs/path") - Canonicalizes and validates the path is a directory - Swaps the server's active root atomically - Loads the new root's .rpg/graph.json if present; reports "No graph — call build_rpg" if absent - Resets lifting/hierarchy sessions, auto-sync markers, pending routing, and the embedding index — everything project-scoped - Idempotent, non-destructive (annotations mark it as such) Implementation: - RpgServer.project_root field → project_root_cell: Arc<RwLock<PathBuf>> - New async accessor RpgServer::project_root() returns a PathBuf snapshot - Every tool handler and server method now reads project_root via self.project_root().await at call time (~50 sites converted) - get_config_blocking → load_config, both made async - staleness_detail made async MCP tool count: 27 → 28. All 651 workspace tests pass. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e1423ad commit a2782ec

11 files changed

Lines changed: 216 additions & 88 deletions

File tree

.gemini/extensions/rpg/gemini-extension.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rpg-encoder",
3-
"version": "0.8.1",
3+
"version": "0.8.2",
44
"description": "Build and query semantic code graphs (Repository Planning Graphs) for AI-assisted code understanding. Provides entity search, dependency exploration, and autonomous LLM-driven semantic lifting.",
55
"mcpServers": {
66
"rpg-encoder": {

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.8.2] - 2026-04-14
9+
10+
### Added
11+
12+
- **`set_project_root` MCP tool** — switch the active project root at runtime
13+
without restarting the session. Loads the new root's `.rpg/graph.json` if
14+
present, resets lifting/hierarchy sessions, auto-sync markers, and pending
15+
routing state. Tilde-expands and canonicalizes the supplied path. Fixes the
16+
common case where a session is launched from `$HOME` but the user wants to
17+
work on `~/some-project` — previously the server's project root was locked
18+
to the launch directory.
19+
- MCP tool count: 27 → 28.
20+
21+
### Changed
22+
23+
- `RpgServer::project_root` is now an async accessor backed by
24+
`Arc<RwLock<PathBuf>>` (previously a static `PathBuf` field). All tool
25+
handlers acquire a snapshot at call time, so each invocation reads whatever
26+
`set_project_root` most recently set.
27+
- `get_config_blocking` renamed to `load_config` and made async.
28+
- `staleness_detail` made async (needed to acquire the new project-root lock).
29+
830
## [0.8.1] - 2026-04-14
931

1032
### Fixed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ members = [
1111
]
1212

1313
[workspace.package]
14-
version = "0.8.1"
14+
version = "0.8.2"
1515
edition = "2024"
1616
license = "MIT"
1717
authors = ["userFRM"]

crates/rpg-mcp/src/hierarchy_helpers.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ impl RpgServer {
1414
let session_guard = self.hierarchy_session.read().await;
1515
let session = session_guard.as_ref().unwrap();
1616

17-
let repo_name = self
18-
.project_root
17+
let root = self.project_root().await;
18+
let repo_name = root
1919
.file_name()
2020
.and_then(|n| n.to_str())
2121
.unwrap_or("unknown");
@@ -90,8 +90,8 @@ impl RpgServer {
9090
let guard = self.graph.read().await;
9191
let graph = guard.as_ref().unwrap();
9292

93-
let repo_name = self
94-
.project_root
93+
let root = self.project_root().await;
94+
let repo_name = root
9595
.file_name()
9696
.and_then(|n| n.to_str())
9797
.unwrap_or("unknown");

crates/rpg-mcp/src/main.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async fn main() -> Result<()> {
3535
if let Some(ref mut graph) = *lock
3636
&& let (Some(base), Ok(head)) = (
3737
&graph.base_commit.clone(),
38-
rpg_encoder::evolution::get_head_sha(&server.project_root),
38+
rpg_encoder::evolution::get_head_sha(&server.project_root().await),
3939
)
4040
{
4141
if *base != head {
@@ -51,7 +51,7 @@ async fn main() -> Result<()> {
5151
let qcache_result =
5252
rpg_parser::paradigms::query_engine::QueryCache::compile_all(&paradigm_defs);
5353
let active_defs = rpg_parser::paradigms::detect_paradigms_toml(
54-
&server.project_root,
54+
&server.project_root().await,
5555
&detected_langs,
5656
&paradigm_defs,
5757
);
@@ -66,13 +66,13 @@ async fn main() -> Result<()> {
6666
});
6767
match rpg_encoder::evolution::run_update(
6868
graph,
69-
&server.project_root,
69+
&server.project_root().await,
7070
None,
7171
pipeline.as_ref(),
7272
) {
7373
Ok(s) => {
7474
graph.metadata.paradigms = paradigm_names;
75-
let _ = storage::save(&server.project_root, graph);
75+
let _ = storage::save(&server.project_root().await, graph);
7676
eprintln!(
7777
" Auto-update complete: +{} -{} ~{}",
7878
s.entities_added, s.entities_removed, s.entities_modified
@@ -85,7 +85,7 @@ async fn main() -> Result<()> {
8585
}
8686
// Seed auto-sync HEAD so the first query doesn't redundantly re-sync
8787
*server.last_auto_sync_head.write().await =
88-
rpg_encoder::evolution::get_head_sha(&server.project_root).ok();
88+
rpg_encoder::evolution::get_head_sha(&server.project_root().await).ok();
8989
}
9090
}
9191

crates/rpg-mcp/src/params.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ pub(crate) struct ExploreRpgParams {
5656
pub(crate) max_results: Option<usize>,
5757
}
5858

59+
/// Parameters for the `set_project_root` tool.
60+
#[derive(Debug, Deserialize, JsonSchema)]
61+
pub(crate) struct SetProjectRootParams {
62+
/// Absolute path to the project directory. Tilde expansion (`~`) is supported.
63+
pub(crate) path: String,
64+
}
65+
5966
/// Parameters for the `build_rpg` tool.
6067
#[derive(Debug, Deserialize, JsonSchema)]
6168
pub(crate) struct BuildRpgParams {

crates/rpg-mcp/src/server.rs

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ impl PromptVersions {
3737
/// The RPG MCP server state.
3838
#[derive(Clone)]
3939
pub(crate) struct RpgServer {
40-
pub(crate) project_root: PathBuf,
40+
/// Active project root. Mutable at runtime via the `set_project_root` tool
41+
/// so a single long-lived session can switch between projects without
42+
/// restart. Tools acquire a snapshot via [`RpgServer::project_root`].
43+
pub(crate) project_root_cell: Arc<RwLock<PathBuf>>,
4144
pub(crate) graph: Arc<RwLock<Option<RPGraph>>>,
4245
pub(crate) config: Arc<RwLock<RpgConfig>>,
4346
pub(crate) lifting_session: Arc<RwLock<Option<LiftingSession>>>,
@@ -69,13 +72,18 @@ pub(crate) struct RpgServer {
6972
impl std::fmt::Debug for RpgServer {
7073
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7174
f.debug_struct("RpgServer")
72-
.field("project_root", &self.project_root)
75+
.field("project_root", &"<lock>")
7376
.field("lifting_session", &"...")
7477
.finish()
7578
}
7679
}
7780

7881
impl RpgServer {
82+
/// Snapshot of the active project root. Cheap — locks for a single clone.
83+
pub(crate) async fn project_root(&self) -> PathBuf {
84+
self.project_root_cell.read().await.clone()
85+
}
86+
7987
/// Create a new server, loading graph and config from `project_root` if present.
8088
pub(crate) fn new(project_root: PathBuf) -> Self {
8189
let graph = storage::load(&project_root).ok();
@@ -86,7 +94,7 @@ impl RpgServer {
8694
.map(|s| s.entries)
8795
.unwrap_or_default();
8896
Self {
89-
project_root,
97+
project_root_cell: Arc::new(RwLock::new(project_root)),
9098
graph: Arc::new(RwLock::new(graph)),
9199
config: Arc::new(RwLock::new(config)),
92100
lifting_session: Arc::new(RwLock::new(None)),
@@ -107,16 +115,17 @@ impl RpgServer {
107115

108116
/// Check if the loaded graph is stale (behind git HEAD) and return a notice string.
109117
pub(crate) async fn staleness_notice(&self) -> String {
118+
let project_root = self.project_root().await;
110119
let guard = self.graph.read().await;
111120
let Some(graph) = guard.as_ref() else {
112121
return String::new();
113122
};
114123
// Detect workdir changes (committed + staged + unstaged)
115-
let Ok(changes) = rpg_encoder::evolution::detect_workdir_changes(&self.project_root, graph)
124+
let Ok(changes) = rpg_encoder::evolution::detect_workdir_changes(&project_root, graph)
116125
else {
117126
return String::new();
118127
};
119-
let changes = rpg_encoder::evolution::filter_rpgignore_changes(&self.project_root, changes);
128+
let changes = rpg_encoder::evolution::filter_rpgignore_changes(&project_root, changes);
120129
let languages = Self::resolve_languages(&graph.metadata);
121130
let source_changes = if languages.is_empty() {
122131
changes
@@ -150,8 +159,9 @@ impl RpgServer {
150159
/// markers — the next query will retry, so transient failures don't
151160
/// silently leave the server stale.
152161
pub(crate) async fn auto_sync_if_stale(&self) -> String {
162+
let project_root = self.project_root().await;
153163
// Step 1: Get current HEAD (cheap, just opens .git/HEAD)
154-
let Ok(current_head) = rpg_encoder::evolution::get_head_sha(&self.project_root) else {
164+
let Ok(current_head) = rpg_encoder::evolution::get_head_sha(&project_root) else {
155165
return self.staleness_notice().await;
156166
};
157167

@@ -161,21 +171,19 @@ impl RpgServer {
161171
let Some(graph) = guard.as_ref() else {
162172
return String::new();
163173
};
164-
let Ok(changes) =
165-
rpg_encoder::evolution::detect_workdir_changes(&self.project_root, graph)
174+
let Ok(changes) = rpg_encoder::evolution::detect_workdir_changes(&project_root, graph)
166175
else {
167176
return String::new();
168177
};
169-
let changes =
170-
rpg_encoder::evolution::filter_rpgignore_changes(&self.project_root, changes);
178+
let changes = rpg_encoder::evolution::filter_rpgignore_changes(&project_root, changes);
171179
let languages = Self::resolve_languages(&graph.metadata);
172180
let source_changes = if languages.is_empty() {
173181
changes
174182
} else {
175183
rpg_encoder::evolution::filter_source_changes(changes, &languages)
176184
};
177185
let paths = Self::change_paths(&source_changes);
178-
let hash = Self::compute_changeset_hash(&source_changes, &self.project_root);
186+
let hash = Self::compute_changeset_hash(&source_changes, &project_root);
179187
(source_changes, paths, hash)
180188
};
181189

@@ -187,7 +195,7 @@ impl RpgServer {
187195
let last_paths = self.last_auto_sync_workdir_paths.read().await;
188196
let mut effective = source_changes.clone();
189197
for path in last_paths.difference(&current_paths) {
190-
let abs = self.project_root.join(path);
198+
let abs = project_root.join(path);
191199
if abs.is_file() {
192200
effective.push(rpg_encoder::evolution::FileChange::Modified(path.clone()));
193201
} else {
@@ -228,7 +236,7 @@ impl RpgServer {
228236
let qcache_result =
229237
rpg_parser::paradigms::query_engine::QueryCache::compile_all(&paradigm_defs);
230238
let active_defs = rpg_parser::paradigms::detect_paradigms_toml(
231-
&self.project_root,
239+
&project_root,
232240
&detected_langs,
233241
&paradigm_defs,
234242
);
@@ -243,13 +251,13 @@ impl RpgServer {
243251

244252
match rpg_encoder::evolution::run_update_from_changes(
245253
graph,
246-
&self.project_root,
254+
&project_root,
247255
effective_changes,
248256
pipeline.as_ref(),
249257
) {
250258
Ok(summary) => {
251259
graph.metadata.paradigms = paradigm_names;
252-
let _ = storage::save(&self.project_root, graph);
260+
let _ = storage::save(&project_root, graph);
253261
*self.last_auto_sync_head.write().await = Some(current_head);
254262
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
255263
*self.last_auto_sync_workdir_paths.write().await = current_paths;
@@ -405,7 +413,8 @@ impl RpgServer {
405413
}
406414
drop(read);
407415

408-
match storage::load(&self.project_root) {
416+
let project_root = self.project_root().await;
417+
match storage::load(&project_root) {
409418
Ok(g) => {
410419
*self.graph.write().await = Some(g);
411420
Ok(())
@@ -417,10 +426,10 @@ impl RpgServer {
417426
}
418427

419428
/// Detailed staleness info: which source files changed (committed + staged + unstaged).
420-
pub(crate) fn staleness_detail(&self, graph: &RPGraph) -> Option<String> {
421-
let changes =
422-
rpg_encoder::evolution::detect_workdir_changes(&self.project_root, graph).ok()?;
423-
let changes = rpg_encoder::evolution::filter_rpgignore_changes(&self.project_root, changes);
429+
pub(crate) async fn staleness_detail(&self, graph: &RPGraph) -> Option<String> {
430+
let project_root = self.project_root().await;
431+
let changes = rpg_encoder::evolution::detect_workdir_changes(&project_root, graph).ok()?;
432+
let changes = rpg_encoder::evolution::filter_rpgignore_changes(&project_root, changes);
424433
let languages = Self::resolve_languages(&graph.metadata);
425434
let changes = rpg_encoder::evolution::filter_source_changes(changes, &languages);
426435

@@ -471,7 +480,7 @@ impl RpgServer {
471480
};
472481

473482
// Check staleness
474-
let stale_detail = self.staleness_detail(graph);
483+
let stale_detail = self.staleness_detail(graph).await;
475484
let graph_line = match &stale_detail {
476485
Some(detail) => format!(
477486
"graph: {} ({} entities, {} files)",
@@ -592,9 +601,10 @@ impl RpgServer {
592601
Ok(out)
593602
}
594603

595-
/// Load config synchronously (for contexts that cannot await).
596-
pub(crate) fn get_config_blocking(&self) -> RpgConfig {
597-
RpgConfig::load(&self.project_root).unwrap_or_default()
604+
/// Load the RPG config for the active project.
605+
pub(crate) async fn load_config(&self) -> RpgConfig {
606+
let project_root = self.project_root().await;
607+
RpgConfig::load(&project_root).unwrap_or_default()
598608
}
599609

600610
/// Lazy-initialize the embedding index on first semantic search.
@@ -612,8 +622,9 @@ impl RpgServer {
612622
return;
613623
}
614624

625+
let project_root = self.project_root().await;
615626
let updated_at = graph.updated_at.to_rfc3339();
616-
match rpg_nav::embeddings::EmbeddingIndex::load_or_init(&self.project_root, &updated_at) {
627+
match rpg_nav::embeddings::EmbeddingIndex::load_or_init(&project_root, &updated_at) {
617628
Ok(mut idx) => {
618629
// Incremental sync: only re-embed entities whose features changed
619630
if let Err(e) = idx.sync(graph) {

0 commit comments

Comments
 (0)