structify more dicts

This commit is contained in:
lda
2026-05-13 23:47:24 +07:00 Verified
parent ed239b5fd8
commit 7887e4750e
4 changed files with 170 additions and 55 deletions
+2
View File
@@ -72,6 +72,8 @@ tools still only stage changes and return `requires_reload`; they do not emit
list-changed notifications until reload remounts the visible capability set. list-changed notifications until reload remounts the visible capability set.
Internally, reload metadata uses `ProxyReloadResult`; MCP tools serialize that Internally, reload metadata uses `ProxyReloadResult`; MCP tools serialize that
typed result to a plain payload at the boundary. typed result to a plain payload at the boundary.
Proxy tool listing similarly uses `ProxyToolPayload` / `ProxyToolsPage`
internally and serializes to admin MCP payloads at the boundary.
Do not memoize mounted proxies or clients without an explicit lifecycle design. Do not memoize mounted proxies or clients without an explicit lifecycle design.
The tempting implementation is a dictionary keyed by connection id around The tempting implementation is a dictionary keyed by connection id around
+14 -10
View File
@@ -19,7 +19,8 @@ from ..proxy_config import broker_config_to_fastmcp_config
from ..proxy_validation import validate_transparent_proxy_config from ..proxy_validation import validate_transparent_proxy_config
from .admin import create_proxy_admin_server from .admin import create_proxy_admin_server
from .tools import ( from .tools import (
collect_proxy_tool_payloads, ProxyToolPayload,
collect_proxy_tools,
filter_proxy_tools, filter_proxy_tools,
proxy_tools_page, proxy_tools_page,
) )
@@ -133,18 +134,22 @@ class ProxyRuntime:
self.event_bus.publish(event) self.event_bus.publish(event)
async def list_proxy_tools(self) -> list[dict[str, Any]]: async def list_proxy_tools(self) -> list[dict[str, Any]]:
return await self._list_proxy_tools() return [
tool.to_payload(include_schema=False)
for tool in await self._list_proxy_tools()
]
async def _list_proxy_tools(self) -> list[dict[str, Any]]: async def _list_proxy_tools(
self,
) -> list[ProxyToolPayload]:
config = self.current_config() config = self.current_config()
connection_ids = { connection_ids = {
connection.id for connection in config.connections if connection.enabled connection.id for connection in config.connections if connection.enabled
} }
tools = await self.server.list_tools() tools = await self.server.list_tools()
return collect_proxy_tool_payloads( return collect_proxy_tools(
tools=tools, tools=tools,
connection_ids=connection_ids, connection_ids=connection_ids,
include_schema=False,
) )
async def list_proxy_tools_page( async def list_proxy_tools_page(
@@ -169,14 +174,13 @@ class ProxyRuntime:
connection.id for connection in config.connections if connection.enabled connection.id for connection in config.connections if connection.enabled
} }
tools = await self.server.list_tools() tools = await self.server.list_tools()
payloads = collect_proxy_tool_payloads( payloads = collect_proxy_tools(
tools=tools, tools=tools,
connection_ids=connection_ids, connection_ids=connection_ids,
include_schema=True,
) )
for payload in payloads: for tool in payloads:
if payload["proxy_name"] == proxy_name: if tool.proxy_name == proxy_name:
return payload return tool.to_payload(include_schema=True)
raise KeyError(proxy_name) raise KeyError(proxy_name)
+111 -45
View File
@@ -1,12 +1,61 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any from typing import Any
from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name
from ..shared.pagination import paginate_items from ..shared.pagination import paginate_items
@dataclass(frozen=True, slots=True)
class ProxyToolPayload:
"""Typed proxy-tool metadata before admin MCP serialization."""
proxy_name: str
connection_id: str
local_name: str
title: str | None = None
description: str | None = None
enabled: bool = True
input_schema: Any | None = None
output_schema: Any | None = None
def to_payload(self, *, include_schema: bool) -> dict[str, Any]:
"""Serialize proxy-tool metadata for admin MCP responses."""
payload = {
"proxy_name": self.proxy_name,
"connection_id": self.connection_id,
"local_name": self.local_name,
"title": self.title,
"description": self.description,
"enabled": self.enabled,
}
if include_schema:
payload["input_schema"] = self.input_schema
payload["output_schema"] = self.output_schema
return payload
@dataclass(frozen=True, slots=True)
class ProxyToolsPage:
"""Typed page of proxy-tool metadata before admin MCP serialization."""
tools: list[ProxyToolPayload]
next_cursor: str | None
total: int
def to_payload(self, *, include_schema: bool) -> dict[str, Any]:
"""Serialize a proxy tool page with FastMCP-compatible cursor casing."""
return {
"tools": [
tool.to_payload(include_schema=include_schema) for tool in self.tools
],
"nextCursor": self.next_cursor,
"total": self.total,
}
def proxy_tool_payload( def proxy_tool_payload(
*, *,
proxy_name: str, proxy_name: str,
@@ -16,22 +65,50 @@ def proxy_tool_payload(
include_schema: bool, include_schema: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return the admin-facing metadata payload for one proxied tool.""" """Return the admin-facing metadata payload for one proxied tool."""
payload = { return ProxyToolPayload(
"proxy_name": proxy_name, proxy_name=proxy_name,
"connection_id": connection_id, connection_id=connection_id,
"local_name": local_name, local_name=local_name,
"title": getattr(tool, "title", None), title=getattr(tool, "title", None),
"description": getattr(tool, "description", None), description=getattr(tool, "description", None),
"enabled": True, input_schema=getattr(
}
if include_schema:
payload["input_schema"] = getattr(
tool, tool,
"input_schema", "input_schema",
getattr(tool, "parameters", None), getattr(tool, "parameters", None),
),
output_schema=getattr(tool, "output_schema", None),
).to_payload(include_schema=include_schema)
def collect_proxy_tools(
*,
tools: Sequence[Any],
connection_ids: set[str],
) -> list[ProxyToolPayload]:
"""Collect visible upstream tool metadata from FastMCP's listed tools."""
result: list[ProxyToolPayload] = []
for tool in tools:
if is_admin_tool_name(tool.name):
continue
parsed = parse_namespaced_tool_name(tool.name, connection_ids)
if parsed is None:
continue
result.append(
ProxyToolPayload(
proxy_name=parsed.proxy_name,
connection_id=parsed.connection_id,
local_name=parsed.local_name,
title=getattr(tool, "title", None),
description=getattr(tool, "description", None),
input_schema=getattr(
tool,
"input_schema",
getattr(tool, "parameters", None),
),
output_schema=getattr(tool, "output_schema", None),
)
) )
payload["output_schema"] = getattr(tool, "output_schema", None) return sorted(result, key=lambda item: item.proxy_name)
return payload
def collect_proxy_tool_payloads( def collect_proxy_tool_payloads(
@@ -41,34 +118,21 @@ def collect_proxy_tool_payloads(
include_schema: bool, include_schema: bool,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Collect visible upstream tool payloads from FastMCP's listed tools.""" """Collect visible upstream tool payloads from FastMCP's listed tools."""
result: list[dict[str, Any]] = [] return [
for tool in tools: tool.to_payload(include_schema=include_schema)
if is_admin_tool_name(tool.name): for tool in collect_proxy_tools(tools=tools, connection_ids=connection_ids)
continue ]
parsed = parse_namespaced_tool_name(tool.name, connection_ids)
if parsed is None:
continue
result.append(
proxy_tool_payload(
proxy_name=parsed.proxy_name,
connection_id=parsed.connection_id,
local_name=parsed.local_name,
tool=tool,
include_schema=include_schema,
)
)
return sorted(result, key=lambda item: item["proxy_name"])
def filter_proxy_tools( def filter_proxy_tools(
tools: list[dict[str, Any]], tools: list[ProxyToolPayload],
*, *,
connection_id: str | None = None, connection_id: str | None = None,
query: str | None = None, query: str | None = None,
) -> list[dict[str, Any]]: ) -> list[ProxyToolPayload]:
"""Filter proxied tool payloads by connection and simple text query.""" """Filter proxied tool payloads by connection and simple text query."""
if connection_id is not None: if connection_id is not None:
tools = [tool for tool in tools if tool["connection_id"] == connection_id] tools = [tool for tool in tools if tool.connection_id == connection_id]
if not query: if not query:
return tools return tools
@@ -78,28 +142,30 @@ def filter_proxy_tools(
for tool in tools for tool in tools
if needle if needle
in " ".join( in " ".join(
str(tool.get(key, "")) str(value or "")
for key in ( for value in (
"proxy_name", tool.proxy_name,
"connection_id", tool.connection_id,
"local_name", tool.local_name,
"title", tool.title,
"description", tool.description,
) )
).casefold() ).casefold()
] ]
def proxy_tools_page( def proxy_tools_page(
tools: list[dict[str, Any]], tools: list[ProxyToolPayload],
*, *,
cursor: str | None, cursor: str | None,
limit: int, limit: int,
include_schema: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a cursor-paginated proxy tool listing payload.""" """Return a cursor-paginated proxy tool listing payload."""
page, next_cursor = paginate_items(tools, cursor=cursor, limit=limit) page, next_cursor = paginate_items(tools, cursor=cursor, limit=limit)
return { typed_page = ProxyToolsPage(
"tools": page, tools=page,
"nextCursor": next_cursor, next_cursor=next_cursor,
"total": len(tools), total=len(tools),
} )
return typed_page.to_payload(include_schema=include_schema)
+43
View File
@@ -16,6 +16,7 @@ from wf_mcp.transparent_proxy.reload_events import (
ProxyReloadResult, ProxyReloadResult,
reload_change_events, reload_change_events,
) )
from wf_mcp.transparent_proxy.tools import ProxyToolPayload, ProxyToolsPage
from wf_mcp.broker import load_broker_config from wf_mcp.broker import load_broker_config
from .test_support import fixture_server_path, local_temp_root from .test_support import fixture_server_path, local_temp_root
@@ -581,3 +582,45 @@ def test_proxy_reload_result_serializes_and_drives_reload_events() -> None:
assert rehydrated == result assert rehydrated == result
assert events[0].payload["mounted_connections"] == ["fixture.personal"] assert events[0].payload["mounted_connections"] == ["fixture.personal"]
assert events[0].payload["enabled_connection_count"] == 1 assert events[0].payload["enabled_connection_count"] == 1
def test_proxy_tool_payload_serializes_admin_tool_metadata() -> None:
payload = ProxyToolPayload(
proxy_name="fixture.personal_echo_tool",
connection_id="fixture.personal",
local_name="echo_tool",
title="Echo Tool",
description="Echo text back",
input_schema={"type": "object"},
output_schema={"type": "object"},
)
minimal = payload.to_payload(include_schema=False)
with_schema = payload.to_payload(include_schema=True)
assert minimal["proxy_name"] == "fixture.personal_echo_tool"
assert minimal["connection_id"] == "fixture.personal"
assert minimal["local_name"] == "echo_tool"
assert minimal["enabled"] is True
assert "input_schema" not in minimal
assert with_schema["input_schema"] == {"type": "object"}
assert with_schema["output_schema"] == {"type": "object"}
def test_proxy_tools_page_serializes_paginated_payload() -> None:
tool = ProxyToolPayload(
proxy_name="fixture.personal_echo_tool",
connection_id="fixture.personal",
local_name="echo_tool",
)
page = ProxyToolsPage(
tools=[tool],
next_cursor="cursor-1",
total=3,
)
payload = page.to_payload(include_schema=False)
assert payload["nextCursor"] == "cursor-1"
assert payload["total"] == 3
assert payload["tools"][0]["proxy_name"] == "fixture.personal_echo_tool"