//! go-mcp, an MCP server exposing GoingsOn's tasks and projects to an LLM pair. //! //! Built on kberg's `ToolRegistry` + Streamable HTTP server. Opens the same //! `goingson.db` the desktop app uses and writes through the normal repository //! layer, so the sync-changelog triggers fire and a running GoingsOn stays //! consistent. Primary consumer is the `/dellm` skill's todo migration. //! //! Usage: //! go-mcp [--db ] [--host ] [--port ] //! [--grant ]... | [--grant-all] [--compact] //! //! Reads (list_projects, list_tasks, get_task) are always callable. Writes are //! refused unless their capability is granted; grant them explicitly with //! `--grant go.task.bulk_import` (repeatable) or `--grant-all`. use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use kberg::SurfaceProjection; use kberg::server::{self, ServeConfig}; use go_mcp::context::{Ctx, default_db_path}; use go_mcp::tools; struct Args { db: Option, host: String, port: u16, grants: HashSet, grant_all: bool, compact: bool, stdio: bool, } impl Args { fn parse() -> Result { let mut db = None; let mut host = "127.0.0.1".to_string(); let mut port: u16 = 7337; let mut grants = HashSet::new(); let mut grant_all = false; let mut compact = false; let mut stdio = false; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { "--db" => db = Some(PathBuf::from(next(&mut it, "--db")?)), "--host" => host = next(&mut it, "--host")?, "--port" => { port = next(&mut it, "--port")? .parse() .map_err(|_| "--port must be a number".to_string())? } "--grant" => { grants.insert(next(&mut it, "--grant")?); } "--grant-all" => grant_all = true, "--compact" => compact = true, "--stdio" => stdio = true, "-h" | "--help" => { print_help(); std::process::exit(0); } other => return Err(format!("unknown argument: {other}")), } } Ok(Self { db, host, port, grants, grant_all, compact, stdio, }) } } fn next(it: &mut impl Iterator, flag: &str) -> Result { it.next().ok_or_else(|| format!("{flag} requires a value")) } fn print_help() { eprintln!( "go-mcp: MCP server for GoingsOn tasks/projects\n\n\ Usage: go-mcp [--db ] [--stdio] [--host ] [--port ]\n\ \x20 [--grant ]... | [--grant-all] [--compact]\n\n\ Transport: default is Streamable HTTP; --stdio speaks newline-delimited\n\ \x20 JSON-RPC over stdin/stdout (for `claude mcp add go-mcp -- go-mcp --stdio`).\n\n\ Write capabilities (grant to enable the matching tool):\n\ \x20 go.project.create go.task.create go.task.bulk_import\n\ \x20 go.task.update go.task.complete\n" ); } #[tokio::main] async fn main() -> Result<(), Box> { // Logs go to stderr: in --stdio mode stdout is the protocol channel and any // stray byte there corrupts the JSON-RPC stream. tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,go_mcp=debug".into()), ) .init(); let args = Args::parse().map_err(|e| { eprintln!("error: {e}\n"); print_help(); e })?; let db_path = args .db .or_else(default_db_path) .ok_or("could not resolve a default goingson.db path; pass --db")?; if !db_path.exists() { return Err(format!( "database not found at {}. Run GoingsOn at least once to create it, or pass --db.", db_path.display() ) .into()); } let pool = goingson_db_sqlite::init_pool(Some(&db_path.to_string_lossy())).await?; let ctx = Arc::new(Ctx::new(pool)); let registry = tools::registry(ctx); // Resolve the granted write capabilities. let grants: HashSet = if args.grant_all { registry .write_capabilities() .into_iter() .map(|c| c.id) .collect() } else { args.grants }; if grants.is_empty() { tracing::warn!( "no write capabilities granted; only read tools will work (use --grant or --grant-all)" ); } else { tracing::info!(?grants, "granted write capabilities"); } let projection = if args.compact { SurfaceProjection::Compact } else { SurfaceProjection::Full }; let config = ServeConfig { projection, grants: Some(grants), resources: None, }; if args.stdio { tracing::info!(db = %db_path.display(), "go-mcp serving over stdio"); server::stdio::serve(registry, config).await?; return Ok(()); } let bind = format!("{}:{}", args.host, args.port); let bound = server::serve(registry, &bind, config).await?; tracing::info!(db = %db_path.display(), url = %bound.url(), "go-mcp listening"); println!("go-mcp listening on {}", bound.url()); // Park until Ctrl-C or the server task ends. On Ctrl-C the `wait()` future // is dropped and the process exits; the detached server task goes with it. tokio::select! { _ = tokio::signal::ctrl_c() => tracing::info!("shutting down"), res = bound.wait() => res?, } Ok(()) }