structify more dicts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 payload
|
return sorted(result, key=lambda item: item.proxy_name)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user