This commit is contained in:
2026-07-06 10:39:24 +03:00
parent f7619a31ec
commit 5ec3cb4ab4
13 changed files with 4425 additions and 4426 deletions
+140 -141
View File
@@ -29,10 +29,10 @@ from mcp.server.fastmcp import FastMCP
from owa_mcp.owa_client import OWAClient
from owa_mcp.server_lifecycle import (
HashTokenVerifier,
SessionKeepalive,
apply_tool_filters,
create_owa_client,
HashTokenVerifier,
SessionKeepalive,
apply_tool_filters,
create_owa_client,
)
logger = logging.getLogger(__name__)
@@ -40,32 +40,32 @@ logger = logging.getLogger(__name__)
@dataclass
class AppContext:
"""Shared application state available to all tools via lifespan context."""
"""Shared application state available to all tools via lifespan context."""
client: OWAClient | None = None
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.
"""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
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()
keepalive = SessionKeepalive(client)
await keepalive.start()
try:
yield AppContext(client=client)
finally:
await keepalive.stop()
try:
yield AppContext(client=client)
finally:
await keepalive.stop()
# ── Temporary storage for lifespan args (set by main() before mcp.run()) ──
@@ -78,144 +78,143 @@ mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
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 to expose",
)
parser.add_argument(
"--disabled-tools",
default=None,
help="Comma-separated blacklist of tool names to hide",
)
return parser.parse_args(argv)
"""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 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,
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
"""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
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
"""Entry point: parse CLI args, configure server, run transport."""
global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603
args = _parse_args(argv)
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)
# ── 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
# ── 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)
# ── 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
# ── 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)
# ── 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()]
# ── 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()]
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)
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
# ── 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")
# ── 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()
main()