mod agent_config;
mod crypto;
mod install;
mod sanitize;
mod server;
mod tools;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme};
use elisym_core::AgentNodeBuilder;
use rmcp::{ServiceExt, transport::stdio};
use tracing_subscriber::{self, EnvFilter};
use zeroize::{Zeroize, Zeroizing};
use agent_config::{
builder_from_config, load_agent_config, run_init, validate_agent_name,
};
use server::ElisymServer;
#[derive(Parser)]
#[command(name = "elisym-mcp", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
http: bool,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value = "8080")]
port: u16,
#[arg(long)]
http_token: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
Install {
#[arg(long)]
client: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
http_token: Option<String>,
#[arg(long = "env", value_name = "KEY=VALUE")]
extra_env: Vec<String>,
#[arg(long)]
list: bool,
},
Uninstall {
#[arg(long)]
client: Option<String>,
},
Init {
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
capabilities: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
network: Option<String>,
#[arg(long)]
install: bool,
},
}
fn run_init_wizard() -> Result<()> {
let theme = ColorfulTheme::default();
println!();
println!(" elisym — agent setup wizard");
println!(" ───────────────────────────");
println!();
let name: String = Input::with_theme(&theme)
.with_prompt("Agent name")
.default("my-agent".into())
.validate_with(|input: &String| -> Result<(), String> {
validate_agent_name(input).map_err(|e| e.to_string())
})
.interact_text()
.context("Failed to read agent name")?;
let home = dirs::home_dir().context("Cannot find home directory")?;
let config_path = home
.join(".elisym")
.join("agents")
.join(&name)
.join("config.toml");
if config_path.exists() {
println!();
println!(" Agent '{}' already exists at {}", name, config_path.display());
println!(" To recreate, first delete the existing config.");
return Ok(());
}
let description: String = Input::with_theme(&theme)
.with_prompt("Description")
.default("Elisym MCP agent".into())
.interact_text()
.context("Failed to read description")?;
let capabilities: String = Input::with_theme(&theme)
.with_prompt("Capabilities (comma-separated)")
.default("mcp-gateway".into())
.interact_text()
.context("Failed to read capabilities")?;
let networks = &["devnet", "testnet", "mainnet"];
let network_idx = Select::with_theme(&theme)
.with_prompt("Solana network")
.items(networks)
.default(0)
.interact()
.context("Failed to select network")?;
let network = networks[network_idx];
let encrypt = Confirm::with_theme(&theme)
.with_prompt("Encrypt secret keys with a password?")
.default(false)
.interact()
.context("Failed to read encryption preference")?;
let mut password: Option<Zeroizing<String>> = if encrypt {
let pw = Password::with_theme(&theme)
.with_prompt("Password")
.with_confirmation("Confirm password", "Passwords don't match")
.interact()
.context("Failed to read password")?;
Some(Zeroizing::new(pw))
} else {
None
};
let already_installed = install::is_installed();
let auto_install = if already_installed {
false
} else {
Confirm::with_theme(&theme)
.with_prompt("Install into MCP clients (Claude Desktop, Cursor, etc.)?")
.default(true)
.interact()
.context("Failed to read install preference")?
};
println!();
run_init(
&name,
Some(&description),
Some(&capabilities),
password.as_deref().map(|s| s.as_str()),
network,
false,
)?;
if auto_install {
println!();
let mut env = vec![("ELISYM_AGENT".to_string(), name)];
if let Some(ref pw) = password {
env.push(("ELISYM_AGENT_PASSWORD".to_string(), pw.to_string()));
}
install::run_install(None, None, &env)?;
}
if let Some(ref mut pw) = password {
pw.zeroize();
}
Ok(())
}
#[cfg(feature = "transport-http")]
async fn start_http_server(
agent: elisym_core::AgentNode,
host: &str,
port: u16,
http_token: Option<String>,
) -> Result<()> {
use std::sync::Arc;
use tokio::sync::Mutex;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService,
session::local::LocalSessionManager,
};
let agent_name = agent.capability_card.name.clone();
let agent = Arc::new(agent);
server::spawn_ping_responder(Arc::clone(&agent));
let job_cache = Arc::new(Mutex::new(server::JobEventsCache::new()));
let mut registry = std::collections::HashMap::new();
registry.insert(agent_name.clone(), Arc::clone(&agent));
let agent_registry = Arc::new(std::sync::RwLock::new(registry));
let active_agent_name = Arc::new(std::sync::RwLock::new(agent_name));
let ct = tokio_util::sync::CancellationToken::new();
let config = StreamableHttpServerConfig {
stateful_mode: true,
cancellation_token: ct.clone(),
..Default::default()
};
let agent_clone = Arc::clone(&agent);
let registry_clone = Arc::clone(&agent_registry);
let active_clone = Arc::clone(&active_agent_name);
let job_cache_clone = Arc::clone(&job_cache);
let service: StreamableHttpService<ElisymServer, LocalSessionManager> =
StreamableHttpService::new(
move || Ok(ElisymServer::from_shared(
Arc::clone(&agent_clone),
Arc::clone(®istry_clone),
Arc::clone(&active_clone),
Arc::clone(&job_cache_clone),
)),
Default::default(),
config,
);
let mut router = axum::Router::new().nest_service("/mcp", service);
if let Some(token) = http_token {
use axum::http::StatusCode;
use subtle::ConstantTimeEq;
let expected = format!("Bearer {token}");
router = router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let expected = expected.clone();
async move {
let auth = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if auth.len() == expected.len()
&& auth.as_bytes().ct_eq(expected.as_bytes()).into()
{
Ok(next.run(req).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
},
));
tracing::info!("HTTP bearer token authentication enabled");
} else if host != "127.0.0.1" && host != "localhost" {
tracing::warn!(
"HTTP transport exposed on {host} without authentication. \
Consider using --http-token for security."
);
}
if host != "127.0.0.1" && host != "localhost" {
router = router.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::list([])),
);
}
let bind_addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.with_context(|| format!("Cannot bind to {bind_addr}"))?;
tracing::info!(address = %bind_addr, endpoint = "/mcp", "HTTP transport started");
axum::serve(listener, router)
.with_graceful_shutdown(async move {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Shutting down HTTP server");
ct.cancel();
})
.await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Install {
client,
agent,
mut password,
http_token: install_http_token,
extra_env,
list,
}) => {
if list {
install::run_list();
} else {
let mut env = Vec::new();
if let Some(ref pw) = password {
eprintln!("WARNING: --password is visible in process listings. Use ELISYM_AGENT_PASSWORD env var instead.");
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("HINT: Running non-interactively. Prefer ELISYM_AGENT_PASSWORD env var over --password.");
}
env.push(("ELISYM_AGENT_PASSWORD".to_string(), pw.clone()));
}
if let Some(ref tok) = install_http_token {
eprintln!("WARNING: --http-token is visible in process listings. Use ELISYM_HTTP_TOKEN env var instead.");
env.push(("ELISYM_HTTP_TOKEN".to_string(), tok.clone()));
}
for kv in &extra_env {
if let Some((k, v)) = kv.split_once('=') {
env.push((k.to_string(), v.to_string()));
} else {
anyhow::bail!("Invalid --env format: '{kv}'. Expected KEY=VALUE.");
}
}
install::run_install(client.as_deref(), agent.as_deref(), &env)?;
}
if let Some(ref mut pw) = password {
pw.zeroize();
}
return Ok(());
}
Some(Commands::Uninstall { client }) => {
install::run_uninstall(client.as_deref())?;
return Ok(());
}
Some(Commands::Init {
name,
description,
capabilities,
mut password,
network,
install: auto_install,
}) => {
if let Some(name) = name {
if password.is_some() {
eprintln!("WARNING: --password is visible in process listings. Use ELISYM_AGENT_PASSWORD env var instead.");
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("HINT: Running non-interactively. Prefer ELISYM_AGENT_PASSWORD env var over --password.");
}
}
let desc = description.as_deref().or(Some("Elisym MCP agent"));
let caps = capabilities.as_deref().or(Some("mcp-gateway"));
let net = network.as_deref().unwrap_or("devnet");
run_init(
&name,
desc,
caps,
password.as_deref(),
net,
false,
)?;
if auto_install {
if install::is_installed() {
println!();
println!(" MCP already configured — agent created.");
println!(" Use `create_agent` or `switch_agent` tools to manage agents at runtime.");
} else {
let mut env = vec![("ELISYM_AGENT".to_string(), name)];
if let Some(ref pw) = password {
env.push(("ELISYM_AGENT_PASSWORD".to_string(), pw.clone()));
}
install::run_install(None, None, &env)?;
}
}
if let Some(ref mut pw) = password {
pw.zeroize();
}
} else {
run_init_wizard()?;
}
return Ok(());
}
None => {}
}
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
tracing::info!("Starting elisym MCP server");
let builder = if let Ok(agent_name) = std::env::var("ELISYM_AGENT") {
let config = load_agent_config(&agent_name)?;
tracing::info!(agent = %agent_name, "Loading agent from ~/.elisym/agents/");
builder_from_config(&config)
} else if std::env::var("ELISYM_NOSTR_SECRET").is_ok() {
let agent_name =
std::env::var("ELISYM_AGENT_NAME").unwrap_or_else(|_| "mcp-agent".into());
let agent_desc = std::env::var("ELISYM_AGENT_DESCRIPTION")
.unwrap_or_else(|_| "elisym MCP server agent".into());
let secret = Zeroizing::new(std::env::var("ELISYM_NOSTR_SECRET").unwrap());
let mut b = AgentNodeBuilder::new(&agent_name, &agent_desc)
.capabilities(vec!["mcp-gateway".into()])
.secret_key(secret.as_str());
if let Ok(relays) = std::env::var("ELISYM_RELAYS") {
let relay_list: Vec<String> =
relays.split(',').map(|s| s.trim().to_string()).collect();
if !relay_list.is_empty() {
b = b.relays(relay_list);
}
}
b
} else {
let agent_name =
std::env::var("ELISYM_AGENT_NAME").unwrap_or_else(|_| "mcp-agent".into());
match load_agent_config(&agent_name) {
Ok(config) => {
tracing::info!(agent = %agent_name, "Reusing persisted agent identity");
builder_from_config(&config)
}
Err(_) => {
tracing::info!(agent = %agent_name, "Creating new agent identity");
run_init(&agent_name, None, None, None, "devnet", true)?;
let config = load_agent_config(&agent_name)
.context("Failed to load newly created agent config")?;
builder_from_config(&config)
}
}
};
let agent = builder.build().await?;
tracing::info!(
npub = %agent.identity.npub(),
payments = agent.payments.is_some(),
"Agent node started"
);
if cli.http {
#[cfg(feature = "transport-http")]
{
if cli.http_token.is_some() {
eprintln!("WARNING: --http-token is visible in process listings. Use ELISYM_HTTP_TOKEN env var instead.");
}
let http_token = cli
.http_token
.or_else(|| std::env::var("ELISYM_HTTP_TOKEN").ok());
start_http_server(agent, &cli.host, cli.port, http_token).await?;
}
#[cfg(not(feature = "transport-http"))]
{
anyhow::bail!(
"HTTP transport not available. Rebuild with: cargo build --features transport-http"
);
}
} else {
let server = ElisymServer::new(agent);
let service = server
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("Failed to start MCP service: {e}"))?;
service.waiting().await?;
}
tracing::info!("elisym MCP server stopped");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_agent_names() {
assert!(validate_agent_name("my-agent").is_ok());
assert!(validate_agent_name("agent_1").is_ok());
assert!(validate_agent_name("AgentX").is_ok());
assert!(validate_agent_name("a").is_ok());
}
#[test]
fn empty_name() {
assert!(validate_agent_name("").is_err());
}
#[test]
fn path_traversal() {
assert!(validate_agent_name("../evil").is_err());
assert!(validate_agent_name("foo/bar").is_err());
assert!(validate_agent_name("foo\\bar").is_err());
}
#[test]
fn hidden_dir() {
assert!(validate_agent_name(".hidden").is_err());
}
#[test]
fn control_chars() {
assert!(validate_agent_name("agent\x00").is_err());
assert!(validate_agent_name("agent\n").is_err());
assert!(validate_agent_name("agent\t").is_err());
}
#[test]
fn spaces_rejected() {
assert!(validate_agent_name("my agent").is_err());
}
#[test]
fn too_long_name() {
let long = "a".repeat(65);
assert!(validate_agent_name(&long).is_err());
let ok = "a".repeat(64);
assert!(validate_agent_name(&ok).is_ok());
}
#[test]
fn shell_metacharacters() {
assert!(validate_agent_name("agent;rm").is_err());
assert!(validate_agent_name("agent$(cmd)").is_err());
assert!(validate_agent_name("agent`cmd`").is_err());
}
}