My first mcp server!

This commit is contained in:
lda
2026-04-29 17:53:48 +07:00 Verified
parent a0819e8988
commit 35945b489a
4 changed files with 183 additions and 0 deletions
+15
View File
@@ -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")
+47
View File
@@ -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"}
+2
View File
@@ -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",
+119
View File
@@ -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)