"""MCP resources exposing the server's static capability surface.
Resources are read-only context an agent pulls without spending Anlas: the
model catalogue, the sampler vocabulary, the active generation defaults, and
the generated PNGs themselves. The vocabularies are derived from
``nai.constants`` so there is a single source of truth.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastmcp.dependencies import Depends
from fastmcp.exceptions import ResourceError
from .deps import (
AppContext,
get_app_context,
get_route_scoped_output,
get_routing_context,
)
from .nai import (
Model,
NoiseSchedule,
Sampler,
is_inpaint_model,
is_v4_model,
is_v5_model,
supports_upscale,
supports_vibe,
)
from .output import resolve_output_path, route_output_dir
from .server import mcp
if TYPE_CHECKING:
from .routing import RoutingContext
_TAGS = {"novelai", "catalog"}
[docs]
@mcp.resource(
"novelai://models",
mime_type="application/json",
title="NovelAI models",
description="Every supported model id with its capability flags.",
tags=_TAGS,
)
def list_models() -> dict[str, Any]:
"""List the supported NovelAI models and their capabilities."""
return {
"models": [
{
"id": model.value,
"name": model.name,
"structured_prompt": is_v4_model(model),
"v5": is_v5_model(model),
"inpainting": is_inpaint_model(model),
"vibe_transfer": supports_vibe(model),
"standalone_upscale": supports_upscale(model),
}
for model in Model
]
}
[docs]
@mcp.resource(
"novelai://samplers",
mime_type="application/json",
title="NovelAI sampler vocabulary",
description="Sampler ids, noise schedules and UC preset indexes.",
tags=_TAGS,
)
def list_samplers() -> dict[str, Any]:
"""List the sampler, noise-schedule and UC-preset values the API accepts."""
return {
"samplers": [sampler.value for sampler in Sampler],
"noise_schedules": [schedule.value for schedule in NoiseSchedule],
"uc_presets": [0, 1, 2, 3],
}
[docs]
@mcp.resource(
"novelai://defaults",
mime_type="application/json",
title="Active generation defaults",
description="Generation defaults this server applies when a tool omits them.",
tags=_TAGS,
)
def generation_defaults(app: AppContext = Depends(get_app_context)) -> dict[str, Any]:
"""Return the active generation defaults (never credentials or endpoints)."""
settings = app.settings
return {
"model": settings.default_model,
"width": settings.default_width,
"height": settings.default_height,
"steps": settings.default_steps,
"scale": settings.default_scale,
"sampler": settings.default_sampler,
"output_dir": settings.output_dir,
}
[docs]
@mcp.resource(
"novelai://outputs/{name}",
mime_type="image/png",
title="Generated image",
description="Read a previously generated PNG back from the output directory.",
tags={"novelai", "outputs"},
)
def read_output_image(
name: str,
app: AppContext = Depends(get_app_context),
routing: RoutingContext = Depends(get_routing_context),
*,
route_scoped_output: bool = Depends(get_route_scoped_output),
) -> bytes:
"""Return one generated PNG by filename.
The name must be a plain ``*.png`` filename that resolves inside the
directory the request's images were written to: the configured output
directory, or its per-route subdirectory when model routing is active and
route-scoped output is on. Anything else -- a path separator, a traversal,
another file type, or a name that only exists outside the scoped
directory -- is rejected.
"""
try:
path = resolve_output_path(
name,
output_dir=route_output_dir(
app.settings.output_dir, routing, route_scoped=route_scoped_output
),
)
except (OSError, ValueError) as exc:
raise ResourceError(str(exc)) from exc
return path.read_bytes()
__all__ = [
"generation_defaults",
"list_models",
"list_samplers",
"read_output_image",
]