foldnotes-mcp 2.2.0__py3-none-any.whl

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,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: foldnotes-mcp
3
+ Version: 2.2.0
4
+ Summary: MCP server exposing the FoldNotes CLI (fn) as tools for Claude Desktop, Claude Code, and other MCP clients.
5
+ Project-URL: Homepage, https://foldnotes.io
6
+ Project-URL: Documentation, https://foldnotes.io/advanced/ai-integration/
7
+ Project-URL: Repository, https://github.com/Foldsoft/foldnotes-mcp
8
+ Project-URL: Discussions, https://github.com/Foldsoft/foldnotes-mcp/discussions
9
+ Author-email: Foldsoft Pty Ltd <support@foldnotes.io>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: claude,cli,foldnotes,mcp,model-context-protocol,notes
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: mcp>=1.2.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # FoldNotes MCP Server
18
+
19
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
20
+ FoldNotes command-line tool (`fn`) as tools any MCP-compatible client can call —
21
+ [Claude Desktop](https://claude.ai/download), [Claude Code](https://claude.com/claude-code),
22
+ or third-party clients. It's a thin, read/write bridge to your FoldNotes collection:
23
+ everything the CLI can do, an AI assistant can do — under the same licensing and safety limits.
24
+
25
+ Made by **Foldsoft Pty Ltd** · [foldnotes.io](https://foldnotes.io)
26
+
27
+ > **Source-available, install-only.** This repository is published so you can install and run the
28
+ > server — we don't accept code contributions. Questions, tips, and bug reports are welcome in
29
+ > [Discussions](https://github.com/Foldsoft/foldnotes-mcp/discussions); see
30
+ > [CONTRIBUTING](CONTRIBUTING.md) for direct support and security reporting.
31
+
32
+ ## Requirements
33
+
34
+ - **macOS** with the FoldNotes app, and the **`fn` CLI** installed from the app:
35
+ **FoldNotes → Install Command Line Tool**. The server shells out to `fn`.
36
+ - **Python 3.10+**.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ git clone https://github.com/Foldsoft/foldnotes-mcp.git
42
+ cd foldnotes-mcp/python-sdk
43
+ python3 -m venv venv
44
+ source venv/bin/activate
45
+ pip install mcp
46
+ ```
47
+
48
+ Verify:
49
+
50
+ ```bash
51
+ fn version # confirm the CLI is on your PATH
52
+ python3 foldnotes_mcp.py # starts the server on stdio — Ctrl-C to stop
53
+ ```
54
+
55
+ The server auto-discovers `fn` at `/usr/local/bin/fn` or `/opt/homebrew/bin/fn`, falling
56
+ back to `fn` on your `PATH`.
57
+
58
+ ## Connect to Claude Desktop
59
+
60
+ Add a `foldnotes` entry to `~/Library/Application Support/Claude/claude_desktop_config.json`
61
+ (absolute paths to the venv's Python and the server script):
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "foldnotes": {
67
+ "command": "/absolute/path/to/foldnotes-mcp/python-sdk/venv/bin/python3",
68
+ "args": ["/absolute/path/to/foldnotes-mcp/python-sdk/foldnotes_mcp.py"]
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Restart Claude Desktop; the FoldNotes tools appear under the tools menu.
75
+
76
+ ## Connect to Claude Code
77
+
78
+ ```bash
79
+ claude mcp add foldnotes -- \
80
+ /absolute/path/to/foldnotes-mcp/python-sdk/venv/bin/python3 \
81
+ /absolute/path/to/foldnotes-mcp/python-sdk/foldnotes_mcp.py
82
+ ```
83
+
84
+ Add `--scope user` for all projects, or `--scope project` to share a `.mcp.json`. Confirm with
85
+ `claude mcp list` or `/mcp` inside a session.
86
+
87
+ ## What it exposes
88
+
89
+ 40+ tools covering notes, tasks, tags, backlinks, daily notes, properties/schema, saved
90
+ queries, templates, projects, attachments, collections, and search. The full, always-current
91
+ list is in the docs: **[foldnotes.io → AI & Automation](https://foldnotes.io/advanced/ai-integration/)**.
92
+
93
+ Two safety rules are built in:
94
+
95
+ - **Reading is free; writing needs a licence.** Read tools always work; write tools and `bind`
96
+ require a valid FoldNotes licence or active trial, otherwise they change nothing.
97
+ - **Trash is human-owned.** The server can move a note to the trash and restore it, but cannot
98
+ empty the trash or permanently delete — that stays a deliberate, human-only action in the app.
99
+
100
+ ## Questions & support
101
+
102
+ - **Setup help, usage questions, bugs, tips** → [Discussions](https://github.com/Foldsoft/foldnotes-mcp/discussions).
103
+ - **Direct support** → [support@foldnotes.io](mailto:support@foldnotes.io).
104
+ - **Security** → report privately via the repo's **Security → Report a vulnerability**. We don't use
105
+ Issues or accept pull requests — see [CONTRIBUTING](CONTRIBUTING.md).
106
+
107
+ ## Licence
108
+
109
+ MIT © 2026 Foldsoft Pty Ltd — see [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ foldnotes_mcp.py,sha256=Tf3CMSJmGrvG5GDJsi_6qj31BuE8ueXoJ5Jgd5Ucj08,34120
2
+ foldnotes_mcp-2.2.0.dist-info/METADATA,sha256=BmnNEemPV0RytM2vwUyqhZa0nd5VAH_9rdOG59FOxyE,4232
3
+ foldnotes_mcp-2.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
4
+ foldnotes_mcp-2.2.0.dist-info/entry_points.txt,sha256=ievwEyPlKvTdWOqpSDvkWJ8d2ekZ67sFw_1DKCe7WQY,53
5
+ foldnotes_mcp-2.2.0.dist-info/licenses/LICENSE,sha256=q-FOKzyXOPLzvGbQsbKBuxvJLUPxGVyNlORdRZd-rW8,1073
6
+ foldnotes_mcp-2.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ foldnotes-mcp = foldnotes_mcp:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Foldsoft Pty Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
foldnotes_mcp.py ADDED
@@ -0,0 +1,1120 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Foldsoft Pty Ltd. Released under the MIT Licence — see LICENSE.
3
+ """
4
+ FoldNotes MCP Server (Python 3.10+ with official MCP SDK)
5
+
6
+ A Model Context Protocol server that exposes FoldNotes CLI (`fn`) commands
7
+ as tools for AI models. Works with Claude Desktop, Claude Code, or any
8
+ MCP-compatible client.
9
+
10
+ Requires:
11
+ Python 3.10+
12
+ pip install mcp
13
+ `fn` CLI installed (via FoldNotes app or manual symlink)
14
+
15
+ Usage:
16
+ python3 foldnotes_mcp.py
17
+
18
+ Configure in Claude Desktop's claude_desktop_config.json:
19
+ {
20
+ "mcpServers": {
21
+ "foldnotes": {
22
+ "command": "/path/to/venv/bin/python3",
23
+ "args": ["/path/to/python-sdk/foldnotes_mcp.py"]
24
+ }
25
+ }
26
+ }
27
+ """
28
+
29
+ __version__ = "2.2.0"
30
+
31
+ import json
32
+ import os
33
+ import subprocess
34
+ from typing import Any
35
+
36
+ from mcp.server.fastmcp import FastMCP
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # fn CLI wrapper
40
+ # ---------------------------------------------------------------------------
41
+
42
+ FN_PATHS = [
43
+ "/usr/local/bin/fn",
44
+ "/opt/homebrew/bin/fn",
45
+ ]
46
+
47
+
48
+ def _find_fn() -> str:
49
+ for path in FN_PATHS:
50
+ if os.path.isfile(path) and os.access(path, os.X_OK):
51
+ return path
52
+ return "fn"
53
+
54
+
55
+ FN_BIN = _find_fn()
56
+
57
+
58
+ def run_fn(args: list[str], collection: str | None = None) -> dict:
59
+ """Run an fn CLI command and return parsed JSON or raw text."""
60
+ cmd = [FN_BIN]
61
+ cmd += args
62
+ cmd += ["--quiet", "--json"]
63
+ if collection:
64
+ cmd += ["--collection", collection]
65
+
66
+ try:
67
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
68
+ except FileNotFoundError:
69
+ return {"error": f"fn CLI not found. Tried: {FN_BIN}"}
70
+ except subprocess.TimeoutExpired:
71
+ return {"error": "fn command timed out after 30 seconds"}
72
+
73
+ output = result.stdout.strip()
74
+ if result.returncode != 0:
75
+ err = result.stderr.strip() or output or "Unknown error"
76
+ return {"error": err}
77
+
78
+ try:
79
+ return json.loads(output)
80
+ except json.JSONDecodeError:
81
+ return {"text": output}
82
+
83
+
84
+ def run_fn_raw(args: list[str], collection: str | None = None) -> str:
85
+ """Run fn without --json, return raw stdout."""
86
+ cmd = [FN_BIN]
87
+ cmd += args
88
+ cmd += ["--quiet"]
89
+ if collection:
90
+ cmd += ["--collection", collection]
91
+
92
+ try:
93
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
94
+ except FileNotFoundError:
95
+ return f"Error: fn CLI not found at {FN_BIN}"
96
+ except subprocess.TimeoutExpired:
97
+ return "Error: command timed out"
98
+
99
+ if result.returncode != 0:
100
+ return result.stderr.strip() or result.stdout.strip() or "Unknown error"
101
+ return result.stdout.strip()
102
+
103
+
104
+ def _task_selector(text: str | None, task_id: str | None) -> list[str] | None:
105
+ """Build the CLI args that identify a single task.
106
+
107
+ Prefers the exact paragraph UUID (`--id`, from `list_tasks`), which targets
108
+ one task unambiguously even when several share the same wording. Falls back
109
+ to a case-insensitive substring of the task text. A substring must match
110
+ exactly one task or the CLI refuses the command (use task_id to disambiguate).
111
+ Returns None when neither is supplied so the caller can surface an error.
112
+ """
113
+ if task_id:
114
+ return ["--id", task_id]
115
+ if text:
116
+ return [text]
117
+ return None
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # MCP Server
122
+ # ---------------------------------------------------------------------------
123
+
124
+ mcp = FastMCP("FoldNotes")
125
+
126
+
127
+ # ---- Version ----
128
+
129
+ @mcp.tool()
130
+ def version() -> str:
131
+ """Show the FoldNotes MCP server version and fn CLI version."""
132
+ fn_version = run_fn_raw(["--version"])
133
+ return json.dumps({
134
+ "mcp_server": __version__,
135
+ "fn_cli": fn_version,
136
+ "fn_path": FN_BIN,
137
+ }, indent=2)
138
+
139
+
140
+ # ---- Notes: List ----
141
+
142
+ @mcp.tool()
143
+ def list_notes(
144
+ tag: str | None = None,
145
+ property: str | None = None,
146
+ sort: str | None = None,
147
+ limit: int | None = None,
148
+ favourites: bool = False,
149
+ has_tasks: bool = False,
150
+ has_overdue: bool = False,
151
+ include_trashed: bool = False,
152
+ archived: bool = False,
153
+ include_archived: bool = False,
154
+ include_subtags: bool = False,
155
+ modified_after: str | None = None,
156
+ modified_before: str | None = None,
157
+ reverse: bool = False,
158
+ collection: str | None = None,
159
+ ) -> str:
160
+ """List notes in the active FoldNotes collection with filtering and sorting.
161
+
162
+ Args:
163
+ tag: Filter by tag name (without #).
164
+ property: Filter by property (key=value).
165
+ sort: Sort by: modified, created, title.
166
+ limit: Max notes to return.
167
+ favourites: Only show favourited notes.
168
+ has_tasks: Only show notes with active tasks.
169
+ has_overdue: Only show notes with overdue tasks.
170
+ include_trashed: Include trashed notes in results.
171
+ archived: Show only archived notes.
172
+ include_archived: Include archived notes alongside active ones (distinct from `archived`, which shows only archived).
173
+ include_subtags: When filtering by tag, also match sub-tags (e.g. projects matches projects/marketing).
174
+ modified_after: Only notes modified after this date (YYYY-MM-DD).
175
+ modified_before: Only notes modified before this date (YYYY-MM-DD).
176
+ reverse: Reverse sort order.
177
+ collection: Collection name, UUID, or path (default: active).
178
+ """
179
+ args = ["list"]
180
+ if tag:
181
+ args += ["--tag", tag]
182
+ if property:
183
+ args += ["--property", property]
184
+ if sort:
185
+ args += ["--sort", sort]
186
+ if limit:
187
+ args += ["--limit", str(limit)]
188
+ if favourites:
189
+ args.append("--favourites")
190
+ if has_tasks:
191
+ args.append("--has-tasks")
192
+ if has_overdue:
193
+ args.append("--has-overdue")
194
+ if include_trashed:
195
+ args.append("--include-trashed")
196
+ if archived:
197
+ args.append("--archived")
198
+ if include_archived:
199
+ args.append("--include-archived")
200
+ if include_subtags:
201
+ args.append("--include-subtags")
202
+ if modified_after:
203
+ args += ["--modified-after", modified_after]
204
+ if modified_before:
205
+ args += ["--modified-before", modified_before]
206
+ if reverse:
207
+ args.append("--reverse")
208
+ return json.dumps(run_fn(args, collection), indent=2)
209
+
210
+
211
+ # ---- Notes: Show ----
212
+
213
+ @mcp.tool()
214
+ def show_note(
215
+ note: str | None = None,
216
+ id: str | None = None,
217
+ body: bool = True,
218
+ properties: bool = False,
219
+ tasks: bool = False,
220
+ backlinks: bool = False,
221
+ collection: str | None = None,
222
+ ) -> str:
223
+ """Read a note's content and metadata. Resolves by title, UUID, or filename.
224
+
225
+ Args:
226
+ note: Note title, UUID, or filename.
227
+ id: Note UUID (exact lookup; alternative to the fuzzy note/title).
228
+ body: Include full body text (default: true).
229
+ properties: Show only front matter / properties.
230
+ tasks: Show tasks in the note.
231
+ backlinks: Show notes that link to this one.
232
+ collection: Collection name, UUID, or path.
233
+ """
234
+ args = ["show"]
235
+ if id:
236
+ args += ["--id", id]
237
+ elif note:
238
+ args.append(note)
239
+ if properties:
240
+ args.append("--properties")
241
+ elif tasks:
242
+ args.append("--tasks")
243
+ elif backlinks:
244
+ args.append("--backlinks")
245
+ elif body:
246
+ args.append("--body")
247
+ return json.dumps(run_fn(args, collection), indent=2)
248
+
249
+
250
+ # ---- Notes: Create ----
251
+
252
+ @mcp.tool()
253
+ def create_note(
254
+ title: str,
255
+ content: str | None = None,
256
+ tags: list[str] | None = None,
257
+ properties: list[str] | None = None,
258
+ collection: str | None = None,
259
+ ) -> str:
260
+ """Create a new note with optional content, tags, and properties.
261
+
262
+ Args:
263
+ title: Note title (becomes the filename).
264
+ content: Note body content (markdown).
265
+ tags: Tags to add (without #).
266
+ properties: User properties as key=value strings.
267
+ collection: Collection name, UUID, or path.
268
+ """
269
+ args = ["create", title]
270
+ if content:
271
+ args += ["--content", content]
272
+ for t in tags or []:
273
+ args += ["--tag", t]
274
+ for p in properties or []:
275
+ args += ["--property", p]
276
+ return json.dumps(run_fn(args, collection), indent=2)
277
+
278
+
279
+ # ---- Notes: Edit ----
280
+
281
+ @mcp.tool()
282
+ def edit_note(
283
+ note: str,
284
+ append: str | None = None,
285
+ prepend_after_heading: str | None = None,
286
+ content: str | None = None,
287
+ set_property: list[str] | None = None,
288
+ remove_property: list[str] | None = None,
289
+ favourite: bool = False,
290
+ unfavourite: bool = False,
291
+ archive: bool = False,
292
+ unarchive: bool = False,
293
+ force: bool = False,
294
+ collection: str | None = None,
295
+ ) -> str:
296
+ """Modify a note's content or properties. Property values are validated
297
+ against the schema (name casing, type, select options) unless force=True.
298
+
299
+ Args:
300
+ note: Note title, UUID, or filename.
301
+ append: Text to append to the end of the note.
302
+ prepend_after_heading: Text to insert after the first heading.
303
+ content: Replace entire body with this content.
304
+ set_property: Properties to set as key=value strings. Validated against schema.
305
+ remove_property: Properties to remove by key name.
306
+ favourite: Mark as favourite.
307
+ unfavourite: Remove favourite.
308
+ archive: Archive the note.
309
+ unarchive: Unarchive the note.
310
+ force: Bypass property schema validation.
311
+ collection: Collection name, UUID, or path.
312
+ """
313
+ args = ["edit", note]
314
+ if append:
315
+ args += ["--append", append]
316
+ if prepend_after_heading:
317
+ args += ["--prepend-after-heading", prepend_after_heading]
318
+ if content:
319
+ args += ["--content", content]
320
+ for p in set_property or []:
321
+ args += ["--set-property", p]
322
+ for p in remove_property or []:
323
+ args += ["--remove-property", p]
324
+ if favourite:
325
+ args.append("--favourite")
326
+ if unfavourite:
327
+ args.append("--unfavourite")
328
+ if archive:
329
+ args.append("--archive")
330
+ if unarchive:
331
+ args.append("--unarchive")
332
+ if force:
333
+ args.append("--force")
334
+ return json.dumps(run_fn(args, collection), indent=2)
335
+
336
+
337
+ # ---- Notes: Delete ----
338
+
339
+ @mcp.tool()
340
+ def delete_note(
341
+ note: str,
342
+ collection: str | None = None,
343
+ ) -> str:
344
+ """Soft-delete a note: moves it to the .trash/ folder. Always reversible
345
+ with restore_note.
346
+
347
+ Deletion via this MCP is intentionally reversible — notes go to the trash,
348
+ never permanently. Emptying the trash / permanent deletion is deliberately
349
+ NOT exposed here; that irreversible step is left to the user in the app.
350
+
351
+ Args:
352
+ note: Note title, UUID, or filename.
353
+ collection: Collection name, UUID, or path.
354
+ """
355
+ return json.dumps(run_fn(["delete", note], collection), indent=2)
356
+
357
+
358
+ @mcp.tool()
359
+ def restore_note(
360
+ note: str,
361
+ collection: str | None = None,
362
+ ) -> str:
363
+ """Restore a note from the trash — the inverse of delete_note.
364
+
365
+ Args:
366
+ note: Trashed note title, UUID, or filename.
367
+ collection: Collection name, UUID, or path.
368
+ """
369
+ return json.dumps(run_fn(["restore", note], collection), indent=2)
370
+
371
+
372
+ # ---- Notes: Rename ----
373
+
374
+ @mcp.tool()
375
+ def rename_note(
376
+ note: str,
377
+ new_name: str,
378
+ collection: str | None = None,
379
+ ) -> str:
380
+ """Rename a note (changes the filename on disk).
381
+
382
+ Args:
383
+ note: Current note title, UUID, or filename.
384
+ new_name: New name for the note.
385
+ collection: Collection name, UUID, or path.
386
+ """
387
+ return json.dumps(run_fn(["rename", note, new_name], collection), indent=2)
388
+
389
+
390
+ # ---- Notes: Search ----
391
+
392
+ @mcp.tool()
393
+ def search_notes(
394
+ query: str,
395
+ context: int = 2,
396
+ tag: str | None = None,
397
+ limit: int | None = None,
398
+ titles_only: bool = False,
399
+ regex: bool = False,
400
+ collection: str | None = None,
401
+ ) -> str:
402
+ """Full-text search across all notes in the collection.
403
+
404
+ Args:
405
+ query: Search query string.
406
+ context: Lines of context around matches (default: 2).
407
+ tag: Filter results to notes with this tag.
408
+ limit: Maximum number of results.
409
+ titles_only: Search titles only, not body content.
410
+ regex: Treat query as a regular expression.
411
+ collection: Collection name, UUID, or path.
412
+ """
413
+ args = ["search", query, "--context", str(context)]
414
+ if tag:
415
+ args += ["--tag", tag]
416
+ if limit:
417
+ args += ["--limit", str(limit)]
418
+ if titles_only:
419
+ args.append("--titles-only")
420
+ if regex:
421
+ args.append("--regex")
422
+ return json.dumps(run_fn(args, collection), indent=2)
423
+
424
+
425
+ # ---- Tags ----
426
+
427
+ @mcp.tool()
428
+ def list_tags(
429
+ tag: str | None = None,
430
+ prefix: str | None = None,
431
+ collection: str | None = None,
432
+ ) -> str:
433
+ """List all tags with note counts, or show notes for a specific tag.
434
+
435
+ Args:
436
+ tag: Show notes tagged with this tag.
437
+ prefix: Filter tags by prefix.
438
+ collection: Collection name, UUID, or path.
439
+ """
440
+ args = ["tags"]
441
+ if tag:
442
+ args.append(tag)
443
+ if prefix:
444
+ args += ["--prefix", prefix]
445
+ return json.dumps(run_fn(args, collection), indent=2)
446
+
447
+
448
+ # ---- Tasks: List ----
449
+
450
+ @mcp.tool()
451
+ def list_tasks(
452
+ due_today: bool = False,
453
+ due_this_week: bool = False,
454
+ overdue: bool = False,
455
+ status: str | None = None,
456
+ priority: str | None = None,
457
+ note: str | None = None,
458
+ project: str | None = None,
459
+ all: bool = False,
460
+ collection: str | None = None,
461
+ ) -> str:
462
+ """List tasks across all notes with filtering.
463
+
464
+ Args:
465
+ due_today: Show only tasks due today.
466
+ due_this_week: Show tasks due this week.
467
+ overdue: Show only overdue tasks.
468
+ status: Filter by status: not-started, in-progress, done, cancelled.
469
+ priority: Filter by priority: high, medium, low.
470
+ note: Filter by note title.
471
+ project: Filter by project name.
472
+ all: Include done and cancelled tasks.
473
+ collection: Collection name, UUID, or path.
474
+ """
475
+ args = ["tasks"]
476
+ if due_today:
477
+ args.append("--due-today")
478
+ if due_this_week:
479
+ args.append("--due-this-week")
480
+ if overdue:
481
+ args.append("--overdue")
482
+ if status:
483
+ args += ["--status", status]
484
+ if priority:
485
+ args += ["--priority", priority]
486
+ if note:
487
+ args += ["--note", note]
488
+ if project:
489
+ args += ["--project", project]
490
+ if all:
491
+ args.append("--all")
492
+ return json.dumps(run_fn(args, collection), indent=2)
493
+
494
+
495
+ # ---- Tasks: Add ----
496
+
497
+ @mcp.tool()
498
+ def add_task(
499
+ text: str,
500
+ note: str,
501
+ due: str | None = None,
502
+ priority: str | None = None,
503
+ project: str | None = None,
504
+ collection: str | None = None,
505
+ ) -> str:
506
+ """Add a new task to a note.
507
+
508
+ Args:
509
+ text: Task description text.
510
+ note: Target note title to add the task to.
511
+ due: Due date (YYYY-MM-DD, 'today', 'tomorrow', 'next monday', etc.).
512
+ priority: Priority level: high, medium, low.
513
+ project: Project name for the task.
514
+ collection: Collection name, UUID, or path.
515
+ """
516
+ args = ["tasks", "add", text, "--note", note]
517
+ if due:
518
+ args += ["--due", due]
519
+ if priority:
520
+ args += ["--priority", priority]
521
+ if project:
522
+ args += ["--project", project]
523
+ return json.dumps(run_fn(args, collection), indent=2)
524
+
525
+
526
+ # ---- Tasks: Complete ----
527
+
528
+ @mcp.tool()
529
+ def complete_task(
530
+ note: str,
531
+ text: str | None = None,
532
+ task_id: str | None = None,
533
+ collection: str | None = None,
534
+ ) -> str:
535
+ """Mark a task as done.
536
+
537
+ Identify the task by `task_id` (exact, preferred) or `text` (substring).
538
+
539
+ Args:
540
+ note: Note title containing the task.
541
+ text: Task text to match (case-insensitive substring). Must match exactly
542
+ one task; if several match, the command is refused — pass task_id.
543
+ task_id: Exact task UUID from `list_tasks`. Preferred — it targets the
544
+ precise task even when several share the same wording.
545
+ collection: Collection name, UUID, or path.
546
+ """
547
+ sel = _task_selector(text, task_id)
548
+ if sel is None:
549
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
550
+ return json.dumps(run_fn(["tasks", "complete"] + sel + ["--note", note], collection), indent=2)
551
+
552
+
553
+ # ---- Tasks: Cancel ----
554
+
555
+ @mcp.tool()
556
+ def cancel_task(
557
+ note: str,
558
+ text: str | None = None,
559
+ task_id: str | None = None,
560
+ collection: str | None = None,
561
+ ) -> str:
562
+ """Cancel a task.
563
+
564
+ Identify the task by `task_id` (exact, preferred) or `text` (substring).
565
+
566
+ Args:
567
+ note: Note title containing the task.
568
+ text: Task text to match (case-insensitive substring). Must match exactly
569
+ one task; if several match, the command is refused — pass task_id.
570
+ task_id: Exact task UUID from `list_tasks`. Preferred — it targets the
571
+ precise task even when several share the same wording.
572
+ collection: Collection name, UUID, or path.
573
+ """
574
+ sel = _task_selector(text, task_id)
575
+ if sel is None:
576
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
577
+ return json.dumps(run_fn(["tasks", "cancel"] + sel + ["--note", note], collection), indent=2)
578
+
579
+
580
+ # ---- Tasks: Progress ----
581
+
582
+ @mcp.tool()
583
+ def start_task(
584
+ note: str,
585
+ text: str | None = None,
586
+ task_id: str | None = None,
587
+ collection: str | None = None,
588
+ ) -> str:
589
+ """Mark a task as in-progress.
590
+
591
+ Identify the task by `task_id` (exact, preferred) or `text` (substring).
592
+
593
+ Args:
594
+ note: Note title containing the task.
595
+ text: Task text to match (case-insensitive substring). Must match exactly
596
+ one task; if several match, the command is refused — pass task_id.
597
+ task_id: Exact task UUID from `list_tasks`. Preferred — it targets the
598
+ precise task even when several share the same wording.
599
+ collection: Collection name, UUID, or path.
600
+ """
601
+ sel = _task_selector(text, task_id)
602
+ if sel is None:
603
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
604
+ return json.dumps(run_fn(["tasks", "progress"] + sel + ["--note", note], collection), indent=2)
605
+
606
+
607
+ # ---- Tasks: Reset ----
608
+
609
+ @mcp.tool()
610
+ def reset_task(
611
+ note: str,
612
+ text: str | None = None,
613
+ task_id: str | None = None,
614
+ collection: str | None = None,
615
+ ) -> str:
616
+ """Reset a task to not-started.
617
+
618
+ Identify the task by `task_id` (exact, preferred) or `text` (substring).
619
+
620
+ Args:
621
+ note: Note title containing the task.
622
+ text: Task text to match (case-insensitive substring). Must match exactly
623
+ one task; if several match, the command is refused — pass task_id.
624
+ task_id: Exact task UUID from `list_tasks`. Preferred — it targets the
625
+ precise task even when several share the same wording.
626
+ collection: Collection name, UUID, or path.
627
+ """
628
+ sel = _task_selector(text, task_id)
629
+ if sel is None:
630
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
631
+ return json.dumps(run_fn(["tasks", "reset"] + sel + ["--note", note], collection), indent=2)
632
+
633
+
634
+ # ---- Tasks: Set (amend due / priority / project) ----
635
+
636
+ @mcp.tool()
637
+ def set_task(
638
+ note: str,
639
+ text: str | None = None,
640
+ task_id: str | None = None,
641
+ due: str | None = None,
642
+ clear_due: bool = False,
643
+ priority: str | None = None,
644
+ clear_priority: bool = False,
645
+ project: str | None = None,
646
+ clear_project: bool = False,
647
+ collection: str | None = None,
648
+ ) -> str:
649
+ """Amend an existing task's due date, priority, or project.
650
+
651
+ Identify the task by `task_id` (exact, preferred) or `text` (substring),
652
+ then pass at least one field to change. Amending metadata preserves the
653
+ task's stable UUID (identity is hashed over the prose, not the metadata).
654
+
655
+ Args:
656
+ note: Note title containing the task.
657
+ text: Task text to match (case-insensitive substring). Must match exactly
658
+ one task; if several match, the command is refused — pass task_id.
659
+ task_id: Exact task UUID from `list_tasks`. Preferred.
660
+ due: New due date (YYYY-MM-DD, 'today', 'tomorrow', 'next monday', ...).
661
+ clear_due: Remove the due date instead of setting one.
662
+ priority: New priority: high, medium, low.
663
+ clear_priority: Remove the priority.
664
+ project: New project name.
665
+ clear_project: Remove the project.
666
+ collection: Collection name, UUID, or path.
667
+ """
668
+ sel = _task_selector(text, task_id)
669
+ if sel is None:
670
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
671
+ args = ["tasks", "set"] + sel + ["--note", note]
672
+ if due:
673
+ args += ["--due", due]
674
+ if clear_due:
675
+ args += ["--clear-due"]
676
+ if priority:
677
+ args += ["--priority", priority]
678
+ if clear_priority:
679
+ args += ["--clear-priority"]
680
+ if project:
681
+ args += ["--project", project]
682
+ if clear_project:
683
+ args += ["--clear-project"]
684
+ return json.dumps(run_fn(args, collection), indent=2)
685
+
686
+
687
+ # ---- Tasks: Remove ----
688
+
689
+ @mcp.tool()
690
+ def remove_task(
691
+ note: str,
692
+ text: str | None = None,
693
+ task_id: str | None = None,
694
+ collection: str | None = None,
695
+ ) -> str:
696
+ """Delete a task line from a note.
697
+
698
+ Identify the task by `task_id` (exact, preferred) or `text` (substring).
699
+ This permanently removes the task line from the note body — unlike
700
+ delete_note it does not go to the trash. Prefer `task_id`, and confirm with
701
+ `list_tasks` first when matching by text. Surviving tasks keep their UUIDs.
702
+
703
+ Args:
704
+ note: Note title containing the task.
705
+ text: Task text to match (case-insensitive substring). Must match exactly
706
+ one task; if several match, the command is refused — pass task_id.
707
+ task_id: Exact task UUID from `list_tasks`. Preferred.
708
+ collection: Collection name, UUID, or path.
709
+ """
710
+ sel = _task_selector(text, task_id)
711
+ if sel is None:
712
+ return json.dumps({"error": "Provide either text or task_id."}, indent=2)
713
+ return json.dumps(run_fn(["tasks", "remove"] + sel + ["--note", note], collection), indent=2)
714
+
715
+
716
+ # ---- Tasks: Projects ----
717
+
718
+ @mcp.tool()
719
+ def list_projects(collection: str | None = None) -> str:
720
+ """List all projects (derived from task metadata).
721
+
722
+ Args:
723
+ collection: Collection name, UUID, or path.
724
+ """
725
+ return json.dumps(run_fn(["tasks", "projects"], collection), indent=2)
726
+
727
+
728
+ # ---- Backlinks ----
729
+
730
+ @mcp.tool()
731
+ def backlinks(
732
+ note: str,
733
+ context: bool = False,
734
+ collection: str | None = None,
735
+ ) -> str:
736
+ """Show notes that reference (link to) a target note.
737
+
738
+ Args:
739
+ note: Target note title, UUID, or filename.
740
+ context: Show the paragraph containing the [[link]].
741
+ collection: Collection name, UUID, or path.
742
+ """
743
+ args = ["backlinks", note]
744
+ if context:
745
+ args.append("--context")
746
+ return json.dumps(run_fn(args, collection), indent=2)
747
+
748
+
749
+ # ---- Daily Notes ----
750
+
751
+ @mcp.tool()
752
+ def daily_note(
753
+ date: str | None = None,
754
+ create: bool = False,
755
+ tasks: bool = False,
756
+ collection: str | None = None,
757
+ ) -> str:
758
+ """Show or create a daily note.
759
+
760
+ Args:
761
+ date: Date (YYYY-MM-DD, 'today', 'yesterday', 'tomorrow'). Default: today.
762
+ create: Create the daily note if it doesn't exist.
763
+ tasks: Show tasks from the daily note.
764
+ collection: Collection name, UUID, or path.
765
+ """
766
+ args = ["daily"]
767
+ if date:
768
+ args += [date]
769
+ if create:
770
+ args.append("--create")
771
+ if tasks:
772
+ args.append("--tasks")
773
+ return json.dumps(run_fn(args, collection), indent=2)
774
+
775
+
776
+ @mcp.tool()
777
+ def daily_append(
778
+ text: str,
779
+ date: str | None = None,
780
+ collection: str | None = None,
781
+ ) -> str:
782
+ """Append text to a daily note (creates it if needed).
783
+
784
+ Args:
785
+ text: Text to append.
786
+ date: Date for the daily note (default: today).
787
+ collection: Collection name, UUID, or path.
788
+ """
789
+ args = ["daily", "append", text]
790
+ if date:
791
+ args += ["--date", date]
792
+ return json.dumps(run_fn(args, collection), indent=2)
793
+
794
+
795
+ # ---- Collections ----
796
+
797
+ @mcp.tool()
798
+ def collection_info(collection: str | None = None) -> str:
799
+ """Show information about the active collection (path, note/tag counts, cache status).
800
+
801
+ Args:
802
+ collection: Collection name, UUID, or path.
803
+ """
804
+ return json.dumps(run_fn(["collection", "info"], collection), indent=2)
805
+
806
+
807
+ @mcp.tool()
808
+ def list_collections() -> str:
809
+ """List all registered collections."""
810
+ return json.dumps(run_fn(["collections"]), indent=2)
811
+
812
+
813
+ @mcp.tool()
814
+ def switch_collection(reference: str) -> str:
815
+ """Switch the active collection.
816
+
817
+ Args:
818
+ reference: Collection name, UUID, or path.
819
+ """
820
+ return json.dumps(run_fn(["collection", "switch", reference]), indent=2)
821
+
822
+
823
+ # ---- Properties: Schema ----
824
+
825
+ @mcp.tool()
826
+ def list_properties(collection: str | None = None) -> str:
827
+ """List all property definitions in the collection schema.
828
+ Shows name, type, options, validation rules, and usage counts.
829
+
830
+ Args:
831
+ collection: Collection name, UUID, or path.
832
+ """
833
+ return json.dumps(run_fn(["properties", "list"], collection), indent=2)
834
+
835
+
836
+ @mcp.tool()
837
+ def show_property(
838
+ name: str,
839
+ collection: str | None = None,
840
+ ) -> str:
841
+ """Show details of a property definition including type, options,
842
+ validation rules, and which notes use it.
843
+
844
+ Args:
845
+ name: Property name (case-insensitive).
846
+ collection: Collection name, UUID, or path.
847
+ """
848
+ return json.dumps(run_fn(["properties", "show", name], collection), indent=2)
849
+
850
+
851
+ @mcp.tool()
852
+ def add_property(
853
+ name: str,
854
+ type: str,
855
+ options: str | None = None,
856
+ required: bool = False,
857
+ collection: str | None = None,
858
+ ) -> str:
859
+ """Create a new property definition in the collection schema.
860
+
861
+ Args:
862
+ name: Property name.
863
+ type: Property type: text, number, date, dateTime, checkbox, singleSelect, multiSelect, url, email, phone, rating.
864
+ options: Comma-separated options (required for singleSelect/multiSelect).
865
+ required: Mark this property as required.
866
+ collection: Collection name, UUID, or path.
867
+ """
868
+ args = ["properties", "add", name, "--type", type]
869
+ if options:
870
+ args += ["--property-options", options]
871
+ if required:
872
+ args.append("--required")
873
+ return json.dumps(run_fn(args, collection), indent=2)
874
+
875
+
876
+ @mcp.tool()
877
+ def delete_property(
878
+ name: str,
879
+ force: bool = False,
880
+ collection: str | None = None,
881
+ ) -> str:
882
+ """Delete a property definition from the schema. Values in notes are preserved.
883
+
884
+ Deleting a definition that is still in use is guarded: with force=False
885
+ (the default) the CLI does NOT delete — it reports how many notes use the
886
+ property and asks you to re-run with force=True to confirm. Unused
887
+ definitions delete without needing force. Set force=True only when you
888
+ intend to remove a definition you know is still referenced.
889
+
890
+ Args:
891
+ name: Property name (case-insensitive).
892
+ force: Skip the "used by N notes" confirmation and delete anyway
893
+ (default: false — respects the CLI safety gate).
894
+ collection: Collection name, UUID, or path.
895
+ """
896
+ args = ["properties", "delete", name]
897
+ if force:
898
+ args.append("--force")
899
+ return json.dumps(run_fn(args, collection), indent=2)
900
+
901
+
902
+ @mcp.tool()
903
+ def property_orphans(collection: str | None = None) -> str:
904
+ """Find front matter property keys that have no schema definition.
905
+ Useful for detecting typos, case mismatches, or legacy properties.
906
+
907
+ Args:
908
+ collection: Collection name, UUID, or path.
909
+ """
910
+ return json.dumps(run_fn(["properties", "orphans"], collection), indent=2)
911
+
912
+
913
+ @mcp.tool()
914
+ def property_notes(
915
+ name: str,
916
+ value: str | None = None,
917
+ collection: str | None = None,
918
+ ) -> str:
919
+ """List notes that use a specific property, optionally filtered by value.
920
+
921
+ Args:
922
+ name: Property name (case-insensitive).
923
+ value: Filter by value (case-insensitive).
924
+ collection: Collection name, UUID, or path.
925
+ """
926
+ args = ["properties", "notes", name]
927
+ if value:
928
+ args += ["--value", value]
929
+ return json.dumps(run_fn(args, collection), indent=2)
930
+
931
+
932
+ # ---- Open in App ----
933
+
934
+ @mcp.tool()
935
+ def open_note(
936
+ note: str | None = None,
937
+ id: str | None = None,
938
+ daily: bool = False,
939
+ collection: str | None = None,
940
+ ) -> str:
941
+ """Open a note in the FoldNotes app via URL scheme.
942
+
943
+ Args:
944
+ note: Note title, UUID, or filename.
945
+ id: Note UUID (exact lookup; alternative to note).
946
+ daily: Open today's daily note instead.
947
+ collection: Collection name, UUID, or path.
948
+ """
949
+ args = ["open"]
950
+ if daily:
951
+ args.append("--daily")
952
+ elif id:
953
+ args += ["--id", id]
954
+ elif note:
955
+ args.append(note)
956
+ return json.dumps(run_fn(args, collection), indent=2)
957
+
958
+
959
+ # ---------------------------------------------------------------------------
960
+ # v2.2.0 additions — extra fn CLI surface wrapped as thin passthroughs:
961
+ # saved queries, templates, project refactoring, attachments, archive,
962
+ # notifications. (bind, import, collection add/remove deliberately omitted.)
963
+ # ---------------------------------------------------------------------------
964
+
965
+ @mcp.tool()
966
+ def notifications(collection: str | None = None) -> str:
967
+ """Show notification settings and upcoming task reminders.
968
+
969
+ Args:
970
+ collection: Collection name, UUID, or path.
971
+ """
972
+ return json.dumps(run_fn(["notifications"], collection), indent=2)
973
+
974
+
975
+ @mcp.tool()
976
+ def list_queries(collection: str | None = None) -> str:
977
+ """List saved queries in the collection (.queries/).
978
+
979
+ Args:
980
+ collection: Collection name, UUID, or path.
981
+ """
982
+ return json.dumps(run_fn(["query", "list"], collection), indent=2)
983
+
984
+
985
+ @mcp.tool()
986
+ def run_query(name: str, limit: int | None = None, collection: str | None = None) -> str:
987
+ """Run a saved query; returns the matching notes (same shape as list_notes).
988
+
989
+ Args:
990
+ name: Saved query name (or unambiguous prefix); see list_queries.
991
+ limit: Maximum number of results.
992
+ collection: Collection name, UUID, or path.
993
+ """
994
+ args = ["query", "run", name]
995
+ if limit is not None:
996
+ args += ["--limit", str(limit)]
997
+ return json.dumps(run_fn(args, collection), indent=2)
998
+
999
+
1000
+ @mcp.tool()
1001
+ def list_templates(collection: str | None = None) -> str:
1002
+ """List available note templates.
1003
+
1004
+ Args:
1005
+ collection: Collection name, UUID, or path.
1006
+ """
1007
+ return json.dumps(run_fn(["templates"], collection), indent=2)
1008
+
1009
+
1010
+ @mcp.tool()
1011
+ def rename_project(
1012
+ old_name: str,
1013
+ new_name: str,
1014
+ include_subtree: bool = False,
1015
+ collection: str | None = None,
1016
+ ) -> str:
1017
+ """Rename a project across the whole collection (tags + task keywords).
1018
+
1019
+ Args:
1020
+ old_name: Existing project name.
1021
+ new_name: New project name.
1022
+ include_subtree: Also rename sub-projects (old/sub -> new/sub).
1023
+ collection: Collection name, UUID, or path.
1024
+ """
1025
+ args = ["projects", "rename", old_name, new_name]
1026
+ if include_subtree:
1027
+ args.append("--include-subtree")
1028
+ return json.dumps(run_fn(args, collection), indent=2)
1029
+
1030
+
1031
+ @mcp.tool()
1032
+ def strip_project(name: str, collection: str | None = None) -> str:
1033
+ """Remove a project's tags and task keywords across the collection.
1034
+
1035
+ Args:
1036
+ name: Project name to strip.
1037
+ collection: Collection name, UUID, or path.
1038
+ """
1039
+ return json.dumps(run_fn(["projects", "strip", name], collection), indent=2)
1040
+
1041
+
1042
+ @mcp.tool()
1043
+ def list_attachments(collection: str | None = None) -> str:
1044
+ """List images with size and reference count.
1045
+
1046
+ Args:
1047
+ collection: Collection name, UUID, or path.
1048
+ """
1049
+ return json.dumps(run_fn(["attachments", "list"], collection), indent=2)
1050
+
1051
+
1052
+ @mcp.tool()
1053
+ def orphan_attachments(collection: str | None = None) -> str:
1054
+ """List images that no note references.
1055
+
1056
+ Args:
1057
+ collection: Collection name, UUID, or path.
1058
+ """
1059
+ return json.dumps(run_fn(["attachments", "orphans"], collection), indent=2)
1060
+
1061
+
1062
+ @mcp.tool()
1063
+ def prune_attachments(force: bool = False, collection: str | None = None) -> str:
1064
+ """Delete orphaned images. Without force, only reports what would be deleted.
1065
+
1066
+ Args:
1067
+ force: Actually delete the orphaned images (default: dry-run).
1068
+ collection: Collection name, UUID, or path.
1069
+ """
1070
+ args = ["attachments", "prune"]
1071
+ if force:
1072
+ args.append("--force")
1073
+ return json.dumps(run_fn(args, collection), indent=2)
1074
+
1075
+
1076
+ @mcp.tool()
1077
+ def archive_note(note: str | None = None, id: str | None = None, collection: str | None = None) -> str:
1078
+ """Archive a note (excluded from list_notes by default). Same as edit --archive.
1079
+
1080
+ Args:
1081
+ note: Note title.
1082
+ id: Note UUID (alternative to note).
1083
+ collection: Collection name, UUID, or path.
1084
+ """
1085
+ args = ["archive"]
1086
+ if id:
1087
+ args += ["--id", id]
1088
+ elif note:
1089
+ args.append(note)
1090
+ return json.dumps(run_fn(args, collection), indent=2)
1091
+
1092
+
1093
+ @mcp.tool()
1094
+ def unarchive_note(note: str | None = None, id: str | None = None, collection: str | None = None) -> str:
1095
+ """Restore an archived note. Same as edit --unarchive.
1096
+
1097
+ Args:
1098
+ note: Note title.
1099
+ id: Note UUID (alternative to note).
1100
+ collection: Collection name, UUID, or path.
1101
+ """
1102
+ args = ["unarchive"]
1103
+ if id:
1104
+ args += ["--id", id]
1105
+ elif note:
1106
+ args.append(note)
1107
+ return json.dumps(run_fn(args, collection), indent=2)
1108
+
1109
+
1110
+ def main() -> None:
1111
+ """Console-script entry point for ``foldnotes-mcp`` / ``uvx foldnotes-mcp``.
1112
+
1113
+ Starts the MCP server on stdio — the transport Claude Desktop and Claude
1114
+ Code use. Equivalent to running ``python3 foldnotes_mcp.py`` directly.
1115
+ """
1116
+ mcp.run()
1117
+
1118
+
1119
+ if __name__ == "__main__":
1120
+ main()