diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 7bd6491..13e62b5 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -2523,7 +2523,7 @@ def relay_ws_url(project_id, session_id, ticket, socket="terminal"): parsed = urllib.parse.urlparse(relay_base_url()) scheme = "wss" if parsed.scheme == "https" else "ws" netloc = parsed.netloc - return f"{scheme}://{netloc}/v1/projects/{project_id}/gateway-sessions/{session_id}/relay/{socket}?{urllib.parse.urlencode({'ticket': ticket})}" + return f"{scheme}://{netloc}/v1/projects/{project_id}/gateway-sessions/{session_id}/relay/{socket}?{rest_query_string({'ticket': ticket})}" def gateway_listener_ws_url(project_id, agent_id): @@ -5325,6 +5325,23 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru raise RuntimeError(f"{method} {path} failed: HTTP {exc.code}: {body_text}") from exc +def rest_query_string(params): + def encode_value(value): + if isinstance(value, bool): + return "true" if value else "false" + return value + + normalized = {} + for key, value in (params or {}).items(): + if value is None: + continue + if isinstance(value, (list, tuple)): + normalized[key] = [encode_value(item) for item in value] + else: + normalized[key] = encode_value(value) + return urllib.parse.urlencode(normalized, doseq=True) + + def request_form_json(method, path, payload, token_required=False, extra_headers=None): if token_required and not TOKEN: raise RuntimeError("NAPSEER_TOKEN is required") @@ -5832,7 +5849,7 @@ def reindex_project(args): query = {"limit": limit} if cursor: query["cursor"] = cursor - page = request_json("GET", f"/v1/projects/{project_id}/nodes?{urllib.parse.urlencode(query)}") + page = request_json("GET", f"/v1/projects/{project_id}/nodes?{rest_query_string(query)}") nodes.extend(filter_project_nodes(page.get("items", []))) cursor = page.get("next_cursor") if not cursor: @@ -7134,7 +7151,7 @@ def get_node_by_path(args, allow_agent=False): params = {"path": path} if args.get("view"): params["view"] = args["view"] - node = request_json("GET", f"/v1/projects/{project_id}/nodes/by-path?{urllib.parse.urlencode(params)}") + node = request_json("GET", f"/v1/projects/{project_id}/nodes/by-path?{rest_query_string(params)}") return decrypt_node_for_return(node, project_id=project_id) @@ -7177,7 +7194,7 @@ def guarded_node_patch(args): path = normalize_node_path(args["path"]) result = request_project_write( "PATCH", - f"/v1/projects/{project_id}/nodes/by-path?{urllib.parse.urlencode({'path': path})}", + f"/v1/projects/{project_id}/nodes/by-path?{rest_query_string({'path': path})}", payload, project_id, f"guarded patch node {path}", @@ -7391,7 +7408,7 @@ def list_project_nodes(args): query = dict(base_query) query["q"] = server_query query["limit"] = max(1, limit - len(unique_nodes(collected))) - suffix = f"?{urllib.parse.urlencode(query)}" + suffix = f"?{rest_query_string(query)}" page = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}") items = decrypt_nodes_for_return(filter_project_nodes(page.get("items", [])), project_id=project_id) diagnostics["server_queries"].append({"q": server_query, "matched": len(items)}) @@ -7412,7 +7429,7 @@ def list_project_nodes(args): ), } - query = urllib.parse.urlencode(base_query) + query = rest_query_string(base_query) suffix = f"?{query}" if query else "" result = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}") items = decrypt_nodes_for_return(filter_project_nodes(result.get("items", [])), project_id=project_id) @@ -7623,7 +7640,7 @@ def list_kanban_cards(args): source_pages = 0 seen_cursors = set() while True: - suffix = f"?{urllib.parse.urlencode(source_query)}" + suffix = f"?{rest_query_string(source_query)}" raw = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}") page_items = decrypt_nodes_for_return(filter_project_nodes(raw.get("items", [])), project_id=project_id) source_items.extend(page_items) @@ -8160,7 +8177,7 @@ def list_feedback_reports(args=None): value = args.get(key) if value not in (None, ""): query[key] = value - suffix = f"?{urllib.parse.urlencode(query)}" if query else "" + suffix = f"?{rest_query_string(query)}" if query else "" return request_json("GET", f"/v1/feedback{suffix}") @@ -8171,7 +8188,7 @@ def list_admin_feedback_reports(args=None): value = args.get(key) if value not in (None, ""): query[key] = value - suffix = f"?{urllib.parse.urlencode(query)}" if query else "" + suffix = f"?{rest_query_string(query)}" if query else "" if LOCAL_AUTH_SECRET and LOCAL_AUTH_PURPOSE == "admin_feedback": return request_json( "GET", @@ -8655,7 +8672,7 @@ def list_agent_nodes(args): query["tag"] = args["tag"] if args.get("folder_path"): query["folder_path"] = agent_scoped_folder(args) - suffix = urllib.parse.urlencode(query) + suffix = rest_query_string(query) nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{suffix}").get("items", []) return { "agent_slug": args["agent_slug"], @@ -9141,12 +9158,12 @@ def start_local_ui(args): nodes_query = {"limit": 200} if search: nodes_query["q"] = search - nodes_suffix = urllib.parse.urlencode(nodes_query) + nodes_suffix = rest_query_string(nodes_query) all_nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", []) archived_query = {"limit": 200, "archived_only": "true"} if search: archived_query["q"] = search - archived_suffix = urllib.parse.urlencode(archived_query) + archived_suffix = rest_query_string(archived_query) archived_nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{archived_suffix}").get("items", []) archived_nodes = filter_project_nodes(archived_nodes) nodes = filter_project_nodes(all_nodes) @@ -9352,7 +9369,7 @@ def start_local_ui(args): nodes_query = {"limit": 200} if search: nodes_query["q"] = search - nodes_suffix = urllib.parse.urlencode(nodes_query) + 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: @@ -9380,7 +9397,7 @@ def start_local_ui(args): 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( diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 06182af..4cfc740 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -80,6 +80,24 @@ def test_node_discovery_allows_large_requested_limit(mod): assert len(calls) == 1 +def test_rest_query_string_serializes_booleans_lowercase(mod): + query = mod.rest_query_string({ + "folder_path": "/kanban", + "include_archived": False, + "archived_only": False, + "active_only": True, + "status": ["todo", "doing"], + }) + + assert "include_archived=false" in query + assert "archived_only=false" in query + assert "active_only=true" in query + assert "include_archived=False" not in query + assert "active_only=True" not in query + assert "status=todo" in query + assert "status=doing" in query + + def test_kanban_list_grouping_and_filters(mod): doing = node( "/kanban/doing/fix-wrapper", @@ -107,6 +125,8 @@ def test_kanban_list_grouping_and_filters(mod): def fake_request(method, path, payload=None, **kwargs): assert method == "GET" assert "q=" in path + assert "include_archived=false" in path + assert "archived_only=false" in path return {"items": [doing, todo, done, outside], "next_cursor": None} mod.request_json = fake_request @@ -146,6 +166,8 @@ def test_kanban_list_reads_beyond_first_source_page(mod): assert method == "GET" assert "q=%2Fkanban" in path assert "limit=10000" in path + assert "include_archived=false" in path + assert "archived_only=false" in path if "cursor=page-2" in path: return {"items": second_page, "next_cursor": None} return {"items": first_page, "next_cursor": "page-2"}