diff --git a/dictionary-mcp-server/.actor/actor.json b/dictionary-mcp-server/.actor/actor.json new file mode 100644 index 0000000..e383bf2 --- /dev/null +++ b/dictionary-mcp-server/.actor/actor.json @@ -0,0 +1,22 @@ +{ + "actorSpecification": 1, + "name": "dictionary-mcp-server", + "title": "Dictionary MCP Server", + "description": "Dictionary and thesaurus MCP server proxy exposing define, example_usage, and synonyms tools over Streamable HTTP.", + "version": "0.0", + "buildTag": "latest", + "usesStandbyMode": true, + "meta": { + "templateId": "python-mcp-server" + }, + "input": { + "title": "Actor input schema", + "description": "This is Actor input schema", + "type": "object", + "schemaVersion": 1, + "properties": {}, + "required": [] + }, + "dockerfile": "../Dockerfile", + "webServerMcpPath": "/mcp" +} \ No newline at end of file diff --git a/dictionary-mcp-server/.actor/pay_per_event.json b/dictionary-mcp-server/.actor/pay_per_event.json new file mode 100644 index 0000000..eb46f08 --- /dev/null +++ b/dictionary-mcp-server/.actor/pay_per_event.json @@ -0,0 +1,22 @@ +{ + "actor-start": { + "eventTitle": "MCP server startup", + "eventDescription": "Initial fee for starting the MCP Server Actor", + "eventPriceUsd": 0.1 + }, + "define": { + "eventTitle": "Word definition lookup", + "eventDescription": "Fee for retrieving definitions using the define tool.", + "eventPriceUsd": 0.001 + }, + "example_usage": { + "eventTitle": "Example usage lookup", + "eventDescription": "Fee for retrieving example usage sentences using the example_usage tool.", + "eventPriceUsd": 0.001 + }, + "synonyms": { + "eventTitle": "Synonyms lookup", + "eventDescription": "Fee for retrieving synonyms using the synonyms tool.", + "eventPriceUsd": 0.001 + } +} diff --git a/dictionary-mcp-server/.gitignore b/dictionary-mcp-server/.gitignore new file mode 100644 index 0000000..ae5f0df --- /dev/null +++ b/dictionary-mcp-server/.gitignore @@ -0,0 +1,167 @@ +.mise.toml +.nvim.lua +storage + +# The rest is copied from https://github.com/github/gitignore/blob/main/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Visual Studio Code +# Ignores the folder created by VS Code when changing workspace settings, doing debugger +# configuration, etc. Can be commented out to share Workspace Settings within a team +.vscode + +# Zed editor +# Ignores the folder created when setting Project Settings in the Zed editor. Can be commented out +# to share Project Settings within a team +.zed + +# Added by Apify CLI +node_modules diff --git a/dictionary-mcp-server/Dockerfile b/dictionary-mcp-server/Dockerfile new file mode 100644 index 0000000..58f09c7 --- /dev/null +++ b/dictionary-mcp-server/Dockerfile @@ -0,0 +1,35 @@ +# First, specify the base Docker image. +# You can see the Docker images from Apify at https://hub.docker.com/r/apify/. +# You can also use any other image from Docker Hub. +FROM apify/actor-python:3.13 + +USER myuser + +# Second, copy just requirements.txt into the Actor image, +# since it should be the only file that affects the dependency installation in the next step, +# in order to speed up the build. +COPY --chown=myuser:myuser requirements.txt ./ + +# Install the packages specified in requirements.txt, +# print the installed Python version, pip version, +# and all installed packages with their versions for debugging. +RUN echo "Python version:" \ + && python --version \ + && echo "Pip version:" \ + && pip --version \ + && echo "Installing dependencies:" \ + && pip install -r requirements.txt \ + && echo "All installed Python packages:" \ + && pip freeze + +# Next, copy the remaining files and directories with the source code. +# Since we do this after installing the dependencies, quick builds will be really fast +# for most source file changes. +COPY --chown=myuser:myuser . ./ + +# Use compileall to ensure the runnability of the Actor Python code. +RUN python3 -m compileall -q src/ + +# Specify how to launch the source code of your Actor. +# By default, the "python3 -m ." command is run. +CMD ["python3", "-m", "src"] diff --git a/dictionary-mcp-server/README.md b/dictionary-mcp-server/README.md new file mode 100644 index 0000000..be695f1 --- /dev/null +++ b/dictionary-mcp-server/README.md @@ -0,0 +1,297 @@ +## Dictionary MCP Server + +Dictionary and thesaurus MCP server exposing Free Dictionary API via Streamable HTTP on Apify. Provides tools to define words, fetch example usage, and list synonyms. + +About this MCP Server: To understand how to connect to and utilize this MCP server, see the official Model Context Protocol documentation at mcp.apify.com. + +Source MCP server: casual-mcp-server-words (Python, stdio), published as a pip tool. + +Credits: All credits to the original authors of the source server. 🚩 Claim this MCP server – write to ai@apify.com. + +## Connection URL + +MCP clients can connect to this server at: + +https://mcp-servers--dictionary-mcp-server.apify.actor/mcp + +## Client Configuration + +To connect to this MCP server, use the following configuration in your MCP client: + +```json +{ + "mcpServers": { + "dictionary": { + "type": "http", + "url": "https://mcp-servers--dictionary-mcp-server.apify.actor/mcp", + "headers": { + "Authorization": "Bearer YOUR_APIFY_TOKEN" + } + } + } +} +``` + +Note: Replace YOUR_APIFY_TOKEN with your actual Apify API token. You can find your token in the Apify Console. + +## 💰 Pricing + +This template uses the [Pay Per Event (PPE)](https://docs.apify.com/platform/actors/publishing/monetize#pay-per-event-pricing-model) monetization model, which provides flexible pricing based on defined events. + +### Charging strategy options + +The template supports multiple charging approaches that you can customize based on your needs: + +#### 1. Generic MCP charging + +Charge for standard MCP operations with flat rates: + +```json +{ + "actor-start": { + "eventTitle": "MCP server startup", + "eventDescription": "Initial fee for starting the Actor MCP Server", + "eventPriceUsd": 0.1 + }, + "tool-call": { + "eventTitle": "MCP tool call", + "eventDescription": "Fee for executing MCP tools", + "eventPriceUsd": 0.05 + }, + "resource-read": { + "eventTitle": "MCP resource access", + "eventDescription": "Fee for accessing full content or resources", + "eventPriceUsd": 0.0001 + }, + "prompt-get": { + "eventTitle": "MCP prompt processing", + "eventDescription": "Fee for processing AI prompts", + "eventPriceUsd": 0.0001 + } +} +``` + +#### 2. Domain-specific charging (Words) + +Charge per tool based on functionality: + +```json +{ + "actor-start": { + "eventTitle": "MCP server startup", + "eventDescription": "Initial fee for starting the Dictionary MCP Server Actor", + "eventPriceUsd": 0.1 + }, + "define": { + "eventTitle": "Word definition lookup", + "eventDescription": "Fee for retrieving definitions", + "eventPriceUsd": 0.001 + }, + "example_usage": { + "eventTitle": "Example usage lookup", + "eventDescription": "Fee for retrieving example sentences", + "eventPriceUsd": 0.001 + }, + "synonyms": { + "eventTitle": "Synonyms lookup", + "eventDescription": "Fee for retrieving synonyms", + "eventPriceUsd": 0.001 + } +} +``` + +#### 3. No charging (free service) + +Comment out all charging lines in the code for a free service. + +### How to implement charging + +1. **Define your events** in `.actor/pay_per_event.json` (see examples above). This file is not actually used at Apify platform but serves as a reference. + +2. **Enable charging in code** by uncommenting the appropriate lines in `src/mcp_gateway.py`: + + ```python + # For generic charging: + await charge_mcp_operation(actor_charge_function, ChargeEvents.TOOL_CALL) + + # For domain-specific charging: + if tool_name == 'search_papers': + await charge_mcp_operation(actor_charge_function, ChargeEvents.SEARCH_PAPERS) + ``` + +3. **Add custom events** to `src/const.py` if needed: + + ```python + class ChargeEvents(str, Enum): + # Your custom events + CUSTOM_OPERATION = 'custom-operation' + ``` + +4. **Set up PPE model** on Apify: + - Go to your Actor's **Publication settings** + - Set the **Pricing model** to `Pay per event` + - Add your pricing schema from `pay_per_event.json` + +### Authorized tools + +This template includes **tool authorization** - only tools listed in `src/const.py` can be executed: + +**Note**: The `TOOL_WHITELIST` dictionary only applies to **tools** (executable functions). +Prompts (like `deep-paper-analysis`) are handled separately and don't need to be added to this list. + +Tool whitelist for MCP server +Only tools listed here will be present to the user and allowed to execute. +Format of the dictionary: {tool_name: (charge_event_name, default_count)} +To add new authorized tools, add an entry with the tool name and its charging configuration. + +```python +TOOL_WHITELIST = { + ChargeEvents.DEFINE.value: (ChargeEvents.DEFINE.value, 1), + ChargeEvents.EXAMPLE_USAGE.value: (ChargeEvents.EXAMPLE_USAGE.value, 1), + ChargeEvents.SYNONYMS.value: (ChargeEvents.SYNONYMS.value, 1), +} +``` + +**To add new tools:** + +1. Add charge event to `ChargeEvents` enum +2. Add tool entry to `TOOL_WHITELIST` dictionary with format: `tool_name: (event_name, count)` +3. Update pricing in `pay_per_event.json` +4. Update pricing at Apify platform + +Unauthorized tools are blocked with clear error messages. + +## 🔧 How it works + +This template implements a MCP gateway that can connect to a stdio-based, Streamable HTTP, or SSE-based MCP server and expose it via [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http). Here's how it works: + +### Server types + +1. **Stdio server** (`StdioServerParameters`): + - Spawns a local process that implements the MCP protocol over stdio. + - Configure using the `command` parameter to specify the executable and the `args` parameter for additional arguments. + - Optionally, use the `env` parameter to pass environment variables to the process. + +Example: + +```python +server_type = ServerType.STDIO +MCP_SERVER_PARAMS = StdioServerParameters( + command='uv', + args=['tool', 'run', 'casual-mcp-server-words'], + env={}, # Optional environment variables +) +``` + +2. **Remote server** (`RemoteServerParameters`): + - Connects to a remote MCP server via HTTP or SSE transport. + - Configure using the `url` parameter to specify the server's endpoint. + - Set the appropriate `server_type` (ServerType.HTTP or ServerType.SSE). + - Optionally, use the `headers` parameter to include custom headers (e.g., for authentication) and the `auth` parameter for additional authentication mechanisms. + +Example: + +```python +server_type = ServerType.HTTP +MCP_SERVER_PARAMS = RemoteServerParameters( + url='https://mcp.apify.com', + headers={'Authorization': 'Bearer YOUR-API-KEY'}, # Replace with your authentication token +) +``` + +Note: SSE transport is also supported by setting `server_type = ServerType.SSE`. + +- **Tips**: + - Ensure the remote server supports the transport type you're using and is accessible from the Actor's environment. + - Use environment variables to securely store sensitive information like tokens or API keys. + +#### Environment variables: + +Environment variables can be securely stored and managed at the Actor level on the [Apify platform](https://docs.apify.com/platform/actors/development/programming-interface/environment-variables#custom-environment-variables). These variables are automatically injected into the Actor's runtime environment, allowing you to: + +- Keep sensitive information like API keys secure. +- Simplify configuration by avoiding hardcoded values in your code. + +### Gateway implementation + +The MCP gateway (`create_gateway` function) handles: + +- Creating a Starlette web server with Streamable HTTP (`/mcp`) endpoint +- Managing connections to the underlying MCP server +- Forwarding requests and responses between clients and the MCP server +- Handling charging through the `actor_charge_function` (`Actor.charge` in Apify Actors) +- Tool authorization: Only allowing whitelisted tools to execute +- Access control: Blocking unauthorized tool calls with clear error messages + +Key components: + +- `create_gateway`: Creates an MCP server instance that acts as a gateway +- `charge_mcp_operation`: Handles charging for different MCP operations +- `TOOL_WHITELIST`: Dictionary mapping tool names to (event_name, count) tuples for authorization and charging + +### MCP operations + +The MCP gateway supports all standard MCP operations: + +- `list_tools()`: List available tools +- `call_tool()`: Execute a tool with arguments +- `list_prompts()`: List available prompts +- `get_prompt()`: Get a specific prompt +- `list_resources()`: List available resources +- `read_resource()`: Read a specific resource + +Each operation can be configured for charging in the PPE model. + +## 📚 Resources + +- [What is Anthropic's Model Context Protocol?](https://blog.apify.com/what-is-model-context-protocol/) +- [How to use MCP with Apify Actors](https://blog.apify.com/how-to-use-mcp/) +- [Apify MCP server](https://mcp.apify.com) +- [Apify MCP server documentation](https://docs.apify.com/platform/integrations/mcp) +- [Apify MCP client](https://apify.com/jiri.spilka/tester-mcp-client) +- [MCP Servers hosted at Apify](https://apify.com/store/categories/mcp-servers) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [Apify SDK documentation](https://docs.apify.com/sdk/js/) + + +## Getting started + +For complete information [see this article](https://docs.apify.com/platform/actors/development#build-actor-locally). To run the Actor use the following command: + +```bash +apify run +``` + +## Deploy to Apify + +### Connect Git repository to Apify + +If you've created a Git repository for the project, you can easily connect to Apify: + +1. Go to [Actor creation page](https://console.apify.com/actors/new) +2. Click on **Link Git Repository** button + +### Push project on your local machine to Apify + +You can also deploy the project on your local machine to Apify without the need for the Git repository. + +1. Log in to Apify. You will need to provide your [Apify API Token](https://console.apify.com/account/integrations) to complete this action. + + ```bash + apify login + ``` + +2. Deploy your Actor. This command will deploy and build the Actor on the Apify Platform. You can find your newly created Actor under [Actors -> My Actors](https://console.apify.com/actors?tab=my). + + ```bash + apify push + ``` + +## Documentation reference + +To learn more about Apify and Actors, take a look at the following resources: + +- [Apify SDK for JavaScript documentation](https://docs.apify.com/sdk/js) +- [Apify SDK for Python documentation](https://docs.apify.com/sdk/python) +- [Apify Platform documentation](https://docs.apify.com/platform) +- [Join our developer community on Discord](https://discord.com/invite/jyEM2PRvMU) diff --git a/dictionary-mcp-server/requirements.txt b/dictionary-mcp-server/requirements.txt new file mode 100644 index 0000000..1de78f6 --- /dev/null +++ b/dictionary-mcp-server/requirements.txt @@ -0,0 +1,13 @@ +# Feel free to add your Python dependencies below. For formatting guidelines, see: +# https://pip.pypa.io/en/latest/reference/requirements-file-format/ + +apify < 4.0.0 +apify-client +casual-mcp-server-words +fastapi==0.118.0 +httpx>=0.24.0 +mcp==1.13.1 +pydantic>=2.0.0 +sse-starlette>=3.0.2 +uv>=0.7.8 +uvicorn>=0.27.0 diff --git a/dictionary-mcp-server/src/__init__.py b/dictionary-mcp-server/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dictionary-mcp-server/src/__main__.py b/dictionary-mcp-server/src/__main__.py new file mode 100644 index 0000000..8a11883 --- /dev/null +++ b/dictionary-mcp-server/src/__main__.py @@ -0,0 +1,6 @@ +import asyncio + +from .main import main + +# Execute the Actor entry point. +asyncio.run(main()) diff --git a/dictionary-mcp-server/src/const.py b/dictionary-mcp-server/src/const.py new file mode 100644 index 0000000..077935f --- /dev/null +++ b/dictionary-mcp-server/src/const.py @@ -0,0 +1,37 @@ +from enum import Enum + + +class ChargeEvents(str, Enum): + """Event types for charging MCP operations. + + These events are used to charge users for different types of MCP operations + when running as an Apify Actor. Each event corresponds to a specific operation + that can be charged for. + + The template includes both generic MCP operations and domain-specific operations + (here tailored for the Dictionary server). You can customize these events based on your + specific MCP server needs. + """ + + # Generic MCP operations (can be used for any MCP server) + ACTOR_START = 'actor-start' + RESOURCE_READ = 'resource-read' + TOOL_LIST = 'tool-list' + PROMPT_GET = 'prompt-get' + TOOL_CALL = 'tool-call' + + # Words-specific operations (domain-specific charging) + DEFINE = 'define' + EXAMPLE_USAGE = 'example_usage' + SYNONYMS = 'synonyms' + + +# Tool whitelist for MCP server +# Only tools listed here will be present to the user and allowed to execute. +# Format of the dictionary: {tool_name: (charge_event_name, default_count)} +# To add new authorized tools, add an entry with the tool name and its charging configuration. +TOOL_WHITELIST = { + ChargeEvents.DEFINE.value: (ChargeEvents.DEFINE.value, 1), + ChargeEvents.EXAMPLE_USAGE.value: (ChargeEvents.EXAMPLE_USAGE.value, 1), + ChargeEvents.SYNONYMS.value: (ChargeEvents.SYNONYMS.value, 1), +} diff --git a/dictionary-mcp-server/src/event_store.py b/dictionary-mcp-server/src/event_store.py new file mode 100644 index 0000000..034ae3e --- /dev/null +++ b/dictionary-mcp-server/src/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 diff --git a/dictionary-mcp-server/src/main.py b/dictionary-mcp-server/src/main.py new file mode 100644 index 0000000..af09668 --- /dev/null +++ b/dictionary-mcp-server/src/main.py @@ -0,0 +1,117 @@ +"""Main entry point for the MCP Server Actor.""" + +import os + +from apify import Actor + +from .const import TOOL_WHITELIST, ChargeEvents +from .models import ServerType +from .server import ProxyServer + +# Actor configuration +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 at Apify platform +PORT = (Actor.is_at_home() and int(os.environ.get('ACTOR_STANDBY_PORT') or '5001')) or 5001 +SERVER_NAME = 'dictionary-mcp-server' # Name of the MCP server, without spaces + +# EDIT THIS SECTION ------------------------------------------------------------ +# Configuration constants - You need to override these values. You can also pass environment variables if needed. +# 1) If you are wrapping stdio server type, you need to provide the command and args +from mcp.client.stdio import StdioServerParameters # noqa: E402 + +server_type = ServerType.STDIO +MCP_SERVER_PARAMS = StdioServerParameters( + command='uv', + args=['tool', 'run', 'casual-mcp-server-words'], + env={}, # No extra env needed for Dictionary server +) + +# 2) If you are connecting to a Streamable HTTP or SSE server, you need to provide the url and headers if needed +# from .models import RemoteServerParameters # noqa: ERA001 + +# server_type = ServerType.HTTP # or ServerType.SSE, depending on your server type # noqa: ERA001 +# MCP_SERVER_PARAMS = RemoteServerParameters( # noqa: ERA001, RUF100 +# url='https://your-mcp-server', # noqa: ERA001 +# headers={'Authorization': 'Bearer YOUR-API-KEY'}, # Optional headers, e.g., for authentication # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# ------------------------------------------------------------------------------ + + +async def main() -> None: + """Run the MCP Server Actor. + + This function: + 1. Initializes the Actor + 2. Charges for Actor startup + 3. Creates and starts the proxy server + 4. Configures charging for MCP operations using Actor.charge + + CHARGING STRATEGIES: + The template supports multiple charging approaches: + + 1. GENERIC MCP CHARGING: + - Charge for all tool calls with a flat rate (TOOL_CALL event) + - Charge for resource operations (RESOURCE_LIST, RESOURCE_READ) + - Charge for prompt operations (PROMPT_LIST, PROMPT_GET) + - Charge for tool listing (TOOL_LIST) + + 2. DOMAIN-SPECIFIC CHARGING (Dictionary example): + - Charge different amounts for different tools + - define: $0.001 per definition lookup + - example_usage: $0.001 per examples lookup + - synonyms: $0.001 per synonyms lookup + + 3. NO CHARGING: + - Comment out all charging lines for free service + + Charging events are defined in .actor/pay_per_event.json + """ + async with Actor: + # Initialize and charge for Actor startup + Actor.log.info('Starting MCP Server Actor') + await Actor.charge(ChargeEvents.ACTOR_START.value) + + url = os.environ.get('ACTOR_STANDBY_URL', HOST) + if not STANDBY_MODE: + msg = ( + 'Actor is not designed to run in the NORMAL mode. Use MCP server URL to connect to the server.\n' + f'Connect to {url}/mcp to establish a connection.\n' + 'Learn more at https://mcp.apify.com/' + ) + Actor.log.info(msg) + await Actor.exit(status_message=msg) + return + + try: + # Create and start the server with charging enabled + Actor.log.info('Starting MCP server') + Actor.log.info('Add the following configuration to your MCP client to use Streamable HTTP transport:') + Actor.log.info( + f""" + {{ + "mcpServers": {{ + "{SERVER_NAME}": {{ + "url": "{url}/mcp", + }} + }} + }} + """ + ) + # Pass Actor.charge to enable charging for MCP operations + # The proxy server will use this to charge for different operations + proxy_server = ProxyServer( + SERVER_NAME, + MCP_SERVER_PARAMS, + HOST, + PORT, + server_type, + 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 diff --git a/dictionary-mcp-server/src/mcp_gateway.py b/dictionary-mcp-server/src/mcp_gateway.py new file mode 100644 index 0000000..4a25603 --- /dev/null +++ b/dictionary-mcp-server/src/mcp_gateway.py @@ -0,0 +1,216 @@ +"""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 + +from .const import ChargeEvents + +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) # noqa: PERF401 + tools.tools = authorized_tools + + await charge_mcp_operation(actor_charge_function, ChargeEvents.TOOL_LIST.value) + 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 not in tool_whitelist: + error_message = ( + f"The requested tool '{tool_name or 'unknown'}' is not authorized." + 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), + ) + + try: + logger.info(f"Tool call. Tool: '{tool_name}', Arguments: {arguments}") + result = await client_session.call_tool(tool_name, arguments) + logger.info(f'Tool executed successfully: {tool_name}') + + # Determine event name and count for charging (default to TOOL_CALL if not whitelisted) + default_tool_call = ChargeEvents.TOOL_CALL.value, 1 + event_name, default_count = ( + tool_whitelist.get(tool_name, default_tool_call) if tool_whitelist else default_tool_call + ) + await charge_mcp_operation(actor_charge_function, event_name, default_count) + return types.ServerResult(result) + except Exception as e: + 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 diff --git a/dictionary-mcp-server/src/models.py b/dictionary-mcp-server/src/models.py new file mode 100644 index 0000000..31cebdf --- /dev/null +++ b/dictionary-mcp-server/src/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 diff --git a/dictionary-mcp-server/src/server.py b/dictionary-mcp-server/src/server.py new file mode 100644 index 0000000..f7aaffa --- /dev/null +++ b/dictionary-mcp-server/src/server.py @@ -0,0 +1,300 @@ +"""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.requests import Request +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.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""" + +
+ +MCP endpoint 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'https://{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) + + # ASGI handler for Streamable HTTP connections + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + # Check if this is a GET request from a browser + if scope['method'] == 'GET': + request = Request(scope, receive) + if is_html_browser(request): + server_url = f'https://{request.headers.get("host", "localhost")}' + mcp_url = f'{server_url}/mcp' + response = serve_html_page(server_name, mcp_url) + await response(scope, receive, send) + return + + # For non-browser requests or non-GET requests, delegate to session manager + 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'], + ), + 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}')