use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use crate::app_spec;
use crate::error::{CliError, CliResult};
use crate::output;
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
pub enum ConnectWriteTarget {
Vscode,
}
pub fn run(port: u16, write: Option<ConnectWriteTarget>) -> CliResult {
let spec = load_mcp_spec()?;
let project_root = std::env::current_dir().map_err(|e| CliError::IoError {
context: "failed to read the current directory".to_string(),
source: e,
})?;
let crate_name = to_crate_name(&spec.app.name);
let (binary, built) = resolve_binary(&project_root, &crate_name);
let url = format!("http://127.0.0.1:{port}/mcp");
output::print_heading(&format!("MCP connection for '{}'", spec.app.name));
println!();
if !built {
output::print_dim(&format!(
" Note: {} does not exist yet; run `cargo build --release` first.",
binary
));
println!();
}
print_stdio_section(&spec.app.name, &binary);
print_http_section(&spec.app.name, &url);
match write {
Some(ConnectWriteTarget::Vscode) => {
let path = write_vscode_config(&project_root, &spec.app.name, &binary)?;
println!();
output::print_create(&path.display().to_string());
output::print_success(" VS Code MCP configuration written");
}
None => {
println!();
output::print_dim(
" Write the in-project VS Code config with `solverforge connect --write vscode`.",
);
}
}
Ok(())
}
fn load_mcp_spec() -> CliResult<app_spec::AppSpec> {
if !Path::new("solverforge.app.toml").exists() {
return Err(CliError::NotInProject {
missing: "solverforge.app.toml",
});
}
let spec = app_spec::load()?;
if spec.app.shell != "mcp" {
return Err(CliError::with_hint(
format!(
"`solverforge connect` is only available for MCP-shell projects (this project's shell is `{}`)",
spec.app.shell
),
"scaffold an MCP project with `solverforge new <name> --shell mcp`",
));
}
Ok(spec)
}
fn stdio_command(binary: &str) -> String {
binary.to_string()
}
fn stdio_server_json(binary: &str) -> Value {
json!({
"command": stdio_command(binary),
"args": [],
})
}
fn http_server_json(url: &str) -> Value {
json!({
"type": "http",
"url": url,
})
}
fn mcp_servers_document(name: &str, entry: Value) -> Value {
let mut servers = Map::new();
servers.insert(name.to_string(), entry);
let mut root = Map::new();
root.insert("mcpServers".to_string(), Value::Object(servers));
Value::Object(root)
}
fn vscode_servers_document(name: &str, entry: Value) -> Value {
let mut servers = Map::new();
servers.insert(name.to_string(), entry);
let mut root = Map::new();
root.insert("servers".to_string(), Value::Object(servers));
Value::Object(root)
}
fn print_json_block(value: &Value) {
match serde_json::to_string_pretty(value) {
Ok(rendered) => {
for line in rendered.lines() {
println!(" {line}");
}
}
Err(error) => output::print_error(&format!("failed to render JSON: {error}")),
}
}
fn print_stdio_section(name: &str, binary: &str) {
println!(" stdio (default transport)");
println!();
println!(" Claude Code:");
println!(" claude mcp add {name} -- {binary}");
println!();
println!(" Claude Desktop (claude_desktop_config.json):");
println!(" macOS: ~/Library/Application Support/Claude/claude_desktop_config.json");
println!(" Linux: ~/.config/Claude/claude_desktop_config.json");
println!(" Windows: %APPDATA%\\Claude\\claude_desktop_config.json");
print_json_block(&mcp_servers_document(name, stdio_server_json(binary)));
println!();
println!(" Cursor (.cursor/mcp.json):");
print_json_block(&mcp_servers_document(name, stdio_server_json(binary)));
println!();
println!(" VS Code (.vscode/mcp.json):");
print_json_block(&vscode_servers_document(name, stdio_server_json(binary)));
println!();
println!(" Any other MCP client: run `{binary}` and speak JSON-RPC over stdio.");
}
fn print_http_section(name: &str, url: &str) {
println!();
println!(" Streamable HTTP (start with `cargo run --release -- --http`)");
println!();
println!(" Claude Code:");
println!(" claude mcp add --transport http {name} {url}");
println!();
println!(" VS Code (.vscode/mcp.json):");
print_json_block(&vscode_servers_document(name, http_server_json(url)));
println!();
println!(" Any other MCP client: connect to {url}");
}
fn write_vscode_config(project_root: &Path, name: &str, binary: &str) -> CliResult<PathBuf> {
let directory = project_root.join(".vscode");
fs::create_dir_all(&directory).map_err(|e| CliError::IoError {
context: "failed to create .vscode/".to_string(),
source: e,
})?;
let path = directory.join("mcp.json");
let existing = if path.exists() {
Some(fs::read_to_string(&path).map_err(|e| CliError::IoError {
context: format!("failed to read {}", path.display()),
source: e,
})?)
} else {
None
};
let rendered = merge_vscode_config(existing.as_deref(), name, binary)?;
fs::write(&path, rendered).map_err(|e| CliError::IoError {
context: format!("failed to write {}", path.display()),
source: e,
})?;
Ok(path)
}
fn merge_vscode_config(existing: Option<&str>, name: &str, binary: &str) -> CliResult<String> {
let mut root = match existing {
Some(contents) if !contents.trim().is_empty() => {
let parsed: Value = serde_json::from_str(contents).map_err(|error| {
CliError::with_hint(
format!("existing .vscode/mcp.json is not valid JSON: {error}"),
"fix or remove .vscode/mcp.json and run `solverforge connect --write vscode` again",
)
})?;
let Value::Object(map) = parsed else {
return Err(CliError::with_hint(
"existing .vscode/mcp.json is not a JSON object",
"fix or remove .vscode/mcp.json and run `solverforge connect --write vscode` again",
));
};
map
}
_ => Map::new(),
};
let servers = root
.entry("servers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let Value::Object(servers) = servers else {
return Err(CliError::with_hint(
"existing .vscode/mcp.json has a non-object `servers` entry",
"fix or remove .vscode/mcp.json and run `solverforge connect --write vscode` again",
));
};
servers.insert(name.to_string(), stdio_server_json(binary));
serde_json::to_string_pretty(&Value::Object(root))
.map(|mut rendered| {
rendered.push('\n');
rendered
})
.map_err(|error| CliError::general(format!("failed to render VS Code config: {error}")))
}
fn resolve_binary(project_root: &Path, crate_name: &str) -> (String, bool) {
let executable = format!("{crate_name}{}", std::env::consts::EXE_SUFFIX);
for profile in ["release", "debug"] {
let candidate = project_root.join("target").join(profile).join(&executable);
if candidate.exists() {
return (candidate.display().to_string(), true);
}
}
(
project_root
.join("target")
.join("release")
.join(executable)
.display()
.to_string(),
false,
)
}
fn to_crate_name(name: &str) -> String {
name.chars()
.map(|c| {
if c == '-' {
'_'
} else {
c.to_ascii_lowercase()
}
})
.collect()
}
#[cfg(test)]
#[path = "connect_tests.rs"]
mod tests;