This commit is contained in:
2026-07-06 01:46:14 +03:00
parent 8b3e116062
commit a9497625b1
18 changed files with 5771 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Exchange 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
Tool filtering (env fallback):
EXCHANGE_ENABLED_TOOLS — comma-separated whitelist (used only when
--enabled-tools is not provided).
EXCHANGE_DISABLED_TOOLS — comma-separated blacklist (used only when
--disabled-tools is not provided).
"""
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 AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
from exchange_mcp.owa_client import OWAClient
from exchange_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:`~exchange_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("exchange", lifespan=app_lifespan)
def _parse_args(argv=None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Exchange 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 to expose",
)
parser.add_argument(
"--disabled-tools",
default=None,
help="Comma-separated blacklist of tool names 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 exchange_mcp.tools.email # noqa: E402, F401
import exchange_mcp.tools.calendar # noqa: E402, F401
import exchange_mcp.tools.people # noqa: E402, F401
import exchange_mcp.tools.folders # noqa: E402, F401
import exchange_mcp.tools.availability # noqa: E402, F401
import exchange_mcp.tools.analytics # noqa: E402, F401
import exchange_mcp.tools.auth # noqa: E402, F401
# ── Auth for HTTP transports ───────────────────────────────────────────
_configure_auth(mcp, args.token_hash)
# ── Tool filtering ─────────────────────────────────────────────────────
# CLI args take precedence over env vars.
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()]
elif os.environ.get("EXCHANGE_ENABLED_TOOLS", "").strip():
enabled = [
t.strip()
for t in os.environ["EXCHANGE_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()]
elif os.environ.get("EXCHANGE_DISABLED_TOOLS", "").strip():
disabled = [
t.strip()
for t in os.environ["EXCHANGE_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 ─────────────────────────────────────────────────────
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()