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
5 changes: 3 additions & 2 deletions rust/src/doctor/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,12 +410,13 @@ pub(super) fn mcp_config_outcome() -> Outcome {
};

let locations = mcp_config_locations(&home);
let location_names = lean_ctx_mcp_location_names(&home);
let mut found: Vec<String> = Vec::new();
let mut exists_no_ref: Vec<String> = Vec::new();

for loc in &locations {
if let Ok(content) = std::fs::read_to_string(&loc.path) {
if has_lean_ctx_mcp_entry(&content) {
if std::fs::read_to_string(&loc.path).is_ok() {
if location_names.contains(loc.name) {
found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
} else {
exists_no_ref.push(loc.name.to_string());
Expand Down
14 changes: 14 additions & 0 deletions rust/src/doctor/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,20 @@ pub(super) fn has_lean_ctx_mcp_entry(content: &str) -> bool {
content.contains("lean-ctx")
}

pub(super) fn lean_ctx_mcp_location_names(
home: &std::path::Path,
) -> std::collections::BTreeSet<&'static str> {
let mut names = std::collections::BTreeSet::new();
for loc in mcp_config_locations(home) {
if let Ok(content) = std::fs::read_to_string(&loc.path)
&& has_lean_ctx_mcp_entry(&content)
{
names.insert(loc.name);
}
}
names
}

pub(super) fn proxy_auth_probe(port: u16) -> bool {
use std::io::{Read, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
Expand Down
4 changes: 2 additions & 2 deletions rust/src/doctor/fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ fn build_and_persist_fix_report(
warnings: Vec::new(),
errors: Vec::new(),
};
let user_has_lean_ctx = !targets.is_empty();
let ws_fixed = super::workspace_scope::fix_workspace_dual_scope(user_has_lean_ctx);
let user_scope_mcp_locations = super::lean_ctx_mcp_location_names(&home);
let ws_fixed = super::workspace_scope::fix_workspace_dual_scope(&user_scope_mcp_locations);
ws_scope_step.items.push(SetupItem {
name: "dual_scope_dedup".to_string(),
status: if ws_fixed > 0 {
Expand Down
5 changes: 4 additions & 1 deletion rust/src/doctor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,12 @@ pub fn run() -> u32 {
// 7) MCP
let mcp = mcp_config_outcome();
board.check(&mcp);
let user_scope_mcp_locations = dirs::home_dir()
.map(|home| lean_ctx_mcp_location_names(&home))
.unwrap_or_default();

// 8) Workspace-scope MCP (optional; only when a project-local config exists)
let workspace_scope = workspace_scope::workspace_scope_outcome(mcp.ok);
let workspace_scope = workspace_scope::workspace_scope_outcome(&user_scope_mcp_locations);
if let Some(ref ws) = workspace_scope {
board.check(ws);
}
Expand Down
115 changes: 102 additions & 13 deletions rust/src/doctor/workspace_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
//! diagnosis instead of leaving the user to trace a Copilot runtime failure.

use super::{BOLD, DIM, GREEN, Outcome, RED, RST, YELLOW};
use std::collections::BTreeSet;

/// A workspace-scope MCP config location, relative to the project root (cwd).
struct WorkspaceLocation {
/// Human-facing editor label.
label: &'static str,
/// User/global MCP config location names that share this workspace surface.
user_scope_names: &'static [&'static str],
/// Path relative to the current working directory.
rel: &'static str,
}
Expand All @@ -23,18 +26,22 @@ struct WorkspaceLocation {
const WORKSPACE_LOCATIONS: &[WorkspaceLocation] = &[
WorkspaceLocation {
label: "VS Code / Cline",
user_scope_names: &["VS Code", "Cline"],
rel: ".vscode/mcp.json",
},
WorkspaceLocation {
label: "Copilot",
user_scope_names: &[],
rel: ".github/mcp.json",
},
WorkspaceLocation {
label: "Cursor",
user_scope_names: &["Cursor"],
rel: ".cursor/mcp.json",
},
WorkspaceLocation {
label: "Zed",
user_scope_names: &["Zed"],
rel: ".zed/settings.json",
},
];
Expand All @@ -46,10 +53,12 @@ const WORKSPACE_LOCATIONS: &[WorkspaceLocation] = &[
/// or a healthy workspace-only registration. Returns `None` when no workspace
/// MCP config is present, so the doctor output stays uncluttered for the
/// common (user-scope only) case.
pub(super) fn workspace_scope_outcome(user_scope_has_lean_ctx: bool) -> Option<Outcome> {
pub(super) fn workspace_scope_outcome(
user_scope_mcp_locations: &BTreeSet<&'static str>,
) -> Option<Outcome> {
let cwd = std::env::current_dir().ok()?;

let mut registered: Vec<String> = Vec::new();
let mut registered: Vec<(&WorkspaceLocation, String)> = Vec::new();
let mut malformed: Vec<String> = Vec::new();

for loc in WORKSPACE_LOCATIONS {
Expand All @@ -63,7 +72,7 @@ pub(super) fn workspace_scope_outcome(user_scope_has_lean_ctx: bool) -> Option<O
match crate::core::jsonc::parse_jsonc(&content) {
Ok(_) => {
if super::has_lean_ctx_mcp_entry(&content) {
registered.push(format!("{} ({})", loc.label, loc.rel));
registered.push((loc, format!("{} ({})", loc.label, loc.rel)));
}
}
Err(e) => {
Expand Down Expand Up @@ -97,15 +106,25 @@ pub(super) fn workspace_scope_outcome(user_scope_has_lean_ctx: bool) -> Option<O
// running inside the lean-ctx repo itself (the workspace config is part of
// the distribution). Marking it `ok: true` keeps it out of the failure
// count while still surfacing the hint.
if user_scope_has_lean_ctx {
let duplicated: Vec<String> = registered
.iter()
.filter(|(loc, _)| {
loc.user_scope_names
.iter()
.any(|name| user_scope_mcp_locations.contains(name))
})
.map(|(_, display)| display.clone())
.collect();

if !duplicated.is_empty() {
return Some(Outcome {
ok: true,
line: format!(
"{BOLD}Workspace MCP{RST} {YELLOW}lean-ctx registered in BOTH user and \
workspace scope{RST} {DIM}({}){RST} {DIM}(keep only one scope — duplicate \
registration can cause Copilot 'ws0 not found' / 'tool not contributed' \
errors){RST}",
registered.join(", ")
duplicated.join(", ")
),
});
}
Expand All @@ -115,16 +134,20 @@ pub(super) fn workspace_scope_outcome(user_scope_has_lean_ctx: bool) -> Option<O
ok: true,
line: format!(
"{BOLD}Workspace MCP{RST} {GREEN}lean-ctx found in workspace scope: {}{RST}",
registered.join(", ")
registered
.into_iter()
.map(|(_, display)| display)
.collect::<Vec<_>>()
.join(", ")
),
})
}

/// Removes lean-ctx from workspace-scope MCP configs when user-scope already
/// has it registered. Called by `doctor --fix` to resolve the dual-scope conflict.
/// Returns the number of files cleaned up.
pub(super) fn fix_workspace_dual_scope(user_scope_has_lean_ctx: bool) -> usize {
if !user_scope_has_lean_ctx {
pub(super) fn fix_workspace_dual_scope(user_scope_mcp_locations: &BTreeSet<&'static str>) -> usize {
if user_scope_mcp_locations.is_empty() {
return 0;
}
let Some(cwd) = std::env::current_dir().ok() else {
Expand All @@ -133,6 +156,13 @@ pub(super) fn fix_workspace_dual_scope(user_scope_has_lean_ctx: bool) -> usize {

let mut fixed = 0;
for loc in WORKSPACE_LOCATIONS {
if !loc
.user_scope_names
.iter()
.any(|name| user_scope_mcp_locations.contains(name))
{
continue;
}
let path = cwd.join(loc.rel);
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
Expand Down Expand Up @@ -215,7 +245,9 @@ mod tests {
#[test]
fn none_when_no_workspace_config() {
let tmp = tempfile::tempdir().unwrap();
let out = with_cwd(tmp.path(), || workspace_scope_outcome(true));
let out = with_cwd(tmp.path(), || {
workspace_scope_outcome(&BTreeSet::from(["VS Code"]))
});
assert!(out.is_none());
}

Expand All @@ -227,7 +259,10 @@ mod tests {
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": {"command": "lean-ctx"}}}"#,
);
let out = with_cwd(tmp.path(), || workspace_scope_outcome(true)).unwrap();
let out = with_cwd(tmp.path(), || {
workspace_scope_outcome(&BTreeSet::from(["VS Code"]))
})
.unwrap();
// Dual-scope is a WARN (informational), not a hard failure — it's the
// expected state inside the lean-ctx repo itself.
assert!(out.ok, "dual-scope should be ok:true (informational WARN)");
Expand All @@ -242,7 +277,7 @@ mod tests {
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": {"command": "lean-ctx"}}}"#,
);
let out = with_cwd(tmp.path(), || workspace_scope_outcome(false)).unwrap();
let out = with_cwd(tmp.path(), || workspace_scope_outcome(&BTreeSet::new())).unwrap();
assert!(out.ok);
assert!(out.line.contains("workspace scope"));
}
Expand All @@ -256,11 +291,31 @@ mod tests {
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": "#,
);
let out = with_cwd(tmp.path(), || workspace_scope_outcome(true)).unwrap();
let out = with_cwd(tmp.path(), || {
workspace_scope_outcome(&BTreeSet::from(["VS Code"]))
})
.unwrap();
assert!(!out.ok);
assert!(out.line.contains("malformed"));
}

#[test]
fn copilot_cli_does_not_duplicate_vscode_workspace_mcp() {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": {"command": "lean-ctx"}}}"#,
);
let out = with_cwd(tmp.path(), || {
workspace_scope_outcome(&BTreeSet::from(["GitHub Copilot CLI"]))
})
.unwrap();
assert!(out.ok);
assert!(out.line.contains("workspace scope"));
assert!(!out.line.contains("BOTH user and"));
}

#[test]
fn jsonc_workspace_config_with_trailing_comma_is_accepted() {
let tmp = tempfile::tempdir().unwrap();
Expand All @@ -269,7 +324,41 @@ mod tests {
".vscode/mcp.json",
"{\n \"servers\": {\n \"lean-ctx\": { \"command\": \"lean-ctx\" },\n },\n}",
);
let out = with_cwd(tmp.path(), || workspace_scope_outcome(false)).unwrap();
let out = with_cwd(tmp.path(), || workspace_scope_outcome(&BTreeSet::new())).unwrap();
assert!(out.ok, "JSONC with trailing commas must parse cleanly");
}

#[test]
fn fix_skips_vscode_workspace_when_only_copilot_cli_is_user_scope() {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": {"command": "lean-ctx"}}}"#,
);
let fixed = with_cwd(tmp.path(), || {
fix_workspace_dual_scope(&BTreeSet::from(["GitHub Copilot CLI"]))
});
assert_eq!(fixed, 0);

let content = fs::read_to_string(tmp.path().join(".vscode/mcp.json")).unwrap();
assert!(content.contains("lean-ctx"));
}

#[test]
fn fix_removes_vscode_workspace_when_vscode_is_user_scope() {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
".vscode/mcp.json",
r#"{"servers": {"lean-ctx": {"command": "lean-ctx"}}}"#,
);
let fixed = with_cwd(tmp.path(), || {
fix_workspace_dual_scope(&BTreeSet::from(["VS Code"]))
});
assert_eq!(fixed, 1);

let content = fs::read_to_string(tmp.path().join(".vscode/mcp.json")).unwrap();
assert!(!content.contains("lean-ctx"));
}
}
Loading