-
Notifications
You must be signed in to change notification settings - Fork 269
feat(mcp): serve notes as MCP resources via their memory:// URLs #1394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
30258d9
fix(mcp): make the project info resource readable over resources/read
phernandez 952a626
feat(mcp): serve notes as resources at memory://{project}/{path*}
phernandez d33216e
fix(mcp): route note resources like the tools and keep real error causes
phernandez 7fc1cb7
fix(mcp): open the note resource client for the URI's own project
phernandez c1d6157
fix(mcp): honor unprefixed-permalink config and keep fallback error c…
phernandez c860315
fix(mcp): only the entity resolver's miss reads as a missing note
phernandez fab7c77
fix(mcp): detect workspace-qualified routes before opening the note c…
phernandez f9efe5a
test(mcp): exercise the manual and note resources end to end
phernandez 77e71f5
fix(mcp): keep workspace routes when project prefixes are disabled
phernandez 0e1b1d7
fix(mcp): surface invalid project-info payloads instead of note fallback
phernandez e7ec6d4
fix(mcp): run the info-note fallback on forced-local route misses
phernandez 7312652
fix(mcp): move the man-project note fallback into the winning template
phernandez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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*}" | ||
|
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 | ||
| ) | ||
|
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": | ||
|
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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.