Skip to main content
Glama
README.md
# notes-mcp: Virtual Sticky Notes
### for Mac

[![CI](https://github.com/pdegner/notes-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/pdegner/notes-mcp/actions/workflows/ci.yml)

An [MCP](https://modelcontextprotocol.io) server that keeps notes on your own machine, stored as
plain Markdown files. Connect it to Claude Code or Claude Desktop and you can say "save a note
about this" or "what did I write down about the migration?" in the middle of a conversation.

## What it exposes

MCP servers offer three kinds of things to a client. This one uses all three, because they do
different jobs:

- **Tools** are verbs. They are actions the model decides to take. Writing and deleting notes belongs here.
- **Resources** are nouns. They are content the client can pull into context, addressed by URI. The model does
  not have to "decide" to call them; you or the client can attach them directly.
- **Prompts** are reusable message templates the user invokes deliberately, usually from a menu.

### Tools

| Tool | Arguments | Returns |
| --- | --- | --- |
| `add_note` | `title`, `content`, `tags` | The new note's id |
| `list_notes` | `tag` (optional) | A Markdown table: id, title, tags, and a short blurb |
| `search_notes` | `query`, `tags`, `match_all` | Matching notes with a short snippet |
| `get_note` | `note_id` | One note in full, including its body |
| `delete_note` | `note_id` | Confirmation |

`search_notes` matches `query` against note titles and bodies, case-insensitively, and ANDs that
with the tag filter. `match_all` decides whether a note needs every tag listed or just one of them.

### Resources

| URI | Contents |
| --- | --- |
| `notes://all` | An index of every note: id, title, tags |
| `notes://tags` | Every tag in use, with how many notes carry it |
| `note://{note_id}` | One note's full Markdown source |

`note://{note_id}` is a resource *template* — the client fills in `{note_id}` to read a specific
note, rather than the server listing all of them up front. Since ids are small integers, that
means `note://5` for the fifth note.

### Prompt

`summarize_notes(tag)` gathers your notes, groups them by theme, and pulls out anything that looks
like a task or an open question. Pass a tag to narrow it, or leave it empty for everything.

## Setup

Requires [uv](https://docs.astral.sh/uv/) and Python 3.10+.

```sh
git clone https://github.com/pdegner/notes-mcp.git
cd notes-mcp
uv sync
```

### Claude Code

```sh
claude mcp add notes -- uv --directory /full/path/to/notes-mcp run notes-mcp
```

### Claude Desktop

Add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "notes": {
      "command": "uv",
      "args": ["--directory", "/full/path/to/notes-mcp", "run", "notes-mcp"]
    }
  }
}
```

## Where your virtual sticky notes live

In `~/.notes-mcp/notes/`, one Markdown file per note, named `{id}.md`. Set `NOTES_MCP_DIR` to put
them elsewhere.

```markdown
---
id: '5'
title: Dentist appointment
tags:
- errands
- health
created: '2026-07-30T17:19:20Z'
updated: '2026-07-30T17:19:20Z'
---

Tuesday 3pm, Dr. Okafor.
```

Ids are assigned in order starting from `1`. Deleting a note frees its number, and the next note
you write gets the lowest free number rather than the next-highest — so if you delete note `3` out
of five notes, the next note you add becomes the new `3`, not `6`.

Because a note is just a Markdown file, you can read, edit, grep, or back it up without this
server involved. Hand-edited files are read back fine, including a `tags: work, urgent` shorthand
instead of a YAML list.

**Your notes stay on this machine.** They live outside this repository, so git never sees them and
they are never committed or pushed — the repo holds code only. The notes directory is created
`0700` and each note file `0600`, so other accounts on the machine cannot read them.

They are stored as plain text, not encrypted. That protects against other local users and against
accidentally publishing them; it does not protect against anyone who has your login, admin access
to the machine, or a backup that copies your home directory. Treat them like sticky notes on your
desk, and **don't put passwords in them**.

## Design notes

A few decisions worth explaining:

**List and search return metadata, not bodies.** Only `get_note` returns note text. If
`list_notes` returned full bodies, a directory of a few hundred notes would flood the model's
context on a single call. Search returns a short snippet around the match instead, which is enough
for the model to pick the right note and then ask for it.

**Note ids are validated before they touch the filesystem.** Ids arrive as tool arguments, which
means they come from a model and are untrusted input. Every id is checked against
`^[a-z0-9][a-z0-9-]*$` before it is turned into a path, so `../../etc/passwd` is rejected rather
than resolved. That pattern is broader than the plain integers the server assigns on purpose: a
hand-named file dropped into the notes directory stays readable even though the server would never
generate an id like that itself. Writes go to a temp file and are then atomically renamed, so an
interrupted write can't truncate an existing note.

**A note's id never changes for as long as it exists.** These are meant to work like sticky notes:
you write one down, and if you end up checking it often, `note://5` is a fixed address you can
always go back to, not something that drifts as other notes come and go. Ids are only ever handed
out at creation, so nothing renumbers a note out from under you. Deleting a note frees its number
for the *next* note written after it, rather than leaving a permanent gap; the store scans
existing filenames on every `add_note` call and picks the smallest positive integer not already in
use (an O(n) scan, fine for a personal notes directory, not for thousands of notes). That reuse is
what keeps ids low and memorable instead of growing forever, but it only ever assigns a freed
number to a brand-new note, never to one that's still around.

**Nothing prints to stdout.** On the stdio transport, file descriptor 1 carries the JSON-RPC
message stream, so anything else written there corrupts the framing. The Python SDK does defend
against this — `mcp.server.stdio` claims fd 1 for the transport and repoints the process's own
stdout at stderr — but that only happens when the SDK opens the stream itself, and other MCP
implementations don't all do it. Treating stdout as reserved and sending logs to stderr is the
portable discipline rather than a workaround for one library's behavior.

## Development

```sh
uv run pytest          # 59 tests
uv run ruff check .    # lint
uv run ruff format .   # format
```

## License

MIT

TDQS

A4.2/5.0

Scored across 5 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: adding, listing (with metadata only), searching (with snippets), getting full content, and deleting. The list and search tools are complementary rather than overlapping, with explicit guidance to call get_note for full bodies.

Naming Consistency4/5

Tool names follow a consistent verb_noun pattern, but there is a minor inconsistency in pluralization: add_note, get_note, and delete_note use singular while list_notes and search_notes use plural. Despite this, the pattern is predictable and easy to follow.

Tool Count5/5

Five tools is well-scoped for a notes server, covering the essential operations (create, read, list, search, delete) without unnecessary bloat. Each tool has a clear role and contributes to the overall functionality.

Completeness4/5

The tool surface covers most of the CRUD lifecycle (add, list, get, search, delete) but lacks an update/edit operation, which is a common requirement for notes. This is a minor gap that can be worked around by deleting and re-adding, but it is not ideal.

Maintenance

ActivitySlowing
ResponsivenessNo issues