10 · Project — A Local Notes/Tasks MCP Server¶
This project pulls together everything from Level 1: tools, a resource, and a real connection to Claude Desktop, applied to a small but genuinely useful server — a notes/tasks manager backed by a local JSON file.
Spec¶
Build a server named notes-server with these three tools and one
resource:
add_note(text: str) -> dict
Adds a new note with the given text. Generates a unique ID (an incrementing
integer is fine), stores it as not-yet-complete, and returns the full note
record as a dict: {"id": ..., "text": ..., "done": False}.
list_notes(include_done: bool = True) -> list[dict]
Returns all stored notes as a list of records. When include_done=False,
filters out notes already marked done.
complete_note(note_id: int) -> dict
Marks the note with the given ID as done ("done": True) and returns the
updated record. If no note with that ID exists, return a dict like
{"error": f"No note with id {note_id}"} instead of raising — tool results
should communicate failure as data the model can read and relay, not as an
unhandled exception.
Resource notes://all
Returns all notes rendered as a single markdown document — one line per
note, something like:
Persistence¶
Notes must survive a server restart, so store them in a JSON file on disk
(e.g. notes.json next to your server script) rather than only in memory.
A reasonable structure:
import json
from pathlib import Path
NOTES_FILE = Path(__file__).parent / "notes.json"
def load_notes() -> list[dict]:
if not NOTES_FILE.exists():
return []
return json.loads(NOTES_FILE.read_text())
def save_notes(notes: list[dict]) -> None:
NOTES_FILE.write_text(json.dumps(notes, indent=2))
Each tool should load the current notes, make its change, save, and return
the relevant result — you decide the exact shape of load_notes /
save_notes and how add_note picks the next ID (the highest existing ID
plus one is a reasonable approach, since IDs may not always start at 1 after
notes get removed in a future version of this server).
Build it¶
Scaffold the server the same way as lesson 4, with the three
@mcp.tool()-decorated functions and one @mcp.resource("notes://all")
function described above, wired to the persistence functions. The core
server setup (FastMCP("notes-server"), the if __name__ == "__main__":
mcp.run() block) is exactly what you've already built twice this level —
reuse it rather than re-deriving it.
Verify it end to end¶
- In the MCP Inspector, call
add_notetwo or three times with different text, thenlist_notes()and confirm all of them come back. - Call
complete_noteon one of the IDs, thenlist_notes(include_done=False)and confirm the completed one is filtered out while it still shows up withinclude_done=True. - Read the
notes://allresource and confirm the markdown checklist format matches what you'd expect from the current state. - Restart the Inspector (which restarts your server process) and confirm
list_notes()still returns the same notes — proving the JSON file persistence actually works, not just the in-memory state of a single run. - Register it in
claude_desktop_config.jsonper lesson 9, restart Claude Desktop, and ask it to add a note and then list your notes, in a real conversation.
Exercise¶
Extend the server with a fourth tool, delete_note(note_id: int) -> dict,
that removes a note entirely (not just marks it done) and returns either
the removed record or an error dict if the ID doesn't exist — matching the
same not-found-as-data convention complete_note uses. Confirm it works in
the Inspector, including the case of deleting an ID that was never added.