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 03b47a0..32b6011 100644 Binary files a/src/__pycache__/caldav_client.cpython-314.pyc and b/src/__pycache__/caldav_client.cpython-314.pyc differ diff --git a/src/__pycache__/server.cpython-314.pyc b/src/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000..4977e58 Binary files /dev/null and b/src/__pycache__/server.cpython-314.pyc differ 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)