@ai-setting/roy-task-plugin 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * roy-task MCP server — exposes the task lifecycle to Claude Code as native
4
+ * MCP tools.
5
+ *
6
+ * ## Protocol
7
+ *
8
+ * Newline-delimited JSON-RPC 2.0 over stdin/stdout, per the MCP stdio
9
+ * transport. We implement exactly the methods a task server needs:
10
+ *
11
+ * | method | direction | notes |
12
+ * |-------------------------|-----------------|----------------------------------|
13
+ * | `initialize` | client -> server | we negotiate the protocol version |
14
+ * | `notifications/initialized` | client -> server | acknowledged by doing nothing |
15
+ * | `tools/list` | client -> server | the tool catalogue |
16
+ * | `tools/call` | client -> server | run one tool |
17
+ * | `ping` | client -> server | health check |
18
+ *
19
+ * Unknown methods get JSON-RPC error `-32601`. Notifications (requests with
20
+ * no `id`) get **no** response at all — replying to a notification is a
21
+ * protocol violation and desynchronises the client.
22
+ *
23
+ * ## stdout discipline
24
+ *
25
+ * stdout carries the protocol and nothing else. Every diagnostic goes to
26
+ * stderr. A stray `console.log` would inject garbage into the JSON-RPC stream
27
+ * and make Claude Code drop the server.
28
+ *
29
+ * ## stdio discipline (the other direction)
30
+ *
31
+ * This process reads stdin — that is the transport. It must NOT be spawned
32
+ * with `stdin: "ignore"` (the rule the daemon's spawn adapter enforces for
33
+ * *agent* subprocesses); an MCP server whose stdin is closed can never
34
+ * receive a request.
35
+ */
36
+
37
+ import { createInterface } from "node:readline";
38
+
39
+ import { callTool, openStore, BridgeError, defaultTaskDbPath } from "./bridge.mjs";
40
+ import {
41
+ MCP_TOOLS,
42
+ SUPPORTED_PROTOCOL_VERSIONS,
43
+ DEFAULT_PROTOCOL_VERSION,
44
+ JSON_RPC_ERRORS,
45
+ } from "./tools.mjs";
46
+
47
+ const SERVER_NAME = "roy-task";
48
+ const SERVER_VERSION = "0.5.0";
49
+
50
+ /**
51
+ * Write one JSON-RPC message to stdout.
52
+ *
53
+ * `jsonrpc: "2.0"` is **added here**, not by the callers. That is deliberate:
54
+ * the MCP SDK's response schema (`JSONRPCResultResponseSchema`) is `.strict()`
55
+ * and rejects any key it does not know — but it *requires* `jsonrpc` to be
56
+ * exactly `"2.0"`. A response built as `{ id, result }` and written verbatim
57
+ * fails to parse on the client with
58
+ * `Unrecognized keys: "id", "result"` / `expected "2.0"`, and the client then
59
+ * reports "Failed to connect" even though the server is alive and correct.
60
+ *
61
+ * Measured: this exact omission was the reason `claude --plugin-dir` showed
62
+ * `mcp_servers: [{"name":"plugin:roy-task:roy-task","status":"failed"}]` while
63
+ * the same server answered perfectly over a hand-rolled pipe.
64
+ */
65
+ function send(message) {
66
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
67
+ }
68
+
69
+ function sendResult(id, result) {
70
+ send({ jsonrpc: "2.0", id, result });
71
+ }
72
+
73
+ function sendError(id, code, message, data) {
74
+ const error = { code, message };
75
+ if (data !== undefined) error.data = data;
76
+ send({ jsonrpc: "2.0", id, error });
77
+ }
78
+
79
+ /**
80
+ * Pick the protocol version to answer with.
81
+ *
82
+ * Echo the client's version when we know it; otherwise offer ours. This is
83
+ * what the official SDK does, and it is what lets an older Claude Code talk to
84
+ * a newer server without a hard failure.
85
+ */
86
+ export function negotiateProtocolVersion(requested) {
87
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
88
+ return requested;
89
+ }
90
+ return DEFAULT_PROTOCOL_VERSION;
91
+ }
92
+
93
+ /** Turn a thrown error into an MCP tool result with `isError: true`. */
94
+ function toolError(message) {
95
+ return {
96
+ content: [{ type: "text", text: message }],
97
+ isError: true,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The server's identity, shared by `initialize` and `server/discover`.
103
+ */
104
+ const SERVER_INFO = { name: SERVER_NAME, version: SERVER_VERSION };
105
+
106
+ /**
107
+ * The capabilities we advertise.
108
+ */
109
+ const SERVER_CAPABILITIES = { tools: { listChanged: false } };
110
+
111
+ /**
112
+ * Handle one parsed JSON-RPC request.
113
+ *
114
+ * Exported for tests: the pure part (dispatch) is separated from the I/O
115
+ * part (stdin/stdout) so the protocol can be exercised without a pipe.
116
+ */
117
+ export function handleRequest(request, db) {
118
+ const { id, method, params } = request;
119
+
120
+ // Notifications have no id and must not be answered.
121
+ if (id === undefined || id === null) {
122
+ return null;
123
+ }
124
+
125
+ switch (method) {
126
+ case "initialize": {
127
+ const requested = params && params.protocolVersion;
128
+ return {
129
+ id,
130
+ result: {
131
+ protocolVersion: negotiateProtocolVersion(requested),
132
+ capabilities: SERVER_CAPABILITIES,
133
+ serverInfo: SERVER_INFO,
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * `server/discover` — Claude Code 2.1.285's pre-handshake probe.
140
+ *
141
+ * Measured against 2.1.285: before it sends `initialize`, it sends
142
+ *
143
+ * ```jsonc
144
+ * {"jsonrpc":"2.0","id":"server-discover-probe-1","method":"server/discover",
145
+ * "params":{"_meta":{
146
+ * "io.modelcontextprotocol/protocolVersion":"2026-07-28",
147
+ * "io.modelcontextprotocol/clientInfo":{"name":"claude-code","version":"2.1.285"},
148
+ * "io.modelcontextprotocol/clientCapabilities":{…}}}}
149
+ * ```
150
+ *
151
+ * A server that answers `-32601 Method not found` is treated by
152
+ * 2.1.285 as **failed to connect** — the whole MCP server is dropped
153
+ * even though the later `initialize` handshake would have succeeded.
154
+ * (Measured: with `server/discover` unhandled the init event reported
155
+ * `{"name":"plugin:roy-task:roy-task","status":"failed"}`; with it
156
+ * handled the same event reported `status:"connected"`.)
157
+ *
158
+ * The response shape mirrors `initialize` — the server describes itself
159
+ * — so the two handlers share `SERVER_INFO` / `SERVER_CAPABILITIES`.
160
+ *
161
+ * This method is **not** in @modelcontextprotocol/sdk 1.32.1, which is why
162
+ * an SDK-based client never exercises it and a hand-rolled server can
163
+ * pass its own tests while failing inside Claude Code.
164
+ */
165
+ case "server/discover": {
166
+ return {
167
+ id,
168
+ result: {
169
+ protocolVersion: negotiateProtocolVersion(
170
+ (params &&
171
+ params._meta &&
172
+ params._meta["io.modelcontextprotocol/protocolVersion"]) ||
173
+ undefined,
174
+ ),
175
+ capabilities: SERVER_CAPABILITIES,
176
+ serverInfo: SERVER_INFO,
177
+ },
178
+ };
179
+ }
180
+
181
+ case "ping":
182
+ return { id, result: {} };
183
+
184
+ case "tools/list":
185
+ return { id, result: { tools: MCP_TOOLS } };
186
+
187
+ case "tools/call": {
188
+ const toolName = params && params.name;
189
+ const args = (params && params.arguments) || {};
190
+ if (typeof toolName !== "string") {
191
+ return {
192
+ id,
193
+ error: {
194
+ code: JSON_RPC_ERRORS.INVALID_PARAMS,
195
+ message: "tools/call requires a tool name",
196
+ },
197
+ };
198
+ }
199
+ try {
200
+ const value = callTool(db, toolName, args);
201
+ return {
202
+ id,
203
+ result: {
204
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
205
+ },
206
+ };
207
+ } catch (err) {
208
+ const message =
209
+ err instanceof BridgeError
210
+ ? err.message
211
+ : `tool "${toolName}" failed: ${err && err.message ? err.message : String(err)}`;
212
+ return { id, result: toolError(message) };
213
+ }
214
+ }
215
+
216
+ default:
217
+ return {
218
+ id,
219
+ error: {
220
+ code: JSON_RPC_ERRORS.METHOD_NOT_FOUND,
221
+ message: `method not found: ${method}`,
222
+ },
223
+ };
224
+ }
225
+ }
226
+
227
+ /** Read newline-delimited JSON-RPC from stdin and answer on stdout. */
228
+ export function main() {
229
+ const dbPath = process.env.ROY_TASK_DB_PATH || defaultTaskDbPath();
230
+ let db;
231
+ try {
232
+ db = openStore(dbPath);
233
+ } catch (err) {
234
+ process.stderr.write(
235
+ `roy-task mcp server: cannot open task store at ${dbPath}: ${err && err.message}\n`,
236
+ );
237
+ process.exit(1);
238
+ }
239
+
240
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
241
+
242
+ rl.on("line", (line) => {
243
+ const trimmed = line.trim();
244
+ if (trimmed.length === 0) return;
245
+
246
+ let request;
247
+ try {
248
+ request = JSON.parse(trimmed);
249
+ } catch {
250
+ sendError(null, JSON_RPC_ERRORS.PARSE_ERROR, "invalid JSON");
251
+ return;
252
+ }
253
+
254
+ if (typeof request !== "object" || request === null || Array.isArray(request)) {
255
+ sendError(null, JSON_RPC_ERRORS.INVALID_REQUEST, "request must be a JSON object");
256
+ return;
257
+ }
258
+
259
+ const response = handleRequest(request, db);
260
+ if (response) send(response);
261
+ });
262
+
263
+ rl.on("close", () => {
264
+ try {
265
+ db.close();
266
+ } catch {
267
+ // already closed
268
+ }
269
+ process.exit(0);
270
+ });
271
+ }
272
+
273
+ // Only run when executed directly, so tests can import the pure helpers.
274
+ const invokedDirectly =
275
+ process.argv[1] &&
276
+ (process.argv[1].endsWith("roy-task-mcp-server.mjs") ||
277
+ process.argv[1].endsWith("roy-task-mcp-server.js"));
278
+
279
+ if (invokedDirectly) {
280
+ main();
281
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Zero-dependency MCP server exposing the roy task tools over stdio.
3
+ *
4
+ * ## Why hand-rolled instead of @modelcontextprotocol/sdk
5
+ *
6
+ * The published npm package (`@ai-setting/roy-task-plugin`) is installed on
7
+ * plain Node.js machines with no native build step. Adding the SDK would add a
8
+ * dependency tree to a package whose whole point is "install and it runs".
9
+ * The MCP wire format we need is small and stable:
10
+ *
11
+ * - newline-delimited JSON-RPC 2.0 on stdin/stdout
12
+ * - `initialize` / `tools/list` / `tools/call` (plus `notifications/initialized`)
13
+ *
14
+ * That is implemented here in ~200 lines with nothing but `node:child_process`.
15
+ * The protocol version is negotiated: we echo the client's requested version
16
+ * when it is one we know, else we answer with our own.
17
+ *
18
+ * ## Where the data comes from
19
+ *
20
+ * The task store is **roy-agent's** store (`~/.local/share/roy-agent/tasks.db`),
21
+ * not the plugin's own `~/.roy-task-plugin/tasks.db`. The reason is
22
+ * interoperability: the roy agent inside opencode/claude creates tasks through
23
+ * `roy-agent`'s own task tools, and the progress card the daemon sends is
24
+ * rendered from the *same* rows. If this MCP server wrote to a different
25
+ * database, Claude would create tasks the card can never see.
26
+ *
27
+ * So the tools shell out to the `roy-agent tasks …` CLI, which is the stable,
28
+ * already-migrated, already-schema-validated way to reach that store. We do
29
+ * not re-implement the store, and we do not open the SQLite file ourselves.
30
+ *
31
+ * ## stdio hygiene
32
+ *
33
+ * This process is spawned by Claude Code as an MCP server. It must:
34
+ *
35
+ * - **read** stdin (that is the transport) — obviously not `ignore` here;
36
+ * - **never** write anything but protocol messages to stdout. A stray
37
+ * `console.log` would corrupt the stream, so every log line goes to stderr.
38
+ *
39
+ * The "stdin must be ignore" rule from the daemon's spawn adapter applies to
40
+ * *agent* subprocesses, not to an MCP server — an MCP server whose stdin is
41
+ * closed can never receive a single request.
42
+ */
43
+
44
+ export const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
45
+ "2025-11-25",
46
+ "2025-06-18",
47
+ "2025-03-26",
48
+ "2024-11-05",
49
+ "2024-10-07",
50
+ ]);
51
+
52
+ export const DEFAULT_PROTOCOL_VERSION = "2025-11-25";
53
+
54
+ /** The JSON-RPC error codes we use. */
55
+ export const JSON_RPC_ERRORS = Object.freeze({
56
+ PARSE_ERROR: -32700,
57
+ INVALID_REQUEST: -32600,
58
+ METHOD_NOT_FOUND: -32601,
59
+ INVALID_PARAMS: -32602,
60
+ INTERNAL_ERROR: -32603,
61
+ });
62
+
63
+ /**
64
+ * The task tools we expose.
65
+ *
66
+ * The names mirror roy-agent's own tool names (`task_create`, `task_list`, …)
67
+ * so the mental model transfers one-to-one.
68
+ */
69
+ export const MCP_TOOLS = Object.freeze([
70
+ {
71
+ name: "task_tree",
72
+ description:
73
+ "Show the task tree. Call this BEFORE creating anything, to decide whether new work belongs under an existing root (parent_task_id) or is genuinely new.",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ depth: {
78
+ type: "number",
79
+ description: "Tree depth. 1 = roots + their direct children. -1 = unlimited.",
80
+ },
81
+ parent_task_id: {
82
+ type: "number",
83
+ description: "Only show the subtree under this task id.",
84
+ },
85
+ },
86
+ },
87
+ },
88
+ {
89
+ name: "task_create",
90
+ description:
91
+ "Create a tracked task. Task is a first-class citizen: create the task BEFORE doing the work, then update it as you go and complete it when done.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ title: { type: "string", description: "Imperative, specific one-line title." },
96
+ description: { type: "string", description: "What needs to be done and why." },
97
+ goals_and_expected_deliverables: {
98
+ type: "string",
99
+ description: "Success criteria and expected outputs.",
100
+ },
101
+ priority: {
102
+ type: "string",
103
+ enum: ["low", "medium", "high"],
104
+ description: "Task priority.",
105
+ },
106
+ type: {
107
+ type: "string",
108
+ enum: ["normal", "cycle", "longterm"],
109
+ description: "normal = one-off; cycle = periodic; longterm = ongoing goal.",
110
+ },
111
+ parent_task_id: {
112
+ type: "number",
113
+ description: "Parent task id. Omit to create a root task.",
114
+ },
115
+ project_path: { type: "string", description: "Project root path." },
116
+ context: {
117
+ type: "string",
118
+ description: "JSON string with worktree_path / branch / type etc.",
119
+ },
120
+ tags: {
121
+ type: "array",
122
+ items: { type: "string" },
123
+ description: "Task tags.",
124
+ },
125
+ },
126
+ required: ["title"],
127
+ },
128
+ },
129
+ {
130
+ name: "task_get",
131
+ description: "Get one task's full detail, including its operation records.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: { id: { type: "number", description: "Task id." } },
135
+ required: ["id"],
136
+ },
137
+ },
138
+ {
139
+ name: "task_list",
140
+ description: "List tasks. Supports filtering by status / priority / type.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ status: {
145
+ type: "string",
146
+ enum: ["todo", "active", "completed", "paused", "cancelled", "archived"],
147
+ },
148
+ priority: { type: "string", enum: ["low", "medium", "high"] },
149
+ type: { type: "string", enum: ["normal", "cycle", "longterm"] },
150
+ limit: { type: "number", description: "Max rows (default 20)." },
151
+ offset: { type: "number", description: "Row offset." },
152
+ },
153
+ },
154
+ },
155
+ {
156
+ name: "task_update",
157
+ description:
158
+ "Update a task's status, progress or description. Report progress as you go, not only at the end.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ id: { type: "number", description: "Task id." },
163
+ status: {
164
+ type: "string",
165
+ enum: ["todo", "active", "completed", "paused", "cancelled", "archived"],
166
+ },
167
+ progress: { type: "number", description: "0-100." },
168
+ current_status: { type: "string", description: "One sentence: where you are right now." },
169
+ description: { type: "string", description: "Replace the description." },
170
+ goals_and_expected_deliverables: { type: "string", description: "Replace the goals." },
171
+ priority: { type: "string", enum: ["low", "medium", "high"] },
172
+ },
173
+ required: ["id"],
174
+ },
175
+ },
176
+ {
177
+ name: "task_operation_create",
178
+ description:
179
+ "Record a milestone (what was reached) and, optionally, a process summary (how you got there, 100-150 chars). Milestones mark state transitions: RED->GREEN, blocker found, decision made.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ task_id: { type: "number", description: "Task id." },
184
+ milestone_type: {
185
+ type: "string",
186
+ enum: [
187
+ "create",
188
+ "progress",
189
+ "milestone",
190
+ "problem",
191
+ "solution",
192
+ "decision",
193
+ "review",
194
+ "completed",
195
+ ],
196
+ },
197
+ milestone_title: { type: "string", description: "One-line milestone title." },
198
+ milestone_description: { type: "string", description: "What was reached." },
199
+ process_description: {
200
+ type: "string",
201
+ description: "How you got there. 100-150 characters, strictly.",
202
+ },
203
+ },
204
+ required: ["task_id", "milestone_type", "milestone_title"],
205
+ },
206
+ },
207
+ {
208
+ name: "task_complete",
209
+ description:
210
+ "Close out a task. Provide deliverable_storage_path so the outcome is traceable.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ id: { type: "number", description: "Task id." },
215
+ deliverable_storage_path: {
216
+ type: "string",
217
+ description: "Where the work product lives (path / URL / branch).",
218
+ },
219
+ },
220
+ required: ["id"],
221
+ },
222
+ },
223
+ {
224
+ name: "task_search",
225
+ description:
226
+ "Search tasks by keywords across title, description, goals, tags and operation records.",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ keywords: {
231
+ type: "array",
232
+ items: { type: "string" },
233
+ description: "Keywords (AND logic).",
234
+ },
235
+ limit: { type: "number" },
236
+ },
237
+ required: ["keywords"],
238
+ },
239
+ },
240
+ ]);