Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
24fd49c01c
|
|||
|
5707441e5c
|
|||
|
e9a087fc69
|
|||
|
3314443fb7
|
|||
|
c58be7c902
|
|||
|
2463e83a43
|
|||
|
19c0fa81ab
|
|||
|
2cd4b34347
|
|||
|
e484e279d9
|
|||
|
abffaa8ef9
|
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
*
|
||||
!src/
|
||||
!migrations/
|
||||
!build.rs
|
||||
!Cargo.toml
|
||||
!Cargo.lock
|
||||
61
.github/workflows/build-docker.yml
vendored
Normal file
61
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: container-images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
|
||||
jobs:
|
||||
build-container-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set ALL_TAGS
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
run: |
|
||||
# tag main if main branch
|
||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
image_tags=("main")
|
||||
# tag with tag name if tag
|
||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
image_tags=("${{ github.ref_name }}")
|
||||
# tag with latest if tag is a new major, minor or patch version
|
||||
if [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
image_tags+=("latest")
|
||||
fi
|
||||
fi
|
||||
|
||||
lc_repo=${REPOSITORY,,}
|
||||
|
||||
image_paths=()
|
||||
for tag in ${image_tags[@]}; do
|
||||
image_paths+=("ghcr.io/$lc_repo:$tag")
|
||||
done
|
||||
|
||||
# join with ',' and then skip first character
|
||||
ALL_TAGS=$(printf ',%s' "${image_paths[@]}")
|
||||
echo "ALL_TAGS=${ALL_TAGS:1}" >>$GITHUB_ENV
|
||||
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push default image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
35
.github/workflows/release.yml
vendored
Normal file
35
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if stable release
|
||||
id: check
|
||||
run: |
|
||||
# align with build-docker.yml: stable only for strict semver tags
|
||||
if [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ steps.check.outputs.stable != 'true' }}
|
||||
make_latest: ${{ steps.check.outputs.stable == 'true' }}
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -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"
|
||||
@@ -1536,8 +1556,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "todo"
|
||||
version = "0.1.0"
|
||||
name = "todo-mcp"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1561,6 +1581,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "todo"
|
||||
version = "0.1.0"
|
||||
name = "todo-mcp"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "simple todo cli with mcp server for ai integration"
|
||||
@@ -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"
|
||||
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -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"]
|
||||
72
README.md
72
README.md
@@ -1,4 +1,4 @@
|
||||
# Todo
|
||||
# todo-mcp
|
||||
|
||||
A todo CLI with a built-in [MCP](https://modelcontextprotocol.io/) server.
|
||||
The main idea is that you can tell an MCP-compatible AI assistant "add this to
|
||||
@@ -23,60 +23,94 @@ Set `TODO_DB` to override the default database path (`~/.local/share/todo/todos.
|
||||
|
||||
```sh
|
||||
# add a todo
|
||||
todo add "fix the login bug"
|
||||
todo-mcp add "fix the login bug"
|
||||
|
||||
# add with priority and tags
|
||||
todo add "refactor auth module" -p high -t backend -t tech-debt
|
||||
todo-mcp add "refactor auth module" -p high -t backend -t tech-debt
|
||||
|
||||
# add without associating to the current git repo
|
||||
todo add "something general" --no-project
|
||||
todo-mcp add "something general" --no-project
|
||||
|
||||
# list open todos
|
||||
todo ls
|
||||
todo-mcp ls
|
||||
|
||||
# list todos for the current repo only
|
||||
todo ls --here
|
||||
todo-mcp ls --here
|
||||
|
||||
# filter by tag or priority
|
||||
todo ls -t backend
|
||||
todo ls -p critical
|
||||
todo-mcp ls -t backend
|
||||
todo-mcp ls -p critical
|
||||
|
||||
# search
|
||||
todo ls -s "auth"
|
||||
todo-mcp ls -s "auth"
|
||||
|
||||
# mark as done
|
||||
todo done 3
|
||||
todo-mcp done 3
|
||||
|
||||
# edit text, priority, or tags
|
||||
todo edit 3 "updated description"
|
||||
todo edit 3 -p low
|
||||
todo edit 3 -t new-tag --untag old-tag
|
||||
todo-mcp edit 3 "updated description"
|
||||
todo-mcp edit 3 -p low
|
||||
todo-mcp edit 3 -t new-tag --untag old-tag
|
||||
|
||||
# remove permanently
|
||||
todo rm 3
|
||||
todo-mcp rm 3
|
||||
|
||||
# show all tags in use
|
||||
todo tags
|
||||
todo-mcp tags
|
||||
|
||||
# clean up completed todos
|
||||
todo purge
|
||||
todo-mcp purge
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
A pre-built image is available from GitHub Container Registry:
|
||||
|
||||
```sh
|
||||
docker run --rm -v todo-mcp-data:/data -e TODO_DB=/data/todos.db ghcr.io/janezicmatej/todo-mcp --help
|
||||
```
|
||||
|
||||
Or build locally:
|
||||
|
||||
```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 ships with an MCP server that lets Claude manage your todos. Add this to
|
||||
your Claude MCP config:
|
||||
### Native
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"todo": {
|
||||
"command": "todo",
|
||||
"command": "todo-mcp",
|
||||
"args": ["mcp-serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"todo": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run", "-i", "--rm",
|
||||
"-v", "todo-mcp-data:/data",
|
||||
"-e", "TODO_DB=/data/todos.db",
|
||||
"ghcr.io/janezicmatej/todo-mcp", "mcp-serve"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This gives Claude access to all todo operations — adding, listing, completing,
|
||||
editing, removing, and purging.
|
||||
|
||||
2
build.rs
2
build.rs
@@ -19,7 +19,7 @@ fn main() -> Result<(), Error> {
|
||||
let mut cmd = Cli::command();
|
||||
|
||||
for shell in Shell::value_variants() {
|
||||
let path = generate_to(*shell, &mut cmd, "todo", &outdir)?;
|
||||
let path = generate_to(*shell, &mut cmd, env!("CARGO_PKG_NAME"), &outdir)?;
|
||||
|
||||
println!("cargo:warning=completion file is generated: {path:?}");
|
||||
}
|
||||
|
||||
@@ -86,8 +86,9 @@ pub struct ListArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DoneArgs {
|
||||
/// todo id
|
||||
pub id: i64,
|
||||
/// todo id(s)
|
||||
#[arg(required = true)]
|
||||
pub id: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
||||
@@ -65,9 +65,11 @@ async fn list(ctx: &Ctx<'_>, args: ListArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn done(ctx: &Ctx<'_>, args: DoneArgs) -> Result<()> {
|
||||
let todo = db::complete_todo(ctx.pool, args.id).await?;
|
||||
for id in args.id {
|
||||
let todo = db::complete_todo(ctx.pool, id).await?;
|
||||
print!("{} ", "done:".green().bold());
|
||||
print_todo(&todo);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
41
src/mcp.rs
41
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;
|
||||
@@ -79,9 +80,9 @@ fn tool_schemas() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
"id": { "type": "integer", "description": "single todo id" },
|
||||
"ids": { "type": "array", "items": { "type": "integer" }, "description": "multiple todo ids" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -182,11 +183,21 @@ async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value {
|
||||
.map(|t| serde_json::to_string(&t).unwrap())
|
||||
}
|
||||
"todo_done" => {
|
||||
let id = match args.get("id").and_then(|v| v.as_i64()) {
|
||||
Some(id) => id,
|
||||
None => return tool_error("missing required arg: id".into()),
|
||||
let ids: Vec<i64> = if let Some(arr) = args.get("ids").and_then(|v| v.as_array()) {
|
||||
arr.iter().filter_map(|v| v.as_i64()).collect()
|
||||
} else if let Some(id) = args.get("id").and_then(|v| v.as_i64()) {
|
||||
vec![id]
|
||||
} else {
|
||||
return tool_error("missing required arg: id or ids".into());
|
||||
};
|
||||
db::complete_todo(pool, id).await.map(|t| serde_json::to_string(&t).unwrap())
|
||||
let mut todos = Vec::new();
|
||||
for id in ids {
|
||||
match db::complete_todo(pool, id).await {
|
||||
Ok(t) => todos.push(t),
|
||||
Err(e) => return tool_error(e.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_string(&todos).unwrap())
|
||||
}
|
||||
"todo_edit" => {
|
||||
let id = match args.get("id").and_then(|v| v.as_i64()) {
|
||||
@@ -223,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user