Files
2026-07-08 19:51:16 +03:00

241 lines
8.1 KiB
Python

"""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()