diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 585cd72..66edbc1 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -9707,6 +9707,22 @@ def agent_node_args(args, key="path"): return scoped +def resolve_node_ref_allow_agent(args, *, agent_slug=None): + if args.get("path"): + raise RuntimeError("path is an index/route only; resolve it with nap_node_by_path, then use node_id or uri") + if args.get("uri"): + resolved = node_by_uri({"uri": args["uri"], "view": args.get("view", "render")})["node"] + elif args.get("node_id") or args.get("id"): + resolved = get_node_by_id({"node_id": args.get("node_id") or args.get("id"), "view": args.get("view", "render")}) + else: + raise RuntimeError("one of node_id or uri is required") + if agent_slug: + root = agent_root_path(agent_slug) + if not (resolved.get("full_path") or "").startswith(f"{root}/"): + raise RuntimeError(f"node is not inside /agents/{agent_slug}") + return resolved + + def list_agent_nodes(args): project_id = resolve_project_id(args) root = agent_root_path(agent_slug_from_args(args)) @@ -9727,23 +9743,32 @@ def list_agent_nodes(args): def get_agent_node(args): - return read_node_by_path({"path": agent_scoped_path(args)}, allow_agent=True) + slug = agent_slug_from_args(args) + return resolve_node_ref_allow_agent(args, agent_slug=slug) def upsert_agent_node(args): - scoped = agent_node_args(args) project_id = resolve_project_id(args) - existing = try_get_node_by_path(scoped["path"], allow_agent=True) - if existing: + slug = agent_slug_from_args(args) + if args.get("node_id") or args.get("id") or args.get("uri"): + existing = resolve_node_ref_allow_agent(args, agent_slug=slug) updated = request_project_write( "PATCH", f"/v1/projects/{project_id}/nodes/{existing['id']}", - node_payload_from_args(scoped, existing=existing), + node_payload_from_args(args, existing=existing), project_id, f"update agent node {existing['full_path']}", + "node", + existing["id"], ) index_node(updated) return {"ok": True, "changed": True, "created": False, "updated": True, **node_write_reference(updated)} + if not args.get("path"): + raise RuntimeError("path is required when creating an agent node; use node_id or uri to update an existing node") + scoped = agent_node_args(args) + existing = try_get_node_by_path(scoped["path"], allow_agent=True) + if existing: + raise RuntimeError("path already resolves to an agent node; use node_id or uri to update it") created = request_project_write( "POST", f"/v1/projects/{project_id}/nodes", @@ -9756,30 +9781,34 @@ def upsert_agent_node(args): def update_agent_node(args): - scoped = agent_node_args(args) + slug = agent_slug_from_args(args) project_id = resolve_project_id(args) - existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True) + existing = resolve_node_ref_allow_agent(args, agent_slug=slug) updated = request_project_write( "PATCH", f"/v1/projects/{project_id}/nodes/{existing['id']}", - node_payload_from_args(scoped, existing=existing), + node_payload_from_args(args, existing=existing), project_id, f"update agent node {existing['full_path']}", + "node", + existing["id"], ) index_node(updated) return {"ok": True, "changed": True, "updated": True, **node_write_reference(updated)} def archive_agent_node(args): - scoped = agent_node_args(args) + slug = agent_slug_from_args(args) project_id = resolve_project_id(args) - existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True) + existing = resolve_node_ref_allow_agent(args, agent_slug=slug) request_project_write( "DELETE", f"/v1/projects/{project_id}/nodes/{existing['id']}", None, project_id, f"archive agent node {existing['full_path']}", + "node", + existing["id"], ) remove_indexed_node(existing["id"]) return {"archived": True, "path": existing["full_path"], "id": existing["id"]} @@ -9788,11 +9817,10 @@ def archive_agent_node(args): def link_agent_node(args): slug = agent_slug_from_args(args) project_id = resolve_project_id(args) - source_path = agent_scoped_path({"agent_slug": slug, "path": args["source_path"]}) - target_raw = str(args.get("target_path") or "").strip() - target_path = agent_scoped_path({"agent_slug": slug, "path": target_raw}) if is_agent_namespace_path(target_raw) or not target_raw.startswith("/") else normalize_node_path(target_raw) - source = read_node_by_path({"path": source_path}, allow_agent=True) - target = read_node_by_path({"path": target_path}, allow_agent=is_agent_namespace_path(target_path)) + if args.get("source_path") or args.get("target_path"): + raise RuntimeError("paths are index/routes only; use source_node_id/target_node_id or source_uri/target_uri") + source = resolve_node_ref_allow_agent({"node_id": args.get("source_node_id"), "uri": args.get("source_uri")}, agent_slug=slug) + target = resolve_node_ref_allow_agent({"node_id": args.get("target_node_id"), "uri": args.get("target_uri")}) relation = args.get("relation", "references") links = source.get("links") or [] if not isinstance(links, list): @@ -9811,6 +9839,8 @@ def link_agent_node(args): {"links": links}, project_id, f"link agent node {source['full_path']}", + "node", + source["id"], ) index_node(updated) else: @@ -11640,23 +11670,29 @@ def raw_tools(): }, { "name": "nap_agent_cat", - "description": "Read one file inside /agents/{agent_slug}. Path may be relative to the agent root.", + "description": "Read one existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.", "inputSchema": { "type": "object", - "required": ["agent_slug", "path"], - "properties": {"agent_slug": {"type": "string"}, "path": {"type": "string"}}, + "required": ["agent_slug"], + "properties": { + "agent_slug": {"type": "string"}, + "node_id": {"type": "string"}, + "uri": {"type": "string"}, + }, "additionalProperties": False, }, }, { "name": "nap_agent_tee", - "description": "Create or update a file inside /agents/{agent_slug} using an auto-acquired project lock. Use this for agent profile, role, personality, rules, and memory.", + "description": "Create a route-addressed node inside /agents/{agent_slug}, or update an existing agent node by canonical node_id or nap:// URI.", "inputSchema": { "type": "object", - "required": ["agent_slug", "path"], + "required": ["agent_slug"], "properties": { "agent_slug": {"type": "string"}, - "path": {"type": "string"}, + "path": {"type": "string", "description": "Route path for creating a new agent node. Existing-node updates require node_id or uri."}, + "node_id": {"type": "string"}, + "uri": {"type": "string"}, "content_text": {"type": "string"}, "type": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, @@ -11669,13 +11705,14 @@ def raw_tools(): }, { "name": "nap_agent_patch", - "description": "Update an existing file inside /agents/{agent_slug}.", + "description": "Update an existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.", "inputSchema": { "type": "object", - "required": ["agent_slug", "path"], + "required": ["agent_slug"], "properties": { "agent_slug": {"type": "string"}, - "path": {"type": "string"}, + "node_id": {"type": "string"}, + "uri": {"type": "string"}, "content_text": {"type": "string"}, "type": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, @@ -11688,24 +11725,30 @@ def raw_tools(): }, { "name": "nap_agent_rm", - "description": "Archive a file inside /agents/{agent_slug}.", + "description": "Archive an existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.", "inputSchema": { "type": "object", - "required": ["agent_slug", "path"], - "properties": {"agent_slug": {"type": "string"}, "path": {"type": "string"}}, + "required": ["agent_slug"], + "properties": { + "agent_slug": {"type": "string"}, + "node_id": {"type": "string"}, + "uri": {"type": "string"}, + }, "additionalProperties": False, }, }, { "name": "nap_agent_ln", - "description": "Create a graph link from an agent file to another node. Agent source is scoped by agent_slug.", + "description": "Create a graph link from an existing agent node to another existing node by canonical node identities.", "inputSchema": { "type": "object", - "required": ["agent_slug", "source_path", "target_path"], + "required": ["agent_slug"], "properties": { "agent_slug": {"type": "string"}, - "source_path": {"type": "string"}, - "target_path": {"type": "string"}, + "source_node_id": {"type": "string"}, + "source_uri": {"type": "string"}, + "target_node_id": {"type": "string"}, + "target_uri": {"type": "string"}, "relation": {"type": "string", "default": "references"}, }, "additionalProperties": False, diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 32507d3..00edffa 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -348,6 +348,26 @@ def test_tools_include_node_by_path_descriptor(mod): assert "plan_uri" in plan_to_kanban_schema["properties"] assert "plan_path" not in plan_to_kanban_schema["properties"] + for name in ["nap_agent_cat", "nap_agent_patch", "nap_agent_rm"]: + schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"] + assert schema["required"] == ["agent_slug"] + assert "node_id" in schema["properties"] + assert "uri" in schema["properties"] + assert "path" not in schema["properties"] + + agent_tee_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_tee")["inputSchema"] + assert agent_tee_schema["required"] == ["agent_slug"] + assert "path" in agent_tee_schema["properties"] + assert "node_id" in agent_tee_schema["properties"] + assert "uri" in agent_tee_schema["properties"] + + agent_ln_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_ln")["inputSchema"] + assert agent_ln_schema["required"] == ["agent_slug"] + assert "source_node_id" in agent_ln_schema["properties"] + assert "target_node_id" in agent_ln_schema["properties"] + assert "source_path" not in agent_ln_schema["properties"] + assert "target_path" not in agent_ln_schema["properties"] + def test_mutation_tools_reject_path_and_patch_by_node_id(mod): found = node("/notes/current", "active", node_type="note") @@ -485,6 +505,67 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod): mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]}) +def test_agent_tools_create_by_route_and_update_by_identity(mod): + agent_file = node("/agents/alice/memory/current", "active", node_type="agent-file") + agent_file["id"] = "agent-node-123" + target = node("/notes/target", "active", node_type="note") + target["id"] = "target-123" + writes = [] + + def fake_get_node_by_id(args): + node_id = args.get("node_id") + if node_id == "agent-node-123": + return agent_file + if node_id == "target-123": + return target + raise AssertionError(f"unexpected node_id {node_id}") + + mod.get_node_by_id = fake_get_node_by_id + mod.try_get_node_by_path = lambda path, allow_agent=False: agent_file if path == "/agents/alice/memory/current" else None + mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or { + **agent_file, + "id": "agent-node-new" if method == "POST" else agent_file["id"], + "full_path": f"{payload.get('folder_path')}/{payload.get('name')}" if method == "POST" else agent_file["full_path"], + "content_text": payload.get("content_text", agent_file["content_text"]) if payload else agent_file["content_text"], + "links": payload.get("links", agent_file["links"]) if payload else agent_file["links"], + } + mod.index_node = lambda node: None + mod.remove_indexed_node = lambda node_id: None + + read = mod.get_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"}) + assert read["id"] == "agent-node-123" + + created = mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/new", "content_text": "new"}) + assert created["created"] is True + assert writes[-1][0] == "POST" + assert writes[-1][2]["folder_path"] == "/agents/alice/memory" + + updated = mod.upsert_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "updated"}) + assert updated["updated"] is True + assert writes[-1][1] == "/v1/projects/project-1/nodes/agent-node-123" + assert writes[-1][3][2:] == ("node", "agent-node-123") + + patched = mod.update_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "patched"}) + assert patched["updated"] is True + + archived = mod.archive_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"}) + assert archived["archived"] is True + assert writes[-1][0] == "DELETE" + assert writes[-1][3][2:] == ("node", "agent-node-123") + + linked = mod.link_agent_node({"agent_slug": "alice", "source_node_id": "agent-node-123", "target_node_id": "target-123"}) + assert linked["created"] is True + assert writes[-1][2]["links"] == [{"path": "/notes/target", "relation": "references"}] + assert writes[-1][3][2:] == ("node", "agent-node-123") + + with pytest.raises(RuntimeError, match="path already resolves"): + mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"}) + with pytest.raises(RuntimeError, match="path is an index/route only"): + mod.update_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"}) + with pytest.raises(RuntimeError, match="paths are index/routes only"): + mod.link_agent_node({"agent_slug": "alice", "source_path": "/memory/current", "target_path": "/notes/target"}) + + 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"})