From 32dd756885aef80405a6807ac90f564a95f1a234 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 25 Jan 2026 15:56:42 +0100 Subject: [PATCH] feat: Implement Docker deployment with configurable SSE/STDIO modes and add `get_current_time` tool. --- Dockerfile | 28 +++++++++++ README.md | 33 +++++++++++++ debug_mcp.py | 11 +++++ debug_settings.py | 6 +++ debug_sig.py | 5 ++ docker-compose.yml | 14 ++++++ entrypoint.sh | 13 +++++ sig.txt | 1 + sig_output.txt | 2 + src/__pycache__/caldav_client.cpython-314.pyc | Bin 8246 -> 8228 bytes src/__pycache__/server.cpython-314.pyc | Bin 0 -> 7489 bytes src/server.py | 16 ++++++- test_mcp.py | 45 ++++++++++++++++++ 13 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 debug_mcp.py create mode 100644 debug_settings.py create mode 100644 debug_sig.py create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 sig.txt create mode 100644 sig_output.txt create mode 100644 src/__pycache__/server.cpython-314.pyc create mode 100644 test_mcp.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..129151d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Use Python 3.14 as requested (using rc-slim as 3.14 is likely in pre-release) +# If 3.14-slim-bookworm is not available, this might need adjustment to python:3.14-rc-slim or similar. +FROM python:3.14-rc-slim-bookworm + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Sync dependencies (frozen to ensure reproducibility) +RUN uv sync --frozen + +# Copy source code +COPY src/ src/ +COPY entrypoint.sh . + +# Make entrypoint executable +RUN chmod +x entrypoint.sh + +# Expose port 8000 for SSE mode +EXPOSE 8000 + +# Set entrypoint +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index d561cf6..86240f9 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,42 @@ CALDAV_PASSWORD=YourPassword ## Usage ### Running Manually + +#### Standard I/O (Default) You can run the server directly using `uv`. It communicates via standard input/output (stdio), so running it directly in a terminal will just wait for input. ```bash uv run src/server.py ``` +#### HTTP (SSE) +To run the server over HTTP (Server-Sent Events) - useful for testing with Postman/Inspector or remote access: + +```bash +uv run uvicorn src.server:mcp.sse_app --host 0.0.0.0 --port 8000 +``` +**Endpoint:** `http://localhost:8000/sse` + +### Docker Deployment + +You can run the server using Docker and Docker Compose. Environment variables are loaded from the `.env` file. + +**Build and Run (SSE Mode by default):** +```bash +docker compose up --build +``` + +**Switching Modes:** +The container supports two modes via the `MCP_MODE` environment variable: +- `SSE` (default): Runs the HTTP server on port 8000. +- `STDIO`: Runs the script via standard I/O. + +To run in STDIO mode (e.g., for piping): +```bash +docker compose run -e MCP_MODE=STDIO -T caldev_mcp +``` + + ### Configuring an MCP Client To use this with an MCP client (like Claude Desktop or another MCP-compatible app), add the following configuration to your client's settings (e.g., `claude_desktop_config.json`): @@ -73,6 +103,9 @@ To use this with an MCP client (like Claude Desktop or another MCP-compatible ap ## Available Tools +- **`get_current_time()`** + - Returns the current local machine time in ISO format. + - **`list_calendars()`** - Returns a list of all calendars the user has access to. diff --git a/debug_mcp.py b/debug_mcp.py new file mode 100644 index 0000000..f7cd4fe --- /dev/null +++ b/debug_mcp.py @@ -0,0 +1,11 @@ +try: + from src.server import mcp + print(f"DIR: {dir(mcp)}") + try: + print(f"DICT_KEYS: {list(mcp.__dict__.keys())}") + except: + pass +except ImportError: + print("ImportError") +except Exception as e: + print(f"Error: {e}") diff --git a/debug_settings.py b/debug_settings.py new file mode 100644 index 0000000..86f24df --- /dev/null +++ b/debug_settings.py @@ -0,0 +1,6 @@ +from src.server import mcp +try: + print(f"SETTINGS DIR: {dir(mcp.settings)}") + print(f"SETTINGS DICT: {mcp.settings.__dict__}") +except Exception as e: + print(f"Error: {e}") diff --git a/debug_sig.py b/debug_sig.py new file mode 100644 index 0000000..171ffa5 --- /dev/null +++ b/debug_sig.py @@ -0,0 +1,5 @@ +from mcp.server.fastmcp import FastMCP +import inspect + +print("Init Signature:") +print(inspect.signature(FastMCP.__init__)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dbedfff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + caldev_mcp: + build: . + image: caldev_mcp + environment: + - MCP_MODE=${MCP_MODE:-SSE} + env_file: + - .env + ports: + - "8000:8000" + volumes: + - .:/app + stdin_open: true # Open stdin + tty: true # Allocate pseudo-TTY diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..94f5028 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +# Default to STDIO if not set +MCP_MODE="${MCP_MODE:-STDIO}" + +if [ "$MCP_MODE" = "SSE" ]; then + echo "Starting in SSE mode..." >&2 + exec uv run uvicorn src.server:mcp.sse_app --host 0.0.0.0 --port 8000 +else + echo "Starting in STDIO mode..." >&2 + exec uv run src/server.py +fi diff --git a/sig.txt b/sig.txt new file mode 100644 index 0000000..dc22c7a --- /dev/null +++ b/sig.txt @@ -0,0 +1 @@ +(self, name: 'str | None' = None, instructions: 'str | None' = None, website_url: 'str | None' = None, icons: 'list[Icon] | None' = None, auth_server_provider: 'OAuthAuthorizationServerProvider[Any, Any, Any] | None' = None, token_verifier: 'TokenVerifier | None' = None, event_store: 'EventStore | None' = None, retry_interval: 'int | None' = None, *, tools: 'list[Tool] | None' = None, debug: 'bool' = False, log_level: "Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']" = 'INFO', host: 'str' = '127.0.0.1', port: 'int' = 8000, mount_path: 'str' = '/', sse_path: 'str' = '/sse', message_path: 'str' = '/messages/', streamable_http_path: 'str' = '/mcp', json_response: 'bool' = False, stateless_http: 'bool' = False, warn_on_duplicate_resources: 'bool' = True, warn_on_duplicate_tools: 'bool' = True, warn_on_duplicate_prompts: 'bool' = True, dependencies: 'Collection[str]' = (), lifespan: 'Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None' = None, auth: 'AuthSettings | None' = None, transport_security: 'TransportSecuritySettings | None' = None) \ No newline at end of file diff --git a/sig_output.txt b/sig_output.txt new file mode 100644 index 0000000..dd736f4 --- /dev/null +++ b/sig_output.txt @@ -0,0 +1,2 @@ +Init Signature: +(self, name: 'str | None' = None, instructions: 'str | None' = None, website_url: 'str | None' = None, icons: 'list[Icon] | None' = None, auth_server_provider: 'OAuthAuthorizationServerProvider[Any, Any, Any] | None' = None, token_verifier: 'TokenVerifier | None' = None, event_store: 'EventStore | None' = None, retry_interval: 'int | None' = None, *, tools: 'list[Tool] | None' = None, debug: 'bool' = False, log_level: "Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']" = 'INFO', host: 'str' = '127.0.0.1', port: 'int' = 8000, mount_path: 'str' = '/', sse_path: 'str' = '/sse', message_path: 'str' = '/messages/', streamable_http_path: 'str' = '/mcp', json_response: 'bool' = False, stateless_http: 'bool' = False, warn_on_duplicate_resources: 'bool' = True, warn_on_duplicate_tools: 'bool' = True, warn_on_duplicate_prompts: 'bool' = True, dependencies: 'Collection[str]' = (), lifespan: 'Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None' = None, auth: 'AuthSettings | None' = None, transport_security: 'TransportSecuritySettings | None' = None) diff --git a/src/__pycache__/caldav_client.cpython-314.pyc b/src/__pycache__/caldav_client.cpython-314.pyc index 03b47a0df8f1f1481fc69b8c225881049dc3c25f..32b60119775d83589cfa089d3e4eeb11b4be27de 100644 GIT binary patch delta 72 zcmdnyu*88+n~#@^0SJs_$}(RHZ{&+)(YDgh$j?pH&q++r*LTTJF3nBND=F4@&n!tz a)c17`&~;ACad8aOFD^>f-@Jrnk~9GE>lR!9 delta 90 zcmZ4Du+4!_n~#@^0SI~max;@eHu6QX*!nnI#e^2878S?%B&O$w=NDxMmn4>C=I6!u s=cT$7WtOGJxa237=BDPAIItS=FC+4^~hQ$;YCC6-@#4<@500+<>lmGw# diff --git a/src/__pycache__/server.cpython-314.pyc b/src/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4977e581bae906ae4e32fe41c575fb3399e16b2b GIT binary patch literal 7489 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 M4eaCY?DGKsACBSo;{X5v literal 0 HcmV?d00001 diff --git a/src/server.py b/src/server.py index d802a11..e44ec95 100644 --- a/src/server.py +++ b/src/server.py @@ -1,10 +1,17 @@ from mcp.server.fastmcp import FastMCP -from src.caldav_client import CalDAVClient +try: + from src.caldav_client import CalDAVClient +except ImportError: + from caldav_client import CalDAVClient from datetime import datetime from typing import Optional # Initialize FastMCP server mcp = FastMCP("CalDAV Server") +# Allow external connections +# Allow external connections +mcp.settings.transport_security.allowed_hosts = ["*"] +mcp.settings.transport_security.enable_dns_rebinding_protection = False # Initialize CalDAV client # We initialize it lazily or here if credentials are guaranteed to be present @@ -14,6 +21,11 @@ except Exception as e: import sys sys.stderr.write(f"Error initializing CalDAV client: {e}\n") client = None +@mcp.tool() +def get_current_time() -> str: + """Returns the current local machine time in ISO format.""" + return datetime.now().isoformat() + @mcp.tool() def list_calendars() -> str: @@ -116,4 +128,6 @@ def delete_event(calendar_name: str, event_uid: str) -> str: return f"Error deleting event: {str(e)}" if __name__ == "__main__": + import sys + sys.stderr.write("CalDAV MCP Server running on stdio... (Press Ctrl+C to stop)\n") mcp.run() diff --git a/test_mcp.py b/test_mcp.py new file mode 100644 index 0000000..f84bb7d --- /dev/null +++ b/test_mcp.py @@ -0,0 +1,45 @@ +import asyncio +import sys +import os +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def main(): + # Get absolute path to the project root + project_dir = os.path.dirname(os.path.abspath(__file__)) + + # Define the server parameters + server_params = StdioServerParameters( + command="uv", + args=["run", "src/server.py"], + env=os.environ.copy() # Pass current environment including PATH and .env vars + ) + + print("Connecting to server...") + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize + await session.initialize() + print("Initialized!") + + # List tools + print("\n--- Available Tools ---") + tools = await session.list_tools() + for tool in tools.tools: + print(f"- {tool.name}: {tool.description}") + + # Call list_calendars + print("\n--- Calling list_calendars ---") + try: + result = await session.call_tool("list_calendars", {}) + print("Result:") + for content in result.content: + print(content.text) + except Exception as e: + print(f"Error calling tool: {e}") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + sys.exit(0)