diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65b471e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +* +!src/ +!migrations/ +!build.rs +!Cargo.toml +!Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index ebdd0f2..973ca26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -1193,6 +1203,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1561,6 +1581,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 33bad8f..2ec2738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ dirs = "6" serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } -tokio = { version = "1", features = ["rt", "macros"] } +tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "signal"] } [build-dependencies] anyhow = "1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40c9557 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM rust:1.85 AS build +WORKDIR /src +COPY . . +ENV SQLX_OFFLINE=true +RUN cargo build --release --bin todo-mcp + +FROM gcr.io/distroless/cc-debian12 +COPY --from=build /src/target/release/todo-mcp / +ENTRYPOINT ["/todo-mcp"] diff --git a/README.md b/README.md index 71de2d2..b51692b 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,18 @@ todo-mcp tags todo-mcp purge ``` +## Docker + +```sh +docker build -t local/todo-mcp . +docker run --rm -v todo-mcp-data:/data -e TODO_DB=/data/todos.db local/todo-mcp --help +``` + +Data is persisted in the `todo-mcp-data` volume. See [Usage](#usage) for more commands. + ## Claude Setup -todo-mcp ships with an MCP server that lets Claude manage your todos. Add this -to your Claude MCP config: +### Native ```json { @@ -78,5 +86,25 @@ to your Claude MCP config: } ``` +### Docker + +Build the image first (see [Docker](#docker)), then add to your Claude MCP config: + +```json +{ + "mcpServers": { + "todo": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-v", "todo-mcp-data:/data", + "-e", "TODO_DB=/data/todos.db", + "local/todo-mcp", "mcp-serve" + ] + } + } +} +``` + This gives Claude access to all todo operations — adding, listing, completing, editing, removing, and purging. diff --git a/src/mcp.rs b/src/mcp.rs index bcbe04a..dc420f5 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1,8 +1,9 @@ -use std::io::{self, BufRead, Write}; +use std::io::{self, Write}; use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, BufReader}; use crate::db::{self, ListFilters, Pool}; use crate::model::Priority; @@ -233,11 +234,19 @@ async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value { } pub async fn serve(pool: &Pool) -> Result<()> { - let stdin = io::stdin().lock(); + let stdin = BufReader::new(tokio::io::stdin()); let mut stdout = io::stdout().lock(); + let mut lines = stdin.lines(); + + loop { + let line: String = tokio::select! { + line = lines.next_line() => match line? { + Some(line) => line, + None => break, + }, + _ = tokio::signal::ctrl_c() => break, + }; - for line in stdin.lines() { - let line = line?; if line.is_empty() { continue; }