Skip to main content

max / goingson

5.7 KB · 167 lines History Blame Raw
1 //! go-mcp — an MCP server exposing GoingsOn's tasks and projects to an LLM pair.
2 //!
3 //! Built on kberg's `ToolRegistry` + Streamable HTTP server. Opens the same
4 //! `goingson.db` the desktop app uses and writes through the normal repository
5 //! layer, so the sync-changelog triggers fire and a running GoingsOn stays
6 //! consistent. Primary consumer is the `/dellm` skill's todo migration.
7 //!
8 //! Usage:
9 //! go-mcp [--db <path>] [--host <ip>] [--port <n>]
10 //! [--grant <cap>]... | [--grant-all] [--compact]
11 //!
12 //! Reads (list_projects, list_tasks, get_task) are always callable. Writes are
13 //! refused unless their capability is granted; grant them explicitly with
14 //! `--grant go.task.bulk_import` (repeatable) or `--grant-all`.
15
16 use std::collections::HashSet;
17 use std::path::PathBuf;
18 use std::sync::Arc;
19
20 use kberg::server::{self, ServeConfig};
21 use kberg::SurfaceProjection;
22
23 use go_mcp::context::{Ctx, default_db_path};
24 use go_mcp::tools;
25
26 struct Args {
27 db: Option<PathBuf>,
28 host: String,
29 port: u16,
30 grants: HashSet<String>,
31 grant_all: bool,
32 compact: bool,
33 stdio: bool,
34 }
35
36 impl Args {
37 fn parse() -> Result<Self, String> {
38 let mut db = None;
39 let mut host = "127.0.0.1".to_string();
40 let mut port: u16 = 7337;
41 let mut grants = HashSet::new();
42 let mut grant_all = false;
43 let mut compact = false;
44 let mut stdio = false;
45
46 let mut it = std::env::args().skip(1);
47 while let Some(arg) = it.next() {
48 match arg.as_str() {
49 "--db" => db = Some(PathBuf::from(next(&mut it, "--db")?)),
50 "--host" => host = next(&mut it, "--host")?,
51 "--port" => {
52 port = next(&mut it, "--port")?
53 .parse()
54 .map_err(|_| "--port must be a number".to_string())?
55 }
56 "--grant" => {
57 grants.insert(next(&mut it, "--grant")?);
58 }
59 "--grant-all" => grant_all = true,
60 "--compact" => compact = true,
61 "--stdio" => stdio = true,
62 "-h" | "--help" => {
63 print_help();
64 std::process::exit(0);
65 }
66 other => return Err(format!("unknown argument: {other}")),
67 }
68 }
69 Ok(Self { db, host, port, grants, grant_all, compact, stdio })
70 }
71 }
72
73 fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
74 it.next().ok_or_else(|| format!("{flag} requires a value"))
75 }
76
77 fn print_help() {
78 eprintln!(
79 "go-mcp — MCP server for GoingsOn tasks/projects\n\n\
80 Usage: go-mcp [--db <path>] [--stdio] [--host <ip>] [--port <n>]\n\
81 \x20 [--grant <cap>]... | [--grant-all] [--compact]\n\n\
82 Transport: default is Streamable HTTP; --stdio speaks newline-delimited\n\
83 \x20 JSON-RPC over stdin/stdout (for `claude mcp add go-mcp -- go-mcp --stdio`).\n\n\
84 Write capabilities (grant to enable the matching tool):\n\
85 \x20 go.project.create go.task.create go.task.bulk_import\n\
86 \x20 go.task.update go.task.complete\n"
87 );
88 }
89
90 #[tokio::main]
91 async fn main() -> Result<(), Box<dyn std::error::Error>> {
92 // Logs go to stderr: in --stdio mode stdout is the protocol channel and any
93 // stray byte there corrupts the JSON-RPC stream.
94 tracing_subscriber::fmt()
95 .with_writer(std::io::stderr)
96 .with_env_filter(
97 tracing_subscriber::EnvFilter::try_from_default_env()
98 .unwrap_or_else(|_| "info,go_mcp=debug".into()),
99 )
100 .init();
101
102 let args = Args::parse().map_err(|e| {
103 eprintln!("error: {e}\n");
104 print_help();
105 e
106 })?;
107
108 let db_path = args
109 .db
110 .or_else(default_db_path)
111 .ok_or("could not resolve a default goingson.db path; pass --db")?;
112
113 if !db_path.exists() {
114 return Err(format!(
115 "database not found at {}. Run GoingsOn at least once to create it, or pass --db.",
116 db_path.display()
117 )
118 .into());
119 }
120
121 let pool = goingson_db_sqlite::init_pool(Some(&db_path.to_string_lossy())).await?;
122 let ctx = Arc::new(Ctx::new(pool));
123 let registry = tools::registry(ctx);
124
125 // Resolve the granted write capabilities.
126 let grants: HashSet<String> = if args.grant_all {
127 registry.write_capabilities().into_iter().map(|c| c.id).collect()
128 } else {
129 args.grants
130 };
131 if grants.is_empty() {
132 tracing::warn!("no write capabilities granted; only read tools will work (use --grant or --grant-all)");
133 } else {
134 tracing::info!(?grants, "granted write capabilities");
135 }
136
137 let projection = if args.compact {
138 SurfaceProjection::Compact
139 } else {
140 SurfaceProjection::Full
141 };
142 let config = ServeConfig {
143 projection,
144 grants: Some(grants),
145 resources: None,
146 };
147
148 if args.stdio {
149 tracing::info!(db = %db_path.display(), "go-mcp serving over stdio");
150 server::stdio::serve(registry, config).await?;
151 return Ok(());
152 }
153
154 let bind = format!("{}:{}", args.host, args.port);
155 let bound = server::serve(registry, &bind, config).await?;
156 tracing::info!(db = %db_path.display(), url = %bound.url(), "go-mcp listening");
157 println!("go-mcp listening on {}", bound.url());
158
159 // Park until Ctrl-C or the server task ends. On Ctrl-C the `wait()` future
160 // is dropped and the process exits; the detached server task goes with it.
161 tokio::select! {
162 _ = tokio::signal::ctrl_c() => tracing::info!("shutting down"),
163 res = bound.wait() => res?,
164 }
165 Ok(())
166 }
167