max / goingson
13 files changed,
+1384 insertions,
-0 deletions
| @@ -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]] |
| @@ -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(¤t); | |
| 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(®, "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(®, "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(®, "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(®, "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(®, "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(®, "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(®, "list_tasks", json!({})).await; | |
| 111 | + | assert_eq!(all["count"], 4); | |
| 112 | + | let in_project = call(®, "list_tasks", json!({ "project": "dellm" })).await; | |
| 113 | + | assert_eq!(in_project["count"], 2); | |
| 114 | + | let tagged = call(®, "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(®, "create_task", json!({ "description": "draft", "priority": "Low" })).await; | |
| 130 | + | let id = created["id"].as_str().unwrap().to_string(); | |
| 131 | + | ||
| 132 | + | let got = call(®, "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 | + | ®, | |
| 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(®, "complete_task", json!({ "id": id })).await; | |
| 147 | + | assert_eq!(done["status"], "Completed"); | |
| 148 | + | } |