My first mcp server!
This commit is contained in:
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
|
||||||
|
server = FastMCP("echo-fixture")
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def echo_tool(text: str) -> dict[str, str]:
|
||||||
|
return {"echoed": text}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server.run("stdio")
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from wf_authoring import NodeReturn, node
|
from wf_authoring import NodeReturn, node
|
||||||
@@ -13,6 +15,7 @@ from wf_mcp import (
|
|||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
DiscoveredTool,
|
DiscoveredTool,
|
||||||
FileStore,
|
FileStore,
|
||||||
|
McpSdkAdapter,
|
||||||
RawWorkflowPlan,
|
RawWorkflowPlan,
|
||||||
ToolCallResult,
|
ToolCallResult,
|
||||||
WfMcpService,
|
WfMcpService,
|
||||||
@@ -56,6 +59,10 @@ def _local_temp_root() -> Path:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_server_path() -> str:
|
||||||
|
return str((Path(__file__).parent / "fixtures" / "mcp_echo_server.py").resolve())
|
||||||
|
|
||||||
|
|
||||||
class FakeAdapter:
|
class FakeAdapter:
|
||||||
async def list_tools(
|
async def list_tools(
|
||||||
self,
|
self,
|
||||||
@@ -218,3 +225,43 @@ def test_service_refreshes_catalog_from_adapter() -> None:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(_local_temp_root() / "sdk_adapter_store"))
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(
|
||||||
|
id="fixture.personal",
|
||||||
|
server="fixture",
|
||||||
|
account="personal",
|
||||||
|
metadata={
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [_fixture_server_path()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.register_adapter("fixture", McpSdkAdapter())
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(service.refresh_connection_catalog("fixture.personal"))
|
||||||
|
except PermissionError as exc:
|
||||||
|
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
|
||||||
|
|
||||||
|
payload = service.get_catalog().as_payload()
|
||||||
|
assert payload["nodes"][0]["qualified_name"] == "fixture.personal.echo_tool"
|
||||||
|
|
||||||
|
adapter = McpSdkAdapter()
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
adapter.call_tool(
|
||||||
|
connection=service.connections.get("fixture.personal"),
|
||||||
|
auth=None,
|
||||||
|
tool_name="echo_tool",
|
||||||
|
payload={"text": "hello"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
|
||||||
|
assert result.outcome == "ok"
|
||||||
|
assert result.output == {"echoed": "hello"}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .models import (
|
|||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
RawWorkflowPlan,
|
RawWorkflowPlan,
|
||||||
)
|
)
|
||||||
|
from .mcp_sdk_adapter import McpSdkAdapter
|
||||||
from .service import WfMcpService
|
from .service import WfMcpService
|
||||||
from .store import FileStore, Store
|
from .store import FileStore, Store
|
||||||
from .wrappers import wrap_discovered_tool
|
from .wrappers import wrap_discovered_tool
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"ConnectionRegistry",
|
"ConnectionRegistry",
|
||||||
"DiscoveredTool",
|
"DiscoveredTool",
|
||||||
"FileStore",
|
"FileStore",
|
||||||
|
"McpSdkAdapter",
|
||||||
"RawWorkflowPlan",
|
"RawWorkflowPlan",
|
||||||
"Store",
|
"Store",
|
||||||
"ToolCallResult",
|
"ToolCallResult",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.client.session import ClientSession
|
||||||
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
from mcp.types import CallToolResult as McpCallToolResult
|
||||||
|
from mcp.types import ListToolsResult, Tool as McpTool
|
||||||
|
|
||||||
|
from .adapters import BackendAdapter, DiscoveredTool, ToolCallResult
|
||||||
|
from .models import AuthRecord, ConnectionConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
|
||||||
|
if auth is None:
|
||||||
|
return {}
|
||||||
|
headers = dict(auth.payload.get("headers", {}))
|
||||||
|
token = auth.payload.get("token")
|
||||||
|
if isinstance(token, str) and "Authorization" not in headers:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||||
|
output_schema = tool.outputSchema or {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"content": {"type": "array"}},
|
||||||
|
}
|
||||||
|
return DiscoveredTool(
|
||||||
|
name=tool.name,
|
||||||
|
description=tool.description,
|
||||||
|
input_schema=tool.inputSchema,
|
||||||
|
output_schema=output_schema,
|
||||||
|
outcomes=("ok", "error"),
|
||||||
|
metadata=tool.model_dump(by_alias=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _session(
|
||||||
|
self,
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
auth: AuthRecord | None,
|
||||||
|
):
|
||||||
|
transport = connection.metadata.get("transport", "stdio")
|
||||||
|
if transport == "stdio":
|
||||||
|
command = connection.metadata["command"]
|
||||||
|
args = list(connection.metadata.get("args", []))
|
||||||
|
env = connection.metadata.get("env")
|
||||||
|
cwd = connection.metadata.get("cwd")
|
||||||
|
if auth is not None:
|
||||||
|
auth_env = auth.payload.get("env")
|
||||||
|
if isinstance(auth_env, dict):
|
||||||
|
env = {**(env or {}), **auth_env}
|
||||||
|
params = StdioServerParameters(
|
||||||
|
command=command,
|
||||||
|
args=args,
|
||||||
|
env=env,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
async with stdio_client(params) as (read_stream, write_stream):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
|
||||||
|
if transport == "streamable_http":
|
||||||
|
url = connection.metadata["url"]
|
||||||
|
headers = _auth_headers(auth)
|
||||||
|
http_client = httpx.AsyncClient(headers=headers or None)
|
||||||
|
async with http_client:
|
||||||
|
async with streamable_http_client(
|
||||||
|
url,
|
||||||
|
http_client=http_client,
|
||||||
|
) as (read_stream, write_stream, _get_session_id):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(f"unsupported MCP transport {transport!r}")
|
||||||
|
|
||||||
|
async def list_tools(
|
||||||
|
self,
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
auth: AuthRecord | None,
|
||||||
|
) -> list[DiscoveredTool]:
|
||||||
|
async with self._session(connection, auth) as session:
|
||||||
|
result: ListToolsResult = await session.list_tools()
|
||||||
|
return [_tool_to_discovered(tool) for tool in result.tools]
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
auth: AuthRecord | None,
|
||||||
|
tool_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> ToolCallResult:
|
||||||
|
async with self._session(connection, auth) as session:
|
||||||
|
result = await session.call_tool(tool_name, payload)
|
||||||
|
return _tool_result_to_call_result(result)
|
||||||
Reference in New Issue
Block a user