mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
185 lines
5.9 KiB
Dart
185 lines
5.9 KiB
Dart
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.
|
|
- A repo has ONE open note, "<repo> todos", holding all its tasks as
|
|
"## title" sections. add_task appends to it (or creates it) and returns its id.
|
|
- 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.
|
|
Done checks of appended tasks are joined with &&.
|
|
- 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 a task to the repo's single open note (by: claude): appended as a "
|
|
'"## title" section, or the note is created. Returns "added <full id>" '
|
|
'or "appended to <full id>", then 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);
|