MCP: Dart task server replaces Python todo_mcp
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 09:36:36 +01:00
parent 9e18821c7b
commit 2a9332094a
31 changed files with 1048 additions and 845 deletions

View File

@ -1,10 +1,11 @@
{ {
"mcpServers": { "mcpServers": {
"todo": { "todo": {
"type": "stdio", "type": "http",
"command": "${HOME}/.venvs/todo-mcp/bin/python", "url": "http://127.0.0.1:8735/mcp",
"args": ["-m", "todo_mcp._mcp"], "headers": {
"env": {} "Authorization": "Bearer ${TODO_MCP_TOKEN}"
}
} }
} }
} }

View File

@ -43,6 +43,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
- Release APK: `flutter build apk --release` (signs with the debug key; debug - Release APK: `flutter build apk --release` (signs with the debug key; debug
builds are janky — always measure smoothness on a release build). builds are janky — always measure smoothness on a release build).
- Sync smoke test (hits real GitHub, needs a token): `dart run tool/sync_smoke.dart`. - Sync smoke test (hits real GitHub, needs a token): `dart run tool/sync_smoke.dart`.
- MCP server (`lib/mcp/`, :8735): tests `flutter test test/mcp/`, deploy `mcp/install.sh`; see `mcp/README.md`.
- run: `./run.sh` (desktop; mobile: `flutter run`) - run: `./run.sh` (desktop; mobile: `flutter run`)
- test: `flutter test --reporter=compact` - test: `flutter test --reporter=compact`
- test-changed: `scripts/test_changed.sh` - test-changed: `scripts/test_changed.sh`

View File

@ -55,24 +55,9 @@ class CliCommands {
/// Prints one note in full. /// Prints one note in full.
Future<int> get(String ref) async { Future<int> get(String ref) async {
_report(await session.sync(push: false)); _report(await session.sync(push: false));
final note = findNote(await session.repository.listNotes(), ref); _out.writeln(
_out noteDetail(findNote(await session.repository.listNotes(), ref)),
..writeln('id: ${note.id}') );
..writeln('priority: ${note.priority.name}')
..writeln('status: ${CliArgs.statusName(note.status)}')
..writeln('created: ${note.createdAt.toIso8601String()}')
..writeln('updated: ${note.updatedAt.toIso8601String()}');
for (final (name, value) in [
('repo', note.repo),
('done_check', note.doneCheck),
('by', note.by),
('evidence', note.evidence),
]) {
if (value != null) _out.writeln('$name: $value');
}
_out
..writeln('---')
..writeln(note.text);
return 0; return 0;
} }

View File

@ -59,6 +59,7 @@ class CliSession {
static Future<CliSession> open( static Future<CliSession> open(
String dataDir, { String dataDir, {
required String seedCredentials, required String seedCredentials,
String seedApp = 'todo',
http.Client? httpClient, http.Client? httpClient,
}) async { }) async {
final dir = Directory(dataDir); final dir = Directory(dataDir);
@ -83,7 +84,7 @@ class CliSession {
await repository.close(); await repository.close();
throw FirebaseAuthError( throw FirebaseAuthError(
'no Firebase session: $seedCredentials is missing; re-seed it with ' 'no Firebase session: $seedCredentials is missing; re-seed it with '
'`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo`', '`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app $seedApp`',
); );
} }
final firebase = FirebaseRestClient( final firebase = FirebaseRestClient(

View File

@ -45,3 +45,18 @@ String listLine(Note note) => [
].join('\t'); ].join('\t');
String _title(Note note) => note.text.split('\n').first; String _title(Note note) => note.text.split('\n').first;
/// The note's metadata lines, `---`, then its text.
String noteDetail(Note note) => [
'id: ${note.id}',
'priority: ${note.priority.name}',
'status: ${CliArgs.statusName(note.status)}',
'created: ${note.createdAt.toIso8601String()}',
'updated: ${note.updatedAt.toIso8601String()}',
if (note.repo case final v?) 'repo: $v',
if (note.doneCheck case final v?) 'done_check: $v',
if (note.by case final v?) 'by: $v',
if (note.evidence case final v?) 'evidence: $v',
'---',
note.text,
].join('\n');

View File

@ -32,6 +32,11 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
Note noteFromRecord(Record record) { Note noteFromRecord(Record record) {
final fields = record.fields; final fields = record.fields;
String? str(String name) => fields[name]?.$1 as String?; String? str(String name) => fields[name]?.$1 as String?;
// '' is how a peer clears a task field: omitting it never clears.
String? task(String name) => switch (str(name)) {
'' => null,
final v => v,
};
return Note( return Note(
id: record.id, id: record.id,
text: str(_text) ?? '', text: str(_text) ?? '',
@ -39,10 +44,10 @@ Note noteFromRecord(Record record) {
status: Status.fromValue(fields[_status]?.$1 as int?), status: Status.fromValue(fields[_status]?.$1 as int?),
createdAt: _parseDate(str(_createdAt)), createdAt: _parseDate(str(_createdAt)),
updatedAt: _parseDate(str(_updatedAt)), updatedAt: _parseDate(str(_updatedAt)),
repo: str(_repo), repo: task(_repo),
doneCheck: str(_doneCheck), doneCheck: task(_doneCheck),
by: str(_by), by: task(_by),
evidence: str(_evidence), evidence: task(_evidence),
); );
} }

48
lib/mcp/mcp_main.dart Normal file
View File

@ -0,0 +1,48 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_session.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/todo_mcp_server.dart';
/// Starts the server from `$HOME` paths and returns its stop function, or
/// null after writing to [err] why it cannot run (no tokens, seed, network).
Future<Future<void> Function()?> startTodoMcp(
Map<String, String> env,
StringSink err, {
http.Client? httpClient,
}) async {
final home = env['HOME'] ?? '';
final config = p.join(home, '.config', 'todo-mcp');
final tokens = File(p.join(config, 'tokens'));
if (!tokens.existsSync()) {
err.writeln('error: ${tokens.path} missing; run mcp/mint_token.sh pc');
return null;
}
CliSession? session;
try {
session = await CliSession.open(
env['TODO_MCP_DATA_DIR'] ?? p.join(home, '.local', 'share', 'todo-mcp'),
seedCredentials: p.join(config, 'firebase_auth.json'),
seedApp: 'todo-mcp',
httpClient: httpClient,
);
await session.sync(push: true);
final server = await TodoMcpServer.start(
tasks: TaskService(session),
tokens: tokens,
port: int.tryParse(env['TODO_MCP_PORT'] ?? '') ?? kTodoMcpPort,
);
err.writeln('todo-mcp: serving ${server.uri}');
final open = session;
return () async {
await server.close();
await open.close();
};
} on Object catch (e) {
err.writeln('error: $e');
await session?.close();
return null;
}
}

190
lib/mcp/task_service.dart Normal file
View File

@ -0,0 +1,190 @@
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:uuid/uuid.dart';
/// The six task tools over one persistent sync node; calls run one at a time.
class TaskService {
/// Serves [_session]'s notes; every call pulls first, writes then push.
TaskService(this._session);
final CliSession _session;
Future<void> _tail = Future.value();
Future<String> _serial(Future<String> Function() body) {
final run = _tail.then((_) => body());
_tail = run.then((_) {}, onError: (_) {});
return run;
}
Future<List<Note>> _pull() async {
final skipped = (await _session.sync(push: true)).skippedFiles;
if (skipped.isNotEmpty) throw StateError('sync: unmergeable $skipped');
return await _session.repository.listNotes();
}
Future<String> _save(Note note, String verb) async {
await _session.repository.upsert(note.copyWith(updatedAt: DateTime.now()));
await _session.sync(push: true);
return '$verb ${note.id}\n${listLine(note)}';
}
Future<Note> _find(_Args a) async {
final id = a.required('id');
try {
return findNote(await _pull(), id);
} on NoteLookupError catch (e) {
throw FormatException('id: $e');
}
}
/// `add_task`: a new repo task by claude; returns its full id.
Future<String> add(Map<String, dynamic> args) => _serial(() async {
final a = _Args(args);
final repo = a.repo(required: true)!;
final title = a.required('title');
final body = a.optional('body') ?? '';
final now = DateTime.now();
await _pull();
return await _save(
Note(
id: const Uuid().v4(),
text: body.isEmpty ? title : '$title\n\n$body',
priority: a.priority() ?? Priority.medium,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: a.optional('done_check'),
by: 'claude',
),
'added',
);
});
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
Future<String> list(Map<String, dynamic> args) => _serial(() async {
final a = _Args(args);
final repo = a.repo();
final statuses = a.statuses();
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(
statuses: statuses,
sources: {repo ?? kRepoTasksSource},
),
),
);
});
/// `search_tasks`: repo tasks of any status whose text or repo matches.
Future<String> search(Map<String, dynamic> args) => _serial(() async {
final query = _Args(args).required('query');
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(query: query, sources: {kRepoTasksSource}),
),
);
});
/// `get_task`: one note in full.
Future<String> get(Map<String, dynamic> args) =>
_serial(() async => noteDetail(await _find(_Args(args))));
/// `update_task`: changes only the keys given; `done_check: ""` clears it.
Future<String> update(Map<String, dynamic> args) => _serial(() async {
final a = _Args(args);
final note = await _find(a);
final status = a.status();
if (status == Status.done) {
throw const FormatException('status: use close_task with evidence');
}
final lines = note.text.split('\n');
final title = a.has('title') ? a.required('title') : lines.first;
final body = a.has('body')
? (a.optional('body') ?? '')
: lines.skip(1).join('\n').trimLeft();
final reopened = status != null && note.status == Status.done;
return await _save(
note.copyWith(
text: body.isEmpty ? title : '$title\n\n$body',
priority: a.priority(),
status: status,
repo: a.repo(),
doneCheck: a.has('done_check')
? (a.optional('done_check') ?? '')
: null,
evidence: reopened ? '' : null,
),
'updated',
);
});
/// `close_task`: status done plus evidence; refused without evidence.
Future<String> close(Map<String, dynamic> args) => _serial(() async {
final a = _Args(args);
final evidence = a.required('evidence');
final note = await _find(a);
return await _save(
note.copyWith(status: Status.done, evidence: evidence),
'closed',
);
});
String _lines(List<Note> notes) =>
notes.isEmpty ? 'no tasks match' : notes.map(listLine).join('\n');
}
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
final Map<String, Status> _statuses = {
for (final s in Status.values) CliArgs.statusName(s): s,
};
// Every refusal is a FormatException whose message starts with the field.
extension type _Args(Map<String, dynamic> _args) {
bool has(String name) => _args.containsKey(name);
String? optional(String name) {
final value = _args[name];
if (value == null) return null;
if (value is! String) throw FormatException('$name: must be a string');
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
String required(String name) =>
optional(name) ?? (throw FormatException('$name: required, not blank'));
String? repo({bool required = false}) {
if (!required && !has('repo')) return null;
final value = optional('repo') ?? '';
if (!_repoShape.hasMatch(value)) {
throw FormatException('repo: must look like owner/name, got "$value"');
}
return value;
}
Priority? priority() =>
_pick('priority', {for (final p in Priority.values) p.name: p});
Status? status() => _pick('status', _statuses);
Set<Status> statuses() => switch (optional('status')) {
null || 'open' => {Status.todo, Status.inProgress},
'all' => const {},
_ => {status()!},
};
T? _pick<T>(String name, Map<String, T> choices) {
final value = optional(name);
if (value == null) return null;
return choices[value] ??
(throw FormatException(
'$name: must be one of ${choices.keys.join('|')}, got "$value"',
));
}
}

View File

@ -0,0 +1,179 @@
import 'dart:io';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/token_file.dart';
/// Loopback port; Caddy proxies todo.kuhy.duckdns.org/mcp here.
const kTodoMcpPort = 8735;
const _publicHost = 'todo.kuhy.duckdns.org';
const _instructions = '''
Tasks for kuhy's repos live in the todo app (not in TODO*.md files, which are
banned). Use this server instead of writing a TODO file.
- At session start in a repo, call list_tasks(repo: "kuhyx/<name>") to see
its open tasks.
- When you find work you will not finish now, call add_task with the repo,
a one-line title, the body (what / where / done / verify) and a done_check:
a shell command run from the repo root that exits 0 once the work is done.
- When a task's work lands, run its done_check; only if it exits 0 call
close_task with evidence "<commit sha> done_check exit 0". Never close
without running it.
- Ids accept an 8-character prefix. Errors start with the argument to fix.''';
/// The task tools over Streamable HTTP, behind sha256-hashed bearer tokens.
class TodoMcpServer {
TodoMcpServer._(this._http);
final StreamableMcpServer _http;
/// `http://<host>:<bound port>/mcp`.
Uri get uri => Uri.parse('http://${_http.host}:${_http.boundPort}/mcp');
/// Binds [host]:[port] (0 = any free port); [tokens] is read per request.
static Future<TodoMcpServer> start({
required TaskService tasks,
required File tokens,
String host = '127.0.0.1',
int port = kTodoMcpPort,
}) async {
final http = StreamableMcpServer(
serverFactory: (_) => _server(tasks),
host: host,
port: port,
authenticator: (request) => tokenAccepted(
tokens,
request is HttpRequest
? request.headers.value(HttpHeaders.authorizationHeader)
: null,
),
allowedHosts: const {'localhost', '127.0.0.1', '::1', _publicHost},
);
await http.start();
return TodoMcpServer._(http);
}
/// Stops listening and closes every session.
Future<void> close() => _http.stop();
}
McpServer _server(TaskService tasks) {
final server = McpServer(
const Implementation(name: 'todo', version: '1.0.0'),
options: const McpServerOptions(instructions: _instructions),
);
final id = JsonSchema.string(
description: 'Task id, or a unique prefix of at least 8 characters.',
);
final repo = JsonSchema.string(
description: 'GitHub repo as owner/name, e.g. kuhyx/todo.',
);
final priority = JsonSchema.string(description: 'low | medium | high.');
final doneCheck = JsonSchema.string(
description:
'Shell command run from the repo root; exit 0 means done. '
'In update_task, "" clears it.',
);
void tool(
String name,
String description,
Map<String, JsonSchema> props,
List<String> required,
Future<String> Function(Map<String, dynamic>) run,
) => server.registerTool(
name,
description: description,
inputSchema: JsonSchema.object(properties: props, required: required),
callback: (args, _) => _result(() => run(args)),
);
tool(
'add_task',
'Add an open task for a repo (by: claude). Returns "added <full id>" and '
'its list line. Use when you find work you will not finish now.',
{
'repo': repo,
'title': JsonSchema.string(description: 'One-line title.'),
'body': JsonSchema.string(description: 'Markdown: what/where/verify.'),
'done_check': doneCheck,
'priority': priority,
},
['repo', 'title'],
tasks.add,
);
tool(
'list_tasks',
'List repo tasks, one line each: 8-char id, status, priority, repo, '
'title. Default status "open" (todo + in_progress).',
{
'repo': repo,
'status': JsonSchema.string(
description: 'open | all | todo | in_progress | done | abandoned.',
),
},
[],
tasks.list,
);
tool(
'get_task',
'One task in full: metadata lines, then --- and the text.',
{'id': id},
['id'],
tasks.get,
);
tool(
'update_task',
'Change only the arguments given; others stay as they are. Marking done '
'is close_task. Returns "updated <id>" and its list line.',
{
'id': id,
'title': JsonSchema.string(description: 'New one-line title.'),
'body': JsonSchema.string(description: 'New body; "" empties it.'),
'done_check': doneCheck,
'priority': priority,
'status': JsonSchema.string(
description: 'todo | in_progress | abandoned.',
),
'repo': repo,
},
['id'],
tasks.update,
);
tool(
'close_task',
'Mark a task done, keeping it. Refused without evidence. Run the '
"task's done_check first and close only when it exits 0.",
{
'id': id,
'evidence': JsonSchema.string(
description:
'Commit sha and done_check exit code, e.g. "abc1234 exit 0".',
),
},
['id', 'evidence'],
tasks.close,
);
tool(
'search_tasks',
'Repo tasks of any status whose text or repo contains the query '
'(case-insensitive), one line each as list_tasks.',
{'query': JsonSchema.string(description: 'Text to look for.')},
['query'],
tasks.search,
);
return server;
}
Future<CallToolResult> _result(Future<String> Function() run) async {
try {
return CallToolResult.fromContent([TextContent(text: await run())]);
} on FormatException catch (e) {
return _error(e.message);
} on Object catch (e) {
return _error('$e');
}
}
CallToolResult _error(String text) =>
CallToolResult(content: [TextContent(text: 'error: $text')], isError: true);

31
lib/mcp/token_file.dart Normal file
View File

@ -0,0 +1,31 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
/// sha256 hex of [token] (crdt_sync's `revisionOf` is exactly that).
String tokenHash(String token) => revisionOf(token);
Set<String> _hashes(File tokens) {
final String text;
try {
text = tokens.readAsStringSync();
} on FileSystemException {
return const {};
}
return {
for (final line in text.split('\n'))
if (line.trim() case final t when t.isNotEmpty && !t.startsWith('#'))
t.split(RegExp(r'\s+')).first.toLowerCase(),
};
}
/// Whether [authorization] bears a token hashed in [tokens], read per call.
bool tokenAccepted(File tokens, String? authorization) {
const scheme = 'bearer ';
if (authorization == null ||
!authorization.toLowerCase().startsWith(scheme)) {
return false;
}
final token = authorization.substring(scheme.length).trim();
return token.isNotEmpty && _hashes(tokens).contains(tokenHash(token));
}

6
mcp/.gitignore vendored
View File

@ -1,6 +0,0 @@
__pycache__/
*.egg-info/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@ -1,47 +1,30 @@
# todo backlog MCP server # todo MCP server
A small, standalone, **read-only** [MCP](https://modelcontextprotocol.io) Repo tasks for any Claude session (local or cloud): `add_task`, `list_tasks`,
server that lets Claude Code (and its subagents) query the todo app's backlog. `get_task`, `update_task`, `close_task` (refused without evidence),
`search_tasks`. Dart, Streamable HTTP on `127.0.0.1:8735/mcp`; Caddy
publishes it as `https://todo.kuhy.duckdns.org/mcp`.
The todo app is Dart/Flutter and stores notes in a `sqlite_crdt` database that The server is one more sync node: it reuses `lib/cli` (`CliSession`,
must never be touched from outside the app (CRDT writes corrupt cross-device `SyncService`) and writes its own CRDT log at
merge). Instead, this server reads the app's exported `BACKLOG.md` — the `todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
canonical NotesMarkdown snapshot written by **Settings → "Export notes"**. Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
## Staleness note | What | Where |
|---|---|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
| Bearer token hashes | `~/.config/todo-mcp/tokens` (`<sha256> <env>`) |
| Log, node id, session copy (0600) | `~/.local/share/todo-mcp/` |
| Binary | `~/.local/lib/todo-mcp/todo_mcp` |
`BACKLOG.md` is a *manual* export, so it can be out of date. Every tool result ## One-time setup
includes the file's modification time (`mtime`) so you can judge freshness. If
the file is missing, tools return `backlog_found: false` with a message telling
you to export it from the app — they never crash.
## Tools (all read-only)
- `list_backlog(priority: str | None = None, status: str | None = None, limit: int = 100)`
— notes, optionally filtered by `priority` (`low`/`medium`/`high`) and/or
`status` (`todo`/`inProgress`/`done`/`abandoned`), plus `total`, `returned`,
and `mtime`.
- `get_note(note_id: str)` — one note by id, or `found: false`.
- `backlog_stats()` — counts `by_status` and `by_priority`, `total`, and `mtime`.
Nothing here writes to the database or to `BACKLOG.md`.
## Setup
```bash ```bash
mcp/scripts/setup_mcp.sh python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo-mcp
mcp/mint_token.sh pc # prints the token once; export it as TODO_MCP_TOKEN
mcp/install.sh # build + install the unit
systemctl --user enable --now todo-mcp.service
``` ```
This creates `~/.venvs/todo-mcp`, installs this package (which pulls in the Deploy a new build: `mcp/install.sh` (restarts a running unit).
`mcp` SDK), and verifies the imports. The server is registered via the Revoke a token: delete its line in `tokens`; no restart needed.
repo-root `.mcp.json`; restart Claude Code in this repo and approve the project
MCP server prompt. Override the backlog location with `TODO_BACKLOG_PATH`
(defaults to `~/src/todo/BACKLOG.md`).
## Development
```bash
~/.venvs/todo-mcp/bin/pip install -e mcp pytest pytest-cov ruff mypy
~/.venvs/todo-mcp/bin/python -m pytest mcp/todo_mcp/tests \
--cov=todo_mcp --cov-branch --cov-report=term-missing --cov-fail-under=100
```

27
mcp/install.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/bash
# Deploy: frozen binary + systemd --user unit; restarts the unit if running.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ROOT
readonly BIN_DIR="${HOME}/.local/lib/todo-mcp"
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly UNIT="todo-mcp.service"
main() {
mkdir -p "$BIN_DIR" "$UNIT_DIR"
(cd "$ROOT" && flutter pub get >/dev/null)
(cd "$ROOT" && dart compile exe mcp/todo_mcp.dart -o "$BIN_DIR/todo_mcp.new")
mv "$BIN_DIR/todo_mcp.new" "$BIN_DIR/todo_mcp"
install -m 644 "$ROOT/mcp/$UNIT" "$UNIT_DIR/$UNIT"
systemctl --user daemon-reload
if systemctl --user is-active --quiet "$UNIT"; then
systemctl --user restart "$UNIT"
echo "restarted $UNIT"
else
echo "installed; start with: systemctl --user enable --now $UNIT"
fi
}
main "$@"

29
mcp/mint_token.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Prints a new <env> token once; ~/.config/todo-mcp/tokens keeps only its sha256.
set -euo pipefail
readonly CONFIG_DIR="${HOME}/.config/todo-mcp"
readonly TOKENS="${CONFIG_DIR}/tokens"
main() {
local env="${1:-}"
if [[ ! "$env" =~ ^[a-z0-9_-]+$ ]]; then
echo "usage: $(basename "$0") <env> (e.g. pc, cloud)" >&2
exit 2
fi
umask 077
mkdir -p "$CONFIG_DIR"
local token hash tmp
token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
hash="$(printf '%s' "$token" | sha256sum | cut -d' ' -f1)"
tmp="$(mktemp "${TOKENS}.XXXXXX")"
if [[ -f "$TOKENS" ]]; then
awk -v env="$env" '$2 != env' "$TOKENS" >"$tmp"
fi
printf '%s %s\n' "$hash" "$env" >>"$tmp"
mv "$tmp" "$TOKENS"
echo "$token"
}
main "$@"

View File

@ -1,134 +0,0 @@
[project]
name = "todo-mcp"
version = "1.0.0"
description = "Read-only MCP server over the todo app's exported BACKLOG.md"
requires-python = ">=3.14"
dependencies = [
# The MCP SDK is the only runtime dependency: FastMCP powers the stdio
# server. It lives in a dedicated venv (see scripts/setup_mcp.sh /
# ../.mcp.json), kept entirely separate from the Dart/Flutter app.
"mcp==2.3.0",
]
# Explicit package discovery so ``pip install -e .`` picks up the package and
# not the scripts/ dir.
[tool.setuptools.packages.find]
include = ["todo_mcp*"]
# ============================================================================
# RUFF - Fast Python linter and formatter
# ============================================================================
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203", # conflicts with D211
"D213", # conflicts with D212
"COM812", # formatter handles this
"ISC001", # formatter may create these
"S603", # prone to false positives
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["todo_mcp"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
# ============================================================================
# MYPY - Static type checker
# ============================================================================
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
# ============================================================================
# PYTEST
# ============================================================================
[tool.pytest.ini_options]
testpaths = ["todo_mcp/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=todo_mcp",
"--cov-branch",
"--cov-report=term-missing",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
# ============================================================================
# COVERAGE
# ============================================================================
[tool.coverage.run]
source = ["todo_mcp"]
branch = true
omit = ["*/__pycache__/*", "*/tests/*", "*/.venv/*"]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"raise AssertionError",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

View File

@ -1,41 +0,0 @@
#!/bin/bash
# ============================================================================
# Set up the dedicated virtualenv that hosts the todo backlog MCP server.
# Claude Code spawns this interpreter (see the repo-root .mcp.json) to run
# `python -m todo_mcp._mcp` over stdio.
#
# The MCP SDK (`mcp`) and the `todo_mcp` package live ONLY in this venv — the
# todo app itself is Dart/Flutter and has no Python, so this is fully separate.
# Both `mcp` and `todo_mcp` must be importable by this one interpreter or the
# MCP server fails to start silently.
#
# Idempotent: safe to re-run to pick up dependency changes.
# ============================================================================
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PKG_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
readonly VENV_DIR="${HOME}/.venvs/todo-mcp"
main() {
echo "== Setting up MCP venv at ${VENV_DIR} =="
if [[ ! -d "${VENV_DIR}" ]]; then
python3 -m venv "${VENV_DIR}"
fi
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip
"${VENV_DIR}/bin/pip" install --quiet -e "${PKG_DIR}"
echo "== Verifying imports =="
"${VENV_DIR}/bin/python" -c \
"import mcp, todo_mcp; print('mcp + todo_mcp import OK')"
echo
echo "Done. The server is registered via $(cd "${PKG_DIR}/.." && pwd)/.mcp.json"
echo "Restart Claude Code in this repo and approve the project MCP server prompt."
}
main "$@"

12
mcp/todo-mcp.service Normal file
View File

@ -0,0 +1,12 @@
[Unit]
Description=todo MCP server (Streamable HTTP, loopback :8735)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=%h/.local/lib/todo-mcp/todo_mcp
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target

15
mcp/todo_mcp.dart Normal file
View File

@ -0,0 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:todo/mcp/mcp_main.dart';
Future<void> main() async {
final stop = await startTodoMcp(Platform.environment, stderr);
if (stop == null) exit(1);
await Future.any([
ProcessSignal.sigterm.watch().first,
ProcessSignal.sigint.watch().first,
]);
await stop();
exit(0);
}

View File

@ -1,17 +0,0 @@
"""Read-only MCP server package for the ``todo`` Flutter app's backlog.
The ``todo`` app is Dart/Flutter and stores notes in a ``sqlite_crdt`` database
that must never be touched from here (CRDT writes corrupt cross-device merge).
Instead, this package reads the app's exported ``BACKLOG.md`` file — the
canonical, human-exportable "NotesMarkdown" snapshot — and exposes it to MCP
clients (Claude Code and its subagents) as a handful of read-only tools.
See :mod:`todo_mcp._backlog` for the parser and :mod:`todo_mcp._mcp` for the
MCPServer stdio server.
"""
from __future__ import annotations
__all__ = ["__version__"]
__version__ = "1.0.0"

View File

@ -1,141 +0,0 @@
"""Parser for the ``todo`` app's exported ``BACKLOG.md`` (NotesMarkdown) file.
This is a faithful Python re-implementation of the Dart exporter/parser in
``todo/lib/sync/notes_markdown.dart``. The exported document is valid Markdown:
a one-line header (``<!-- todo-backlog v1 -->``) followed by, per note, an HTML
comment metadata marker::
<!-- @note id="..." priority="medium" status="todo" created="..." updated="..." -->
whose body (everything up to the next marker, trimmed) is the note's Markdown.
The functions here are pure and side-effect free so they can be unit-tested in
isolation; the MCP layer (:mod:`todo_mcp._mcp`) handles file I/O and staleness.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
import uuid
# Priority/status vocabularies mirror the Dart ``Priority``/``Status`` enums,
# which are serialised by their ``.name`` (e.g. ``priority="medium"``). Unknown
# or missing values fall back to the same defaults the Dart parser uses, so a
# hand-edited file never yields an invalid note.
_PRIORITIES = ("low", "medium", "high")
_STATUSES = ("todo", "inProgress", "done", "abandoned")
_DEFAULT_PRIORITY = "medium"
_DEFAULT_STATUS = "todo"
# Matches a per-note metadata marker at the start of a line (multi-line mode).
# The body is everything between one marker and the next (or end of file).
_MARKER_PATTERN = re.compile(r"^<!--\s*@note\s+(.*?)\s*-->[ \t]*$", re.MULTILINE)
# Matches ``key="value"`` attribute pairs inside a marker.
_ATTR_PATTERN = re.compile(r'(\w+)="([^"]*)"')
@dataclass(frozen=True)
class Note:
"""A single parsed backlog note.
Attributes:
id: Stable UUID from the marker, or a freshly minted one when the
source marker had no (or a blank) ``id``.
title: Human-readable title derived from the body's first line.
body: The note's Markdown body, verbatim but trimmed.
priority: One of ``low`` / ``medium`` / ``high``.
status: One of ``todo`` / ``inProgress`` / ``done`` / ``abandoned``.
"""
id: str
title: str
body: str
priority: str
status: str
def _parse_attrs(raw: str) -> dict[str, str]:
"""Extract ``key="value"`` pairs from a marker's attribute string.
Args:
raw: The captured text between ``@note`` and the closing ``-->``.
Returns:
A mapping of attribute name to its (possibly empty) string value.
"""
return {m.group(1): m.group(2) for m in _ATTR_PATTERN.finditer(raw)}
def _normalize(value: str | None, allowed: tuple[str, ...], fallback: str) -> str:
"""Return ``value`` if it is a recognised token, else ``fallback``.
Mirrors the Dart parser's tolerant enum resolution: a missing (``None``) or
unknown value degrades to the enum's default rather than raising.
Args:
value: The raw attribute value, or ``None`` if the marker omitted it.
allowed: The permitted tokens for this field.
fallback: The default token to use when ``value`` is unrecognised.
Returns:
A valid token drawn from ``allowed``.
"""
return value if value in allowed else fallback
def _derive_title(body: str) -> str:
"""Derive a display title from a note's (already-trimmed) body.
Uses the first line, stripping any leading Markdown heading hashes so a
``# Set up my own mail`` heading becomes ``Set up my own mail``. An empty
body yields an empty title.
Args:
body: The trimmed Markdown body of the note.
Returns:
The derived title, or an empty string for an empty body.
"""
if not body:
return ""
return body.splitlines()[0].lstrip("#").strip()
def parse_backlog(text: str) -> list[Note]:
"""Parse an exported ``BACKLOG.md`` document into typed notes.
Tolerant by design (matching the Dart parser): a missing/blank ``id`` is
replaced with a fresh UUID, and unknown/missing ``priority``/``status``
values fall back to their defaults, so a partially hand-edited file never
raises. The header line and any content before the first marker are ignored.
Args:
text: The full contents of an exported NotesMarkdown document.
Returns:
The notes in document order (empty if there are no ``@note`` markers).
"""
markers = list(_MARKER_PATTERN.finditer(text))
notes: list[Note] = []
for i, marker in enumerate(markers):
attrs = _parse_attrs(marker.group(1))
body_start = marker.end()
body_end = markers[i + 1].start() if i + 1 < len(markers) else len(text)
body = text[body_start:body_end].strip()
raw_id = attrs.get("id", "")
note_id = raw_id or str(uuid.uuid4())
notes.append(
Note(
id=note_id,
title=_derive_title(body),
body=body,
priority=_normalize(
attrs.get("priority"), _PRIORITIES, _DEFAULT_PRIORITY
),
status=_normalize(attrs.get("status"), _STATUSES, _DEFAULT_STATUS),
)
)
return notes

View File

@ -1,214 +0,0 @@
"""Read-only MCP server exposing the ``todo`` app's exported backlog.
Run via the dedicated venv that has ``mcp`` and ``todo_mcp`` installed::
~/.venvs/todo-mcp/bin/python -m todo_mcp._mcp
(see ``mcp/scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
Safety invariants (do not break when adding tools):
* **READ-ONLY.** Every tool only *reads* ``BACKLOG.md``. Nothing here writes
to the file, and nothing ever touches the app's ``sqlite_crdt`` database
(CRDT writes corrupt cross-device merge). There is no write tool.
* **stdout is the JSON-RPC channel.** This module must never write to stdout;
all logging is routed to STDERR below.
* **No secret ever leaves.** The backlog holds only user-authored note text;
there are no credentials to expose, and no config/token is ever read.
Because ``BACKLOG.md`` is a *manual* export (the app writes it from Settings →
"Export notes"), it may be stale. Every result therefore includes the file's
modification time (``mtime``) so a client can judge freshness, and a missing
file yields a graceful "please export it" message rather than an error.
"""
from __future__ import annotations
from datetime import UTC, datetime
import logging
import os
from pathlib import Path
import sys
from typing import Any
from mcp.server.mcpserver import MCPServer
from todo_mcp._backlog import Note, parse_backlog
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
# single stray stdout write would corrupt the stream and kill the session.
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [%(levelname)s] todo-mcp: %(message)s",
)
logger = logging.getLogger(__name__)
# mcp 2.x renamed FastMCP to MCPServer; the decorator and run() API are
# unchanged, so only the import and the constructor moved.
mcp = MCPServer("todo")
_DEFAULT_LIST_LIMIT = 100
def _backlog_path() -> Path:
"""Resolve the ``BACKLOG.md`` path (env override, else the default).
Returns:
``$TODO_BACKLOG_PATH`` when set, otherwise ``~/src/todo/BACKLOG.md``.
"""
override = os.environ.get("TODO_BACKLOG_PATH")
if override:
return Path(override)
return Path.home() / "src/todo" / "BACKLOG.md"
def _missing_message() -> str:
"""Return the user-facing hint shown when the backlog file is absent."""
return (
f"BACKLOG.md not found at {_backlog_path()}. Export it from the todo "
"app (Settings -> Export notes), then try again."
)
def _load_backlog() -> tuple[bool, list[Note], str | None]:
"""Load and parse the backlog, reporting existence and file mtime.
Returns:
A ``(found, notes, mtime)`` triple. When the file is absent ``found``
is ``False`` and ``notes``/``mtime`` are empty; otherwise ``mtime`` is
the file's UTC modification time as an ISO-8601 string.
"""
path = _backlog_path()
try:
stat = path.stat()
except OSError:
return False, [], None
mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
notes = parse_backlog(path.read_text(encoding="utf-8"))
return True, notes, mtime
def _note_to_dict(note: Note) -> dict[str, str]:
"""Project a :class:`Note` into a JSON-serialisable dict for a tool result.
Args:
note: The parsed note to serialise.
Returns:
The note's fields as a plain string-keyed dict.
"""
return {
"id": note.id,
"title": note.title,
"priority": note.priority,
"status": note.status,
"body": note.body,
}
@mcp.tool()
def list_backlog(
priority: str | None = None,
status: str | None = None,
limit: int = _DEFAULT_LIST_LIMIT,
) -> dict[str, Any]:
"""List backlog notes, optionally filtered by priority and/or status.
Args:
priority: If given, keep only notes with this priority
(``low`` / ``medium`` / ``high``).
status: If given, keep only notes with this status
(``todo`` / ``inProgress`` / ``done`` / ``abandoned``).
limit: Maximum number of notes to return (non-positive returns none).
Returns:
A dict with the matching ``notes`` (capped at ``limit``), the total
match count, the number returned, and the backlog file ``mtime``. If
the file is missing, ``backlog_found`` is ``False`` and a ``note`` field
explains how to export it.
"""
found, notes, mtime = _load_backlog()
if not found:
return {"backlog_found": False, "note": _missing_message()}
matches = [
n
for n in notes
if (priority is None or n.priority == priority)
and (status is None or n.status == status)
]
capped = matches[: max(0, limit)]
return {
"backlog_found": True,
"mtime": mtime,
"total": len(matches),
"returned": len(capped),
"notes": [_note_to_dict(n) for n in capped],
}
@mcp.tool()
def get_note(note_id: str) -> dict[str, Any]:
"""Fetch a single backlog note by its id.
Args:
note_id: The note's UUID (the ``id`` from its marker).
Returns:
A dict with ``found`` and, on a hit, the ``note`` plus the backlog
``mtime``. If the file is missing, ``backlog_found`` is ``False`` and a
``note`` field explains how to export it.
"""
found, notes, mtime = _load_backlog()
if not found:
return {"backlog_found": False, "found": False, "note": _missing_message()}
for note in notes:
if note.id == note_id:
return {
"backlog_found": True,
"found": True,
"mtime": mtime,
"note": _note_to_dict(note),
}
return {
"backlog_found": True,
"found": False,
"mtime": mtime,
"note_id": note_id,
}
@mcp.tool()
def backlog_stats() -> dict[str, Any]:
"""Summarise the backlog: note counts by status and by priority.
Returns:
A dict with the ``total`` note count, ``by_status`` and ``by_priority``
count maps, and the backlog ``mtime``. If the file is missing,
``backlog_found`` is ``False`` and a ``note`` field explains how to
export it.
"""
found, notes, mtime = _load_backlog()
if not found:
return {"backlog_found": False, "note": _missing_message()}
by_status: dict[str, int] = {}
by_priority: dict[str, int] = {}
for note in notes:
by_status[note.status] = by_status.get(note.status, 0) + 1
by_priority[note.priority] = by_priority.get(note.priority, 0) + 1
return {
"backlog_found": True,
"mtime": mtime,
"total": len(notes),
"by_status": by_status,
"by_priority": by_priority,
}
def main() -> None:
"""Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs)."""
logger.info("Starting todo MCP server (python=%s)", sys.executable)
mcp.run() # pragma: no cover
if __name__ == "__main__":
main()

View File

@ -1 +0,0 @@
"""Test package for todo_mcp."""

View File

@ -1,96 +0,0 @@
"""Tests for the NotesMarkdown backlog parser in ``_backlog``."""
from __future__ import annotations
import uuid
from todo_mcp._backlog import Note, parse_backlog
HEADER = "<!-- todo-backlog v1 -->\n\n"
def _marker(**attrs: str) -> str:
pairs = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return f"<!-- @note {pairs} -->"
class TestParseBacklog:
def test_empty_document_has_no_notes(self) -> None:
assert parse_backlog("") == []
def test_header_only_has_no_notes(self) -> None:
assert parse_backlog(HEADER) == []
def test_parses_a_single_note_with_heading_title(self) -> None:
doc = (
HEADER
+ _marker(
id="abc",
priority="high",
status="inProgress",
)
+ "\n# Set up my own mail\n\nbody text here\n"
)
notes = parse_backlog(doc)
assert len(notes) == 1
note = notes[0]
assert note == Note(
id="abc",
title="Set up my own mail",
body="# Set up my own mail\n\nbody text here",
priority="high",
status="inProgress",
)
def test_plain_first_line_becomes_title_verbatim(self) -> None:
doc = _marker(id="x", priority="low", status="todo") + "\nInfakt api + mcp\n"
(note,) = parse_backlog(doc)
assert note.title == "Infakt api + mcp"
def test_multiple_notes_split_on_markers(self) -> None:
doc = (
HEADER
+ _marker(id="1", priority="low", status="todo")
+ "\nfirst\n\n"
+ _marker(id="2", priority="medium", status="done")
+ "\nsecond\n"
)
notes = parse_backlog(doc)
assert [n.id for n in notes] == ["1", "2"]
assert notes[0].body == "first"
assert notes[1].body == "second"
def test_missing_id_gets_fresh_uuid(self) -> None:
doc = _marker(priority="low", status="todo") + "\nno id here\n"
(note,) = parse_backlog(doc)
# A valid UUID string was minted for the absent id.
assert uuid.UUID(note.id)
def test_blank_id_gets_fresh_uuid(self) -> None:
doc = _marker(id="", priority="low", status="todo") + "\nblank id\n"
(note,) = parse_backlog(doc)
assert uuid.UUID(note.id)
def test_unknown_priority_and_status_fall_back_to_defaults(self) -> None:
doc = _marker(id="z", priority="urgent", status="waiting") + "\nx\n"
(note,) = parse_backlog(doc)
assert note.priority == "medium"
assert note.status == "todo"
def test_absent_priority_and_status_fall_back_to_defaults(self) -> None:
doc = _marker(id="z") + "\nx\n"
(note,) = parse_backlog(doc)
assert note.priority == "medium"
assert note.status == "todo"
def test_empty_body_yields_empty_title(self) -> None:
# Two adjacent markers => the first note's body is empty.
doc = (
_marker(id="1", priority="low", status="todo")
+ "\n"
+ _marker(id="2", priority="low", status="todo")
+ "\nsecond\n"
)
notes = parse_backlog(doc)
assert notes[0].body == ""
assert notes[0].title == ""

View File

@ -1,129 +0,0 @@
"""Tests for the read-only MCP tools in ``_mcp``.
Tools are driven directly (``@mcp.tool()`` leaves the function callable). File
I/O is exercised for real against a temp ``BACKLOG.md`` pointed at via the
``TODO_BACKLOG_PATH`` env var, so ``_backlog_path``'s override branch runs on
every call; one test covers the default-path branch explicitly.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from todo_mcp import _mcp
_DOC = """<!-- todo-backlog v1 -->
<!-- @note id="n1" priority="high" status="todo" -->
# First task
alpha
<!-- @note id="n2" priority="low" status="done" -->
# Second task
beta
<!-- @note id="n3" priority="high" status="done" -->
# Third task
gamma
"""
@pytest.fixture
def backlog(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "BACKLOG.md"
path.write_text(_DOC, encoding="utf-8")
monkeypatch.setenv("TODO_BACKLOG_PATH", str(path))
return path
@pytest.fixture
def missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "absent.md"
monkeypatch.setenv("TODO_BACKLOG_PATH", str(path))
return path
class TestBacklogPath:
def test_default_path_when_env_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TODO_BACKLOG_PATH", raising=False)
assert _mcp._backlog_path() == Path.home() / "src/todo" / "BACKLOG.md"
class TestListBacklog:
def test_lists_all_notes_with_mtime(self, backlog: Path) -> None:
out = _mcp.list_backlog()
assert out["backlog_found"] is True
assert out["total"] == 3
assert out["returned"] == 3
assert out["mtime"] is not None
assert [n["id"] for n in out["notes"]] == ["n1", "n2", "n3"]
def test_filter_by_priority(self, backlog: Path) -> None:
out = _mcp.list_backlog(priority="high")
assert [n["id"] for n in out["notes"]] == ["n1", "n3"]
def test_filter_by_status(self, backlog: Path) -> None:
out = _mcp.list_backlog(status="done")
assert [n["id"] for n in out["notes"]] == ["n2", "n3"]
def test_filter_by_priority_and_status(self, backlog: Path) -> None:
out = _mcp.list_backlog(priority="high", status="done")
assert [n["id"] for n in out["notes"]] == ["n3"]
def test_limit_caps_results(self, backlog: Path) -> None:
out = _mcp.list_backlog(limit=1)
assert out["total"] == 3
assert out["returned"] == 1
def test_negative_limit_returns_none(self, backlog: Path) -> None:
out = _mcp.list_backlog(limit=-3)
assert out["returned"] == 0
assert out["notes"] == []
def test_missing_file_is_graceful(self, missing: Path) -> None:
out = _mcp.list_backlog()
assert out["backlog_found"] is False
assert "Export it" in out["note"]
class TestGetNote:
def test_found(self, backlog: Path) -> None:
out = _mcp.get_note("n2")
assert out["found"] is True
assert out["note"]["title"] == "Second task"
assert out["mtime"] is not None
def test_not_found_in_present_file(self, backlog: Path) -> None:
out = _mcp.get_note("nope")
assert out["backlog_found"] is True
assert out["found"] is False
assert out["note_id"] == "nope"
def test_missing_file_is_graceful(self, missing: Path) -> None:
out = _mcp.get_note("n1")
assert out["backlog_found"] is False
assert out["found"] is False
assert "Export it" in out["note"]
class TestBacklogStats:
def test_counts_by_status_and_priority(self, backlog: Path) -> None:
out = _mcp.backlog_stats()
assert out["total"] == 3
assert out["by_status"] == {"todo": 1, "done": 2}
assert out["by_priority"] == {"high": 2, "low": 1}
assert out["mtime"] is not None
def test_missing_file_is_graceful(self, missing: Path) -> None:
out = _mcp.backlog_stats()
assert out["backlog_found"] is False
assert "Export it" in out["note"]
def test_main_runs_stdio_server(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[()]] = []
monkeypatch.setattr(_mcp.mcp, "run", lambda: calls.append(()))
_mcp.main()
assert calls == [()]

View File

@ -627,6 +627,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
mcp_dart:
dependency: "direct main"
description:
name: mcp_dart
sha256: af0b2e25412f70440c881314a0b70a56ef87fd090b3eb71f31c356313096e6f5
url: "https://pub.dev"
source: hosted
version: "2.4.3"
meta: meta:
dependency: transitive dependency: transitive
description: description:

View File

@ -72,6 +72,9 @@ dependencies:
image: 4.12.0 image: 4.12.0
# Camera + gallery picks for image attachments on Android. # Camera + gallery picks for image attachments on Android.
image_picker: 1.2.4 image_picker: 1.2.4
# The todo MCP server (lib/mcp/): Streamable HTTP transport + tool
# registry. Only mcp/todo_mcp.dart imports it; the app never does.
mcp_dart: 2.4.3
# Hands an attached non-image file to whichever Android app opens its # Hands an attached non-image file to whichever Android app opens its
# type (FileProvider intent). Adds no permissions to the manifest. # type (FileProvider intent). Adds no permissions to the manifest.
open_file: 4.0.0 open_file: 4.0.0

View File

@ -0,0 +1,55 @@
// Bearer tokens are checked against sha256 hashes in the tokens file, re-read
// per request so revoking a line takes effect without a restart; the Host
// check admits loopback and the public proxy name only.
import 'package:flutter_test/flutter_test.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
setUp(() async {
h = McpHarness();
await h.start();
});
tearDown(() => h.dispose());
const ok = 'Bearer ${McpHarness.token}';
test('a listed token is admitted', () async {
expect(await h.rawInitialize({'authorization': ok}), 200);
});
test('no token or a wrong token is refused', () async {
expect(await h.rawInitialize({}), anyOf(401, 403));
expect(
await h.rawInitialize({'authorization': 'Bearer nope'}),
anyOf(401, 403),
);
});
test('removing the line revokes without a restart', () async {
h.tokens.writeAsStringSync('# revoked\n');
expect(await h.rawInitialize({'authorization': ok}), anyOf(401, 403));
});
test('a missing tokens file admits nobody', () async {
h.tokens.deleteSync();
expect(await h.rawInitialize({'authorization': ok}), anyOf(401, 403));
});
test('the proxy host is admitted, a foreign host is not', () async {
expect(
await h.rawInitialize({
'authorization': ok,
'host': 'todo.kuhy.duckdns.org',
'origin': 'https://todo.kuhy.duckdns.org',
}),
200,
);
expect(
await h.rawInitialize({'authorization': ok, 'host': 'evil.example'}),
anyOf(400, 403),
);
});
}

View File

@ -0,0 +1,125 @@
// Spec B / Q6 contract: over real Streamable HTTP, add -> list -> update
// (explicit clear of done_check) -> close with evidence, refused without;
// the result is visible to `todo_note.dart --list --repo` after a sync.
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/cli/cli_runner.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/sync/notes_markdown.dart';
import '../cli/fake_rtdb.dart';
import 'mcp_harness.dart';
const _repo = 'kuhyx/todo';
void main() {
late McpHarness h;
late McpClient c;
setUp(() async {
h = McpHarness();
await h.start();
c = await h.connect();
});
tearDown(() => h.dispose());
Future<String> addTask() async => idIn(
await h.call(c, 'add_task', {
'repo': _repo,
'title': 'Fix the "quoted" thing',
'body': 'what: details',
'done_check': 'grep -q "ok" f',
'priority': 'high',
}),
);
test('add, list, update clearing done_check, close with evidence', () async {
final id = await addTask();
final prefix = id.substring(0, 8);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed, contains(prefix));
expect(listed, contains('Fix the "quoted" thing'));
expect(listed, isNot(contains('&quot;')));
var got = await h.call(c, 'get_task', {'id': prefix});
expect(got, contains('done_check: grep -q "ok" f'));
expect(got, contains('by: claude'));
await h.call(c, 'update_task', {'id': prefix, 'done_check': ''});
got = await h.call(c, 'get_task', {'id': prefix});
expect(got, isNot(contains('done_check')));
for (final args in [
{'id': prefix},
{'id': prefix, 'evidence': ' '},
]) {
expect(await h.refused(c, 'close_task', args), contains('evidence'));
}
await h.call(c, 'close_task', {
'id': prefix,
'evidence': 'abc1234 "done_check" exit 0',
});
got = await h.call(c, 'get_task', {'id': id});
expect(got, contains('status: done'));
expect(got, contains('evidence: abc1234 "done_check" exit 0'));
// Another node (the CLI) sees the closed task, done_check cleared.
final out = StringBuffer();
final code = await runCli(
['--list', '--repo', _repo, '--data-dir', '${h.tmp.path}/cli'],
env: {'HOME': h.tmp.path},
out: out,
err: StringBuffer(),
readStdin: () async => '',
open: (d, {required seedCredentials}) => CliSession.open(
d,
seedCredentials: writeSeedCredentials(h.tmp).path,
httpClient: h.db.client,
),
);
expect(code, 0);
expect(out.toString(), contains('$prefix\tdone\thigh\t$_repo'));
// BACKLOG.md escapes `"` as &quot;; parsing decodes it back.
final notes = await h.session.repository.listNotes();
final md = NotesMarkdown.export(notes);
expect(md, contains('&quot;'));
final back = NotesMarkdown.parse(md).single;
expect(back.evidence, 'abc1234 "done_check" exit 0');
expect(back.doneCheck, isNull);
});
test('errors name the field to fix', () async {
final id = await addTask();
expect(
await h.refused(c, 'update_task', {'id': id, 'priority': 'urgent'}),
contains('priority'),
);
expect(
await h.refused(c, 'add_task', {'repo': 'no-slash', 'title': 't'}),
contains('repo'),
);
expect(
await h.refused(c, 'add_task', {'repo': _repo, 'title': ' '}),
contains('title'),
);
expect(await h.refused(c, 'get_task', {'id': 'abc'}), contains('id'));
expect(
await h.refused(c, 'list_tasks', {'status': 'finished'}),
contains('status'),
);
});
test('search finds tasks by text; instructions say when to use', () async {
final id = await addTask();
expect(
await h.call(c, 'search_tasks', {'query': 'QUOTED'}),
contains(id.substring(0, 8)),
);
final how = c.getInstructions()!;
for (final tool in ['add_task', 'list_tasks', 'close_task']) {
expect(how, contains(tool));
}
});
}

133
test/mcp/mcp_harness.dart Normal file
View File

@ -0,0 +1,133 @@
/// Runs the todo MCP server in-process over real HTTP, backed by a
/// [FakeRtdb] and a temp data dir. Never the real RTDB or ~/.local/share.
///
/// Not named `*_test.dart`: the runner would collect it and fail on the
/// missing `main()`.
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/todo_mcp_server.dart';
import 'package:todo/mcp/token_file.dart';
import '../cli/fake_rtdb.dart';
class McpHarness {
static const token = 'test-token-for-pc';
final tmp = Directory.systemTemp.createTempSync('mcp_harness');
final db = FakeRtdb();
late final File tokens = File('${tmp.path}/tokens');
late final CliSession session;
late final TodoMcpServer server;
final _clients = <McpClient>[];
Future<void> start() async {
tokens.writeAsStringSync('${tokenHash(token)} pc\n');
session = await CliSession.open(
'${tmp.path}/data',
seedCredentials: writeSeedCredentials(tmp).path,
httpClient: db.client,
);
server = await TodoMcpServer.start(
tasks: TaskService(session),
tokens: tokens,
port: 0,
);
}
Future<void> dispose() async {
for (final c in _clients) {
await c.close();
}
await server.close();
await session.close();
tmp.deleteSync(recursive: true);
}
/// A connected MCP client sending `Authorization: Bearer <bearer>`.
Future<McpClient> connect({String bearer = token}) async {
final client = McpClient(
const Implementation(name: 'contract-test', version: '1.0.0'),
);
await client.connect(
StreamableHttpClientTransport(
server.uri,
opts: StreamableHttpClientTransportOptions(
requestInit: {
'headers': {'Authorization': 'Bearer $bearer'},
},
),
),
);
_clients.add(client);
return client;
}
/// Calls [tool] and returns its text, failing the test on a tool error.
Future<String> call(
McpClient client,
String tool,
Map<String, dynamic> args,
) async {
final (text, isError) = await _call(client, tool, args);
expect(isError, isFalse, reason: '$tool failed: $text');
return text;
}
/// Calls [tool], expects it to be refused, and returns the error text.
Future<String> refused(
McpClient client,
String tool,
Map<String, dynamic> args,
) async {
final (text, isError) = await _call(client, tool, args);
expect(isError, isTrue, reason: '$tool should be refused: $text');
return text;
}
Future<(String, bool)> _call(
McpClient client,
String tool,
Map<String, dynamic> args,
) async {
final result = await client.callTool(
CallToolRequest(name: tool, arguments: args),
);
final text = result.content
.whereType<TextContent>()
.map((c) => c.text)
.join('\n');
return (text, result.isError);
}
/// POSTs a raw `initialize` with the given headers; returns the status.
Future<int> rawInitialize(Map<String, String> headers) async {
final http = HttpClient();
try {
final req = await http.postUrl(server.uri);
req.headers
..set('content-type', 'application/json')
..set('accept', 'application/json, text/event-stream');
headers.forEach(req.headers.set);
req.write(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":'
'{"protocolVersion":"2025-06-18","capabilities":{},'
'"clientInfo":{"name":"raw","version":"1"}}}',
);
final res = await req.close();
await res.drain<void>();
return res.statusCode;
} finally {
http.close(force: true);
}
}
}
/// The first full uuid in [text].
String idIn(String text) =>
RegExp(r'[0-9a-f]{8}-[0-9a-f-]{27}').firstMatch(text)!.group(0)!;

View File

@ -0,0 +1,69 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/mcp/mcp_main.dart';
import 'package:todo/mcp/token_file.dart';
import '../cli/fake_rtdb.dart';
void main() {
late Directory home;
late FakeRtdb db;
late StringBuffer err;
late Map<String, String> env;
setUp(() {
home = Directory.systemTemp.createTempSync('mcp_main');
db = FakeRtdb();
err = StringBuffer();
env = {
'HOME': home.path,
'TODO_MCP_PORT': '0',
'TODO_MCP_DATA_DIR': '${home.path}/data',
};
});
tearDown(() => home.deleteSync(recursive: true));
Future<Future<void> Function()?> start() =>
startTodoMcp(env, err, httpClient: db.client);
void writeTokens() => File('${home.path}/.config/todo-mcp/tokens')
..createSync(recursive: true)
..writeAsStringSync('${tokenHash('t')} pc\n');
test('refuses to start without a tokens file', () async {
expect(await start(), isNull);
expect(err.toString(), contains('mint_token.sh'));
});
test('refuses to start without a seeded session, naming the app', () async {
writeTokens();
expect(await start(), isNull);
expect(err.toString(), contains('--app todo-mcp'));
});
test('refuses to start when the first sync fails', () async {
writeTokens();
writeSeedCredentials(
Directory('${home.path}/.config/todo-mcp'),
name: 'firebase_auth.json',
);
db.failWith = 503;
expect(await start(), isNull);
expect(err.toString(), startsWith('error:'));
});
test('starts, publishes its own node log, and stops', () async {
writeTokens();
writeSeedCredentials(
Directory('${home.path}/.config/todo-mcp'),
name: 'firebase_auth.json',
);
final stop = await start();
expect(stop, isNotNull);
expect(err.toString(), contains('serving http://127.0.0.1:'));
expect(db.files.keys, contains(startsWith('todo-sync/notes/')));
expect(db.files.keys, contains(startsWith('todo-sync/revs/')));
await stop!();
});
}

View File

@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
late String id;
setUp(() async {
h = McpHarness();
await h.start();
c = await h.connect();
id = idIn(
await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Old title',
'body': 'old body',
}),
);
});
tearDown(() => h.dispose());
test('title, body, priority and repo change independently', () async {
await h.call(c, 'update_task', {'id': id, 'title': 'New title'});
var got = await h.call(c, 'get_task', {'id': id});
expect(got, endsWith('---\nNew title\n\nold body'));
await h.call(c, 'update_task', {
'id': id,
'body': '',
'priority': 'low',
'repo': 'kuhyx/utils',
});
got = await h.call(c, 'get_task', {'id': id});
expect(got, endsWith('---\nNew title'));
expect(got, contains('priority: low'));
expect(got, contains('repo: kuhyx/utils'));
});
test('done only via close; reopening drops the evidence', () async {
expect(
await h.refused(c, 'update_task', {'id': id, 'status': 'done'}),
startsWith('error: status:'),
);
expect(
await h.refused(c, 'update_task', {'id': id, 'title': ' '}),
startsWith('error: title:'),
);
await h.call(c, 'close_task', {'id': id, 'evidence': 'abc exit 0'});
expect(
await h.call(c, 'list_tasks', {'status': 'all'}),
contains('\tdone\t'),
);
expect(await h.call(c, 'list_tasks', {}), 'no tasks match');
await h.call(c, 'update_task', {'id': id, 'status': 'in_progress'});
final got = await h.call(c, 'get_task', {'id': id});
expect(got, contains('status: in_progress'));
expect(got, isNot(contains('evidence')));
});
test('an unmergeable peer log is reported, not hidden', () async {
h.db.files['todo-sync/notes/broken~2Ejson'] = 'not a log';
expect(await h.refused(c, 'list_tasks', {}), contains('unmergeable'));
});
}