Hearth
by grotefigi
README.md
# Hearth
**A self-hosted household memory MCP server for Alexa+ that knows who is listening.**
A smart speaker sits in a shared room. Every mainstream assistant fails the same
way: it cannot tell who is speaking, so it either refuses to remember anything
useful or it announces your secrets to whoever happens to be standing there.
"I bought Sam a birthday present" becomes a sentence the whole kitchen hears.
Hearth fixes that **at the protocol layer instead of the prompt layer**.
Each household member holds their own bearer token. The MCP transport
authenticates the token, the server derives the caller's identity from the
authenticated principal, and the store filters every read in SQL against that
identity. Because identity never travels as a tool argument, a model being driven
by a confused or malicious prompt still cannot read another member's private
memories or forge authorship.
> **The prompt is not the security boundary. The transport is.**
---
## Why this is not just a wrapper
Most "memory MCP servers" are a key-value store with a `remember` and a
`recall`. The interesting problem in a *household* is not storage, it is
**consent**:
| Naive memory server | Hearth |
|---|---|
| One shared bucket | Every memory has an owner and a scope |
| The model decides who may see what | SQL decides, against the authenticated caller |
| "Who is speaking?" is a tool argument | Identity comes from the bearer token; arguments are never trusted |
| Deleting others' notes is possible | Only the author can delete, even for shared memories |
| Undiscoverable data access | Every write/delete lands in a household-visible audit log |
The threat model is a **prompt-injected or simply over-eager model**. Hearth
assumes the model can be wrong. The guarantee holds anyway, because the model
never holds the deciding vote.
---
## Quickstart
```bash
git clone <this repo> && cd hearth
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
# 1. Create the household and issue one token per person (shown once)
hearth bootstrap alex sam
# 2. Run the Streamable HTTP server
hearth serve --host 0.0.0.0 --port 8765
```
`bootstrap` writes `members.json` containing **only SHA-256 hashes**. The tokens
printed to your terminal are not recoverable from that file.
```
Hearth 1.0.0 -- self-hosted household memory
transport : Streamable HTTP
endpoint : http://0.0.0.0:8765/mcp
database : /srv/hearth/hearth.db
members : alex, sam (2)
auth : bearer token per member, required
```
### Connecting a client
Any MCP client that supports the Streamable HTTP transport. Point it at
`http://<host>:8765/mcp` and give it **that person's** token:
```json
{
"mcpServers": {
"hearth": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp",
"headers": { "Authorization": "Bearer hearth_XXXX..." }
}
}
}
```
For Alexa+ this is the self-hosted MCP server the Alexa+ track asks for: a
Streamable HTTP endpoint on your own box, reachable over your own network, with
no third-party cloud in the data path.
**Spec compliance.** Hearth handshakes MCP protocol versions `2025-11-25` and `2026-07-28`.
The Alexa+ track requires `2025-11-25` or later, and the SDK's *fallback* negotiated version
is older than that — so the handshake is asserted in
`tests/test_http.py::test_server_negotiates_a_spec_new_enough_for_alexa_plus` rather than
assumed.
---
## The tools
| Tool | What it does |
|---|---|
| `whoami` | Which member this request is authenticated as, and what they may see |
| `remember` | Store a fact, `private` (default) or `household` |
| `recall` | Search — **only** returns memories the caller is entitled to |
| `forget` | Delete a memory; **only its author** may, even if shared |
| `add_to_list` / `read_list` / `complete_list_item` / `list_names` | Shared lists with per-item privacy |
| `set_presence` / `household_state` | Who is home; which lists have open items |
| `audit_tail` | The household transparency log |
| `household_briefing` (prompt) | A short spoken briefing for the current speaker |
---
## Seeing the guarantee
Two members, same server, same question, different answers. This is the whole
point, so it is a test, not a demo script: see
`tests/test_privacy.py::test_private_memory_invisible_to_other_member`.
```python
# alex writes a private memory
alex_store.add_memory(owner_id="alex", text="bought sam a bike", scope="private")
# alex sees it
assert alex_store.search_memories(viewer_id="alex")[0]["text"] == "bought sam a bike"
# sam searches for it and gets nothing at all
assert sam_store.search_memories(viewer_id="sam", query="bike") == []
```
Now flip only the scope:
```python
alex_store.add_memory(owner_id="alex", text="wifi code is hunter2", scope="household")
assert sam_store.search_memories(viewer_id="sam", query="wifi") # visible
assert not sam_store.forget_memory(memory_id=that_id, viewer_id="sam") # still not deletable
```
---
## Architecture
```
Authorization: Bearer <alex's token>
|
Streamable HTTP
|
+---------------+----------------+
| HouseholdTokenVerifier | unknown token -> 401, no tool runs
+---------------+----------------+
|
authenticated principal
|
+---------------+----------------+ +---------------------+
| resolve_caller() | <------ | MemberRegistry |
| (never reads tool arguments) | | token_sha256 only |
+---------------+----------------+ +---------------------+
|
+---------------+----------------+
| Store -- SQLite + FTS5 | every SELECT carries
| visibility filter in SQL | (owner = :viewer OR scope='household')
+--------------------------------+
```
| Module | Responsibility |
|---|---|
| `store.py` | Domain model, schema, and the SQL-level visibility filter |
| `members.py` | Token minting, hashing, constant-time resolution |
| `auth.py` | `TokenVerifier` implementation + per-call caller resolution |
| `server.py` | MCP tool definitions and instructions |
| `__main__.py` | `bootstrap` / `member add` / `serve` CLI |
### Durability
- SQLite in WAL mode; one connection per operation, safe under concurrent ASGI workers.
- FTS5 full-text search, with raw question text sanitised into a safe `MATCH`
expression so `"where are my keys?"` cannot raise a syntax error.
- `ttl_days` on `remember` makes memories expire. A memory that outlives its
truth is worse than no memory.
---
## Operational notes
**Running behind a reverse proxy.** Terminate TLS at the proxy and forward
`Authorization` unchanged. Hearth never logs token values.
**Rotating a token.** Delete the member's entry from `members.json` and run
`hearth member add <name>`. Resolution is a hash lookup, so removal is immediate.
**Backups.** `hearth.db` is the entire household state. `members.json` is the
only credential material and contains hashes only — back it up separately.
**What Hearth deliberately does not do.** It does not call an LLM, it does not
phone home, and it has no telemetry. It stores text and enforces who may read it.
---
## Tests
```bash
pytest -q
```
Coverage focuses on the guarantee rather than on line count: cross-member
isolation, unauthenticated rejection, author-only deletion, private list items,
memory expiry, and FTS5 query sanitisation.
---
## License
Apache-2.0. See [`LICENSE`](LICENSE).
Built for the **Build, Ship, Shape: Amazon Developer Hackathon** — Alexa+ track.
The Alexa+ track asks for a self-hosted MCP server speaking Streamable HTTP;
Hearth is one, and it solves the problem Alexa+ integrations have not yet
solved: a shared assistant that can hold a household's private context without
broadcasting it to the room.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues