diff --git a/CHANGELOG.md b/CHANGELOG.md index deec7f0..21371a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index ac3619e..c8b9790 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -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. @@ -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)` diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..096b023 --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["leonardsellem"] +} diff --git a/src/hypermnesic/mcp_server.py b/src/hypermnesic/mcp_server.py index 89bbb96..3b01402 100644 --- a/src/hypermnesic/mcp_server.py +++ b/src/hypermnesic/mcp_server.py @@ -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 @@ -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 @@ -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, @@ -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), @@ -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, @@ -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 @@ -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, @@ -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: @@ -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 @@ -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 @@ -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) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b0b5106..0e01fef 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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] @@ -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 @@ -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"]) @@ -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) diff --git a/tests/test_think.py b/tests/test_think.py index 83d12f2..e53e8d0 100644 --- a/tests/test_think.py +++ b/tests/test_think.py @@ -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: