"""OWA MCP Server. Exposes OWA email, calendar, directory, and availability tools via MCP. Uses FastMCP with a lifespan context manager to share a single OWAClient instance across all tool invocations. CLI arguments: --transport Transport: stdio (default), sse, streamable-http --owa-url Base URL of the OWA instance (required) --cookie-file Path to session cookies file (default: session-cookies.txt) --token-hash SHA-256 hash of the bearer token (required for HTTP) --port HTTP port (default 8000) --host HTTP host (default 127.0.0.1) --enabled-tools Comma-separated whitelist of tool names --disabled-tools Comma-separated blacklist of tool names """ from __future__ import annotations import argparse import logging import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from mcp.server.auth.provider import TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from owa_mcp.owa_client import OWAClient from owa_mcp.server_lifecycle import ( HashTokenVerifier, SessionKeepalive, apply_tool_filters, create_owa_client, ) logger = logging.getLogger(__name__) @dataclass class AppContext: """Shared application state available to all tools via lifespan context.""" client: OWAClient | None = None @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Initialise the OWA client on first connection, then keep the session alive. A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts immediately (pings inbox, extends the session, persists fresh cookies) and repeats every 5 minutes until the server shuts down. """ client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file) try: client._ensure_loaded() except Exception: pass # no cookies yet — login tool will handle this keepalive = SessionKeepalive(client) await keepalive.start() try: yield AppContext(client=client) finally: await keepalive.stop() # ── Temporary storage for lifespan args (set by main() before mcp.run()) ── _lifespan_owa_url: str = "" _lifespan_cookie_file: str | None = None # ── Module-level MCP server (imported by all tool modules) ──────────────── mcp: FastMCP = FastMCP( "owa", lifespan=app_lifespan, transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False, allowed_hosts=[], allowed_origins=[], ), ) # Force-disable DNS rebinding protection even if FastMCP auto-config # overwrites it (e.g. when --host is a non-localhost address). mcp.settings.transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) def _parse_args(argv=None) -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( description="OWA MCP Server — OWA/Exchange integration via MCP", ) parser.add_argument( "--transport", choices=["stdio", "sse", "streamable-http"], default="stdio", help="Transport protocol (default: stdio)", ) parser.add_argument( "--owa-url", required=True, help="Base URL of the OWA instance (e.g. https://owa.example.com)", ) parser.add_argument( "--cookie-file", default=None, help="Path to session cookies file (default: session-cookies.txt)", ) parser.add_argument( "--token-hash", default=None, help=( "SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum" ), ) parser.add_argument( "--port", type=int, default=8000, help="Port for HTTP/SSE transport (default: 8000)", ) parser.add_argument( "--host", default="127.0.0.1", help="Host for HTTP/SSE transport (default: 127.0.0.1)", ) parser.add_argument( "--enabled-tools", default=None, help="Comma-separated whitelist of tool names or group aliases (read-only, write-only) to expose", ) parser.add_argument( "--disabled-tools", default=None, help="Comma-separated blacklist of tool names or group aliases (read-only, write-only) to hide", ) return parser.parse_args(argv) def _configure_auth( mcp_server: FastMCP, token_hash: str | None, ) -> None: """Apply token-hash auth to *mcp_server* if *token_hash* is provided.""" if not token_hash: return token_verifier: TokenVerifier = HashTokenVerifier(token_hash) mcp_server.settings.auth = AuthSettings( # type: ignore[misc] issuer_url="http://localhost", # type: ignore[arg-type] resource_server_url="http://localhost", # type: ignore[arg-type] ) mcp_server._token_verifier = token_verifier # noqa: SLF001 def main(argv=None) -> None: """Entry point: parse CLI args, configure server, run transport.""" global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603 args = _parse_args(argv) # ── Validate --owa-url ───────────────────────────────────────────────── owa_url = args.owa_url.strip().rstrip("/") if not owa_url: print( "ERROR: --owa-url must not be empty.", file=__import__("sys").stderr, ) raise SystemExit(1) # ── Stash lifespan args (consumed by app_lifespan) ───────────────────── _lifespan_owa_url = owa_url _lifespan_cookie_file = args.cookie_file # ── Validate --token-hash for non-stdio transports ───────────────────── if args.transport != "stdio" and not args.token_hash: print( "ERROR: --token-hash is required when using HTTP transport.", file=__import__("sys").stderr, ) print( " Compute hash: echo -n 'my-token' | sha256sum", file=__import__("sys").stderr, ) raise SystemExit(1) # ── Register tools (imports populate module-level `mcp`) ─────────────── import owa_mcp.tools.analytics # noqa: E402, F401 import owa_mcp.tools.auth # noqa: E402, F401 import owa_mcp.tools.availability # noqa: E402, F401 import owa_mcp.tools.calendar # noqa: E402, F401 import owa_mcp.tools.email # noqa: E402, F401 import owa_mcp.tools.folders # noqa: E402, F401 import owa_mcp.tools.people # noqa: E402, F401 # ── Auth for HTTP transports ─────────────────────────────────────────── _configure_auth(mcp, args.token_hash) # ── Tool filtering ───────────────────────────────────────────────────── enabled: list[str] | None = None if args.enabled_tools is not None: enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] disabled: list[str] | None = None if args.disabled_tools is not None: disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] if enabled or disabled: apply_tool_filters(mcp, enabled=enabled, disabled=disabled) # ── Host / port (already passed to FastMCP at module level; update if # different from defaults — useful when server is re-invoked with # different args in tests) ───────────────────────────────────────────── mcp.settings.host = args.host mcp.settings.port = args.port # ── Run transport ───────────────────────────────────────────────────── # Allow all X-Forwarded-* headers (needed when behind a proxy or # when clients connect with a non-default Host header). os.environ["FORWARDED_ALLOW_IPS"] = "*" match args.transport: case "stdio": mcp.run() case "sse": mcp.run(transport="sse") case "streamable-http": mcp.run(transport="streamable-http") if __name__ == "__main__": main()