refactor: move mcp sdk converters to wf_sources_mcp
This commit is contained in:
@@ -286,10 +286,13 @@ implementation state.
|
||||
Third `wf_sources_mcp` slice complete: upstream MCP catalog/discovery DTOs
|
||||
and catalog snapshot dumping now live in `wf_sources_mcp.catalog`, with
|
||||
`wf_mcp.capabilities` and `wf_mcp.catalog.models` retained as shims.
|
||||
Fourth `wf_sources_mcp` slice complete: upstream SDK protocol/result
|
||||
types (`BackendAdapter`, `ToolExecutor`, `ToolCallResult`) now live in
|
||||
`wf_sources_mcp.sdk`, with `wf_mcp.sdk` and `wf_mcp.runtime.protocols`
|
||||
retained as compatibility shims.
|
||||
Fourth `wf_sources_mcp` slice complete: upstream SDK protocol/result
|
||||
types (`BackendAdapter`, `ToolExecutor`, `ToolCallResult`) now live in
|
||||
`wf_sources_mcp.sdk`, with `wf_mcp.sdk` and `wf_mcp.runtime.protocols`
|
||||
retained as compatibility shims.
|
||||
Fifth `wf_sources_mcp` slice complete: MCP SDK conversion helpers now live
|
||||
in `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained
|
||||
as a compatibility shim.
|
||||
The `wf-mcp` script is now a legacy/special-purpose MCP entrypoint, not the
|
||||
preferred durable workflow server. New product paths should target
|
||||
`wf-rpc-server` plus neutral `wf_config`/`wf_server` composition, then keep
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
# wf_sources_mcp SDK Converters Slice Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move MCP SDK conversion helpers into `wf_sources_mcp.sdk.converters` so adapter/runtime code can later move without depending on `wf_mcp.sdk.converters`.
|
||||
|
||||
**Architecture:** `wf_sources_mcp` owns upstream MCP conversion logic from MCP SDK models into workflow-source DTOs. `wf_mcp.sdk.converters` remains a compatibility shim. This slice moves pure conversion functions only; it does not move `McpSdkAdapter`, runtime sessions, broker discovery, or upstream transport services.
|
||||
|
||||
**Tech Stack:** Python 3.14, MCP SDK model types, pytest, Ruff, basedpyright, `src/` package layout.
|
||||
|
||||
---
|
||||
|
||||
## Boundaries
|
||||
|
||||
Move only:
|
||||
|
||||
- `tool_to_discovered`
|
||||
- `resource_to_discovered`
|
||||
- `prompt_to_discovered`
|
||||
- `tool_result_to_call_result`
|
||||
- `workflow_output_schema_from_mcp_tool_schema`
|
||||
|
||||
Do not move:
|
||||
|
||||
- `McpSdkAdapter`
|
||||
- `PersistentMcpSession`
|
||||
- `PersistentSessionFactory`
|
||||
- `McpRuntimePool`
|
||||
- `discover_connection_capabilities`
|
||||
- `snapshot_from_specs`
|
||||
- `UpstreamTransportService`
|
||||
|
||||
## File Map
|
||||
|
||||
Create:
|
||||
|
||||
- `src/wf_sources_mcp/sdk/converters.py` — canonical converter helpers.
|
||||
- `tests/wf_sources_mcp/test_sdk_converters.py` — canonical converter tests.
|
||||
|
||||
Modify:
|
||||
|
||||
- `src/wf_sources_mcp/sdk/__init__.py` — export converter helpers if convenient.
|
||||
- `src/wf_mcp/sdk/converters.py` — compatibility shim.
|
||||
- `src/wf_mcp/sdk/adapter.py` — import converters from canonical path.
|
||||
- `src/wf_mcp/runtime/session.py` — import `tool_result_to_call_result` from canonical path.
|
||||
- `src/wf_mcp/broker/catalog.py` — import `workflow_output_schema_from_mcp_tool_schema` from canonical path.
|
||||
- `tests/wf_mcp/test_compat_imports.py` — shim identity test.
|
||||
- `tests/wf_sources_mcp/test_import_direction_guard.py` — guard against old converter imports.
|
||||
- `docs/current_roadmap.md` — mark converter slice complete.
|
||||
- `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md` — mark converter slice complete.
|
||||
|
||||
After implementation, move this plan to:
|
||||
|
||||
- `docs/historical/superpowers/plans/2026-06-07-wf-sources-mcp-sdk-converters-slice.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create Canonical Converter Module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/wf_sources_mcp/sdk/converters.py`
|
||||
- Modify: `src/wf_sources_mcp/sdk/__init__.py`
|
||||
- Test: `tests/wf_sources_mcp/test_sdk_converters.py`
|
||||
|
||||
- [ ] **Step 1: Create converter module**
|
||||
|
||||
Create `src/wf_sources_mcp/sdk/converters.py` by moving the current implementation from `src/wf_mcp/sdk/converters.py`:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
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 wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||
from wf_sources_mcp.sdk import ToolCallResult
|
||||
|
||||
|
||||
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
"""Convert an MCP SDK tool into the source discovery model."""
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.outputSchema)
|
||||
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 workflow_output_schema_from_mcp_tool_schema(
|
||||
schema: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the MCP tool output schema without inventing workflow fields.
|
||||
|
||||
MCP tools without structured output expose raw content blocks. Those blocks
|
||||
can be text, images, resource links, or mixed results, so this layer must not
|
||||
pretend there is a stable top-level ``text`` field. Workflow authors should
|
||||
add an explicit wrapper/extraction node for the block shape they expect.
|
||||
"""
|
||||
return schema or {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "array"}},
|
||||
}
|
||||
|
||||
|
||||
def resource_to_discovered(resource: McpResource) -> DiscoveredResource:
|
||||
"""Convert an MCP SDK resource into the source 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 source 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: dict[str, Any] = {
|
||||
"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 {},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"prompt_to_discovered",
|
||||
"resource_to_discovered",
|
||||
"tool_result_to_call_result",
|
||||
"tool_to_discovered",
|
||||
"workflow_output_schema_from_mcp_tool_schema",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export converter helpers**
|
||||
|
||||
Update `src/wf_sources_mcp/sdk/__init__.py` to import and export:
|
||||
|
||||
```python
|
||||
from .converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
```
|
||||
|
||||
Add those names to `__all__`.
|
||||
|
||||
- [ ] **Step 3: Add canonical converter tests**
|
||||
|
||||
Create `tests/wf_sources_mcp/test_sdk_converters.py` by copying `tests/wf_mcp/test_sdk_converters.py`, but change imports to:
|
||||
|
||||
```python
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
)
|
||||
```
|
||||
|
||||
Keep the four existing test bodies unchanged.
|
||||
|
||||
- [ ] **Step 4: Run canonical converter tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_sources_mcp/test_sdk_converters.py -q
|
||||
```
|
||||
|
||||
Expected: 4 tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace Old Converter Module With Shim
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/sdk/converters.py`
|
||||
- Modify: `tests/wf_mcp/test_compat_imports.py`
|
||||
|
||||
- [ ] **Step 1: Replace old converter module**
|
||||
|
||||
Set `src/wf_mcp/sdk/converters.py` to:
|
||||
|
||||
```python
|
||||
"""Compatibility shim for MCP SDK converter helpers.
|
||||
|
||||
Canonical implementation lives in `wf_sources_mcp.sdk.converters`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"prompt_to_discovered",
|
||||
"resource_to_discovered",
|
||||
"tool_result_to_call_result",
|
||||
"tool_to_discovered",
|
||||
"workflow_output_schema_from_mcp_tool_schema",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add shim identity test**
|
||||
|
||||
Append to `tests/wf_mcp/test_compat_imports.py`:
|
||||
|
||||
```python
|
||||
def test_wf_mcp_sdk_converter_shim_reexports_wf_sources_mcp_converters() -> None:
|
||||
from wf_mcp.sdk.converters import tool_result_to_call_result as compat_tool_result
|
||||
from wf_mcp.sdk.converters import tool_to_discovered as compat_tool_to_discovered
|
||||
from wf_mcp.sdk.converters import (
|
||||
workflow_output_schema_from_mcp_tool_schema as compat_output_schema,
|
||||
)
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
|
||||
assert compat_tool_result is tool_result_to_call_result
|
||||
assert compat_tool_to_discovered is tool_to_discovered
|
||||
assert compat_output_schema is workflow_output_schema_from_mcp_tool_schema
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run compatibility tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/test_compat_imports.py tests/wf_mcp/test_sdk_converters.py -q
|
||||
```
|
||||
|
||||
Expected: compatibility tests and old converter tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite Production Imports to Canonical Converter Path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/sdk/adapter.py`
|
||||
- Modify: `src/wf_mcp/runtime/session.py`
|
||||
- Modify: `src/wf_mcp/broker/catalog.py`
|
||||
|
||||
- [ ] **Step 1: Update adapter imports**
|
||||
|
||||
In `src/wf_mcp/sdk/adapter.py`, replace:
|
||||
|
||||
```python
|
||||
from .converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update runtime session import**
|
||||
|
||||
In `src/wf_mcp/runtime/session.py`, replace:
|
||||
|
||||
```python
|
||||
from ..sdk.converters import tool_result_to_call_result
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
from wf_sources_mcp.sdk.converters import tool_result_to_call_result
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update broker catalog import**
|
||||
|
||||
In `src/wf_mcp/broker/catalog.py`, replace:
|
||||
|
||||
```python
|
||||
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Confirm production imports no longer use old converter path**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "wf_mcp\\.sdk\\.converters|\\.sdk\\.converters|\\.\\.sdk\\.converters" src
|
||||
```
|
||||
|
||||
Expected: only `src/wf_mcp/sdk/converters.py` shim may mention `wf_sources_mcp.sdk.converters`; no production code imports old converter path.
|
||||
|
||||
- [ ] **Step 5: Run focused production tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/test_sdk_adapter.py tests/wf_mcp/test_sdk_converters.py tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/service/test_catalog.py -q
|
||||
```
|
||||
|
||||
Expected: all focused tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Strengthen Import-Direction Guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/wf_sources_mcp/test_import_direction_guard.py`
|
||||
|
||||
- [ ] **Step 1: Add old converter module to forbidden guard**
|
||||
|
||||
Append this test to `tests/wf_sources_mcp/test_import_direction_guard.py`:
|
||||
|
||||
```python
|
||||
def test_wf_sources_mcp_does_not_import_old_sdk_converter_module() -> None:
|
||||
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
|
||||
forbidden = {
|
||||
"wf_mcp.sdk.converters",
|
||||
}
|
||||
violations: list[str] = []
|
||||
|
||||
for py_file in sorted(root.rglob("*.py")):
|
||||
rel = py_file.relative_to(root.parent)
|
||||
module = str(rel.with_suffix("")).replace("/", ".").replace("\\", ".")
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module in forbidden:
|
||||
violations.append(f"{module}:{node.lineno}: from {node.module} import ...")
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name in forbidden:
|
||||
violations.append(f"{module}:{node.lineno}: import {alias.name}")
|
||||
|
||||
assert violations == [], (
|
||||
"wf_sources_mcp still imports old wf_mcp SDK converter module:\n"
|
||||
+ "\n".join(f" {violation}" for violation in violations)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run guard tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_sources_mcp/test_import_direction_guard.py -q
|
||||
```
|
||||
|
||||
Expected: guard tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Docs Status and Plan Archival
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/current_roadmap.md`
|
||||
- Modify: `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`
|
||||
- Move: `docs/superpowers/plans/2026-06-07-wf-sources-mcp-sdk-converters-slice.md` to `docs/historical/superpowers/plans/2026-06-07-wf-sources-mcp-sdk-converters-slice.md`
|
||||
|
||||
- [ ] **Step 1: Update roadmap**
|
||||
|
||||
In `docs/current_roadmap.md`, under the MCP package split section, add:
|
||||
|
||||
```markdown
|
||||
Fifth `wf_sources_mcp` slice complete: MCP SDK conversion helpers now live
|
||||
in `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained
|
||||
as a compatibility shim.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update long-lived API boundary spec**
|
||||
|
||||
In `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`, update the `wf_sources_mcp` status list so it includes:
|
||||
|
||||
```markdown
|
||||
5. Complete: MCP SDK conversion helpers moved to `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained as a shim.
|
||||
6. Upstream transport/discovery/session services.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Move completed plan to historical**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git mv docs/superpowers/plans/2026-06-07-wf-sources-mcp-sdk-converters-slice.md docs/historical/superpowers/plans/2026-06-07-wf-sources-mcp-sdk-converters-slice.md
|
||||
```
|
||||
|
||||
Expected: `git status --short` shows an `R` rename for this plan.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All changed files
|
||||
|
||||
- [ ] **Step 1: Run focused extraction tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_sources_mcp tests/wf_mcp/test_compat_imports.py tests/wf_mcp/test_sdk_adapter.py tests/wf_mcp/test_sdk_converters.py tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/service/test_catalog.py -q
|
||||
```
|
||||
|
||||
Expected: all focused tests pass.
|
||||
|
||||
- [ ] **Step 2: Run lint and type checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright --level error src
|
||||
```
|
||||
|
||||
Expected: Ruff reports `All checks passed!`; basedpyright reports `0 errors`.
|
||||
|
||||
- [ ] **Step 3: Run full suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
Expected: full suite passes with current skip/xfail counts. If it times out locally, rerun with a longer timeout before reporting.
|
||||
|
||||
- [ ] **Step 4: Review remaining old converter imports**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "wf_mcp\\.sdk\\.converters|from wf_mcp\\.sdk\\.converters|from \\.\\.sdk\\.converters|from \\.sdk\\.converters" src tests
|
||||
```
|
||||
|
||||
Expected: remaining occurrences are compatibility shims or tests intentionally exercising old import paths.
|
||||
|
||||
- [ ] **Step 5: Report**
|
||||
|
||||
Report:
|
||||
|
||||
- files created/modified
|
||||
- focused/full verification output
|
||||
- whether `wf_sources_mcp.sdk.converters` owns all converter helpers
|
||||
- whether `wf_mcp.sdk.converters` remains as a shim
|
||||
- deviations from this plan
|
||||
|
||||
Do not commit unless the user explicitly asks. If committing, use:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: move mcp sdk converters to wf_sources_mcp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: moves the pure conversion helpers needed before moving SDK adapter/runtime sessions.
|
||||
- Placeholder scan: no `TODO`, `TBD`, or unspecified test steps.
|
||||
- Type consistency: function names and behavior match current `wf_mcp.sdk.converters` exactly, with only import ownership changing.
|
||||
@@ -92,7 +92,8 @@ First slices should move leaf modules only and leave `wf_mcp` re-export shims:
|
||||
2. Complete: MCP source registry models/conversion moved to `wf_sources_mcp.source_registry`, with `wf_mcp.source_registry` retained as a shim.
|
||||
3. Complete: upstream MCP catalog/discovery DTOs moved to `wf_sources_mcp.catalog`, with `wf_mcp.capabilities` and `wf_mcp.catalog.models` retained as shims.
|
||||
4. Complete: upstream SDK protocol/result types moved to `wf_sources_mcp.sdk`, with `wf_mcp.sdk` and `wf_mcp.runtime.protocols` retained as shims.
|
||||
5. Upstream transport/discovery/session services.
|
||||
5. Complete: MCP SDK conversion helpers moved to `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained as a shim.
|
||||
6. Upstream transport/discovery/session services.
|
||||
|
||||
Each slice should add import-direction tests so the new source-provider package
|
||||
does not depend on `wf_mcp.workflow_surface`, `wf_mcp.admin_surface`,
|
||||
|
||||
@@ -12,9 +12,11 @@ from wf_sources_mcp.catalog import (
|
||||
DiscoveredResource,
|
||||
)
|
||||
from wf_sources_mcp.catalog.models import CatalogSnapshot
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
|
||||
from ..connections import qualify_node_name
|
||||
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||
|
||||
|
||||
def snapshot_from_specs(
|
||||
|
||||
@@ -8,10 +8,10 @@ from mcp.client.session import ClientSession
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
from wf_sources_mcp.sdk import ToolCallResult
|
||||
from wf_sources_mcp.sdk.converters import tool_result_to_call_result
|
||||
|
||||
from ..auth import AuthRecord
|
||||
from ..models import ConnectionConfig
|
||||
from ..sdk.converters import tool_result_to_call_result
|
||||
|
||||
RawToolCaller = Callable[[str, dict[str, Any]], Awaitable[CallToolResult]]
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@ from pydantic import AnyUrl
|
||||
from wf_sources_mcp.auth import AuthRecord, mcp_auth_env, mcp_auth_headers
|
||||
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult
|
||||
|
||||
from ..models import ConnectionConfig
|
||||
from .converters import (
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
)
|
||||
|
||||
from ..models import ConnectionConfig
|
||||
|
||||
|
||||
class McpSdkAdapter(BackendAdapter):
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -1,89 +1,22 @@
|
||||
"""Compatibility shim for MCP SDK converter helpers.
|
||||
|
||||
Canonical implementation lives in `wf_sources_mcp.sdk.converters`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
|
||||
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 wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||
from wf_sources_mcp.sdk import ToolCallResult
|
||||
|
||||
|
||||
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
"""Convert an MCP SDK tool into the broker discovery model."""
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.outputSchema)
|
||||
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 workflow_output_schema_from_mcp_tool_schema(
|
||||
schema: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the MCP tool output schema without inventing workflow fields.
|
||||
|
||||
MCP tools without structured output expose raw content blocks. Those blocks
|
||||
can be text, images, resource links, or mixed results, so wf_mcp must not
|
||||
pretend there is a stable top-level ``text`` field. Workflow authors should
|
||||
add an explicit wrapper/extraction node for the block shape they expect.
|
||||
"""
|
||||
return schema or {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "array"}},
|
||||
}
|
||||
|
||||
|
||||
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: dict[str, Any] = {
|
||||
"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 {},
|
||||
)
|
||||
__all__ = [
|
||||
"prompt_to_discovered",
|
||||
"resource_to_discovered",
|
||||
"tool_result_to_call_result",
|
||||
"tool_to_discovered",
|
||||
"workflow_output_schema_from_mcp_tool_schema",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
from .protocols import BackendAdapter, ToolCallResult, ToolExecutor
|
||||
|
||||
__all__ = [
|
||||
"BackendAdapter",
|
||||
"ToolCallResult",
|
||||
"ToolExecutor",
|
||||
"prompt_to_discovered",
|
||||
"resource_to_discovered",
|
||||
"tool_result_to_call_result",
|
||||
"tool_to_discovered",
|
||||
"workflow_output_schema_from_mcp_tool_schema",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
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 wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||
|
||||
from .protocols import ToolCallResult
|
||||
|
||||
|
||||
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
"""Convert an MCP SDK tool into the source discovery model."""
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.outputSchema)
|
||||
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 workflow_output_schema_from_mcp_tool_schema(
|
||||
schema: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the MCP tool output schema without inventing workflow fields.
|
||||
|
||||
MCP tools without structured output expose raw content blocks. Those blocks
|
||||
can be text, images, resource links, or mixed results, so this layer must not
|
||||
pretend there is a stable top-level ``text`` field. Workflow authors should
|
||||
add an explicit wrapper/extraction node for the block shape they expect.
|
||||
"""
|
||||
return schema or {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "array"}},
|
||||
}
|
||||
|
||||
|
||||
def resource_to_discovered(resource: McpResource) -> DiscoveredResource:
|
||||
"""Convert an MCP SDK resource into the source 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 source 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: dict[str, Any] = {
|
||||
"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 {},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"prompt_to_discovered",
|
||||
"resource_to_discovered",
|
||||
"tool_result_to_call_result",
|
||||
"tool_to_discovered",
|
||||
"workflow_output_schema_from_mcp_tool_schema",
|
||||
]
|
||||
@@ -124,3 +124,26 @@ def test_wf_mcp_runtime_protocol_shim_reexports_wf_sources_mcp_tool_executor() -
|
||||
|
||||
assert CompatRuntimeToolExecutor is ToolExecutor
|
||||
assert CompatProtocolToolExecutor is ToolExecutor
|
||||
|
||||
|
||||
def test_wf_mcp_sdk_converter_shim_reexports_wf_sources_mcp_converters() -> None:
|
||||
from wf_mcp.sdk.converters import prompt_to_discovered as compat_prompt
|
||||
from wf_mcp.sdk.converters import resource_to_discovered as compat_resource
|
||||
from wf_mcp.sdk.converters import tool_result_to_call_result as compat_tool_result
|
||||
from wf_mcp.sdk.converters import tool_to_discovered as compat_tool_to_discovered
|
||||
from wf_mcp.sdk.converters import (
|
||||
workflow_output_schema_from_mcp_tool_schema as compat_output_schema,
|
||||
)
|
||||
from wf_sources_mcp.sdk.converters import (
|
||||
prompt_to_discovered,
|
||||
resource_to_discovered,
|
||||
tool_result_to_call_result,
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
|
||||
assert compat_prompt is prompt_to_discovered
|
||||
assert compat_resource is resource_to_discovered
|
||||
assert compat_tool_result is tool_result_to_call_result
|
||||
assert compat_tool_to_discovered is tool_to_discovered
|
||||
assert compat_output_schema is workflow_output_schema_from_mcp_tool_schema
|
||||
|
||||
@@ -100,3 +100,28 @@ def test_wf_sources_mcp_does_not_import_old_sdk_protocol_modules() -> None:
|
||||
"wf_sources_mcp still imports old wf_mcp SDK/runtime protocol modules:\n"
|
||||
+ "\n".join(f" {violation}" for violation in violations)
|
||||
)
|
||||
|
||||
|
||||
def test_wf_sources_mcp_does_not_import_old_sdk_converter_module() -> None:
|
||||
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
|
||||
forbidden = {
|
||||
"wf_mcp.sdk.converters",
|
||||
}
|
||||
violations: list[str] = []
|
||||
|
||||
for py_file in sorted(root.rglob("*.py")):
|
||||
rel = py_file.relative_to(root.parent)
|
||||
module = str(rel.with_suffix("")).replace("/", ".").replace("\\", ".")
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module in forbidden:
|
||||
violations.append(f"{module}:{node.lineno}: from {node.module} import ...")
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name in forbidden:
|
||||
violations.append(f"{module}:{node.lineno}: import {alias.name}")
|
||||
|
||||
assert violations == [], (
|
||||
"wf_sources_mcp still imports old wf_mcp SDK converter module:\n"
|
||||
+ "\n".join(f" {violation}" for violation in violations)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.types import CallToolResult, TextContent, Tool
|
||||
|
||||
from wf_sources_mcp.sdk.converters import tool_result_to_call_result, tool_to_discovered
|
||||
|
||||
|
||||
def test_tool_without_output_schema_exposes_raw_content_schema() -> None:
|
||||
tool = Tool(
|
||||
name="echo",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
discovered = tool_to_discovered(tool)
|
||||
|
||||
properties = discovered.output_schema["properties"]
|
||||
assert properties["content"]["type"] == "array"
|
||||
assert "text" not in properties
|
||||
|
||||
|
||||
def test_tool_with_content_only_output_schema_stays_raw() -> None:
|
||||
tool = Tool(
|
||||
name="echo",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
outputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"description": "Raw MCP content blocks.",
|
||||
}
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
)
|
||||
|
||||
discovered = tool_to_discovered(tool)
|
||||
|
||||
properties = discovered.output_schema["properties"]
|
||||
assert properties["content"]["type"] == "array"
|
||||
assert "text" not in properties
|
||||
assert discovered.output_schema["required"] == ["content"]
|
||||
|
||||
|
||||
def test_tool_result_single_text_content_block_stays_in_content() -> None:
|
||||
result = CallToolResult(
|
||||
content=[TextContent(type="text", text="Echo: hello")],
|
||||
)
|
||||
|
||||
converted = tool_result_to_call_result(result)
|
||||
|
||||
assert converted.outcome == "ok"
|
||||
assert "text" not in converted.output
|
||||
assert converted.output["content"][0]["type"] == "text"
|
||||
assert converted.output["content"][0]["text"] == "Echo: hello"
|
||||
|
||||
|
||||
def test_tool_result_structured_content_is_not_rewritten() -> None:
|
||||
result = CallToolResult(
|
||||
content=[TextContent(type="text", text="ignored")],
|
||||
structuredContent={"value": "structured"},
|
||||
)
|
||||
|
||||
converted = tool_result_to_call_result(result)
|
||||
|
||||
assert converted.output["value"] == "structured"
|
||||
assert "text" not in converted.output
|
||||
Reference in New Issue
Block a user