Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=8.3.5",
"pytest-asyncio>=0.23.0",
"pytest-asyncio>=1.1.0",
"trio>=0.30.0",
]
66 changes: 15 additions & 51 deletions src/mcpo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client

from mcpo.utils.auth import APIKeyMiddleware, get_verify_api_key
from mcpo.utils.main import (
get_model_fields,
get_tool_handler,
normalize_server_type,
from mcpo.utils.config_watcher import ConfigWatcher
from mcpo.utils.normalize_server_type import normalize_server_type
from mcpo.utils.register_tools import (
register_tools,
)
from mcpo.utils.main import get_model_fields, get_tool_handler
from mcpo.utils.auth import get_verify_api_key, APIKeyMiddleware
from mcpo.utils.config_watcher import ConfigWatcher
from mcpo.utils.oauth import create_oauth_provider

from mcpo.utils.register_resource_templates import register_resource_templates
from mcpo.utils.register_resources import register_resources

logger = logging.getLogger(__name__)

Expand All @@ -51,6 +50,7 @@ def track_task(self, task):
task.add_done_callback(self.tasks.discard)



def validate_server_config(server_name: str, server_cfg: Dict[str, Any]) -> None:
"""Validate individual server configuration."""
server_type = server_cfg.get("type")
Expand Down Expand Up @@ -147,7 +147,7 @@ def create_sub_app(server_name: str, server_cfg: Dict[str, Any], cors_allow_orig

sub_app.state.api_dependency = api_dependency
sub_app.state.connection_timeout = connection_timeout

# Store OAuth configuration if present
sub_app.state.oauth_config = server_cfg.get("oauth")

Expand Down Expand Up @@ -272,47 +272,11 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None):
if instructions:
app.description = instructions

tools_result = await session.list_tools()
tools = tools_result.tools

for tool in tools:
endpoint_name = tool.name
endpoint_description = tool.description

inputSchema = tool.inputSchema
outputSchema = getattr(tool, "outputSchema", None)

form_model_fields = get_model_fields(
f"{endpoint_name}_form_model",
inputSchema.get("properties", {}),
inputSchema.get("required", []),
inputSchema.get("$defs", {}),
)

response_model_fields = None
if outputSchema:
response_model_fields = get_model_fields(
f"{endpoint_name}_response_model",
outputSchema.get("properties", {}),
outputSchema.get("required", []),
outputSchema.get("$defs", {}),
)

tool_handler = get_tool_handler(
session,
endpoint_name,
form_model_fields,
response_model_fields,
)

app.post(
f"/{endpoint_name}",
summary=endpoint_name.replace("_", " ").title(),
description=endpoint_description,
response_model_exclude_none=True,
dependencies=[Depends(api_dependency)] if api_dependency else [],
)(tool_handler)
dependencies = [Depends(api_dependency)] if api_dependency else []

await register_tools(app, session, dependencies)
await register_resource_templates(app, session, dependencies)
await register_resources(app, session, dependencies)

@asynccontextmanager
async def lifespan(app: FastAPI):
Expand Down Expand Up @@ -406,7 +370,7 @@ async def lifespan(app: FastAPI):
# Check for OAuth configuration
oauth_config = getattr(app.state, "oauth_config", None)
auth_provider = None

if oauth_config:
server_name = app.title
logger.info(f"OAuth configuration detected for server: {server_name}")
Expand All @@ -420,7 +384,7 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Failed to create OAuth provider for {server_name}: {e}")
raise

if server_type == "stdio":
# stdio doesn't support OAuth authentication
if oauth_config:
Expand All @@ -444,7 +408,7 @@ async def lifespan(app: FastAPI):
elif server_type == "streamable-http":
headers = getattr(app.state, "headers", None)
client_context = streamablehttp_client(
url=args[0],
url=args[0],
headers=headers,
auth=auth_provider, # Pass OAuth provider if configured
)
Expand Down
135 changes: 135 additions & 0 deletions src/mcpo/tests/test_register_resource_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from fastapi import FastAPI, Depends
from mcp import ClientSession
from mcp.types import ResourceTemplate, ListResourceTemplatesResult, ReadResourceResult
from starlette.convertors import StringConvertor

from mcpo.utils.register_resource_templates import register_resource_templates, create_resource_handler


@pytest.fixture
def mock_app():
app = MagicMock(spec=FastAPI)
app.get = MagicMock(return_value=app)
return app


@pytest.fixture
def mock_session():
session = AsyncMock(spec=ClientSession)
return session


@pytest.fixture
def mock_dependencies():
return [Depends(lambda: "mock_dependency")]


@pytest.fixture
def mock_resource_templates():
return [
ResourceTemplate(
name="test_resource_1",
description="Test resource 1 description",
uriTemplate="resource://{resource_id}",
),
ResourceTemplate(
name="test_resource_2",
description="Test resource 2 description",
uriTemplate="resource://{resource_id}/subresource/{subresource_id}",
),
]


@pytest.fixture
def mock_convertors():
return {
"resource_id": StringConvertor(),
"subresource_id": StringConvertor(),
}


@pytest.mark.asyncio
async def test_register_resource_templates(mock_app, mock_session, mock_dependencies, mock_resource_templates):
mock_session.list_resource_templates.return_value = ListResourceTemplatesResult(resourceTemplates=mock_resource_templates)

await register_resource_templates(mock_app, mock_session, mock_dependencies)

assert mock_session.list_resource_templates.called
assert mock_app.get.call_count == len(mock_resource_templates)

for i, resource in enumerate(mock_resource_templates):
call_args = mock_app.get.call_args_list[i][0]
call_kwargs = mock_app.get.call_args_list[i][1]

assert call_args[0].startswith("/resource/{resource_id}")

assert call_kwargs["summary"] == resource.name.replace("_", " ").title()
assert call_kwargs["description"] == resource.description
assert call_kwargs["dependencies"] == mock_dependencies


@pytest.mark.asyncio
async def test_create_resource_handler():
session = AsyncMock(spec=ClientSession)
url = "resource://{resource_id}"
convertors = {"resource_id": StringConvertor()}

expected_result = ReadResourceResult(contents=[])
session.read_resource.return_value = expected_result

handler = create_resource_handler(session, url, convertors)

result = await handler(resource_id="123")

session.read_resource.assert_called_once()
call_args = session.read_resource.call_args[1]
assert "uri" in call_args
assert str(call_args["uri"]) == "resource://123"
assert result == []


@pytest.mark.asyncio
async def test_create_resource_handler_multiple_params():
session = AsyncMock(spec=ClientSession)
url = "resource://{resource_id}/subresource/{subresource_id}"
convertors = {
"resource_id": StringConvertor(),
"subresource_id": StringConvertor(),
}

expected_result = ReadResourceResult(contents=[])
session.read_resource.return_value = expected_result

handler = create_resource_handler(session, url, convertors)

result = await handler(resource_id="123", subresource_id="456")

session.read_resource.assert_called_once()
call_args = session.read_resource.call_args[1]
assert "uri" in call_args
assert str(call_args["uri"]) == "resource://123/subresource/456"
assert result == []


@pytest.mark.asyncio
async def test_handler_signature():
session = MagicMock(spec=ClientSession)
url = "resource://{resource_id}/subresource/{subresource_id}"
convertors = {
"resource_id": StringConvertor(),
"subresource_id": StringConvertor(),
}

handler = create_resource_handler(session, url, convertors)

import inspect
sig = inspect.signature(handler)

assert "resource_id" in sig.parameters
assert "subresource_id" in sig.parameters

assert sig.parameters["resource_id"].annotation == str
assert sig.parameters["subresource_id"].annotation == str
96 changes: 96 additions & 0 deletions src/mcpo/tests/test_register_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import pytest
from unittest.mock import AsyncMock, MagicMock

from fastapi import FastAPI, Depends
from mcp import ClientSession
from mcp.types import Resource, ListResourcesResult, ReadResourceResult
from pydantic import AnyUrl

from mcpo.utils.register_resources import register_resources, create_resource_handler


@pytest.fixture
def mock_app():
app = MagicMock(spec=FastAPI)
app.get = MagicMock(return_value=app)
return app


@pytest.fixture
def mock_session():
session = AsyncMock(spec=ClientSession)
return session


@pytest.fixture
def mock_dependencies():
return [Depends(lambda: "mock_dependency")]


@pytest.fixture
def mock_resources():
return [
Resource(
name="test_resource_1",
description="Test resource 1 description",
uri=AnyUrl(url="example://resource1"),
),
Resource(
name="test_resource_2",
description="Test resource 2 description",
uri=AnyUrl(url="example://resource2"),
),
]


@pytest.mark.anyio
async def test_register_resources(mock_app, mock_session, mock_dependencies, mock_resources):
mock_session.list_resources.return_value = ListResourcesResult(resources=mock_resources)

await register_resources(mock_app, mock_session, mock_dependencies)

assert mock_session.list_resources.called
assert mock_app.get.call_count == len(mock_resources)

for i, resource in enumerate(mock_resources):
call_args = mock_app.get.call_args_list[i][0]
call_kwargs = mock_app.get.call_args_list[i][1]

assert call_args[0] == f"/example/resource{i+1}"

assert call_kwargs["summary"] == resource.name.replace("_", " ").title()
assert call_kwargs["description"] == resource.description
assert call_kwargs["dependencies"] == mock_dependencies


@pytest.mark.anyio
async def test_create_resource_handler():
session = AsyncMock(spec=ClientSession)
url = "example://resource"

expected_result = ReadResourceResult(contents=[])
session.read_resource.return_value = expected_result

handler = create_resource_handler(session, url)

result = await handler()

session.read_resource.assert_called_once()
call_args = session.read_resource.call_args[1]
assert "uri" in call_args
assert str(call_args["uri"]) == url
assert result == []


@pytest.mark.anyio
async def test_register_resources_with_empty_resources():
app = MagicMock(spec=FastAPI)
session = AsyncMock(spec=ClientSession)
dependencies = [Depends(lambda: "mock_dependency")]

session.list_resources.return_value = ListResourcesResult(resources=[])

await register_resources(app, session, dependencies)

assert session.list_resources.called
assert app.get.call_count == 0
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pydantic import BaseModel, Field
from typing import Any, List, Dict, Union

from mcpo.utils.main import _process_schema_property
from mcpo.utils.register_tools import _process_schema_property


_model_cache = {}
Expand Down
Loading