refactor: complete agent skill API contracts

This commit is contained in:
jxxghp
2026-09-01 01:19:45 +08:00
parent 871632af25
commit 6d4d7331d1
53 changed files with 19368 additions and 1541 deletions

View File

@@ -14,7 +14,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS # noqa: E402
from app.agent.policy.api_mcp_contract import build_api_mcp_input_schema # noqa: E402
from app.agent.policy.mcp import build_api_mcp_input_schema # noqa: E402
from app.api.apiv1 import api_router # noqa: E402
OUTPUT_PATH = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""Generate the complete OpenAPI-to-Agent surface audit."""
from __future__ import annotations
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from fastapi import FastAPI
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.agent.policy.api import API_OPERATION_ROUTES # noqa: E402
from app.api.apiv1 import api_router # noqa: E402
JSON_OUTPUT = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.json"
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.md"
HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
TRANSPORT_TAGS = frozenset(
{
"agent",
"anthropic",
"auth",
"llm",
"login",
"mcp",
"message",
"mfa",
"notification",
"openai",
"user",
"webhook",
}
)
CONSOLIDATED_TAGS = frozenset(
{
"anilist",
"bangumi",
"discover",
"douban",
"recommend",
"tmdb",
}
)
PROVIDER_PATH_PREFIXES = (
"/api/v1/download/",
"/api/v1/mediaserver/",
)
CONSOLIDATED_ROUTE_OWNERS: dict[tuple[str, str], str] = {
("POST", "/api/v1/download/"): "download.add",
("GET", "/api/v1/plugin/installed"): "plugin.installed",
("GET", "/api/v1/plugin/source/{plugin_id}/options"): "plugin.source.options",
("GET", "/api/v1/plugin/{plugin_id}"): "plugin.config.get",
("GET", "/api/v1/search/last"): "search.results",
("GET", "/api/v1/search/media/{media_id}/stream"): "search.torrents",
("GET", "/api/v1/search/subtitle/media/{media_id}/stream"): "subtitle.search.media",
("GET", "/api/v1/search/subtitle/title/stream"): "subtitle.search.title",
("GET", "/api/v1/search/title/stream"): "search.title",
("GET", "/api/v1/site/"): "site.list",
("GET", "/api/v1/site/cookie/{site_id}"): "site.cookie.update",
("GET", "/api/v1/site/domain/{site_url}"): "site.list",
("GET", "/api/v1/site/{site_id}"): "site.list",
("POST", "/api/v1/storage/list"): "storage.list",
("GET", "/api/v1/subscribe/list"): "subscription.list",
("GET", "/api/v1/system/env"): "config.system.get",
("POST", "/api/v1/system/env"): "config.system.update",
("GET", "/api/v1/system/global"): "config.system.get",
("GET", "/api/v1/system/setting/{key}"): "config.system.get",
("POST", "/api/v1/system/setting/{key}"): "config.system.update",
("GET", "/api/v1/transfer/now"): "scheduler.run",
("GET", "/api/v1/workflow/"): "workflow.list",
}
STREAM_OR_BINARY_PATHS = frozenset(
{
"/api/v1/plugin/file/{plugin_id}/{filepath}",
"/api/v1/site/icon/{site_id}",
"/api/v1/storage/download",
"/api/v1/storage/image",
"/api/v1/system/cache/image",
"/api/v1/system/img/{proxy}",
"/api/v1/system/logging",
"/api/v1/system/logging/download/{name}",
"/api/v1/system/message",
"/api/v1/system/progress/{process_type}",
}
)
UI_PRESENTATION_PATHS = frozenset(
{
"/api/v1/plugin/dashboard/meta",
"/api/v1/plugin/dashboard/{plugin_id}",
"/api/v1/plugin/dashboard/{plugin_id}/{key}",
"/api/v1/plugin/page/{plugin_id}",
"/api/v1/plugin/sidebar_nav",
}
)
EXPLICIT_TRANSPORT_PATHS = frozenset(
{
"/api/v1/plugin/remotes",
"/api/v1/subscribe/seerr",
"/api/v1/system/ping",
}
)
def _gateway_routes() -> dict[tuple[str, str], list[str]]:
"""Return exact HTTP routes and every stable gateway operation using them."""
results: dict[tuple[str, str], list[str]] = defaultdict(list)
for operation_id, route in API_OPERATION_ROUTES.items():
results[(route.method.upper(), route.path)].append(operation_id)
return {key: sorted(values) for key, values in results.items()}
def _classify(
*,
method: str,
path: str,
tags: list[str],
gateway_routes: dict[tuple[str, str], list[str]],
) -> tuple[str, str, str, list[str]]:
"""Classify one OpenAPI operation into one reviewed Agent ownership boundary."""
operations = gateway_routes.get((method, path), [])
if operations:
return (
"gateway",
"moviepilot-api",
"Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
operations,
)
primary_tag = tags[0] if tags else "untagged"
if primary_tag in TRANSPORT_TAGS:
return (
"transport_or_identity",
"host-runtime",
"Authentication, protocol compatibility, conversation transport, callback, or account lifecycle endpoint; never recursively exposed as an Agent business action.",
[],
)
consolidated_owner = CONSOLIDATED_ROUTE_OWNERS.get((method, path))
if consolidated_owner:
return (
"consolidated",
"moviepilot-api",
f"This compatibility, broader-response, or UI route is represented by the safer stable operation {consolidated_owner}.",
[consolidated_owner],
)
if path in STREAM_OR_BINARY_PATHS:
return (
"stream_or_binary",
"host-transport",
"Streaming, image, archive, or file response consumed by a direct client; the structured JSON Agent gateway does not proxy binary or unbounded streams.",
[],
)
if path in UI_PRESENTATION_PATHS:
return (
"ui_presentation",
"host-ui",
"Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.",
[],
)
if path in EXPLICIT_TRANSPORT_PATHS:
return (
"transport_or_identity",
"host-runtime",
"Health, bootstrap, federation, or external webhook transport endpoint; it is not recursively callable as an Agent business action.",
[],
)
if path.startswith(PROVIDER_PATH_PREFIXES):
return (
"provider-skill",
"downloader-operation" if "/download/" in path else "mediaserver-operation",
"Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.",
[],
)
if primary_tag in CONSOLIDATED_TAGS:
return (
"consolidated",
"moviepilot-api",
"Source-specific or presentation-oriented route is represented by a stable aggregate search, detail, person, recommendation, or music operation instead of duplicating every frontend route.",
[],
)
if path.endswith("2") or "/schedule2" in path or "/recognize2" in path or "/recognize_file2" in path:
return (
"alternate-auth-duplicate",
"moviepilot-api",
"API-token compatibility duplicate; the Agent uses the corresponding bearer-authenticated operation with its persisted user identity.",
[],
)
raise ValueError(f"Unclassified OpenAPI operation: {method} {path}")
def generate_audit() -> dict[str, Any]:
"""Build a deterministic entry for every v1 OpenAPI HTTP operation."""
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
openapi = app.openapi()
gateway_routes = _gateway_routes()
entries = []
for path, path_item in sorted(openapi.get("paths", {}).items()):
for raw_method, operation in sorted(path_item.items()):
method = raw_method.upper()
if method not in HTTP_METHODS or not isinstance(operation, dict):
continue
tags = [str(tag) for tag in operation.get("tags") or []]
disposition, owner, reason, operation_ids = _classify(
method=method,
path=path,
tags=tags,
gateway_routes=gateway_routes,
)
entries.append(
{
"method": method,
"path": path,
"tags": tags,
"summary": str(operation.get("summary") or ""),
"disposition": disposition,
"owner": owner,
"operation_ids": operation_ids,
"reason": reason,
}
)
counts = Counter(entry["disposition"] for entry in entries)
matched_gateway_routes = {
(entry["method"], entry["path"])
for entry in entries
if entry["disposition"] == "gateway"
}
dynamic_gateway_routes = [
{
"method": method,
"path": path,
"operation_ids": operation_ids,
"reason": (
"The executor validates and expands this bounded source placeholder to one of "
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
),
}
for (method, path), operation_ids in sorted(gateway_routes.items())
if (method, path) not in matched_gateway_routes
]
return {
"openapi_operation_count": len(entries),
"gateway_operation_count": len(API_OPERATION_ROUTES),
"gateway_http_route_count": len(gateway_routes),
"matched_gateway_http_route_count": len(matched_gateway_routes),
"dynamic_gateway_routes": dynamic_gateway_routes,
"disposition_counts": dict(sorted(counts.items())),
"operations": entries,
}
def render_markdown(audit: dict[str, Any]) -> str:
"""Render the complete audit as a reviewable architecture document."""
lines = [
"# MoviePilot Agent API Surface Audit",
"",
"> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.",
"> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.",
"",
"## Result",
"",
f"- OpenAPI HTTP operations: **{audit['openapi_operation_count']}**",
f"- Stable `moviepilot_api` operations: **{audit['gateway_operation_count']}**",
f"- Exact HTTP routes used by the gateway: **{audit['gateway_http_route_count']}**",
f"- OpenAPI routes matched directly by the gateway: **{audit['matched_gateway_http_route_count']}**",
f"- Bounded dynamic gateway routes: **{len(audit['dynamic_gateway_routes'])}**",
"- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.",
"- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.",
"",
"## Dispositions",
"",
"| disposition | count | meaning |",
"| :--- | ---: | :--- |",
]
meanings = {
"gateway": "Approved structured MoviePilot Agent operation.",
"provider-skill": "Low-level downloader or media-server capability owned by a provider Skill.",
"consolidated": "Source/UI route represented by a stable aggregate Agent operation.",
"alternate-auth-duplicate": "API-token compatibility duplicate of a bearer-authenticated capability.",
"transport_or_identity": "Authentication, protocol, callback, account, or conversation transport boundary.",
"stream_or_binary": "Streaming or binary response owned by a direct client transport.",
"ui_presentation": "Frontend or plugin-rendered presentation contract.",
}
for disposition, count in audit["disposition_counts"].items():
lines.append(f"| `{disposition}` | {count} | {meanings[disposition]} |")
if audit["dynamic_gateway_routes"]:
lines.extend(
[
"",
"## Bounded Dynamic Routes",
"",
"| method | route template | operations | constraint |",
"| :--- | :--- | :--- | :--- |",
]
)
for item in audit["dynamic_gateway_routes"]:
lines.append(
f"| `{item['method']}` | `{item['path']}` | {', '.join(item['operation_ids'])} | {item['reason']} |"
)
lines.extend(
[
"",
"## Complete Route Inventory",
"",
"| method | path | tags | disposition | owner / operation | summary |",
"| :--- | :--- | :--- | :--- | :--- | :--- |",
]
)
for item in audit["operations"]:
tags = ", ".join(item["tags"]) or "-"
owner = ", ".join(item["operation_ids"]) or item["owner"]
summary = item["summary"].replace("|", "\\|")
lines.append(
f"| `{item['method']}` | `{item['path']}` | {tags} | `{item['disposition']}` | {owner} | {summary} |"
)
lines.extend(
[
"",
"## Exposure Rule",
"",
"Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.",
"",
]
)
return "\n".join(lines)
def main() -> int:
"""Write deterministic JSON and Markdown audit artifacts."""
audit = generate_audit()
JSON_OUTPUT.write_text(
json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8")
print(
"generated "
f"{JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,663 @@
#!/usr/bin/env python3
"""Generate English Agent Skill contracts from runtime metadata."""
from __future__ import annotations
import json
import runpy
import sys
import textwrap
from pathlib import Path
from typing import Any, Mapping
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
"alembic_version": (
"Records the Alembic migration revision currently applied to the database.",
"Diagnosing startup migration failures or a database/code revision mismatch.",
"Never edit it directly; advance or roll back revisions only through Alembic.",
),
"agentchat": (
"Stores Web Agent and messaging-channel session indexes, titles, previews, and message snapshots.",
"Tracing Agent history or context restoration by user, session, or update time.",
"Owned by the Agent conversation service; do not rewrite message JSON, counters, or ownership.",
),
"agenttask": (
"Stores one-shot or recurring Agent task definitions, triggers, and the latest execution summary.",
"Inspecting task ownership, enablement, cron/run_at settings, and the latest result.",
"Create, update, enable, disable, or delete tasks through the Agent task API.",
),
"agenttaskrun": (
"Stores the input snapshot, status, timestamps, and result of each Agent task execution.",
"Auditing one run or correlating a failure with task_id, run_id, and trigger source.",
"Execution evidence owned by the task runner; never fabricate rows or edit run status.",
),
"downloadfailure": (
"Stores stable fingerprints, media/torrent context, errors, and retry scheduling for failed downloads.",
"Analyzing failure causes, retry counts, next retry time, and affected media or sites.",
"Owned by download-failure compensation; retry or clean records through its business API.",
),
"downloadfiles": (
"Maps downloader task hashes to full paths, save directories, relative files, and active state.",
"Finding task files by downloader/download_hash or diagnosing savepath associations.",
"Maintained by download and transfer flows; do not manually change state or path mappings.",
),
"downloadhistory": (
"Stores media identity, torrent, downloader, user, and recognition context for submitted downloads.",
"Reviewing download history or tracing a media identity or hash back to its source.",
"Written by the download use case; delete or correct records through the download-history API.",
),
"mediaserveritem": (
"Stores the local index and canonical media identity projected from media-server libraries.",
"Checking library presence, server/library/path placement, and season information.",
"This is a rebuildable projection; writes and cleanup belong to media-server synchronization.",
),
"message": (
"Stores inbound and outbound messages, channels, content, attachments, users, and timestamps.",
"Paging notification history, distinguishing direction, or tracing duplicates by source.",
"Written by messaging and notification services; clean it through the message API or retention job.",
),
"outboxmessage": (
"Stores externally visible side-effect intents committed atomically with business transactions.",
"Diagnosing pending/processing/failed state, leases, attempts, and the last error.",
"Owned by the Outbox Dispatcher state machine; never mark completion or delete undelivered events manually.",
),
"passkey": (
"Stores WebAuthn/PassKey credentials, public keys, signature counters, and activation state.",
"Authorized authentication diagnostics such as ownership, activation, and last use.",
"Security-sensitive; manage it only through the PassKey API and never disclose credential material.",
),
"plugindata": (
"Stores plugin-owned JSON values isolated by plugin_id and key.",
"Diagnosing persistence or migration issues for one explicitly identified plugin and key.",
"The plugin owns these values; prefer plugin capabilities or the plugin-data API.",
),
"pluginidentity": (
"Stores trusted source, payload source, version, receipt, and CAS revision for a physical plugin package.",
"Auditing source binding, package generation, payload application, or identity conflicts.",
"Plugin supply-chain state owned exclusively by installation and update transactions.",
),
"plugininstallation": (
"Stores plugin installation phase, membership target, identity revisions, and backup state.",
"Diagnosing interrupted installations, rollback conditions, and package or backup presence.",
"Owned by the plugin installation state machine; never advance phase or overwrite evidence manually.",
),
"site": (
"Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.",
"Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure.",
"Contains cookies, API keys, and tokens; manage it through the site API.",
),
"siteicon": (
"Caches site names, domains, icon URLs, and Base64 icon content.",
"Diagnosing missing icons, incorrect domain mapping, or cache generation.",
"Rebuildable cache owned by site-icon synchronization; direct writes are not recommended.",
),
"sitestatistic": (
"Aggregates site request successes, failures, durations, latest state, and diagnostic notes.",
"Comparing site availability, failure rate, and the most recent access state.",
"Accumulated by site access statistics; never edit counters to conceal runtime behavior.",
),
"siteuserdata": (
"Stores tracker account level, traffic, ratio, seeding, and unread-message data.",
"Inspecting account state, traffic trends, seeding volume, and the latest collection error.",
"A site-scraping projection refreshed by synchronization; do not edit it directly.",
),
"subscribe": (
"Stores active movie, TV, or music subscriptions, filters, progress, and download targets.",
"Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress.",
"Create, update, search, or delete through the subscription API to preserve state-machine consistency.",
),
"subscribehistory": (
"Stores snapshots of completed or archived subscriptions and their final filter state.",
"Auditing historical subscriptions, media identity, completion criteria, and filter configuration.",
"Generated by subscription completion and archival; restore or delete through its business API.",
),
"systemconfig": (
"Stores JSON business configuration values keyed by SystemConfigKey.",
"Verifying the physical value only when the managed settings API behaves unexpectedly.",
"Use config.system.get/update first; direct writes bypass validation, events, and plugin admission.",
),
"transferexecutionstep": (
"Stores intent, attempt identity, state, and result evidence for each durable transfer operation.",
"Diagnosing stuck, failed, or repeated steps by task_id or operation_id.",
"Owned by the transfer execution state machine and lease CAS; never force state transitions manually.",
),
"transferhistory": (
"Stores transfer source, destination, mode, media identity, download linkage, and outcome.",
"Reviewing success/failure history, destination paths, media classification, and download linkage.",
"Written by transfer settlement; delete or retry through transfer-history business APIs.",
),
"transferpending": (
"Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state.",
"Diagnosing restart recovery, expired leases, retry_wait, terminal failures, or manual review.",
"Core durable state machine advanced only by planning, execution, retry, and review services.",
),
"transfersettlementreceipt": (
"Stores immutable terminal settlement receipts with contiguous revisions per transfer task.",
"Verifying that history, pending deletion, and execution fingerprints were settled reliably.",
"Idempotency and audit evidence; append revisions only and never overwrite or delete old receipts.",
),
"user": (
"Stores user accounts, password hashes, administrator state, OTP, permissions, and preferences.",
"Authorized diagnostics of account state, permissions, or authentication configuration.",
"Security-sensitive; manage through user, permission, password, and two-factor APIs.",
),
"userconfig": (
"Stores per-user JSON configuration isolated by username and key.",
"Inspecting UI preferences, message clear cursors, or other personalized state.",
"Modify through the owning user or messaging API to preserve key semantics.",
),
"workflow": (
"Stores workflow definitions, triggers, action graphs, execution context, and runtime state.",
"Inspecting scheduled/event workflows, pause state, current action, run count, and failures.",
"Create, modify, run, pause, or reset through the workflow API.",
),
}
def _load_json_schema() -> dict[str, Any]:
"""Load the generated moviepilot_api MCP schema."""
path = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("The API MCP schema must be a JSON object")
return payload
def _schema_type(schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> str:
"""Compress JSON Schema into a readable Skill type expression."""
reference = schema.get("$ref")
if isinstance(reference, str):
return reference.rsplit("/", 1)[-1]
if isinstance(schema.get("const"), (str, int, float, bool)):
return f"{schema.get('type', 'value')}={schema['const']}"
enum = schema.get("enum")
if isinstance(enum, list):
return f"{schema.get('type', 'value')}({','.join(map(str, enum))})"
schema_type = schema.get("type")
if schema_type == "array":
items = schema.get("items")
item_type = _schema_type(items, definitions) if isinstance(items, Mapping) else "value"
return f"array<{item_type}>"
if schema_type:
return str(schema_type)
any_of = schema.get("anyOf")
if isinstance(any_of, list):
return "|".join(
_schema_type(item, definitions)
for item in any_of
if isinstance(item, Mapping)
)
if "properties" in schema or schema.get("additionalProperties") is not None:
return "object"
return "value"
def _field_suffix(schema: Mapping[str, Any]) -> str:
"""Format defaults, ranges, and collection constraints."""
suffix: list[str] = []
if "default" in schema:
suffix.append(f"default `{schema['default']}`")
if "minimum" in schema:
suffix.append(f"minimum `{schema['minimum']}`")
if "maximum" in schema:
suffix.append(f"maximum `{schema['maximum']}`")
if "minLength" in schema:
suffix.append(f"minimum length `{schema['minLength']}`")
if "minItems" in schema:
suffix.append(f"minimum items `{schema['minItems']}`")
return f"; {'; '.join(suffix)}" if suffix else ""
def _schema_fields(
schema: Mapping[str, Any],
definitions: Mapping[str, Any],
) -> list[tuple[str, str, str, bool]]:
"""Extract fields, types, descriptions, and required markers."""
reference = schema.get("$ref")
if isinstance(reference, str):
schema = definitions.get(reference.rsplit("/", 1)[-1], {})
properties = schema.get("properties")
if not isinstance(properties, Mapping):
return []
required = set(schema.get("required") or [])
fields = []
for name, raw_schema in properties.items():
if not isinstance(raw_schema, Mapping):
continue
fields.append(
(
str(name),
_schema_type(raw_schema, definitions),
str(raw_schema.get("description") or ""),
str(name) in required,
)
)
return fields
def _resolve_schema(
schema: Mapping[str, Any],
definitions: Mapping[str, Any],
) -> Mapping[str, Any]:
"""Resolve a local JSON Schema reference into an enumerable object."""
reference = schema.get("$ref")
if isinstance(reference, str):
resolved = definitions.get(reference.rsplit("/", 1)[-1])
if isinstance(resolved, Mapping):
return resolved
return schema
def _render_api_docs() -> str:
"""Render the complete MoviePilot API operation and field contract."""
schema = _load_json_schema()
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), Mapping) else {}
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS
specs = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
lines = [
"## Operation Catalog",
"",
"The operations, HTTP methods, routes, and path/query/body fields below exactly match external MCP `tools/list`.",
"A field name ending in `*` is required. Omit an empty bucket or send `{}`. Referenced body models are expanded below.",
"",
]
for operation_id in sorted(API_OPERATION_ROUTES):
route = API_OPERATION_ROUTES[operation_id]
branch = next(
item
for item in schema["oneOf"]
if item["properties"]["operation_id"].get("const") == operation_id
)
description = str(branch.get("description") or "")
effect = specs[operation_id].effect.value
lines.extend(
[
f"### `{operation_id}`",
f"`{route.method} {route.path}`; policy effect: `{effect}`.",
f"Purpose: {description.split(' Method:', 1)[0].strip()}",
]
)
for bucket in ("path_params", "query", "body"):
bucket_schema = branch["properties"].get(bucket)
if not isinstance(bucket_schema, Mapping):
lines.append(f"- `{bucket}`: none")
continue
resolved_bucket = _resolve_schema(bucket_schema, definitions)
fields = _schema_fields(resolved_bucket, definitions)
if not fields:
if resolved_bucket.get("type") != "object":
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
description_text = str(resolved_bucket.get("description") or "")
if not description_text:
raise ValueError(
f"API Skill scalar guidance is missing: {operation_id}.{bucket}"
)
lines.append(
f"- `{bucket}{required_mark}` ({_schema_type(resolved_bucket, definitions)}): "
f"{description_text}"
)
continue
dynamic_description = str(resolved_bucket.get("description") or "")
if resolved_bucket.get("additionalProperties") is True and dynamic_description:
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
lines.append(f"- `{bucket}{required_mark}` (object): {dynamic_description}")
continue
body_ref = bucket_schema.get("$ref")
label = (
body_ref.rsplit("/", 1)[-1]
if isinstance(body_ref, str)
else "empty object"
)
lines.append(f"- `{bucket}`: `{label}` with no direct fields")
continue
rendered = []
for name, field_type, description_text, required in fields:
required_mark = "*" if required else ""
if not description_text:
raise ValueError(
f"API Skill field guidance is missing: {operation_id}.{bucket}.{name}"
)
raw_schema = resolved_bucket["properties"][name]
rendered.append(
f"`{name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
)
lines.append(f"- `{bucket}`: " + "; ".join(rendered))
lines.append("")
lines.extend(["### Referenced Body Models", ""])
referenced: set[str] = set()
for branch in schema["oneOf"]:
for bucket in ("path_params", "query", "body"):
bucket_schema = branch["properties"].get(bucket)
if not isinstance(bucket_schema, Mapping):
continue
resolved_bucket = _resolve_schema(bucket_schema, definitions)
for raw_schema in (resolved_bucket.get("properties") or {}).values():
if not isinstance(raw_schema, Mapping):
continue
refs = [raw_schema.get("$ref")]
refs.extend(
item.get("$ref")
for item in raw_schema.get("anyOf", [])
if isinstance(item, Mapping)
)
for reference in refs:
if isinstance(reference, str) and reference.startswith("#/$defs/"):
referenced.add(reference.rsplit("/", 1)[-1])
pending = list(referenced)
while pending:
name = pending.pop()
model = definitions.get(name)
if not isinstance(model, Mapping):
continue
for raw_schema in (model.get("properties") or {}).values():
if not isinstance(raw_schema, Mapping):
continue
references = [raw_schema.get("$ref")]
references.extend(
item.get("$ref")
for item in raw_schema.get("anyOf", [])
if isinstance(item, Mapping)
)
for reference in references:
if isinstance(reference, str) and reference.startswith("#/$defs/"):
child = reference.rsplit("/", 1)[-1]
if child not in referenced:
referenced.add(child)
pending.append(child)
for name in sorted(referenced):
model = definitions.get(name)
if not isinstance(model, Mapping):
continue
lines.append(f"#### `{name}`")
model_description = str(model.get("description") or "")
if not model_description:
raise ValueError(f"API Skill model guidance is missing: {name}")
lines.append(model_description)
fields = _schema_fields(model, definitions)
if not fields:
lines.append("This runtime model has no directly writable fields.")
else:
for field_name, field_type, description_text, required in fields:
required_mark = "*" if required else ""
raw_schema = model["properties"][field_name]
if not description_text:
raise ValueError(f"API Skill model field guidance is missing: {name}.{field_name}")
lines.append(
f"- `{field_name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
)
lines.append("")
lines.append(_render_system_settings_docs().rstrip())
lines.append("")
lines.extend(
[
"## Operation Order And Failure Handling",
"",
"1. Select the operation first, then place each value in its documented bucket. Never move query fields into path_params or send undeclared fields.",
"2. Reuse the exact `media_source` + `media_id` pair returned by search. For music, also preserve `music_type`.",
"3. Downloads, transfers, configuration/rule/plugin writes, scheduler/workflow runs, and deletions have side effects; obtain confirmation and inspect the result.",
"4. `success=false`, HTTP errors, validation errors, and empty results are real outcomes. Never report them as success.",
"5. Use `database-operation`, `downloader-operation`, or `mediaserver-operation` for their native capabilities. Never bypass the gateway with an arbitrary URL.",
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def _render_service_docs(script_path: Path, title: str) -> str:
"""Render one downloader or media-server action contract."""
namespace = runpy.run_path(str(script_path))
actions = namespace.get("ACTIONS")
if not isinstance(actions, Mapping):
raise ValueError(f"{script_path} does not expose ACTIONS")
lines = [
"## Complete Action Contract",
"",
f"This is the complete {title} action contract. It comes directly from the script `ACTIONS` registry and matches the external MCP `tools/list` oneOf branches.",
"A field name ending in `*` is required. Put every action parameter in the `arguments` object.",
"",
"| action | Purpose and argument summary |",
"| :--- | :--- |",
]
for name, spec in sorted(actions.items()):
contract = spec.to_dict(name)
argument_names = [
f"`{argument['name']}{'*' if argument['required'] else ''}`"
for argument in contract["arguments"]
]
summary = contract["description"]
if argument_names:
summary += "; arguments: " + ", ".join(argument_names)
else:
summary += "; no arguments"
lines.append(f"| `{name}` | {summary} |")
lines.append("")
for name, spec in sorted(actions.items()):
contract = spec.to_dict(name)
lines.extend(
[
f"### `{name}`",
f"{contract['description']} Effect: `{contract['effect']}`. Providers: `{', '.join(contract['providers'])}`.",
]
)
if not contract["arguments"]:
lines.append("- `arguments`: `{}`")
else:
for argument in contract["arguments"]:
required_mark = "*" if argument["required"] else ""
extras = []
if "default" in argument:
extras.append(f"default `{argument['default']}`")
if argument.get("enum"):
extras.append(f"allowed values `{','.join(map(str, argument['enum']))}`")
suffix = f"; {'; '.join(extras)}" if extras else ""
lines.append(
f"- `{argument['name']}{required_mark}` ({argument['type']}{suffix}): {argument['description']}"
)
for rule in contract.get("argument_rules") or []:
lines.append(f"- Rule: {rule}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _render_database_tables() -> str:
"""Render all ORM tables, purposes, and access boundaries."""
from app.db.base import Base
from app.db.models import load_all_models
load_all_models()
table_names = {"alembic_version", *Base.metadata.tables}
missing_guides = table_names - DATABASE_TABLE_GUIDES.keys()
stale_guides = DATABASE_TABLE_GUIDES.keys() - table_names
if missing_guides or stale_guides:
raise ValueError(
"Database table guidance does not match ORM metadata: "
f"missing={sorted(missing_guides)}, stale={sorted(stale_guides)}"
)
lines = [
"## Core Tables",
"",
"`tables` returns the tables that exist in the current instance. The catalog below covers every MoviePilot ORM table plus Alembic metadata. Always treat the live `schema <table>` result as authoritative.",
"",
]
for table_name in sorted(table_names):
purpose, query_usage, write_boundary = DATABASE_TABLE_GUIDES[table_name]
if table_name == "alembic_version":
columns = "`version_num`"
else:
table = Base.metadata.tables[table_name]
columns = ", ".join(f"`{column.name}`" for column in table.columns)
lines.extend(
[
f"### `{table_name}`",
f"- Purpose: {purpose}",
f"- Useful queries: {query_usage}",
f"- Write boundary: {write_boundary}",
f"- Columns: {columns}",
"",
]
)
lines.extend(
[
"## Database Action Contract",
"",
"- `tables`: `arguments={}` lists current database tables.",
"- `schema`: `arguments={\"table_name\":\"downloadhistory\"}`; table_name must come from `tables`.",
"- `query`: `arguments={\"sql\":\"SELECT ...\",\"limit\":100,\"write\":false}`; provide exactly one of sql and file. SELECT/WITH/EXPLAIN are allowed by default.",
"- `write`: `arguments={\"sql\":\"UPDATE ... WHERE ...\"}`; provide exactly one of sql and file and only one statement.",
"- `file` is a local SQL path readable by the MoviePilot process. MCP clients normally send `sql` directly.",
"",
"Use the live `schema` result instead of guessing columns from older documentation. Treat `media_source` and `media_id` as one atomic identity pair.",
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def _render_system_settings_docs() -> str:
"""Render dynamic system-setting discovery and update guidance."""
return "\n".join([
"## System Settings Contract",
"",
"Do not enumerate setting keys in this Skill. Settings change as MoviePilot evolves, so use `config.system.get` as the runtime discovery operation before updating an unfamiliar key.",
"",
"| `source` | Contents | Persistence |",
"| :--- | :--- | :--- |",
"| `settings` | Runtime `Settings` fields such as APP_DOMAIN or LLM_MODEL | Type-converted and persisted to `app.env`, then applied to the current process |",
"| `systemconfig` | Database-backed business configuration such as downloaders, media servers, directories, and notifications | Written through the configuration service with plugin admission and change events |",
"",
"The `systemconfig` database table is only the physical store for the second source. Use `config.system.get/update` for normal reads and writes. Direct SQL is reserved for an explicitly authorized repair when the managed API cannot complete the operation.",
"",
"### Discover definitions",
"",
"1. Call `config.system.get` with `query={\"group\":\"settings\",\"keyword\":\"LLM\"}` or another group/keyword. Discovery defaults to summaries instead of full values.",
"2. Each returned setting includes `setting_key`, `source`, `group`, `label`, and a `definition` object with `declared_type`, current `value_shape`, `nullable`, `sensitive`, allowed `update_operations`, `default_match_field`, and `persistence`.",
"3. Read one exact value with `query={\"setting_key\":\"LLM_MODEL\"}`. Exact-key reads include the value by default.",
"4. Use `show_secrets=true` only when an administrator explicitly requests the plaintext value; secret reads remain confirmation-protected.",
"",
"### Update settings",
"",
"Choose an operation listed in the discovered setting definition, then send it in `body`:",
"",
"| operation | Fields | Meaning |",
"| :--- | :--- | :--- |",
"| `replace` | `setting_key*`, `value` | Replace the complete scalar, list, or object value |",
"| `merge_dict` | `setting_key*`, `value`; optional `remove_keys` | Shallow-merge an object and optionally remove keys |",
"| `upsert_list_item` | `setting_key*`, `value`; optional `match_field`, `match_value` | Replace a matched list item or append it when absent |",
"| `remove_list_item` | `setting_key*`, `value` or `match_value`; optional `match_field` | Remove one matched list item without replacing the list |",
"",
"After every update, call `config.system.get` again with the exact setting_key and verify the saved value. Do not guess a key, value shape, list match field, or update operation when discovery can return it.",
"",
]).rstrip() + "\n"
def _sync_api_frontmatter(text: str) -> str:
"""Synchronize the API Skill authorization list with the fixed operation registry."""
from app.agent.policy.api import list_api_operation_ids
operation_text = " ".join(list_api_operation_ids())
wrapped = textwrap.wrap(
operation_text,
width=96,
break_long_words=False,
break_on_hyphens=False,
)
replacement = "allowed-api-operations: >-\n" + "\n".join(f" {line}" for line in wrapped) + "\n"
lines = text.splitlines(keepends=True)
frontmatter_end = next(
(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"),
None,
)
if frontmatter_end is None:
raise ValueError("moviepilot-api Skill frontmatter closing marker is missing")
field_index = next(
(
index
for index, line in enumerate(lines[:frontmatter_end])
if line.startswith("allowed-api-operations:")
),
None,
)
if field_index is None:
raise ValueError("moviepilot-api Skill frontmatter operation list is missing")
field_end = field_index + 1
while field_end < frontmatter_end and lines[field_end].startswith((" ", "\t")):
field_end += 1
lines[field_index:field_end] = [replacement]
return "".join(lines)
def _replace_section(text: str, heading: str, replacement: str, next_heading: str | None = None) -> str:
"""Replace one Markdown section from a heading to the next heading."""
start = text.index(heading)
if next_heading is None:
return text[:start].rstrip() + "\n\n" + replacement
end = text.index(next_heading, start)
return text[:start].rstrip() + "\n\n" + replacement + "\n" + text[end:]
def main() -> int:
"""Generate complete contracts for four built-in Skills."""
api_path = PROJECT_ROOT / "skills/moviepilot-api/SKILL.md"
api_text = api_path.read_text(encoding="utf-8")
api_text = _sync_api_frontmatter(api_text)
api_path.write_text(
_replace_section(api_text, "## Operation Catalog", _render_api_docs()),
encoding="utf-8",
)
downloader_path = PROJECT_ROOT / "skills/downloader-operation/SKILL.md"
downloader_text = downloader_path.read_text(encoding="utf-8")
downloader_path.write_text(
_replace_section(
downloader_text,
"## Complete Action Contract",
_render_service_docs(
PROJECT_ROOT / "skills/downloader-operation/scripts/mp-downloader.py",
"Downloader Operation",
),
"## Verification",
),
encoding="utf-8",
)
mediaserver_path = PROJECT_ROOT / "skills/mediaserver-operation/SKILL.md"
mediaserver_text = mediaserver_path.read_text(encoding="utf-8")
mediaserver_path.write_text(
_replace_section(
mediaserver_text,
"## Complete Action Contract",
_render_service_docs(
PROJECT_ROOT / "skills/mediaserver-operation/scripts/mp-mediaserver.py",
"Media Server Operation",
),
"## Safety And Verification",
),
encoding="utf-8",
)
database_path = PROJECT_ROOT / "skills/database-operation/SKILL.md"
database_text = database_path.read_text(encoding="utf-8")
database_path.write_text(
_replace_section(
database_text,
"## Core Tables",
_render_database_tables(),
"## Common Queries",
),
encoding="utf-8",
)
print("generated four Agent Skill contracts")
return 0
if __name__ == "__main__":
raise SystemExit(main())