Source code for novelai_image_mcp.server

"""MCP server: lifespan owns the shared httpx session + NovelAIClient.

The server is a thin composition root over ``nai.NovelAIClient``. The lifespan
creates one long-lived ``httpx.AsyncClient`` (connection pooling, with Chrome
TLS + header fingerprint impersonation via ``create_http_client``) and one
``NovelAIClient``; every tool reads them from
``ctx.request_context.lifespan_context`` (the MCP v2 ``Context`` API). Transport
is selected at runtime from ``MCP_TRANSPORT`` (stdio by default,
streamable-http for remote deployments).
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from ._mcp import MCPServer
from .nai import NovelAIClient, create_http_client, create_novelai_client
from .settings import (
    NovelAISettings,
    get_mcp_settings,
    get_novelai_settings,
)
from .tools import register_all


[docs] @dataclass class AppContext: """Shared state yielded by the lifespan to every tool invocation.""" client: NovelAIClient settings: NovelAISettings
[docs] @asynccontextmanager async def lifespan(_server: MCPServer) -> AsyncIterator[AppContext]: """Build the NovelAI client from settings; tear down on shutdown.""" settings = get_novelai_settings() if not settings.has_credentials(): raise RuntimeError( "NovelAI credentials are not configured: set NOVELAI_TOKEN or " "NOVELAI_USERNAME + NOVELAI_PASSWORD (see .env.example)." ) # ``create_http_client`` returns an ``httpx.AsyncClient`` backed by # ``curl_cffi`` (Chrome TLS fingerprint) with browser headers set as # defaults — required for Cloudflare's bot WAF to accept the connection. http_client = create_http_client(timeout=settings.timeout) client = create_novelai_client(settings, http_client=http_client) try: yield AppContext(client=client, settings=settings) finally: # Close the NovelAI client first; even if it raises, the underlying # httpx session must still be released to avoid leaking sockets. try: await client.aclose() finally: if not http_client.is_closed: await http_client.aclose()
mcp = MCPServer("novelai-image", lifespan=lifespan) # Register all MCP tools against the server instance. register_all(mcp)
[docs] def main() -> None: """Run the server with the transport selected from ``MCP_*`` settings.""" mcp_settings = get_mcp_settings() if mcp_settings.transport == "streamable-http": mcp.run( transport="streamable-http", host=mcp_settings.host, port=mcp_settings.port, ) else: mcp.run(transport="stdio")
__all__ = ["AppContext", "lifespan", "main", "mcp"]