diff --git a/.mcp.json b/.mcp.json index 02eaba1..83db8bf 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,10 +1,11 @@ { "mcpServers": { "todo": { - "type": "stdio", - "command": "${HOME}/.venvs/todo-mcp/bin/python", - "args": ["-m", "todo_mcp._mcp"], - "env": {} + "type": "http", + "url": "http://127.0.0.1:8735/mcp", + "headers": { + "Authorization": "Bearer ${TODO_MCP_TOKEN}" + } } } } diff --git a/AGENTS.md b/AGENTS.md index 68aa810..0bf470b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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`. +- 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`) - test: `flutter test --reporter=compact` - test-changed: `scripts/test_changed.sh` diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index e3770c5..eea80f4 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -55,24 +55,9 @@ class CliCommands { /// Prints one note in full. Future get(String ref) async { _report(await session.sync(push: false)); - final note = findNote(await session.repository.listNotes(), ref); - _out - ..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); + _out.writeln( + noteDetail(findNote(await session.repository.listNotes(), ref)), + ); return 0; } diff --git a/lib/cli/cli_session.dart b/lib/cli/cli_session.dart index e2f8ed7..3c76ec9 100644 --- a/lib/cli/cli_session.dart +++ b/lib/cli/cli_session.dart @@ -59,6 +59,7 @@ class CliSession { static Future open( String dataDir, { required String seedCredentials, + String seedApp = 'todo', http.Client? httpClient, }) async { final dir = Directory(dataDir); @@ -83,7 +84,7 @@ class CliSession { await repository.close(); throw FirebaseAuthError( '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( diff --git a/lib/cli/note_lookup.dart b/lib/cli/note_lookup.dart index 02e45dd..46acdf9 100644 --- a/lib/cli/note_lookup.dart +++ b/lib/cli/note_lookup.dart @@ -45,3 +45,18 @@ String listLine(Note note) => [ ].join('\t'); 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'); diff --git a/lib/data/note_record.dart b/lib/data/note_record.dart index f73f0a3..0717379 100644 --- a/lib/data/note_record.dart +++ b/lib/data/note_record.dart @@ -32,6 +32,11 @@ Map noteFields(Note note, Hlc hlc) => { Note noteFromRecord(Record record) { final fields = record.fields; 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( id: record.id, text: str(_text) ?? '', @@ -39,10 +44,10 @@ Note noteFromRecord(Record record) { status: Status.fromValue(fields[_status]?.$1 as int?), createdAt: _parseDate(str(_createdAt)), updatedAt: _parseDate(str(_updatedAt)), - repo: str(_repo), - doneCheck: str(_doneCheck), - by: str(_by), - evidence: str(_evidence), + repo: task(_repo), + doneCheck: task(_doneCheck), + by: task(_by), + evidence: task(_evidence), ); } diff --git a/lib/mcp/mcp_main.dart b/lib/mcp/mcp_main.dart new file mode 100644 index 0000000..4899874 --- /dev/null +++ b/lib/mcp/mcp_main.dart @@ -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 Function()?> startTodoMcp( + Map 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; + } +} diff --git a/lib/mcp/task_service.dart b/lib/mcp/task_service.dart new file mode 100644 index 0000000..f8eb775 --- /dev/null +++ b/lib/mcp/task_service.dart @@ -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 _tail = Future.value(); + + Future _serial(Future Function() body) { + final run = _tail.then((_) => body()); + _tail = run.then((_) {}, onError: (_) {}); + return run; + } + + Future> _pull() async { + final skipped = (await _session.sync(push: true)).skippedFiles; + if (skipped.isNotEmpty) throw StateError('sync: unmergeable $skipped'); + return await _session.repository.listNotes(); + } + + Future _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 _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 add(Map 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 list(Map 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 search(Map 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 get(Map args) => + _serial(() async => noteDetail(await _find(_Args(args)))); + + /// `update_task`: changes only the keys given; `done_check: ""` clears it. + Future update(Map 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 close(Map 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 notes) => + notes.isEmpty ? 'no tasks match' : notes.map(listLine).join('\n'); +} + +final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$'); +final Map _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 _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 statuses() => switch (optional('status')) { + null || 'open' => {Status.todo, Status.inProgress}, + 'all' => const {}, + _ => {status()!}, + }; + + T? _pick(String name, Map 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"', + )); + } +} diff --git a/lib/mcp/todo_mcp_server.dart b/lib/mcp/todo_mcp_server.dart new file mode 100644 index 0000000..ed7e726 --- /dev/null +++ b/lib/mcp/todo_mcp_server.dart @@ -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/") 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 " 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://:/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 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 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 props, + List required, + Future Function(Map) 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 " 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 " 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 _result(Future 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); diff --git a/lib/mcp/token_file.dart b/lib/mcp/token_file.dart new file mode 100644 index 0000000..0988d29 --- /dev/null +++ b/lib/mcp/token_file.dart @@ -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 _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)); +} diff --git a/mcp/.gitignore b/mcp/.gitignore deleted file mode 100644 index 6f52416..0000000 --- a/mcp/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -__pycache__/ -*.egg-info/ -*.pyc -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ diff --git a/mcp/README.md b/mcp/README.md index 471f018..58bd20a 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,47 +1,30 @@ -# todo backlog MCP server +# todo MCP server -A small, standalone, **read-only** [MCP](https://modelcontextprotocol.io) -server that lets Claude Code (and its subagents) query the todo app's backlog. +Repo tasks for any Claude session (local or cloud): `add_task`, `list_tasks`, +`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 -must never be touched from outside the app (CRDT writes corrupt cross-device -merge). Instead, this server reads the app's exported `BACKLOG.md` — the -canonical NotesMarkdown snapshot written by **Settings → "Export notes"**. +The server is one more sync node: it reuses `lib/cli` (`CliSession`, +`SyncService`) and writes its own CRDT log at +`todo-sync/notes/.json` plus `todo-sync/revs/`. +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` (` `) | +| 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 -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 +## One-time setup ```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 -`mcp` SDK), and verifies the imports. The server is registered via the -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 -``` +Deploy a new build: `mcp/install.sh` (restarts a running unit). +Revoke a token: delete its line in `tokens`; no restart needed. diff --git a/mcp/install.sh b/mcp/install.sh new file mode 100755 index 0000000..9c53078 --- /dev/null +++ b/mcp/install.sh @@ -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 "$@" diff --git a/mcp/mint_token.sh b/mcp/mint_token.sh new file mode 100755 index 0000000..ca22f4d --- /dev/null +++ b/mcp/mint_token.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Prints a new 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") (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 "$@" diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml deleted file mode 100644 index ed72277..0000000 --- a/mcp/pyproject.toml +++ /dev/null @@ -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"] diff --git a/mcp/scripts/setup_mcp.sh b/mcp/scripts/setup_mcp.sh deleted file mode 100755 index d127638..0000000 --- a/mcp/scripts/setup_mcp.sh +++ /dev/null @@ -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 "$@" diff --git a/mcp/todo-mcp.service b/mcp/todo-mcp.service new file mode 100644 index 0000000..3822e41 --- /dev/null +++ b/mcp/todo-mcp.service @@ -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 diff --git a/mcp/todo_mcp.dart b/mcp/todo_mcp.dart new file mode 100644 index 0000000..5003ad0 --- /dev/null +++ b/mcp/todo_mcp.dart @@ -0,0 +1,15 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:todo/mcp/mcp_main.dart'; + +Future 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); +} diff --git a/mcp/todo_mcp/__init__.py b/mcp/todo_mcp/__init__.py deleted file mode 100644 index c498695..0000000 --- a/mcp/todo_mcp/__init__.py +++ /dev/null @@ -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" diff --git a/mcp/todo_mcp/_backlog.py b/mcp/todo_mcp/_backlog.py deleted file mode 100644 index 8cba1c2..0000000 --- a/mcp/todo_mcp/_backlog.py +++ /dev/null @@ -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 (````) followed by, per note, an HTML -comment metadata marker:: - - - -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"^[ \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 diff --git a/mcp/todo_mcp/_mcp.py b/mcp/todo_mcp/_mcp.py deleted file mode 100644 index 4a9928c..0000000 --- a/mcp/todo_mcp/_mcp.py +++ /dev/null @@ -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() diff --git a/mcp/todo_mcp/tests/__init__.py b/mcp/todo_mcp/tests/__init__.py deleted file mode 100644 index 57c6090..0000000 --- a/mcp/todo_mcp/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for todo_mcp.""" diff --git a/mcp/todo_mcp/tests/test_backlog.py b/mcp/todo_mcp/tests/test_backlog.py deleted file mode 100644 index 25ff070..0000000 --- a/mcp/todo_mcp/tests/test_backlog.py +++ /dev/null @@ -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 = "\n\n" - - -def _marker(**attrs: str) -> str: - pairs = " ".join(f'{k}="{v}"' for k, v in attrs.items()) - return f"" - - -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 == "" diff --git a/mcp/todo_mcp/tests/test_mcp.py b/mcp/todo_mcp/tests/test_mcp.py deleted file mode 100644 index bf16666..0000000 --- a/mcp/todo_mcp/tests/test_mcp.py +++ /dev/null @@ -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 = """ - - -# First task -alpha - - -# Second task -beta - - -# 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 == [()] diff --git a/pubspec.lock b/pubspec.lock index c9bd9bc..d33d61d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -627,6 +627,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c418355..2324ed5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,9 @@ dependencies: image: 4.12.0 # Camera + gallery picks for image attachments on Android. 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 # type (FileProvider intent). Adds no permissions to the manifest. open_file: 4.0.0 diff --git a/test/mcp/mcp_auth_test.dart b/test/mcp/mcp_auth_test.dart new file mode 100644 index 0000000..d531438 --- /dev/null +++ b/test/mcp/mcp_auth_test.dart @@ -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), + ); + }); +} diff --git a/test/mcp/mcp_contract_test.dart b/test/mcp/mcp_contract_test.dart new file mode 100644 index 0000000..44556ed --- /dev/null +++ b/test/mcp/mcp_contract_test.dart @@ -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 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('"'))); + + 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 "; parsing decodes it back. + final notes = await h.session.repository.listNotes(); + final md = NotesMarkdown.export(notes); + expect(md, contains('"')); + 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)); + } + }); +} diff --git a/test/mcp/mcp_harness.dart b/test/mcp/mcp_harness.dart new file mode 100644 index 0000000..93b3514 --- /dev/null +++ b/test/mcp/mcp_harness.dart @@ -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 = []; + + Future 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 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 `. + Future 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 call( + McpClient client, + String tool, + Map 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 refused( + McpClient client, + String tool, + Map 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 args, + ) async { + final result = await client.callTool( + CallToolRequest(name: tool, arguments: args), + ); + final text = result.content + .whereType() + .map((c) => c.text) + .join('\n'); + return (text, result.isError); + } + + /// POSTs a raw `initialize` with the given headers; returns the status. + Future rawInitialize(Map 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(); + 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)!; diff --git a/test/mcp/mcp_main_test.dart b/test/mcp/mcp_main_test.dart new file mode 100644 index 0000000..f347c6a --- /dev/null +++ b/test/mcp/mcp_main_test.dart @@ -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 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 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!(); + }); +} diff --git a/test/mcp/mcp_update_test.dart b/test/mcp/mcp_update_test.dart new file mode 100644 index 0000000..fe987a7 --- /dev/null +++ b/test/mcp/mcp_update_test.dart @@ -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')); + }); +}