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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
gains `basic-memory-diagnostics(3)`, closing the one gap between the section-3
corpus and the tool registry.

- Notes are readable as MCP resources. Every `memory://` URL Basic Memory hands out
now answers the standard `resources/read`: `memory://<project>/<path>` returns the
note's raw markdown, frontmatter included, with the identifier accepted as a
permalink, title, or file path. Unknown notes point at `search_notes`; binary files
point at `read_content`. `memory://man/...` keeps answering as the manual and
`memory://<workspace>/<project>/info` as project info, whichever template the
server matches first.

- **#610**: The manual ships in the package and is served over MCP. The 21 section-3
pages (one per MCP tool -- `search-notes(3)`, `write-note(3)`, ...) now live in
`src/basic_memory/man/man3/` as canonical, portable notes. The MCP server exposes
Expand All @@ -37,6 +45,13 @@

### Bug Fixes

- The `memory://{workspace}/{project}/info` resource is now actually readable over
`resources/read`: it returned a Pydantic model, which the resource runtime rejects
(`contents must be str, bytes, or list[ResourceContent]`), so every served read
failed. It returns the validated response as JSON text now. Found by the new
note-resource tests, which read through a real client session instead of calling
the handler directly.

- **#1344**: Deleting a note no longer erases the relations pointing at it. The
`relation.to_id` foreign key is now `ON DELETE SET NULL` rather than
`ON DELETE CASCADE`, and `Entity.incoming_relations` no longer cascades deletes
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/mcp/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Bundled MCP resources for Basic Memory."""

from basic_memory.mcp.resources.man import manual_index, manual_page
from basic_memory.mcp.resources.notes import note_resource
from basic_memory.mcp.resources.project_info import project_info

__all__ = ["manual_index", "manual_page", "project_info"]
__all__ = ["manual_index", "manual_page", "note_resource", "project_info"]
28 changes: 22 additions & 6 deletions src/basic_memory/mcp/resources/man.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
``search_notes`` — so an agent's first guess resolves.
"""

from fastmcp import Context
from fastmcp.exceptions import ResourceError
from fastmcp.resources import FileResource
from pydantic import AnyUrl
Expand Down Expand Up @@ -41,17 +42,32 @@ async def manual_index() -> str:
),
mime_type="text/markdown",
)
def manual_page(ref: str) -> str:
async def manual_page(ref: str, context: Context | None = None) -> str:
try:
page_ref = parse_page_ref(ref)
except ValueError as error:
raise ResourceError(f"{error}; read {MANUAL_INDEX_URI} for the index") from error
page = find_page(page_ref)
if page is None:
raise ResourceError(
page = None
miss = ResourceError(f"{error}; read {MANUAL_INDEX_URI} for the index")
else:
page = find_page(page_ref)
miss = ResourceError(
f"No manual entry for {page_ref.display}; read {MANUAL_INDEX_URI} for the index"
)
return page.read()
if page is not None:
return page.read()

# This template registers first and wins ties for memory://man/... over the
# notes template, and nothing reserves `man` as a project name — so when no
# page matches, the URI may be a note in a project really named man.
# Deferred import: notes.py imports this module.
from basic_memory.mcp.resources.notes import NoteNotFoundError, read_note_markdown

try:
return await read_note_markdown(f"man/{ref}", context)
Comment thread
phernandez marked this conversation as resolved.
except NoteNotFoundError:
# Neither a page nor a note — the manual's hint is the useful one; an
# operational note failure keeps its own cause instead.
raise miss from None


# Concrete resources are what clients list; the template only answers reads.
Expand Down
141 changes: 141 additions & 0 deletions src/basic_memory/mcp/resources/notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Notes as MCP resources.

Basic Memory hands out ``memory://`` URLs everywhere — pages, prompts, handoffs,
conversation summaries — so reading one through the standard MCP
``resources/read`` must work too. ``memory://{project}/{path*}`` returns the
note's raw markdown, exactly as it sits on disk, frontmatter included.
"""

from fastmcp import Context
from fastmcp.exceptions import ResourceError, ToolError

from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
from basic_memory.mcp.resources.man import manual_page
from basic_memory.mcp.resources.project_info import project_info
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.utils import generate_permalink

NOTE_TEMPLATE = "memory://{project}/{path*}"
Comment thread
phernandez marked this conversation as resolved.


class NoteNotFoundError(ResourceError):
"""The identifier resolved to no note — distinct from operational failures.

Fallback dispatchers (the manual namespace, the /info shape) may only swap
in their own error when the note is confirmed missing; auth, server, and
transport failures must keep their cause.
"""


async def _route_for(identifier: str, context: Context | None) -> str | None:
"""The project route the URI's prefix names, or None for the default client.

The canonical prefix detection decides, covering configured local projects
and workspace-qualified cloud routes alike: the client must be opened for
the URI's own project, because a cloud project needs its own transport.

One refinement: with permalinks_include_project=False a *local* project
match is a directory collision — the active project owns unprefixed
permalinks — so it is dropped. Workspace-qualified cloud routes keep their
workspace/project segments regardless of that flag, so they still route.
"""
config = ConfigManager().config
route = await detect_project_from_memory_url_prefix(
f"memory://{identifier}", config, context=context
)
if route is None or config.permalinks_include_project:
return route
requested = generate_permalink(route)
for configured_name in config.projects:
if generate_permalink(configured_name) == requested:
return None
return route


async def read_note_markdown(identifier: str, context: Context | None) -> str:
"""Read one note's raw markdown by its memory:// identifier.

Routing uses the same semantics as the tools: a leading segment that names a
configured project routes there (with that project's own client — cloud or
local); otherwise — legacy unprefixed permalinks,
permalinks_include_project=False — resolve_project_and_path resolves the
whole path in the active/default project.
"""
try:
route = await _route_for(identifier, context)
async with get_project_client(route, context) as (client, active_project):
target, entity_path, _ = await resolve_project_and_path(
client, f"memory://{identifier}", active_project.name, context
)
Comment thread
phernandez marked this conversation as resolved.
# strict: a resource read returns the addressed document or an error —
# never the fuzzy-search guess the tools use for suggestions. Only this
# call's not-found is a confirmed note miss; a 'Project not found' from
# routing above must surface as the route failure it is.
try:
resolved = await call_post(
client,
f"/v2/projects/{target.external_id}/knowledge/resolve",
json={"identifier": entity_path, "strict": True},
)
except ToolError as error:
if "not found" in str(error).lower():
raise NoteNotFoundError(
f"No note {identifier!r}; search_notes can find the identifier"
) from error
raise
entity_id = resolved.json()["external_id"]
response = await call_get(
client, f"/v2/projects/{target.external_id}/resource/{entity_id}"
)
except (ValueError, RuntimeError) as error:
# Routing failed before any read happened (a constrained or unresolvable
# route, or the cloud workspace index consulted without credentials).
raise ResourceError(str(error)) from error
except ToolError as error:
# Routing and content-read failures (a stale project route, auth, server,
# transport) keep their actionable cause; the confirmed note miss is
# mapped where the entity resolver answers, above.
raise ResourceError(str(error)) from error

content_type = response.headers.get("content-type", "")
# Only text comes back byte-exact; steer binaries to the tool built for them.
if not (content_type.startswith("text/") or content_type == "application/json"):
raise ResourceError(
f"{identifier!r} is {content_type or 'binary'}; use the read_content tool "
"for non-text files"
)
return response.text


@mcp.resource(
uri=NOTE_TEMPLATE,
name="note",
description=(
"A note's raw markdown, addressed by its memory:// URL — "
"memory://<project>/<identifier>, e.g. memory://research/specs/search-design. "
"The identifier may be a permalink, a title, or a file path in the project."
),
mime_type="text/markdown",
)
async def note_resource(project: str, path: str, context: Context | None = None) -> str:
"""Return the raw markdown of one note."""
# `man` is the manual's namespace; its template registers first and wins the
# tie, and manual_page itself falls back to a note in a project really named
# man — delegating keeps both templates' answers identical either way.
if project == "man":
Comment thread
phernandez marked this conversation as resolved.
return await manual_page(path, context)

# The {workspace}/{project}/info shape belongs to the project_info resource,
# which itself falls back to a note named .../info — delegating keeps both
# handlers' answers identical whichever template wins the tie.
head, _, tail = path.rpartition("/")
if tail == "info" and head and "/" not in head:
return await project_info(workspace=project, project=head, context=context)

return await read_note_markdown(f"{project}/{path}", context)
43 changes: 38 additions & 5 deletions src/basic_memory/mcp/resources/project_info.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Project info resource for Basic Memory MCP server."""

from fastmcp import Context
from fastmcp.exceptions import ResourceError, ToolError
from loguru import logger

from basic_memory.config import ConfigManager, ProjectMode
Expand All @@ -19,7 +20,7 @@ async def project_info(
workspace: str,
project: str,
context: Context | None = None,
) -> ProjectInfoResponse:
) -> str:
"""Get comprehensive information about a workspace-qualified Basic Memory project.

This resource provides detailed statistics and status information about a
Expand All @@ -38,7 +39,8 @@ async def project_info(
context: Optional FastMCP context for performance caching.

Returns:
Detailed project information and statistics.
Detailed project information and statistics as a JSON document —
resources carry text, so the validated response is serialized here.
"""
logger.info("Getting project info")

Expand All @@ -57,6 +59,37 @@ async def project_info(
):
project_route = configured_project

async with get_project_client(project_route, context) as (client, active_project):
response = await call_get(client, f"/v2/projects/{active_project.external_id}/info")
return ProjectInfoResponse.model_validate(response.json())
try:
async with get_project_client(project_route, context) as (client, active_project):
response = await call_get(client, f"/v2/projects/{active_project.external_id}/info")
try:
info = ProjectInfoResponse.model_validate(response.json())
except ValueError as payload_error:
# A reachable route answered with an incompatible payload — a backend
# fault to surface, never a cue for the outer handler to serve a note.
raise ResourceError(
f"Project info for '{project_route}' returned an invalid payload: "
f"{payload_error}"
) from payload_error
return info.model_dump_json(indent=2)
Comment thread
phernandez marked this conversation as resolved.
except (ValueError, RuntimeError, ToolError) as error:
# Trigger: forced-local transports surface an unknown compound route as a
# ToolError rather than ValueError/RuntimeError.
# Why: only a missing project route may fall back to a note; auth, server,
# and transport failures on a real route must keep their cause.
# Outcome: route misses continue into the fallback; other ToolErrors raise.
if isinstance(error, ToolError) and "not found" not in str(error).lower():
raise
# This template also wins ties for {project}/{directory}/info note URIs
# (precedence between overlapping template matches is undefined), so a
# failed workspace/project route may really be a note whose canonical
# permalink ends in /info. Deferred import: notes.py imports this module.
from basic_memory.mcp.resources.notes import NoteNotFoundError, read_note_markdown

try:
return await read_note_markdown(f"{workspace}/{project}/info", context)
except NoteNotFoundError:
# Neither a project route nor a note — the route error is the cause.
# An operational note failure (auth, server, transport) propagates
# with its own cause instead.
raise ResourceError(str(error)) from error
Comment thread
phernandez marked this conversation as resolved.
4 changes: 3 additions & 1 deletion src/basic_memory/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ async def lifespan(app: FastMCP):
"For a fuller guide, read the `memory://ai_assistant_guide` resource. The manual has a "
"page for nearly every tool, with verified examples and gotchas: `memory://man` lists "
"them, and `memory://man/<tool>(3)` (for example `memory://man/search-notes(3)`) is one "
"page — read it before using a tool for the first time. If you have a web or fetch tool "
"page — read it before using a tool for the first time. Any note is readable the same "
"way: its memory://<project>/<path> URL is a resource returning the raw markdown. If "
"you have a web or fetch tool "
"and need current "
"documentation, fetch `https://docs.basicmemory.com/llms.txt` first, then fetch only the "
"relevant linked `/raw/...md` page."
Expand Down
86 changes: 86 additions & 0 deletions test-int/mcp/test_resources_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Integration tests for MCP resources: the manual and notes over resources/read.

Full flow, no mocks: MCP Client → MCP Server → FastAPI (ASGI) → database. This is
what an actual MCP client does with the `memory://` URIs Basic Memory hands out.
"""

from typing import Any

import pytest
from fastmcp import Client

# The mcp_server fixture registers tools, resources, and prompts.


async def read_text(client: Client[Any], uri: str) -> str:
contents = await client.read_resource(uri)
text = getattr(contents[0], "text", None)
assert isinstance(text, str)
return text


@pytest.mark.asyncio
async def test_manual_resources_are_listed_and_readable(mcp_server, app):
"""The manual index and pages answer resources/list and resources/read."""
async with Client(mcp_server) as client:
listed = {str(resource.uri) for resource in await client.list_resources()}
assert "memory://man" in listed
assert "memory://man/search-notes(3)" in listed

index = await read_text(client, "memory://man")
assert index.startswith("# Basic Memory manual")

# Any common spelling of a page resolves through the template.
page = await read_text(client, "memory://man/search-notes(3)")
by_tool_name = await read_text(client, "memory://man/search_notes")
assert page.startswith("---\ntitle: search-notes(3)\n")
assert by_tool_name == page


@pytest.mark.asyncio
async def test_note_is_readable_at_its_memory_uri(mcp_server, app, test_project):
"""A note written through the tools reads back as raw markdown via its URI."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Search Design",
"directory": "specs",
"content": (
"# Search Design\n\n"
"- [decision] notes answer resources/read #mcp\n"
"- relates_to [[Indexing]]\n"
),
},
)

# Project-prefixed canonical URI.
text = await read_text(client, f"memory://{test_project.name}/specs/search-design")
assert text.startswith("---\n") # raw file: frontmatter included
assert "- [decision] notes answer resources/read #mcp" in text

# Unprefixed spelling: the first segment is a directory, not a project,
# so routing falls back to the active/default project.
unprefixed = await read_text(client, "memory://specs/search-design")
assert unprefixed == text

# File-path spelling.
by_path = await read_text(client, f"memory://{test_project.name}/specs/search-design.md")
assert by_path == text


@pytest.mark.asyncio
async def test_project_info_uri_reads_over_the_wire(mcp_server, app, test_project):
"""The workspace/project/info template serves JSON stats through a real session."""
async with Client(mcp_server) as client:
info = await read_text(client, f"memory://local/{test_project.permalink}/info")
assert test_project.name in info


@pytest.mark.asyncio
async def test_unknown_note_reports_a_missing_note(mcp_server, app, test_project):
"""A miss surfaces as an error naming the note, not a fuzzy match or silence."""
async with Client(mcp_server) as client:
with pytest.raises(Exception, match="No note"):
await client.read_resource(f"memory://{test_project.name}/nope/does-not-exist")
Loading
Loading