Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ its own changelog and version.
`media/companion/capture-obsidian-stills.sh` (reproducible Obsidian-CLI capture, TCC-free via
`dev:screenshot`). Re-rendered the hero + index-recovery GIFs to fix a hidden-setup leak
(`clear` is a no-op in VHS's headless terminal).
- MCP `read_note(path)` read tool — fetches a located note's full markdown content by
repo-relative path, bounded to indexed notes (a `../` traversal, an absolute path, or a
non-note file returns `found: false` with `content: null`, never an out-of-vault read).
- `glama.json` server-metadata file (maintainers) for the Glama MCP directory.

### Changed
- GitHub repository description and topics were applied for the public release presentation pass.
Expand All @@ -62,6 +66,9 @@ its own changelog and version.
raising the Glama Tool Definition Quality Score; `hypermnesic_search` now states it is the
prefixed alias of `search`. A connector-quality test guards against regressing to terse
descriptions.
- Every MCP tool parameter now carries a schema description (previously 0% coverage), so clients
and directory scorers see documented inputs — addressing the Glama TDQS "Parameters" dimension
across all read tools and the gated `commit_note` write tool.

### Fixed
- README: removed a duplicated hero "receipt loop" GIF (embedded twice after the launch-assets
Expand Down
15 changes: 13 additions & 2 deletions docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ when `HEAD` has jumped far past the index checkpoint).
| `think` | read (`readOnlyHint: true`) | read | always |
| `resolve` | read (`readOnlyHint: true`) | read | always |
| `list_folders` | read (`readOnlyHint: true`) | read | always |
| `read_note` | read (`readOnlyHint: true`) | read | always |
| `commit_note` | write (`readOnlyHint: false`) | `write` | only on a write-enabled server |

## Read tools

Daily recall uses the read tools together: `search` for direct recall, `build_context` for graph
expansion around a known hit, `think` for related notes and questions, and `resolve` before
wikilinking an entity. `hypermnesic_search` is a compatibility alias for clients that prefix tool
expansion around a known hit, `think` for related notes and questions, `resolve` before
wikilinking an entity, and `read_note` to fetch a located note's full body. `hypermnesic_search` is a compatibility alias for clients that prefix tool
names; it has the same inputs and output shape as `search`. The owner-facing daily workflow
dashboard is generated by the local `hypermnesic daily-review` CLI command; it does not add an MCP
tool.
Expand Down Expand Up @@ -101,6 +102,16 @@ part of the memory taxonomy: durable project memory belongs in Hypermnesic, whil
behavioural preference/session memory belongs in Honcho or an equivalent adjacent layer
by default.

### `read_note(path: str)`

Read the full markdown content of a single note by its repo-relative `path` — typically a
`path` returned by `search`, `resolve`, or `build_context`. Read-only and **bounded to indexed
notes**: index membership is the security boundary, so a path that is not a committed note in the
vault (a `../` traversal, an absolute path, or a non-note file such as `.env`/`.git/`) returns
`found: false` with `content: null` rather than reading outside the vault.

**Returns** `{ path, found: bool, content: string|null, manual_reindex_recommended }`.

## Write tool (gated)

### `commit_note(path: str, body: str | None = None, set_fields: dict | None = None, summary: str | None = None)`
Expand Down
4 changes: 4 additions & 0 deletions glama.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": ["leonardsellem"]
}
92 changes: 82 additions & 10 deletions src/hypermnesic/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
import json
from collections.abc import Callable
from pathlib import Path
from typing import Annotated

from mcp.server.fastmcp import FastMCP
from mcp.types import CallToolResult, TextContent, ToolAnnotations
from pydantic import Field

# Pydantic (the SDK's schema generator) requires typing_extensions.TypedDict on Python < 3.12.
from typing_extensions import TypedDict
Expand Down Expand Up @@ -77,6 +79,13 @@ class SearchOutput(TypedDict):
hits: list[SearchHit]


class ReadNoteOutput(TypedDict):
path: str
found: bool
content: str | None
manual_reindex_recommended: bool


class BuildContextOutput(TypedDict):
start: str
depth: int
Expand Down Expand Up @@ -287,6 +296,21 @@ def embedder(self):
return self._embedder or None


def _read_indexed_note(idx, repo: Path, path: str) -> tuple[bool, str | None]:
"""Read an indexed note's content. Index membership IS the security boundary: only
committed, in-vault markdown notes appear in ``all_paths()``, so traversal (``../``),
absolute paths, and non-note files (``.env``, ``.git/``) are absent and return
``(False, None)``. A resolved-within-repo check adds defense in depth."""
if path not in idx.all_paths():
return False, None
try:
fp = (repo / path).resolve()
fp.relative_to(repo.resolve())
return True, fp.read_text(encoding="utf-8", errors="replace")
except (OSError, ValueError):
return False, None


def build_server(index_db: Path, *, host: str, port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH, embedder=None, repo: Path | None = None,
authoring_host: bool = False, write_enabled: bool = False,
Expand Down Expand Up @@ -439,7 +463,10 @@ def _search(query: str, k: int = 10) -> SearchOutput:
"and git recency) so an agent can recall facts, notes, and "
"decisions from the vault by a natural-language query. `k` caps "
"the number of hits (default 10).")
def search(query: str, k: int = 10) -> SearchOutput:
def search(
query: Annotated[str, Field(description="Natural-language query to recall notes by.")],
k: Annotated[int, Field(description="Maximum number of ranked hits to return.")] = 10,
) -> SearchOutput:
return _search(query, k)

@mcp.tool(name="hypermnesic_search", annotations=ToolAnnotations(readOnlyHint=True),
Expand All @@ -452,12 +479,19 @@ def search(query: str, k: int = 10) -> SearchOutput:
"score, channels, snippet, git recency). Prefer `search`; reach "
"for this alias only when your client requires the prefixed name. "
"`k` caps the number of hits (default 10).")
def hypermnesic_search(query: str, k: int = 10) -> SearchOutput:
def hypermnesic_search(
query: Annotated[str, Field(description="Natural-language query to recall notes by.")],
k: Annotated[int, Field(description="Maximum number of ranked hits to return.")] = 10,
) -> SearchOutput:
return _search(query, k)

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True),
description="Pages reachable from a page via body wikilinks (in+out edges).")
def build_context(path: str, depth: int = 1) -> BuildContextOutput:
def build_context(
path: Annotated[str, Field(description="Repo-relative path of the note to expand from.")],
depth: Annotated[int, Field(
description="Number of wikilink hops to traverse (in + out edges).")] = 1,
) -> BuildContextOutput:
cr = backend.converge()
reachable = graph_mod.build_context(backend.graph, path, depth=depth)
return {"start": path, "depth": depth, "context": reachable,
Expand All @@ -467,7 +501,9 @@ def build_context(path: str, depth: int = 1) -> BuildContextOutput:
description="Entity resolution: resolve a name to an existing page path "
"(gbrain's `get` role), or null if ambiguous/missing. The caller "
"strips `.md` (use `slug`) to form a wikilink target.")
def resolve(name: str) -> ResolveOutput:
def resolve(
name: Annotated[str, Field(description="Entity or page name to resolve to a vault path.")],
) -> ResolveOutput:
cr = backend.converge()
resolved = backend.graph.resolve(name)
slug = resolved[:-3] if resolved and resolved.endswith(".md") else resolved
Expand All @@ -478,7 +514,14 @@ def resolve(name: str) -> ResolveOutput:
description="Thinking-mode: related notes + Socratic prompts + "
"related-but-not-yet-linked pairs. Pass the active note's `path` to "
"exclude it from its own results. Never writes (wrote: false).")
def think(topic: str, k: int = 8, depth: int = 1, path: str | None = None) -> ThinkOutput:
def think(
topic: Annotated[str, Field(description="Topic or question to explore.")],
k: Annotated[int, Field(description="Maximum number of related notes to return.")] = 8,
depth: Annotated[int, Field(description="Graph hops used to expand related notes.")] = 1,
path: Annotated[str | None, Field(
description="Repo-relative path of the active note to exclude from its own "
"results.")] = None,
) -> ThinkOutput:
cr = backend.converge()
out = think_mod.think(backend.idx, topic, embedder=backend.embedder,
graph=backend.graph, k=k, depth=depth, path=path,
Expand All @@ -494,7 +537,11 @@ def think(topic: str, k: int = 8, depth: int = 1, path: str | None = None) -> Th
"note count, plus direct root-local AGENTS.md/CLAUDE.md guidance when "
"present. Read-only; the `writable` flag matches what `commit_note` "
"accepts. Narrow `root` to drill deeper when `truncated` is true.")
def list_folders(root: str = "", depth: int = 1) -> ListFoldersOutput:
def list_folders(
root: Annotated[str, Field(
description="Repo-relative folder to drill down from ('' = vault root).")] = "",
depth: Annotated[int, Field(description="Number of folder levels to descend.")] = 1,
) -> ListFoldersOutput:
cr = backend.converge()
instruction = None
try:
Expand All @@ -517,6 +564,23 @@ def list_folders(root: str = "", depth: int = 1) -> ListFoldersOutput:
}
return out

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True),
description="Read the full markdown content of a single note by its repo-relative "
"path (typically a `path` returned by `search`, `resolve`, or "
"`build_context`). Read-only and bounded to indexed notes: a path that "
"is not a committed note in the vault returns `found: false` with "
"`content: null`, never an out-of-vault or traversal read. Use it to "
"fetch a note's body after locating it with the other read tools.")
def read_note(
path: Annotated[str, Field(
description="Repo-relative path of the note to read, e.g. 'notes/topic.md' "
"(typically a `path` from a search/resolve/build_context result).")],
) -> ReadNoteOutput:
cr = backend.converge()
found, content = _read_indexed_note(backend.idx, backend.repo, path)
return {"path": path, "found": found, "content": content,
"manual_reindex_recommended": cr.manual_reindex_recommended}

if write_enabled:
# The one sanctioned write path, exposed over MCP only on a write-enabled
# (master) server. Git-first: file → git commit → index follows. Reuses
Expand All @@ -530,9 +594,17 @@ def list_folders(root: str = "", depth: int = 1) -> ListFoldersOutput:
description="Git-first write: commit a single note on the master "
"(guard → diff-or-die gate → git commit → audit; never merges). "
"The index follows as a projection — a reindex never loses it.")
def commit_note(path: str, body: str | None = None,
set_fields: dict | None = None,
summary: str | None = None) -> CommitNoteOutput:
def commit_note(
path: Annotated[str, Field(
description="Repo-relative path of the note to write, e.g. 'notes/x.md'.")],
body: Annotated[str | None, Field(
description="Full markdown body to commit; omit to only set frontmatter "
"fields on an existing note.")] = None,
set_fields: Annotated[dict | None, Field(
description="Frontmatter fields to set or merge (a YAML mapping).")] = None,
summary: Annotated[str | None, Field(
description="Optional commit-message summary for the git write.")] = None,
) -> CommitNoteOutput:
# V14 / security-review fix: when auth is on, the write tool self-enforces the
# write scope — independent of the transport's (misconfigurable, global)
# required_scopes. The HTTP auth middleware guarantees a principal for any
Expand Down Expand Up @@ -875,5 +947,5 @@ def build_cloud_server(index_db: Path, *, host: str = "127.0.0.1", port: int = D


READ_TOOL_NAMES = {"search", "hypermnesic_search", "build_context", "think", "resolve",
"list_folders"}
"list_folders", "read_note"}
WRITE_TOOL_NAMES = {"commit_note"} # registered only when write_enabled (U31)
35 changes: 33 additions & 2 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ def test_only_read_tools_no_write_tool(built_index, fake_embedder):
tools = asyncio.run(srv.list_tools())
names = {t.name for t in tools}
assert names == {
"search", "hypermnesic_search", "build_context", "think", "resolve", "list_folders"}
"search", "hypermnesic_search", "build_context", "think", "resolve", "list_folders",
"read_note"}
# read-only is structural: no write-ish tool exists
assert not any(
kw in n for t in tools for n in [t.name]
Expand All @@ -121,7 +122,7 @@ def test_every_tool_advertises_an_output_schema(built_index, fake_embedder):
schemas = {t.name: t.outputSchema for t in asyncio.run(srv.list_tools())}
assert set(schemas) == {
"search", "hypermnesic_search", "build_context", "think", "resolve", "list_folders",
"commit_note"}
"read_note", "commit_note"}
for name, sch in schemas.items():
assert sch and sch.get("properties"), f"{name} has no outputSchema"
# spot-check the declared shapes match the real returns
Expand All @@ -133,6 +134,7 @@ def test_every_tool_advertises_an_output_schema(built_index, fake_embedder):
assert {"folders", "truncated", "omitted", "agent_instruction"} <= set(
schemas["list_folders"]["properties"]
)
assert {"found", "content"} <= set(schemas["read_note"]["properties"])
assert {"committed", "refused"} <= set(schemas["commit_note"]["properties"])


Expand All @@ -151,6 +153,35 @@ def test_search_tools_carry_descriptive_definitions(built_index, fake_embedder):
assert "alias" in (tools["hypermnesic_search"].description or "").lower()


def test_read_note_reads_indexed_notes_only(built_index, fake_embedder):
# Completeness (TDQS): agents can read a note's full content by path. The index IS the
# security boundary — only committed, in-vault notes are readable; traversal, absolute,
# and non-note paths return (False, None) with no out-of-vault read.
backend = mcp_server._Backend(built_index, embedder=fake_embedder)
backend.converge()
assert "hetzner.md" in backend.idx.all_paths()
found, content = mcp_server._read_indexed_note(backend.idx, backend.repo, "hetzner.md")
assert found is True
assert content == (backend.repo / "hetzner.md").read_text(encoding="utf-8")
for bad in ("../../../etc/passwd", ".git/config", "/etc/hosts", "missing.md"):
f, c = mcp_server._read_indexed_note(backend.idx, backend.repo, bad)
assert f is False and c is None, f"out-of-vault read risk for {bad!r}"
backend.idx.close()


def test_tools_document_their_parameters(built_index, fake_embedder):
# TDQS Parameters dimension (was 0% schema coverage): every input parameter of every tool
# carries a description, so agents understand inputs without guessing. Includes the write
# tool (write-enabled master on loopback).
srv = mcp_server.build_server(built_index, host="127.0.0.1", embedder=fake_embedder,
write_enabled=True, repo=built_index.parent.parent)
for t in asyncio.run(srv.list_tools()):
props = (t.inputSchema or {}).get("properties", {})
assert props, f"{t.name} has no input properties"
for pname, pschema in props.items():
assert pschema.get("description"), f"{t.name}.{pname} lacks a parameter description"


def test_search_tool_returns_hits(built_index, fake_embedder):
assert mcp_server.build_server(built_index, host=TAILNET_IP, embedder=fake_embedder)
backend = mcp_server._Backend(built_index, embedder=fake_embedder)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_think.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ def test_think_mcp_tool_is_read_only_and_no_write_tool(make_corpus, fake_embedde
names = {t.name for t in tools}
assert "think" in names
assert names <= {
"search", "hypermnesic_search", "build_context", "think", "resolve", "list_folders"} # U3
"search", "hypermnesic_search", "build_context", "think", "resolve", "list_folders",
"read_note"} # U3
assert not any(kw in n for t in tools for n in [t.name]
for kw in ("write", "commit", "delete", "put", "update", "create"))
for t in tools:
Expand Down
Loading