@ashikahmad/kanbanmd 0.0.0-stage → 2.0.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.
package/mcp/server.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Kanban.md MCP server (stdio). kanbanmd-mcp --dir /path/to/project (or KANBANMD_DIR)
3
+ // Read and write tools. Every call re-reads the files, so it always sees the page's and other
4
+ // writers' latest changes; every write is one verified, locked, atomic edit.
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { publicTools, callTool, SERVER_INSTRUCTIONS } from './tools.js';
9
+
10
+ const argDir = process.argv.indexOf('--dir');
11
+ const dir = (argDir > 0 && process.argv[argDir + 1]) || process.env.KANBANMD_DIR || process.cwd();
12
+ // --read-only (or KANBANMD_READONLY=1): expose only the read tools (fewer tokens, and no way to write).
13
+ const readOnly = process.argv.includes('--read-only') || process.env.KANBANMD_READONLY === '1';
14
+
15
+ const server = new Server(
16
+ { name: 'kanbanmd', version: '0.1.0' },
17
+ { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
18
+
19
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: publicTools({ readOnly }) }));
20
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
21
+ const client = server.getClientVersion();
22
+ return callTool(dir, req.params.name, req.params.arguments, { actor: (client && client.name) || 'mcp', readOnly });
23
+ });
24
+
25
+ await server.connect(new StdioServerTransport());
package/mcp/tools.js ADDED
@@ -0,0 +1,201 @@
1
+ // Tool definitions for the Kanban.md MCP server: reads and writes. Kept apart from the transport so tests and the
2
+ // token measurement can import them. Schemas are deliberately terse: every tool schema is paid for
3
+ // in context on every session, so descriptions say only what the name and the types do not.
4
+ import { resolve } from 'node:path';
5
+ import { BoardError } from '../core/text.js';
6
+ import { getBoard, getTask, getConfig, listArchive, getStats, summarizeChanges } from '../core/read.js';
7
+ import { readWorkspace, readActivity, mutate } from '../io/node-fs.js';
8
+ import { etagOf } from '../core/text.js';
9
+
10
+ const filterProps = {
11
+ status: { type: ['string', 'array'], items: { type: 'string' }, description: 'column id(s)' },
12
+ assignee: { type: ['string', 'array'], items: { type: 'string' }, description: '@name' },
13
+ tag: { type: ['string', 'array'], items: { type: 'string' }, description: 'all of these' },
14
+ priority: { type: ['string', 'array'], items: { type: 'string' } },
15
+ category: { type: ['string', 'array'], items: { type: 'string' } },
16
+ text: { type: 'string', description: 'words that must all appear in id/title/description/notes/tags' },
17
+ dueBefore: { type: 'string', description: 'YYYY-MM-DD' },
18
+ overdue: { type: 'boolean' },
19
+ limit: { type: 'integer', description: 'default 50, max 500' },
20
+ offset: { type: 'integer' },
21
+ fields: { type: 'array', items: { type: 'string' }, description: 'columns per row instead of the default (id always kept), e.g. title,status,progress' },
22
+ };
23
+
24
+ export const TOOLS = [
25
+ {
26
+ name: 'get_board',
27
+ description: 'Compact task list in display order (summaries only), plus per-column counts and rev. Filter here instead of reading files. archived: true or backlog: true list that side file instead (reverse: newest first).',
28
+ inputSchema: { type: 'object', properties: { ...filterProps, archived: { type: 'boolean' }, backlog: { type: 'boolean' }, reverse: { type: 'boolean' } } },
29
+ run: (ws, a) => (a.archived ? listArchive(ws, a) : getBoard(ws, a)),
30
+ },
31
+ {
32
+ name: 'get_task',
33
+ description: 'One task in full (board or archive), with its etag for later edits. `fields` limits the result.',
34
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, fields: filterProps.fields }, required: ['id'] },
35
+ run: (ws, a) => getTask(ws, a.id, a),
36
+ },
37
+ {
38
+ name: 'get_config',
39
+ description: 'Valid column ids, priorities, categories, users, tags, and format info.',
40
+ inputSchema: { type: 'object', properties: {} },
41
+ run: (ws) => getConfig(ws),
42
+ },
43
+ {
44
+ name: 'get_board_stats',
45
+ description: 'Counts by status, assignee and priority, plus overdue and due-soon tasks.',
46
+ inputSchema: { type: 'object', properties: {} },
47
+ run: (ws) => getStats(ws),
48
+ },
49
+ {
50
+ name: 'get_changes_since',
51
+ description: 'Edits made through these tools after an ISO timestamp, plus current rev. Page edits are not logged: if rev differs from the one you hold, re-read.',
52
+ inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'ISO timestamp' } } },
53
+ run: async (ws, a, dir) => summarizeChanges(await readActivity(dir), { since: a.since, rev: etagOf(ws.board) }),
54
+ },
55
+ ];
56
+
57
+ // ---- writes -----------------------------------------------------------------------------------
58
+ // Every write is one or more ops applied as a single verified, locked, atomic edit (io/node-fs.js).
59
+ // Arguments are flat (no nested "set" object) and one field definition serves create and update.
60
+ const date = { type: 'string', description: 'YYYY-MM-DD' };
61
+ const fieldProps = {
62
+ title: { type: 'string' },
63
+ status: { type: 'string', description: 'column id' },
64
+ priority: { type: 'string' },
65
+ category: { type: 'string' },
66
+ assignees: { type: 'array', items: { type: 'string' } },
67
+ tags: { type: 'array', items: { type: 'string' } },
68
+ blockedBy: { type: 'array', items: { type: 'string' }, description: 'task ids this one waits on' },
69
+ created: date, started: date, due: date, completed: date,
70
+ description: { type: 'string' },
71
+ subtasks: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' }, completed: { type: 'boolean' } }, required: ['text'] } },
72
+ notes: { type: 'string' },
73
+ order: { type: 'integer' },
74
+ };
75
+ const dryRun = { type: 'boolean', description: 'preview the diff, write nothing' };
76
+ const ifMatch = { type: 'string', description: 'etag from a read; fails if the task changed since' };
77
+ const FIELD_KEYS = Object.keys(fieldProps);
78
+ const pick = (args, keys) => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
79
+ const id = { type: 'string' };
80
+
81
+ TOOLS.push(
82
+ {
83
+ name: 'save_task',
84
+ description: 'Create a task (no id: appended last, id assigned) or change one (id: only the fields you pass; subtasks and notes replace, so prefer subtask and add_note). Returns id and the new etag.',
85
+ inputSchema: { type: 'object', properties: { id, ...fieldProps, ifMatch, dryRun } },
86
+ write: (a) => {
87
+ if (a.id) return [{ type: 'updateTask', id: a.id, set: pick(a, FIELD_KEYS), ifMatch: a.ifMatch }];
88
+ if (!a.title) throw new BoardError('missing_field', 'A new task needs a title (or pass id to change one)');
89
+ return [{ type: 'createTask', task: pick(a, FIELD_KEYS) }];
90
+ },
91
+ },
92
+ {
93
+ name: 'move_task',
94
+ description: 'Change column in place (a one-line edit). before (a task id) or atEnd also sets the position.',
95
+ inputSchema: { type: 'object', properties: { id, status: fieldProps.status, before: id, atEnd: { type: 'boolean' }, ifMatch, dryRun }, required: ['id', 'status'] },
96
+ write: (a) => [{ type: 'moveTask', id: a.id, status: a.status, before: a.before, atEnd: a.atEnd, ifMatch: a.ifMatch }],
97
+ },
98
+ {
99
+ name: 'subtask',
100
+ description: 'Add a subtask (add) or flip one (toggle: its index or exact text; done sets the state).',
101
+ inputSchema: { type: 'object', properties: { id, add: { type: 'string' }, toggle: { type: ['integer', 'string'] }, done: { type: 'boolean' }, dryRun }, required: ['id'] },
102
+ write: (a) => {
103
+ if (a.toggle !== undefined) return [{ type: 'toggleSubtask', id: a.id, ...(Number.isInteger(a.toggle) ? { index: a.toggle } : { text: String(a.toggle) }), ...(a.done !== undefined && { completed: a.done }) }];
104
+ if (a.add) return [{ type: 'addSubtask', id: a.id, text: a.add, ...(a.done !== undefined && { completed: a.done }) }];
105
+ throw new BoardError('missing_field', 'Pass add or toggle');
106
+ },
107
+ },
108
+ {
109
+ name: 'add_note',
110
+ description: 'Append text to a task\'s notes without rewriting them.',
111
+ inputSchema: { type: 'object', properties: { id, text: { type: 'string' }, dryRun }, required: ['id', 'text'] },
112
+ write: (a) => [{ type: 'appendNote', id: a.id, text: a.text }],
113
+ },
114
+ {
115
+ name: 'archive_tasks',
116
+ description: 'Move tasks to the archive, all or nothing, as one edit.',
117
+ inputSchema: { type: 'object', properties: { ids: { type: 'array', items: id }, dryRun }, required: ['ids'] },
118
+ write: (a) => [{ type: 'archiveTasks', ids: a.ids }],
119
+ },
120
+ {
121
+ name: 'restore_task',
122
+ description: 'Bring an archived task back to the board (its column, or the first one if that is gone).',
123
+ inputSchema: { type: 'object', properties: { id, dryRun }, required: ['id'] },
124
+ write: (a) => [{ type: 'restoreTask', id: a.id }],
125
+ },
126
+ {
127
+ name: 'delete_task',
128
+ description: 'Delete a task for good (board or archive). Prefer archive_tasks.',
129
+ inputSchema: { type: 'object', properties: { id, ifMatch, dryRun }, required: ['id'] },
130
+ write: (a) => [{ type: 'deleteTask', id: a.id, ifMatch: a.ifMatch }],
131
+ },
132
+ {
133
+ name: 'sort_tasks',
134
+ description: 'File-wide reorder, never implicit. mode: ids and positions write Order keys (by id / by current order), clear removes them, blocks reorders the file by id.',
135
+ inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['ids', 'positions', 'clear', 'blocks'] }, order: { type: 'string', enum: ['asc', 'desc'] }, scope: { type: 'string', enum: ['board', 'archive', 'both'] }, dryRun }, required: ['mode'] },
136
+ write: (a) => [{ type: 'sortTasks', mode: a.mode, order: a.order, scope: a.scope }],
137
+ },
138
+ {
139
+ name: 'update_config',
140
+ description: 'Replace config lists, the column list (reassign maps removed column ids to new ones), the housekeeping policy ({off}, {reset}, or done/backlog {column, afterDays} and max), or the backlog file ({enabled, column, file}: its column\'s tasks live there; a status change moves a task in or out).',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ categories: { type: 'array', items: { type: 'string' } }, users: { type: 'array', items: { type: 'string' } },
145
+ priorities: { type: 'array', items: { type: 'string' } }, tags: { type: 'array', items: { type: 'string' } },
146
+ columns: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, id: { type: 'string' } }, required: ['name', 'id'] } },
147
+ reassign: { type: 'object', additionalProperties: { type: 'string' } },
148
+ housekeeping: { type: 'object' }, backlogFile: { type: 'object' }, dryRun,
149
+ },
150
+ },
151
+ write: (a) => {
152
+ const ops = [];
153
+ if (a.columns) ops.push({ type: 'setColumns', columns: a.columns, reassign: a.reassign });
154
+ for (const name of ['categories', 'users', 'priorities', 'tags']) if (a[name]) ops.push({ type: 'setConfigList', name, values: a[name] });
155
+ if (a.housekeeping) ops.push({ type: 'setHousekeeping', ...a.housekeeping });
156
+ if (a.backlogFile) ops.push({ type: 'setBacklogFile', ...a.backlogFile });
157
+ if (!ops.length) throw new BoardError('missing_field', 'Pass at least one of columns, categories, users, priorities, tags, housekeeping, backlogFile');
158
+ return ops;
159
+ },
160
+ },
161
+ );
162
+
163
+ export const SERVER_INSTRUCTIONS = 'Kanban board in markdown files. Read with get_board (filtered summaries) and get_task; change it only through the write tools, never by editing the files. Ids look like TASK-014; statuses are column ids (get_config). Empty fields are omitted. Pass a task\'s etag as ifMatch to avoid overwriting someone else\'s edit; dryRun previews. If get_board_stats lists housekeeping suggestions, tell the user; do not archive without asking.';
164
+
165
+ export const publicTools = ({ readOnly = false } = {}) => TOOLS.filter((t) => !(readOnly && t.write)).map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
166
+
167
+ // What a write returns: just enough to continue (ids, the new etag), never the task back.
168
+ function ack(out, dry) {
169
+ const rs = out.results || [out.result];
170
+ const ids = rs.flatMap((r) => (r.ids ? r.ids : r.id ? [r.id] : []));
171
+ return {
172
+ ok: true,
173
+ ...(ids.length === 1 ? { id: ids[0] } : ids.length > 1 ? { ids } : {}),
174
+ ...(rs.length === 1 && rs[0].etag ? { etag: rs[0].etag } : {}),
175
+ ...(out.files.length ? { files: out.files } : { changed: false }),
176
+ ...(dry ? { dryRun: true, diff: out.diff } : {}),
177
+ };
178
+ }
179
+
180
+ // Run a tool against the project folder; always returns an MCP tool result. `actor` names the caller
181
+ // in the activity log.
182
+ export async function callTool(dir, name, args = {}, { actor = 'mcp', readOnly = false } = {}) {
183
+ const tool = TOOLS.find((t) => t.name === name && !(readOnly && t.write));
184
+ try {
185
+ if (!tool) throw new BoardError('unknown_tool', `Unknown tool "${name}"`, { valid: publicTools({ readOnly }).map((t) => t.name) });
186
+ const root = resolve(dir);
187
+ args = args || {};
188
+ let result;
189
+ if (tool.write) {
190
+ const ops = tool.write(args);
191
+ const out = await mutate(root, ops.length === 1 ? ops[0] : ops, { dryRun: !!args.dryRun, actor });
192
+ result = ack(out, !!args.dryRun);
193
+ } else {
194
+ result = await tool.run(await readWorkspace(root), args, root);
195
+ }
196
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
197
+ } catch (e) {
198
+ const err = e instanceof BoardError ? e.toJSON() : { code: 'internal', message: e.message };
199
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err }) }] };
200
+ }
201
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@ashikahmad/kanbanmd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "Kanban.md: a Markdown Kanban board for people and AI agents. The app (one HTML file), the kanbanmd CLI, an MCP server, and `kanbanmd serve` to edit a folder in any browser.",
5
+ "license": "MPL-2.0",
6
+ "repository": { "type": "git", "url": "git+https://github.com/ashikahmad/Kanban.md.git" },
7
+ "homepage": "https://ashikahmad.github.io/Kanban.md/",
8
+ "bugs": { "url": "https://github.com/ashikahmad/Kanban.md/issues" },
9
+ "type": "module",
10
+ "bin": {
11
+ "kanbanmd": "cli/kanbanmd.js"
12
+ },
13
+ "files": [
14
+ "cli/",
15
+ "core/",
16
+ "io/",
17
+ "mcp/server.js",
18
+ "mcp/tools.js",
19
+ "kanbanmd.html",
20
+ ".claude/skills/markdown-task-manager/SKILL.md",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "keywords": [
28
+ "kanban",
29
+ "markdown",
30
+ "mcp",
31
+ "ai-agents",
32
+ "cli",
33
+ "task-manager"
34
+ ],
35
+ "scripts": {
36
+ "build": "node build.mjs",
37
+ "build:dev": "node build.mjs --dev",
38
+ "test": "node --test test/*.test.mjs",
39
+ "measure": "node test/tools/measure.mjs",
40
+ "build:site": "node build-site.mjs",
41
+ "prepublishOnly": "npm run build && npm test"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.32.1"
45
+ },
46
+ "devDependencies": {
47
+ "esbuild": "^0.28.2",
48
+ "html-minifier-terser": "^7.2.0",
49
+ "marked": "^18.1.1",
50
+ "terser": "^5.51.2"
51
+ }
52
+ }