@qzoft/check-list 1.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/README.md +130 -0
- package/dist/parser.d.ts +27 -0
- package/dist/parser.js +65 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +154 -0
- package/dist/writer.d.ts +13 -0
- package/dist/writer.js +59 -0
- package/package.json +22 -0
- package/ui/task-checklist.html +369 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# check-list
|
|
2
|
+
|
|
3
|
+
A general-purpose MCP App that discovers and displays checklists from **all markdown files** in your project, rendered as an interactive checkbox UI inside VS Code Copilot Chat. Changes are saved automatically — no confirmation needed.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- VS Code Insiders (for [MCP Apps support](https://code.visualstudio.com/blogs/2026/01/26/mcp-apps-support))
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
1. Clone this repo:
|
|
13
|
+
```sh
|
|
14
|
+
git clone https://github.com/qzoft/check-list.git
|
|
15
|
+
cd check-list
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Install dependencies:
|
|
19
|
+
```sh
|
|
20
|
+
npm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
3. Build the project:
|
|
24
|
+
```sh
|
|
25
|
+
npm run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
4. Open VS Code — the `.vscode/mcp.json` config auto-registers the server. VS Code will pick it up automatically when you open the workspace.
|
|
29
|
+
|
|
30
|
+
5. In Copilot Chat, ask **"show my tasks"** → the `list_tasks` tool scans the project and renders the interactive checkbox UI.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Once the server is running, you can use two tools in Copilot Chat:
|
|
35
|
+
|
|
36
|
+
- **`list_tasks`** — scans all `.md` files in the project directory, finds checkbox items, and displays an interactive UI grouped by file and section. Toggle any checkbox and it saves automatically.
|
|
37
|
+
- **`update_tasks`** — called automatically when you toggle a checkbox. Can also be called directly by Copilot to update tasks in any markdown file.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
┌──────────────────────────┐
|
|
43
|
+
│ VS Code Copilot Chat │
|
|
44
|
+
│ │
|
|
45
|
+
│ "show my tasks" │
|
|
46
|
+
│ │ │
|
|
47
|
+
│ ▼ │
|
|
48
|
+
│ list_tasks tool ───────┼──► MCP Server (Node.js)
|
|
49
|
+
│ │ │ │
|
|
50
|
+
│ │ │ scans project for *.md
|
|
51
|
+
│ │ │ parses checkboxes
|
|
52
|
+
│ │ │ │
|
|
53
|
+
│ renders iframe UI ◄────┼─────────┘
|
|
54
|
+
│ (task-checklist.html) │
|
|
55
|
+
│ │ │
|
|
56
|
+
│ [checkbox toggle] │
|
|
57
|
+
│ │ │
|
|
58
|
+
│ auto-save ─────────────┼──► MCP Server writes to file
|
|
59
|
+
└──────────────────────────┘
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. The MCP server recursively discovers all `.md` files in the project directory.
|
|
63
|
+
2. Each file is parsed for `## Section` headers and `- [ ]` / `- [x]` checkbox items.
|
|
64
|
+
3. VS Code renders `ui/task-checklist.html` as an interactive iframe grouped by file.
|
|
65
|
+
4. Toggling a checkbox **immediately saves** the change back to the originating file — no save button required.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Variable | Description | Example |
|
|
70
|
+
|---------------|-----------------------------------------------------|----------------------------------|
|
|
71
|
+
| `PROJECT_DIR` | Root directory to scan for markdown files | `~/repos/my-project` |
|
|
72
|
+
| `TASK_FILE` | *(backward-compat)* Falls back to parent directory | `~/repos/my-project/task.md` |
|
|
73
|
+
|
|
74
|
+
If neither is set, the server uses the current working directory.
|
|
75
|
+
|
|
76
|
+
The project directory is configured in `.vscode/mcp.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"servers": {
|
|
81
|
+
"check-list": {
|
|
82
|
+
"command": "node",
|
|
83
|
+
"args": ["dist/server.js"],
|
|
84
|
+
"env": {
|
|
85
|
+
"PROJECT_DIR": "${workspaceFolder}"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Task file format
|
|
93
|
+
|
|
94
|
+
The parser recognizes `## Section` headers and checkbox list items in any `.md` file:
|
|
95
|
+
|
|
96
|
+
```markdown
|
|
97
|
+
## Today
|
|
98
|
+
- [ ] Write tests
|
|
99
|
+
- [ ] Update README
|
|
100
|
+
|
|
101
|
+
## This Week
|
|
102
|
+
- [ ] Review PRs
|
|
103
|
+
- [ ] Deploy to staging
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Checkbox items that appear before any section header are grouped under a default "Tasks" section. Non-checkbox content is preserved as-is when writing back.
|
|
107
|
+
|
|
108
|
+
The server skips common non-project directories (`node_modules`, `.git`, `dist`, `build`, etc.) during scanning.
|
|
109
|
+
|
|
110
|
+
## Project structure
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
check-list/
|
|
114
|
+
├── package.json
|
|
115
|
+
├── tsconfig.json
|
|
116
|
+
├── README.md
|
|
117
|
+
├── .vscode/
|
|
118
|
+
│ └── mcp.json # VS Code MCP server config
|
|
119
|
+
├── src/
|
|
120
|
+
│ ├── server.ts # MCP server entry point
|
|
121
|
+
│ ├── parser.ts # Markdown checkbox parser
|
|
122
|
+
│ └── writer.ts # File discovery & read/write
|
|
123
|
+
└── ui/
|
|
124
|
+
└── task-checklist.html # MCP App UI rendered in iframe
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Learn more
|
|
128
|
+
|
|
129
|
+
- [MCP Apps support in VS Code](https://code.visualstudio.com/blogs/2026/01/26/mcp-apps-support)
|
|
130
|
+
- [Model Context Protocol SDK](https://github.com/modelcontextprotocol/typescript-sdk)
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface Task {
|
|
2
|
+
text: string;
|
|
3
|
+
checked: boolean;
|
|
4
|
+
line: number;
|
|
5
|
+
}
|
|
6
|
+
export interface TaskSection {
|
|
7
|
+
name: string;
|
|
8
|
+
tasks: Task[];
|
|
9
|
+
}
|
|
10
|
+
/** Represents all checkbox tasks found in a single markdown file. */
|
|
11
|
+
export interface FileTaskGroup {
|
|
12
|
+
file: string;
|
|
13
|
+
sections: TaskSection[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parses a markdown string, finding section headers (## SectionName) and
|
|
17
|
+
* checkbox lines within each section.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseTasks(markdown: string): TaskSection[];
|
|
20
|
+
/**
|
|
21
|
+
* Applies updates to the original markdown, toggling checkbox state for each
|
|
22
|
+
* specified line. All other content is preserved exactly as-is.
|
|
23
|
+
*/
|
|
24
|
+
export declare function serializeTasks(original: string, updates: {
|
|
25
|
+
line: number;
|
|
26
|
+
checked: boolean;
|
|
27
|
+
}[]): string;
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a markdown string, finding section headers (## SectionName) and
|
|
3
|
+
* checkbox lines within each section.
|
|
4
|
+
*/
|
|
5
|
+
export function parseTasks(markdown) {
|
|
6
|
+
const lines = markdown.replace(/\r/g, '').split('\n');
|
|
7
|
+
const sections = [];
|
|
8
|
+
let currentSection = null;
|
|
9
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10
|
+
const line = lines[i];
|
|
11
|
+
// Match section headers: ## SectionName
|
|
12
|
+
const sectionMatch = line.match(/^##\s+(.+)$/);
|
|
13
|
+
if (sectionMatch) {
|
|
14
|
+
currentSection = { name: sectionMatch[1].trim(), tasks: [] };
|
|
15
|
+
sections.push(currentSection);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
// Match checkbox lines (with or without a section header)
|
|
19
|
+
const checkedMatch = line.match(/^- \[x\]\s+(.+)$/i);
|
|
20
|
+
const uncheckedMatch = line.match(/^- \[ \]\s+(.+)$/);
|
|
21
|
+
if (checkedMatch || uncheckedMatch) {
|
|
22
|
+
// Ensure there is a section to hold the task
|
|
23
|
+
if (!currentSection) {
|
|
24
|
+
currentSection = { name: 'Tasks', tasks: [] };
|
|
25
|
+
sections.push(currentSection);
|
|
26
|
+
}
|
|
27
|
+
if (checkedMatch) {
|
|
28
|
+
currentSection.tasks.push({
|
|
29
|
+
text: checkedMatch[1].trim(),
|
|
30
|
+
checked: true,
|
|
31
|
+
line: i,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else if (uncheckedMatch) {
|
|
35
|
+
currentSection.tasks.push({
|
|
36
|
+
text: uncheckedMatch[1].trim(),
|
|
37
|
+
checked: false,
|
|
38
|
+
line: i,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return sections;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Applies updates to the original markdown, toggling checkbox state for each
|
|
47
|
+
* specified line. All other content is preserved exactly as-is.
|
|
48
|
+
*/
|
|
49
|
+
export function serializeTasks(original, updates) {
|
|
50
|
+
const lines = original.split('\n');
|
|
51
|
+
for (const update of updates) {
|
|
52
|
+
const line = lines[update.line];
|
|
53
|
+
if (line === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (update.checked) {
|
|
56
|
+
// Mark as checked: replace - [ ] with - [x]
|
|
57
|
+
lines[update.line] = line.replace(/^(- )\[ \]/, '$1[x]');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Mark as unchecked: replace - [x] with - [ ]
|
|
61
|
+
lines[update.line] = line.replace(/^(- )\[[xX]\]/, '$1[ ]');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { discoverMarkdownFiles, readTaskFile, writeTaskFile } from './writer.js';
|
|
10
|
+
import { parseTasks, serializeTasks } from './parser.js';
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
// Use PROJECT_DIR if set, otherwise fall back to TASK_FILE's parent dir, or CWD
|
|
14
|
+
const projectDir = (() => {
|
|
15
|
+
const dir = process.env['PROJECT_DIR'];
|
|
16
|
+
if (dir) {
|
|
17
|
+
return dir.startsWith('~')
|
|
18
|
+
? path.join(process.env['HOME'] ?? '', dir.slice(1))
|
|
19
|
+
: path.resolve(dir);
|
|
20
|
+
}
|
|
21
|
+
// Backward-compat: if TASK_FILE is set, use its parent directory
|
|
22
|
+
const taskFile = process.env['TASK_FILE'];
|
|
23
|
+
if (taskFile) {
|
|
24
|
+
const resolved = taskFile.startsWith('~')
|
|
25
|
+
? path.join(process.env['HOME'] ?? '', taskFile.slice(1))
|
|
26
|
+
: path.resolve(taskFile);
|
|
27
|
+
return path.dirname(resolved);
|
|
28
|
+
}
|
|
29
|
+
return process.cwd();
|
|
30
|
+
})();
|
|
31
|
+
// MCP App UI resource
|
|
32
|
+
const uiHtmlPath = path.resolve(__dirname, '..', 'ui', 'task-checklist.html');
|
|
33
|
+
const uiResourceUri = 'ui://check-list/task-checklist.html';
|
|
34
|
+
const server = new McpServer({
|
|
35
|
+
name: 'check-list',
|
|
36
|
+
version: '2.0.0',
|
|
37
|
+
});
|
|
38
|
+
// Register the HTML resource for the UI
|
|
39
|
+
registerAppResource(server, 'Task Checklist', uiResourceUri, { description: 'Interactive checkbox UI for task management' }, async () => ({
|
|
40
|
+
contents: [{
|
|
41
|
+
uri: uiResourceUri,
|
|
42
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
43
|
+
text: fs.readFileSync(uiHtmlPath, 'utf-8'),
|
|
44
|
+
}],
|
|
45
|
+
}));
|
|
46
|
+
registerAppTool(server, 'list_tasks', {
|
|
47
|
+
description: 'Discover and display checklists from all markdown files in the project',
|
|
48
|
+
_meta: { ui: { resourceUri: uiResourceUri } },
|
|
49
|
+
}, async () => {
|
|
50
|
+
let mdFiles;
|
|
51
|
+
try {
|
|
52
|
+
mdFiles = await discoverMarkdownFiles(projectDir);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
return {
|
|
57
|
+
isError: true,
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: 'text',
|
|
61
|
+
text: `Error scanning project directory ${projectDir}: ${message}`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const fileGroups = [];
|
|
67
|
+
for (const filePath of mdFiles) {
|
|
68
|
+
let content;
|
|
69
|
+
try {
|
|
70
|
+
content = await readTaskFile(filePath);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue; // skip files we can't read
|
|
74
|
+
}
|
|
75
|
+
const sections = parseTasks(content);
|
|
76
|
+
const hasTasks = sections.some((s) => s.tasks.length > 0);
|
|
77
|
+
if (!hasTasks)
|
|
78
|
+
continue;
|
|
79
|
+
// Use relative path for cleaner display
|
|
80
|
+
const relPath = path.relative(projectDir, filePath);
|
|
81
|
+
fileGroups.push({ file: relPath, sections });
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: 'The tasks are displayed in the interactive UI above. Do not repeat or summarize the task content in your response — the user can already see and interact with them.',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
structuredContent: { files: fileGroups },
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
registerAppTool(server, 'update_tasks', {
|
|
94
|
+
description: 'Update checkbox states in a project markdown file (auto-saved on toggle)',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
file: z.string().describe('Relative path to the markdown file within the project'),
|
|
97
|
+
updates: z.array(z.object({
|
|
98
|
+
line: z.number().describe('0-indexed line number in the markdown file'),
|
|
99
|
+
checked: z.boolean().describe('New checked state for the checkbox'),
|
|
100
|
+
})).describe('Array of line updates to apply'),
|
|
101
|
+
},
|
|
102
|
+
_meta: { ui: { resourceUri: uiResourceUri, visibility: ['app'] } },
|
|
103
|
+
}, async ({ file, updates }) => {
|
|
104
|
+
const filePath = path.resolve(projectDir, file);
|
|
105
|
+
let content;
|
|
106
|
+
try {
|
|
107
|
+
content = await readTaskFile(filePath);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
return {
|
|
112
|
+
isError: true,
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: 'text',
|
|
116
|
+
text: `Error reading file ${file}: ${message}`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const updated = serializeTasks(content, updates);
|
|
122
|
+
try {
|
|
123
|
+
await writeTaskFile(filePath, updated);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
return {
|
|
128
|
+
isError: true,
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: `Error writing file ${file}: ${message}`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const checkedCount = updates.filter((u) => u.checked).length;
|
|
138
|
+
const uncheckedCount = updates.filter((u) => !u.checked).length;
|
|
139
|
+
const parts = [];
|
|
140
|
+
if (checkedCount > 0)
|
|
141
|
+
parts.push(`${checkedCount} task(s) marked as done`);
|
|
142
|
+
if (uncheckedCount > 0)
|
|
143
|
+
parts.push(`${uncheckedCount} task(s) marked as undone`);
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: `✅ Saved ${file}: ${parts.join(', ')}.`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
const transport = new StdioServerTransport();
|
|
154
|
+
await server.connect(transport);
|
package/dist/writer.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively discovers all `.md` files under the given directory,
|
|
3
|
+
* skipping common non-project directories.
|
|
4
|
+
*/
|
|
5
|
+
export declare function discoverMarkdownFiles(dir: string): Promise<string[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Reads the markdown file from disk and returns its content as a string.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readTaskFile(filePath: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Writes the updated markdown content back to the local file.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeTaskFile(filePath: string, content: string): Promise<void>;
|
package/dist/writer.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
/** Directories to skip when discovering markdown files. */
|
|
4
|
+
const IGNORED_DIRS = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'.git',
|
|
7
|
+
'dist',
|
|
8
|
+
'.next',
|
|
9
|
+
'.nuxt',
|
|
10
|
+
'build',
|
|
11
|
+
'coverage',
|
|
12
|
+
'vendor',
|
|
13
|
+
'__pycache__',
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* Recursively discovers all `.md` files under the given directory,
|
|
17
|
+
* skipping common non-project directories.
|
|
18
|
+
*/
|
|
19
|
+
export async function discoverMarkdownFiles(dir) {
|
|
20
|
+
const results = [];
|
|
21
|
+
async function walk(current) {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return; // skip directories we can't read
|
|
28
|
+
}
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name.startsWith('.'))
|
|
31
|
+
continue;
|
|
32
|
+
const fullPath = path.join(current, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
35
|
+
await walk(fullPath);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
39
|
+
results.push(fullPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await walk(dir);
|
|
44
|
+
// Sort alphabetically for consistent ordering in the UI
|
|
45
|
+
results.sort();
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Reads the markdown file from disk and returns its content as a string.
|
|
50
|
+
*/
|
|
51
|
+
export async function readTaskFile(filePath) {
|
|
52
|
+
return fs.readFile(filePath, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Writes the updated markdown content back to the local file.
|
|
56
|
+
*/
|
|
57
|
+
export async function writeTaskFile(filePath, content) {
|
|
58
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qzoft/check-list",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP App for interactive task management from markdown files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"bin": "dist/server.js",
|
|
8
|
+
"files": ["dist", "ui"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/server.js",
|
|
12
|
+
"prepare": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/ext-apps": "^1.2.0",
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.27.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"typescript": "^5.7.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Tasks</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #ffffff;
|
|
14
|
+
--fg: #1f2328;
|
|
15
|
+
--border: #d1d5db;
|
|
16
|
+
--section-bg: #f6f8fa;
|
|
17
|
+
--checked-fg: #6b7280;
|
|
18
|
+
--accent: #0969da;
|
|
19
|
+
--accent-hover: #0550ae;
|
|
20
|
+
--file-bg: #f0f4f8;
|
|
21
|
+
--file-border: #c8d1da;
|
|
22
|
+
--radius: 6px;
|
|
23
|
+
--save-ok: #1a7f37;
|
|
24
|
+
--save-err: #cf222e;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@media (prefers-color-scheme: dark) {
|
|
28
|
+
:root {
|
|
29
|
+
--bg: #0d1117;
|
|
30
|
+
--fg: #e6edf3;
|
|
31
|
+
--border: #30363d;
|
|
32
|
+
--section-bg: #161b22;
|
|
33
|
+
--checked-fg: #8b949e;
|
|
34
|
+
--accent: #58a6ff;
|
|
35
|
+
--accent-hover: #79c0ff;
|
|
36
|
+
--file-bg: #161b22;
|
|
37
|
+
--file-border: #30363d;
|
|
38
|
+
--save-ok: #3fb950;
|
|
39
|
+
--save-err: #f85149;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
body {
|
|
44
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
background: var(--bg);
|
|
47
|
+
color: var(--fg);
|
|
48
|
+
margin: 0;
|
|
49
|
+
padding: 16px;
|
|
50
|
+
line-height: 1.5;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#app {
|
|
54
|
+
max-width: 600px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
h2 {
|
|
58
|
+
font-size: 16px;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
margin: 0 0 16px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.file-group {
|
|
64
|
+
margin-bottom: 20px;
|
|
65
|
+
border: 1px solid var(--file-border);
|
|
66
|
+
border-radius: var(--radius);
|
|
67
|
+
overflow: hidden;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.file-header {
|
|
71
|
+
font-size: 12px;
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
74
|
+
padding: 6px 10px;
|
|
75
|
+
background: var(--file-bg);
|
|
76
|
+
border-bottom: 1px solid var(--file-border);
|
|
77
|
+
color: var(--checked-fg);
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 6px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.file-header .icon {
|
|
84
|
+
font-size: 14px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.file-body {
|
|
88
|
+
padding: 8px 10px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.section {
|
|
92
|
+
margin-bottom: 12px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.section:last-child {
|
|
96
|
+
margin-bottom: 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.section-title {
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
letter-spacing: 0.05em;
|
|
104
|
+
color: var(--checked-fg);
|
|
105
|
+
margin: 0 0 6px;
|
|
106
|
+
padding-bottom: 4px;
|
|
107
|
+
border-bottom: 1px solid var(--border);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.task-list {
|
|
111
|
+
list-style: none;
|
|
112
|
+
margin: 0;
|
|
113
|
+
padding: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.task-item {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: flex-start;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
padding: 3px 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.task-item input[type="checkbox"] {
|
|
124
|
+
margin-top: 2px;
|
|
125
|
+
flex-shrink: 0;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
accent-color: var(--accent);
|
|
128
|
+
width: 14px;
|
|
129
|
+
height: 14px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.task-item input[type="checkbox"]:disabled {
|
|
133
|
+
cursor: wait;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.task-label {
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
user-select: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.task-label.done {
|
|
142
|
+
text-decoration: line-through;
|
|
143
|
+
color: var(--checked-fg);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#status {
|
|
147
|
+
margin-top: 8px;
|
|
148
|
+
font-size: 12px;
|
|
149
|
+
color: var(--checked-fg);
|
|
150
|
+
min-height: 16px;
|
|
151
|
+
transition: opacity 0.3s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#status.ok { color: var(--save-ok); }
|
|
155
|
+
#status.err { color: var(--save-err); }
|
|
156
|
+
|
|
157
|
+
#loading {
|
|
158
|
+
color: var(--checked-fg);
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<div id="app">
|
|
164
|
+
<h2>📋 Tasks</h2>
|
|
165
|
+
<div id="sections">
|
|
166
|
+
<p id="loading">Loading tasks…</p>
|
|
167
|
+
</div>
|
|
168
|
+
<div id="status"></div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<script>
|
|
172
|
+
const sectionsEl = document.getElementById('sections');
|
|
173
|
+
const statusEl = document.getElementById('status');
|
|
174
|
+
|
|
175
|
+
const STATUS_DISPLAY_MS = 3000;
|
|
176
|
+
let statusTimer = null;
|
|
177
|
+
|
|
178
|
+
// --- MCP Apps postMessage transport ---
|
|
179
|
+
let rpcId = 1;
|
|
180
|
+
const pendingRequests = new Map();
|
|
181
|
+
|
|
182
|
+
function sendRequest(method, params) {
|
|
183
|
+
const id = rpcId++;
|
|
184
|
+
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
pendingRequests.set(id, { resolve, reject });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sendNotification(method, params) {
|
|
191
|
+
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Handle incoming messages from host
|
|
195
|
+
window.addEventListener('message', (event) => {
|
|
196
|
+
const data = event.data;
|
|
197
|
+
if (!data || typeof data !== 'object') return;
|
|
198
|
+
|
|
199
|
+
// Response to a pending request
|
|
200
|
+
if ('id' in data && pendingRequests.has(data.id)) {
|
|
201
|
+
const { resolve, reject } = pendingRequests.get(data.id);
|
|
202
|
+
pendingRequests.delete(data.id);
|
|
203
|
+
if (data.error) {
|
|
204
|
+
reject(new Error(data.error.message || JSON.stringify(data.error)));
|
|
205
|
+
} else {
|
|
206
|
+
resolve(data.result);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Notifications from host
|
|
212
|
+
if (data.method === 'ui/notifications/tool-result') {
|
|
213
|
+
handleToolResult(data.params);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function handleToolResult(result) {
|
|
218
|
+
try {
|
|
219
|
+
// Prefer structuredContent, fall back to parsing text content
|
|
220
|
+
let fileGroups = result.structuredContent?.files;
|
|
221
|
+
if (!fileGroups) {
|
|
222
|
+
const textContent = result.content?.find(c => c.type === 'text');
|
|
223
|
+
if (textContent) {
|
|
224
|
+
fileGroups = JSON.parse(textContent.text);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (fileGroups) {
|
|
228
|
+
renderFileGroups(fileGroups);
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
sectionsEl.innerHTML = '<p>Error loading tasks: ' + (err.message || String(err)) + '</p>';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Notify host when content size changes so the iframe resizes
|
|
236
|
+
let lastWidth = 0;
|
|
237
|
+
let lastHeight = 0;
|
|
238
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
239
|
+
const width = document.documentElement.scrollWidth;
|
|
240
|
+
const height = document.documentElement.scrollHeight;
|
|
241
|
+
if (width !== lastWidth || height !== lastHeight) {
|
|
242
|
+
lastWidth = width;
|
|
243
|
+
lastHeight = height;
|
|
244
|
+
sendNotification('ui/notifications/size-changed', { width, height });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
resizeObserver.observe(document.documentElement);
|
|
248
|
+
|
|
249
|
+
// Initialize: handshake with host
|
|
250
|
+
(async () => {
|
|
251
|
+
try {
|
|
252
|
+
await sendRequest('ui/initialize', {
|
|
253
|
+
protocolVersion: '2026-01-26',
|
|
254
|
+
capabilities: {},
|
|
255
|
+
clientInfo: { name: 'check-list', version: '1.0.0' },
|
|
256
|
+
});
|
|
257
|
+
sendNotification('ui/notifications/initialized', {});
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error('MCP Apps init failed:', err);
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
|
|
263
|
+
function showStatus(msg, type) {
|
|
264
|
+
statusEl.textContent = msg;
|
|
265
|
+
statusEl.className = type || '';
|
|
266
|
+
if (statusTimer) clearTimeout(statusTimer);
|
|
267
|
+
statusTimer = setTimeout(() => { statusEl.textContent = ''; statusEl.className = ''; }, STATUS_DISPLAY_MS);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Auto-save a single checkbox toggle by calling the update_tasks tool via the host.
|
|
272
|
+
*/
|
|
273
|
+
async function autoSave(file, line, checked, checkboxEl, labelEl) {
|
|
274
|
+
checkboxEl.disabled = true;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await sendRequest('tools/call', {
|
|
278
|
+
name: 'update_tasks',
|
|
279
|
+
arguments: { file, updates: [{ line, checked }] },
|
|
280
|
+
});
|
|
281
|
+
showStatus('\u2705 Saved', 'ok');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// Revert on failure
|
|
284
|
+
checkboxEl.checked = !checked;
|
|
285
|
+
labelEl.className = 'task-label' + (!checked ? ' done' : '');
|
|
286
|
+
showStatus('\u274C Error: ' + (err.message || String(err)), 'err');
|
|
287
|
+
} finally {
|
|
288
|
+
checkboxEl.disabled = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderFileGroups(fileGroups) {
|
|
293
|
+
sectionsEl.innerHTML = '';
|
|
294
|
+
|
|
295
|
+
const withTasks = fileGroups.filter(fg =>
|
|
296
|
+
fg.sections.some(s => s.tasks.length > 0)
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (withTasks.length === 0) {
|
|
300
|
+
sectionsEl.innerHTML = '<p>No checklists found in any markdown file.</p>';
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const fg of withTasks) {
|
|
305
|
+
const groupEl = document.createElement('div');
|
|
306
|
+
groupEl.className = 'file-group';
|
|
307
|
+
|
|
308
|
+
const headerEl = document.createElement('div');
|
|
309
|
+
headerEl.className = 'file-header';
|
|
310
|
+
const iconSpan = document.createElement('span');
|
|
311
|
+
iconSpan.className = 'icon';
|
|
312
|
+
iconSpan.textContent = '\uD83D\uDCC4';
|
|
313
|
+
headerEl.appendChild(iconSpan);
|
|
314
|
+
headerEl.appendChild(document.createTextNode(' ' + fg.file));
|
|
315
|
+
groupEl.appendChild(headerEl);
|
|
316
|
+
|
|
317
|
+
const bodyEl = document.createElement('div');
|
|
318
|
+
bodyEl.className = 'file-body';
|
|
319
|
+
|
|
320
|
+
const visibleSections = fg.sections.filter(s => s.tasks.length > 0);
|
|
321
|
+
|
|
322
|
+
for (const section of visibleSections) {
|
|
323
|
+
const sectionEl = document.createElement('div');
|
|
324
|
+
sectionEl.className = 'section';
|
|
325
|
+
|
|
326
|
+
const titleEl = document.createElement('div');
|
|
327
|
+
titleEl.className = 'section-title';
|
|
328
|
+
titleEl.textContent = section.name;
|
|
329
|
+
sectionEl.appendChild(titleEl);
|
|
330
|
+
|
|
331
|
+
const listEl = document.createElement('ul');
|
|
332
|
+
listEl.className = 'task-list';
|
|
333
|
+
|
|
334
|
+
for (const task of section.tasks) {
|
|
335
|
+
const itemEl = document.createElement('li');
|
|
336
|
+
itemEl.className = 'task-item';
|
|
337
|
+
|
|
338
|
+
const checkboxEl = document.createElement('input');
|
|
339
|
+
checkboxEl.type = 'checkbox';
|
|
340
|
+
checkboxEl.checked = task.checked;
|
|
341
|
+
checkboxEl.id = 'task-' + fg.file + '-' + task.line;
|
|
342
|
+
|
|
343
|
+
const labelEl = document.createElement('label');
|
|
344
|
+
labelEl.htmlFor = checkboxEl.id;
|
|
345
|
+
labelEl.className = 'task-label' + (task.checked ? ' done' : '');
|
|
346
|
+
labelEl.textContent = task.text;
|
|
347
|
+
|
|
348
|
+
checkboxEl.addEventListener('change', () => {
|
|
349
|
+
const newChecked = checkboxEl.checked;
|
|
350
|
+
labelEl.className = 'task-label' + (newChecked ? ' done' : '');
|
|
351
|
+
autoSave(fg.file, task.line, newChecked, checkboxEl, labelEl);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
itemEl.appendChild(checkboxEl);
|
|
355
|
+
itemEl.appendChild(labelEl);
|
|
356
|
+
listEl.appendChild(itemEl);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
sectionEl.appendChild(listEl);
|
|
360
|
+
bodyEl.appendChild(sectionEl);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
groupEl.appendChild(bodyEl);
|
|
364
|
+
sectionsEl.appendChild(groupEl);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
</script>
|
|
368
|
+
</body>
|
|
369
|
+
</html>
|