wf-mcp reorg 3 the generated docs

This commit is contained in:
lda
2026-05-07 16:22:27 +07:00 Verified
parent 496dc78c55
commit 17fb356536
8 changed files with 142 additions and 73 deletions
+53
View File
@@ -0,0 +1,53 @@
# wf_mcp Architecture Boundaries
`wf_mcp` is one distribution for now, but it is organized as separable concerns.
The goal is to keep future package extraction cheap without adding packaging
overhead before the APIs settle.
## Packages
| Package | Responsibility |
| --- | --- |
| `wf_mcp.transparent_proxy` | Expose configured upstream MCP servers as a transparent MCP proxy. Owns proxy runtime, admin tools, and proxy tool listing helpers. |
| `wf_mcp.broker` | Coordinate remembered connections, catalog snapshots, discovery, events, and workflow execution through broker services. |
| `wf_mcp.workflow` | Convert discovered MCP tools into `wf_authoring` / `wf_core` node specs. |
| `wf_mcp.sdk` | Speak to upstream MCP servers through the MCP Python SDK. Owns adapter protocols, SDK transport/session calls, and SDK object converters. |
| `wf_mcp.control` | Parse and mutate file-backed proxy/broker configuration. |
| `wf_mcp.storage` | Persist auth records and catalog snapshots. |
| `wf_mcp.shared` | Pure helpers used across concerns, such as names, pagination, and error payloads. |
Root modules such as `wf_mcp.store`, `wf_mcp.service`, and
`wf_mcp.mcp_sdk_adapter` are compatibility shims. New internal imports should
prefer the concern package directly.
## Dependency Rules
- `wf_mcp.sdk` should not import `wf_core` or `wf_authoring`.
- `wf_mcp.transparent_proxy` should not import `wf_mcp.workflow`.
- `wf_mcp.workflow` is the only layer that converts MCP capabilities into node specs.
- `wf_mcp.broker` may coordinate `sdk`, `storage`, `control`, and `workflow`.
- `wf_mcp.control` should not know about live MCP clients or workflow execution.
- `wf_mcp.shared` should stay pure and should not import other `wf_mcp` concern packages.
- Root compatibility shims should stay thin: import and re-export only.
## Hot Reload
Transparent proxy reload is intentionally isolated in
`wf_mcp.transparent_proxy.runtime`. FastMCP does not currently expose a complete
provider/proxy unmount lifecycle that we can rely on for safe per-connection
teardown. Until that exists, reload should be treated as best-effort remounting,
not a fully safe session/subscription lifecycle.
Do not add notification proxying or long-lived subscription handling across
reloads without first introducing an explicit mount lifecycle boundary.
## Future Extraction
If this becomes multiple distributions, likely split points are:
- `wf-mcp-proxy`: `transparent_proxy`, `control`, `shared`
- `wf-mcp-broker`: `broker`, `storage`, `workflow`, `shared`
- `wf-mcp-sdk`: `sdk`, `capabilities`, `models`, `shared`
For now, keep one distribution and use import discipline to preserve those
boundaries.
+1 -1
View File
@@ -2,7 +2,7 @@
name = "lda-wf" name = "lda-wf"
version = "0.0.1" version = "0.0.1"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "readme.md"
authors = [{ name = "lda", email = "[email protected]" }] authors = [{ name = "lda", email = "[email protected]" }]
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
+4 -3
View File
@@ -2,9 +2,10 @@
## Related docs ## Related docs
- [authoring_sketch.md](authoring_sketch.md): `wf_authoring` direction, including `@node`, `NodeSpec`, builder ergonomics, async registry seams, and subgraph-as-node. - [docs/authoring_sketch.md](docs/authoring_sketch.md): `wf_authoring` direction, including `@node`, `NodeSpec`, builder ergonomics, async registry seams, and subgraph-as-node.
- [wf_mcp_plan.md](wf_mcp_plan.md): `wf_mcp` direction as a namespaced MCP capability broker plus workflow build/run layer. - [docs/wf_mcp_plan.md](docs/wf_mcp_plan.md): `wf_mcp` direction as a namespaced MCP capability broker plus workflow build/run layer.
- [scratchpad.md](scratchpad.md): rougher design history and intermediate spec notes that fed the current model. - [docs/wf_mcp_architecture.md](docs/wf_mcp_architecture.md): current `wf_mcp` package boundaries and dependency rules.
- [docs/scratchpad.md](docs/scratchpad.md): rougher design history and intermediate spec notes that fed the current model.
## What is this ## What is this
+10 -69
View File
@@ -8,7 +8,6 @@ from mcp import ClientResult
from mcp.client.session import ClientSession 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 ( from mcp.types import (
ClientNotification, ClientNotification,
ClientRequest, ClientRequest,
@@ -16,14 +15,17 @@ from mcp.types import (
ListResourcesResult, ListResourcesResult,
ListToolsResult, ListToolsResult,
) )
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
from mcp.types import Tool as McpTool
from pydantic import AnyUrl from pydantic import AnyUrl
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig from ..models import AuthRecord, ConnectionConfig
from .base import BackendAdapter, ToolCallResult from .base import BackendAdapter, ToolCallResult
from .converters import (
prompt_to_discovered,
resource_to_discovered,
tool_result_to_call_result,
tool_to_discovered,
)
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]: def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
@@ -36,67 +38,6 @@ def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
return headers return headers
def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
display_name = (
tool.annotations.title
if tool.annotations is not None and tool.annotations.title
else tool.title
)
return DiscoveredTool(
name=tool.name,
title=display_name,
description=tool.description,
input_schema=tool.inputSchema,
output_schema=output_schema,
outcomes=("ok", "error"),
metadata=tool.model_dump(by_alias=True, mode="json"),
)
def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
local_name = resource.name or str(resource.uri)
return DiscoveredResource(
uri=str(resource.uri),
name=local_name,
title=resource.title,
description=resource.description,
mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"),
)
def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
arguments = [
argument.model_dump(by_alias=True, mode="json")
for argument in prompt.arguments or []
]
return DiscoveredPrompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"),
)
def _tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
outcome="error" if result.isError else "ok",
output=output,
meta=result.meta or {},
)
class McpSdkAdapter(BackendAdapter): class McpSdkAdapter(BackendAdapter):
@asynccontextmanager @asynccontextmanager
async def _session( async def _session(
@@ -149,7 +90,7 @@ class McpSdkAdapter(BackendAdapter):
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
result: ListToolsResult = await session.list_tools() result: ListToolsResult = await session.list_tools()
return [_tool_to_discovered(tool) for tool in result.tools] return [tool_to_discovered(tool) for tool in result.tools]
async def list_resources( async def list_resources(
self, self,
@@ -158,7 +99,7 @@ class McpSdkAdapter(BackendAdapter):
) -> list[DiscoveredResource]: ) -> list[DiscoveredResource]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
result: ListResourcesResult = await session.list_resources() result: ListResourcesResult = await session.list_resources()
return [_resource_to_discovered(resource) for resource in result.resources] return [resource_to_discovered(resource) for resource in result.resources]
async def list_prompts( async def list_prompts(
self, self,
@@ -167,7 +108,7 @@ class McpSdkAdapter(BackendAdapter):
) -> list[DiscoveredPrompt]: ) -> list[DiscoveredPrompt]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
result: ListPromptsResult = await session.list_prompts() result: ListPromptsResult = await session.list_prompts()
return [_prompt_to_discovered(prompt) for prompt in result.prompts] return [prompt_to_discovered(prompt) for prompt in result.prompts]
async def get_connection_metadata( async def get_connection_metadata(
self, self,
@@ -235,4 +176,4 @@ class McpSdkAdapter(BackendAdapter):
) -> ToolCallResult: ) -> ToolCallResult:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
result = await session.call_tool(tool_name, payload) result = await session.call_tool(tool_name, payload)
return _tool_result_to_call_result(result) return tool_result_to_call_result(result)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
from mcp.types import Tool as McpTool
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from .base import ToolCallResult
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
"""Convert an MCP SDK tool into the broker discovery model."""
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
display_name = (
tool.annotations.title
if tool.annotations is not None and tool.annotations.title
else tool.title
)
return DiscoveredTool(
name=tool.name,
title=display_name,
description=tool.description,
input_schema=tool.inputSchema,
output_schema=output_schema,
outcomes=("ok", "error"),
metadata=tool.model_dump(by_alias=True, mode="json"),
)
def resource_to_discovered(resource: McpResource) -> DiscoveredResource:
"""Convert an MCP SDK resource into the broker discovery model."""
local_name = resource.name or str(resource.uri)
return DiscoveredResource(
uri=str(resource.uri),
name=local_name,
title=resource.title,
description=resource.description,
mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"),
)
def prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
"""Convert an MCP SDK prompt into the broker discovery model."""
arguments = [
argument.model_dump(by_alias=True, mode="json")
for argument in prompt.arguments or []
]
return DiscoveredPrompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"),
)
def tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
"""Convert an MCP SDK tool call result into the adapter result model."""
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
outcome="error" if result.isError else "ok",
output=output,
meta=result.meta or {},
)