Skip to main content

max / goingson

add go-mcp: MCP server exposing tasks and projects to an LLM pair New bin+lib crate crates/go-mcp built on kberg. Opens goingson.db directly through the repository layer, so the sync-changelog triggers fire and a running GoingsOn picks up writes. Reads plus capability-gated writes: bulk_import_tasks (the /dellm primitive, source-tag idempotent), create_project, create_task, update_task, complete_task. 4 e2e tests over a seeded in-memory database. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 18:55 UTC
Commit: 1124a3074084a087917f6c3e732286a07cd81abc
Parent: fdb5fd5
13 files changed, +1384 insertions, -0 deletions
M Cargo.lock +107
@@ -330,6 +330,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
330 330 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
331 331
332 332 [[package]]
333 + name = "axum"
334 + version = "0.8.9"
335 + source = "registry+https://github.com/rust-lang/crates.io-index"
336 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
337 + dependencies = [
338 + "axum-core",
339 + "bytes",
340 + "form_urlencoded",
341 + "futures-util",
342 + "http",
343 + "http-body",
344 + "http-body-util",
345 + "hyper",
346 + "hyper-util",
347 + "itoa",
348 + "matchit",
349 + "memchr",
350 + "mime",
351 + "percent-encoding",
352 + "pin-project-lite",
353 + "serde_core",
354 + "serde_json",
355 + "serde_path_to_error",
356 + "serde_urlencoded",
357 + "sync_wrapper",
358 + "tokio",
359 + "tower",
360 + "tower-layer",
361 + "tower-service",
362 + "tracing",
363 + ]
364 +
365 + [[package]]
366 + name = "axum-core"
367 + version = "0.5.6"
368 + source = "registry+https://github.com/rust-lang/crates.io-index"
369 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
370 + dependencies = [
371 + "bytes",
372 + "futures-core",
373 + "http",
374 + "http-body",
375 + "http-body-util",
376 + "mime",
377 + "pin-project-lite",
378 + "sync_wrapper",
379 + "tower-layer",
380 + "tower-service",
381 + "tracing",
382 + ]
383 +
384 + [[package]]
333 385 name = "base64"
334 386 version = "0.21.7"
335 387 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1845,6 +1897,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1845 1897 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
1846 1898
1847 1899 [[package]]
1900 + name = "go-mcp"
1901 + version = "0.4.2"
1902 + dependencies = [
1903 + "async-trait",
1904 + "chrono",
1905 + "goingson-core",
1906 + "goingson-db-sqlite",
1907 + "kberg",
1908 + "serde",
1909 + "serde_json",
1910 + "sqlx",
1911 + "tokio",
1912 + "tracing",
1913 + "tracing-subscriber",
1914 + "uuid",
1915 + ]
1916 +
1917 + [[package]]
1848 1918 name = "gobject-sys"
1849 1919 version = "0.18.0"
1850 1920 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2205,6 +2275,7 @@ dependencies = [
2205 2275 "http",
2206 2276 "http-body",
2207 2277 "httparse",
2278 + "httpdate",
2208 2279 "itoa",
2209 2280 "pin-project-lite",
2210 2281 "pin-utils",
@@ -2675,6 +2746,22 @@ dependencies = [
2675 2746 ]
2676 2747
2677 2748 [[package]]
2749 + name = "kberg"
2750 + version = "0.1.0"
2751 + dependencies = [
2752 + "async-trait",
2753 + "axum",
2754 + "serde",
2755 + "serde_json",
2756 + "thiserror 2.0.18",
2757 + "tokio",
2758 + "tower",
2759 + "tower-http",
2760 + "tracing",
2761 + "uuid",
2762 + ]
2763 +
2764 + [[package]]
2678 2765 name = "keyboard-types"
2679 2766 version = "0.7.0"
2680 2767 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2977,6 +3064,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2977 3064 checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
2978 3065
2979 3066 [[package]]
3067 + name = "matchit"
3068 + version = "0.8.4"
3069 + source = "registry+https://github.com/rust-lang/crates.io-index"
3070 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
3071 +
3072 + [[package]]
2980 3073 name = "md-5"
2981 3074 version = "0.10.6"
2982 3075 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4876,6 +4969,17 @@ dependencies = [
4876 4969 ]
4877 4970
4878 4971 [[package]]
4972 + name = "serde_path_to_error"
4973 + version = "0.1.20"
4974 + source = "registry+https://github.com/rust-lang/crates.io-index"
4975 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
4976 + dependencies = [
4977 + "itoa",
4978 + "serde",
4979 + "serde_core",
4980 + ]
4981 +
4982 + [[package]]
4879 4983 name = "serde_repr"
4880 4984 version = "0.1.20"
4881 4985 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6221,6 +6325,7 @@ dependencies = [
6221 6325 "libc",
6222 6326 "mio 1.2.0",
6223 6327 "pin-project-lite",
6328 + "signal-hook-registry",
6224 6329 "socket2",
6225 6330 "tokio-macros",
6226 6331 "windows-sys 0.61.2",
@@ -6399,6 +6504,7 @@ dependencies = [
6399 6504 "tokio",
6400 6505 "tower-layer",
6401 6506 "tower-service",
6507 + "tracing",
6402 6508 ]
6403 6509
6404 6510 [[package]]
@@ -6417,6 +6523,7 @@ dependencies = [
6417 6523 "tower",
6418 6524 "tower-layer",
6419 6525 "tower-service",
6526 + "tracing",
6420 6527 ]
6421 6528
6422 6529 [[package]]
M Cargo.toml +4
@@ -2,6 +2,7 @@
2 2 members = [
3 3 "crates/core",
4 4 "crates/db-sqlite",
5 + "crates/go-mcp",
5 6 "src-tauri",
6 7 ]
7 8 default-members = ["src-tauri"]
@@ -93,6 +94,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
93 94 # Tag standard
94 95 tagtree = { path = "../../MNW/shared/tagtree" }
95 96
97 + # MCP bridge (go-mcp)
98 + kberg = { path = "../../MNW/shared/kberg", default-features = false, features = ["server"] }
99 +
96 100 # Internal crates
97 101 goingson-core = { path = "crates/core" }
98 102 goingson-db-sqlite = { path = "crates/db-sqlite" }
@@ -0,0 +1,27 @@
1 + [package]
2 + name = "go-mcp"
3 + version.workspace = true
4 + edition.workspace = true
5 +
6 + [lib]
7 + name = "go_mcp"
8 + path = "src/lib.rs"
9 +
10 + [[bin]]
11 + name = "go-mcp"
12 + path = "src/main.rs"
13 +
14 + [dependencies]
15 + goingson-core = { workspace = true, features = ["sqlx-sqlite"] }
16 + goingson-db-sqlite = { workspace = true }
17 + kberg = { workspace = true }
18 +
19 + tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
20 + sqlx = { workspace = true, features = ["sqlite"] }
21 + serde = { workspace = true }
22 + serde_json = { workspace = true }
23 + chrono = { workspace = true }
24 + uuid = { workspace = true }
25 + async-trait = { workspace = true }
26 + tracing = { workspace = true }
27 + tracing-subscriber = { workspace = true }
@@ -0,0 +1,68 @@
1 + # go-mcp
2 +
3 + An MCP server that exposes GoingsOn's tasks and projects to an LLM pair. It lets
4 + a Claude session create and query tasks directly instead of round-tripping
5 + through the CSV import wizard. The primary consumer is the `/dellm` skill, which
6 + migrates scattered todo backlogs into GoingsOn.
7 +
8 + Built on [kberg](../../../../MNW/shared/kberg) (`ToolRegistry` + Streamable HTTP
9 + MCP server). go-mcp opens the same `goingson.db` the desktop app uses and writes
10 + through the normal repository layer, so the sync-changelog triggers fire and a
11 + running GoingsOn stays consistent. It is a peer writer, never a back door.
12 +
13 + ## Run
14 +
15 + ```
16 + go-mcp [--db <path>] [--host <ip>] [--port <n>]
17 + [--grant <cap>]... | [--grant-all] [--compact]
18 + ```
19 +
20 + - `--db` defaults to the desktop app's database (`com.goingson.app`'s
21 + `app_data_dir`). The database must already exist — run GoingsOn once to create
22 + the schema and the single desktop user.
23 + - Reads (`list_projects`, `list_tasks`, `get_task`) are always callable.
24 + - Writes are refused unless their capability is granted. Grant them with
25 + `--grant go.task.bulk_import` (repeatable) or `--grant-all` for a fully-trusted
26 + local session.
27 + - `--compact` serves only the small-model-safe surface (the read tools).
28 +
29 + Default bind is `127.0.0.1:7337`; the MCP endpoint is `POST /mcp`.
30 +
31 + ## Tools
32 +
33 + Read:
34 +
35 + - `list_projects` — id, name, type, status.
36 + - `list_tasks(project?, status?, tag?)` — compact task rows.
37 + - `get_task(id)` — one task with subtasks and annotations.
38 +
39 + Write (each gated on a capability id):
40 +
41 + | Tool | Capability |
42 + |------|------------|
43 + | `create_project(name, type?, description?)` — idempotent on name | `go.project.create` |
44 + | `create_task({description, project?, tags?, due?, priority?})` | `go.task.create` |
45 + | `bulk_import_tasks([...])` — the `/dellm` primitive | `go.task.bulk_import` |
46 + | `update_task(id, fields)` — overlays only the fields you pass | `go.task.update` |
47 + | `complete_task(id)` | `go.task.complete` |
48 +
49 + `bulk_import_tasks` dedupes on a `source:` provenance tag (e.g.
50 + `source:todo.md:42`), so re-running a migration wave does not double-insert.
51 + Projects referenced by name are resolved, and created if absent.
52 +
53 + ## Wiring into a Claude session
54 +
55 + Register the running server as an HTTP MCP server, then grant the capabilities
56 + `/dellm` needs:
57 +
58 + ```
59 + go-mcp --grant-all &
60 + claude mcp add --transport http go-mcp http://127.0.0.1:7337/mcp
61 + ```
62 +
63 + `/dellm` detects go-mcp among the available MCP tools and uses it for
64 + `bulk_import_tasks`; without it, the skill falls back to CSV import.
65 +
66 + ## Design
67 +
68 + `_private/docs/goingson/plans/go-mcp-design.md`.
@@ -0,0 +1,28 @@
1 + //! Stable write-capability ids (public contract; never silently reassigned).
2 + //!
3 + //! Each mutating tool is gated on one of these. A driving session must hold the
4 + //! id in its granted set or kberg refuses the call. See the go-mcp design doc.
5 +
6 + use kberg::WriteCapability;
7 +
8 + pub const PROJECT_CREATE: &str = "go.project.create";
9 + pub const TASK_CREATE: &str = "go.task.create";
10 + pub const TASK_BULK_IMPORT: &str = "go.task.bulk_import";
11 + pub const TASK_UPDATE: &str = "go.task.update";
12 + pub const TASK_COMPLETE: &str = "go.task.complete";
13 +
14 + pub fn project_create() -> WriteCapability {
15 + WriteCapability::new(PROJECT_CREATE, "create a project")
16 + }
17 + pub fn task_create() -> WriteCapability {
18 + WriteCapability::new(TASK_CREATE, "create one task")
19 + }
20 + pub fn task_bulk_import() -> WriteCapability {
21 + WriteCapability::new(TASK_BULK_IMPORT, "create many tasks in one call")
22 + }
23 + pub fn task_update() -> WriteCapability {
24 + WriteCapability::new(TASK_UPDATE, "update an existing task")
25 + }
26 + pub fn task_complete() -> WriteCapability {
27 + WriteCapability::new(TASK_COMPLETE, "mark a task complete")
28 + }
@@ -0,0 +1,60 @@
1 + //! Shared tool context: the database handle and the fixed desktop user.
2 + //!
3 + //! go-mcp is a peer writer against the same `goingson.db` the desktop app uses.
4 + //! Every write flows through the normal repository layer, so the 33 SQL sync
5 + //! triggers fire and a running GoingsOn picks the changes up. There is no back
6 + //! door around the changelog.
7 +
8 + use std::path::PathBuf;
9 +
10 + use goingson_core::UserId;
11 + use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
12 + use sqlx::SqlitePool;
13 + use uuid::Uuid;
14 +
15 + /// Fixed single-user desktop id. Must match `DESKTOP_USER_ID` in the GoingsOn
16 + /// Tauri app (`src-tauri/src/state.rs`); tasks are keyed on it, so a mismatch
17 + /// would write rows the app never shows.
18 + pub const DESKTOP_USER_ID: UserId = UserId::from_uuid(Uuid::from_u128(1));
19 +
20 + /// Everything a tool needs to reach the database. Cheap to clone (the pool is
21 + /// an `Arc` internally); tools hold it behind an `Arc` anyway.
22 + pub struct Ctx {
23 + pub pool: SqlitePool,
24 + pub user_id: UserId,
25 + }
26 +
27 + impl Ctx {
28 + pub fn new(pool: SqlitePool) -> Self {
29 + Self {
30 + pool,
31 + user_id: DESKTOP_USER_ID,
32 + }
33 + }
34 +
35 + pub fn tasks(&self) -> SqliteTaskRepository {
36 + SqliteTaskRepository::new(self.pool.clone())
37 + }
38 +
39 + pub fn projects(&self) -> SqliteProjectRepository {
40 + SqliteProjectRepository::new(self.pool.clone())
41 + }
42 + }
43 +
44 + /// Default location of `goingson.db`, matching Tauri's `app_data_dir` for the
45 + /// `com.goingson.app` bundle. Resolves the same path the desktop app opens so
46 + /// `go-mcp` with no `--db` flag targets the real database.
47 + pub fn default_db_path() -> Option<PathBuf> {
48 + let home = std::env::var_os("HOME")?;
49 + let home = PathBuf::from(home);
50 +
51 + #[cfg(target_os = "macos")]
52 + let base = home.join("Library/Application Support");
53 +
54 + #[cfg(not(target_os = "macos"))]
55 + let base = std::env::var_os("XDG_DATA_HOME")
56 + .map(PathBuf::from)
57 + .unwrap_or_else(|| home.join(".local/share"));
58 +
59 + Some(base.join("com.goingson.app").join("goingson.db"))
60 + }
@@ -0,0 +1,110 @@
1 + //! Parsing and projection helpers shared across the tools.
2 + //!
3 + //! These translate between the wire shapes an LLM sends (string ids, ISO
4 + //! dates, priority words) and the `goingson-core` domain types, and project a
5 + //! [`Task`] back down to the compact JSON rows the read tools return.
6 +
7 + use chrono::{DateTime, NaiveDate, TimeZone, Utc};
8 + use goingson_core::{Priority, ProjectId, Task, TaskId};
9 + use kberg::Error;
10 + use serde_json::{Value, json};
11 + use uuid::Uuid;
12 +
13 + /// Pull a required string argument, or an `InvalidArgs` naming the field.
14 + pub fn req_str<'a>(tool: &str, args: &'a Value, field: &str) -> Result<&'a str, Error> {
15 + args.get(field)
16 + .and_then(Value::as_str)
17 + .filter(|s| !s.trim().is_empty())
18 + .ok_or_else(|| Error::InvalidArgs {
19 + tool: tool.to_string(),
20 + message: format!("missing or empty string field `{field}`"),
21 + })
22 + }
23 +
24 + /// Parse a task id string into a [`TaskId`], or an `InvalidArgs`.
25 + pub fn parse_task_id(tool: &str, s: &str) -> Result<TaskId, Error> {
26 + Uuid::parse_str(s.trim())
27 + .map(TaskId::from_uuid)
28 + .map_err(|_| Error::InvalidArgs {
29 + tool: tool.to_string(),
30 + message: format!("`{s}` is not a valid task id (expected a UUID)"),
31 + })
32 + }
33 +
34 + /// Best-effort due-date parse. Accepts an RFC 3339 timestamp or a bare
35 + /// `YYYY-MM-DD` (interpreted as midnight UTC), matching the CSV import
36 + /// interchange semantics. Returns `None` for empty/absent, `Err` for garbage
37 + /// so a typo surfaces instead of silently dropping the date.
38 + pub fn parse_due(tool: &str, value: Option<&Value>) -> Result<Option<DateTime<Utc>>, Error> {
39 + let Some(raw) = value.and_then(Value::as_str) else {
40 + return Ok(None);
41 + };
42 + let raw = raw.trim();
43 + if raw.is_empty() {
44 + return Ok(None);
45 + }
46 + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
47 + return Ok(Some(dt.with_timezone(&Utc)));
48 + }
49 + if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
50 + let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
51 + return Ok(Some(Utc.from_utc_datetime(&naive)));
52 + }
53 + Err(Error::InvalidArgs {
54 + tool: tool.to_string(),
55 + message: format!("`{raw}` is not an RFC 3339 timestamp or YYYY-MM-DD date"),
56 + })
57 + }
58 +
59 + /// Parse a priority word (`High`/`Medium`/`Low`, case-insensitive), defaulting
60 + /// to `Medium` when absent. Never errors — an unknown word falls back rather
61 + /// than blocking a migration.
62 + pub fn parse_priority(value: Option<&Value>) -> Priority {
63 + match value.and_then(Value::as_str) {
64 + Some(s) => Priority::from_str_or_default(s),
65 + None => Priority::default(),
66 + }
67 + }
68 +
69 + /// Read a `tags` argument shaped as a JSON array of strings.
70 + pub fn parse_tags(value: Option<&Value>) -> Vec<String> {
71 + value
72 + .and_then(Value::as_array)
73 + .map(|arr| {
74 + arr.iter()
75 + .filter_map(Value::as_str)
76 + .map(str::to_string)
77 + .collect()
78 + })
79 + .unwrap_or_default()
80 + }
81 +
82 + /// The tag carrying an item's provenance, used as the idempotency key so a
83 + /// re-run of a `/dellm` wave does not double-insert. E.g. `source:todo.md:42`.
84 + pub fn source_tag(source: &str) -> String {
85 + format!("source:{source}")
86 + }
87 +
88 + /// Full-word priority, since [`Priority::as_str`] returns a one-letter badge
89 + /// ("H"/"M"/"L") meant for the UI, not a wire value.
90 + pub fn priority_word(p: &Priority) -> &'static str {
91 + match p {
92 + Priority::High => "High",
93 + Priority::Medium => "Medium",
94 + Priority::Low => "Low",
95 + }
96 + }
97 +
98 + /// Compact JSON projection of a task for the read tools.
99 + pub fn task_row(t: &Task) -> Value {
100 + json!({
101 + "id": t.id.to_string(),
102 + "description": t.description,
103 + "status": t.status.as_str(),
104 + "priority": priority_word(&t.priority),
105 + "due": t.due.map(|d| d.to_rfc3339()),
106 + "tags": t.tags,
107 + "project": t.project_name,
108 + "project_id": t.project_id.map(|p: ProjectId| p.to_string()),
109 + })
110 + }
@@ -0,0 +1,9 @@
1 + //! go-mcp library surface.
2 + //!
3 + //! The binary (`main.rs`) is a thin CLI over this crate; the modules are public
4 + //! so integration tests can drive the tool registry against a seeded database.
5 +
6 + pub mod caps;
7 + pub mod context;
8 + pub mod convert;
9 + pub mod tools;
@@ -0,0 +1,151 @@
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 + }
34 +
35 + impl Args {
36 + fn parse() -> Result<Self, String> {
37 + let mut db = None;
38 + let mut host = "127.0.0.1".to_string();
39 + let mut port: u16 = 7337;
40 + let mut grants = HashSet::new();
41 + let mut grant_all = false;
42 + let mut compact = false;
43 +
44 + let mut it = std::env::args().skip(1);
45 + while let Some(arg) = it.next() {
46 + match arg.as_str() {
47 + "--db" => db = Some(PathBuf::from(next(&mut it, "--db")?)),
48 + "--host" => host = next(&mut it, "--host")?,
49 + "--port" => {
50 + port = next(&mut it, "--port")?
51 + .parse()
52 + .map_err(|_| "--port must be a number".to_string())?
53 + }
54 + "--grant" => {
55 + grants.insert(next(&mut it, "--grant")?);
56 + }
57 + "--grant-all" => grant_all = true,
58 + "--compact" => compact = true,
59 + "-h" | "--help" => {
60 + print_help();
61 + std::process::exit(0);
62 + }
63 + other => return Err(format!("unknown argument: {other}")),
64 + }
65 + }
66 + Ok(Self { db, host, port, grants, grant_all, compact })
67 + }
68 + }
69 +
70 + fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
71 + it.next().ok_or_else(|| format!("{flag} requires a value"))
72 + }
73 +
74 + fn print_help() {
75 + eprintln!(
76 + "go-mcp — MCP server for GoingsOn tasks/projects\n\n\
77 + Usage: go-mcp [--db <path>] [--host <ip>] [--port <n>]\n\
78 + \x20 [--grant <cap>]... | [--grant-all] [--compact]\n\n\
79 + Write capabilities (grant to enable the matching tool):\n\
80 + \x20 go.project.create go.task.create go.task.bulk_import\n\
81 + \x20 go.task.update go.task.complete\n"
82 + );
83 + }
84 +
85 + #[tokio::main]
86 + async fn main() -> Result<(), Box<dyn std::error::Error>> {
87 + tracing_subscriber::fmt()
88 + .with_env_filter(
89 + tracing_subscriber::EnvFilter::try_from_default_env()
90 + .unwrap_or_else(|_| "info,go_mcp=debug".into()),
91 + )
92 + .init();
93 +
94 + let args = Args::parse().map_err(|e| {
95 + eprintln!("error: {e}\n");
96 + print_help();
97 + e
98 + })?;
99 +
100 + let db_path = args
101 + .db
102 + .or_else(default_db_path)
103 + .ok_or("could not resolve a default goingson.db path; pass --db")?;
104 +
105 + if !db_path.exists() {
106 + return Err(format!(
107 + "database not found at {}. Run GoingsOn at least once to create it, or pass --db.",
108 + db_path.display()
109 + )
110 + .into());
111 + }
112 +
113 + let pool = goingson_db_sqlite::init_pool(Some(&db_path.to_string_lossy())).await?;
114 + let ctx = Arc::new(Ctx::new(pool));
115 + let registry = tools::registry(ctx);
116 +
117 + // Resolve the granted write capabilities.
118 + let grants: HashSet<String> = if args.grant_all {
119 + registry.write_capabilities().into_iter().map(|c| c.id).collect()
120 + } else {
121 + args.grants
122 + };
123 + if grants.is_empty() {
124 + tracing::warn!("no write capabilities granted; only read tools will work (use --grant or --grant-all)");
125 + } else {
126 + tracing::info!(?grants, "granted write capabilities");
127 + }
128 +
129 + let projection = if args.compact {
130 + SurfaceProjection::Compact
131 + } else {
132 + SurfaceProjection::Full
133 + };
134 + let config = ServeConfig {
135 + projection,
136 + grants: Some(grants),
137 + };
138 +
139 + let bind = format!("{}:{}", args.host, args.port);
140 + let bound = server::serve(registry, &bind, config).await?;
141 + tracing::info!(db = %db_path.display(), url = %bound.url(), "go-mcp listening");
142 + println!("go-mcp listening on {}", bound.url());
143 +
144 + // Park until Ctrl-C or the server task ends. On Ctrl-C the `wait()` future
145 + // is dropped and the process exits; the detached server task goes with it.
146 + tokio::select! {
147 + _ = tokio::signal::ctrl_c() => tracing::info!("shutting down"),
148 + res = bound.wait() => res?,
149 + }
150 + Ok(())
151 + }
@@ -0,0 +1,30 @@
1 + //! Tool registration. Every GoingsOn operation is a kberg `Tool`; this module
2 + //! wires them into a single `ToolRegistry`.
3 +
4 + use std::sync::Arc;
5 +
6 + use kberg::ToolRegistry;
7 +
8 + use crate::context::Ctx;
9 +
10 + mod project;
11 + mod task;
12 +
13 + pub use project::{CreateProject, ListProjects};
14 + pub use task::{BulkImportTasks, CompleteTask, CreateTask, GetTask, ListTasks, UpdateTaskTool};
15 +
16 + /// Build the full go-mcp tool surface over a shared context.
17 + pub fn registry(ctx: Arc<Ctx>) -> ToolRegistry {
18 + let mut r = ToolRegistry::new();
19 + // Reads (small-model safe).
20 + r.register(ListProjects(ctx.clone()));
21 + r.register(ListTasks(ctx.clone()));
22 + r.register(GetTask(ctx.clone()));
23 + // Writes (capability-gated).
24 + r.register(CreateProject(ctx.clone()));
25 + r.register(CreateTask(ctx.clone()));
26 + r.register(BulkImportTasks(ctx.clone()));
27 + r.register(UpdateTaskTool(ctx.clone()));
28 + r.register(CompleteTask(ctx));
29 + r
30 + }
@@ -0,0 +1,142 @@
1 + //! Project tools: `list_projects` (read) and `create_project` (write,
2 + //! idempotent on name).
3 +
4 + use std::sync::Arc;
5 +
6 + use async_trait::async_trait;
7 + use goingson_core::repository::ProjectRepository;
8 + use goingson_core::models::ParseableEnum;
9 + use goingson_core::{NewProject, ProjectStatus, ProjectType};
10 + use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
11 + use serde_json::{Value, json};
12 +
13 + use crate::caps;
14 + use crate::context::Ctx;
15 + use crate::convert::req_str;
16 +
17 + fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
18 + Error::ToolFailed {
19 + tool: tool.to_string(),
20 + message: e.to_string(),
21 + }
22 + }
23 +
24 + pub struct ListProjects(pub Arc<Ctx>);
25 +
26 + #[async_trait]
27 + impl Tool for ListProjects {
28 + fn name(&self) -> &str {
29 + "list_projects"
30 + }
31 + fn description(&self) -> &str {
32 + "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks."
33 + }
34 + fn kind(&self) -> ToolKind {
35 + ToolKind::Read
36 + }
37 + fn small_model_safe(&self) -> bool {
38 + true
39 + }
40 + fn input_schema(&self) -> Value {
41 + json!({ "type": "object", "properties": {} })
42 + }
43 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
44 + let projects = self
45 + .0
46 + .projects()
47 + .list_all(self.0.user_id)
48 + .await
49 + .map_err(|e| fail(self.name(), e))?;
50 + let rows: Vec<Value> = projects
51 + .iter()
52 + .map(|p| {
53 + json!({
54 + "id": p.id.to_string(),
55 + "name": p.name,
56 + "type": p.project_type.as_str(),
57 + "status": p.status.as_str(),
58 + })
59 + })
60 + .collect();
61 + Ok(ToolCallResult::text(
62 + serde_json::to_string(&json!({ "projects": rows })).unwrap(),
63 + ))
64 + }
65 + }
66 +
67 + pub struct CreateProject(pub Arc<Ctx>);
68 +
69 + #[async_trait]
70 + impl Tool for CreateProject {
71 + fn name(&self) -> &str {
72 + "create_project"
73 + }
74 + fn description(&self) -> &str {
75 + "Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Writing (defaults to SideProject)."
76 + }
77 + fn kind(&self) -> ToolKind {
78 + ToolKind::Write(caps::project_create())
79 + }
80 + fn input_schema(&self) -> Value {
81 + json!({
82 + "type": "object",
83 + "properties": {
84 + "name": { "type": "string" },
85 + "type": { "type": "string", "description": "Job | SideProject | Company | Writing" },
86 + "description": { "type": "string" }
87 + },
88 + "required": ["name"]
89 + })
90 + }
91 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
92 + let name = req_str(self.name(), &args, "name")?;
93 + let repo = self.0.projects();
94 +
95 + // Idempotency: return the existing project rather than duplicating.
96 + if let Some(existing) = repo
97 + .find_by_name(self.0.user_id, name)
98 + .await
99 + .map_err(|e| fail(self.name(), e))?
100 + {
101 + return Ok(ToolCallResult::text(
102 + serde_json::to_string(&json!({
103 + "id": existing.id.to_string(),
104 + "created": false,
105 + }))
106 + .unwrap(),
107 + ));
108 + }
109 +
110 + let project_type = args
111 + .get("type")
112 + .and_then(Value::as_str)
113 + .map(ProjectType::from_str_or_default)
114 + .unwrap_or(ProjectType::SideProject);
115 + let description = args
116 + .get("description")
117 + .and_then(Value::as_str)
118 + .unwrap_or_default()
119 + .to_string();
120 +
121 + let created = repo
122 + .create(
123 + self.0.user_id,
124 + NewProject {
125 + name: name.to_string(),
126 + description,
127 + project_type,
128 + status: ProjectStatus::default(),
129 + },
130 + )
131 + .await
132 + .map_err(|e| fail(self.name(), e))?;
133 +
134 + Ok(ToolCallResult::text(
135 + serde_json::to_string(&json!({
136 + "id": created.id.to_string(),
137 + "created": true,
138 + }))
139 + .unwrap(),
140 + ))
141 + }
142 + }
@@ -0,0 +1,506 @@
1 + //! Task tools: the read surface (`list_tasks`, `get_task`) and the write
2 + //! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`).
3 + //!
4 + //! `bulk_import_tasks` is the `/dellm` migration primitive: it takes a parsed
5 + //! backlog and creates tasks in one capability-gated call, deduping on a
6 + //! `source:` provenance tag so a re-run does not double-insert.
7 +
8 + use std::collections::{HashMap, HashSet};
9 + use std::sync::Arc;
10 +
11 + use async_trait::async_trait;
12 + use goingson_core::repository::{ProjectRepository, TaskAnnotations, TaskCrud};
13 + use goingson_core::models::ParseableEnum;
14 + use goingson_core::{
15 + NewProject, NewTask, ProjectId, ProjectStatus, ProjectType, Task, TaskStatus, UpdateTask,
16 + };
17 + use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
18 + use serde_json::{Value, json};
19 +
20 + use crate::caps;
21 + use crate::context::Ctx;
22 + use crate::convert::{
23 + parse_due, parse_priority, parse_tags, parse_task_id, req_str, source_tag, task_row,
24 + };
25 +
26 + fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
27 + Error::ToolFailed {
28 + tool: tool.to_string(),
29 + message: e.to_string(),
30 + }
31 + }
32 +
33 + // ---------- reads ----------
34 +
35 + pub struct ListTasks(pub Arc<Ctx>);
36 +
37 + #[async_trait]
38 + impl Tool for ListTasks {
39 + fn name(&self) -> &str {
40 + "list_tasks"
41 + }
42 + fn description(&self) -> &str {
43 + "List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Use for reconciling a migration."
44 + }
45 + fn kind(&self) -> ToolKind {
46 + ToolKind::Read
47 + }
48 + fn small_model_safe(&self) -> bool {
49 + true
50 + }
51 + fn input_schema(&self) -> Value {
52 + json!({
53 + "type": "object",
54 + "properties": {
55 + "project": { "type": "string" },
56 + "status": { "type": "string" },
57 + "tag": { "type": "string" }
58 + }
59 + })
60 + }
61 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
62 + let tasks = self
63 + .0
64 + .tasks()
65 + .list_all(self.0.user_id)
66 + .await
67 + .map_err(|e| fail(self.name(), e))?;
68 +
69 + let project = args.get("project").and_then(Value::as_str);
70 + let status = args
71 + .get("status")
72 + .and_then(Value::as_str)
73 + .map(TaskStatus::from_str_or_default);
74 + let tag = args.get("tag").and_then(Value::as_str);
75 +
76 + let rows: Vec<Value> = tasks
77 + .iter()
78 + .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p)))
79 + .filter(|t| status.as_ref().is_none_or(|s| &t.status == s))
80 + .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want)))
81 + .map(task_row)
82 + .collect();
83 +
84 + Ok(ToolCallResult::text(
85 + serde_json::to_string(&json!({ "count": rows.len(), "tasks": rows })).unwrap(),
86 + ))
87 + }
88 + }
89 +
90 + pub struct GetTask(pub Arc<Ctx>);
91 +
92 + #[async_trait]
93 + impl Tool for GetTask {
94 + fn name(&self) -> &str {
95 + "get_task"
96 + }
97 + fn description(&self) -> &str {
98 + "Fetch one task by id, including its subtasks and annotations."
99 + }
100 + fn kind(&self) -> ToolKind {
101 + ToolKind::Read
102 + }
103 + fn small_model_safe(&self) -> bool {
104 + true
105 + }
106 + fn input_schema(&self) -> Value {
107 + json!({
108 + "type": "object",
109 + "properties": { "id": { "type": "string" } },
110 + "required": ["id"]
111 + })
112 + }
113 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
114 + let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
115 + let repo = self.0.tasks();
116 + let task = repo
117 + .get_by_id(id, self.0.user_id)
118 + .await
119 + .map_err(|e| fail(self.name(), e))?
120 + .ok_or_else(|| Error::ToolFailed {
121 + tool: self.name().to_string(),
122 + message: format!("no task with id {id}"),
123 + })?;
124 +
125 + let subtasks = repo
126 + .get_subtasks_for_task(id)
127 + .await
128 + .map_err(|e| fail(self.name(), e))?;
129 + let annotations = repo
130 + .get_annotations_for_task(id)
131 + .await
132 + .map_err(|e| fail(self.name(), e))?;
133 +
134 + let mut row = task_row(&task);
135 + row["subtasks"] = json!(
136 + subtasks
137 + .iter()
138 + .map(|s| json!({ "text": s.text, "completed": s.is_completed }))
139 + .collect::<Vec<_>>()
140 + );
141 + row["annotations"] = json!(
142 + annotations
143 + .iter()
144 + .map(|a| json!({ "note": a.note, "created_at": a.timestamp.to_rfc3339() }))
145 + .collect::<Vec<_>>()
146 + );
147 + Ok(ToolCallResult::text(serde_json::to_string(&row).unwrap()))
148 + }
149 + }
150 +
151 + // ---------- writes ----------
152 +
153 + pub struct CreateTask(pub Arc<Ctx>);
154 +
155 + #[async_trait]
156 + impl Tool for CreateTask {
157 + fn name(&self) -> &str {
158 + "create_task"
159 + }
160 + fn description(&self) -> &str {
161 + "Create one task. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low."
162 + }
163 + fn kind(&self) -> ToolKind {
164 + ToolKind::Write(caps::task_create())
165 + }
166 + fn input_schema(&self) -> Value {
167 + json!({
168 + "type": "object",
169 + "properties": {
170 + "description": { "type": "string" },
171 + "project": { "type": "string" },
172 + "tags": { "type": "array", "items": { "type": "string" } },
173 + "due": { "type": "string" },
174 + "priority": { "type": "string" }
175 + },
176 + "required": ["description"]
177 + })
178 + }
179 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
180 + let description = req_str(self.name(), &args, "description")?.to_string();
181 + let due = parse_due(self.name(), args.get("due"))?;
182 + let priority = parse_priority(args.get("priority"));
183 + let tags = parse_tags(args.get("tags"));
184 +
185 + let project_id = match args.get("project").and_then(Value::as_str) {
186 + Some(name) if !name.trim().is_empty() => {
187 + Some(ensure_project(&self.0, name).await.map_err(|e| fail(self.name(), e))?)
188 + }
189 + _ => None,
190 + };
191 +
192 + let mut builder = NewTask::builder(description).priority(priority).tags(tags);
193 + if let Some(pid) = project_id {
194 + builder = builder.project_id(pid);
195 + }
196 + if let Some(d) = due {
197 + builder = builder.due(d);
198 + }
199 +
200 + let task = self
201 + .0
202 + .tasks()
203 + .create(self.0.user_id, builder.build())
204 + .await
205 + .map_err(|e| fail(self.name(), e))?;
206 +
207 + Ok(ToolCallResult::text(
208 + serde_json::to_string(&json!({ "id": task.id.to_string() })).unwrap(),
209 + ))
210 + }
211 + }
212 +
213 + pub struct BulkImportTasks(pub Arc<Ctx>);
214 +
215 + #[async_trait]
216 + impl Tool for BulkImportTasks {
217 + fn name(&self) -> &str {
218 + "bulk_import_tasks"
219 + }
220 + fn description(&self) -> &str {
221 + "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, source?}. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids."
222 + }
223 + fn kind(&self) -> ToolKind {
224 + ToolKind::Write(caps::task_bulk_import())
225 + }
226 + fn input_schema(&self) -> Value {
227 + json!({
228 + "type": "object",
229 + "properties": {
230 + "tasks": {
231 + "type": "array",
232 + "items": {
233 + "type": "object",
234 + "properties": {
235 + "description": { "type": "string" },
236 + "project": { "type": "string" },
237 + "tags": { "type": "array", "items": { "type": "string" } },
238 + "due": { "type": "string" },
239 + "priority": { "type": "string" },
240 + "source": { "type": "string" }
241 + },
242 + "required": ["description"]
243 + }
244 + }
245 + },
246 + "required": ["tasks"]
247 + })
248 + }
249 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
250 + let items = args
251 + .get("tasks")
252 + .and_then(Value::as_array)
253 + .ok_or_else(|| Error::InvalidArgs {
254 + tool: self.name().to_string(),
255 + message: "missing array field `tasks`".into(),
256 + })?;
257 +
258 + let repo = self.0.tasks();
259 +
260 + // Existing source tags → idempotency. One scan up front.
261 + let existing = repo
262 + .list_all(self.0.user_id)
263 + .await
264 + .map_err(|e| fail(self.name(), e))?;
265 + let mut seen_sources: HashSet<String> = existing
266 + .iter()
267 + .flat_map(|t| t.tags.iter())
268 + .filter(|tag| tag.starts_with("source:"))
269 + .cloned()
270 + .collect();
271 +
272 + let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
273 + let mut created_ids = Vec::new();
274 + let mut skipped = 0usize;
275 +
276 + for (idx, item) in items.iter().enumerate() {
277 + let description = item
278 + .get("description")
279 + .and_then(Value::as_str)
280 + .filter(|s| !s.trim().is_empty())
281 + .ok_or_else(|| Error::InvalidArgs {
282 + tool: self.name().to_string(),
283 + message: format!("tasks[{idx}] is missing a non-empty `description`"),
284 + })?
285 + .to_string();
286 +
287 + let mut tags = parse_tags(item.get("tags"));
288 +
289 + // Provenance / idempotency.
290 + if let Some(source) = item.get("source").and_then(Value::as_str) {
291 + let tag = source_tag(source);
292 + if !seen_sources.insert(tag.clone()) {
293 + skipped += 1;
294 + continue;
295 + }
296 + tags.push(tag);
297 + }
298 +
299 + let due = parse_due(self.name(), item.get("due"))?;
300 + let priority = parse_priority(item.get("priority"));
301 +
302 + let project_id = match item.get("project").and_then(Value::as_str) {
303 + Some(name) if !name.trim().is_empty() => {
304 + Some(resolve_project_cached(&self.0, name, &mut project_cache).await
305 + .map_err(|e| fail(self.name(), e))?)
306 + }
307 + _ => None,
308 + };
309 +
310 + let mut builder = NewTask::builder(description).priority(priority).tags(tags);
311 + if let Some(pid) = project_id {
312 + builder = builder.project_id(pid);
313 + }
314 + if let Some(d) = due {
315 + builder = builder.due(d);
316 + }
317 +
318 + let task = repo
319 + .create(self.0.user_id, builder.build())
320 + .await
321 + .map_err(|e| fail(self.name(), e))?;
322 + created_ids.push(task.id.to_string());
323 + }
324 +
325 + Ok(ToolCallResult::text(
326 + serde_json::to_string(&json!({
327 + "created": created_ids.len(),
328 + "skipped": skipped,
329 + "task_ids": created_ids,
330 + }))
331 + .unwrap(),
332 + ))
333 + }
334 + }
335 +
336 + pub struct UpdateTaskTool(pub Arc<Ctx>);
337 +
338 + #[async_trait]
339 + impl Tool for UpdateTaskTool {
340 + fn name(&self) -> &str {
341 + "update_task"
342 + }
343 + fn description(&self) -> &str {
344 + "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`."
345 + }
346 + fn kind(&self) -> ToolKind {
347 + ToolKind::Write(caps::task_update())
348 + }
349 + fn input_schema(&self) -> Value {
350 + json!({
351 + "type": "object",
352 + "properties": {
353 + "id": { "type": "string" },
354 + "description": { "type": "string" },
355 + "priority": { "type": "string" },
356 + "due": { "type": "string" },
357 + "status": { "type": "string" },
358 + "tags": { "type": "array", "items": { "type": "string" } },
359 + "project": { "type": "string" }
360 + },
361 + "required": ["id"]
362 + })
363 + }
364 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
365 + let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
366 + let repo = self.0.tasks();
367 + let current = repo
368 + .get_by_id(id, self.0.user_id)
369 + .await
370 + .map_err(|e| fail(self.name(), e))?
371 + .ok_or_else(|| Error::ToolFailed {
372 + tool: self.name().to_string(),
373 + message: format!("no task with id {id}"),
374 + })?;
375 +
376 + // Start from the current task, overlay only the provided fields.
377 + let mut patch = update_from_task(&current);
378 + if let Some(desc) = args.get("description").and_then(Value::as_str) {
379 + patch.description = desc.to_string();
380 + }
381 + if args.get("priority").is_some() {
382 + patch.priority = parse_priority(args.get("priority"));
383 + }
384 + if let Some(due) = args.get("due") {
385 + patch.due = parse_due(self.name(), Some(due))?;
386 + }
387 + if let Some(status) = args.get("status").and_then(Value::as_str) {
388 + patch.status = TaskStatus::from_str_or_default(status);
389 + }
390 + if let Some(tags) = args.get("tags") {
391 + patch.tags = parse_tags(Some(tags));
392 + }
393 + if let Some(name) = args.get("project").and_then(Value::as_str) {
394 + patch.project_id = if name.trim().is_empty() {
395 + None
396 + } else {
397 + Some(ensure_project(&self.0, name).await.map_err(|e| fail(self.name(), e))?)
398 + };
399 + }
400 +
401 + let updated = repo
402 + .update(id, self.0.user_id, patch)
403 + .await
404 + .map_err(|e| fail(self.name(), e))?
405 + .ok_or_else(|| Error::ToolFailed {
406 + tool: self.name().to_string(),
407 + message: format!("task {id} vanished during update"),
408 + })?;
409 +
410 + Ok(ToolCallResult::text(serde_json::to_string(&task_row(&updated)).unwrap()))
411 + }
412 + }
413 +
414 + pub struct CompleteTask(pub Arc<Ctx>);
415 +
416 + #[async_trait]
417 + impl Tool for CompleteTask {
418 + fn name(&self) -> &str {
419 + "complete_task"
420 + }
421 + fn description(&self) -> &str {
422 + "Mark a task complete by id."
423 + }
424 + fn kind(&self) -> ToolKind {
425 + ToolKind::Write(caps::task_complete())
426 + }
427 + fn input_schema(&self) -> Value {
428 + json!({
429 + "type": "object",
430 + "properties": { "id": { "type": "string" } },
431 + "required": ["id"]
432 + })
433 + }
434 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
435 + let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
436 + let completed = self
437 + .0
438 + .tasks()
439 + .complete(id, self.0.user_id)
440 + .await
441 + .map_err(|e| fail(self.name(), e))?
442 + .ok_or_else(|| Error::ToolFailed {
443 + tool: self.name().to_string(),
444 + message: format!("no task with id {id}"),
445 + })?;
446 + Ok(ToolCallResult::text(serde_json::to_string(&task_row(&completed)).unwrap()))
447 + }
448 + }
449 +
450 + // ---------- helpers ----------
451 +
452 + /// Resolve a project by name, creating a default `SideProject` if absent.
453 + async fn ensure_project(ctx: &Ctx, name: &str) -> goingson_core::repository::Result<ProjectId> {
454 + let repo = ctx.projects();
455 + if let Some(existing) = repo.find_by_name(ctx.user_id, name).await? {
456 + return Ok(existing.id);
457 + }
458 + let created = repo
459 + .create(
460 + ctx.user_id,
461 + NewProject {
462 + name: name.to_string(),
463 + description: String::new(),
464 + project_type: ProjectType::SideProject,
465 + status: ProjectStatus::default(),
466 + },
467 + )
468 + .await?;
469 + Ok(created.id)
470 + }
471 +
472 + /// `ensure_project` with a within-call cache, so a bulk import that references
473 + /// the same project 200 times hits the database once.
474 + async fn resolve_project_cached(
475 + ctx: &Ctx,
476 + name: &str,
477 + cache: &mut HashMap<String, ProjectId>,
478 + ) -> goingson_core::repository::Result<ProjectId> {
479 + if let Some(id) = cache.get(name) {
480 + return Ok(*id);
481 + }
482 + let id = ensure_project(ctx, name).await?;
483 + cache.insert(name.to_string(), id);
484 + Ok(id)
485 + }
486 +
487 + /// Build a full [`UpdateTask`] mirroring a task's current state, so a caller can
488 + /// overlay just the fields it wants to change.
489 + fn update_from_task(t: &Task) -> UpdateTask {
490 + UpdateTask {
491 + project_id: t.project_id,
492 + milestone_id: t.milestone_id,
493 + contact_id: t.contact_id,
494 + description: t.description.clone(),
495 + status: t.status.clone(),
496 + priority: t.priority.clone(),
497 + due: t.due,
498 + tags: t.tags.clone(),
499 + recurrence: t.recurrence.clone(),
500 + recurrence_rule: t.recurrence_rule.clone(),
Lines truncated
@@ -0,0 +1,148 @@
1 + //! End-to-end tests: seed an in-memory GoingsOn database (real migrations +
2 + //! the fixed desktop user), then drive the tools through a kberg `ToolRegistry`
3 + //! exactly as the HTTP server would, asserting capability gating, idempotent
4 + //! bulk import, and read-back reconciliation.
5 +
6 + use std::collections::HashSet;
7 + use std::sync::Arc;
8 +
9 + use go_mcp::context::{Ctx, DESKTOP_USER_ID};
10 + use go_mcp::tools;
11 + use kberg::Error;
12 + use serde_json::{Value, json};
13 + use sqlx::SqlitePool;
14 +
15 + async fn seed_db() -> SqlitePool {
16 + let pool = goingson_db_sqlite::init_pool(Some(":memory:"))
17 + .await
18 + .expect("open in-memory db");
19 + goingson_db_sqlite::run_migrations(&pool)
20 + .await
21 + .expect("run migrations");
22 + // Fixed single-user desktop row, mirroring the app's ensure_desktop_user.
23 + sqlx::query(
24 + "INSERT INTO users (id, email, password_hash, display_name, created_at) \
25 + VALUES (?, 'desktop@localhost', 'x', 'Desktop User', datetime('now'))",
26 + )
27 + .bind(DESKTOP_USER_ID.to_string())
28 + .execute(&pool)
29 + .await
30 + .expect("seed desktop user");
31 + pool
32 + }
33 +
34 + /// Call a tool through the registry with all writes granted, unwrapping the
35 + /// JSON text result.
36 + async fn call(reg: &kberg::ToolRegistry, name: &str, args: Value) -> Value {
37 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
38 + let out = reg
39 + .call(name, args, Some(&grants))
40 + .await
41 + .unwrap_or_else(|e| panic!("{name} failed: {e}"));
42 + assert!(!out.is_error, "{name} returned is_error");
43 + let kberg::ContentPart::Text { text } = &out.content[0];
44 + serde_json::from_str(text).expect("tool result is JSON")
45 + }
46 +
47 + #[tokio::test]
48 + async fn write_is_denied_without_the_capability() {
49 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
50 + // Empty grant set: create_task must be refused by kberg before it runs.
51 + let err = reg
52 + .call("create_task", json!({ "description": "nope" }), Some(&HashSet::new()))
53 + .await
54 + .unwrap_err();
55 + match err {
56 + Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.task.create"),
57 + other => panic!("expected CapabilityDenied, got {other:?}"),
58 + }
59 + }
60 +
61 + #[tokio::test]
62 + async fn create_project_is_idempotent_on_name() {
63 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
64 +
65 + let first = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
66 + assert_eq!(first["created"], true);
67 + let id1 = first["id"].as_str().unwrap().to_string();
68 +
69 + let second = call(&reg, "create_project", json!({ "name": "Kberg" })).await;
70 + assert_eq!(second["created"], false);
71 + assert_eq!(second["id"].as_str().unwrap(), id1);
72 +
73 + let projects = call(&reg, "list_projects", json!({})).await;
74 + assert_eq!(projects["projects"].as_array().unwrap().len(), 1);
75 + }
76 +
77 + #[tokio::test]
78 + async fn bulk_import_creates_dedupes_and_reconciles() {
79 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
80 +
81 + let payload = json!({
82 + "tasks": [
83 + { "description": "migrate todos", "project": "dellm", "due": "2026-07-15",
84 + "priority": "High", "tags": ["migration"], "source": "todo.md:10" },
85 + { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" },
86 + { "description": "no source, always inserts" }
87 + ]
88 + });
89 +
90 + let first = call(&reg, "bulk_import_tasks", payload.clone()).await;
91 + assert_eq!(first["created"], 3);
92 + assert_eq!(first["skipped"], 0);
93 +
94 + // Re-run: the two sourced items dedupe; the source-less one inserts again.
95 + let second = call(&reg, "bulk_import_tasks", payload).await;
96 + assert_eq!(second["created"], 1, "only the source-less task re-inserts");
97 + assert_eq!(second["skipped"], 2, "both sourced tasks are skipped");
98 +
99 + // Project was auto-created and reused (not duplicated).
100 + let projects = call(&reg, "list_projects", json!({})).await;
101 + let names: Vec<&str> = projects["projects"]
102 + .as_array()
103 + .unwrap()
104 + .iter()
105 + .map(|p| p["name"].as_str().unwrap())
106 + .collect();
107 + assert_eq!(names, vec!["dellm"]);
108 +
109 + // Reconciliation: 4 tasks total, filterable by project and tag.
110 + let all = call(&reg, "list_tasks", json!({})).await;
111 + assert_eq!(all["count"], 4);
112 + let in_project = call(&reg, "list_tasks", json!({ "project": "dellm" })).await;
113 + assert_eq!(in_project["count"], 2);
114 + let tagged = call(&reg, "list_tasks", json!({ "tag": "migration" })).await;
115 + assert_eq!(tagged["count"], 1);
116 +
117 + // The high-priority task carries its due date and provenance tag.
118 + let hi = &tagged["tasks"][0];
119 + assert_eq!(hi["priority"], "High");
120 + assert!(hi["due"].as_str().unwrap().starts_with("2026-07-15"));
121 + let tags: Vec<&str> = hi["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
122 + assert!(tags.contains(&"source:todo.md:10"));
123 + }
124 +
125 + #[tokio::test]
126 + async fn get_update_and_complete_round_trip() {
127 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
128 +
129 + let created = call(&reg, "create_task", json!({ "description": "draft", "priority": "Low" })).await;
130 + let id = created["id"].as_str().unwrap().to_string();
131 +
132 + let got = call(&reg, "get_task", json!({ "id": id })).await;
133 + assert_eq!(got["description"], "draft");
134 + assert_eq!(got["priority"], "Low");
135 + assert!(got["subtasks"].is_array());
136 +
137 + let updated = call(
138 + &reg,
139 + "update_task",
140 + json!({ "id": id, "priority": "High", "description": "final draft" }),
141 + )
142 + .await;
143 + assert_eq!(updated["priority"], "High");
144 + assert_eq!(updated["description"], "final draft");
145 +
146 + let done = call(&reg, "complete_task", json!({ "id": id })).await;
147 + assert_eq!(done["status"], "Completed");
148 + }