@leeoohoo/ui-apps-devkit 0.1.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.
Files changed (55) hide show
  1. package/README.md +70 -0
  2. package/bin/chatos-uiapp.js +5 -0
  3. package/package.json +22 -0
  4. package/src/cli.js +53 -0
  5. package/src/commands/dev.js +14 -0
  6. package/src/commands/init.js +141 -0
  7. package/src/commands/install.js +55 -0
  8. package/src/commands/pack.js +72 -0
  9. package/src/commands/validate.js +103 -0
  10. package/src/lib/args.js +49 -0
  11. package/src/lib/config.js +29 -0
  12. package/src/lib/fs.js +78 -0
  13. package/src/lib/path-boundary.js +16 -0
  14. package/src/lib/plugin.js +45 -0
  15. package/src/lib/template.js +168 -0
  16. package/src/sandbox/server.js +861 -0
  17. package/templates/basic/README.md +58 -0
  18. package/templates/basic/chatos.config.json +5 -0
  19. package/templates/basic/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  20. package/templates/basic/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  21. package/templates/basic/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  22. package/templates/basic/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  23. package/templates/basic/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  24. package/templates/basic/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  25. package/templates/basic/plugin/apps/app/index.mjs +263 -0
  26. package/templates/basic/plugin/apps/app/mcp-prompt.en.md +7 -0
  27. package/templates/basic/plugin/apps/app/mcp-prompt.zh.md +7 -0
  28. package/templates/basic/plugin/apps/app/mcp-server.mjs +15 -0
  29. package/templates/basic/plugin/backend/index.mjs +37 -0
  30. package/templates/basic/template.json +7 -0
  31. package/templates/notepad/README.md +36 -0
  32. package/templates/notepad/chatos.config.json +4 -0
  33. package/templates/notepad/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  34. package/templates/notepad/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  35. package/templates/notepad/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  36. package/templates/notepad/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  37. package/templates/notepad/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  38. package/templates/notepad/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  39. package/templates/notepad/plugin/apps/app/api.mjs +30 -0
  40. package/templates/notepad/plugin/apps/app/dom.mjs +14 -0
  41. package/templates/notepad/plugin/apps/app/ds-tree.mjs +35 -0
  42. package/templates/notepad/plugin/apps/app/index.mjs +1056 -0
  43. package/templates/notepad/plugin/apps/app/layers.mjs +338 -0
  44. package/templates/notepad/plugin/apps/app/markdown.mjs +120 -0
  45. package/templates/notepad/plugin/apps/app/mcp-prompt.en.md +22 -0
  46. package/templates/notepad/plugin/apps/app/mcp-prompt.zh.md +22 -0
  47. package/templates/notepad/plugin/apps/app/mcp-server.mjs +200 -0
  48. package/templates/notepad/plugin/apps/app/styles.mjs +355 -0
  49. package/templates/notepad/plugin/apps/app/tags.mjs +21 -0
  50. package/templates/notepad/plugin/apps/app/ui.mjs +280 -0
  51. package/templates/notepad/plugin/backend/index.mjs +99 -0
  52. package/templates/notepad/plugin/plugin.json +23 -0
  53. package/templates/notepad/plugin/shared/notepad-paths.mjs +62 -0
  54. package/templates/notepad/plugin/shared/notepad-store.mjs +765 -0
  55. package/templates/notepad/template.json +8 -0
@@ -0,0 +1,338 @@
1
+ function noop() {}
2
+
3
+ function safeSetStatus(setStatus, text) {
4
+ if (typeof setStatus !== 'function') return;
5
+ try {
6
+ setStatus(String(text || ''), 'bad');
7
+ } catch {
8
+ // ignore
9
+ }
10
+ }
11
+
12
+ export function createNotepadLayerManager({ getDisposed, setStatus } = {}) {
13
+ const isDisposed = typeof getDisposed === 'function' ? getDisposed : () => false;
14
+
15
+ let activeLayer = null;
16
+
17
+ const closeActiveLayer = () => {
18
+ const layer = activeLayer;
19
+ activeLayer = null;
20
+ if (!layer) return;
21
+ try {
22
+ layer.dispose?.();
23
+ } catch {
24
+ // ignore
25
+ }
26
+ };
27
+
28
+ const showMenu = (x, y, items = []) => {
29
+ if (isDisposed()) return;
30
+ closeActiveLayer();
31
+
32
+ const overlay = document.createElement('div');
33
+ overlay.className = 'np-menu-overlay';
34
+
35
+ const menu = document.createElement('div');
36
+ menu.className = 'np-menu';
37
+
38
+ const close = () => {
39
+ try {
40
+ document.removeEventListener('keydown', onKeyDown, true);
41
+ } catch {
42
+ // ignore
43
+ }
44
+ try {
45
+ overlay.remove();
46
+ } catch {
47
+ // ignore
48
+ }
49
+ if (activeLayer?.overlay === overlay) activeLayer = null;
50
+ };
51
+
52
+ const onKeyDown = (ev) => {
53
+ if (ev?.key !== 'Escape') return;
54
+ try {
55
+ ev.preventDefault();
56
+ } catch {
57
+ // ignore
58
+ }
59
+ close();
60
+ };
61
+
62
+ overlay.addEventListener('mousedown', (ev) => {
63
+ if (ev?.target !== overlay) return;
64
+ close();
65
+ });
66
+ overlay.addEventListener('contextmenu', (ev) => {
67
+ try {
68
+ ev.preventDefault();
69
+ } catch {
70
+ // ignore
71
+ }
72
+ close();
73
+ });
74
+
75
+ (Array.isArray(items) ? items : []).forEach((item) => {
76
+ const label = typeof item?.label === 'string' ? item.label : '';
77
+ if (!label) return;
78
+ const btn = document.createElement('button');
79
+ btn.type = 'button';
80
+ btn.className = 'np-menu-item';
81
+ btn.textContent = label;
82
+ btn.disabled = item?.disabled === true;
83
+ btn.dataset.danger = item?.danger === true ? '1' : '0';
84
+ btn.addEventListener('click', async () => {
85
+ if (btn.disabled) return;
86
+ close();
87
+ try {
88
+ await item?.onClick?.();
89
+ } catch (err) {
90
+ safeSetStatus(setStatus, `Notes: ${err?.message || String(err)}`);
91
+ }
92
+ });
93
+ menu.appendChild(btn);
94
+ });
95
+
96
+ overlay.appendChild(menu);
97
+ document.body.appendChild(overlay);
98
+ document.addEventListener('keydown', onKeyDown, true);
99
+
100
+ menu.style.left = `${Math.max(0, Math.floor(x))}px`;
101
+ menu.style.top = `${Math.max(0, Math.floor(y))}px`;
102
+ try {
103
+ const rect = menu.getBoundingClientRect();
104
+ const margin = 8;
105
+ let left = Math.floor(x);
106
+ let top = Math.floor(y);
107
+ if (left + rect.width + margin > window.innerWidth) left = Math.max(margin, window.innerWidth - rect.width - margin);
108
+ if (top + rect.height + margin > window.innerHeight) top = Math.max(margin, window.innerHeight - rect.height - margin);
109
+ menu.style.left = `${left}px`;
110
+ menu.style.top = `${top}px`;
111
+ } catch {
112
+ // ignore
113
+ }
114
+
115
+ activeLayer = { overlay, dispose: close };
116
+ };
117
+
118
+ const showDialog = ({
119
+ title,
120
+ description = '',
121
+ fields = [],
122
+ confirmText = '确定',
123
+ cancelText = '取消',
124
+ danger = false,
125
+ } = {}) =>
126
+ new Promise((resolve) => {
127
+ if (isDisposed()) return resolve(null);
128
+ closeActiveLayer();
129
+
130
+ const overlay = document.createElement('div');
131
+ overlay.className = 'np-modal-overlay';
132
+ const modal = document.createElement('div');
133
+ modal.className = 'np-modal';
134
+
135
+ const header = document.createElement('div');
136
+ header.className = 'np-modal-header';
137
+ header.textContent = typeof title === 'string' && title.trim() ? title.trim() : '提示';
138
+
139
+ const body = document.createElement('div');
140
+ body.className = 'np-modal-body';
141
+
142
+ const desc = document.createElement('div');
143
+ desc.className = 'np-modal-desc';
144
+ desc.textContent = typeof description === 'string' ? description : '';
145
+ if (desc.textContent.trim()) {
146
+ body.appendChild(desc);
147
+ }
148
+
149
+ const errorEl = document.createElement('div');
150
+ errorEl.className = 'np-modal-error';
151
+ errorEl.textContent = '';
152
+
153
+ const inputs = [];
154
+ (Array.isArray(fields) ? fields : []).forEach((field) => {
155
+ if (!field || typeof field !== 'object') return;
156
+ const name = typeof field.name === 'string' ? field.name.trim() : '';
157
+ if (!name) return;
158
+ const row = document.createElement('div');
159
+ row.className = 'np-modal-field';
160
+
161
+ const label = document.createElement('div');
162
+ label.className = 'np-modal-label';
163
+ label.textContent = typeof field.label === 'string' ? field.label : name;
164
+ row.appendChild(label);
165
+
166
+ let control = null;
167
+ const kind = field.kind === 'select' ? 'select' : 'text';
168
+ if (kind === 'select') {
169
+ const select = document.createElement('select');
170
+ select.className = 'np-select';
171
+ const options = Array.isArray(field.options) ? field.options : [];
172
+ options.forEach((opt) => {
173
+ const value = typeof opt?.value === 'string' ? opt.value : '';
174
+ const labelText = typeof opt?.label === 'string' ? opt.label : value;
175
+ const option = document.createElement('option');
176
+ option.value = value;
177
+ option.textContent = labelText || value || '(空)';
178
+ select.appendChild(option);
179
+ });
180
+ select.value = typeof field.value === 'string' ? field.value : '';
181
+ control = select;
182
+ } else {
183
+ const input = document.createElement('input');
184
+ input.className = 'np-input';
185
+ input.type = 'text';
186
+ input.placeholder = typeof field.placeholder === 'string' ? field.placeholder : '';
187
+ input.value = typeof field.value === 'string' ? field.value : '';
188
+ control = input;
189
+ }
190
+
191
+ row.appendChild(control);
192
+ body.appendChild(row);
193
+ inputs.push({
194
+ name,
195
+ required: field.required === true,
196
+ control,
197
+ label: typeof field.label === 'string' ? field.label : name,
198
+ });
199
+ });
200
+
201
+ if (inputs.length > 0) {
202
+ body.appendChild(errorEl);
203
+ }
204
+
205
+ const actions = document.createElement('div');
206
+ actions.className = 'np-modal-actions';
207
+
208
+ const btnCancel = document.createElement('button');
209
+ btnCancel.type = 'button';
210
+ btnCancel.className = 'np-btn';
211
+ btnCancel.textContent = cancelText || '取消';
212
+
213
+ const btnOk = document.createElement('button');
214
+ btnOk.type = 'button';
215
+ btnOk.className = 'np-btn';
216
+ btnOk.textContent = confirmText || '确定';
217
+ btnOk.dataset.variant = danger ? 'danger' : '';
218
+
219
+ const cleanup = () => {
220
+ try {
221
+ document.removeEventListener('keydown', onKeyDown, true);
222
+ } catch {
223
+ // ignore
224
+ }
225
+ try {
226
+ overlay.remove();
227
+ } catch {
228
+ // ignore
229
+ }
230
+ if (activeLayer?.overlay === overlay) activeLayer = null;
231
+ };
232
+
233
+ const close = (result) => {
234
+ cleanup();
235
+ resolve(result);
236
+ };
237
+
238
+ const validateAndClose = () => {
239
+ const values = {};
240
+ for (const it of inputs) {
241
+ const raw = it?.control?.value;
242
+ const value = typeof raw === 'string' ? raw.trim() : String(raw ?? '').trim();
243
+ if (it.required && !value) {
244
+ errorEl.textContent = `请填写:${it.label}`;
245
+ try {
246
+ it.control?.focus?.();
247
+ } catch {
248
+ // ignore
249
+ }
250
+ return;
251
+ }
252
+ values[it.name] = value;
253
+ }
254
+ close(values);
255
+ };
256
+
257
+ const onKeyDown = (ev) => {
258
+ if (ev?.key === 'Escape') {
259
+ try {
260
+ ev.preventDefault();
261
+ } catch {
262
+ // ignore
263
+ }
264
+ close(null);
265
+ return;
266
+ }
267
+ if (ev?.key === 'Enter') {
268
+ const active = document.activeElement;
269
+ const isTextArea = active && active.tagName === 'TEXTAREA';
270
+ if (isTextArea) return;
271
+ try {
272
+ ev.preventDefault();
273
+ } catch {
274
+ // ignore
275
+ }
276
+ validateAndClose();
277
+ }
278
+ };
279
+
280
+ overlay.addEventListener('mousedown', (ev) => {
281
+ if (ev?.target !== overlay) return;
282
+ close(null);
283
+ });
284
+
285
+ btnOk.addEventListener('click', () => validateAndClose());
286
+ btnCancel.addEventListener('click', () => close(null));
287
+
288
+ actions.appendChild(btnCancel);
289
+ actions.appendChild(btnOk);
290
+ modal.appendChild(header);
291
+ modal.appendChild(body);
292
+ modal.appendChild(actions);
293
+ overlay.appendChild(modal);
294
+ document.body.appendChild(overlay);
295
+ document.addEventListener('keydown', onKeyDown, true);
296
+
297
+ activeLayer = { overlay, dispose: () => close(null) };
298
+
299
+ const first = inputs[0]?.control;
300
+ if (first) {
301
+ setTimeout(() => {
302
+ try {
303
+ first.focus();
304
+ } catch {
305
+ // ignore
306
+ }
307
+ }, 0);
308
+ } else {
309
+ setTimeout(() => {
310
+ try {
311
+ btnOk.focus();
312
+ } catch {
313
+ // ignore
314
+ }
315
+ }, 0);
316
+ }
317
+ });
318
+
319
+ const confirmDialog = async (message, options = {}) => {
320
+ const res = await showDialog({
321
+ title: options?.title || '确认',
322
+ description: typeof message === 'string' ? message : '',
323
+ fields: [],
324
+ confirmText: options?.confirmText || '确定',
325
+ cancelText: options?.cancelText || '取消',
326
+ danger: options?.danger === true,
327
+ });
328
+ return Boolean(res);
329
+ };
330
+
331
+ return {
332
+ closeActiveLayer,
333
+ showMenu,
334
+ showDialog,
335
+ confirmDialog,
336
+ };
337
+ }
338
+
@@ -0,0 +1,120 @@
1
+ function escapeHtml(input) {
2
+ return String(input ?? '')
3
+ .replace(/&/g, '&')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+ }
9
+
10
+ function renderInlineMd(text) {
11
+ let out = escapeHtml(text);
12
+ out = out.replace(/`([^`]+?)`/g, '<code>$1</code>');
13
+ out = out.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>');
14
+ out = out.replace(/\*([^*]+?)\*/g, '<em>$1</em>');
15
+ out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" />');
16
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
17
+ return out;
18
+ }
19
+
20
+ export function renderMarkdown(md) {
21
+ const text = String(md || '').replace(/\r\n/g, '\n');
22
+ const lines = text.split('\n');
23
+ let html = '';
24
+ let inCode = false;
25
+ let listMode = '';
26
+ let paragraph = [];
27
+
28
+ const flushParagraph = () => {
29
+ if (paragraph.length === 0) return;
30
+ const content = paragraph.map((l) => renderInlineMd(l)).join('<br />');
31
+ html += `<p>${content}</p>`;
32
+ paragraph = [];
33
+ };
34
+
35
+ const closeList = () => {
36
+ if (!listMode) return;
37
+ html += listMode === 'ol' ? '</ol>' : '</ul>';
38
+ listMode = '';
39
+ };
40
+
41
+ for (const rawLine of lines) {
42
+ const line = String(rawLine ?? '');
43
+ const trimmed = line.trimEnd();
44
+
45
+ const fence = trimmed.trim().match(/^```(\S+)?\s*$/);
46
+ if (fence) {
47
+ flushParagraph();
48
+ closeList();
49
+ if (!inCode) {
50
+ inCode = true;
51
+ const lang = escapeHtml(fence[1] || '');
52
+ html += `<pre><code data-lang="${lang}">`;
53
+ } else {
54
+ inCode = false;
55
+ html += '</code></pre>';
56
+ }
57
+ continue;
58
+ }
59
+
60
+ if (inCode) {
61
+ html += `${escapeHtml(line)}\n`;
62
+ continue;
63
+ }
64
+
65
+ if (!trimmed.trim()) {
66
+ flushParagraph();
67
+ closeList();
68
+ continue;
69
+ }
70
+
71
+ const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
72
+ if (heading) {
73
+ flushParagraph();
74
+ closeList();
75
+ const level = Math.min(6, heading[1].length);
76
+ html += `<h${level}>${renderInlineMd(heading[2])}</h${level}>`;
77
+ continue;
78
+ }
79
+
80
+ const quote = trimmed.match(/^>\s?(.*)$/);
81
+ if (quote) {
82
+ flushParagraph();
83
+ closeList();
84
+ html += `<blockquote>${renderInlineMd(quote[1] || '')}</blockquote>`;
85
+ continue;
86
+ }
87
+
88
+ const ul = trimmed.match(/^[-*+]\s+(.+)$/);
89
+ if (ul) {
90
+ flushParagraph();
91
+ if (listMode && listMode !== 'ul') closeList();
92
+ if (!listMode) {
93
+ listMode = 'ul';
94
+ html += '<ul>';
95
+ }
96
+ html += `<li>${renderInlineMd(ul[1])}</li>`;
97
+ continue;
98
+ }
99
+
100
+ const ol = trimmed.match(/^\d+\.\s+(.+)$/);
101
+ if (ol) {
102
+ flushParagraph();
103
+ if (listMode && listMode !== 'ol') closeList();
104
+ if (!listMode) {
105
+ listMode = 'ol';
106
+ html += '<ol>';
107
+ }
108
+ html += `<li>${renderInlineMd(ol[1])}</li>`;
109
+ continue;
110
+ }
111
+
112
+ paragraph.push(trimmed);
113
+ }
114
+
115
+ flushParagraph();
116
+ closeList();
117
+ if (inCode) html += '</code></pre>';
118
+ return html;
119
+ }
120
+
@@ -0,0 +1,22 @@
1
+ # __PLUGIN_NAME__ (Markdown Notes)
2
+
3
+ This app exposes MCP tools to manage local Markdown notes (folders + tags):
4
+
5
+ - `init`: initialize storage (data dir, index, notes root)
6
+ - `list_folders`: list folders (categories)
7
+ - `create_folder`: create a folder (supports nested paths)
8
+ - `rename_folder`: rename/move a folder
9
+ - `delete_folder`: delete a folder (optionally recursive)
10
+ - `list_notes`: list notes filtered by folder/tags/title
11
+ - `create_note`: create a note (folder/title/content/tags)
12
+ - `read_note`: read a note (metadata + markdown content)
13
+ - `update_note`: update a note (title/content/tags/move folder)
14
+ - `delete_note`: delete a note
15
+ - `list_tags`: list tags with counts
16
+ - `search_notes`: search by keyword (optional content search + folder/tags filters)
17
+
18
+ Guidelines:
19
+
20
+ 1) If you don't know the structure, start with `list_folders` + `list_notes`/`list_tags` before making changes.
21
+ 2) Ask for confirmation before destructive operations (especially `delete_folder` with `recursive=true`).
22
+ 3) For quick lookup by folder + tags, prefer `list_notes` (folder+tags) or `search_notes` with filters, instead of scanning everything.
@@ -0,0 +1,22 @@
1
+ # __PLUGIN_NAME__(Markdown Notes)
2
+
3
+ 本应用向 ChatOS 暴露了一组 MCP tools,用于管理本地 Markdown 笔记(文件夹分类 + 标签检索):
4
+
5
+ - `init`:初始化存储(数据目录、索引、notes 根目录)
6
+ - `list_folders`:列出文件夹(分类)
7
+ - `create_folder`:创建文件夹(支持多级路径)
8
+ - `rename_folder`:重命名/移动文件夹
9
+ - `delete_folder`:删除文件夹(可递归删除)
10
+ - `list_notes`:按文件夹/标签/标题筛选列出笔记
11
+ - `create_note`:创建笔记(可指定文件夹、标题、内容、标签)
12
+ - `read_note`:读取笔记(返回元数据与 Markdown 内容)
13
+ - `update_note`:更新笔记(标题/内容/标签/移动文件夹)
14
+ - `delete_note`:删除笔记
15
+ - `list_tags`:列出全部标签与使用次数
16
+ - `search_notes`:按关键字搜索(可选:搜内容 + 叠加文件夹/标签过滤)
17
+
18
+ 使用建议(重要):
19
+
20
+ 1) 不确定结构时,先 `list_folders` + `list_notes`/`list_tags` 再做编辑或移动。
21
+ 2) 移动或删除(尤其是 `delete_folder` 且 `recursive=true`)前先向用户确认。
22
+ 3) 若需要“按标签 + 分类”快速定位,优先用 `list_notes`(folder+tags)或 `search_notes`(叠加过滤),不要盲目遍历全部内容。
@@ -0,0 +1,200 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { createNotepadStore } from '../../shared/notepad-store.mjs';
5
+ import { resolveUiAppDataDir } from '../../shared/notepad-paths.mjs';
6
+
7
+ const PLUGIN_ID = '__PLUGIN_ID__';
8
+ const SERVER_NAME = '__PLUGIN_ID__.__APP_ID__';
9
+
10
+ const store = createNotepadStore({ dataDir: resolveUiAppDataDir({ pluginId: PLUGIN_ID }) });
11
+
12
+ const server = new McpServer({
13
+ name: SERVER_NAME,
14
+ version: '0.1.0',
15
+ });
16
+
17
+ const toText = (payload) => ({
18
+ content: [
19
+ {
20
+ type: 'text',
21
+ text: JSON.stringify(payload, null, 2),
22
+ },
23
+ ],
24
+ });
25
+
26
+ server.registerTool(
27
+ 'init',
28
+ {
29
+ title: 'Init Notepad Storage',
30
+ description: 'Initialize the Notepad storage (data directory, index, notes root).',
31
+ inputSchema: z.object({}).optional(),
32
+ },
33
+ async () => toText(await store.init())
34
+ );
35
+
36
+ server.registerTool(
37
+ 'list_folders',
38
+ {
39
+ title: 'List Folders',
40
+ description: 'List all folders (categories) under Notepad notes root.',
41
+ inputSchema: z.object({}).optional(),
42
+ },
43
+ async () => toText(await store.listFolders())
44
+ );
45
+
46
+ server.registerTool(
47
+ 'create_folder',
48
+ {
49
+ title: 'Create Folder',
50
+ description: 'Create a folder (category). Supports nested paths like "work/ideas".',
51
+ inputSchema: z.object({
52
+ folder: z.string().min(1).describe('Folder path, relative to notes root (e.g., "work/ideas")'),
53
+ }),
54
+ },
55
+ async ({ folder }) => toText(await store.createFolder({ folder }))
56
+ );
57
+
58
+ server.registerTool(
59
+ 'rename_folder',
60
+ {
61
+ title: 'Rename Folder',
62
+ description: 'Rename/move a folder (and updates indexed notes folder paths).',
63
+ inputSchema: z.object({
64
+ from: z.string().min(1).describe('Source folder path'),
65
+ to: z.string().min(1).describe('Target folder path'),
66
+ }),
67
+ },
68
+ async ({ from, to }) => toText(await store.renameFolder({ from, to }))
69
+ );
70
+
71
+ server.registerTool(
72
+ 'delete_folder',
73
+ {
74
+ title: 'Delete Folder',
75
+ description: 'Delete a folder. If recursive=true, deletes all notes under it and removes them from index.',
76
+ inputSchema: z.object({
77
+ folder: z.string().min(1).describe('Folder path to delete'),
78
+ recursive: z.boolean().optional().describe('Delete folder recursively'),
79
+ }),
80
+ },
81
+ async ({ folder, recursive } = {}) => toText(await store.deleteFolder({ folder, recursive: recursive === true }))
82
+ );
83
+
84
+ server.registerTool(
85
+ 'list_notes',
86
+ {
87
+ title: 'List Notes',
88
+ description: 'List notes with optional folder/tags/title filtering.',
89
+ inputSchema: z.object({
90
+ folder: z.string().optional().describe('Folder path; empty means all'),
91
+ recursive: z.boolean().optional().describe('Include notes in subfolders (default true)'),
92
+ tags: z.array(z.string()).optional().describe('Filter notes by tags'),
93
+ match: z.enum(['all', 'any']).optional().describe('Tag match mode'),
94
+ query: z.string().optional().describe('Substring filter for title/folder'),
95
+ limit: z.number().int().min(1).max(500).optional().describe('Max notes to return'),
96
+ }),
97
+ },
98
+ async ({ folder, recursive, tags, match, query, limit } = {}) =>
99
+ toText(await store.listNotes({ folder, recursive, tags, match, query, limit }))
100
+ );
101
+
102
+ server.registerTool(
103
+ 'create_note',
104
+ {
105
+ title: 'Create Note',
106
+ description: 'Create a new markdown note under a folder with optional title/content/tags.',
107
+ inputSchema: z.object({
108
+ folder: z.string().optional().describe('Folder path to create note in'),
109
+ title: z.string().optional().describe('Note title'),
110
+ content: z.string().optional().describe('Markdown content (optional)'),
111
+ tags: z.array(z.string()).optional().describe('Tags (optional)'),
112
+ }),
113
+ },
114
+ async ({ folder, title, content, tags } = {}) => toText(await store.createNote({ folder, title, content, tags }))
115
+ );
116
+
117
+ server.registerTool(
118
+ 'read_note',
119
+ {
120
+ title: 'Read Note',
121
+ description: 'Read a note by id (returns metadata and markdown content).',
122
+ inputSchema: z.object({
123
+ id: z.string().min(1).describe('Note id'),
124
+ }),
125
+ },
126
+ async ({ id }) => toText(await store.getNote({ id }))
127
+ );
128
+
129
+ server.registerTool(
130
+ 'update_note',
131
+ {
132
+ title: 'Update Note',
133
+ description: 'Update note metadata/content by id. You can also move it by changing folder.',
134
+ inputSchema: z.object({
135
+ id: z.string().min(1).describe('Note id'),
136
+ title: z.string().optional().describe('New title'),
137
+ content: z.string().optional().describe('New markdown content'),
138
+ folder: z.string().optional().describe('New folder path'),
139
+ tags: z.array(z.string()).optional().describe('Replace tags'),
140
+ }),
141
+ },
142
+ async ({ id, title, content, folder, tags } = {}) => toText(await store.updateNote({ id, title, content, folder, tags }))
143
+ );
144
+
145
+ server.registerTool(
146
+ 'delete_note',
147
+ {
148
+ title: 'Delete Note',
149
+ description: 'Delete a note by id (removes file and index entry).',
150
+ inputSchema: z.object({
151
+ id: z.string().min(1).describe('Note id'),
152
+ }),
153
+ },
154
+ async ({ id }) => toText(await store.deleteNote({ id }))
155
+ );
156
+
157
+ server.registerTool(
158
+ 'list_tags',
159
+ {
160
+ title: 'List Tags',
161
+ description: 'List all tags with usage counts.',
162
+ inputSchema: z.object({}).optional(),
163
+ },
164
+ async () => toText(await store.listTags())
165
+ );
166
+
167
+ server.registerTool(
168
+ 'search_notes',
169
+ {
170
+ title: 'Search Notes',
171
+ description: 'Search notes by query, optionally filtered by folder/tags; can include content search.',
172
+ inputSchema: z.object({
173
+ query: z.string().min(1).describe('Search keyword'),
174
+ folder: z.string().optional().describe('Folder path filter'),
175
+ recursive: z.boolean().optional().describe('Include notes in subfolders (default true)'),
176
+ tags: z.array(z.string()).optional().describe('Tag filter'),
177
+ match: z.enum(['all', 'any']).optional().describe('Tag match mode'),
178
+ includeContent: z.boolean().optional().describe('Search in content (default true)'),
179
+ limit: z.number().int().min(1).max(200).optional().describe('Max results'),
180
+ }),
181
+ },
182
+ async ({ query, folder, recursive, tags, match, includeContent, limit } = {}) =>
183
+ toText(await store.searchNotes({ query, folder, recursive, tags, match, includeContent: includeContent !== false, limit }))
184
+ );
185
+
186
+ async function main() {
187
+ const initRes = await store.init();
188
+ if (!initRes?.ok) {
189
+ // eslint-disable-next-line no-console
190
+ console.error('Notepad MCP init failed:', initRes?.message || initRes);
191
+ }
192
+ const transport = new StdioServerTransport();
193
+ await server.connect(transport);
194
+ }
195
+
196
+ main().catch((error) => {
197
+ // eslint-disable-next-line no-console
198
+ console.error('MCP server error:', error);
199
+ process.exit(1);
200
+ });