From 031b888fdd398416f28a81a5bed4b45b4fafb01b Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Fri, 22 May 2026 23:39:59 -0300 Subject: [PATCH] Use node identity in local helper UI --- resources/scripts/napseer_mcp_server.py | 73 ++++++++++++++------- tests/test_mcp_memory_ergonomics_helpers.py | 12 ++++ 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index e422e6b..b72ca08 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -10041,7 +10041,7 @@ def ui_tree_insert(root, node): cursor.setdefault("nodes", []).append(node) -def ui_render_tree_branch(branch, selected_path, mode, search, prefix=""): +def ui_render_tree_branch(branch, selected_path, selected_node_id, mode, search, prefix=""): folders = branch.get("folders", {}) nodes = branch.get("nodes", []) output = [] @@ -10052,14 +10052,15 @@ def ui_render_tree_branch(branch, selected_path, mode, search, prefix=""): output.append( f"
" f"dir{html.escape(name)}" - f"{ui_render_tree_branch(child, selected_path, mode, search, folder_path)}" + f"{ui_render_tree_branch(child, selected_path, selected_node_id, mode, search, folder_path)}" "
" ) for node in sort_nodes_by_recency(nodes): full_path = node.get("full_path") or "" + node_id = node.get("id") or "" name = full_path.rstrip("/").split("/")[-1] or full_path - params = urllib.parse.urlencode({"mode": mode, "q": search, "path": full_path}) - active = " active" if full_path == selected_path else "" + params = urllib.parse.urlencode({"mode": mode, "q": search, "node_id": node_id}) + active = " active" if node_id and node_id == selected_node_id else "" output.append( f"node" f"{html.escape(name)}{html.escape(node.get('type') or 'note')}" @@ -10241,6 +10242,7 @@ def start_local_ui(args): "feedback": [], } search = query.get("q", [""])[0] + selected_node_id = query.get("node_id", [""])[0] selected_path = query.get("path", [""])[0] nodes_query = {"limit": 200} if search: @@ -10257,14 +10259,21 @@ def start_local_ui(args): agent_nodes = [node for node in all_nodes if is_agent_namespace_path(node.get("full_path") or "")] if is_agent_namespace_path(selected_path): selected_path = "" - if not selected_path and nodes: - selected_path = nodes[0].get("full_path") or "" - selected_node = None - if selected_path: + if not selected_node_id and selected_path: try: - selected_node = read_node_by_path({"path": selected_path}) + selected_node_id = (node_by_path({"path": selected_path}).get("identity") or {}).get("node_id") or "" + except Exception: + selected_node_id = "" + if not selected_node_id and nodes: + selected_node_id = nodes[0].get("id") or "" + selected_node = None + if selected_node_id: + try: + selected_node = get_node_by_id({"node_id": selected_node_id}) + selected_path = selected_node.get("full_path") or "" except Exception: selected_node = None + selected_path = "" schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", []) try: feedback = request_json("GET", "/v1/feedback?limit=50").get("items", []) @@ -10320,6 +10329,7 @@ def start_local_ui(args): "archived_nodes": archived_nodes, "agent_nodes": agent_nodes, "connected_agents": connected_agents, + "selected_node_id": selected_node_id, "selected_path": selected_path, "selected_node": selected_node, "schedules": schedules, @@ -10453,6 +10463,7 @@ def start_local_ui(args): mode = query.get("mode", ["filesystem"])[0] if mode not in {"filesystem", "path-list"}: mode = "filesystem" + selected_node_id = query.get("node_id", [""])[0] selected_path = query.get("path", [""])[0] nodes_query = {"limit": 200} if search: @@ -10460,32 +10471,40 @@ def start_local_ui(args): nodes_suffix = rest_query_string(nodes_query) nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", []) nodes = sort_nodes_by_recency(nodes) - if not selected_path and nodes: - selected_path = nodes[0].get("full_path") or "" - selected_node = None - if selected_path: + if not selected_node_id and selected_path: try: - selected_node = read_node_by_path({"path": selected_path}) + selected_node_id = (node_by_path({"path": selected_path}).get("identity") or {}).get("node_id") or "" + except Exception: + selected_node_id = "" + if not selected_node_id and nodes: + selected_node_id = nodes[0].get("id") or "" + selected_node = None + if selected_node_id: + try: + selected_node = get_node_by_id({"node_id": selected_node_id}) + selected_path = selected_node.get("full_path") or "" except Exception: selected_node = None + selected_path = "" schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", []) feedback = request_json("GET", "/v1/feedback?limit=50").get("items", []) status = update_status() current_url = "/" + (f"?{parsed_path.query}" if parsed_path.query else "") - filesystem_url = "/?" + urllib.parse.urlencode({"mode": "filesystem", "q": search, "path": selected_path}) - path_list_url = "/?" + urllib.parse.urlencode({"mode": "path-list", "q": search, "path": selected_path}) + filesystem_url = "/?" + urllib.parse.urlencode({"mode": "filesystem", "q": search, "node_id": selected_node_id}) + path_list_url = "/?" + urllib.parse.urlencode({"mode": "path-list", "q": search, "node_id": selected_node_id}) tree = ui_build_tree(nodes) - tree_html = ui_render_tree_branch(tree, selected_path, mode, search) + tree_html = ui_render_tree_branch(tree, selected_path, selected_node_id, mode, search) selected_links = ui_json_list((selected_node or {}).get("links") or []) selected_metadata = ui_json((selected_node or {}).get("metadata") or {}) selected_tags = ", ".join((selected_node or {}).get("tags") or []) selected_content = html.escape((selected_node or {}).get("content_text") or "") selected_type = html.escape((selected_node or {}).get("type") or "note") selected_path_value = html.escape(selected_path or "") + selected_node_id_value = html.escape(selected_node_id or "") selected_title = html.escape(selected_path or "New node") selected_badges = ui_tags((selected_node or {}).get("tags") or []) node_rows = "".join( - f"{html.escape(item['full_path'])}{ui_tags(item.get('tags') or [])}{html.escape(item.get('type') or 'note')}
" + f"{html.escape(item['full_path'])}{ui_tags(item.get('tags') or [])}{html.escape(item.get('type') or 'note')}
" for item in nodes ) schedule_rows = "".join( @@ -10516,13 +10535,13 @@ def start_local_ui(args): {f'''
{selected_title}
{selected_badges}
-

{'Edit node' if selected_node else 'Create node'}

+

{'Edit node' if selected_node else 'Create node'}

New node
-

Link nodes

+

Link nodes

''' if mode == 'filesystem' else f'''
{node_rows or ''}
PathTagsTypeAction
No nodes.
'''} @@ -10600,20 +10619,24 @@ def start_local_ui(args): "payload": json.loads((form.get("payload", ["{}"])[0] or "{}")), }) elif self.path == "/nodes/upsert": - upsert_node({ + node_id = form.get("node_id", [""])[0].strip() + payload = { "path": form.get("path", [""])[0], "type": form.get("type", ["note"])[0], "tags": [tag.strip() for tag in form.get("tags", [""])[0].split(",") if tag.strip()], "links": json.loads(form.get("links", ["[]"])[0] or "[]"), "metadata": json.loads(form.get("metadata", ["{}"])[0] or "{}"), "content_text": form.get("content_text", [""])[0], - }) + } + if node_id: + payload["node_id"] = node_id + upsert_node(payload) elif self.path == "/nodes/archive": - archive_node_by_path({"path": form.get("path", [""])[0]}) + archive_node_by_path({"node_id": form.get("node_id", [""])[0]}) elif self.path == "/nodes/link": link_nodes({ - "source_path": form.get("source_path", [""])[0], - "target_path": form.get("target_path", [""])[0], + "source_node_id": form.get("source_node_id", [""])[0], + "target_node_id": form.get("target_node_id", [""])[0], "relation": form.get("relation", ["references"])[0], }) elif self.path == "/auth/renew": diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 8f5d633..573e628 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -632,6 +632,18 @@ def test_context_and_related_use_identity_inputs(mod): mod.find_related_nodes({"path": "/notes/root"}) +def test_local_helper_ui_forms_submit_identity_for_existing_node_actions(): + source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text() + assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source + assert 'name="source_node_id"' in source + assert 'name="target_node_id"' in source + assert 'archive_node_by_path({"node_id": form.get("node_id", [""])[0]})' in source + assert '"source_node_id": form.get("source_node_id", [""])[0]' in source + assert '"target_node_id": form.get("target_node_id", [""])[0]' in source + assert 'name="source_path"' not in source + assert 'name="target_path"' not in source + + 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"})