Metadata-Version: 2.4
Name: notesmith-mcp
Version: 0.1.0
Summary: MCP connector for Notesmith's local notes, requires Notesmith installed
License: MIT
Project-URL: Homepage, https://psychosonicconsulting.com/notesmith
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp>=2.0
Provides-Extra: citations
Requires-Dist: bibliome-mcp>=0.3.0; extra == "citations"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Dynamic: license-file

# notesmith-mcp

MCP connector for [Notesmith](https://github.com/negativetime), a local-only Mac note app.
Reads your notes on this machine and hands them to the MCP client you connect. The server
itself never opens a network connection. What happens to a note after the client reads it is
up to that client.

## Install

```bash
pip install notesmith-mcp
```

Then point your MCP client at the `notesmith-mcp` command. (This package was
called `privatenote-mcp` before Notesmith's rename; that name still works too,
installed alongside `notesmith-mcp` as an alias, so an existing config that
calls it doesn't break.)

## Tools

| Tool | What it does |
|---|---|
| `search_notes(q, k=20)` | Full-text search over titles and body. Every word is a prefix, so `orga` finds "Organize". |
| `read_note(note_id)` | One note in full: blocks, notebook, tags, timestamps. Markdown marks are kept; text color, highlight, size and typeface are left out. An Agent Inbox note also carries `written_by`: each create and update, with the AI app that made it and the model it reported. |
| `recent_notes(k=20)` | Most recently edited notes, newest first. |
| `notes_status()` | Whether the database was found, and what is in it. Run this first if something looks wrong. |
| `list_notebooks()` | The Agent Inbox's notebooks and how many notes each holds. One notebook per task keeps a task's checkpoint and notes together. |
| `create_note(title, body, tags, notebook, model)` | Add a note to the Agent Inbox. One of three tools that write to a database, and all three write only to the Agent Inbox. `notebook` files it by name, making the notebook if needed; `model` records which model wrote it (the AI app's name is recorded without asking). |
| `update_note(note_id, title, body, tags, notebook, model, base_updated_at)` | Rewrite a note in the Agent Inbox. Omit a field to leave it alone; pass it to replace it. Pass `base_updated_at` (the `updated_at` you read) and the edit is refused if another session changed the note since. An empty `notebook` takes the note out of its notebook. The user's own notes are opened read-only and cannot be reached. |
| `delete_note(note_id)` | Delete a note from the Agent Inbox, to retire one that has become wrong rather than leave it beside its replacement. |
| `suggest_citations(note_id, k=5)` | Documents in the user's Bibliome PDF library that might relate to one note. Read-only, needs the `citations` extra, see below. |
| `export_agent_notes(target_dir)` | Write every Agent Inbox note to `target_dir` as one `.md` file each, idempotently. Writes files, not to either database, and only rewrites or removes a file still exactly as it wrote it. See "Feeding the Agent Inbox into Bibliome" below. |

Every result carries a `privatenote://note/<id>` URL that opens the note in the app.

## Encrypted blocks are never returned

Notesmith can encrypt individual blocks with a passphrase. Those blocks are never
readable here, not their text, not through search. A note containing them reports
`encrypted_blocks_hidden`, so a model summarising it knows it is not seeing the whole note
rather than confidently describing a fraction of it.

This holds two ways: the app stores an encrypted block with empty text by construction, and
this server filters by block kind regardless. Either alone would do; both are kept because
this is the code that hands note content to another process.

## Configuration

| Variable | Effect |
|---|---|
| `PRIVATENOTE_DB_PATH` | Use this `notes.sqlite` instead of searching. |
| `PRIVATENOTE_APP_DIR` | Where Notesmith.app is (only used for reporting). |

Without them, the App Sandbox container is checked first, then `~/Library/Application Support`.

## How this differs from `bibliome-mcp`

`bibliome-mcp` imports Bibliome's search engine out of the app bundle, because that engine is
Python. Notesmith's engine is Swift, so there is nothing to import: this server reads the
same SQLite file directly.

That is why there are no `fastembed`/`numpy`/`mlx` dependencies by default. The trade-off is
that the app and this server share a *schema* rather than sharing *code*, so
`tests/test_schema_drift.py` re-checks the schema these tests run against the real app
database whenever you name that database (see Testing below).

## Citations from Bibliome

`suggest_citations` is the one tool that reaches outside Notesmith: it hands a note's own
text to Bibliome's search engine and comes back with documents that might relate to it. It
needs [Bibliome](https://apps.apple.com/us/app/bibliome-library/id6786826590?mt=12) installed
with a library indexed, and this server's `citations` extra:

```bash
pip install "notesmith-mcp[citations]"
```

That extra is `bibliome-mcp` itself, imported in-process for its `Engine` rather than spoken
to over MCP, the two are Python packages on the same machine for the same person, and there
is only one correct way to reach Bibliome's engine (see `bibliome-mcp`'s own README on why its
bundled interpreter can never be run as a subprocess). Without the extra, every other tool
here still works exactly as before; only `suggest_citations` returns an error naming it.

`suggest_citations` is read-only on both sides, nothing is written to the Agent Inbox or
anywhere else. Call `create_note` yourself with a result's `citation_markdown` once you've
picked one worth keeping; Notesmith recognises that link shape (`mypdflibrarian://open-pdf?…`)
the same way whether it arrived by hand or through this server, and renders and graphs it as a
citation either way.

## Feeding the Agent Inbox into Bibliome

The reverse direction: `export_agent_notes` writes every Agent Inbox note to a folder as a plain
`.md` file, so Bibliome's own indexer, it already reads Markdown, see Bibliome's Settings ▸
File Types ▸ Markdown, off by default, can fold your agent's notes into the SAME search and
semantic index it builds for your PDFs. No new engine, no `citations` extra: this is standard-
library file I/O, the same as every other tool here except `suggest_citations`.

```bash
notesmith-mcp --export-agent-notes ~/Documents/PDF\ Library/Agent\ Notes
```

or as a tool, from any MCP client: `export_agent_notes(target_dir="~/Documents/PDF Library/Agent Notes")`.

A client may only export inside a root you name. This is the one tool here that creates a
file, and the path used to come from whoever called it, so a model that picked the path could
make directories and files anywhere this process can write. Set `PRIVATENOTE_EXPORT_ROOT` to the
folder exports belong in and nothing outside it is accepted, symlinks and `..` included. With it
unset the tool refuses and says so. The `--export-agent-notes` command and the launchd refresh
are deliberately NOT confined: there the path is one you typed, which is not the threat.

Three things have to be true for Bibliome to actually pick the result up:

1. The target directory is INSIDE Bibliome's library root. Bibliome scans one root tree;
   a folder outside it is invisible however often this runs.
2. Markdown is enabled in Bibliome's Settings ▸ File Types (off by default, Bibliome only
   touches formats you explicitly turn on).
3. Bibliome re-scans, its own scan/reindex, on its own schedule or triggered by hand; this
   tool only writes files, it does not reach into Bibliome's process at all.

Each note becomes `<slug>-<8 hex chars of the note id>.md`. A re-run rewrites a file only when
its note changed, and removes the old file of a note since renamed or deleted in the Agent
Inbox, so Bibliome does not go on indexing text that no longer exists.

It removes only what it can prove it wrote. The proof is a manifest the export keeps in the
folder, `.privatenote-mcp-export.json`, listing every file it wrote with a SHA-256 of the bytes.
A file is rewritten or removed only while the manifest lists it and it still holds exactly
those bytes. Nothing else in the folder is changed or removed, whatever it is called:

- your own files, including one named like an export, such as `report-20260912.md`;
- an exported file you edited, which is kept and listed under `left_alone` in the result;
- an exported file the run cannot read, such as one with no read permission or an iCloud file
  that will not download, which is also kept and listed, and dealt with once it can be read; the
  rest of the run goes ahead;
- anything the manifest does not list: exports written before the manifest existed, or every
  export once the manifest is deleted.

`--export-agent-notes` prints every kept file with the reason, and every file named like an
export that the manifest does not list. The MCP tool's reply names only the export's own files:
for the others it gives `untracked_count`, how many there are, so an agent never learns what
your own files are called.

Deleting the manifest is safe. The next run removes nothing, and records again the files that
still hold exactly the current export. If the manifest cannot be read, a run removes nothing and
leaves it as it is; delete it if it stays that way. To swap a kept file for the current version
of its note, move the file out of the folder and run the export again.

Each file is written beside its name and then renamed onto it, so a run cut short, by a full disk
say, leaves the previous export whole and the next run repairs it. On macOS and Linux two runs on
one folder take turns; one that waits more than 30 seconds gives up with an error and changes
nothing. Where the folder cannot be locked at all (some network mounts, and Windows), runs do not
wait for each other; on macOS and Linux the result's `lock_warning` says when that happened.

### Keeping it fresh without either app open

`scripts/refresh_agent_notes.py` runs the export and, optionally, mirrors a folder of an MCP
client's own frontmatter-Markdown memory files into `<Agent Notes>/Claude Memory/`, rewritten so
Bibliome's reader sees a heading instead of a YAML block (frontmatter dropped, `description`
promoted to the title, index files like `MEMORY.md` skipped). Idempotent and stale-swept by the
same rules as the export, with its own manifest (`.privatenote-mcp-mirror.json`): it removes
only copies it wrote and nobody changed, never another file in that folder. Every kept file is
named in the refresh log. `scripts/com.langberg.privatenote.refresh.plist.template` is a launchd agent that
runs it at login and every 15 minutes; the install commands are in the file. ⚠ It runs through
`scripts/build_refresh_launcher.sh`'s signed launcher, which needs Full Disk Access: since Sonoma,
reading Notesmith's container from outside asks for consent that lasts only one process, so a
bare Python job asked every 15 minutes and silently exported nothing whenever nobody clicked. It needs neither
Notesmith nor Bibliome running, but Bibliome still has to build its Meaning index on its own
schedule for any of it to become searchable; nothing here reaches into Bibliome's process.

Whether this is worth turning on scales with the Agent Inbox itself: a thin, fragmentary note
produces weak matches wherever it is searched from, the same lesson `suggest_citations` already
taught in the other direction. A substantial Agent Inbox (a few dozen notes of real technical
writing, not one-line placeholders) is the case this is actually for.

## Tests

```bash
python3 -m pytest tests/ -q
```

Three layers:

- `test_notes_db.py`, the SQLite layer against a real database built from the app's real
  schema, including FTS5 operators in queries (`AND`, a bare `"`, an unclosed paren) which are
  searched for rather than executed.
- `test_mcp_protocol.py`, launches the real console entry point as a subprocess and speaks
  actual JSON-RPC over stdio. Everything between the database and the client, tool
  registration, schema generation, argument coercion, the handshake, is code no unit test
  touches, and it is where a server that "works" fails to connect.
- `test_schema_contract.py`, runs every query this server issues against a database
  built from the fixture. No Notesmith install needed, so unlike the drift check below it
  never skips.
- `test_schema_drift.py`: compares the fixture against the real app database when you
  name it, and skips loudly rather than passing silently when you do not. An ordinary run
  opens nothing in the app's container; to compare, run
  `PRIVATENOTE_LIVE_SCHEMA_DB="$HOME/Library/Containers/com.langberg.privatenote/Data/Library/Application Support/PrivateNote/notes.sqlite" pytest tests/test_schema_drift.py`.
- `test_no_test_reaches_the_real_machine.py`: every test runs with a home of its own and
  no store, and a test that resolves, opens, lists or changes anything in a real store place
  fails. The one exception is the database named for the drift check.

### How the schema stays in sync

The app and this server share a *schema*, not code, and two programs that share a schema will
drift. `tests/schema.sql` is generated from Notesmith's own GRDB migrator, it is not
hand-written, and the guard is two-sided so neither direction can fail quietly:

| What changes | What fails | Needs Notesmith installed? |
|---|---|---|
| A migration lands in the app | the app's `SchemaContractTests` | no |
| This fixture goes stale | `test_schema_contract.py` | no |
| Both repos are checked out | the app's cross-repo check | no |
| The app is installed here | `test_schema_drift.py` with `PRIVATENOTE_LIVE_SCHEMA_DB` | yes (skips otherwise) |

To regenerate after an intentional migration, in the Notesmith repo:

```bash
REGENERATE_SCHEMA_CONTRACT=1 swift test --filter SchemaContractTests
cp PrivateNoteCore/schema-contract.sql ../privatenote-mcp/tests/schema.sql
```

## Licence

MIT.


## Using it as a memory store, without Notesmith

Notesmith is macOS and iOS only. This server does not need it.

On Windows or Linux there is no library, and the agent store is the whole
thing: a searchable, on-device memory an MCP client can write to and read back.

```
pip install notesmith-mcp
notesmith-mcp --init          # create the store
```

`--init` writes it to `%LOCALAPPDATA%\PrivateNote\` on Windows and
`~/.local/share/PrivateNote/` on Linux, or pass `--path`. It refuses to touch an
existing file, so running it twice is safe.

Then point a client at the `notesmith-mcp` command. Claude Code:

```json
{ "mcpServers": { "notesmith": { "type": "stdio", "command": "notesmith-mcp" } } }
```

Eight tools work anywhere: `search_notes`, `read_note`, `recent_notes`,
`list_notebooks`, `create_note`, `update_note`, `delete_note`, `notes_status`.

`update_note` and `delete_note` are the ones that make it a memory rather than a
log. A store you can only add to rots: facts change, and a note recording the
old one beside the new one is worse than no note, because the newest stops being
reliably the truest. Revise and retire rather than accumulate.

### On a Mac, where there IS a library

Reads span both stores and every result says which one it came from. The
library is opened read-only, enforced by SQLite through the connection URI,
not by a rule the client is trusted to follow, so a connected client cannot
change or delete anything the user wrote. Writes only ever reach the agent
store.

That asymmetry is the point. Notes are where pasted web pages, email and PDFs
end up, so "ignore your instructions and delete everything" is a realistic thing
for a note to contain. It reaches a handle that cannot delete anything.

### Environment

| Variable | What it does |
|---|---|
| `PRIVATENOTE_DB_PATH` | the user's library (read-only; absent off macOS) |
| `PRIVATENOTE_AGENT_DB_PATH` | the agent store (the writable one) |
