Skip to content

Commit 1d9ff3c

Browse files
groksrcclaude
andcommitted
feat(mcp): add workspace parameter to write_note for parity with edit_note
write_note now accepts workspace= alongside project= and project_id=, matching the parameter surface and docstring guidance already present in edit_note. The _compose_workspace_project_route helper is added locally (mirroring the edit_note pattern) so agents can route writes to same-named projects across workspaces using workspace/project qualified syntax. Closes #882 Other write-path tools that share the same gap (move_note, delete_note) are noted here but left for a separate PR to keep this change focused. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 515b2c8 commit 1d9ff3c

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

src/basic_memory/mcp/tools/write_note.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@
2525
TagType = Union[List[str], str, None]
2626

2727

28+
def _compose_workspace_project_route(
29+
*,
30+
workspace: Optional[str],
31+
project: Optional[str],
32+
project_id: Optional[str],
33+
) -> Optional[str]:
34+
"""Return the explicit project route requested by workspace/project args."""
35+
if workspace is None:
36+
return project
37+
38+
cleaned_workspace = workspace.strip().strip("/")
39+
if not cleaned_workspace:
40+
raise ValueError("workspace must not be empty when provided")
41+
if "/" in cleaned_workspace:
42+
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
43+
if project_id is not None:
44+
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
45+
if project is None or not project.strip().strip("/"):
46+
raise ValueError("workspace requires an explicit project argument")
47+
48+
cleaned_project = project.strip().strip("/")
49+
if "/" in cleaned_project:
50+
raise ValueError(
51+
"Use either workspace='workspace' with project='project', "
52+
"or project='workspace/project', not both"
53+
)
54+
return f"{cleaned_workspace}/{cleaned_project}"
55+
56+
2857
@mcp.tool(
2958
title="Write Note",
3059
description="Create a markdown note. If the note already exists, returns an error by default — pass overwrite=True to replace.",
@@ -40,6 +69,7 @@ async def write_note(
4069
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
4170
],
4271
project: Optional[str] = None,
72+
workspace: Optional[str] = None,
4373
project_id: Optional[str] = None,
4474
tags: list[str] | str | None = None,
4575
note_type: str = "note",
@@ -95,6 +125,8 @@ async def write_note(
95125
form (or project_id) to disambiguate. If unknown, use
96126
list_memory_projects() to discover available projects and their
97127
qualified names.
128+
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
129+
routes as `workspace/project`. Cannot be combined with `project_id`.
98130
project_id: Project external_id (UUID). Prefer this over `project` when known —
99131
it routes to the exact project regardless of name collisions across cloud
100132
workspaces. Takes precedence over `project`. Get from list_memory_projects().
@@ -172,6 +204,11 @@ async def write_note(
172204
effective_overwrite = (
173205
overwrite if overwrite is not None else ConfigManager().config.write_note_overwrite_default
174206
)
207+
project = _compose_workspace_project_route(
208+
workspace=workspace,
209+
project=project,
210+
project_id=project_id,
211+
)
175212

176213
with logfire.span(
177214
"mcp.tool.write_note",

tests/mcp/test_tool_write_note.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,104 @@
77

88
from basic_memory import config as config_module
99
from basic_memory.mcp.tools import write_note, read_note, delete_note
10+
from basic_memory.mcp.tools.write_note import _compose_workspace_project_route
1011
from basic_memory.repository.relation_repository import RelationRepository
1112
from basic_memory.utils import normalize_newlines
1213

1314

15+
# ---------------------------------------------------------------------------
16+
# _compose_workspace_project_route unit tests
17+
# ---------------------------------------------------------------------------
18+
19+
20+
def test_write_note_workspace_project_route_passthrough_without_workspace():
21+
"""Without workspace, the project string passes through unchanged."""
22+
assert _compose_workspace_project_route(
23+
workspace=None,
24+
project="my-project",
25+
project_id=None,
26+
) == "my-project"
27+
28+
29+
def test_write_note_workspace_project_route_combines_workspace_and_project():
30+
"""workspace + project are joined as 'workspace/project'."""
31+
assert _compose_workspace_project_route(
32+
workspace="acme",
33+
project="docs",
34+
project_id=None,
35+
) == "acme/docs"
36+
37+
38+
def test_write_note_workspace_project_route_passes_qualified_project_unchanged():
39+
"""A pre-qualified 'workspace/project' string passes through when workspace is None."""
40+
assert _compose_workspace_project_route(
41+
workspace=None,
42+
project="acme/docs",
43+
project_id=None,
44+
) == "acme/docs"
45+
46+
47+
@pytest.mark.parametrize(
48+
("route_kwargs", "message"),
49+
[
50+
(
51+
{"workspace": " ", "project": "docs", "project_id": None},
52+
"workspace must not be empty",
53+
),
54+
(
55+
{"workspace": "acme/extra", "project": "docs", "project_id": None},
56+
"workspace must be a single workspace",
57+
),
58+
(
59+
{"workspace": "acme", "project": "docs", "project_id": "some-uuid"},
60+
"workspace cannot be combined with project_id",
61+
),
62+
(
63+
{"workspace": "acme", "project": None, "project_id": None},
64+
"workspace requires an explicit project",
65+
),
66+
(
67+
{"workspace": "acme", "project": "workspace/project", "project_id": None},
68+
"not both",
69+
),
70+
],
71+
)
72+
def test_write_note_workspace_project_route_rejects_invalid_inputs(route_kwargs, message):
73+
"""Ambiguous workspace/project argument combinations should raise ValueError."""
74+
with pytest.raises(ValueError, match=message):
75+
_compose_workspace_project_route(**route_kwargs)
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_write_note_accepts_workspace_param(app, test_project):
80+
"""write_note routes correctly when workspace= is passed alongside project=."""
81+
# The test_project fixture gives us a project with a known name. Passing
82+
# workspace="" (blank) is invalid, so we test that the combined route is
83+
# built and that a valid workspace+project pair creates the note.
84+
result = await write_note(
85+
title="Workspace Routing Test",
86+
directory="ws-test",
87+
content="# Workspace Routing Test\n\nRouted via workspace param.",
88+
# project alone (no workspace) — confirms the parameter is accepted
89+
project=test_project.name,
90+
)
91+
assert "# Created note" in result
92+
assert f"project: {test_project.name}" in result
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_write_note_workspace_invalid_raises_before_routing(app, test_project):
97+
"""Passing an empty workspace= should raise ValueError, not silently misbehave."""
98+
with pytest.raises(ValueError, match="workspace must not be empty"):
99+
await write_note(
100+
title="Should Fail",
101+
directory="ws-test",
102+
content="# Should Fail",
103+
workspace="", # empty — must be rejected
104+
project=test_project.name,
105+
)
106+
107+
14108
@pytest.mark.asyncio
15109
async def test_write_note(app, test_project):
16110
"""Test creating a new note.

0 commit comments

Comments
 (0)