the thing is good until i touch it

This commit is contained in:
lda
2026-04-30 02:42:56 +07:00 Verified
parent 0afd61cf27
commit c52db4a830
14 changed files with 147 additions and 31 deletions
-2
View File
@@ -1,2 +0,0 @@
def main() -> None:
print("Hello from lda-workflow-as-struct!")
+8
View File
@@ -56,6 +56,10 @@ def create_broker_server(service: WfMcpService) -> FastMCP:
) )
] ]
@server.tool()
async def get_connection_statuses() -> list[dict[str, Any]]:
return service.connection_statuses()
@server.tool() @server.tool()
async def refresh_connection_catalog(connection_id: str) -> dict[str, Any]: async def refresh_connection_catalog(connection_id: str) -> dict[str, Any]:
try: try:
@@ -133,6 +137,10 @@ def create_broker_server(service: WfMcpService) -> FastMCP:
def events_resource() -> str: def events_resource() -> str:
return json.dumps([asdict(event) for event in service.list_events()], indent=2) return json.dumps([asdict(event) for event in service.list_events()], indent=2)
@server.resource("wf-mcp://status", name="status.all")
def status_resource() -> str:
return json.dumps(service.connection_statuses(), indent=2)
@server.prompt( @server.prompt(
name="plan_with_catalog", name="plan_with_catalog",
description="Provide the broker catalog as planning context.", description="Provide the broker catalog as planning context.",
+8 -6
View File
@@ -4,10 +4,12 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
## are these our own redef of MCP VERY MUCH NICE VERY MUCH READY structs?
## These are unfortunately boundary. Hence, we need good typecheck on these, and since mcp lib is good stuff, carry those over. Could be pro
@dataclass(slots=True) @dataclass(slots=True)
class DiscoveredTool: class DiscoveredTool:
name: str name: str
display_name: str | None title: str | None
description: str | None description: str | None
input_schema: dict[str, Any] input_schema: dict[str, Any]
output_schema: dict[str, Any] output_schema: dict[str, Any]
@@ -19,7 +21,7 @@ class DiscoveredTool:
class DiscoveredResource: class DiscoveredResource:
uri: str uri: str
name: str name: str
display_name: str | None title: str | None
description: str | None description: str | None
mime_type: str | None = None mime_type: str | None = None
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
@@ -28,7 +30,7 @@ class DiscoveredResource:
@dataclass(slots=True) @dataclass(slots=True)
class DiscoveredPrompt: class DiscoveredPrompt:
name: str name: str
display_name: str | None title: str | None
description: str | None description: str | None
arguments: list[dict[str, Any]] = field(default_factory=list) arguments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
@@ -39,7 +41,7 @@ class CatalogNodeEntry:
qualified_name: str qualified_name: str
connection_id: str connection_id: str
local_name: str local_name: str
display_name: str | None title: str | None
description: str | None description: str | None
outcomes: tuple[str, ...] outcomes: tuple[str, ...]
input_schema: dict[str, Any] input_schema: dict[str, Any]
@@ -51,7 +53,7 @@ class CatalogResourceEntry:
qualified_name: str qualified_name: str
connection_id: str connection_id: str
local_name: str local_name: str
display_name: str | None title: str | None
uri: str uri: str
description: str | None description: str | None
mime_type: str | None = None mime_type: str | None = None
@@ -63,7 +65,7 @@ class CatalogPromptEntry:
qualified_name: str qualified_name: str
connection_id: str connection_id: str
local_name: str local_name: str
display_name: str | None title: str | None
description: str | None description: str | None
arguments: list[dict[str, Any]] = field(default_factory=list) arguments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
+6 -6
View File
@@ -35,7 +35,7 @@ def snapshot_from_specs(
else qualify_node_name(connection_id, entry.name), else qualify_node_name(connection_id, entry.name),
connection_id=connection_id, connection_id=connection_id,
local_name=entry.name.removeprefix(f"{connection_id}."), local_name=entry.name.removeprefix(f"{connection_id}."),
display_name=(tool_display_names or {}).get( title=(tool_display_names or {}).get(
entry.name.removeprefix(f"{connection_id}."), entry.name.removeprefix(f"{connection_id}."),
entry.display_name, entry.display_name,
), ),
@@ -51,7 +51,7 @@ def snapshot_from_specs(
qualified_name=qualify_node_name(connection_id, resource.name), qualified_name=qualify_node_name(connection_id, resource.name),
connection_id=connection_id, connection_id=connection_id,
local_name=resource.name, local_name=resource.name,
display_name=resource.display_name, title=resource.title,
uri=resource.uri, uri=resource.uri,
description=resource.description, description=resource.description,
mime_type=resource.mime_type, mime_type=resource.mime_type,
@@ -64,7 +64,7 @@ def snapshot_from_specs(
qualified_name=qualify_node_name(connection_id, prompt.name), qualified_name=qualify_node_name(connection_id, prompt.name),
connection_id=connection_id, connection_id=connection_id,
local_name=prompt.name, local_name=prompt.name,
display_name=prompt.display_name, title=prompt.title,
description=prompt.description, description=prompt.description,
arguments=prompt.arguments, arguments=prompt.arguments,
metadata=prompt.metadata, metadata=prompt.metadata,
@@ -123,7 +123,7 @@ class CombinedCatalog:
"qualified_name": entry.qualified_name, "qualified_name": entry.qualified_name,
"connection_id": entry.connection_id, "connection_id": entry.connection_id,
"local_name": entry.local_name, "local_name": entry.local_name,
"display_name": entry.display_name, "title": entry.title,
"description": entry.description, "description": entry.description,
"outcomes": list(entry.outcomes), "outcomes": list(entry.outcomes),
"input_schema": entry.input_schema, "input_schema": entry.input_schema,
@@ -136,7 +136,7 @@ class CombinedCatalog:
"qualified_name": entry.qualified_name, "qualified_name": entry.qualified_name,
"connection_id": entry.connection_id, "connection_id": entry.connection_id,
"local_name": entry.local_name, "local_name": entry.local_name,
"display_name": entry.display_name, "title": entry.title,
"uri": entry.uri, "uri": entry.uri,
"description": entry.description, "description": entry.description,
"mime_type": entry.mime_type, "mime_type": entry.mime_type,
@@ -149,7 +149,7 @@ class CombinedCatalog:
"qualified_name": entry.qualified_name, "qualified_name": entry.qualified_name,
"connection_id": entry.connection_id, "connection_id": entry.connection_id,
"local_name": entry.local_name, "local_name": entry.local_name,
"display_name": entry.display_name, "title": entry.title,
"description": entry.description, "description": entry.description,
"arguments": entry.arguments, "arguments": entry.arguments,
"metadata": entry.metadata, "metadata": entry.metadata,
+5
View File
@@ -32,6 +32,7 @@ def build_parser() -> argparse.ArgumentParser:
) )
subparsers.add_parser("connections", help="List configured connections.") subparsers.add_parser("connections", help="List configured connections.")
subparsers.add_parser("status", help="Show connection status and snapshot counts.")
subparsers.add_parser("catalog", help="Print the broker catalog as JSON.") subparsers.add_parser("catalog", help="Print the broker catalog as JSON.")
refresh = subparsers.add_parser( refresh = subparsers.add_parser(
@@ -111,6 +112,10 @@ def main(argv: list[str] | None = None) -> int:
) )
return 0 return 0
if args.command == "status":
_json_dump(service.connection_statuses())
return 0
if args.command == "catalog": if args.command == "catalog":
_json_dump(service.get_catalog().as_payload()) _json_dump(service.get_catalog().as_payload())
return 0 return 0
-1
View File
@@ -18,4 +18,3 @@ def error_payload(exc: BaseException) -> dict[str, str]:
"error_type": type(root).__name__, "error_type": type(root).__name__,
"error": str(root), "error": str(root),
} }
+11 -6
View File
@@ -9,11 +9,16 @@ from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult as McpCallToolResult from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import ClientNotification, ClientRequest from mcp.types import (
from mcp.types import ListPromptsResult, ListResourcesResult ClientNotification,
from mcp.types import ListToolsResult, Tool as McpTool ClientRequest,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
)
from mcp.types import Prompt as McpPrompt from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource from mcp.types import Resource as McpResource
from mcp.types import Tool as McpTool
from pydantic import AnyUrl from pydantic import AnyUrl
from .adapters import ( from .adapters import (
@@ -48,7 +53,7 @@ def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
) )
return DiscoveredTool( return DiscoveredTool(
name=tool.name, name=tool.name,
display_name=display_name, title=display_name,
description=tool.description, description=tool.description,
input_schema=tool.inputSchema, input_schema=tool.inputSchema,
output_schema=output_schema, output_schema=output_schema,
@@ -62,7 +67,7 @@ def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
return DiscoveredResource( return DiscoveredResource(
uri=str(resource.uri), uri=str(resource.uri),
name=local_name, name=local_name,
display_name=resource.title, title=resource.title,
description=resource.description, description=resource.description,
mime_type=resource.mimeType, mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"), metadata=resource.model_dump(by_alias=True, mode="json"),
@@ -76,7 +81,7 @@ def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
] ]
return DiscoveredPrompt( return DiscoveredPrompt(
name=prompt.name, name=prompt.name,
display_name=prompt.title, title=prompt.title,
description=prompt.description, description=prompt.description,
arguments=arguments, arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"), metadata=prompt.model_dump(by_alias=True, mode="json"),
+27 -1
View File
@@ -115,6 +115,32 @@ class WfMcpService:
self.connections.get(connection_id) self.connections.get(connection_id)
return self.store.load_catalog(connection_id) return self.store.load_catalog(connection_id)
def connection_statuses(self) -> list[dict[str, Any]]:
statuses: list[dict[str, Any]] = []
for connection in self.connections.list_all():
snapshot = self.store.load_catalog(connection.id)
statuses.append(
{
"connection_id": connection.id,
"server": connection.server,
"account": connection.account,
"enabled": connection.enabled,
"has_snapshot": snapshot is not None,
"fetched_at_epoch_ms": None
if snapshot is None
else snapshot.fetched_at_epoch_ms,
"max_age_seconds": None
if snapshot is None
else snapshot.max_age_seconds,
"node_count": 0 if snapshot is None else len(snapshot.nodes),
"resource_count": 0
if snapshot is None
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
return statuses
def list_resources( def list_resources(
self, self,
*, *,
@@ -315,7 +341,7 @@ class WfMcpService:
connection_id, connection_id,
specs=self.specs_by_connection.get(connection_id, {}), specs=self.specs_by_connection.get(connection_id, {}),
tool_display_names={ tool_display_names={
tool.name: tool.display_name for tool in capabilities.tools tool.name: tool.title for tool in capabilities.tools
}, },
resources=capabilities.resources, resources=capabilities.resources,
prompts=capabilities.prompts, prompts=capabilities.prompts,
+3 -1
View File
@@ -6,7 +6,7 @@ from mcp.server.fastmcp import FastMCP
server = FastMCP("echo-fixture") server = FastMCP("echo-fixture")
@server.tool() @server.tool(title="Echo tool")
async def echo_tool(text: str) -> dict[str, str]: async def echo_tool(text: str) -> dict[str, str]:
return {"echoed": text} return {"echoed": text}
@@ -14,6 +14,7 @@ async def echo_tool(text: str) -> dict[str, str]:
@server.resource( @server.resource(
"fixture://docs/welcome", "fixture://docs/welcome",
name="resource.welcome", name="resource.welcome",
title="Resource Welcome",
description="Welcome text resource for fixture tests.", description="Welcome text resource for fixture tests.",
mime_type="text/plain", mime_type="text/plain",
) )
@@ -22,6 +23,7 @@ def welcome_resource() -> str:
@server.prompt( @server.prompt(
title="Prompt Summarize",
name="prompt.summarize", name="prompt.summarize",
description="Summarize an input text for fixture tests.", description="Summarize an input text for fixture tests.",
) )
+5 -1
View File
@@ -60,10 +60,12 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
resource_names = {resource.name for resource in resources} resource_names = {resource.name for resource in resources}
prompt_names = {prompt.name for prompt in prompts} prompt_names = {prompt.name for prompt in prompts}
assert "get_connection_statuses" in tool_names
assert "refresh_connection_catalog" in tool_names assert "refresh_connection_catalog" in tool_names
assert "invoke_broker_method" in tool_names assert "invoke_broker_method" in tool_names
assert "catalog.all" in resource_names assert "catalog.all" in resource_names
assert "events.all" in resource_names assert "events.all" in resource_names
assert "status.all" in resource_names
assert "plan_with_catalog" in prompt_names assert "plan_with_catalog" in prompt_names
@@ -92,7 +94,9 @@ def test_broker_refresh_tool_returns_structured_error() -> None:
server = create_broker_server(service) server = create_broker_server(service)
_content, structured = asyncio.run( _content, structured = asyncio.run(
server.call_tool("refresh_connection_catalog", {"connection_id": "demo.personal"}) server.call_tool(
"refresh_connection_catalog", {"connection_id": "demo.personal"}
)
) )
assert structured == { assert structured == {
"connection_id": "demo.personal", "connection_id": "demo.personal",
+27
View File
@@ -66,3 +66,30 @@ def test_cli_catalog_prints_empty_catalog_when_not_refreshed(
assert payload["nodes"] == [] assert payload["nodes"] == []
assert payload["resources"] == [] assert payload["resources"] == []
assert payload["prompts"] == [] assert payload["prompts"] == []
def test_cli_status_prints_connection_statuses(capsys) -> None:
tmp_path = local_temp_root() / "cli_status_test"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
_write_config(config_path)
exit_code = main(["--config", str(config_path), "status"])
captured = capsys.readouterr()
assert exit_code == 0
payload = json.loads(captured.out)
assert payload == [
{
"connection_id": "demo.personal",
"server": "demo",
"account": "personal",
"enabled": True,
"has_snapshot": False,
"fetched_at_epoch_ms": None,
"max_age_seconds": None,
"node_count": 0,
"resource_count": 0,
"prompt_count": 0,
}
]
+2
View File
@@ -43,6 +43,7 @@ def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
"connection_id": "fixture.personal", "connection_id": "fixture.personal",
"local_name": "resource.welcome", "local_name": "resource.welcome",
"uri": "fixture://docs/welcome", "uri": "fixture://docs/welcome",
"title": "Resource Welcome",
"description": "Welcome text resource for fixture tests.", "description": "Welcome text resource for fixture tests.",
"mime_type": "text/plain", "mime_type": "text/plain",
"metadata": payload["resources"][0]["metadata"], "metadata": payload["resources"][0]["metadata"],
@@ -53,6 +54,7 @@ def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
"qualified_name": "fixture.personal.prompt.summarize", "qualified_name": "fixture.personal.prompt.summarize",
"connection_id": "fixture.personal", "connection_id": "fixture.personal",
"local_name": "prompt.summarize", "local_name": "prompt.summarize",
"title": "Prompt Summarize",
"description": "Summarize an input text for fixture tests.", "description": "Summarize an input text for fixture tests.",
"arguments": payload["prompts"][0]["arguments"], "arguments": payload["prompts"][0]["arguments"],
"metadata": payload["prompts"][0]["metadata"], "metadata": payload["prompts"][0]["metadata"],
+42 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import shutil
from wf_core import END, RunStatus from wf_core import END, RunStatus
from wf_mcp import ( from wf_mcp import (
@@ -116,7 +117,7 @@ def test_service_refreshes_catalog_from_adapter() -> None:
"qualified_name": "demo.personal.echo_tool", "qualified_name": "demo.personal.echo_tool",
"connection_id": "demo.personal", "connection_id": "demo.personal",
"local_name": "echo_tool", "local_name": "echo_tool",
"display_name": "Echo Tool", "title": "Echo Tool",
"description": "Echo text back", "description": "Echo text back",
"outcomes": ["ok"], "outcomes": ["ok"],
"input_schema": { "input_schema": {
@@ -140,7 +141,7 @@ def test_service_refreshes_catalog_from_adapter() -> None:
"qualified_name": "demo.personal.resource.welcome", "qualified_name": "demo.personal.resource.welcome",
"connection_id": "demo.personal", "connection_id": "demo.personal",
"local_name": "resource.welcome", "local_name": "resource.welcome",
"display_name": "Welcome Resource", "title": "Welcome Resource",
"uri": "demo://docs/welcome", "uri": "demo://docs/welcome",
"description": "Welcome resource", "description": "Welcome resource",
"mime_type": "text/plain", "mime_type": "text/plain",
@@ -152,7 +153,7 @@ def test_service_refreshes_catalog_from_adapter() -> None:
"qualified_name": "demo.personal.prompt.summarize", "qualified_name": "demo.personal.prompt.summarize",
"connection_id": "demo.personal", "connection_id": "demo.personal",
"local_name": "prompt.summarize", "local_name": "prompt.summarize",
"display_name": "Summarize Prompt", "title": "Summarize Prompt",
"description": "Summarize text", "description": "Summarize text",
"arguments": [ "arguments": [
{ {
@@ -262,8 +263,45 @@ def test_service_can_inspect_resources_and_prompts() -> None:
assert prompt.arguments[0]["name"] == "text" assert prompt.arguments[0]["name"] == "text"
def test_service_reports_connection_statuses() -> None:
store = local_temp_root() / "status_store"
# clear store before test
shutil.rmtree(store)
service = WfMcpService(store=FileStore(store))
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", FakeAdapter())
before = service.connection_statuses()
assert before == [
{
"connection_id": "demo.personal",
"server": "demo",
"account": "personal",
"enabled": True,
"has_snapshot": False,
"fetched_at_epoch_ms": None,
"max_age_seconds": None,
"node_count": 0,
"resource_count": 0,
"prompt_count": 0,
}
]
asyncio.run(service.refresh_connection_catalog("demo.personal"))
after = service.connection_statuses()
assert after[0]["has_snapshot"] is True
assert after[0]["node_count"] == 1
assert after[0]["resource_count"] == 1
assert after[0]["prompt_count"] == 1
def test_service_can_proxy_resource_reads_and_prompt_gets() -> None: def test_service_can_proxy_resource_reads_and_prompt_gets() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "proxy_store")) store = local_temp_root() / "proxy_store"
# new store
shutil.rmtree(store)
service = WfMcpService(store=FileStore(store))
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
+3 -3
View File
@@ -105,7 +105,7 @@ class FakeAdapter:
return [ return [
DiscoveredTool( DiscoveredTool(
name="echo_tool", name="echo_tool",
display_name="Echo Tool", title="Echo Tool",
description="Echo text back", description="Echo text back",
input_schema={ input_schema={
"type": "object", "type": "object",
@@ -129,7 +129,7 @@ class FakeAdapter:
DiscoveredResource( DiscoveredResource(
uri="demo://docs/welcome", uri="demo://docs/welcome",
name="resource.welcome", name="resource.welcome",
display_name="Welcome Resource", title="Welcome Resource",
description="Welcome resource", description="Welcome resource",
mime_type="text/plain", mime_type="text/plain",
metadata={"kind": "static"}, metadata={"kind": "static"},
@@ -144,7 +144,7 @@ class FakeAdapter:
return [ return [
DiscoveredPrompt( DiscoveredPrompt(
name="prompt.summarize", name="prompt.summarize",
display_name="Summarize Prompt", title="Summarize Prompt",
description="Summarize text", description="Summarize text",
arguments=[ arguments=[
{ {