MCP server and AI control
NameFrame ships a Model Context Protocol server, so an assistant such as Claude can read your scene, propose a scenario, dry-run it and, only with your explicit approval, start a generation task. Reading is free. Writing is deliberately hard.
The safety model in one paragraph#
The MCP server delegates to the public NameFrame Python SDK and API. It never calls Unreal
Remote Control directly, never runs shell commands, never evaluates Python, and never passes
filesystem paths through. Every write tool defaults to a dry run and requires an explicit
approved flag plus an idempotency key. When an approval store is configured, the
request is parked in a durable queue that a human clears from inside the Unreal Studio panel.
List responses are capped, planning inputs are size-limited, and plans expire.
Install and run#
python -m pip install -e ".[mcp]"
nameframe api-serve --scene-manifest _local/scene_manifest.json
nameframe mcp-serve --api-url http://127.0.0.1:8010
mcp-serve speaks STDIO by default, so stdout is reserved for protocol messages
and diagnostics go to stderr. It uses the official Python MCP SDK v2 and the
2026-07-28 protocol revision.
Connecting a client
Point your MCP client at the command. A typical STDIO client entry:
{
"mcpServers": {
"nameframe": {
"command": "nameframe",
"args": ["mcp-serve", "--api-url", "http://127.0.0.1:8010"]
}
}
}
Inside Unreal, the Copy Setup button on the Settings page puts token-free client setup instructions on your clipboard, with secrets represented only as an environment variable name.
Scopes#
The server starts with safe read-only scopes. Write scopes have to be granted explicitly,
one --scope flag at a time.
| Granted by default | Must be granted explicitly |
|---|---|
nameframe:readnameframe:scene:readnameframe:config:readnameframe:previewnameframe:audit:readnameframe:task:readnameframe:metadata:readnameframe:analytics:readnameframe:coverage:readnameframe:model:readnameframe:experiments:read
|
nameframe:scene:writenameframe:config:writenameframe:task:writenameframe:generation:write
|
nameframe mcp-serve --api-url http://127.0.0.1:8010 --scope nameframe:config:write --output-root D:/NameFrame/_local/runs
--disable-writes forces safe read-only scopes regardless of what was granted,
which is the fastest way to make a running endpoint harmless. --output-root
allowlists where generation may write. It takes absolute paths and can be repeated.
Environment variables
| Variable | Effect |
|---|---|
NAMEFRAME_API_URL | Local API base URL (loopback HTTP only) |
NAMEFRAME_MCP_SCOPES | Comma-separated extra scopes |
NAMEFRAME_MCP_ACTOR | Bounded actor identity recorded in the audit |
NAMEFRAME_MCP_OUTPUT_ROOTS | Comma-separated allowed output roots |
NAMEFRAME_MCP_TIMEOUT | API read timeout, 0.05 to 300 seconds |
NAMEFRAME_MCP_LOG_PATH | Absolute path for the bounded JSONL request log |
Authenticated HTTP#
For anything beyond a single local client, use the Streamable HTTP transport. It requires a control-plane project and a bearer token, and adds RBAC, project binding, quotas and durable audit.
nameframe control-init _local/control.json
nameframe auth-user-create _local/control.json --user analyst --role operator
nameframe auth-token-create _local/control.json --user analyst
nameframe mcp-serve --transport streamable-http --control-store _local/control.json --project-id project_xxxx --port 8020 --max-requests-per-minute 120 --max-generation-tasks-per-day 20
Tokens are verified either against the local control store or via RFC 7662 OAuth
introspection, using --token-verifier oauth-introspection with
--oauth-introspection-url and --oauth-client-id. The host defaults to
loopback.
The approval queue#
With --approval-store <absolute path>, generation requests do not run
when an assistant asks. They get recorded as durable pending requests and wait for a human.
From the command line:
nameframe mcp-approval-list _local/approvals.json --status pending
nameframe mcp-approval-decide _local/approvals.json <request-id> --approve
nameframe mcp-approval-decide _local/approvals.json <request-id> --deny
From inside Unreal, the AI / MCP section on the Studio Settings page shows the queue with Inspect Plan, Approve and Deny. Approving one request approves exactly that request. The client still has to retry, and nothing else is unlocked.
Disable MCP Writes restarts a running endpoint with read-only scopes, or forces the next start to be read-only. Disconnect Clients stops the endpoint and closes every current session. Both are on the Studio Settings page.
Diagnosing a connection#
nameframe mcp-doctor
Read-only end-to-end diagnosis: whether the API answers, whether Unreal is reachable, whether the analytics index exists, and which scopes are in effect. Run it first when a client reports a problem.
Tool reference#
Grouped by what they are allowed to do. Read-only tools need no approval. Controlled writes
default to a dry run and require both approved and an idempotency key.
38 tools are exposed by this build.
Read only Read only
Never writes. No approval, safe to call at will.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.server.health | none | Inspect NameFrame MCP, API, Unreal, and analytics health. |
nameframe.enterprise.status | none | Inspect project-scoped quota, workers, shards, jobs, and recent audit. |
nameframe.observability.status | none | Read bounded payload-free MCP request metrics. |
nameframe.scene.get | detail_level: str = "summary" | Read the current scene at summary or full detail. |
nameframe.scene.scan | detail_level: str = "summary" | Request a fresh read-only scene scan through the NameFrame API. |
nameframe.scene.validate | generation_ready: bool = True, strict: bool = False | Validate the current scene and return blockers, warnings, and repairs. |
nameframe.class.list | limit: int = 50, offset: int = 0 | List a bounded page of registered semantic classes. |
nameframe.run.get | run_id: str | Read one existing run by identifier. |
nameframe.analytics.summary | run_id: str | None = None | Read bounded aggregate analytics, optionally with one run. |
nameframe.metadata.search_frames | filters: dict[str, Any] | None = None, limit: int = 50, offset: int = 0 | Search indexed frames through fixed filters; arbitrary SQL is not accepted. |
nameframe.analytics.compare_runs | baseline_run_id: str, candidate_run_id: str, representative_limit: int = 12 | Compare two indexed runs in memory without writing report artifacts. |
nameframe.analytics.annotation_issues | run_id: str = "", limit: int = 100 | Read bounded, measured annotation-health issues from the metadata index. |
nameframe.model.evaluate | results: list[dict[str, Any]], metric: str = "map50", pass_threshold: float = 0.5, slice_keys: list[str] | None = None | Evaluate measured model-result rows over bounded declared slices. |
nameframe.model.failures | evaluation: dict[str, Any], max_slices: int = 10 | Mine underperforming slices from measured evaluation output. |
nameframe.model.compare_synthetic_real | real_profile: dict[str, Any], synthetic_profile: dict[str, Any] | Compare bounded measured real and synthetic dataset profiles. |
nameframe.experiment.regression_campaign | name: str, evaluations: list[dict[str, Any]], required_pass_rate: float = 1.0, | Aggregate measured evaluations into a bounded regression campaign gate. |
nameframe.scenario.validate | scenario: dict[str, Any], strict: bool = False | Validate an inline ScenarioConfig against the current scene. |
nameframe.coverage.evaluate | axes: dict[str, list[Any]], observations: list[dict[str, Any]], target_per_cell: int = 1 | Evaluate a bounded coverage matrix without reading raw dataset arrays. |
nameframe.coverage.gaps | matrix: dict[str, Any], limit: int = 100 | Rank missing and under-covered cells from a NameFrame coverage matrix. |
nameframe.task.list | limit: int = 50, status: str = "" | List up to 100 durable NameFrame tasks, optionally filtered by status. |
nameframe.task.get | task_id: str | Read one durable task and its latest capture progress. |
Computed Computed
Derives a result in memory. Risk R1, no persistent write.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.model.domain_gap | comparison: dict[str, Any], threshold: float = 0.1 | Derive labelled recommendations from measured profile deltas. |
nameframe.scenario.dry_run | scenario: dict[str, Any], samples: int = 8, start_index: int = 0 | Resolve deterministic representative samples without generation or writes. |
nameframe.coverage.correction_plan | matrix: dict[str, Any], limit: int = 100, max_additional_frames: int = 100_000 | Draft corrections from measured deficits; never approve or start generation. |
Plan Plan
Produces an immutable session-local plan. Never starts generation.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.model.hard_case_plan | failure_report: dict[str, Any], samples_per_slice: int = 1000 | Draft an immutable evidence-linked hard-case plan without generation. |
nameframe.dataset.plan | scenario: dict[str, Any], requested_frames: int, annotation_tasks: list[str] | None = None, exporters: list[str] | None = None, split_policy: dict[str, float] | None = None, quality_profile: str = "balanced", output_destination: str | None = None, coverage_targets: dict[str, Any] | None = None | Create an immutable session-local plan; never start dataset generation. |
nameframe.dataset.validate | scenario: dict[str, Any] | None = None, idempotency_key: str = "" | Start a durable validation task without generation. |
Preview Preview
Writes temporary preview state only.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.preview.generate | scenario: dict[str, Any], count: int = 1, layer: str = "metadata", representative_mode: str = "scene_layout" | Generate up to eight temporary metadata scene-layout previews. |
Controlled write Controlled write
Risk R2. Defaults to a dry run; a real write needs approved plus an idempotency key.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.scenario.update | patch: dict[str, Any], expected_config_hash: str, dry_run: bool = True, approved: bool = False, idempotency_key: str = "" | Patch the active scenario with hash conflict checks; dry-run is the default. |
nameframe.spawner.update | patch: dict[str, Any], expected_config_hash: str, dry_run: bool = True, approved: bool = False, idempotency_key: str = "" | Patch only the active scenario spawn section; dry-run is the default. |
nameframe.camera.update | patch: dict[str, Any], expected_config_hash: str, dry_run: bool = True, approved: bool = False, idempotency_key: str = "" | Patch only the active scenario camera section; dry-run is the default. |
nameframe.environment.update | patch: dict[str, Any], expected_config_hash: str, dry_run: bool = True, approved: bool = False, idempotency_key: str = "" | Patch only the active scenario environment section; dry-run is the default. |
nameframe.scene.helper.create | helper: dict[str, Any], expected_config_hash: str, dry_run: bool = True, approved: bool = False, idempotency_key: str = "" | Create one allowlisted NameFrame helper; dry-run is the default. |
nameframe.dataset.generate | plan_id: str, approved: bool = False, idempotency_key: str = "" | Start one approved immutable plan and immediately return a durable task. |
nameframe.dataset.cancel | task_id: str, approved: bool = False, idempotency_key: str = "" | Request safe cancellation and preserve completed frames. |
nameframe.model.generate_hard_cases | hard_case_plan_id: str, case_id: str, dataset_plan_id: str, approved: bool = False, idempotency_key: str = "", | Start one approved dataset task linked to measured hard-case evidence. |
Task, non-idempotent Task, non-idempotent
Changes durable task state and cannot be safely repeated.
| Tool | Parameters | What it does |
|---|---|---|
nameframe.dataset.retry | task_id: str, approved: bool = False, idempotency_key: str = "" | Retry a failed or cancelled immutable task as a new archived attempt. |
nameframe.dataset.resume | task_id: str, approved: bool = False, idempotency_key: str = "" | Resume a restart-paused immutable task after snapshot checks. |
Resources#
Alongside tools, the server exposes read-only resources for clients that prefer to browse rather than call:
nameframe://server/health, nameframe://capabilities,
nameframe://project/current, nameframe://scene/current,
nameframe://scene/current/validation, nameframe://scenario/current,
nameframe://classes, nameframe://runs,
nameframe://runs/latest, nameframe://runs/{run_id},
nameframe://plans/{plan_id}, nameframe://audit/recent
Limits you should know about#
| Limit | Value |
|---|---|
| List responses | capped at 100 records |
| Dry-run sample rows | at most 100 |
| Dataset plan lifetime | 60 minutes, session-local, immutable |
| Representative samples in a plan | at most 8 |
| Previews per request | at most 8, metadata/scene-layout layer only |
| Coverage evaluation | 4 axes, 1,000 cells, 10,000 observations |
| Planning input size | 256 KB |
Capability discovery states explicitly that arbitrary shell, Python, Unreal console and filesystem passthrough are not available. That is a contract, not an oversight.
A realistic assistant workflow#
Read
nameframe.scene.getandnameframe.scene.validate, to see what is in the level and what is blocking.Propose
nameframe.scenario.validatethennameframe.scenario.dry_run, which checks a scenario and shows its deterministic samples with no side effects.Preview
nameframe.preview.generate, up to eight temporary layout previews.Plan
nameframe.dataset.planreturns an immutable plan document. Planning never starts generation.Ask
nameframe.dataset.generatewith the plan id. Without approval it gets parked in the queue.Approve
You inspect and approve it in Studio. The client retries and gets a durable task id.
Watch
nameframe.task.getuntil it completes, thennameframe.analytics.summary.