235 lines
6.8 KiB
Python
235 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from mcp import GetPromptResult, ReadResourceResult
|
|
from mcp.types import (
|
|
CallToolResult,
|
|
ClientNotification,
|
|
ListPromptsResult,
|
|
ListResourcesResult,
|
|
ListToolsResult,
|
|
Prompt,
|
|
Resource,
|
|
TextContent,
|
|
Tool,
|
|
)
|
|
|
|
from wf_sources_mcp.client import McpSourceClient
|
|
from wf_sources_mcp.connections import McpSourceConnection
|
|
from wf_sources_mcp.raw_messages import RawRequest, RawResult
|
|
from wf_sources_mcp.sdk import BackendAdapter, McpSdkAdapter
|
|
from wf_sources_mcp.transports import StdioSourceTransport
|
|
|
|
|
|
def _connection() -> McpSourceConnection:
|
|
return McpSourceConnection(
|
|
id="demo.personal",
|
|
provider="demo",
|
|
account="personal",
|
|
transport=StdioSourceTransport(command="fake"),
|
|
)
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self) -> None:
|
|
self.notifications: list[ClientNotification] = []
|
|
self.requests: list[RawRequest] = []
|
|
|
|
async def list_tools(self) -> ListToolsResult:
|
|
return ListToolsResult(
|
|
tools=[
|
|
Tool(
|
|
name="echo",
|
|
title="Echo",
|
|
description="Echo text.",
|
|
input_schema={"type": "object", "properties": {}},
|
|
)
|
|
]
|
|
)
|
|
|
|
async def list_resources(self) -> ListResourcesResult:
|
|
return ListResourcesResult(
|
|
resources=[
|
|
Resource(
|
|
uri="fixture://docs/welcome",
|
|
name="resource.welcome",
|
|
title="Welcome",
|
|
description="Welcome resource.",
|
|
mime_type="text/plain",
|
|
)
|
|
]
|
|
)
|
|
|
|
async def list_prompts(self) -> ListPromptsResult:
|
|
return ListPromptsResult(
|
|
prompts=[
|
|
Prompt(
|
|
name="prompt.summarize",
|
|
title="Summarize",
|
|
description="Summarize input.",
|
|
arguments=[],
|
|
)
|
|
]
|
|
)
|
|
|
|
async def read_resource(self, uri: str) -> ReadResourceResult:
|
|
return ReadResourceResult.model_validate(
|
|
{"contents": [{"uri": str(uri), "text": "hello"}]}
|
|
)
|
|
|
|
async def get_prompt(
|
|
self,
|
|
prompt_name: str,
|
|
arguments: dict[str, str] | None = None,
|
|
) -> GetPromptResult:
|
|
return GetPromptResult.model_validate(
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": {
|
|
"type": "text",
|
|
"text": f"{prompt_name}:{arguments or {}}",
|
|
},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
async def send_request(
|
|
self,
|
|
request: RawRequest,
|
|
result_type: type[RawResult],
|
|
) -> RawResult:
|
|
self.requests.append(request)
|
|
return result_type.model_validate({"ok": True})
|
|
|
|
async def send_notification(self, notification: ClientNotification) -> None:
|
|
self.notifications.append(notification)
|
|
|
|
async def call_tool(
|
|
self,
|
|
tool_name: str,
|
|
payload: dict[str, Any],
|
|
) -> CallToolResult:
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text="ok")],
|
|
structured_content={"tool": tool_name, "payload": payload},
|
|
)
|
|
|
|
|
|
class _ClientContext:
|
|
def __init__(self, client: McpSourceClient) -> None:
|
|
self.client = client
|
|
|
|
async def __aenter__(self) -> McpSourceClient:
|
|
return self.client
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
tb: object | None,
|
|
) -> None:
|
|
return None
|
|
|
|
|
|
class _FakeAdapter(McpSdkAdapter):
|
|
def __init__(self, session: _FakeSession) -> None:
|
|
self.fake_session = session
|
|
|
|
@asynccontextmanager
|
|
async def _client(
|
|
self,
|
|
connection: McpSourceConnection,
|
|
auth: object | None,
|
|
) -> AsyncIterator[McpSourceClient]:
|
|
assert connection.id == "demo.personal"
|
|
assert auth is None
|
|
async with _ClientContext(
|
|
McpSourceClient(session=self.fake_session, connection=connection)
|
|
) as client:
|
|
yield client
|
|
|
|
|
|
class _ExplodingClientAdapter(McpSdkAdapter):
|
|
@asynccontextmanager
|
|
async def _client(
|
|
self,
|
|
connection: McpSourceConnection,
|
|
auth: object | None,
|
|
) -> AsyncIterator[McpSourceClient]:
|
|
raise AssertionError("metadata lookup must not open an MCP session")
|
|
yield # pragma: no cover
|
|
|
|
|
|
def test_mcp_sdk_adapter_implements_backend_protocol() -> None:
|
|
adapter: BackendAdapter = McpSdkAdapter()
|
|
|
|
assert adapter.__class__.__name__ == "McpSdkAdapter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_sdk_adapter_uses_session_for_all_backend_methods() -> None:
|
|
session = _FakeSession()
|
|
adapter = _FakeAdapter(session)
|
|
connection = _connection()
|
|
|
|
tools = await adapter.list_tools(connection, None)
|
|
resources = await adapter.list_resources(connection, None)
|
|
prompts = await adapter.list_prompts(connection, None)
|
|
metadata = await adapter.get_connection_metadata(connection, None)
|
|
resource_payload = await adapter.read_resource(
|
|
connection,
|
|
None,
|
|
"fixture://docs/welcome",
|
|
)
|
|
prompt_payload = await adapter.get_prompt(
|
|
connection,
|
|
None,
|
|
"prompt.summarize",
|
|
{"text": "hello"},
|
|
)
|
|
tool_result = await adapter.call_tool(connection, None, "echo", {"text": "hello"})
|
|
method_payload = await adapter.invoke_method(
|
|
connection,
|
|
None,
|
|
"ping",
|
|
)
|
|
await adapter.send_notification(
|
|
connection,
|
|
None,
|
|
"notifications/initialized",
|
|
)
|
|
|
|
assert tools[0].name == "echo"
|
|
assert resources[0].uri == "fixture://docs/welcome"
|
|
assert prompts[0].name == "prompt.summarize"
|
|
assert metadata == {"server": "demo", "transport": "stdio"}
|
|
assert resource_payload["contents"][0]["text"] == "hello"
|
|
assert prompt_payload["messages"][0]["content"]["text"] == (
|
|
"prompt.summarize:{'text': 'hello'}"
|
|
)
|
|
assert method_payload == {"ok": True}
|
|
assert session.requests, "invoke_method should have sent a request"
|
|
assert session.notifications, "send_notification should have sent a notification"
|
|
assert tool_result.outcome == "ok"
|
|
assert tool_result.output == {
|
|
"tool": "echo",
|
|
"payload": {"text": "hello"},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_sdk_adapter_metadata_does_not_open_session() -> None:
|
|
metadata = await _ExplodingClientAdapter().get_connection_metadata(
|
|
_connection(),
|
|
None,
|
|
)
|
|
|
|
assert metadata == {"server": "demo", "transport": "stdio"}
|