Fix MCP REST boolean query encoding
This commit is contained in:
@@ -2523,7 +2523,7 @@ def relay_ws_url(project_id, session_id, ticket, socket="terminal"):
|
|||||||
parsed = urllib.parse.urlparse(relay_base_url())
|
parsed = urllib.parse.urlparse(relay_base_url())
|
||||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||||
netloc = parsed.netloc
|
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):
|
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
|
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):
|
def request_form_json(method, path, payload, token_required=False, extra_headers=None):
|
||||||
if token_required and not TOKEN:
|
if token_required and not TOKEN:
|
||||||
raise RuntimeError("NAPSEER_TOKEN is required")
|
raise RuntimeError("NAPSEER_TOKEN is required")
|
||||||
@@ -5832,7 +5849,7 @@ def reindex_project(args):
|
|||||||
query = {"limit": limit}
|
query = {"limit": limit}
|
||||||
if cursor:
|
if cursor:
|
||||||
query["cursor"] = 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", [])))
|
nodes.extend(filter_project_nodes(page.get("items", [])))
|
||||||
cursor = page.get("next_cursor")
|
cursor = page.get("next_cursor")
|
||||||
if not cursor:
|
if not cursor:
|
||||||
@@ -7134,7 +7151,7 @@ def get_node_by_path(args, allow_agent=False):
|
|||||||
params = {"path": path}
|
params = {"path": path}
|
||||||
if args.get("view"):
|
if args.get("view"):
|
||||||
params["view"] = args["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)
|
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"])
|
path = normalize_node_path(args["path"])
|
||||||
result = request_project_write(
|
result = request_project_write(
|
||||||
"PATCH",
|
"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,
|
payload,
|
||||||
project_id,
|
project_id,
|
||||||
f"guarded patch node {path}",
|
f"guarded patch node {path}",
|
||||||
@@ -7391,7 +7408,7 @@ def list_project_nodes(args):
|
|||||||
query = dict(base_query)
|
query = dict(base_query)
|
||||||
query["q"] = server_query
|
query["q"] = server_query
|
||||||
query["limit"] = max(1, limit - len(unique_nodes(collected)))
|
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}")
|
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)
|
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)})
|
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 ""
|
suffix = f"?{query}" if query else ""
|
||||||
result = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}")
|
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)
|
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
|
source_pages = 0
|
||||||
seen_cursors = set()
|
seen_cursors = set()
|
||||||
while True:
|
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}")
|
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)
|
page_items = decrypt_nodes_for_return(filter_project_nodes(raw.get("items", [])), project_id=project_id)
|
||||||
source_items.extend(page_items)
|
source_items.extend(page_items)
|
||||||
@@ -8160,7 +8177,7 @@ def list_feedback_reports(args=None):
|
|||||||
value = args.get(key)
|
value = args.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
query[key] = value
|
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}")
|
return request_json("GET", f"/v1/feedback{suffix}")
|
||||||
|
|
||||||
|
|
||||||
@@ -8171,7 +8188,7 @@ def list_admin_feedback_reports(args=None):
|
|||||||
value = args.get(key)
|
value = args.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
query[key] = value
|
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":
|
if LOCAL_AUTH_SECRET and LOCAL_AUTH_PURPOSE == "admin_feedback":
|
||||||
return request_json(
|
return request_json(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -8655,7 +8672,7 @@ def list_agent_nodes(args):
|
|||||||
query["tag"] = args["tag"]
|
query["tag"] = args["tag"]
|
||||||
if args.get("folder_path"):
|
if args.get("folder_path"):
|
||||||
query["folder_path"] = agent_scoped_folder(args)
|
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", [])
|
nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{suffix}").get("items", [])
|
||||||
return {
|
return {
|
||||||
"agent_slug": args["agent_slug"],
|
"agent_slug": args["agent_slug"],
|
||||||
@@ -9141,12 +9158,12 @@ def start_local_ui(args):
|
|||||||
nodes_query = {"limit": 200}
|
nodes_query = {"limit": 200}
|
||||||
if search:
|
if search:
|
||||||
nodes_query["q"] = 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", [])
|
all_nodes = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", [])
|
||||||
archived_query = {"limit": 200, "archived_only": "true"}
|
archived_query = {"limit": 200, "archived_only": "true"}
|
||||||
if search:
|
if search:
|
||||||
archived_query["q"] = 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 = request_json("GET", f"/v1/projects/{project_id}/nodes?{archived_suffix}").get("items", [])
|
||||||
archived_nodes = filter_project_nodes(archived_nodes)
|
archived_nodes = filter_project_nodes(archived_nodes)
|
||||||
nodes = filter_project_nodes(all_nodes)
|
nodes = filter_project_nodes(all_nodes)
|
||||||
@@ -9352,7 +9369,7 @@ def start_local_ui(args):
|
|||||||
nodes_query = {"limit": 200}
|
nodes_query = {"limit": 200}
|
||||||
if search:
|
if search:
|
||||||
nodes_query["q"] = 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 = request_json("GET", f"/v1/projects/{project_id}/nodes?{nodes_suffix}").get("items", [])
|
||||||
nodes = sort_nodes_by_recency(nodes)
|
nodes = sort_nodes_by_recency(nodes)
|
||||||
if not selected_path and 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_title = html.escape(selected_path or "New node")
|
||||||
selected_badges = ui_tags((selected_node or {}).get("tags") or [])
|
selected_badges = ui_tags((selected_node or {}).get("tags") or [])
|
||||||
node_rows = "".join(
|
node_rows = "".join(
|
||||||
f"<tr><td><a class='path-link' href='/?{urllib.parse.urlencode({'mode': 'filesystem', 'path': item['full_path'], 'q': search})}'><code>{html.escape(item['full_path'])}</code></a></td><td>{ui_tags(item.get('tags') or [])}</td><td>{html.escape(item.get('type') or 'note')}</td><td><form method='post' action='/nodes/archive'><input type='hidden' name='return_to' value='/?{urllib.parse.urlencode({'mode': mode, 'q': search})}'><input type='hidden' name='path' value='{html.escape(item['full_path'])}'><button class='ghost'>Archive</button></form></td></tr>"
|
f"<tr><td><a class='path-link' href='/?{rest_query_string({'mode': 'filesystem', 'path': item['full_path'], 'q': search})}'><code>{html.escape(item['full_path'])}</code></a></td><td>{ui_tags(item.get('tags') or [])}</td><td>{html.escape(item.get('type') or 'note')}</td><td><form method='post' action='/nodes/archive'><input type='hidden' name='return_to' value='/?{rest_query_string({'mode': mode, 'q': search})}'><input type='hidden' name='path' value='{html.escape(item['full_path'])}'><button class='ghost'>Archive</button></form></td></tr>"
|
||||||
for item in nodes
|
for item in nodes
|
||||||
)
|
)
|
||||||
schedule_rows = "".join(
|
schedule_rows = "".join(
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ def test_node_discovery_allows_large_requested_limit(mod):
|
|||||||
assert len(calls) == 1
|
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):
|
def test_kanban_list_grouping_and_filters(mod):
|
||||||
doing = node(
|
doing = node(
|
||||||
"/kanban/doing/fix-wrapper",
|
"/kanban/doing/fix-wrapper",
|
||||||
@@ -107,6 +125,8 @@ def test_kanban_list_grouping_and_filters(mod):
|
|||||||
def fake_request(method, path, payload=None, **kwargs):
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
assert method == "GET"
|
assert method == "GET"
|
||||||
assert "q=" in path
|
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}
|
return {"items": [doing, todo, done, outside], "next_cursor": None}
|
||||||
|
|
||||||
mod.request_json = fake_request
|
mod.request_json = fake_request
|
||||||
@@ -146,6 +166,8 @@ def test_kanban_list_reads_beyond_first_source_page(mod):
|
|||||||
assert method == "GET"
|
assert method == "GET"
|
||||||
assert "q=%2Fkanban" in path
|
assert "q=%2Fkanban" in path
|
||||||
assert "limit=10000" 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:
|
if "cursor=page-2" in path:
|
||||||
return {"items": second_page, "next_cursor": None}
|
return {"items": second_page, "next_cursor": None}
|
||||||
return {"items": first_page, "next_cursor": "page-2"}
|
return {"items": first_page, "next_cursor": "page-2"}
|
||||||
|
|||||||
Reference in New Issue
Block a user