use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::{env, fs, process};
use harn_vm::mcp_auth::OAuthClientAuthMode;
use harn_vm::mcp_oauth::{self, BeginAuthorization};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::cli::{McpCommand, McpLoginArgs, McpServerRefArgs};
use crate::package::{self, McpAuthConfig, McpServerConfig};
mod mock;
mod oauth_resource;
pub(crate) mod presets;
pub(crate) mod serve;
const DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:9783/oauth/callback";
use harn_vm::mcp_protocol::PROTOCOL_VERSION as MCP_PROTOCOL_VERSION;
#[derive(Clone)]
pub(crate) struct ResolvedMcpServer {
pub name: String,
pub url: String,
pub auth: Option<McpAuthConfig>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub scopes: Option<String>,
}
pub(crate) enum AuthResolution {
None,
Bearer(String),
}
pub(crate) async fn handle_mcp_command(command: &McpCommand) {
match command {
McpCommand::Serve(args) => {
if let Err(error) = serve::run(args).await {
eprintln!("error: {error}");
process::exit(1);
}
}
McpCommand::Mock(args) => match mock::run(&args.command).await {
Ok(0) => {}
Ok(code) => process::exit(code),
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
},
McpCommand::Login(options) => {
if let Err(error) = login(options).await {
eprintln!("error: {error}");
process::exit(1);
}
}
McpCommand::Logout(server_ref) => {
let server = resolve_server_reference(server_ref).unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
let discovery = mcp_oauth::discover(&server.url)
.await
.unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
let resource = canonical_server_resource(&server.url).unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
mcp_oauth::delete_token(
&resource,
&discovery.authorization_server_issuer,
server.client_id.as_deref(),
)
.await
.unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
println!(
"Removed stored OAuth token for {} ({})",
server.name, server.url
);
}
McpCommand::Status(server_ref) => {
if server_ref.target.is_none() && server_ref.url.is_none() {
if let Err(error) = run_mcp_status_report(server_ref.json).await {
eprintln!("error: {error}");
process::exit(1);
}
return;
}
let server = resolve_server_reference(server_ref).unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
let discovery = mcp_oauth::discover(&server.url)
.await
.unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
let resource = canonical_server_resource(&server.url).unwrap_or_else(|error| {
eprintln!("error: {error}");
process::exit(1);
});
match mcp_oauth::load_token(
&resource,
&discovery.authorization_server_issuer,
server.client_id.as_deref(),
)
.await
{
Ok(Some(token)) => {
println!("Server: {}", server.name);
println!("URL: {}", server.url);
println!("Connected: yes");
println!("Protocol: {MCP_PROTOCOL_VERSION}");
println!(
"Expires: {}",
token
.expires_at_unix
.map(format_expiry)
.unwrap_or_else(|| "unknown".to_string())
);
println!("Client ID: {}", token.client_id);
println!("Token auth method: {}", token.token_endpoint_auth_method);
println!("Issuer: {}", token.issuer);
}
Ok(None) => {
println!("Server: {}", server.name);
println!("URL: {}", server.url);
println!("Connected: no");
}
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
McpCommand::RedirectUri => {
println!("{DEFAULT_REDIRECT_URI}");
}
McpCommand::Presets(args) => {
presets::run(args);
}
}
}
pub(crate) const MCP_STATUS_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct McpStatusReport {
pub schema_version: u32,
pub manifest: Option<String>,
pub servers: Vec<McpServerStatus>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct McpServerStatus {
pub name: String,
pub transport: String,
pub state: String,
pub url: String,
pub lazy: bool,
pub tools: Option<usize>,
pub resources: Option<usize>,
pub prompts: Option<usize>,
pub last_error: Option<String>,
}
async fn run_mcp_status_report(json: bool) -> Result<(), String> {
let report = mcp_status_report().await;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|error| format!("failed to encode JSON output: {error}"))?
);
} else if report.servers.is_empty() {
println!("No [[mcp]] servers declared in the nearest harn.toml.");
} else {
for server in &report.servers {
let counts = match (server.tools, server.resources, server.prompts) {
(Some(t), Some(r), Some(p)) => format!("tools={t} resources={r} prompts={p}"),
_ => "tools=? resources=? prompts=?".to_string(),
};
let mut line = format!(
"{}\t{}\t{}\t{}",
server.name, server.transport, server.state, counts
);
if let Some(error) = &server.last_error {
line.push_str(&format!("\terror={error}"));
}
println!("{line}");
}
}
Ok(())
}
pub(crate) async fn mcp_status_report() -> McpStatusReport {
let (manifest_path, servers) = match find_manifest() {
Ok((path, manifest)) => (Some(path.display().to_string()), manifest.mcp),
Err(_) => (None, Vec::new()),
};
let mut entries = Vec::with_capacity(servers.len());
for server in servers {
entries.push(derive_server_status(&server).await);
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
McpStatusReport {
schema_version: MCP_STATUS_SCHEMA_VERSION,
manifest: manifest_path,
servers: entries,
}
}
async fn derive_server_status(server: &McpServerConfig) -> McpServerStatus {
let transport = server
.transport
.clone()
.unwrap_or_else(|| "stdio".to_string());
let registry = harn_vm::mcp_snapshot_status()
.into_iter()
.find(|entry| entry.name == server.name);
let active = registry.as_ref().map(|entry| entry.active).unwrap_or(false);
let mut last_error = None;
let state = if transport == "http" && !server.url.is_empty() {
if server.auth_token.as_deref().is_some_and(|t| !t.is_empty()) {
"connected".to_string()
} else {
match resolve_auth_for_server(server).await {
Ok(AuthResolution::Bearer(_)) => "connected".to_string(),
Ok(AuthResolution::None) => "auth_required".to_string(),
Err(error) => {
last_error = Some(error);
"error".to_string()
}
}
}
} else if active {
"connected".to_string()
} else {
"disconnected".to_string()
};
McpServerStatus {
name: server.name.clone(),
transport,
state,
url: server.url.clone(),
lazy: server.lazy,
tools: None,
resources: None,
prompts: None,
last_error,
}
}
pub(crate) async fn resolve_auth_for_server(
server: &McpServerConfig,
) -> Result<AuthResolution, String> {
if let Some(auth) = server.auth.as_ref() {
if auth.mode == Some(OAuthClientAuthMode::Static) || auth.secret_id.is_some() {
let secret_id = auth.secret_id.as_deref().ok_or_else(|| {
format!(
"MCP server '{}' uses static auth but does not set auth.secret_id",
server.name
)
})?;
let token =
crate::commands::connect::store::load_connect_secret_text(secret_id).await?;
return Ok(AuthResolution::Bearer(token));
}
}
if let Some(token) = &server.auth_token {
if !token.is_empty() {
return Ok(AuthResolution::Bearer(token.clone()));
}
}
let transport = server.transport.as_deref().unwrap_or("stdio");
if transport != "http" || server.url.is_empty() {
return Ok(AuthResolution::None);
}
match mcp_oauth::resolve_bearer(&server.url).await? {
Some(bearer) => Ok(AuthResolution::Bearer(bearer)),
None => Ok(AuthResolution::None),
}
}
async fn login(options: &McpLoginArgs) -> Result<(), String> {
let server = resolve_server_reference(&McpServerRefArgs {
target: options.target.clone(),
url: options.url.clone(),
json: false,
})?;
let client_secret = if let Some(secret) = options.client_secret.clone() {
Some(secret)
} else if let Some(secret_id) = server
.auth
.as_ref()
.and_then(|auth| auth.client_secret_id.as_deref())
{
Some(crate::commands::connect::store::load_connect_secret_text(secret_id).await?)
} else {
server.client_secret.clone()
};
let callback_listener = bind_callback_listener(&options.redirect_uri)?;
let pending = mcp_oauth::begin_authorization(BeginAuthorization {
server_url: server.url.clone(),
redirect_uri: options.redirect_uri.clone(),
mode: server.auth.as_ref().and_then(|auth| auth.mode),
client_id: options
.client_id
.clone()
.or_else(|| server.auth.as_ref().and_then(|auth| auth.client_id.clone()))
.or(server.client_id.clone()),
client_secret,
static_secret_id: server.auth.as_ref().and_then(|auth| auth.secret_id.clone()),
scopes: options
.scope
.clone()
.or_else(|| server.auth.as_ref().and_then(|auth| auth.scopes.clone()))
.or(server.scopes.clone()),
})
.await?;
println!("Server: {} ({})", server.name, server.url);
println!("Redirect URI: {}", options.redirect_uri);
println!("Protocol Version: {MCP_PROTOCOL_VERSION}");
println!("Opening browser for OAuth authorization...");
if webbrowser::open(&pending.authorize_url).is_err() {
println!("Open this URL manually:\n{}", pending.authorize_url);
}
let callback =
wait_for_oauth_response(callback_listener, &options.redirect_uri, &pending.state)?;
mcp_oauth::complete_authorization(&pending.state, &callback.code, callback.issuer.as_deref())
.await?;
println!("OAuth token stored for {}.", server.name);
Ok(())
}
fn resolve_server_reference(server_ref: &McpServerRefArgs) -> Result<ResolvedMcpServer, String> {
if let Some(url) = &server_ref.url {
return Ok(ResolvedMcpServer {
name: server_ref
.target
.clone()
.unwrap_or_else(|| infer_name_from_url(url)),
url: url.clone(),
auth: None,
client_id: None,
client_secret: None,
scopes: None,
});
}
let target = server_ref
.target
.as_ref()
.ok_or_else(|| "Missing MCP server name or URL".to_string())?;
if target.starts_with("http://") || target.starts_with("https://") {
return Ok(ResolvedMcpServer {
name: infer_name_from_url(target),
url: target.clone(),
auth: None,
client_id: None,
client_secret: None,
scopes: None,
});
}
let (_, manifest) = find_manifest()?;
let server = manifest
.mcp
.into_iter()
.find(|entry| entry.name == *target)
.ok_or_else(|| format!("No [[mcp]] entry named '{target}' in the nearest harn.toml"))?;
if server.url.is_empty() {
return Err(format!(
"MCP server '{target}' does not define a remote URL. Use --url for ad hoc login or add url = ... to harn.toml."
));
}
Ok(ResolvedMcpServer {
name: server.name,
url: server.url,
auth: server.auth,
client_id: server.client_id,
client_secret: server.client_secret,
scopes: server.scopes,
})
}
fn find_manifest() -> Result<(PathBuf, package::Manifest), String> {
let mut dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
loop {
let manifest_path = dir.join("harn.toml");
if manifest_path.is_file() {
let content = fs::read_to_string(&manifest_path)
.map_err(|error| format!("Failed to read {}: {error}", manifest_path.display()))?;
let manifest = toml::from_str::<package::Manifest>(&content)
.map_err(|error| format!("Failed to parse {}: {error}", manifest_path.display()))?;
return Ok((manifest_path, manifest));
}
if !dir.pop() {
break;
}
}
Err("No harn.toml found in the current directory or its parents".to_string())
}
fn canonical_server_resource(server_url: &str) -> Result<String, String> {
harn_vm::mcp_auth::canonical_resource_indicator(server_url).map_err(|error| error.to_string())
}
fn bind_callback_listener(redirect_uri: &str) -> Result<TcpListener, String> {
let parsed =
Url::parse(redirect_uri).map_err(|error| format!("Invalid redirect URI: {error}"))?;
let host = parsed
.host_str()
.ok_or_else(|| "Redirect URI must include a host".to_string())?;
let port = parsed
.port_or_known_default()
.ok_or_else(|| "Redirect URI must include a port".to_string())?;
let listener = TcpListener::bind((host, port))
.map_err(|error| format!("Failed to bind redirect URI {redirect_uri}: {error}"))?;
listener
.set_nonblocking(false)
.map_err(|error| format!("Failed to configure redirect listener: {error}"))?;
Ok(listener)
}
struct OAuthCallbackResponse {
code: String,
issuer: Option<String>,
}
fn wait_for_oauth_response(
listener: TcpListener,
redirect_uri: &str,
expected_state: &str,
) -> Result<OAuthCallbackResponse, String> {
let expected_path = Url::parse(redirect_uri)
.map_err(|error| format!("Invalid redirect URI: {error}"))?
.path()
.to_string();
let (mut stream, _) = listener
.accept()
.map_err(|error| format!("Failed to accept OAuth callback: {error}"))?;
let mut buffer = [0u8; 8192];
let bytes_read = stream
.read(&mut buffer)
.map_err(|error| format!("Failed to read OAuth callback: {error}"))?;
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let request_line = request
.lines()
.next()
.ok_or_else(|| "OAuth callback request was empty".to_string())?;
let path_and_query = request_line
.split_whitespace()
.nth(1)
.ok_or_else(|| "OAuth callback request line was invalid".to_string())?;
let callback_url = Url::parse(&format!("http://127.0.0.1{path_and_query}"))
.map_err(|error| format!("OAuth callback URL was invalid: {error}"))?;
let response = if callback_url.path() != expected_path {
html_response(404, "Invalid callback path")
} else if callback_url
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.as_deref()
!= Some(expected_state)
{
html_response(400, "State mismatch")
} else if let Some(error) = callback_url
.query_pairs()
.find(|(key, _)| key == "error")
.map(|(_, value)| value.into_owned())
{
let _ = stream
.write_all(html_response(400, &format!("Authorization failed: {error}")).as_bytes());
return Err(format!("Authorization failed: {error}"));
} else {
html_response(200, "Authorization complete. You can close this window.")
};
let _ = stream.write_all(response.as_bytes());
let code = callback_url
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, value)| value.into_owned())
.ok_or_else(|| "OAuth callback did not include an authorization code".to_string())?;
let issuer = callback_url
.query_pairs()
.find(|(key, _)| key == "iss")
.map(|(_, value)| value.into_owned());
Ok(OAuthCallbackResponse { code, issuer })
}
fn format_expiry(unix: i64) -> String {
unix.to_string()
}
fn infer_name_from_url(url: &str) -> String {
Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|host| host.to_string()))
.unwrap_or_else(|| "remote".to_string())
}
fn html_response(status: u16, message: &str) -> String {
let status_line = match status {
200 => "HTTP/1.1 200 OK",
400 => "HTTP/1.1 400 Bad Request",
_ => "HTTP/1.1 404 Not Found",
};
let (title, accent, badge) = match status {
200 => ("Authorization Complete", "#159f6b", "Connected"),
400 => ("Authorization Failed", "#c76b19", "Retry Needed"),
_ => ("Callback Error", "#b42318", "Invalid Request"),
};
let message = html_escape(message);
format!(
r#"{status_line}
Content-Type: text/html; charset=utf-8
Connection: close
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{ color-scheme: light dark; }}
body {{ margin: 0; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at top, rgba(21,159,107,.12), transparent 35%), #0f1115; color: #f5f7fa; min-height: 100vh; display: grid; place-items: center; }}
.card {{ width: min(560px, calc(100vw - 32px)); background: rgba(17, 24, 39, 0.88); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 28px; box-shadow: 0 24px 80px rgba(0,0,0,0.35); }}
.badge {{ display: inline-block; padding: 6px 10px; border-radius: 999px; background: {accent}; color: white; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }}
h1 {{ margin: 16px 0 10px; font-size: 28px; line-height: 1.1; }}
p {{ margin: 0; color: #c6cfdb; font-size: 15px; line-height: 1.55; }}
.hint {{ margin-top: 18px; color: #98a4b3; font-size: 13px; }}
.dot {{ width: 14px; height: 14px; border-radius: 999px; background: {accent}; box-shadow: 0 0 0 8px rgba(255,255,255,0.06); }}
.row {{ display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }}
</style>
</head>
<body>
<main class="card">
<div class="row"><div class="dot"></div><span class="badge">{badge}</span></div>
<h1>{title}</h1>
<p>{message}</p>
<p class="hint">You can close this tab and return to Harn.</p>
</main>
</body>
</html>"#
)
}
fn html_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
use harn_vm::mcp_auth::{
authorization_server_metadata_candidates, build_oauth_authorization_url,
canonical_resource_indicator, protected_resource_metadata_candidates,
OAuthAuthorizationUrlOptions,
};
#[test]
fn protected_resource_candidate_prefers_path_specific_url() {
let url = Url::parse("https://example.com/mcp/notion").unwrap();
let candidates = protected_resource_metadata_candidates(&url);
assert_eq!(
candidates[0].as_str(),
"https://example.com/.well-known/oauth-protected-resource/mcp/notion"
);
assert_eq!(
candidates[1].as_str(),
"https://example.com/.well-known/oauth-protected-resource"
);
}
#[test]
fn authorization_server_candidate_prefers_path_specific_metadata() {
let url = Url::parse("https://auth.example.com/oauth").unwrap();
let candidates = authorization_server_metadata_candidates(&url);
assert_eq!(
candidates[0].url.as_str(),
"https://auth.example.com/.well-known/oauth-authorization-server/oauth"
);
assert_eq!(
candidates[1].url.as_str(),
"https://auth.example.com/.well-known/openid-configuration/oauth"
);
assert_eq!(
candidates[2].url.as_str(),
"https://auth.example.com/oauth/.well-known/openid-configuration"
);
}
#[test]
fn callback_html_response_escapes_message() {
let response = html_response(400, "<script>alert('x')</script>&");
assert!(response.contains("<script>alert('x')</script>&"));
assert!(!response.contains("<script>"));
}
#[test]
fn authorization_url_includes_canonical_resource_indicator() {
let resource = canonical_resource_indicator("https://MCP.Example.com:443/mcp/").unwrap();
let url = build_oauth_authorization_url(OAuthAuthorizationUrlOptions {
authorization_endpoint: "https://auth.example.com/authorize",
client_id: "client-123",
redirect_uri: "http://127.0.0.1:9783/oauth/callback",
state: "state-abc",
code_challenge: "challenge-xyz",
resource: &resource,
scopes: Some("mcp.read"),
})
.unwrap();
let resource_param = url
.query_pairs()
.find(|(key, _)| key == "resource")
.map(|(_, value)| value.into_owned());
assert_eq!(
resource_param.as_deref(),
Some("https://mcp.example.com/mcp")
);
}
fn parse_server(toml_table: &str) -> McpServerConfig {
toml::from_str::<McpServerConfig>(toml_table).expect("mcp server config")
}
#[tokio::test]
async fn stdio_server_without_live_handle_is_disconnected() {
let server =
parse_server("name = \"fs\"\ntransport = \"stdio\"\ncommand = \"/bin/true\"\n");
let status = derive_server_status(&server).await;
assert_eq!(status.name, "fs");
assert_eq!(status.transport, "stdio");
assert_eq!(status.state, "disconnected");
assert_eq!(status.url, "");
assert!(status.tools.is_none());
assert!(status.last_error.is_none());
}
#[tokio::test]
async fn http_server_with_static_token_is_connected() {
let server = parse_server(
"name = \"api\"\ntransport = \"http\"\nurl = \"https://mcp.example.com/mcp\"\nauth_token = \"static-bearer\"\n",
);
let status = derive_server_status(&server).await;
assert_eq!(status.transport, "http");
assert_eq!(status.state, "connected");
assert_eq!(status.url, "https://mcp.example.com/mcp");
}
#[test]
fn status_report_serializes_with_stable_keys() {
let report = McpStatusReport {
schema_version: MCP_STATUS_SCHEMA_VERSION,
manifest: Some("/repo/harn.toml".to_string()),
servers: vec![McpServerStatus {
name: "fs".to_string(),
transport: "stdio".to_string(),
state: "disconnected".to_string(),
url: String::new(),
lazy: true,
tools: None,
resources: None,
prompts: None,
last_error: None,
}],
};
let value: serde_json::Value = serde_json::to_value(&report).expect("serialize");
assert_eq!(value["schema_version"], 1);
assert_eq!(value["manifest"], "/repo/harn.toml");
assert_eq!(value["servers"][0]["name"], "fs");
assert_eq!(value["servers"][0]["transport"], "stdio");
assert_eq!(value["servers"][0]["state"], "disconnected");
assert_eq!(value["servers"][0]["lazy"], true);
assert!(value["servers"][0]["tools"].is_null());
assert!(value["servers"][0]["last_error"].is_null());
}
}