diff --git a/apify-mcp-server-sdk/README.md b/apify-mcp-server-sdk/README.md new file mode 100644 index 0000000..fdc5973 --- /dev/null +++ b/apify-mcp-server-sdk/README.md @@ -0,0 +1,147 @@ +# Apify MCP Server SDK + +A comprehensive Python SDK for building Apify MCP (Model Context Protocol) server implementations with built-in support for charging, tool authorization, and multiple transport protocols. + +## Features + +- **Multi-Transport Support**: Connect to stdio, SSE, or HTTP-based MCP servers +- **Tool Authorization**: Whitelist-based tool authorization with flexible charging +- **HTML Browser Support**: Automatic detection and serving of HTML pages for browser requests +- **OAuth Integration**: Built-in OAuth authorization server support +- **Event Store**: In-memory event store for resumability functionality +- **Flexible Charging**: Configurable charging for different MCP operations +- **Easy Configuration**: Simple configuration utilities for common setups + +## Installation + +```bash +pip install apify-mcp-server-sdk +``` + +## Usage + +### Simple Calculator Server + +```python +import os +from enum import Enum +from apify import Actor +from apify_mcp_server_sdk import ServerType, create_stdio_config, run_mcp_server + +class ChargeEvents(str, Enum): + ACTOR_START = 'actor-start' + TOOL_CALL = 'tool-call' + +async def main(): + # Configure the MCP server + mcp_server_params = create_stdio_config( + command='uv', + args=['run', 'mcp-server-calculator'], + ) + + # Run the server with charging enabled + await run_mcp_server( + server_name='calculator-mcp-server', + mcp_server_params=mcp_server_params, + server_type=ServerType.STDIO, + actor_charge_function=Actor.charge, + ) + +if __name__ == '__main__': + import asyncio + asyncio.run(main()) +``` + +### Remote Server with Authentication + +```python +import os +from enum import Enum +from apify import Actor +from apify_mcp_server_sdk import ServerType, create_remote_config, run_mcp_server + +class ChargeEvents(str, Enum): + ACTOR_START = 'actor-start' + TOOL_CALL = 'tool-call' + +async def main(): + # Configure remote MCP server with authentication + mcp_server_params = create_remote_config( + url='https://your-remote-mcp-server.com/mcp', + headers={'Authorization': f'Bearer {os.getenv("API_KEY")}'}, + timeout=60.0, + ) + + # Run the server + await run_mcp_server( + server_name='remote-mcp-server', + mcp_server_params=mcp_server_params, + server_type=ServerType.HTTP, + actor_charge_function=Actor.charge, + ) +``` + +### Advanced: Tool Whitelisting and Custom Charging + +```python +import os +from enum import Enum +from apify import Actor +from apify_mcp_server_sdk import ProxyServer, ServerType, create_stdio_config, get_actor_config + +class ChargeEvents(str, Enum): + ACTOR_START = 'actor-start' + GENERATE_SLIDE = 'generate-slide' + GET_TEMPLATES = 'get-templates' + +async def main(): + # Configure MCP server + mcp_server_params = create_stdio_config( + command='npx', + args=['mcp-remote', 'https://mcp.slidespeak.co/mcp', '--header', f'Authorization: Bearer {os.getenv("API_KEY")}'], + ) + + # Define tool whitelist with custom charging + tool_whitelist = { + 'generatePowerpoint': (ChargeEvents.GENERATE_SLIDE.value, 1), + 'getAvailableTemplates': (ChargeEvents.GET_TEMPLATES.value, 1), + } + + # Get actor configuration + host, port, standby_mode = get_actor_config() + + async with Actor: + await Actor.charge(ChargeEvents.ACTOR_START.value) + + # Create proxy server with custom configuration + proxy_server = ProxyServer( + server_name='slidespeak-mcp-server', + config=mcp_server_params, + host=host, + port=port, + server_type=ServerType.STDIO, + actor_charge_function=Actor.charge, + ) + await proxy_server.start() +``` + +## Development + +```bash +# Install in development mode +pip install -e . + +# Run tests +pytest + +# Format code +black src/ +isort src/ + +# Type checking +mypy src/ +``` + +## License + +MIT \ No newline at end of file diff --git a/apify-mcp-server-sdk/examples/remote_server.py b/apify-mcp-server-sdk/examples/remote_server.py new file mode 100644 index 0000000..b7db4e5 --- /dev/null +++ b/apify-mcp-server-sdk/examples/remote_server.py @@ -0,0 +1,46 @@ +"""Example: Remote MCP Server using the SDK.""" + +import os +from enum import Enum + +from apify import Actor +from apify_mcp_server_sdk import ( + ServerType, + create_remote_config, + run_mcp_server, +) + + +class ChargeEvents(str, Enum): + """Event types for charging MCP operations.""" + ACTOR_START = 'actor-start' + TOOL_CALL = 'tool-call' + RESOURCE_READ = 'resource-read' + + +async def main() -> None: + """Run a remote MCP server.""" + # Configure the remote MCP server + mcp_server_params = create_remote_config( + url='https://your-remote-mcp-server.com/mcp', + headers={'Authorization': f'Bearer {os.getenv("API_KEY", "your-api-key")}'}, + timeout=60.0, + ) + + async with Actor: + # Initialize and charge for Actor startup + Actor.log.info('Starting Remote MCP Server Actor') + await Actor.charge(ChargeEvents.ACTOR_START.value) + + # Run the server with charging enabled + await run_mcp_server( + server_name='remote-mcp-server', + mcp_server_params=mcp_server_params, + server_type=ServerType.HTTP, # or ServerType.SSE + actor_charge_function=Actor.charge, + ) + + +if __name__ == '__main__': + import asyncio + asyncio.run(main()) \ No newline at end of file diff --git a/apify-mcp-server-sdk/examples/simple_calculator.py b/apify-mcp-server-sdk/examples/simple_calculator.py new file mode 100644 index 0000000..15d4e6c --- /dev/null +++ b/apify-mcp-server-sdk/examples/simple_calculator.py @@ -0,0 +1,45 @@ +"""Example: Simple Calculator MCP Server using the SDK.""" + +import os +from enum import Enum + +from apify import Actor +from apify_mcp_server_sdk import ( + ServerType, + create_stdio_config, + run_mcp_server, +) + + +class ChargeEvents(str, Enum): + """Event types for charging MCP operations.""" + ACTOR_START = 'actor-start' + TOOL_CALL = 'tool-call' + + +async def main() -> None: + """Run the calculator MCP server.""" + # Configure the MCP server to run calculator + mcp_server_params = create_stdio_config( + command='uv', + args=['run', 'mcp-server-calculator'], + env={'YOUR-ENV_VAR': os.getenv('YOUR-ENV-VAR') or ''}, + ) + + async with Actor: + # Initialize and charge for Actor startup + Actor.log.info('Starting Calculator MCP Server Actor') + await Actor.charge(ChargeEvents.ACTOR_START.value) + + # Run the server with charging enabled + await run_mcp_server( + server_name='calculator-mcp-server', + mcp_server_params=mcp_server_params, + server_type=ServerType.STDIO, + actor_charge_function=Actor.charge, + ) + + +if __name__ == '__main__': + import asyncio + asyncio.run(main()) \ No newline at end of file diff --git a/apify-mcp-server-sdk/examples/slidespeak_with_whitelist.py b/apify-mcp-server-sdk/examples/slidespeak_with_whitelist.py new file mode 100644 index 0000000..24deca6 --- /dev/null +++ b/apify-mcp-server-sdk/examples/slidespeak_with_whitelist.py @@ -0,0 +1,86 @@ +"""Example: SlideSpeak MCP Server with tool whitelisting using the SDK.""" + +import os +from enum import Enum + +from apify import Actor +from apify_mcp_server_sdk import ( + ProxyServer, + ServerType, + create_stdio_config, + get_actor_config, +) + + +class ChargeEvents(str, Enum): + """Event types for charging MCP operations.""" + ACTOR_START = 'actor-start' + GET_TEMPLATES = 'get-templates' + GENERATE_SLIDE = 'generate-slide' + GENERATE_SLIDE_BY_SLIDE = 'generate-slide-by-slide' + GET_TASK_STATUS = 'get-task-status' + + +async def main() -> None: + """Run the SlideSpeak MCP server with tool whitelisting.""" + # Get the API key from environment + slidespeak_api_key = os.getenv('SLIDESPEAK_API_KEY') + if not slidespeak_api_key: + raise ValueError("SLIDESPEAK_API_KEY environment variable not set!") + + # Configure the MCP server + mcp_server_params = create_stdio_config( + command='uv', + args=[ + 'run', + 'mcp-remote', + 'https://mcp.slidespeak.co/mcp', + '--header', + f'Authorization: Bearer {slidespeak_api_key}', + ], + ) + + # Define tool whitelist with charging events + tool_whitelist = { + 'generatePowerpoint': (ChargeEvents.GENERATE_SLIDE.value, 1), + 'getAvailableTemplates': (ChargeEvents.GET_TEMPLATES.value, 1), + 'generateSlideBySlide': (ChargeEvents.GENERATE_SLIDE_BY_SLIDE.value, 1), + 'getTaskStatus': (ChargeEvents.GET_TASK_STATUS.value, 1), + } + + # Get actor configuration + host, port, standby_mode = get_actor_config() + + async with Actor: + # Initialize and charge for Actor startup + Actor.log.info('Starting SlideSpeak MCP Server Actor') + await Actor.charge(ChargeEvents.ACTOR_START.value) + + if not standby_mode: + msg = 'This Actor is not meant to be run directly. It should be run in standby mode.' + Actor.log.error(msg) + await Actor.exit(status_message=msg) + return + + try: + # Create and start the server with tool whitelisting + proxy_server = ProxyServer( + server_name='slidespeak-mcp-server', + config=mcp_server_params, + host=host, + port=port, + server_type=ServerType.STDIO, + actor_charge_function=Actor.charge, + tool_whitelist=tool_whitelist, + ) + + await proxy_server.start() + except Exception as e: + Actor.log.exception(f'Server failed to start: {e}') + await Actor.exit() + raise + + +if __name__ == '__main__': + import asyncio + asyncio.run(main()) \ No newline at end of file diff --git a/apify-mcp-server-sdk/pyproject.toml b/apify-mcp-server-sdk/pyproject.toml new file mode 100644 index 0000000..131ed16 --- /dev/null +++ b/apify-mcp-server-sdk/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[project] +name = "apify-mcp-server-sdk" +version = "0.1.0" +description = "Common utilities for Apify MCP server implementations" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Apify", email = "dev@apify.com"}, +] +keywords = ["mcp", "apify", "server", "proxy"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "apify<3.0.0", + "apify_client<2.0.0", + "fastapi>=0.116.0", + "mcp>=1.10.1", + "pydantic>=2.0.0", + "sse-starlette>=1.8.0", + "uvicorn>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/apify_mcp_server_sdk"] + +[tool.black] +line-length = 100 +target-version = ['py38'] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true + +[tool.ruff] +target-version = "py38" +line-length = 100 +select = ["E", "F", "W", "C90", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "DJ", "EM", "EXE", "FA", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TCH", "INT", "ARG", "PTH", "TD", "FIX", "ERA", "PD", "PGH", "PL", "TRY", "FLY", "NPY", "AIR", "PERF", "FURB", "LOG", "RUF"] +ignore = ["S101", "S104", "PLR0913", "PLR0912", "PLR0915", "ANN204", "ANN202", "SLF001", "ERA001", "RUF100"] \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/__init__.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/__init__.py new file mode 100644 index 0000000..e2d3e2b --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/__init__.py @@ -0,0 +1,22 @@ +"""Apify MCP Server SDK - Comprehensive SDK for building Apify MCP server implementations.""" + +from .base_main import run_mcp_server +from .config import create_remote_config, create_stdio_config, get_actor_config +from .event_store import InMemoryEventStore +from .mcp_gateway import create_gateway +from .models import RemoteServerParameters, ServerParameters, ServerType +from .server import ProxyServer + +__version__ = "0.1.0" +__all__ = [ + "ProxyServer", + "InMemoryEventStore", + "ServerParameters", + "RemoteServerParameters", + "ServerType", + "create_gateway", + "run_mcp_server", + "get_actor_config", + "create_stdio_config", + "create_remote_config", +] \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/base_main.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/base_main.py new file mode 100644 index 0000000..b5ad9f3 --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/base_main.py @@ -0,0 +1,37 @@ +"""Base main function template for MCP servers.""" + +import os +from typing import Any + +from .config import get_actor_config +from .server import ProxyServer + + +async def run_mcp_server( + server_name: str, + mcp_server_params: Any, + server_type: Any, + actor_charge_function: Any | None = None, + startup_delay: float = 0.0, +) -> None: + """Run an MCP server with common setup. + + Args: + server_name: Name of the server for logging and HTML page + mcp_server_params: MCP server parameters (stdio or remote) + server_type: Type of server (STDIO, SSE, or HTTP) + actor_charge_function: Optional charging function + startup_delay: Optional startup delay in seconds + """ + host, port, standby_mode = get_actor_config() + + # Create and start the proxy server + proxy_server = ProxyServer( + server_name, + mcp_server_params, + host, + port, + server_type, + actor_charge_function=actor_charge_function + ) + await proxy_server.start() \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/config.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/config.py new file mode 100644 index 0000000..f4d6ba9 --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/config.py @@ -0,0 +1,88 @@ +"""Configuration utilities for MCP servers.""" + +import os +from typing import Any + +from apify import Actor +from mcp.client.stdio import StdioServerParameters + +from .models import RemoteServerParameters + + +def get_actor_config() -> tuple[str, int, bool]: + """Get common Actor configuration. + + Returns: + Tuple of (host, port, standby_mode) + """ + standby_mode = os.environ.get('APIFY_META_ORIGIN') == 'STANDBY' + # Bind to all interfaces (0.0.0.0) as this is running in a containerized environment (Apify Actor) + # The container's network is isolated, so this is safe + host = '0.0.0.0' # noqa: S104 - Required for container networking in Apify platform + port = (Actor.is_at_home() and int(os.environ.get('ACTOR_STANDBY_PORT') or '5001')) or 5001 + + return host, port, standby_mode + + +def create_stdio_config( + command: str, + args: list[str], + env: dict[str, str] | None = None, +) -> StdioServerParameters: + """Create a stdio server configuration. + + Args: + command: Command to run + args: Command arguments + env: Optional environment variables + + Returns: + StdioServerParameters instance + """ + return StdioServerParameters( + command=command, + args=args, + env=env or {}, + ) + + +def create_remote_config( + url: str, + headers: dict[str, Any] | None = None, + timeout: float = 60.0, + sse_read_timeout: float = 300.0, +) -> RemoteServerParameters: + """Create a remote server configuration (SSE or HTTP). + + Args: + url: Server URL + headers: Optional HTTP headers + timeout: Connection timeout + sse_read_timeout: SSE read timeout + + Returns: + RemoteServerParameters instance + """ + return RemoteServerParameters( + url=url, + headers=headers, + timeout=timeout, + sse_read_timeout=sse_read_timeout, + ) + + +def get_client_config_urls(host: str, port: int) -> dict[str, str]: + """Get client configuration URLs for both transport types. + + Args: + host: Server host + port: Server port + + Returns: + Dict with HTTP and SSE URLs + """ + base_url = f"http://{host}:{port}" + return { + 'http': f"{base_url}/mcp", + 'sse': f"{base_url}/sse", + } \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/event_store.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/event_store.py new file mode 100644 index 0000000..7c54160 --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/event_store.py @@ -0,0 +1,98 @@ +# Source https://github.com/modelcontextprotocol/python-sdk/blob/3978c6e1b91e8830e82d97ab3c4e3b6559972021/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import ( + EventCallback, + EventId, + EventMessage, + EventStore, + StreamId, +) +from mcp.types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ # noqa: D205 + + def __init__(self, max_events_per_stream: int = 100): # noqa: ANN204 + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> EventId: + """Stores an event with a generated event ID.""" # noqa: D401 + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f'Event ID {last_event_id} not found in store') + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/mcp_gateway.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/mcp_gateway.py new file mode 100644 index 0000000..d45d01d --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/mcp_gateway.py @@ -0,0 +1,222 @@ +"""Create an MCP server that proxies requests through an MCP client. + +This server is created independent of any transport mechanism. +Source: https://github.com/sparfenyuk/mcp-proxy + +The server can optionally charge for MCP operations using a provided charging function. +This is typically used in Apify Actors to charge users for different types of MCP operations +like tool calls, prompt operations, or resource access. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from mcp import server, types + +# Note: AUTHORIZED_TOOLS, ChargeEvents, and get_charge_event should be defined in your project +# from .const import AUTHORIZED_TOOLS, ChargeEvents, get_charge_event + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from mcp.client.session import ClientSession + +logger = logging.getLogger('apify') + + +async def charge_mcp_operation( + charge_function: Callable[[str, int], Awaitable[Any]] | None, event_name: str | None, count: int = 1 +) -> None: + """Charge for an MCP operation. + + Args: + charge_function: Function to call for charging, or None if charging is disabled + event_name: The type of event to charge for + count: The number of times the event occurred (typically 1, but can be more) + """ + if not charge_function: + return + + if not event_name: + return + + try: + await charge_function(event_name, count) + logger.info(f'Charged for event: {event_name}') + except Exception: + logger.exception(f'Failed to charge for event {event_name}') + # Don't raise the exception - we want the operation to continue even if charging fails + + +async def create_gateway( # noqa: PLR0915 + client_session: ClientSession, + actor_charge_function: Callable[[str, int], Awaitable[Any]] | None = None, + tool_whitelist: dict[str, tuple[str, int]] | None = None, +) -> server.Server[object]: + """Create a server instance from a remote app. + + Args: + client_session: The MCP client session to proxy requests through + actor_charge_function: Optional function to charge for operations. + Should accept (event_name: str, count: int). + Typically, Actor.charge in Apify Actors. + If None, no charging will occur. + tool_whitelist: Optional dict mapping tool names to (event_name, default_count) tuples. + If provided, only whitelisted tools will be allowed and charged. + If None, all tools are allowed without specific charging. + """ + logger.debug('Sending initialization request to remote MCP server...') + response = await client_session.initialize() + capabilities: types.ServerCapabilities = response.capabilities + + logger.debug('Configuring proxied MCP server...') + app: server.Server = server.Server(name=response.serverInfo.name, version=response.serverInfo.version) + + if capabilities.prompts: + logger.debug('Capabilities: adding Prompts...') + + async def _list_prompts(_: Any) -> types.ServerResult: + result = await client_session.list_prompts() + return types.ServerResult(result) + + app.request_handlers[types.ListPromptsRequest] = _list_prompts + + async def _get_prompt(req: types.GetPromptRequest) -> types.ServerResult: + # Uncomment the line below to charge for getting prompts + # await charge_mcp_operation(actor_charge_function, ChargeEvents.PROMPT_GET) # noqa: ERA001 + result = await client_session.get_prompt(req.params.name, req.params.arguments) + return types.ServerResult(result) + + app.request_handlers[types.GetPromptRequest] = _get_prompt + + if capabilities.resources: + logger.debug('Capabilities: adding Resources...') + + async def _list_resources(_: Any) -> types.ServerResult: + result = await client_session.list_resources() + return types.ServerResult(result) + + app.request_handlers[types.ListResourcesRequest] = _list_resources + + async def _list_resource_templates(_: Any) -> types.ServerResult: + result = await client_session.list_resource_templates() + return types.ServerResult(result) + + app.request_handlers[types.ListResourceTemplatesRequest] = _list_resource_templates + + async def _read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + # Uncomment the line below to charge for reading resources + # await charge_mcp_operation(actor_charge_function, ChargeEvents.RESOURCE_READ) # noqa: ERA001 + result = await client_session.read_resource(req.params.uri) + return types.ServerResult(result) + + app.request_handlers[types.ReadResourceRequest] = _read_resource + + if capabilities.logging: + logger.debug('Capabilities: adding Logging...') + + async def _set_logging_level(req: types.SetLevelRequest) -> types.ServerResult: + await client_session.set_logging_level(req.params.level) + return types.ServerResult(types.EmptyResult()) + + app.request_handlers[types.SetLevelRequest] = _set_logging_level + + if capabilities.resources: + logger.debug('Capabilities: adding Resources...') + + async def _subscribe_resource(req: types.SubscribeRequest) -> types.ServerResult: + await client_session.subscribe_resource(req.params.uri) + return types.ServerResult(types.EmptyResult()) + + app.request_handlers[types.SubscribeRequest] = _subscribe_resource + + async def _unsubscribe_resource(req: types.UnsubscribeRequest) -> types.ServerResult: + await client_session.unsubscribe_resource(req.params.uri) + return types.ServerResult(types.EmptyResult()) + + app.request_handlers[types.UnsubscribeRequest] = _unsubscribe_resource + + if capabilities.tools: + logger.debug('Capabilities: adding Tools...') + + async def _list_tools(_: Any) -> types.ServerResult: + tools = await client_session.list_tools() + + # Filter tools to only include authorized ones if whitelist is provided + if tool_whitelist: + authorized_tools = [] + for tool in tools.tools: + if tool.name in tool_whitelist: + authorized_tools.append(tool) + tools.tools = authorized_tools + + await charge_mcp_operation(actor_charge_function, 'tool-list') + return types.ServerResult(tools) + + app.request_handlers[types.ListToolsRequest] = _list_tools + + async def _call_tool(req: types.CallToolRequest) -> types.ServerResult: + tool_name = req.params.name + arguments = req.params.arguments or {} + + # Safe diagnostic logging for every tool call + logger.info(f"Received tool call, tool: '{tool_name}', arguments: {arguments}") + + # Tool whitelisting and charging logic + if tool_whitelist and tool_name in tool_whitelist: + event_name, default_count = tool_whitelist[tool_name] + + # Use default count for all tools + count = default_count + + await charge_mcp_operation(actor_charge_function, event_name, count) + + elif tool_whitelist: + # Block any tool not on the whitelist + error_message = f"The requested tool '{tool_name or 'unknown'}' is not authorized." + error_message += f' Authorized tools are: {list(tool_whitelist.keys())}' + logger.error(f'Blocking unauthorized tool call for: {tool_name or "unknown tool"}') + return types.ServerResult( + types.CallToolResult(content=[types.TextContent(type='text', text=error_message)], isError=True), + ) + else: + # No whitelist - charge with default event + await charge_mcp_operation(actor_charge_function, 'tool-call') + + # If the tool was authorized, proceed with the call + try: + logger.info(f"ATTEMPTING tool call. Tool: '{tool_name}', Arguments: {arguments}") + result = await client_session.call_tool(tool_name, arguments) + logger.info(f'Tool executed successfully: {tool_name}') + return types.ServerResult(result) + except Exception as e: + # Log the full exception for debugging + error_details = f"SERVER FAILED. Tool: '{tool_name}'. Arguments: {arguments}. Full exception: {e}" + logger.exception(error_details) + return types.ServerResult( + types.CallToolResult(content=[types.TextContent(type='text', text=error_details)], isError=True), + ) + + app.request_handlers[types.CallToolRequest] = _call_tool + + async def _send_progress_notification(req: types.ProgressNotification) -> None: + await client_session.send_progress_notification( + req.params.progressToken, + req.params.progress, + req.params.total, + ) + + app.notification_handlers[types.ProgressNotification] = _send_progress_notification + + async def _complete(req: types.CompleteRequest) -> types.ServerResult: + result = await client_session.complete( + req.params.ref, + req.params.argument.model_dump(), + ) + return types.ServerResult(result) + + app.request_handlers[types.CompleteRequest] = _complete + + return app \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/models.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/models.py new file mode 100644 index 0000000..c3b2152 --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/models.py @@ -0,0 +1,36 @@ +from enum import Enum +from typing import Any, TypeAlias + +import httpx +from mcp.client.stdio import StdioServerParameters +from pydantic import BaseModel, ConfigDict + + +class ServerType(str, Enum): + """Type of server to connect.""" + + STDIO = 'stdio' # Connect to a stdio server + SSE = 'sse' # Connect to an SSE server + HTTP = 'http' # Connect to an HTTP server (Streamable HTTP) + + +class RemoteServerParameters(BaseModel): + """Parameters for connecting to a Streamable HTTP or SSE-based MCP server. + + These parameters are passed either to the `streamable http_client` or `sse_client` from MCP SDK. + + Attributes: + url: The URL of the HTTP or SSE server endpoint + headers: Optional HTTP headers to include in the connection request + """ + + url: str + headers: dict[str, Any] | None = None + timeout: float = 60 # HTTP timeout for regular operations + sse_read_timeout: float = 60 * 5 # Timeout for SSE read operations + auth: httpx.Auth | None = None # Optional HTTPX authentication handler + model_config = ConfigDict(arbitrary_types_allowed=True) + + +# Type alias for server parameters +ServerParameters: TypeAlias = StdioServerParameters | RemoteServerParameters \ No newline at end of file diff --git a/apify-mcp-server-sdk/src/apify_mcp_server_sdk/server.py b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/server.py new file mode 100644 index 0000000..82f255f --- /dev/null +++ b/apify-mcp-server-sdk/src/apify_mcp_server_sdk/server.py @@ -0,0 +1,311 @@ +"""Module implementing an MCP server that can be used to connect to stdio or SSE based MCP servers. + +Heavily inspired by: https://github.com/sparfenyuk/mcp-proxy +""" + +from __future__ import annotations + +import contextlib +import logging +from typing import TYPE_CHECKING, Any + +import httpx +import uvicorn +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.client.streamable_http import streamablehttp_client +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from pydantic import ValidationError +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse, RedirectResponse, Response +from starlette.routing import Mount, Route + +from .event_store import InMemoryEventStore +from .mcp_gateway import create_gateway +from .models import RemoteServerParameters, ServerParameters, ServerType + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Awaitable, Callable + + from mcp.server import Server + from starlette import types as st + from starlette.requests import Request + from starlette.types import Receive, Scope, Send + +logger = logging.getLogger('apify') + + +def is_html_browser(request: Request) -> bool: + """Detect if the request is from an HTML browser based on Accept header.""" + accept_header = request.headers.get('accept', '') + return 'text/html' in accept_header + + +def get_html_page(server_name: str, mcp_url: str) -> str: + """Generate simple HTML page with server URL and MCP client link.""" + return f""" + + + + {server_name} + + + +

{server_name}

+

MCP endpoint URL:

+
{mcp_url}
+ +

Add to your MCP client (e.g. VS code):

+
{{
+  "mcpServers": {{
+    "{server_name.lower().replace(' ', '-')}": {{
+      "type": "http",
+      "url": "{mcp_url}",
+      "headers": {{
+        "Authorization": "Bearer YOUR_APIFY_TOKEN"
+      }}
+    }}
+  }}
+}}
+ +""" + + +def serve_html_page(server_name: str, mcp_url: str) -> Response: + """Serve HTML page for browser requests.""" + html = get_html_page(server_name, mcp_url) + return Response(content=html, media_type='text/html') + + +class McpPathRewriteMiddleware(BaseHTTPMiddleware): + """Add middleware to rewrite /mcp to /mcp/ to ensure consistent path handling. + + This is necessary so that Starlette does not return a 307 Temporary Redirect on the /mcp path, + which would otherwise trigger the OAuth flow when the MCP server is deployed on the Apify platform. + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Any: + """Rewrite the request path.""" + if request.url.path == '/mcp': + request.scope['path'] = '/mcp/' + request.scope['raw_path'] = b'/mcp/' + return await call_next(request) + + +class ProxyServer: + """Main class implementing the proxy functionality using MCP SDK. + + This proxy runs a Starlette app that exposes a /mcp endpoint for Streamable HTTP transport. + It then connects to stdio or remote MCP servers and forwards the messages to the client. + Note: SSE endpoint serving has been deprecated, but SSE client connections are still supported. + + The server can optionally charge for operations using a provided charging function. + This is typically used in Apify Actors to charge users for MCP operations. + The charging function should accept an event name and optional parameters. + """ + + def __init__( # noqa: PLR0913 + self, + server_name: str, + config: ServerParameters, + host: str, + port: int, + server_type: ServerType, + actor_charge_function: Callable[[str, int], Awaitable[Any]] | None = None, + tool_whitelist: dict[str, tuple[str, int]] | None = None, + ) -> None: + """Initialize the proxy server. + + Args: + server_name: Name of the server (used in HTML page) + config: Server configuration (stdio or SSE parameters) + host: Host to bind the server to + port: Port to bind the server to + server_type: Type of server to connect (stdio, SSE, or HTTP) + actor_charge_function: Optional function to charge for operations. + Should accept (event_name: str, count: int). + Typically, Actor.charge in Apify Actors. + If None, no charging will occur. + tool_whitelist: Optional dict mapping tool names to (event_name, default_count) tuples. + If provided, only whitelisted tools will be allowed and charged. + If None, all tools are allowed without specific charging. + """ + self.server_name = server_name + self.server_type = server_type + self.config = self._validate_config(self.server_type, config) + self.host: str = host + self.port: int = port + self.actor_charge_function = actor_charge_function + self.tool_whitelist = tool_whitelist + + @staticmethod + def _validate_config(client_type: ServerType, config: ServerParameters) -> ServerParameters | None: + """Validate and return the appropriate server parameters.""" + try: + match client_type: + case ServerType.STDIO: + return StdioServerParameters.model_validate(config) + case ServerType.SSE | ServerType.HTTP: + return RemoteServerParameters.model_validate(config) + case _: + raise ValueError(f'Unsupported server type: {client_type}') + except ValidationError as e: + raise ValueError(f'Invalid server configuration: {e}') from e + + @staticmethod + async def create_starlette_app(server_name: str, mcp_server: Server) -> Starlette: + """Create a Starlette app that exposes /mcp endpoint for Streamable HTTP transport.""" + event_store = InMemoryEventStore() + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=event_store, # Enable resume ability for Streamable HTTP connections + json_response=False, + ) + + @contextlib.asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Context manager for managing session manager lifecycle.""" + async with session_manager.run(): + logger.info('Application started with StreamableHTTP session manager!') + try: + yield + finally: + logger.info('Application shutting down...') + + async def handle_root(request: Request) -> st.Response: + """Handle root endpoint.""" + # Handle Apify standby readiness probe + if 'x-apify-container-server-readiness-probe' in request.headers: + return Response( + content=b'ok', + media_type='text/plain', + status_code=200, + ) + + # Browser client logic - Check if the request is from a HTML browser + if is_html_browser(request): + server_url = f'{request.url.scheme}://{request.headers.get("host", "localhost")}' + mcp_url = f'{server_url}/mcp' + return serve_html_page(server_name, mcp_url) + + return JSONResponse( + { + 'status': 'running', + 'type': 'mcp-server', + 'transport': 'streamable-http', + 'endpoints': { + 'streamableHttp': '/mcp', + }, + } + ) + + async def handle_favicon(_request: Request) -> st.Response: + """Handle favicon.ico requests by redirecting to Apify's favicon.""" + return RedirectResponse(url='https://apify.com/favicon.ico', status_code=301) + + async def handle_oauth_authorization_server(_request: Request) -> st.Response: + """Handle OAuth authorization server well-known endpoint.""" + try: + # Some MCP clients do not follow redirects, so we need to fetch the data and return it directly. + async with httpx.AsyncClient() as client: + response = await client.get('https://api.apify.com/.well-known/oauth-authorization-server') + response.raise_for_status() + data = response.json() + return JSONResponse(data, status_code=200) + except Exception: + logger.exception('Error fetching OAuth authorization server data') + return JSONResponse({'error': 'Failed to fetch OAuth authorization server data'}, status_code=500) + + async def handle_mcp_get(request: Request) -> st.Response: + """Handle GET requests to /mcp endpoint.""" + # Browser client logic - Check if the request is from a HTML browser + if is_html_browser(request): + server_url = f'{request.url.scheme}://{request.headers.get("host", "localhost")}' + mcp_url = f'{server_url}/mcp' + return serve_html_page(server_name, mcp_url) + + # For non-browser requests, return error as GET is not supported for MCP + return JSONResponse( + { + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'message': 'Bad Request: GET method not supported for MCP endpoint', + }, + 'id': None, + }, + status_code=400, + ) + + # ASGI handler for Streamable HTTP connections + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + await session_manager.handle_request(scope, receive, send) + + return Starlette( + debug=True, + routes=[ + Route('/', endpoint=handle_root), + Route('/favicon.ico', endpoint=handle_favicon, methods=['GET']), + Route( + '/.well-known/oauth-authorization-server', + endpoint=handle_oauth_authorization_server, + methods=['GET'], + ), + Route('/mcp/', endpoint=handle_mcp_get, methods=['GET']), + Mount('/mcp/', app=handle_streamable_http), + ], + lifespan=lifespan, + middleware=[Middleware(McpPathRewriteMiddleware)], + ) + + async def _run_server(self, app: Starlette) -> None: + """Run the Starlette app with uvicorn.""" + config_ = uvicorn.Config(app, host=self.host, port=self.port, log_level='info', access_log=True) + server = uvicorn.Server(config_) + await server.serve() + + async def start(self) -> None: + """Start Starlette app and connect to stdio, Streamable HTTP, or SSE based MCP server.""" + logger.info(f'Starting MCP server with client type: {self.server_type} and config {self.config}') + params: dict = (self.config and self.config.model_dump(exclude_unset=True)) or {} + + if self.server_type == ServerType.STDIO: + # validate config again to prevent mypy errors + config_ = StdioServerParameters.model_validate(self.config) + async with ( + stdio_client(config_) as (read_stream, write_stream), + ClientSession(read_stream, write_stream) as session, + ): + mcp_server = await create_gateway(session, self.actor_charge_function, self.tool_whitelist) + app = await self.create_starlette_app(self.server_name, mcp_server) + await self._run_server(app) + + elif self.server_type == ServerType.SSE: + async with ( + sse_client(**params) as (read_stream, write_stream), + ClientSession(read_stream, write_stream) as session, + ): + mcp_server = await create_gateway(session, self.actor_charge_function, self.tool_whitelist) + app = await self.create_starlette_app(self.server_name, mcp_server) + await self._run_server(app) + + elif self.server_type == ServerType.HTTP: + # HTTP streamable server needs to unpack three parameters + async with ( + streamablehttp_client(**params) as (read_stream, write_stream, _), + ClientSession(read_stream, write_stream) as session, + ): + mcp_server = await create_gateway(session, self.actor_charge_function, self.tool_whitelist) + app = await self.create_starlette_app(self.server_name, mcp_server) + await self._run_server(app) + else: + raise ValueError(f'Unknown server type: {self.server_type}') \ No newline at end of file diff --git a/weather-mcp-server/storage/key_value_stores/default/INPUT.json b/weather-mcp-server/storage/key_value_stores/default/INPUT.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/weather-mcp-server/storage/key_value_stores/default/INPUT.json @@ -0,0 +1 @@ +{} \ No newline at end of file