mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
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
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:
parent
9e18821c7b
commit
2a9332094a
@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
48
lib/mcp/mcp_main.dart
Normal 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
190
lib/mcp/task_service.dart
Normal 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"',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/mcp/todo_mcp_server.dart
Normal file
179
lib/mcp/todo_mcp_server.dart
Normal 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
31
lib/mcp/token_file.dart
Normal 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
6
mcp/.gitignore
vendored
@ -1,6 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.egg-info/
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
@ -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
27
mcp/install.sh
Executable 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
29
mcp/mint_token.sh
Executable 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 "$@"
|
||||||
@ -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"]
|
|
||||||
@ -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
12
mcp/todo-mcp.service
Normal 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
15
mcp/todo_mcp.dart
Normal 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);
|
||||||
|
}
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Test package for todo_mcp."""
|
|
||||||
@ -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 == ""
|
|
||||||
@ -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 == [()]
|
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
55
test/mcp/mcp_auth_test.dart
Normal file
55
test/mcp/mcp_auth_test.dart
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
125
test/mcp/mcp_contract_test.dart
Normal file
125
test/mcp/mcp_contract_test.dart
Normal 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('"')));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
133
test/mcp/mcp_harness.dart
Normal file
133
test/mcp/mcp_harness.dart
Normal 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)!;
|
||||||
69
test/mcp/mcp_main_test.dart
Normal file
69
test/mcp/mcp_main_test.dart
Normal 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!();
|
||||||
|
});
|
||||||
|
}
|
||||||
67
test/mcp/mcp_update_test.dart
Normal file
67
test/mcp/mcp_update_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user