From 6759ce705504d8d8b8914e386075b4eff0211b69 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Fri, 22 May 2026 23:09:57 -0300 Subject: [PATCH] Require identity for tee bulk updates --- resources/scripts/napseer_mcp_server.py | 54 +++++++++++++++------ tests/test_mcp_memory_ergonomics_helpers.py | 54 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 978e8b2..3d683fe 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -7784,9 +7784,27 @@ def resolve_existing_node_ref(args, view="render"): def upsert_node(args): - reject_agent_namespace_path(args["path"]) project_id = resolve_project_id(args) + if args.get("node_id") or args.get("id") or args.get("uri"): + existing = resolve_existing_node_ref(args) + updated = request_project_write( + "PATCH", + f"/v1/projects/{project_id}/nodes/{existing['id']}", + encrypt_node_payload_for_write(project_id, node_payload_from_args(args, existing=existing), existing=existing), + project_id, + f"update node {existing['full_path']}", + "node", + existing["id"], + ) + index_node(updated) + node = decrypt_node_for_return(updated, project_id=project_id) + return {"ok": True, "changed": True, "created": False, "updated": True, **node_write_reference(node)} + if not args.get("path"): + raise RuntimeError("path is required when creating a node; use node_id or uri to update an existing node") + reject_agent_namespace_path(args["path"]) existing = try_get_node_by_path(args["path"]) + if existing and not args.get("_internal_path_ref"): + raise RuntimeError("path already resolves to a node; use node_id or uri to update it") if existing: updated = request_project_write( "PATCH", @@ -7794,8 +7812,8 @@ def upsert_node(args): encrypt_node_payload_for_write(project_id, node_payload_from_args(args, existing=existing), existing=existing), project_id, f"update node {existing['full_path']}", - "path", - existing["full_path"], + "node", + existing["id"], ) index_node(updated) node = decrypt_node_for_return(updated, project_id=project_id) @@ -8628,7 +8646,7 @@ def create_kanban_card(args): "metadata": metadata, "content_text": args.get("content_text") or args.get("body") or "", } - result = upsert_node(payload) if as_bool(args.get("upsert"), False) else None + result = upsert_node({**payload, "_internal_path_ref": True}) if as_bool(args.get("upsert"), False) else None if result is None: project_id = resolve_project_id(args) created = request_project_write( @@ -9628,10 +9646,16 @@ def bulk_upsert_nodes(args): headers = lock_headers(lock) for item in nodes: path = item.get("path") - if not path: - raise RuntimeError("each node requires path") - reject_agent_namespace_path(path) - existing = try_get_node_by_path(path) + existing = None + if item.get("node_id") or item.get("id") or item.get("uri"): + existing = resolve_existing_node_ref(item) + else: + if not path: + raise RuntimeError("each new node requires path; updates require node_id or uri") + reject_agent_namespace_path(path) + existing = try_get_node_by_path(path) + if existing and not item.get("_internal_path_ref"): + raise RuntimeError(f"path already resolves to a node; use node_id or uri to update it: {normalize_node_path(path)}") payload = node_payload_from_args(item, existing=existing) payload = encrypt_node_payload_for_write(project_id, payload, existing=existing) if existing: @@ -11234,7 +11258,7 @@ def raw_tools(): }, { "name": "nap_bulk", - "description": "Create or update multiple path-addressed nodes using one auto-acquired project lock. Prefer this over raw REST writes when operating through the local wrapper.", + "description": "Create multiple route-addressed nodes or update existing nodes by node_id/uri using one auto-acquired project lock.", "inputSchema": { "type": "object", "required": ["nodes"], @@ -11245,9 +11269,10 @@ def raw_tools(): "type": "array", "items": { "type": "object", - "required": ["path"], "properties": { - "path": {"type": "string"}, + "path": {"type": "string", "description": "Route path for creating a new node. Existing-node updates require node_id or uri."}, + "node_id": {"type": "string", "description": "Canonical node UUID for updating an existing node."}, + "uri": {"type": "string", "description": "Canonical nap://project-name/project-id/node-id URI for updating an existing node."}, "content_text": {"type": "string"}, "type": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, @@ -11307,12 +11332,13 @@ def raw_tools(): }, { "name": "nap_tee", - "description": "Create or update a node by full path using an auto-acquired project lock. Use this for canonical task lists, current state, and durable project memory.", + "description": "Create a new route-addressed node by path, or update an existing node by canonical node_id or nap:// URI.", "inputSchema": { "type": "object", - "required": ["path"], "properties": { - "path": {"type": "string"}, + "path": {"type": "string", "description": "Route path for creating a new node. Existing-node updates require node_id or uri."}, + "node_id": {"type": "string", "description": "Canonical node UUID for updating an existing node."}, + "uri": {"type": "string", "description": "Canonical nap://project-name/project-id/node-id URI for updating an existing node."}, "content_text": {"type": "string"}, "type": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 443ac7b..6a89099 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -278,6 +278,18 @@ def test_tools_include_node_by_path_descriptor(mod): assert "uri" in schema["properties"] assert "path" not in schema["properties"] + tee_schema = next(item for item in mod.tools() if item["name"] == "nap_tee")["inputSchema"] + assert tee_schema.get("required") is None + assert "path" in tee_schema["properties"] + assert "node_id" in tee_schema["properties"] + assert "uri" in tee_schema["properties"] + + bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"] + assert bulk_node_schema.get("required") is None + assert "path" in bulk_node_schema["properties"] + assert "node_id" in bulk_node_schema["properties"] + assert "uri" in bulk_node_schema["properties"] + for name in [ "nap_kanban_start", "nap_kanban_send_review", @@ -329,6 +341,48 @@ def test_mutation_tools_reject_path_and_patch_by_node_id(mod): mod.update_node_by_path({"path": "/notes/current", "content_text": "bad"}) +def test_tee_and_bulk_create_by_path_update_by_identity(mod): + existing = node("/notes/current", "active", node_type="note") + existing["id"] = "node-123" + writes = [] + + mod.try_get_node_by_path = lambda path, allow_agent=False: existing if path == "/notes/current" else None + mod.get_node_by_id = lambda args: existing + mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or { + **existing, + "id": "node-123" if method == "PATCH" else "node-new", + "full_path": "/notes/new" if method == "POST" else existing["full_path"], + "content_text": payload.get("content_text", existing["content_text"]), + } + mod.index_node = lambda node: None + mod.acquire_project_lock = lambda args: {"id": "lock-1", "lease_token": "lease-1"} + mod.lock_headers = lambda lock: {"X-Lock": lock["id"]} + mod.release_project_lock = lambda args: None + mod.request_json = lambda method, path, payload=None, **kwargs: writes.append((method, path, payload, ())) or { + **existing, + "content_text": payload.get("content_text", existing["content_text"]) if payload else existing["content_text"], + } + + created = mod.upsert_node({"path": "/notes/new", "content_text": "new"}) + assert created["created"] is True + assert writes[-1][0] == "POST" + + updated = mod.upsert_node({"node_id": "node-123", "content_text": "updated"}) + assert updated["updated"] is True + assert writes[-1][0] == "PATCH" + assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123" + + with pytest.raises(RuntimeError, match="path already resolves"): + mod.upsert_node({"path": "/notes/current", "content_text": "bad"}) + + bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]}) + assert bulk["items"][0]["created"] is False + assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123" + + with pytest.raises(RuntimeError, match="path already resolves"): + mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]}) + + def test_discovery_rejects_full_content_view(mod): with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"): mod.list_project_nodes({"view": "full"})