"""Environment-driven configuration for the NovelAI Image MCP server.
Two ``pydantic-settings`` models expose the runtime configuration surface:
``NovelAISettings`` (credentials + generation defaults, ``NOVELAI_*`` env
vars) and ``MCPServerSettings`` (transport, ``MCP_*`` env vars).
``NovelAISettings`` structurally satisfies ``nai.NovelAIConfigLike`` so the
client factory consumes it directly.
"""
from __future__ import annotations
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
[docs]
class NovelAISettings(BaseSettings):
"""NovelAI credentials, endpoints, and generation defaults.
All fields are read from ``NOVELAI_*`` environment variables (and an optional
``.env`` file in the working directory). Exactly one auth method is required
at runtime: either ``token`` (preferred) or the ``username``+``password`` pair.
"""
model_config = SettingsConfigDict(
env_prefix="NOVELAI_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# ── Credentials ──
token: str | None = None
username: str | None = None
password: str | None = None
# ── Endpoints ──
# ``image.novelai.net`` is the consolidated host for most third-party API
# traffic (generation, director, account, upscaling, ...).
# ``legacy_image_base_url`` points at the Primary API
# (``api.novelai.net``), which is still the home of
# ``/ai/annotate-image`` — the one endpoint that was never migrated and
# 404s on ``image.novelai.net``. ``/ai/upscale`` *did* move: it is served
# by ``image_base_url`` and accepts only the V5 line. The Primary API's own
# docs (https://api.novelai.net/docs/) state that third-party users may use
# its ``/ai/`` routes.
image_base_url: str = "https://image.novelai.net"
account_base_url: str = "https://image.novelai.net"
legacy_image_base_url: str = "https://api.novelai.net"
timeout: float = Field(default=120.0, gt=0)
# ── Generation defaults (overridable per tool call) ──
# Default stays on V4.5 for compatibility — V5 is ~2.5x heavier and its
# Opus free quota is refillable/limited; switch to "nai-diffusion-5-full"
# via NOVELAI_DEFAULT_MODEL when V5 is desired.
default_model: str = "nai-diffusion-4-5-full"
default_width: int = Field(default=832, ge=64, le=49_152)
default_height: int = Field(default=1216, ge=64, le=49_152)
default_steps: int = Field(default=28, ge=1, le=50)
default_scale: float = Field(default=5.0, gt=0, le=20)
default_sampler: str = "k_euler_ancestral"
# ── Upscale ──
# The standalone upscaler (4x) only accepts the V5 line; this is a `Model`
# id. See `nai.constants.supports_upscale`.
upscale_model: str = "nai-diffusion-5-full"
# ── Client ──
vibe_cache_entries: int = Field(default=64, ge=1, le=1024)
# ── Output ──
output_dir: str = "outputs"
[docs]
def has_credentials(self) -> bool:
"""True when either a token or a complete username/password pair is set."""
return bool(self.token) or bool(self.username and self.password)
[docs]
class MCPServerSettings(BaseSettings):
"""MCP server transport, auth, routing and logging (``MCP_*`` env vars).
List- and dict-valued fields are read from the environment as JSON
(``pydantic-settings`` decodes complex types with ``json.loads``):
``MCP_MODEL_HEADERS='["X-MCP-Model"]'`` works, while a comma-separated
``X-MCP-Model,X-MCP-Model-Name`` raises a settings error, and an empty
string is not a valid value for a ``dict`` field.
"""
model_config = SettingsConfigDict(
env_prefix="MCP_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# ``streamable-http`` is the historical alias for the canonical
# ``http`` transport; both are accepted so existing scripts keep
# working, and ``resolved_transport`` normalises the value passed to
# FastMCP.
transport: Literal["stdio", "http", "streamable-http"] = "stdio"
host: str = "127.0.0.1"
port: int = Field(default=8000, ge=1, le=65_535)
path: str = "/mcp"
# Opt-in bearer token: when set, the HTTP transport requires the
# ``Authorization: Bearer <token>`` header. stdio is unaffected.
auth_token: str | None = None
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None
# ── Insiders opt-in surface ──
# The opt-in header is configurable so a deployment can move it out of a
# namespace it does not own. Lookup is case-insensitive because HTTP header
# names are.
insiders_header: str = "X-MCP-Insiders"
# Ordered routing candidates: the first header carrying a non-empty value
# supplies the corresponding field. JSON list in the environment, e.g.
# ``MCP_MODEL_HEADERS='["X-MCP-Model","X-MCP-Model-Name"]'``.
model_headers: list[str] = Field(
default_factory=lambda: ["X-MCP-Model", "X-MCP-Model-Name"],
)
agent_headers: list[str] = Field(
default_factory=lambda: [
"X-MCP-Agent-ID",
"X-MCP-Agent",
"X-MCP-Agent-Source",
],
)
client_headers: list[str] = Field(
default_factory=lambda: ["X-MCP-Client", "X-MCP-Client-Session-Id"],
)
# Operator-level kill switch: a feature listed here is off no matter what a
# client sends in the insiders header. JSON list, e.g.
# ``MCP_INSIDERS_DISABLED_FEATURES='["model-routing"]'``.
insiders_disabled_features: list[str] = Field(default_factory=list)
# Audit trail destination; ``None`` means ``<output_dir>/audit.jsonl``.
audit_log_path: str | None = None
# When true, generated images are written under a per-route subdirectory.
route_scoped_output: bool = True
# Optional ``{route: token}`` map for route-bound authentication: the key is
# the route the token authenticates as. JSON object, e.g.
# ``MCP_ROUTE_TOKENS='{"team-a":"s3cret"}'``.
route_tokens: dict[str, str] | None = None
[docs]
@property
def resolved_transport(self) -> Literal["stdio", "http"]:
"""Canonical transport name (the legacy alias maps to ``http``)."""
return "stdio" if self.transport == "stdio" else "http"
[docs]
def get_novelai_settings() -> NovelAISettings:
"""Load NovelAI settings from environment / .env."""
return NovelAISettings()
[docs]
def get_mcp_settings() -> MCPServerSettings:
"""Load MCP server settings from environment / .env."""
return MCPServerSettings()
__all__ = [
"MCPServerSettings",
"NovelAISettings",
"get_mcp_settings",
"get_novelai_settings",
]