structify dicts

This commit is contained in:
lda
2026-05-13 23:37:47 +07:00 Verified
parent 11afed99ab
commit ed239b5fd8
5 changed files with 72 additions and 17 deletions
+2
View File
@@ -70,6 +70,8 @@ event bus is supplied. The admin MCP tool projects the same event kinds into
MCP list-changed notifications for the current client session. Config mutation MCP list-changed notifications for the current client session. Config mutation
tools still only stage changes and return `requires_reload`; they do not emit 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
typed result to a plain payload 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
+4 -3
View File
@@ -6,7 +6,7 @@ from fastmcp import Context, FastMCP
from ..admin_surface import ProxyAdminRuntime, TransparentAdminHandlers from ..admin_surface import ProxyAdminRuntime, TransparentAdminHandlers
from ..notifications import FastMcpContextNotificationSink from ..notifications import FastMcpContextNotificationSink
from .reload_events import reload_change_events from .reload_events import ProxyReloadResult, reload_change_events
def create_proxy_admin_server( def create_proxy_admin_server(
@@ -48,7 +48,7 @@ def create_proxy_admin_server(
) )
async def reload_config(ctx: Context) -> dict[str, Any]: async def reload_config(ctx: Context) -> dict[str, Any]:
result = handlers.reload_config() result = handlers.reload_config()
await _send_reload_notifications(ctx, result) await _send_reload_notifications(ctx, ProxyReloadResult.from_payload(result))
return result return result
@admin.tool( @admin.tool(
@@ -140,8 +140,9 @@ def create_proxy_admin_server(
return admin return admin
async def _send_reload_notifications(ctx: Context, result: dict[str, Any]) -> None: async def _send_reload_notifications(ctx: Context, result: ProxyReloadResult) -> None:
"""Notify the current client that reload may have changed visible capabilities.""" """Notify the current client that reload may have changed visible capabilities."""
sink = FastMcpContextNotificationSink(ctx) sink = FastMcpContextNotificationSink(ctx)
for event in reload_change_events(result): for event in reload_change_events(result):
await sink.send_event(event) await sink.send_event(event)
+33 -4
View File
@@ -1,17 +1,46 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any from typing import Any
from ..events import McpEvent, make_event from ..events import McpEvent, make_event
def reload_change_events(result: dict[str, Any]) -> list[McpEvent]: @dataclass(frozen=True, slots=True)
class ProxyReloadResult:
"""Typed result for a successful proxy remount before MCP serialization."""
mounted_connections: list[str]
connection_count: int
enabled_connection_count: int
def to_payload(self) -> dict[str, Any]:
"""Serialize the reload result for MCP tool responses."""
return {
"ok": True,
"reloaded": True,
"mounted_connections": self.mounted_connections,
"connection_count": self.connection_count,
"enabled_connection_count": self.enabled_connection_count,
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> ProxyReloadResult:
"""Rehydrate the typed result from an MCP/admin payload."""
return cls(
mounted_connections=list(payload["mounted_connections"]),
connection_count=int(payload["connection_count"]),
enabled_connection_count=int(payload["enabled_connection_count"]),
)
def reload_change_events(result: ProxyReloadResult) -> list[McpEvent]:
"""Build local change events for a successful proxy runtime reload.""" """Build local change events for a successful proxy runtime reload."""
event_payload = { event_payload = {
"reason": "transparent_reload", "reason": "transparent_reload",
"mounted_connections": result["mounted_connections"], "mounted_connections": result.mounted_connections,
"connection_count": result["connection_count"], "connection_count": result.connection_count,
"enabled_connection_count": result["enabled_connection_count"], "enabled_connection_count": result.enabled_connection_count,
} }
return [ return [
make_event(kind, payload=event_payload) make_event(kind, payload=event_payload)
+8 -10
View File
@@ -23,7 +23,7 @@ from .tools import (
filter_proxy_tools, filter_proxy_tools,
proxy_tools_page, proxy_tools_page,
) )
from .reload_events import reload_change_events from .reload_events import ProxyReloadResult, reload_change_events
_ADMIN_TOOL_NAMES = [ _ADMIN_TOOL_NAMES = [
f"{ADMIN_NAMESPACE}.list_connections", f"{ADMIN_NAMESPACE}.list_connections",
@@ -117,17 +117,15 @@ class ProxyRuntime:
self.server.mount(proxy) self.server.mount(proxy)
mounted_connections.append(connection.id) mounted_connections.append(connection.id)
result = { result = ProxyReloadResult(
"ok": True, mounted_connections=mounted_connections,
"reloaded": True, connection_count=len(config.connections),
"mounted_connections": mounted_connections, enabled_connection_count=len(mounted_connections),
"connection_count": len(config.connections), )
"enabled_connection_count": len(mounted_connections),
}
self._publish_reload_events(result) self._publish_reload_events(result)
return result return result.to_payload()
def _publish_reload_events(self, result: dict[str, Any]) -> None: def _publish_reload_events(self, result: ProxyReloadResult) -> None:
"""Publish local change events after a successful best-effort remount.""" """Publish local change events after a successful best-effort remount."""
if self.event_bus is None: if self.event_bus is None:
return return
+25
View File
@@ -12,6 +12,10 @@ from wf_mcp.events import EventBus, InMemoryEventSink
from wf_mcp.models import BrokerConfig, ConnectionConfig from wf_mcp.models import BrokerConfig, ConnectionConfig
from wf_mcp.proxy_validation import ProxyConfigError, validate_transparent_proxy_config from wf_mcp.proxy_validation import ProxyConfigError, validate_transparent_proxy_config
from wf_mcp.transparent_proxy import ProxyRuntime, create_transparent_proxy_client from wf_mcp.transparent_proxy import ProxyRuntime, create_transparent_proxy_client
from wf_mcp.transparent_proxy.reload_events import (
ProxyReloadResult,
reload_change_events,
)
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
@@ -556,3 +560,24 @@ def test_transparent_proxy_runtime_reload_publishes_local_change_events() -> Non
assert "resources_changed" in event_kinds assert "resources_changed" in event_kinds
assert "prompts_changed" in event_kinds assert "prompts_changed" in event_kinds
assert catalog_changed[0].payload["reason"] == "transparent_reload" assert catalog_changed[0].payload["reason"] == "transparent_reload"
def test_proxy_reload_result_serializes_and_drives_reload_events() -> None:
result = ProxyReloadResult(
mounted_connections=["fixture.personal"],
connection_count=2,
enabled_connection_count=1,
)
payload = result.to_payload()
rehydrated = ProxyReloadResult.from_payload(payload)
events = reload_change_events(result)
assert payload["ok"] is True
assert payload["reloaded"] is True
assert payload["mounted_connections"] == ["fixture.personal"]
assert payload["connection_count"] == 2
assert payload["enabled_connection_count"] == 1
assert rehydrated == result
assert events[0].payload["mounted_connections"] == ["fixture.personal"]
assert events[0].payload["enabled_connection_count"] == 1