Skip to main content

max / goingson

5.8 KB · 181 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::SurfaceProjection;
21 use kberg::server::{self, ServeConfig};
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 {
70 db,
71 host,
72 port,
73 grants,
74 grant_all,
75 compact,
76 stdio,
77 })
78 }
79 }
80
81 fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
82 it.next().ok_or_else(|| format!("{flag} requires a value"))
83 }
84
85 fn print_help() {
86 eprintln!(
87 "go-mcp: MCP server for GoingsOn tasks/projects\n\n\
88 Usage: go-mcp [--db <path>] [--stdio] [--host <ip>] [--port <n>]\n\
89 \x20 [--grant <cap>]... | [--grant-all] [--compact]\n\n\
90 Transport: default is Streamable HTTP; --stdio speaks newline-delimited\n\
91 \x20 JSON-RPC over stdin/stdout (for `claude mcp add go-mcp -- go-mcp --stdio`).\n\n\
92 Write capabilities (grant to enable the matching tool):\n\
93 \x20 go.project.create go.task.create go.task.bulk_import\n\
94 \x20 go.task.update go.task.complete\n"
95 );
96 }
97
98 #[tokio::main]
99 async fn main() -> Result<(), Box<dyn std::error::Error>> {
100 // Logs go to stderr: in --stdio mode stdout is the protocol channel and any
101 // stray byte there corrupts the JSON-RPC stream.
102 tracing_subscriber::fmt()
103 .with_writer(std::io::stderr)
104 .with_env_filter(
105 tracing_subscriber::EnvFilter::try_from_default_env()
106 .unwrap_or_else(|_| "info,go_mcp=debug".into()),
107 )
108 .init();
109
110 let args = Args::parse().map_err(|e| {
111 eprintln!("error: {e}\n");
112 print_help();
113 e
114 })?;
115
116 let db_path = args
117 .db
118 .or_else(default_db_path)
119 .ok_or("could not resolve a default goingson.db path; pass --db")?;
120
121 if !db_path.exists() {
122 return Err(format!(
123 "database not found at {}. Run GoingsOn at least once to create it, or pass --db.",
124 db_path.display()
125 )
126 .into());
127 }
128
129 let pool = goingson_db_sqlite::init_pool(Some(&db_path.to_string_lossy())).await?;
130 let ctx = Arc::new(Ctx::new(pool));
131 let registry = tools::registry(ctx);
132
133 // Resolve the granted write capabilities.
134 let grants: HashSet<String> = if args.grant_all {
135 registry
136 .write_capabilities()
137 .into_iter()
138 .map(|c| c.id)
139 .collect()
140 } else {
141 args.grants
142 };
143 if grants.is_empty() {
144 tracing::warn!(
145 "no write capabilities granted; only read tools will work (use --grant or --grant-all)"
146 );
147 } else {
148 tracing::info!(?grants, "granted write capabilities");
149 }
150
151 let projection = if args.compact {
152 SurfaceProjection::Compact
153 } else {
154 SurfaceProjection::Full
155 };
156 let config = ServeConfig {
157 projection,
158 grants: Some(grants),
159 resources: None,
160 };
161
162 if args.stdio {
163 tracing::info!(db = %db_path.display(), "go-mcp serving over stdio");
164 server::stdio::serve(registry, config).await?;
165 return Ok(());
166 }
167
168 let bind = format!("{}:{}", args.host, args.port);
169 let bound = server::serve(registry, &bind, config).await?;
170 tracing::info!(db = %db_path.display(), url = %bound.url(), "go-mcp listening");
171 println!("go-mcp listening on {}", bound.url());
172
173 // Park until Ctrl-C or the server task ends. On Ctrl-C the `wait()` future
174 // is dropped and the process exits; the detached server task goes with it.
175 tokio::select! {
176 _ = tokio::signal::ctrl_c() => tracing::info!("shutting down"),
177 res = bound.wait() => res?,
178 }
179 Ok(())
180 }
181