diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml new file mode 100644 index 0000000..556f32e --- /dev/null +++ b/.github/workflows/mcp-ci.yml @@ -0,0 +1,32 @@ +name: MCP CI + +on: + push: + branches: [main] + paths: ['mcp/**', '.github/workflows/mcp-ci.yml'] + pull_request: + branches: [main] + paths: ['mcp/**', '.github/workflows/mcp-ci.yml'] + +defaults: + run: + working-directory: mcp + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4.2.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest + - name: Lint + run: ruff check . diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml new file mode 100644 index 0000000..2c65e9e --- /dev/null +++ b/.github/workflows/mcp-release.yml @@ -0,0 +1,27 @@ +name: MCP Release + +on: + push: + tags: ['mcp-v*'] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for PyPI Trusted Publishing (OIDC) + steps: + - uses: actions/checkout@v4.2.2 + - name: Set up Python + uses: actions/setup-python@v5.6.0 + with: + python-version: "3.12" + - name: Build sdist and wheel + working-directory: mcp + run: | + python -m pip install --upgrade build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: mcp/dist diff --git a/README.md b/README.md index ceaca4e..633f156 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ This repository is the home for Nova3D's open clients and integrations. The host ``` Nova3D/ ├── app/ # Flutter/Dart web client · see app/README.md -├── mcp/ # Nova3D MCP server (coming soon) +├── mcp/ # Nova3D MCP server · see mcp/README.md ├── blender-plugin/ # Blender plugin (coming soon) ├── claude-skills/ # Claude skills (coming soon) ├── docs/ # architecture & the "3D as code" thesis (coming soon) diff --git a/mcp/.env.example b/mcp/.env.example new file mode 100644 index 0000000..0da8d01 --- /dev/null +++ b/mcp/.env.example @@ -0,0 +1,8 @@ +# Nova3D MCP Server — environment variables +# Copy to .env and fill in your values + +# Required: API key from nova3d.xyz → Settings → API Keys +NOVA3D_TOKEN=n3d_your-api-key-here + +# Optional: override the API base URL (advanced / self-hosted only) +# NOVA3D_API_URL=https://nova3d.xyz/api diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..318c9f0 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,13 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +*.egg-link +*.pth +.env +.claude/ +docs/ diff --git a/mcp/LICENSE b/mcp/LICENSE new file mode 100644 index 0000000..0c4fc22 --- /dev/null +++ b/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RareSense + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..77f6a75 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,440 @@ +# nova3d-mcp + + + +**Structured, part-aware 3D generation for AI agents.** + +nova3d-mcp is an [MCP](https://modelcontextprotocol.io) server that exposes +[Nova3D](https://nova3d.xyz)'s generation pipeline as a callable tool inside +Codex, Cursor, VS Code, Visual Studio, Claude Code, and other MCP-compatible agents. + +One tool call. A washing machine comes back with named drum, door, control +panel, and hose connectors — separately editable, not fused into a blob. + +--- + +## Quickstart + +Claude Code: + +1. Run `claude mcp add nova3d -- uvx nova3d-mcp` +2. In Claude, call `nova3d_login` +3. Complete the Nova3D browser sign-in flow +4. Then call `nova3d_status` + +Other MCP clients: + +- See [Install](#install) for client-specific setup +- Then follow the shared [First Run](#first-run) steps + +--- + +## Why Nova3D + +Every major AI 3D generator today produces **mesh blobs** — a single fused +object that looks plausible in a render and collapses the moment you try to +edit, rig, or pipeline it. + +Nova3D is different. Instead of diffusion → mesh, it runs: + +``` +prompt / image + ↓ +LLM writes Blender Python construction code + ↓ +headless Blender executes + validates + repairs + ↓ +structured GLB — named parts, intact hierarchy, real joints +``` + +The result is a 3D asset that **survives contact with real workflows**: game +engines, configurators, robotics simulations, AR scenes. Parts have names. +Hierarchy is intact. Joints are real. You can change one component without +regenerating everything. + +--- + +## Supported clients + +| Client | Status | Install path | Preview path | +|---|---|---|---| +| Codex | Supported | `codex mcp add` or Codex MCP config | Browser `conversation_url` | +| Cursor | Supported | `.cursor/mcp.json` or `~/.cursor/mcp.json` | Browser `conversation_url` | +| VS Code | Supported | `.vscode/mcp.json`, MCP: Add Server, or `code --add-mcp` | Browser `conversation_url` | +| Visual Studio | Supported | `.mcp.json` or Visual Studio MCP UI | Browser `conversation_url` | +| Claude Code | Supported | `claude mcp add` | Browser `conversation_url` | + +Nova3D runs from your MCP client, but model inspection happens through the +hosted browser viewer returned as `conversation_url`. This repository does not +currently ship an embedded IDE-native 3D viewport. + +--- + +## Install + +Add the MCP server in your client first. This only registers the server. It +does **not** complete Nova3D account onboarding yet. + +#### Codex + +```bash +codex mcp add nova3d -- uvx nova3d-mcp +``` + +Codex also supports MCP configuration through `~/.codex/config.toml`. If you +prefer config files over the CLI, use Codex's MCP config surface and point it +at the same stdio command: `uvx nova3d-mcp`. + +#### Claude Code + +```bash +claude mcp add nova3d -- uvx nova3d-mcp +``` + +#### Cursor + +Create `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for a global +install: + +```json +{ + "mcpServers": { + "nova3d": { + "command": "uvx", + "args": ["nova3d-mcp"] + } + } +} +``` + +#### VS Code + +Option A: add the server from the command line: + +```bash +code --add-mcp "{\"name\":\"nova3d\",\"command\":\"uvx\",\"args\":[\"nova3d-mcp\"]}" +``` + +Option B: create `.vscode/mcp.json` in your workspace: + +```json +{ + "servers": { + "nova3d": { + "command": "uvx", + "args": ["nova3d-mcp"] + } + } +} +``` + +You can also use `MCP: Add Server` from the Command Palette. + +#### Visual Studio + +Create `/.mcp.json` or `%USERPROFILE%/.mcp.json`: + +```json +{ + "servers": { + "nova3d": { + "command": "uvx", + "args": ["nova3d-mcp"] + } + } +} +``` + +You can also add the server from the Visual Studio MCP UI by providing the +stdio command `uvx` with args `["nova3d-mcp"]`. + +After the server is installed in any client, the next step is to call +`nova3d_login` or `nova3d_setup` from inside that client. Installation alone +does not complete Nova3D onboarding. + +## First Run + +After installing the MCP server in any client: + +1. Call `nova3d_login` +2. Complete the Nova3D sign-in flow in the browser tab that opens +3. Call `nova3d_status` +4. If `next_action == "purchase_credits"`, follow the returned purchase link +5. When `authenticated: true` and `generation_ready: true`, start using `generate_3d` + +Important: + +- Installing the MCP server and completing Nova3D sign-in are separate steps. +- `nova3d_login` opens a browser tab and may return `login_pending_confirmation: true` + while local MCP session setup is still being confirmed. +- If browser sign-in finishes but login completion is ambiguous, call + `nova3d_status` before retrying or considering manual fallback. +- `NOVA3D_TOKEN` is an advanced/manual fallback only for environments where + browser/loopback auth is unavailable. + +## For AI Assistants + +If you are helping a user set up Nova3D MCP: + +1. Ensure the `nova3d` MCP server is installed in the client +2. Call `nova3d_login` +3. Tell the user a browser tab will open and they must complete sign-in there +4. After browser sign-in, call `nova3d_status` +5. If `next_action == "purchase_credits"`, direct the user to the returned URL +6. Proceed to `generate_3d` only when `authenticated: true` and `generation_ready: true` +7. Only suggest manual `NOVA3D_TOKEN` setup if browser/loopback auth is unavailable + +## Onboarding Decision Tree + +- If `nova3d_login` returns `login_pending_confirmation: true` + - complete the browser sign-in flow + - then call `nova3d_status` +- If `nova3d_status.next_action == "sign_in"` + - call `nova3d_login` +- If `nova3d_status.next_action == "session_expired"` + - call `nova3d_login` again +- If `nova3d_status.next_action == "purchase_credits"` + - follow the returned purchase URL +- If `nova3d_status.next_action == null` and `generation_ready == true` + - proceed to `generate_3d` + +## Local install + +If you prefer to install from source instead of `uvx`, clone the repository and +install the package locally: + +```bash +git clone https://github.com/RareSense/Nova3D.git +cd Nova3D/mcp +python3.10 -m venv .venv && source .venv/bin/activate +pip install . +``` + +Then replace `uvx nova3d-mcp` in the client examples above with the local +`nova3d-mcp` executable from your environment. + +## Typical workflow + +Once onboarding is complete, pass a prompt like this to your AI agent: + +``` +Generate a vending machine with separate door, glass panel, coin slot, +button grid, frame, and interior shelving. +``` + +The agent calls `generate_3d`. You get back: + +```json +{ + "glb_url": "https://nova3d.xyz/assets/abc123.glb", + "conversation_url": "https://app.nova3d.xyz/chat/conv-...", + "parts": ["door", "glass_panel", "coin_slot", "button_grid", "frame", "shelf_1", "shelf_2"], + "joint_count": 1, + "code_artifact": { ... }, + "workflow_id": "state-..." +} +``` + +- **`conversation_url`** — your editing session in the Nova3D app, with the generated model and edit history already hydrated. All subsequent `regenerate_part`, `add_part`, and `articulate_model` calls on this asset link back to the same session. + +--- + +## Configuration notes + +- `conversation_url` is the standard supported way to inspect generated assets — it opens your fully hydrated editing session in the Nova3D app. +- Preferred onboarding is browser sign-in through `nova3d_login`, then `nova3d_status` to confirm credits/readiness. +- `nova3d_login` opens a browser tab and starts local MCP session setup through a loopback callback. +- `nova3d_status` is the canonical follow-up check for authentication, credits, and readiness. +- Keep secrets out of checked-in workspace config when possible. Prefer + per-user configuration files or client-managed environment variables. +- If your editor supports source-controlled MCP config, commit the server entry + and inject `NOVA3D_TOKEN` per-user only for the advanced/manual fallback path. + +--- + +## Troubleshooting + +| Problem | What to check | +|---|---| +| Prompted to sign in before generation | Call `nova3d_login`, then re-check with `nova3d_status` | +| `nova3d_login` returns `login_pending_confirmation: true` | Finish the browser sign-in step, then call `nova3d_status` | +| Browser sign-in finished but `nova3d_login` did not confirm completion | Call `nova3d_status` now. If it still shows not signed in, retry `nova3d_login` | +| Told that credits are required | Follow the purchase link returned by `nova3d_status` | +| Auth failure on startup | Sign in again with `nova3d_login`, or confirm the manual key at https://app.nova3d.xyz/api-key | +| `uvx` not found | Install `uv` or use a local `nova3d-mcp` executable from a virtualenv | +| No 3D preview inside the editor | Open the returned `conversation_url` in the browser; that is the supported preview path | + +--- + +## Tools + +### `generate_3d` + +Generate a structured 3D asset from text (and optional reference image). +Initial generation runs through Nova3D's paid GraphFlow v2 workflow. This MCP +server does not expose BYOK/provider-key generation. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `prompt` | string | ✓ | Asset description. Be specific about parts. | +| `model` | string | | Paid routing preset: `"gemini"` (default) · `"claude-sonnet"` · `"claude-opus"` · `"claude-opus-latest"` · `"gpt-5.5"` | +| `image_base64` | string | | Reference image as plain base64; the server converts it to the v2 `image_artifact` data-URL format | +| `image_mime` | string | | e.g. `"image/jpeg"` | + +**Returns:** `glb_url`, `conversation_url`, `parts`, `joint_count`, `code_artifact`, `model_artifact`, `workflow_id`. Pass `code_artifact` to any edit tool. Open `conversation_url` to see the full edit history for this asset in the Nova3D app. + +--- + +### `regenerate_part` + +Regenerate one named part without rebuilding the whole asset. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `code_artifact` | object | ✓ | From prior `generate_3d` result | +| `part_type` | string | ✓ | Part name e.g. `"door"`, `"handle"` | +| `description` | string | ✓ | What the new part should look like | +| `model` | string | | `"gemini"` (default) · `"claude-sonnet"` · `"claude-opus"` · `"claude-opus-latest"` · `"gpt-5.5"` | + +**Finding part names:** Open the `conversation_url` from your generation and +inspect the model viewer — each mesh is labeled. Use that exact name as +`part_type`. + +--- + +### `add_part` + +Add a new component to an existing asset. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `code_artifact` | object | ✓ | From prior generation result | +| `description` | string | ✓ | Description of the new part and where it goes | +| `model` | string | | `"gemini"` (default) · `"claude-sonnet"` · `"claude-opus"` · `"claude-opus-latest"` · `"gpt-5.5"` | + +--- + +### `articulate_model` + +Add joints, hinges, or rotational articulation to an existing asset. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `code_artifact` | object | ✓ | From prior generation result | +| `articulation_request` | string | ✓ | What should move and how | +| `model_url` | string | | `glb_url` from prior generation. Provide this or `model_artifact`. | +| `model_artifact` | object | | `model_artifact` from prior generation. Provide this or `model_url`. | +| `model` | string | | `"gemini"` (default) · `"claude-sonnet"` · `"claude-opus"` · `"claude-opus-latest"` · `"gpt-5.5"` | +| `selected_meshes` | list | | Specific mesh names to articulate | + +--- + +### `get_generation_status` + +Check the status of a running workflow by ID. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `workflow_id` | string | ✓ | From any prior generation tool | + +--- + +### `nova3d_login` + +Start the preferred browser-based Nova3D sign-in flow and store a local MCP session. +This opens a browser tab. If the browser flow finishes but local completion is +ambiguous, call `nova3d_status` before using manual token fallback. + +--- + +### `nova3d_status` + +Return the canonical Nova3D onboarding/readiness state, including identity, +credits, generation readiness, and the next recommended action. + +--- + +### `nova3d_logout` + +Clear the locally stored MCP session. This does not remove an advanced/manual +`NOVA3D_TOKEN` from your MCP config. + +--- + +## Typical workflow + +``` +1. generate_3d("robot dog with four legs, head, torso, and tail") + → glb_url, conversation_url, parts, code_artifact + +2. Open conversation_url in browser + → see named parts, identify what needs changing + +3. regenerate_part(code_artifact, part_type="head", description="...") + → updated glb_url, same conversation_url + +4. add_part(code_artifact, description="a wagging tail with three segments") + → updated glb_url, parts list now includes new tail segments + +5. articulate_model(code_artifact, model_url, "make legs rotate at hip joints") + → glb_url with working joints +``` + +All edit tools accept the `code_artifact` from any prior result and return an updated one. Always pass the most recent `code_artifact` forward — it carries the session state that links your edits together. + +--- + +## Model reference + +| `model` value | Provider | Notes | +|---|---|---| +| `"gemini"` *(default)* | Google Gemini | Recommended for spatial reasoning | +| `"claude-sonnet"` | Anthropic | Strong reasoning | +| `"claude-opus"` | Anthropic | Most capable Anthropic model | +| `"claude-opus-latest"` | Anthropic | Latest Opus version | +| `"gpt-5.5"` | OpenAI | Latest GPT model | + +--- + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `NOVA3D_TOKEN` | | Advanced/manual fallback API key from https://app.nova3d.xyz/api-key | +| `NOVA3D_API_URL` | | Override API base URL (default: `https://nova3d.xyz/api`) | +| `NOVA3D_APP_URL` | | Override app URL for conversation links (default: `https://app.nova3d.xyz`) | + +--- + +## How it differs from blender-mcp + +[blender-mcp](https://github.com/ahujasid/blender-mcp) (21.9k ★) gives AI +agents a remote control for a **locally running Blender instance**. It requires +Blender installed, produces unstructured output, and inherits all the bpy +hallucination problems of raw LLM → Blender code generation. + +nova3d-mcp is different in kind: + +| | blender-mcp | nova3d-mcp | +|---|---|---| +| Blender required | Yes | No | +| Output | Unstructured scene | Named, hierarchical GLB | +| Validation | None | Server-side repair loop | +| Part awareness | No | Yes — named, addressable | +| Joints | Manual scripting | First-class output | +| Hosted backend | No | Yes | + +--- + +## Contributing + +Issues, PRs, and workflow feedback welcome. +[github.com/RareSense/Nova3D](https://github.com/RareSense/Nova3D/tree/main/mcp) + +Community Discord: [discord.gg/QEH8mzcwdR](https://discord.gg/QEH8mzcwdR) + +--- + +## License + +MIT — see [LICENSE](LICENSE) diff --git a/mcp/llms.txt b/mcp/llms.txt new file mode 100644 index 0000000..88ef8ce --- /dev/null +++ b/mcp/llms.txt @@ -0,0 +1,65 @@ +# Nova3D + +> Structured, part-aware 3D generation for AI agents and developers. + +Nova3D generates 3D assets where every component is a named, separately editable mesh - not a fused blob. It runs a hosted pipeline: an LLM writes Blender Python construction code, headless Blender executes and validates it, and the result is a structured GLB with intact hierarchy, named parts, and real joint definitions. + +## What makes it different + +Most AI 3D generators produce diffusion-based mesh blobs that look good in renders but break in real workflows. Nova3D outputs assets that survive contact with game engines, configurators, robotics simulations, and AR scenes. Parts have names. Hierarchy is intact. Joints are real. You can change one component without regenerating everything. + +## How to use Nova3D as an AI agent + +Nova3D is available as an MCP (Model Context Protocol) server. Install it with: + +``` +claude mcp add nova3d -- uvx nova3d-mcp +``` + +Preferred onboarding after install: + +- call `nova3d_login` to sign in through the browser +- complete the Nova3D sign-in flow in the browser tab that opens +- call `nova3d_status` to confirm credits and readiness + +Advanced/manual fallback: set `NOVA3D_TOKEN` if you cannot complete the browser flow. + +Important: after installing the MCP server in any client, the next step is to +call `nova3d_login` or `nova3d_setup` from inside that client. Installation +alone does not complete Nova3D onboarding. + +Full documentation and source: https://github.com/RareSense/Nova3D/tree/main/mcp + +## Tools available via MCP + +- `nova3d_setup` - get setup instructions if the user hasn't configured a token yet +- `nova3d_login` - start the preferred browser-based Nova3D sign-in flow and store a local MCP session +- if browser sign-in finishes but local completion is ambiguous, call `nova3d_status` before falling back to manual token setup +- `nova3d_status` - return the canonical Nova3D onboarding/readiness state, including credits and next action +- `nova3d_logout` - clear the locally stored MCP session +- `generate_3d` - generate a structured GLB from a text prompt or reference image using Nova3D's paid v2 workflow; returns glb_url, conversation_url, named parts list, and code_artifact +- `regenerate_part` - replace one named part without rebuilding the whole asset +- `add_part` - add a new component to an existing asset +- `articulate_model` - add joints, hinges, or rotational articulation to an existing asset +- `get_generation_status` - check the status of a running workflow by ID + +## Workflow pattern + +``` +generate_3d(prompt) -> glb_url, conversation_url, parts, code_artifact +regenerate_part(code_artifact, part_type, description) -> updated asset +add_part(code_artifact, description) -> asset with new component +articulate_model(code_artifact, model_url, articulation_request) -> asset with joints +``` + +Always pass the `code_artifact` from the most recent result into the next edit call - it carries session state. The `conversation_url` returned by `generate_3d` links to a browser-based view of the full generation and edit history in the Nova3D app; surface it to the user. + +## Authentication + +Preferred authentication is browser sign-in through `nova3d_login`. `NOVA3D_TOKEN` remains available only as an advanced/manual fallback for non-interactive environments. + +Set `NOVA3D_APP_URL` if conversation links should open a local or self-hosted client instead of `https://app.nova3d.xyz`. + +## Model options + +Pass `model` to any generation tool. Valid values: `"gemini"` (default), `"claude-sonnet"`, `"claude-opus"`, `"claude-opus-latest"`, `"gpt-5.5"`. These route to Nova3D-managed paid v2 tiers; this MCP server does not expose BYOK generation. diff --git a/mcp/nova3d_mcp/__init__.py b/mcp/nova3d_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/nova3d_mcp/__main__.py b/mcp/nova3d_mcp/__main__.py new file mode 100644 index 0000000..df77ffc --- /dev/null +++ b/mcp/nova3d_mcp/__main__.py @@ -0,0 +1,3 @@ +from nova3d_mcp.server import main + +main() diff --git a/mcp/nova3d_mcp/auth.py b/mcp/nova3d_mcp/auth.py new file mode 100644 index 0000000..acff197 --- /dev/null +++ b/mcp/nova3d_mcp/auth.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import asyncio +import secrets +import webbrowser +from dataclasses import dataclass +from typing import Awaitable, Callable, Optional +from urllib.parse import urlencode + +from nova3d_mcp.client import Nova3DClient, Nova3DError +from nova3d_mcp.loopback import LoopbackServer +from nova3d_mcp.models import MCPStatus +from nova3d_mcp.session_store import SessionStore + +LOGIN_TIMEOUT_SECONDS = 300.0 + + +class Nova3DLoginError(Nova3DError): + def __init__( + self, + message: str, + *, + browser_url: Optional[str] = None, + should_check_status: bool = False, + manual_fallback_only: bool = False, + ) -> None: + super().__init__(message) + self.browser_url = browser_url + self.should_check_status = should_check_status + self.manual_fallback_only = manual_fallback_only + + +@dataclass +class LoginResult: + token: str + expires_at: Optional[str] + status: MCPStatus + connect_url: str + port: int + + +@dataclass +class PendingLogin: + connect_url: str + port: int + task: "asyncio.Task[LoginResult]" + + +class Nova3DAuthenticator: + def __init__( + self, + *, + base_url: str, + app_url: str, + session_store: SessionStore, + ) -> None: + self._base_url = base_url + self._app_url = app_url.rstrip("/") + self._session_store = session_store + + async def login(self) -> LoginResult: + return await self.login_with_progress() + + async def login_with_progress( + self, + on_progress: Optional[Callable[[str], Awaitable[None]]] = None, + ) -> LoginResult: + pending = await self.begin_login(on_progress=on_progress) + return await pending.task + + async def begin_login( + self, + on_progress: Optional[Callable[[str], Awaitable[None]]] = None, + ) -> PendingLogin: + state = secrets.token_urlsafe(32) + loopback = LoopbackServer() + try: + port = await loopback.start() + except OSError as e: + raise Nova3DLoginError( + "Nova3D could not start the local callback listener needed for browser sign-in. " + "Use manual NOVA3D_TOKEN setup only if browser/loopback auth is unavailable in this environment.", + manual_fallback_only=True, + ) from e + + connect_url = self._build_connect_url(state=state, port=port) + await _notify( + on_progress, + "Opening Nova3D sign-in in your browser.", + ) + opened = webbrowser.open(connect_url) + if not opened: + await loopback.close() + raise Nova3DLoginError( + "Nova3D could not open your browser automatically. " + f"Open this URL manually: {connect_url}", + browser_url=connect_url, + ) + await _notify( + on_progress, + "Complete the Nova3D sign-in flow in the browser tab that opened, then wait here for confirmation.", + ) + + task = asyncio.create_task( + self._complete_login( + loopback=loopback, + state=state, + connect_url=connect_url, + port=port, + on_progress=on_progress, + ) + ) + return PendingLogin(connect_url=connect_url, port=port, task=task) + + async def _complete_login( + self, + *, + loopback: LoopbackServer, + state: str, + connect_url: str, + port: int, + on_progress: Optional[Callable[[str], Awaitable[None]]] = None, + ) -> LoginResult: + + try: + callback = await loopback.wait_for_callback(LOGIN_TIMEOUT_SECONDS) + except asyncio.TimeoutError as e: + raise Nova3DLoginError( + "Nova3D browser sign-in was opened successfully, but the local MCP callback was not confirmed yet. " + "If you completed sign-in in the browser, call nova3d_status now. " + "If status still shows not signed in, retry nova3d_login. " + "Use manual NOVA3D_TOKEN setup only if browser/loopback auth is unavailable in this environment.", + browser_url=connect_url, + should_check_status=True, + ) from e + finally: + await loopback.close() + + if callback.state != state: + raise Nova3DLoginError( + "Nova3D sign-in callback state mismatch. " + "If you completed sign-in in the browser, call nova3d_status now. Otherwise retry nova3d_login.", + browser_url=connect_url, + should_check_status=True, + ) + if not callback.code: + raise Nova3DLoginError( + "Nova3D sign-in callback completed without a session code. " + "If the browser shows you are signed in, call nova3d_status now. Otherwise retry nova3d_login.", + browser_url=connect_url, + should_check_status=True, + ) + + await _notify( + on_progress, + "Browser sign-in completed. Finalizing your Nova3D MCP session.", + ) + async with Nova3DClient(token=None, base_url=self._base_url) as client: + try: + exchange = await client.exchange_mcp_session(callback.code) + except Nova3DError as e: + raise Nova3DLoginError( + "Nova3D browser sign-in completed, but the MCP session could not be finalized. " + "If the browser shows you are signed in, call nova3d_status now. " + "Otherwise retry nova3d_login.", + browser_url=connect_url, + should_check_status=True, + ) from e + + self._session_store.save_session(exchange.token, exchange.expires_at) + + await _notify( + on_progress, + "Nova3D sign-in completed. Checking credits and readiness.", + ) + async with Nova3DClient(token=exchange.token, base_url=self._base_url) as client: + status = await client.get_mcp_status() + + return LoginResult( + token=exchange.token, + expires_at=exchange.expires_at, + status=status, + connect_url=connect_url, + port=port, + ) + + def _build_connect_url(self, *, state: str, port: int) -> str: + query = urlencode({"state": state, "port": str(port)}) + return f"{self._app_url}/mcp/connect?{query}" + + +async def _notify( + callback: Optional[Callable[[str], Awaitable[None]]], + message: str, +) -> None: + if callback is not None: + await callback(message) diff --git a/mcp/nova3d_mcp/client.py b/mcp/nova3d_mcp/client.py new file mode 100644 index 0000000..27dbc0b --- /dev/null +++ b/mcp/nova3d_mcp/client.py @@ -0,0 +1,618 @@ +""" +nova3d_mcp/client.py +──────────────────────────────────────────────────────────────── +Async HTTP client for the Nova3D API. +Handles workflow submission, polling, and result extraction. +──────────────────────────────────────────────────────────────── +""" +from __future__ import annotations + +import asyncio +import time +from typing import Any, Awaitable, Callable, Dict, Optional + +import httpx + +from nova3d_mcp.conversation import ( + build_snapshot_metadata, + remote_message_payload, +) +from nova3d_mcp.models import ( + GenerationReadiness, + GenerationResult, + MCPSessionExchange, + MCPStatus, + WorkflowStatus, +) + +# ── Constants ───────────────────────────────────────────────────────────────── + +NOVA3D_API_BASE = "https://nova3d.xyz/api" + +WORKFLOW_SKETCH_TO_3D = "sketch_to_3d_v2" +WORKFLOW_REGENERATE_PART = "regenerate_3d_part" +WORKFLOW_ADD_PART = "add_3d_part" +WORKFLOW_ARTICULATE = "articulate_3d_model" + +POLL_INTERVAL_SECONDS = 3.0 +START_TIMEOUT_SECONDS = 120.0 +RESULT_TIMEOUT_SECONDS = 300.0 +CONNECT_TIMEOUT_SECONDS = 30.0 + +# Errors that are safe to retry — workflow may not be registered yet +RECOVERABLE_ERROR_SIGNALS = [ + "404", + "workflow not found", + "unavailable", + "still starting", + "timeout", + "timed out", + "request failed (null)", + "request failed (502)", + "request failed (503)", + "request failed (504)", +] + + +# ── Exceptions ──────────────────────────────────────────────────────────────── + +class Nova3DError(Exception): + """Raised for Nova3D API errors with a user-friendly message.""" + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class Nova3DAuthError(Nova3DError): + """Raised when authentication fails or the token is invalid/expired.""" + + +class Nova3DCreditsError(Nova3DError): + """Raised when the user has insufficient credits.""" + + +# ── Workflow ID ─────────────────────────────────────────────────────────────── + +def make_workflow_id() -> str: + return f"state-{int(time.time() * 1_000_000)}" + + +# ── Client ──────────────────────────────────────────────────────────────────── + +class Nova3DClient: + """ + Async client for the Nova3D generation API. + + Usage: + async with Nova3DClient(token="n3d_your-key") as client: + result = await client.generate( + prompt="a toaster with removable tray", + provider="gemini", + llm="gemini", + ) + """ + + def __init__( + self, + token: Optional[str], + base_url: str = NOVA3D_API_BASE, + ): + self._token = token + self._base_url = base_url.rstrip("/") + self._http: Optional[httpx.AsyncClient] = None + + async def __aenter__(self) -> "Nova3DClient": + headers = { + "Content-Type": "application/json", + } + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + self._http = httpx.AsyncClient( + base_url=self._base_url, + timeout=httpx.Timeout( + connect=CONNECT_TIMEOUT_SECONDS, + read=RESULT_TIMEOUT_SECONDS, + write=30.0, + pool=5.0, + ), + headers=headers, + ) + return self + + async def __aexit__(self, *_: Any) -> None: + if self._http: + await self._http.aclose() + self._http = None + + # ── Public methods ──────────────────────────────────────────────────────── + + async def check_readiness(self) -> GenerationReadiness: + """Preflight check — confirm the generation service is available.""" + resp = await self._get(f"/workflow/readiness/{WORKFLOW_SKETCH_TO_3D}") + return GenerationReadiness(**resp) + + async def generate( + self, + prompt: str, + code_llm_profile: str, + code_llm_tier: str, + image_artifact: Optional[list[str]] = None, + conversation_id: Optional[str] = None, + on_progress: Optional[Callable[[WorkflowStatus], Awaitable[None]]] = None, + ) -> GenerationResult: + """ + Generate a 3D asset from a text prompt and optional reference image. + Blocks until the workflow completes or fails. + """ + readiness = await self.check_readiness() + if not readiness.ready: + raise Nova3DError(readiness.user_message) + + payload: Dict[str, Any] = { + "prompt": prompt.strip(), + "code_llm_profile": code_llm_profile, + "code_llm_tier": code_llm_tier, + } + if image_artifact: + payload["has_reference_images"] = True + payload["image_artifact"] = image_artifact + + workflow_id = await self._start_workflow( + workflow=WORKFLOW_SKETCH_TO_3D, + payload=payload, + return_nodes=[ + "final_validated_correction", + "final_latest_valid", + "fail_generation", + ], + conversation_id=conversation_id, + relation_type="initial_generation", + link_metadata={ + "operation": WORKFLOW_SKETCH_TO_3D, + "client": "mcp", + }, + ) + return await self._poll_and_collect(workflow_id, on_progress=on_progress) + + async def regenerate_part( + self, + code_artifact: Dict[str, Any], + part_type: str, + description: str, + provider: str, + llm: str, + conversation_id: Optional[str] = None, + on_progress: Optional[Callable[[WorkflowStatus], Awaitable[None]]] = None, + ) -> GenerationResult: + """Regenerate a specific named part within an existing asset.""" + if not description.strip(): + raise Nova3DError("A description of the desired change is required.") + if not part_type.strip(): + raise Nova3DError("A part name is required (e.g. 'door', 'handle').") + + payload: Dict[str, Any] = { + "code_artifact": code_artifact, + "description": description.strip(), + "part_type": part_type.strip(), + "llm": llm, + "provider": provider, + } + workflow_id = await self._start_workflow( + workflow=WORKFLOW_REGENERATE_PART, + payload=payload, + return_nodes=["regenerate_3d_part"], + conversation_id=conversation_id, + relation_type=WORKFLOW_REGENERATE_PART, + link_metadata={ + "operation": WORKFLOW_REGENERATE_PART, + "client": "mcp", + }, + ) + return await self._poll_and_collect(workflow_id, on_progress=on_progress) + + async def add_part( + self, + code_artifact: Dict[str, Any], + description: str, + provider: str, + llm: str, + conversation_id: Optional[str] = None, + on_progress: Optional[Callable[[WorkflowStatus], Awaitable[None]]] = None, + ) -> GenerationResult: + """Add a new part to an existing asset.""" + if not description.strip(): + raise Nova3DError("A description of the new part is required.") + + payload: Dict[str, Any] = { + "code_artifact": code_artifact, + "description": description.strip(), + "llm": llm, + "provider": provider, + } + workflow_id = await self._start_workflow( + workflow=WORKFLOW_ADD_PART, + payload=payload, + return_nodes=["add_3d_part"], + conversation_id=conversation_id, + relation_type=WORKFLOW_ADD_PART, + link_metadata={ + "operation": WORKFLOW_ADD_PART, + "client": "mcp", + }, + ) + return await self._poll_and_collect(workflow_id, on_progress=on_progress) + + async def articulate_model( + self, + code_artifact: Dict[str, Any], + articulation_request: str, + provider: str, + llm: str, + model_url: Optional[str] = None, + model_artifact: Optional[Dict[str, Any]] = None, + instruction_prompt: Optional[str] = None, + selected_meshes: Optional[list] = None, + conversation_id: Optional[str] = None, + on_progress: Optional[Callable[[WorkflowStatus], Awaitable[None]]] = None, + ) -> GenerationResult: + """Add joints, hinges, or rotation to an existing asset.""" + payload: Dict[str, Any] = { + "code_artifact": code_artifact, + "articulation_request": articulation_request.strip(), + "llm": llm, + "provider": provider, + } + if model_url: + payload["model_url"] = model_url + if model_artifact: + payload["model_artifact"] = model_artifact + if instruction_prompt: + payload["instruction_prompt"] = instruction_prompt + if selected_meshes: + payload["selected_meshes"] = selected_meshes + + workflow_id = await self._start_workflow( + workflow=WORKFLOW_ARTICULATE, + payload=payload, + return_nodes=["articulate_3d_model"], + conversation_id=conversation_id, + relation_type="articulate_model", + link_metadata={ + "operation": WORKFLOW_ARTICULATE, + "client": "mcp", + }, + ) + return await self._poll_and_collect(workflow_id, on_progress=on_progress) + + async def get_status(self, workflow_id: str) -> WorkflowStatus: + """Get current workflow status.""" + resp = await self._get(f"/status/{workflow_id}") + return WorkflowStatus.from_api(workflow_id, resp) + + async def get_result(self, workflow_id: str) -> GenerationResult: + """Fetch final workflow result (blocking).""" + resp = await self._get( + f"/result/{workflow_id}", + timeout=RESULT_TIMEOUT_SECONDS, + ) + return GenerationResult.from_api(resp, workflow_id) + + async def get_me(self) -> Dict[str, Any]: + """Verify credentials and return user identity from GET /me.""" + return await self._get("/me") + + async def get_mcp_status(self) -> MCPStatus: + """Fetch the canonical MCP onboarding/readiness status.""" + resp = await self._get("/mcp/status") + return MCPStatus(**resp) + + async def exchange_mcp_session_code(self, code: str) -> str: + """Exchange a one-time browser handoff code for an MCP credential.""" + exchange = await self.exchange_mcp_session(code) + return exchange.token + + async def exchange_mcp_session(self, code: str) -> MCPSessionExchange: + """Exchange a one-time browser handoff code for a token plus metadata.""" + resp = await self._post("/mcp/session/exchange", json={"code": code.strip()}) + token = _extract_session_token(resp) + if not token: + raise Nova3DError("MCP session exchange did not return a Nova3D credential.") + expires_at = resp.get("expires_at") + if expires_at is not None and not isinstance(expires_at, str): + expires_at = str(expires_at) + return MCPSessionExchange(token=token, expires_at=expires_at) + + async def create_conversation(self, title: str) -> str: + """Create a new conversation and return its ID.""" + resp = await self._post( + "/conversations", + json={ + "source": "mcp", + "kind": "generation", + "title": title[:255], + }, + ) + conv_id = resp.get("id") + if not conv_id: + raise Nova3DError("Conversation creation did not return an ID.") + return str(conv_id) + + async def update_conversation_snapshot( + self, + conversation_id: str, + *, + title: str, + messages: list[Dict[str, Any]], + ) -> None: + """Persist the app-compatible chat snapshot on the conversation.""" + await self._patch( + f"/conversations/{conversation_id}", + json={ + "title": title[:255], + "kind": "generation", + "conversation_metadata": build_snapshot_metadata(messages), + }, + ) + + async def append_conversation_message( + self, + conversation_id: str, + message: Dict[str, Any], + ) -> str: + """Append one app-compatible message and return the remote message ID.""" + resp = await self._post( + f"/conversations/{conversation_id}/messages", + json=remote_message_payload(message), + ) + message_id = resp.get("id") + if not message_id: + raise Nova3DError("Conversation message append did not return an ID.") + return str(message_id) + + async def link_workflow_to_message( + self, + conversation_id: str, + *, + workflow_id: str, + remote_message_id: str, + operation: str, + ) -> None: + """Link a workflow result to its persisted chat message.""" + await self._post( + f"/conversations/{conversation_id}/workflow-links", + json={ + "workflow_id": workflow_id, + "message_id": remote_message_id, + "relation_type": "message_result", + "link_metadata": { + "operation": operation, + "client": "mcp", + "client_relation": "asset_version", + }, + }, + ) + + # ── Internal helpers ────────────────────────────────────────────────────── + + async def _start_workflow( + self, + workflow: str, + payload: Dict[str, Any], + return_nodes: list[str], + conversation_id: Optional[str] = None, + relation_type: str = "triggered_by", + link_metadata: Optional[Dict[str, Any]] = None, + ) -> str: + """Submit a workflow and return the workflow_id.""" + workflow_id = make_workflow_id() + body: Dict[str, Any] = { + "payload": payload, + "return_nodes": return_nodes, + } + if conversation_id: + conversation: Dict[str, Any] = { + "conversation_id": conversation_id, + "relation_type": relation_type, + } + if link_metadata: + conversation["link_metadata"] = link_metadata + body["conversation"] = conversation + try: + resp = await self._post( + f"/run/state/{workflow}", + json=body, + params={"request_id": workflow_id}, + timeout=START_TIMEOUT_SECONDS, + ) + except httpx.TimeoutException: + # Receive timeout — the workflow may have started anyway. + # Return the original workflow_id and let polling sort it out. + return workflow_id + + returned_id = resp.get("workflow_id") or "" + if not returned_id: + raise Nova3DError("Generation did not return a workflow ID.") + return returned_id + + async def _poll_and_collect( + self, + workflow_id: str, + on_progress: Optional[Callable[[WorkflowStatus], Awaitable[None]]] = None, + ) -> GenerationResult: + """Poll status until terminal, then fetch and return result.""" + while True: + await asyncio.sleep(POLL_INTERVAL_SECONDS) + try: + status = await self.get_status(workflow_id) + except Nova3DError as e: + if _is_recoverable(str(e)): + if on_progress: + await on_progress(WorkflowStatus( + workflow_id=workflow_id, + state="pending", # type: ignore[arg-type] + current_node="sketch_to_3d_generator", + )) + continue + raise + + if on_progress: + await on_progress(status) + + if status.state.value == "budget_exhausted": + raise Nova3DError( + "Your provider or generation budget was exhausted before the model completed." + ) + if status.is_terminal: + break + + # Fetch result with retry on recoverable errors + while True: + try: + return await self.get_result(workflow_id) + except Nova3DError as e: + if not _is_recoverable(str(e)): + raise + await asyncio.sleep(POLL_INTERVAL_SECONDS) + + async def _get( + self, + path: str, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + assert self._http is not None, "Client not started — use async with" + try: + kwargs: Dict[str, Any] = {} + if timeout is not None: + kwargs["timeout"] = timeout + resp = await self._http.get(path, **kwargs) + return self._handle_response(resp) + except httpx.TimeoutException as e: + raise Nova3DError(f"Request timed out: {e}") + except httpx.NetworkError as e: + raise Nova3DError(f"Network error: {e}") + + async def _post( + self, + path: str, + json: Dict[str, Any], + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + assert self._http is not None, "Client not started — use async with" + try: + kwargs: Dict[str, Any] = {"json": json} + if params: + kwargs["params"] = params + if timeout is not None: + kwargs["timeout"] = timeout + resp = await self._http.post(path, **kwargs) + return self._handle_response(resp) + except httpx.TimeoutException as e: + raise Nova3DError(f"Request timed out: {e}") + except httpx.NetworkError as e: + raise Nova3DError(f"Network error: {e}") + + async def _patch( + self, + path: str, + json: Dict[str, Any], + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + assert self._http is not None, "Client not started — use async with" + try: + kwargs: Dict[str, Any] = {"json": json} + if timeout is not None: + kwargs["timeout"] = timeout + resp = await self._http.patch(path, **kwargs) + return self._handle_response(resp) + except httpx.TimeoutException as e: + raise Nova3DError(f"Request timed out: {e}") + except httpx.NetworkError as e: + raise Nova3DError(f"Network error: {e}") + + def _handle_response(self, resp: httpx.Response) -> Dict[str, Any]: + if resp.status_code == 401: + _, message = _parse_auth_error(resp) + raise Nova3DAuthError(message, status_code=401) + if resp.status_code == 402: + detail = "" + try: + body = resp.json() + detail = body.get("message") or body.get("detail") or "" + except Exception: + pass + raise Nova3DCreditsError( + detail or "Insufficient credits. Add credits at https://app.nova3d.xyz/api-key", + status_code=402, + ) + if resp.status_code == 404: + raise Nova3DError(f"workflow not found (404): {resp.text[:200]}", status_code=404) + if resp.status_code >= 500: + raise Nova3DError( + f"request failed ({resp.status_code}): {resp.text[:200]}", + status_code=resp.status_code, + ) + if not resp.is_success: + raise Nova3DError( + f"request failed ({resp.status_code}): {resp.text[:200]}", + status_code=resp.status_code, + ) + try: + return resp.json() + except Exception as e: + raise Nova3DError(f"Invalid JSON response: {e}") + + +# ── Utility ─────────────────────────────────────────────────────────────────── + +def _is_recoverable(error_message: str) -> bool: + msg = error_message.lower() + # Don't retry auth or budget errors + if "sign in" in msg or "token" in msg or "budget" in msg: + return False + return any(signal in msg for signal in RECOVERABLE_ERROR_SIGNALS) + + +def _parse_auth_error(resp: httpx.Response) -> tuple[Optional[str], str]: + """Parse a 401 response body for structured error code and user-facing message.""" + try: + body = resp.json() + detail = body.get("detail") + if isinstance(detail, dict): + code = detail.get("code") + msg = detail.get("message", "") + return code, _auth_message_for_code(code, msg) + if isinstance(detail, str) and detail: + return None, detail + except Exception: + pass + return None, ( + "Nova3D authentication failed. " + "Check your API key at https://app.nova3d.xyz/api-key" + ) + + +def _extract_session_token(payload: Dict[str, Any]) -> Optional[str]: + for key in ("token", "api_key", "n3d_token", "credential"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _auth_message_for_code(code: Optional[str], backend_message: str) -> str: + if code == "api_key_revoked": + return ( + "Your Nova3D API key has been revoked. " + "Create a new one at https://app.nova3d.xyz/api-key" + ) + if code == "invalid_api_key": + return ( + "Your Nova3D API key is invalid. " + "Check or replace it at https://app.nova3d.xyz/api-key" + ) + return ( + backend_message + or "Nova3D authentication failed. " + "Check your API key at https://app.nova3d.xyz/api-key" + ) diff --git a/mcp/nova3d_mcp/conversation.py b/mcp/nova3d_mcp/conversation.py new file mode 100644 index 0000000..cd53a21 --- /dev/null +++ b/mcp/nova3d_mcp/conversation.py @@ -0,0 +1,152 @@ +""" +Conversation history helpers for Nova3D's web app. + +The Flutter client stores generated assets as chat messages with a stable JSON +shape. The MCP server writes the same shape so /chat/ opens a +hydrated generation history instead of an empty conversation shell. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from nova3d_mcp.models import GenerationResult + +CHAT_SNAPSHOT_KEY = "nova3d_chat_snapshot" + + +def build_generation_messages( + *, + prompt: str, + result: GenerationResult, + code_artifact: Optional[Dict[str, Any]], + model_option_id: str, +) -> List[Dict[str, Any]]: + """Build the initial user prompt and assistant asset messages.""" + created_at = _utc_now() + workflow_id = result.workflow_id + messages = [ + { + "id": f"user-{workflow_id}", + "role": "user", + "text": prompt, + "created_at": created_at, + "is_streaming": False, + }, + { + "id": f"cad-{workflow_id}", + "role": "assistant", + "text": "Your 3D model is ready.", + "created_at": created_at, + "is_streaming": False, + "model_url": result.glb_url, + "workflow_id": workflow_id, + "operation": "initial_generation", + "source_model_url": result.glb_url, + "model_option_id": model_option_id, + "instruction_prompt": prompt, + **_artifact_fields( + model_artifact=result.model_artifact, + code_artifact=code_artifact, + joints_artifact=result.joints_artifact, + joints=result.joints, + ), + }, + ] + return [_strip_empty(message) for message in messages] + + +def build_edit_message( + *, + operation: str, + description: str, + result: GenerationResult, + code_artifact: Optional[Dict[str, Any]], + model_option_id: str, + instruction_prompt: Optional[str], +) -> Dict[str, Any]: + """Build an app-compatible asset-version message for edit workflows.""" + message = { + "id": f"edit-{result.workflow_id}", + "role": "assistant", + "text": _edit_completion_text(operation, description), + "created_at": _utc_now(), + "is_streaming": False, + "model_url": result.glb_url, + "workflow_id": result.workflow_id, + "message_type": "asset_version", + "operation": operation, + "source_model_url": result.glb_url, + "model_option_id": model_option_id, + "instruction_prompt": instruction_prompt, + **_artifact_fields( + model_artifact=result.model_artifact, + code_artifact=code_artifact, + joints_artifact=result.joints_artifact, + joints=result.joints, + ), + } + return _strip_empty(message) + + +def build_snapshot_metadata(messages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Build the conversation_metadata snapshot used by the Flutter app.""" + return { + CHAT_SNAPSHOT_KEY: { + "schema_version": 1, + "updated_at": _utc_now(), + "messages": messages, + } + } + + +def remote_message_payload(message: Dict[str, Any]) -> Dict[str, Any]: + """Build POST /conversations/{id}/messages payload from content JSON.""" + return { + "client_message_id": message["id"], + "role": message["role"], + "status": "pending" if message.get("is_streaming") else "completed", + "content_text": message.get("text", ""), + "content_json": message, + "sent_at": message.get("created_at") or _utc_now(), + } + + +def _artifact_fields( + *, + model_artifact: Optional[Dict[str, Any]], + code_artifact: Optional[Dict[str, Any]], + joints_artifact: Optional[Dict[str, Any]], + joints: List[Dict[str, Any]], +) -> Dict[str, Any]: + fields: Dict[str, Any] = {} + if model_artifact is not None: + fields["model_artifact"] = model_artifact + if code_artifact is not None: + fields["code_artifact"] = code_artifact + if joints_artifact is not None: + fields["joints_artifact"] = joints_artifact + if joints: + fields["joints"] = joints + return fields + + +def _edit_completion_text(operation: str, description: str) -> str: + label = { + "add_3d_part": "Added part", + "articulate_3d_model": "Articulated model", + }.get(operation, "Regenerated selected part") + clean = description.strip() + return f"{label}: {clean}" if clean else label + + +def _strip_empty(message: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value + for key, value in message.items() + if value is not None and value != "" and value != [] + } + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/mcp/nova3d_mcp/loopback.py b/mcp/nova3d_mcp/loopback.py new file mode 100644 index 0000000..7bd68cc --- /dev/null +++ b/mcp/nova3d_mcp/loopback.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from html import escape +from typing import Dict, Optional +from urllib.parse import parse_qs, urlparse + + +@dataclass +class LoopbackCallback: + code: Optional[str] + state: Optional[str] + raw_query: Dict[str, str] + + +class LoopbackServer: + def __init__(self, *, client_name: Optional[str] = None) -> None: + self._server: Optional[asyncio.base_events.Server] = None + self._callback_future: asyncio.Future[LoopbackCallback] = ( + asyncio.get_running_loop().create_future() + ) + self._port: Optional[int] = None + self._client_name = client_name + + @property + def port(self) -> int: + if self._port is None: + raise RuntimeError("Loopback server is not started.") + return self._port + + async def start(self) -> int: + self._server = await asyncio.start_server( + self._handle_connection, + host="127.0.0.1", + port=0, + ) + sockets = self._server.sockets or [] + if not sockets: + raise RuntimeError("Loopback listener did not bind a socket.") + self._port = int(sockets[0].getsockname()[1]) + return self._port + + async def wait_for_callback(self, timeout_seconds: float) -> LoopbackCallback: + return await asyncio.wait_for(self._callback_future, timeout=timeout_seconds) + + async def close(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _handle_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + request_line = await reader.readline() + path = "/" + if request_line: + parts = request_line.decode("utf-8", errors="replace").split() + if len(parts) >= 2: + path = parts[1] + + while True: + line = await reader.readline() + if not line or line in (b"\r\n", b"\n"): + break + + parsed = urlparse(path) + query_items = { + key: values[-1] + for key, values in parse_qs(parsed.query).items() + if values + } + callback = LoopbackCallback( + code=query_items.get("code"), + state=query_items.get("state"), + raw_query=query_items, + ) + if not self._callback_future.done(): + self._callback_future.set_result(callback) + + body = _render_success_page(self._client_name) + response = ( + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + f"Content-Length: {len(body.encode('utf-8'))}\r\n" + "Connection: close\r\n\r\n" + f"{body}" + ) + writer.write(response.encode("utf-8")) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + +def _render_success_page(client_name: Optional[str]) -> str: + destination = ( + escape(client_name.strip()) + if client_name and client_name.strip() + else "your MCP client" + ) + title = ( + f"Nova3D connected to {destination}" + if destination != "your MCP client" + else "Nova3D connected" + ) + subtitle = ( + f"Your local Nova3D connection is ready in {destination}." + if destination != "your MCP client" + else "Your local Nova3D connection is ready." + ) + return f""" + + + + + {title} + + + +
+
MCP setup complete
+

{title}

+

{subtitle}

+
+ Next step +

You can close this tab and return to {destination} now.

+
+
+ What happened + The Nova3D browser sign-in flow finished its local connection step on this machine. +
+ +
+ +""" diff --git a/mcp/nova3d_mcp/models.py b/mcp/nova3d_mcp/models.py new file mode 100644 index 0000000..87611a9 --- /dev/null +++ b/mcp/nova3d_mcp/models.py @@ -0,0 +1,486 @@ +""" +nova3d_mcp/models.py +──────────────────────────────────────────────────────────────── +Pydantic models for Nova3D API requests and responses. +Derived from the Nova3D frontend (CadService, cad_models.dart). +──────────────────────────────────────────────────────────────── +""" +from __future__ import annotations + +import re +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# ── Workflow state ──────────────────────────────────────────────────────────── + +class WorkflowState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + BUDGET_EXHAUSTED = "budget_exhausted" + FAILED = "failed" + TERMINATED = "terminated" + UNKNOWN = "unknown" + + @classmethod + def parse(cls, value: Optional[str]) -> "WorkflowState": + if value is None: + return cls.UNKNOWN + normalized = value.lower() + mapping = { + "pending": cls.PENDING, + "running": cls.RUNNING, + "completed": cls.COMPLETED, + "succeeded": cls.COMPLETED, + "success": cls.COMPLETED, + "budget_exhausted": cls.BUDGET_EXHAUSTED, + "failed": cls.FAILED, + "terminated": cls.TERMINATED, + "cancelled": cls.TERMINATED, + "timed_out": cls.TERMINATED, + "timeout": cls.TERMINATED, + } + return mapping.get(normalized, cls.UNKNOWN) + + @property + def is_terminal(self) -> bool: + return self in ( + WorkflowState.COMPLETED, + WorkflowState.BUDGET_EXHAUSTED, + WorkflowState.FAILED, + WorkflowState.TERMINATED, + ) + + +TERMINAL_NODES = { + "success_final", + "success_original_glb", + "failed_final", + "final_latest_valid", + "final_validated_correction", + "fail_generation", +} + +NODE_PROGRESS_LABELS: Dict[str, str] = { + "sketch_to_3d_generator": "Generating your 3D model...", + "caption_prompt": "Reading your reference image...", + "caption_llm": "Understanding the reference image...", + "generation_prompt": "Preparing the 3D generation prompt...", + "code_generation_llm": "Writing the Blender scene...", + "run_blender": "Building and exporting the 3D model...", + "blender_retry_gate": "Checking the generated model...", + "build_repair_prompt": "Preparing an automatic repair...", + "repair_llm": "Repairing the Blender script...", + "capture_validation_screenshots": "Capturing validation views...", + "validation_prompt": "Preparing model validation...", + "validation_llm": "Reviewing the generated model...", + "validation_result_parser": "Finalizing the model...", + "validation_correction_blender": "Applying validation fixes...", + "final_latest_valid": "Finalizing the model...", + "final_validated_correction": "Finalizing the corrected model...", + "fail_generation": "Generation failed.", + "regenerate_3d_part": "Regenerating the selected part...", + "add_3d_part": "Adding a new part...", + "articulate_3d_model": "Articulating your 3D model...", +} + + +# ── API response models ─────────────────────────────────────────────────────── + +class WorkflowStartResponse(BaseModel): + workflow_id: str + status_url: str + result_url: str + projected_cost: int = 0 + authorized_budget: int = 0 + + +class WorkflowStatus(BaseModel): + workflow_id: str + state: WorkflowState + current_node: Optional[str] = None + last_exit_node: Optional[str] = None + + @property + def is_terminal_by_node(self) -> bool: + return ( + self.current_node in TERMINAL_NODES + or self.last_exit_node in TERMINAL_NODES + ) + + @property + def is_terminal(self) -> bool: + return self.state.is_terminal or self.is_terminal_by_node + + @property + def progress_label(self) -> str: + node = self.current_node or self.last_exit_node or "" + return NODE_PROGRESS_LABELS.get(node, "Generating...") + + @classmethod + def from_api(cls, workflow_id: str, data: Dict[str, Any]) -> "WorkflowStatus": + runtime = data.get("runtime") or {} + visit_seq = data.get("node_visit_seq") or {} + current_node = list(visit_seq.keys())[-1] if visit_seq else None + return cls( + workflow_id=workflow_id, + state=WorkflowState.parse(runtime.get("state")), + current_node=current_node, + last_exit_node=runtime.get("last_exit_node_id"), + ) + + +class GenerationReadiness(BaseModel): + ready: bool + reason: Optional[str] = None + projected_cost: int = 0 + authorized_budget: int = 0 + + @property + def user_message(self) -> str: + if self.ready: + return "Generation is ready." + if self.reason == "generation_service_unavailable": + return "The generation service is unavailable. Please try again shortly." + return "Generation is not available right now." + + +# ── MCP auth/onboarding models ─────────────────────────────────────────────── + +class MCPStatusIdentity(BaseModel): + user_id: str + email: str + tenant_id: Optional[str] = None + + +class MCPStatusSession(BaseModel): + established: bool = False + expires_at: Optional[str] = None + + +class MCPStatusCredits(BaseModel): + balance: int = 0 + reserved: int = 0 + available: int = 0 + funded: bool = False + + +class MCPStatus(BaseModel): + authenticated: bool = False + identity: Optional[MCPStatusIdentity] = None + mcp_session: MCPStatusSession = Field(default_factory=MCPStatusSession) + credits: Optional[MCPStatusCredits] = None + generation_ready: bool = False + next_action: Optional[str] = None + next_action_url: Optional[str] = None + + @property + def is_ready(self) -> bool: + return self.authenticated and self.generation_ready and self.next_action is None + + @property + def available_credits(self) -> Optional[int]: + return self.credits.available if self.credits else None + + @property + def user_message(self) -> str: + if self.next_action == "sign_in": + return "Sign in to Nova3D to continue." + if self.next_action == "session_expired": + return "Your Nova3D session expired. Sign in again to continue." + if self.next_action == "purchase_credits": + return "Your Nova3D account is connected, but you need credits before generating." + if self.next_action == "service_unavailable": + return "Nova3D is temporarily unavailable. Please try again shortly." + if self.is_ready: + if self.identity and self.credits: + return ( + f"Connected as {self.identity.email}. " + f"Credits available: {self.credits.available}." + ) + return "Nova3D is ready." + if self.authenticated: + return "Nova3D is connected, but readiness is still being confirmed." + return "Nova3D status is unavailable." + + +class MCPSessionExchange(BaseModel): + token: str + expires_at: Optional[str] = None + + +# ── Generation result ───────────────────────────────────────────────────────── + +RESULT_NODE_KEYS = [ + "final_validated_correction", + "final_latest_valid", + "sketch_to_3d_generator", + "regenerate_3d_part", + "add_3d_part", + "articulate_3d_model", + "fail_generation", +] + + +class GenerationResult(BaseModel): + glb_url: Optional[str] = None + model_artifact: Optional[Dict[str, Any]] = None + code_artifact: Optional[Dict[str, Any]] = None + joints_artifact: Optional[Dict[str, Any]] = None + joints: List[Dict[str, Any]] = Field(default_factory=list) + joint_count: int = 0 + parts: List[str] = Field(default_factory=list) + operation: Optional[str] = None + failed: bool = False + error_message: Optional[str] = None + error_category: Optional[str] = None + retryable: bool = False + api_key_source: Optional[str] = None + workflow_id: str = "" + + @classmethod + def from_api( + cls, + data: Dict[str, Any], + workflow_id: str, + ) -> "GenerationResult": + payload = _extract_generator_payload(data) + if payload is None: + error = _extract_root_error(data) + return cls( + failed=True, + error_message=error or "Generation returned no output.", + workflow_id=workflow_id, + ) + + unwrapped = _unwrap_result(payload) + glb_url = _extract_glb_url(unwrapped) + model_artifact = _extract_map(unwrapped, ["model_artifact", "model", "glb_artifact"]) + code_artifact = _extract_map( + unwrapped, + ["code_artifact", "source_code_artifact", "input_code_artifact"], + ) + joints_artifact = _as_str_map(unwrapped.get("joints_artifact")) + joints = _extract_joints(unwrapped) + joint_count = _int_val(unwrapped.get("joint_count")) or len(joints) + operation = _str_val(unwrapped.get("operation")) + api_key_source = _str_val(unwrapped.get("api_key_source")) + parts = _extract_part_names(unwrapped, joints, code_artifact) + failure = _extract_failure(unwrapped) + error_message = (failure.get("message") if failure else None) or _extract_root_error(data) + failed = glb_url is None and (_is_failed(data) or error_message is not None) + return cls( + glb_url=glb_url, + model_artifact=model_artifact, + code_artifact=code_artifact, + joints_artifact=joints_artifact, + joints=joints, + joint_count=joint_count, + parts=parts, + operation=operation, + failed=failed, + error_message=error_message, + error_category=failure.get("category") if failure else None, + retryable=failure.get("retryable", False) if failure else False, + api_key_source=api_key_source, + workflow_id=workflow_id, + ) + + +# ── Result parsing helpers ──────────────────────────────────────────────────── + +def _extract_generator_payload( + data: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + for key in RESULT_NODE_KEYS: + node = data.get(key) + if isinstance(node, list) and node: + first = node[0] + if isinstance(first, dict): + return {str(k): v for k, v in first.items()} + return None + + +def _unwrap_result(payload: Dict[str, Any]) -> Dict[str, Any]: + result = _as_str_map(payload.get("result")) + return result if result is not None else payload + + +def _extract_glb_url(unwrapped: Dict[str, Any]) -> Optional[str]: + url = unwrapped.get("model_url") + if isinstance(url, str) and url.strip(): + return url.strip() + for key in ["model", "model_artifact", "glb_artifact"]: + artifact = unwrapped.get(key) + if isinstance(artifact, dict): + u = artifact.get("url") + if isinstance(u, str) and u.strip(): + return u.strip() + return None + + +def _extract_map( + unwrapped: Dict[str, Any], keys: List[str] +) -> Optional[Dict[str, Any]]: + for key in keys: + result = _as_str_map(unwrapped.get(key)) + if result is not None: + return result + return None + + +def _extract_joints(unwrapped: Dict[str, Any]) -> List[Dict[str, Any]]: + raw = unwrapped.get("joints") + if not isinstance(raw, list): + return [] + return [ + {str(k): v for k, v in item.items()} + for item in raw + if isinstance(item, dict) + ] + + +def _extract_part_names( + unwrapped: Dict[str, Any], + joints: List[Dict[str, Any]], + code_artifact: Optional[Dict[str, Any]] = None, +) -> List[str]: + """Extract named part/mesh identifiers from the result.""" + # 1. API-first: use explicit parts field if the backend returns one + api_parts = unwrapped.get("parts") + if isinstance(api_parts, list) and api_parts: + return [str(p) for p in api_parts if p] + + # 2. Regex over Blender construction script — obj.name = "part_name" + if isinstance(code_artifact, dict): + content = code_artifact.get("content") or "" + if content: + names = re.findall(r'\.name\s*=\s*["\']([^"\']+)["\']', content) + if names: + return list(dict.fromkeys(names)) # deduplicate, preserve order + + # 3. Fallback: extract from joints (articulated assets) + names = [] + for joint in joints: + mesh = joint.get("mesh") or joint.get("name") + if isinstance(mesh, str) and mesh.strip(): + names.append(mesh.strip()) + return list(dict.fromkeys(names)) + + +def _extract_failure( + unwrapped: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + status = (unwrapped.get("status") or "").lower() + action = (unwrapped.get("action") or "").lower() + ok = unwrapped.get("ok") + failure_map = _as_str_map(unwrapped.get("failure")) + + is_failed = ( + status == "failed" + or action == "error" + or ok is False + or failure_map is not None + or unwrapped.get("error_category") is not None + or unwrapped.get("user_message") is not None + ) + if not is_failed: + return None + + category = _str_val( + (failure_map or {}).get("category") or unwrapped.get("error_category") + ) + provider = _str_val( + (failure_map or {}).get("provider") or unwrapped.get("provider") + ) + retryable = bool( + (failure_map or {}).get("retryable") or unwrapped.get("retryable") or False + ) + message = ( + _str_val((failure_map or {}).get("user_message")) + or _str_val((failure_map or {}).get("message")) + or _str_val(unwrapped.get("user_message")) + or _str_val(unwrapped.get("message")) + or _str_val(unwrapped.get("detail")) + or _str_val(unwrapped.get("error")) + or _message_for_category(category, provider, retryable) + ) + return {"message": message, "category": category, "provider": provider, "retryable": retryable} + + +def _extract_root_error(data: Dict[str, Any]) -> Optional[str]: + for key in ["error", "detail", "message"]: + val = data.get(key) + if val is not None: + return _str_val(val) or str(val) + return None + + +def _is_failed(data: Dict[str, Any]) -> bool: + state = (data.get("state") or "").lower() + if state in ("failed", "failure"): + return True + runtime = data.get("runtime") or {} + r_state = (runtime.get("state") or "").lower() + return r_state in ("failed", "failure", "budget_exhausted") + + +def _as_str_map(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + return None + return {str(k): v for k, v in value.items()} + + +def _str_val(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value.strip() or None + if isinstance(value, dict): + for key in ("user_message", "message"): + result = _str_val(value.get(key)) + if result: + return result + return str(value) + + +def _int_val(value: Any) -> Optional[int]: + if isinstance(value, int): + return value + if isinstance(value, (float,)): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _message_for_category( + category: Optional[str], + provider: Optional[str], + retryable: bool, +) -> str: + label = provider if provider else "The selected provider" + messages = { + "invalid_api_key": f"The {label} key saved in your Nova3D account is invalid. Update it at https://app.nova3d.xyz/api-key", + "missing_api_key": f"No {label} key found in your Nova3D account. Add one at https://app.nova3d.xyz/api-key", + "model_access_denied": f"{label} does not allow this key to use the selected model. Choose another.", + "unsupported_provider_for_model": f"{label} cannot run the selected model. Choose a compatible pair.", + "insufficient_credits": f"{label} does not have enough credits for this generation.", + "quota_or_rate_limit": f"{label} quota or rate limit reached. Wait a bit or switch providers.", + "provider_unavailable": f"{label} is temporarily unavailable. Retry shortly or switch providers.", + "api_timeout": f"{label} timed out. Retry or switch providers.", + "blender_generation_failed": "The generated 3D script could not produce a valid model after repair attempts.", + "artifact_upload_failed": "The model was generated but the download artifact could not be prepared.", + "generation_timeout": "Generation took longer than expected. It may still finish — retry if it does not appear.", + } + if category and category in messages: + return messages[category] + if retryable: + return "Generation failed. Retry shortly or switch providers." + return "Generation failed. Try another prompt or model." diff --git a/mcp/nova3d_mcp/server.py b/mcp/nova3d_mcp/server.py new file mode 100644 index 0000000..81286fd --- /dev/null +++ b/mcp/nova3d_mcp/server.py @@ -0,0 +1,1108 @@ +""" +nova3d_mcp/server.py +──────────────────────────────────────────────────────────────── +Nova3D MCP Server. + +Exposes Nova3D's structured 3D generation pipeline as MCP tools +callable from Claude Code, Cursor, and any MCP-compatible agent. + +Configuration (environment variables): + NOVA3D_TOKEN — Advanced/manual Nova3D API key fallback (optional) + NOVA3D_API_URL — Override API base URL (optional) + NOVA3D_APP_URL — Override app URL for conversation links (optional) + +Usage: + uvx nova3d-mcp + # or + python -m nova3d_mcp.server +──────────────────────────────────────────────────────────────── +""" +from __future__ import annotations + +import asyncio +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.server import Context + +from nova3d_mcp.auth import Nova3DAuthenticator, Nova3DLoginError +from nova3d_mcp.client import Nova3DClient, Nova3DAuthError, Nova3DError +from nova3d_mcp.conversation import ( + build_edit_message, + build_generation_messages, +) +from nova3d_mcp.models import GenerationResult, MCPStatus, WorkflowStatus +from nova3d_mcp.session_store import SessionStore + +load_dotenv() + +# ── Startup error state ─────────────────────────────────────────────────────── + +_startup_error: Optional[str] = None +_pending_login: Optional["PendingLoginState"] = None + +# ── Model options ───────────────────────────────────────────────────────────── + +_MODEL_OPTIONS: Dict[str, Dict[str, str]] = { + "gemini": { + "provider": "gemini", + "llm": "gemini", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "gemini_3_1_pro_google", + "option_id": "credits_gemini_3_1_pro_google", + }, + "claude-sonnet": { + "provider": "anthropic", + "llm": "claude-sonnet", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "claude_sonnet_4_6_anthropic", + "option_id": "credits_claude_sonnet_4_6_anthropic", + }, + "claude-opus": { + "provider": "anthropic", + "llm": "claude-opus", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "claude_opus_4_8_anthropic", + "option_id": "credits_claude_opus_4_8_anthropic", + }, + "claude-opus-latest": { + "provider": "anthropic", + "llm": "claude-opus-latest", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "claude_opus_4_8_anthropic", + "option_id": "credits_claude_opus_4_8_anthropic", + }, + "gpt-5.5": { + "provider": "openai", + "llm": "gpt55", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "gpt_5_5_openrouter", + "option_id": "credits_gpt_5_5_openrouter", + }, +} +_DEFAULT_MODEL = "gemini" +LOGIN_GRACE_PERIOD_SECONDS = 1.0 + + +def _resolve_model(model: Optional[str]) -> Optional[Dict[str, str]]: + return _MODEL_OPTIONS.get((model or _DEFAULT_MODEL).strip()) + + +@dataclass +class PendingLoginState: + connect_url: str + port: int + task: "asyncio.Task[Any]" + +# ── Server init ─────────────────────────────────────────────────────────────── + +mcp = FastMCP( + "nova3d", + instructions=( + "Nova3D generates structured, part-aware 3D assets from text prompts or " + "reference images. Unlike diffusion-based tools, Nova3D outputs named, " + "separately editable mesh components — not fused blobs.\n\n" + "WORKFLOW:\n" + "1. Call generate_3d → returns glb_url (download), parts list, " + "code_artifact, and " + "conversation_url. Always surface conversation_url to the user — it opens " + "a browser view of the asset and its full edit history in the Nova3D app.\n" + " - Initial generation runs through Nova3D's paid GraphFlow v2 path.\n" + " - The model selector routes to a paid Nova3D tier; this MCP server does " + "not expose BYOK provider-key generation.\n" + "2. Call regenerate_part, add_part, or articulate_model with the " + "code_artifact from any prior result. These tools return an updated glb_url " + "and the same conversation_url, linking all edits into one session.\n" + " - conversation_url may be absent from edit-tool responses if session " + "creation failed silently at generate time; generation still succeeded.\n" + " - Always pass the most recent code_artifact forward — it carries session " + "state that links edits together.\n\n" + "SETUP:\n" + "1. After installing this MCP server in your client, the next step is to call " + "nova3d_setup or go directly to nova3d_login.\n" + "2. Preferred: Call nova3d_login to sign in through the browser.\n" + "3. Check nova3d_status to confirm credits and readiness before generation.\n" + "4. Advanced fallback: you may still provide NOVA3D_TOKEN manually in " + "non-interactive environments.\n" + "\n" + "If any tool returns {\"failed\": true}, surface the error_message to the user verbatim." + ), +) + + +# ── Auth helper ─────────────────────────────────────────────────────────────── + +def _get_session_store() -> SessionStore: + return SessionStore() + + +def _get_manual_token() -> Optional[str]: + token = os.environ.get("NOVA3D_TOKEN", "").strip() + return token or None + + +def _get_token() -> str: + session_token = _get_session_store().load_token() + if session_token: + return session_token + manual_token = _get_manual_token() + if manual_token: + return manual_token + raise Nova3DError( + "Sign in to Nova3D with nova3d_login to continue. " + "Advanced fallback: set NOVA3D_TOKEN manually in your MCP config." + ) + + +def _get_api_url() -> str: + return os.environ.get("NOVA3D_API_URL", "https://nova3d.xyz/api").rstrip("/") + + +def _get_app_url() -> str: + return os.environ.get("NOVA3D_APP_URL", "https://app.nova3d.xyz").rstrip("/") + + +async def _validate_startup() -> None: + """Validate an existing configured credential if one is present.""" + global _startup_error + + token = _get_session_store().load_token() or _get_manual_token() + if not token: + _startup_error = None + return + + base_url = _get_api_url() + try: + async with Nova3DClient(token=token, base_url=base_url) as client: + me = await client.get_me() + print(f"✓ Nova3D authenticated: {me['email']}", file=sys.stderr) + except Nova3DAuthError as e: + _startup_error = None + print(f"Nova3D: {e}", file=sys.stderr) + except Nova3DError as e: + _startup_error = ( + f"Could not reach Nova3D to verify token: {e}\n" + "Check your connection and try again." + ) + print(_startup_error, file=sys.stderr) + + +async def _get_mcp_status() -> MCPStatus: + token = _get_session_store().load_token() or _get_manual_token() + async with Nova3DClient(token=token, base_url=_get_api_url()) as client: + return await client.get_mcp_status() + + +def _build_session_hint(session_store: SessionStore) -> Dict[str, Any]: + expires_at = session_store.load_expires_at() + if not expires_at: + return {} + hint: Dict[str, Any] = {"stored_session_expires_at": expires_at} + parsed = _parse_iso_datetime(expires_at) + if parsed is not None: + now = datetime.now(timezone.utc) + hint["session_reauth_recommended"] = parsed <= (now + timedelta(days=1)) + return hint + + +def _parse_iso_datetime(value: str) -> Optional[datetime]: + normalized = value.strip() + if not normalized: + return None + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +async def _require_generation_ready() -> Optional[Dict[str, Any]]: + status = await _get_mcp_status() + if status.next_action is None and status.generation_ready and status.authenticated: + return None + + response: Dict[str, Any] = { + "failed": True, + "error_message": status.user_message, + "next_action": status.next_action, + } + if status.next_action_url: + response["next_action_url"] = status.next_action_url + if status.identity is not None: + response["identity"] = status.identity.model_dump() + if status.credits is not None: + response["credits"] = status.credits.model_dump() + if status.mcp_session is not None: + response["mcp_session"] = status.mcp_session.model_dump() + return response + + +# ── Progress helper ─────────────────────────────────────────────────────────── + +def _make_progress_callback( + ctx: Optional[Context], +) -> Callable[[WorkflowStatus], Awaitable[None]]: + """Return an async on_progress callback that reports each newly completed node.""" + seen: set = set() + counter: List[int] = [0] + + async def on_progress(status: WorkflowStatus) -> None: + node = status.last_exit_node or status.current_node + if not node or node in seen: + return + seen.add(node) + counter[0] += 1 + if ctx: + await ctx.report_progress( + progress=counter[0], + total=None, + message=f"Completed: {node}", + ) + + return on_progress + + +def _make_login_progress_callback( + ctx: Optional[Context], +) -> Callable[[str], Awaitable[None]]: + counter: List[int] = [0] + + async def on_progress(message: str) -> None: + counter[0] += 1 + if ctx: + await ctx.report_progress( + progress=counter[0], + total=None, + message=message, + ) + + return on_progress + + +# ── Conversation linking helpers ────────────────────────────────────────────── + +def _extract_conversation_id(code_artifact: Optional[Dict[str, Any]]) -> Optional[str]: + if not isinstance(code_artifact, dict): + return None + return code_artifact.get("_nova3d_conversation_id") or None + + +def _embed_code_artifact_metadata( + code_artifact: Optional[Dict[str, Any]], + conversation_id: Optional[str], + *, + source_code_artifact: Optional[Dict[str, Any]] = None, + prompt: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + if code_artifact is None: + return None + result = dict(code_artifact) + if conversation_id: + result["_nova3d_conversation_id"] = conversation_id + source_prompt = ( + source_code_artifact.get("_nova3d_prompt") + if isinstance(source_code_artifact, dict) + else None + ) + final_prompt = prompt or source_prompt + if final_prompt: + result["_nova3d_prompt"] = final_prompt + return result + + +def _conversation_url(app_url: str, conversation_id: Optional[str]) -> Optional[str]: + if not conversation_id: + return None + return f"{app_url}/chat/{conversation_id}" + + +def _build_image_artifact( + image_base64: Optional[str], + image_mime: Optional[str], +) -> Optional[List[str]]: + if not image_base64: + return None + mime = (image_mime or "image/png").strip() or "image/png" + return [f"data:{mime};base64,{image_base64.strip()}"] + + +async def _persist_generation_history( + client: Nova3DClient, + *, + conversation_id: Optional[str], + title: str, + prompt: str, + result: GenerationResult, + code_artifact: Optional[Dict[str, Any]], + model_option_id: str, +) -> bool: + if not conversation_id: + return False + messages = build_generation_messages( + prompt=prompt, + result=result, + code_artifact=code_artifact, + model_option_id=model_option_id, + ) + try: + await client.update_conversation_snapshot( + conversation_id, + title=title, + messages=messages, + ) + await _append_and_link_messages(client, conversation_id, messages) + return True + except Nova3DError as e: + print(f"Nova3D: conversation history persistence failed: {e}", file=sys.stderr) + return False + + +async def _persist_edit_history( + client: Nova3DClient, + *, + conversation_id: Optional[str], + operation: str, + description: str, + result: GenerationResult, + code_artifact: Optional[Dict[str, Any]], + model_option_id: str, + instruction_prompt: Optional[str], +) -> bool: + if not conversation_id: + return False + message = build_edit_message( + operation=operation, + description=description, + result=result, + code_artifact=code_artifact, + model_option_id=model_option_id, + instruction_prompt=instruction_prompt, + ) + try: + await _append_and_link_messages(client, conversation_id, [message]) + return True + except Nova3DError as e: + print(f"Nova3D: edit history persistence failed: {e}", file=sys.stderr) + return False + + +async def _append_and_link_messages( + client: Nova3DClient, + conversation_id: str, + messages: List[Dict[str, Any]], +) -> None: + for message in messages: + remote_message_id = await client.append_conversation_message( + conversation_id, + message, + ) + workflow_id = message.get("workflow_id") + if workflow_id: + await client.link_workflow_to_message( + conversation_id, + workflow_id=str(workflow_id), + remote_message_id=remote_message_id, + operation=str(message.get("operation") or "generation"), + ) + + +def _status_payload(status: MCPStatus) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "authenticated": status.authenticated, + "generation_ready": status.generation_ready, + "next_action": status.next_action, + "next_action_url": status.next_action_url, + "user_message": status.user_message, + "mcp_session": status.mcp_session.model_dump(), + } + if status.identity is not None: + payload["identity"] = status.identity.model_dump() + if status.credits is not None: + payload["credits"] = status.credits.model_dump() + payload.update(_build_session_hint(_get_session_store())) + return payload + + +def _pending_login_payload(pending: PendingLoginState) -> Dict[str, Any]: + return { + "login_started": True, + "login_pending_confirmation": True, + "browser_url": pending.connect_url, + "local_callback_port": pending.port, + "user_message": ( + "Complete Nova3D sign-in in the browser tab that opened, then call nova3d_status " + "to confirm credits and readiness." + ), + "suggested_next_step": "call nova3d_status", + } + + +def _structured_login_error_payload( + error: Nova3DLoginError, + *, + browser_url: Optional[str] = None, +) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "failed": True, + "error_message": str(error), + } + if browser_url or error.browser_url: + payload["browser_url"] = browser_url or error.browser_url + if error.should_check_status: + payload["suggested_next_step"] = "call nova3d_status" + payload["recovery_instructions"] = ( + "If you completed sign-in in the browser, call nova3d_status now. " + "If status still shows not signed in, retry nova3d_login." + ) + if error.manual_fallback_only: + payload["manual_fallback_available"] = True + return payload + + +async def _consume_pending_login_result() -> Optional[Dict[str, Any]]: + global _pending_login + + pending = _pending_login + if pending is None or not pending.task.done(): + return None + + _pending_login = None + try: + result = await pending.task + except Nova3DLoginError as e: + return _structured_login_error_payload(e, browser_url=pending.connect_url) + except Exception: + return { + "failed": True, + "browser_url": pending.connect_url, + "suggested_next_step": "call nova3d_status", + "error_message": ( + "Nova3D browser sign-in may have completed, but the MCP session could not be confirmed yet. " + "Call nova3d_status now. If status still shows not signed in, retry nova3d_login." + ), + } + + payload = _status_payload(result.status) + payload["browser_url"] = result.connect_url + payload["local_session_path"] = str(_get_session_store().path) + payload["login_started"] = True + if result.expires_at: + payload["stored_session_expires_at"] = result.expires_at + return payload + + +# ── Tools ───────────────────────────────────────────────────────────────────── + +@mcp.tool() +async def nova3d_setup() -> Dict[str, Any]: + """ + Get setup instructions for Nova3D. + + Call this if the user asks how to get started or needs the sign-in flow. + + Returns: + instructions: Step-by-step setup guide with URL and install command. + """ + instructions = ( + "After installing the Nova3D MCP server in Claude, Codex, Cursor, VS Code, " + "Visual Studio, or another MCP client, your next step is not generation yet.\n\n" + "Preferred setup:\n" + "1. Call nova3d_login from inside your MCP client.\n" + "2. Complete the Nova3D sign-in flow in the browser tab that opens.\n" + "3. Then call nova3d_status to confirm credits and readiness.\n" + "4. If credits are required, use the purchase link returned by nova3d_status.\n" + "5. Only after authenticated: true and generation_ready: true should you call generate_3d.\n" + "6. Manual NOVA3D_TOKEN setup is advanced fallback only if browser/loopback auth is unavailable.\n\n" + "Advanced/manual fallback:\n" + "claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-key -- uvx nova3d-mcp" + ) + return {"instructions": instructions} + + +@mcp.tool() +async def nova3d_status() -> Dict[str, Any]: + """ + Get the canonical Nova3D onboarding and readiness state. + + Use this before generation, after sign-in, or after purchasing credits. + """ + pending_result = await _consume_pending_login_result() + if pending_result is not None: + return pending_result + + status = await _get_mcp_status() + payload = _status_payload(status) + if _pending_login is not None and not _pending_login.task.done(): + payload.update(_pending_login_payload(_pending_login)) + return payload + + +@mcp.tool() +async def nova3d_login(ctx: Optional[Context] = None) -> Dict[str, Any]: + """ + Sign in to Nova3D through the browser and establish a local MCP session. + + This is the preferred onboarding path. It starts a loopback callback flow, + opens a browser tab, waits for the local loopback callback, exchanges the + one-time session code for an MCP credential, stores it locally, and returns + the resulting readiness state. + + If browser sign-in finishes but local completion is ambiguous, call + nova3d_status before falling back to manual NOVA3D_TOKEN setup. + """ + global _pending_login + + finished_payload = await _consume_pending_login_result() + if finished_payload is not None: + return finished_payload + + if _pending_login is not None and not _pending_login.task.done(): + return _pending_login_payload(_pending_login) + + authenticator = Nova3DAuthenticator( + base_url=_get_api_url(), + app_url=_get_app_url(), + session_store=_get_session_store(), + ) + try: + pending = await authenticator.begin_login( + on_progress=_make_login_progress_callback(ctx), + ) + _pending_login = PendingLoginState( + connect_url=pending.connect_url, + port=pending.port, + task=pending.task, + ) + try: + result = await asyncio.wait_for( + asyncio.shield(pending.task), + timeout=LOGIN_GRACE_PERIOD_SECONDS, + ) + except asyncio.TimeoutError: + return _pending_login_payload(_pending_login) + _pending_login = None + except Nova3DLoginError as e: + return _structured_login_error_payload(e) + except Exception: + return { + "failed": True, + "error_message": ( + "Nova3D sign-in started, but the local MCP session could not be confirmed yet. " + "Call nova3d_status now. If status still shows not signed in, retry nova3d_login." + ), + "suggested_next_step": "call nova3d_status", + } + payload = _status_payload(result.status) + payload["browser_url"] = result.connect_url + payload["local_session_path"] = str(_get_session_store().path) + payload["login_started"] = True + if result.expires_at: + payload["stored_session_expires_at"] = result.expires_at + return payload + + +@mcp.tool() +async def nova3d_logout() -> Dict[str, Any]: + """ + Clear the locally stored MCP session credential. + + This does not remove an advanced/manual NOVA3D_TOKEN from the MCP config. + """ + store = _get_session_store() + had_session = store.load_token() is not None + store.clear() + return { + "logged_out": True, + "cleared_local_session": had_session, + "manual_token_still_configured": _get_manual_token() is not None, + } + + +@mcp.tool() +async def generate_3d( + prompt: str, + model: Optional[str] = None, + image_base64: Optional[str] = None, + image_mime: Optional[str] = None, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """ + Generate a structured, part-aware 3D asset from a text prompt. + + Initial generation uses Nova3D's paid GraphFlow v2 workflow. The selected + model routes to a Nova3D-managed paid tier; this MCP tool does not expose + BYOK provider-key generation. + + Nova3D writes Blender Python construction code, executes it server-side, + validates spatial structure, and exports a GLB with named, separately + addressable parts — not a fused mesh blob. + + Args: + prompt: Description of the 3D asset. Be specific about parts. + Example: "a washing machine with drum, door, control panel, + and hose connectors" + model: Paid Nova3D routing preset. One of: "gemini" (default), + "claude-sonnet", "claude-opus", "claude-opus-latest", + "gpt-5.5". + image_base64: Optional reference image as plain base64. The MCP server + converts this into the v2 image_artifact data-URL format. + image_mime: MIME type of the reference image e.g. "image/jpeg". + + Returns: + glb_url: Direct download URL for the structured GLB file. + parts: List of named mesh/joint identifiers in the asset. + joint_count: Number of articulated joints. + code_artifact: Blender Python construction script. Pass this to + regenerate_part, add_part, or articulate_model. + model_artifact: GLB artifact object. Pass to articulate_model. + workflow_id: Workflow identifier for status tracking. + conversation_url: Browser URL for the editing session in the Nova3D app. + All regenerate/edit calls on this asset link here too. + Open this to see the full generation history for this asset. + failed: True if generation failed. + error_message: Human-readable error if failed is True. + """ + if _startup_error: + return {"failed": True, "error_message": _startup_error} + readiness_error = await _require_generation_ready() + if readiness_error is not None: + return readiness_error + model_opts = _resolve_model(model) + if model_opts is None: + valid = ", ".join(_MODEL_OPTIONS) + return {"failed": True, "error_message": f"Invalid model '{model or _DEFAULT_MODEL}'. Valid options: {valid}"} + token = _get_token() + base_url = _get_api_url() + app_url = _get_app_url() + + async with Nova3DClient(token=token, base_url=base_url) as client: + conversation_id: Optional[str] = None + try: + conversation_id = await client.create_conversation(title=prompt[:100]) + except Exception as e: + print(f"Nova3D: conversation creation failed (generation will proceed): {e}", file=sys.stderr) + + result = await client.generate( + prompt=prompt, + code_llm_profile=model_opts["code_llm_profile"], + code_llm_tier=model_opts["code_llm_tier"], + image_artifact=_build_image_artifact(image_base64, image_mime), + conversation_id=conversation_id, + on_progress=_make_progress_callback(ctx), + ) + + if result.failed: + return { + "failed": True, + "error_message": result.error_message, + "error_category": result.error_category, + "retryable": result.retryable, + } + + code_artifact = _embed_code_artifact_metadata( + result.code_artifact or {}, + conversation_id, + prompt=prompt, + ) + history_persisted = await _persist_generation_history( + client, + conversation_id=conversation_id, + title=prompt[:100], + prompt=prompt, + result=result, + code_artifact=code_artifact, + model_option_id=model_opts["option_id"], + ) + + response: Dict[str, Any] = { + "glb_url": result.glb_url, + "parts": result.parts, + "joint_count": result.joint_count, + "joints": result.joints, + "code_artifact": code_artifact, + "model_artifact": result.model_artifact, + "workflow_id": result.workflow_id, + "api_key_source": result.api_key_source, + "history_persisted": history_persisted, + "failed": False, + } + conv_url = _conversation_url(app_url, conversation_id) + if conv_url: + response["conversation_url"] = conv_url + return response + + +@mcp.tool() +async def regenerate_part( + code_artifact: Dict[str, Any], + part_type: str, + description: str, + model: Optional[str] = None, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """ + Regenerate a specific named part within an existing 3D asset. + + Use this after generate_3d when you want to change one component without + rebuilding the entire asset. The part name must match a name from the + parts list returned by the original generate_3d call, or a part name visible + in the conversation viewer. + + Args: + code_artifact: The code_artifact object from a prior generate_3d or + edit workflow result. Required — this is how Nova3D + knows the current structure of the asset. + part_type: Name of the part to regenerate. Must match a part name + from the asset. Example: "door", "handle", "drum", + "control_panel". Check the conversation URL to identify + exact part names. + description: Description of what the regenerated part should look + like. Be specific. Example: "glass panel door with + chrome frame and rubber seal around the edges". + model: LLM model. One of: "gemini" (default), "claude-sonnet", + "claude-opus", "claude-opus-latest", "gpt-5.5". + + Returns: + glb_url: Updated GLB with the regenerated part. + code_artifact: Updated construction script for further edits. + workflow_id: Workflow identifier. + conversation_url: Browser URL for the editing session. Present only if + the original generate_3d call successfully created a + conversation. Same URL as returned by generate_3d. + failed: True if regeneration failed. + error_message: Human-readable error if failed is True. + """ + if _startup_error: + return {"failed": True, "error_message": _startup_error} + model_opts = _resolve_model(model) + if model_opts is None: + valid = ", ".join(_MODEL_OPTIONS) + return {"failed": True, "error_message": f"Invalid model '{model or _DEFAULT_MODEL}'. Valid options: {valid}"} + token = _get_token() + base_url = _get_api_url() + app_url = _get_app_url() + conversation_id = _extract_conversation_id(code_artifact) + + async with Nova3DClient(token=token, base_url=base_url) as client: + result = await client.regenerate_part( + code_artifact=code_artifact, + part_type=part_type, + description=description, + provider=model_opts["provider"], + llm=model_opts["llm"], + conversation_id=conversation_id, + on_progress=_make_progress_callback(ctx), + ) + + if result.failed: + return { + "failed": True, + "error_message": result.error_message, + "error_category": result.error_category, + "retryable": result.retryable, + } + + updated_code_artifact = _embed_code_artifact_metadata( + result.code_artifact, + conversation_id, + source_code_artifact=code_artifact, + ) + history_persisted = await _persist_edit_history( + client, + conversation_id=conversation_id, + operation="regenerate_3d_part", + description=description, + result=result, + code_artifact=updated_code_artifact, + model_option_id=model_opts["option_id"], + instruction_prompt=( + code_artifact.get("_nova3d_prompt") + if isinstance(code_artifact, dict) + else None + ), + ) + + response: Dict[str, Any] = { + "glb_url": result.glb_url, + "parts": result.parts, + "code_artifact": updated_code_artifact, + "workflow_id": result.workflow_id, + "api_key_source": result.api_key_source, + "history_persisted": history_persisted, + "failed": False, + } + conv_url = _conversation_url(app_url, conversation_id) + if conv_url: + response["conversation_url"] = conv_url + return response + + +@mcp.tool() +async def add_part( + code_artifact: Dict[str, Any], + description: str, + model: Optional[str] = None, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """ + Add a new named part to an existing 3D asset. + + Use this to extend an asset with additional components after the initial + generation. The new part is integrated into the scene graph alongside the + existing parts, preserving all naming and hierarchy. + + Args: + code_artifact: The code_artifact object from a prior generate_3d or + edit workflow result. + description: Description of the new part to add. Be specific about + shape, position relative to existing parts, and any + material properties. Example: "add a chrome handle bar + to the front face of the door, centered horizontally". + model: LLM model. One of: "gemini" (default), "claude-sonnet", + "claude-opus", "claude-opus-latest", "gpt-5.5". + + Returns: + glb_url: Updated GLB with the new part added. + parts: Updated list of part names including the new part. + code_artifact: Updated construction script for further edits. + workflow_id: Workflow identifier. + conversation_url: Browser URL for the editing session. Present only if + the original generate_3d call successfully created a + conversation. Same URL as returned by generate_3d. + failed: True if the add operation failed. + error_message: Human-readable error if failed is True. + """ + if _startup_error: + return {"failed": True, "error_message": _startup_error} + model_opts = _resolve_model(model) + if model_opts is None: + valid = ", ".join(_MODEL_OPTIONS) + return {"failed": True, "error_message": f"Invalid model '{model or _DEFAULT_MODEL}'. Valid options: {valid}"} + token = _get_token() + base_url = _get_api_url() + app_url = _get_app_url() + conversation_id = _extract_conversation_id(code_artifact) + + async with Nova3DClient(token=token, base_url=base_url) as client: + result = await client.add_part( + code_artifact=code_artifact, + description=description, + provider=model_opts["provider"], + llm=model_opts["llm"], + conversation_id=conversation_id, + on_progress=_make_progress_callback(ctx), + ) + + if result.failed: + return { + "failed": True, + "error_message": result.error_message, + "error_category": result.error_category, + "retryable": result.retryable, + } + + updated_code_artifact = _embed_code_artifact_metadata( + result.code_artifact, + conversation_id, + source_code_artifact=code_artifact, + ) + history_persisted = await _persist_edit_history( + client, + conversation_id=conversation_id, + operation="add_3d_part", + description=description, + result=result, + code_artifact=updated_code_artifact, + model_option_id=model_opts["option_id"], + instruction_prompt=( + code_artifact.get("_nova3d_prompt") + if isinstance(code_artifact, dict) + else None + ), + ) + + response: Dict[str, Any] = { + "glb_url": result.glb_url, + "parts": result.parts, + "code_artifact": updated_code_artifact, + "workflow_id": result.workflow_id, + "api_key_source": result.api_key_source, + "history_persisted": history_persisted, + "failed": False, + } + conv_url = _conversation_url(app_url, conversation_id) + if conv_url: + response["conversation_url"] = conv_url + return response + + +@mcp.tool() +async def articulate_model( + code_artifact: Dict[str, Any], + articulation_request: str, + model_url: Optional[str] = None, + model_artifact: Optional[Dict[str, Any]] = None, + model: Optional[str] = None, + selected_meshes: Optional[List[str]] = None, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """ + Add joints, hinges, or rotational articulation to an existing 3D asset. + + Use this to make parts of a generated asset physically movable — rotating + drums, swinging doors, articulated robot joints, etc. The articulation is + real and exported as joint definitions in the GLB, not baked into the mesh. + + Args: + code_artifact: The code_artifact object from a prior generation. + articulation_request: Plain language description of the desired + articulation. Example: "make the drum rotate + around its central axis and the door swing open + on a hinge at the left edge". + model_url: The glb_url from the prior generation result. + Provide this or model_artifact (or both). + Must be a direct HTTPS URL, not a blob: URL. + model_artifact: The model_artifact object from the prior generation + result. Provide this or model_url (or both). + model: LLM model. One of: "gemini" (default), "claude-sonnet", + "claude-opus", "claude-opus-latest", "gpt-5.5". + selected_meshes: Optional list of specific mesh names to articulate. + If omitted, the LLM infers which parts to articulate + from the articulation_request. + + Returns: + glb_url: Updated GLB with joint definitions embedded. + joints: List of joint definition objects. + joint_count: Number of joints added. + code_artifact: Updated construction script. + workflow_id: Workflow identifier. + conversation_url: Browser URL for the editing session. Present only if + the original generate_3d call successfully created a + conversation. Same URL as returned by generate_3d. + failed: True if articulation failed. + error_message: Human-readable error if failed is True. + """ + if _startup_error: + return {"failed": True, "error_message": _startup_error} + if model_url is None and model_artifact is None: + return { + "failed": True, + "error_message": "Provide model_url or model_artifact from the prior generate_3d result.", + } + model_opts = _resolve_model(model) + if model_opts is None: + valid = ", ".join(_MODEL_OPTIONS) + return {"failed": True, "error_message": f"Invalid model '{model or _DEFAULT_MODEL}'. Valid options: {valid}"} + token = _get_token() + base_url = _get_api_url() + app_url = _get_app_url() + conversation_id = _extract_conversation_id(code_artifact) + instruction_prompt = code_artifact.get("_nova3d_prompt") if isinstance(code_artifact, dict) else None + + async with Nova3DClient(token=token, base_url=base_url) as client: + result = await client.articulate_model( + code_artifact=code_artifact, + articulation_request=articulation_request, + provider=model_opts["provider"], + llm=model_opts["llm"], + model_url=model_url, + model_artifact=model_artifact, + instruction_prompt=instruction_prompt, + selected_meshes=selected_meshes, + conversation_id=conversation_id, + on_progress=_make_progress_callback(ctx), + ) + + if result.failed: + return { + "failed": True, + "error_message": result.error_message, + "error_category": result.error_category, + "retryable": result.retryable, + } + + updated_code_artifact = _embed_code_artifact_metadata( + result.code_artifact, + conversation_id, + source_code_artifact=code_artifact, + ) + history_persisted = await _persist_edit_history( + client, + conversation_id=conversation_id, + operation="articulate_3d_model", + description=articulation_request, + result=result, + code_artifact=updated_code_artifact, + model_option_id=model_opts["option_id"], + instruction_prompt=instruction_prompt, + ) + + response: Dict[str, Any] = { + "glb_url": result.glb_url, + "joints": result.joints, + "joint_count": result.joint_count, + "code_artifact": updated_code_artifact, + "workflow_id": result.workflow_id, + "api_key_source": result.api_key_source, + "history_persisted": history_persisted, + "failed": False, + } + conv_url = _conversation_url(app_url, conversation_id) + if conv_url: + response["conversation_url"] = conv_url + return response + + +@mcp.tool() +async def get_generation_status(workflow_id: str) -> Dict[str, Any]: + """ + Get the current status of a running generation workflow. + + Use this to check on a long-running generation without waiting for + the full result. The generate_3d and edit tools block until completion, + but this tool is useful if you have a workflow_id from a prior session. + + Args: + workflow_id: The workflow_id returned by any generation tool. + + Returns: + state: Current state string. + is_terminal: True if the workflow has finished (success or failure). + progress_label: Human-readable progress description. + current_node: Internal pipeline node currently executing. + """ + if _startup_error: + return {"failed": True, "error_message": _startup_error} + token = _get_token() + base_url = _get_api_url() + + async with Nova3DClient(token=token, base_url=base_url) as client: + status = await client.get_status(workflow_id) + + return { + "workflow_id": status.workflow_id, + "state": status.state.value, + "is_terminal": status.is_terminal, + "progress_label": status.progress_label, + "current_node": status.current_node, + } + + +# ── Entrypoint ──────────────────────────────────────────────────────────────── + +def main() -> None: + asyncio.run(_validate_startup()) + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/mcp/nova3d_mcp/session_store.py b/mcp/nova3d_mcp/session_store.py new file mode 100644 index 0000000..aa92303 --- /dev/null +++ b/mcp/nova3d_mcp/session_store.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional + + +def _default_session_path() -> Path: + override = os.environ.get("NOVA3D_SESSION_PATH", "").strip() + if override: + return Path(override).expanduser() + + xdg_state_home = os.environ.get("XDG_STATE_HOME", "").strip() + if xdg_state_home: + root = Path(xdg_state_home).expanduser() + else: + root = Path.home() / ".local" / "state" + return root / "nova3d" / "mcp-session.json" + + +class SessionStore: + def __init__(self, path: Optional[Path] = None): + self._path = path or _default_session_path() + + @property + def path(self) -> Path: + return self._path + + def load_session(self) -> Dict[str, Optional[str]]: + payload = self._load_payload() + token = payload.get("token") + expires_at = payload.get("expires_at") + return { + "token": token.strip() if isinstance(token, str) and token.strip() else None, + "expires_at": ( + expires_at.strip() + if isinstance(expires_at, str) and expires_at.strip() + else None + ), + } + + def load_token(self) -> Optional[str]: + return self.load_session()["token"] + + def load_expires_at(self) -> Optional[str]: + return self.load_session()["expires_at"] + + def save_session(self, token: str, expires_at: Optional[str] = None) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + payload: Dict[str, Any] = {"token": token.strip()} + if expires_at: + payload["expires_at"] = expires_at.strip() + self._path.write_text(json.dumps(payload), encoding="utf-8") + try: + os.chmod(self._path, 0o600) + except OSError: + pass + + def save_token(self, token: str) -> None: + self.save_session(token) + + def clear(self) -> None: + try: + self._path.unlink() + except FileNotFoundError: + return + + def _load_payload(self) -> Dict[str, Any]: + try: + payload = json.loads(self._path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml new file mode 100644 index 0000000..706243f --- /dev/null +++ b/mcp/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "nova3d-mcp" +version = "0.3.0" +description = "Nova3D MCP server — structured, part-aware 3D generation for AI agents" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +keywords = [ + "mcp", + "3d-generation", + "blender", + "ai", + "nova3d", + "generative-ai", + "3d-assets", + "model-context-protocol", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "mcp[cli]>=1.27.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.21.0", + "ruff>=0.4.0", +] + +[project.urls] +Homepage = "https://nova3d.xyz" +Repository = "https://github.com/RareSense/Nova3D" +"Bug Tracker" = "https://github.com/RareSense/Nova3D/issues" +Documentation = "https://github.com/RareSense/Nova3D/tree/main/mcp#readme" + +[project.scripts] +nova3d-mcp = "nova3d_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["nova3d_mcp"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 88 +target-version = "py310" diff --git a/mcp/server.json b/mcp/server.json new file mode 100644 index 0000000..570fe2d --- /dev/null +++ b/mcp/server.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.RareSense/Nova3D", + "title": "Nova3D", + "description": "Structured, part-aware 3D generation for AI agents. Named-part GLB, preview URL, Blender script.", + "version": "0.3.0", + "repository": { + "url": "https://github.com/RareSense/Nova3D", + "source": "github", + "subfolder": "mcp" + }, + "packages": [ + { + "registryType": "pypi", + "registryBaseUrl": "https://pypi.org", + "identifier": "nova3d-mcp", + "version": "0.3.0", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "NOVA3D_TOKEN", + "description": "Advanced/manual fallback API key from https://app.nova3d.xyz/api-key. Preferred onboarding is browser sign-in via nova3d_login.", + "isRequired": false, + "isSecret": true + }, + { + "name": "NOVA3D_APP_URL", + "description": "App URL used for conversation links, for example https://app.nova3d.xyz or http://127.0.0.1:5555", + "isRequired": false, + "isSecret": false + } + ] + } + ] +} diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py new file mode 100644 index 0000000..c11bb6e --- /dev/null +++ b/mcp/tests/test_client.py @@ -0,0 +1,867 @@ +""" +tests/test_client.py +──────────────────────────────────────────────────────────────── +Unit tests for Nova3DClient. +Uses respx to mock HTTP without hitting the real API. +──────────────────────────────────────────────────────────────── +""" +import json +import pytest +import respx +import httpx +from nova3d_mcp.client import _parse_auth_error +from nova3d_mcp.server import _validate_startup + +from nova3d_mcp.client import Nova3DClient, Nova3DError, Nova3DCreditsError, Nova3DAuthError +from nova3d_mcp.models import GenerationResult, WorkflowState + + +FAKE_TOKEN = "test-jwt-token" +BASE_URL = "https://nova3d.xyz/api" +WORKFLOW_ID = "state-123456789" + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def mock_api(): + with respx.mock(base_url=BASE_URL, assert_all_called=False) as mock: + yield mock + + +@pytest.fixture(autouse=True) +def isolate_session_store(tmp_path, monkeypatch): + monkeypatch.setenv( + "NOVA3D_SESSION_PATH", + str(tmp_path / "mcp-session.json"), + ) + + +def _readiness_ok(): + return {"ready": True, "reason": None, "projected_cost": 10, "authorized_budget": 12} + + +def _start_ok(): + return { + "workflow_id": WORKFLOW_ID, + "status_url": f"/status/{WORKFLOW_ID}", + "result_url": f"/result/{WORKFLOW_ID}", + "projected_cost": 10, + "authorized_budget": 12, + } + + +def _status_running(): + return { + "runtime": {"state": "running", "last_exit_node_id": None}, + "node_visit_seq": {"sketch_to_3d_generator": 1}, + } + + +def _status_completed(): + return { + "runtime": {"state": "completed", "last_exit_node_id": "final_latest_valid"}, + "node_visit_seq": {"final_latest_valid": 1}, + } + + +def _result_ok(): + return { + "final_latest_valid": [ + { + "status": "completed", + "ok": True, + "glb_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "model_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "code_artifact": {"content": "import bpy\n# generated code"}, + "joints": [{"name": "door_hinge", "type": "revolute", "mesh": "door"}], + "joint_count": 1, + "operation": "initial_generation", + } + ] + } + + +def _result_corrected_ok(): + return { + "final_validated_correction": [ + { + "status": "completed", + "ok": True, + "glb_artifact": {"url": "https://nova3d.xyz/assets/corrected.glb"}, + "code_artifact": {"content": "import bpy\n# corrected generated code"}, + } + ] + } + + +def _mcp_status_ready(): + return { + "authenticated": True, + "identity": { + "user_id": "user-123", + "email": "user@example.com", + "tenant_id": "ten_123", + }, + "mcp_session": { + "established": True, + "expires_at": "2026-09-10T14:32:00Z", + }, + "credits": { + "balance": 350, + "reserved": 50, + "available": 300, + "funded": True, + }, + "generation_ready": True, + "next_action": None, + "next_action_url": None, + } + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_generate_success(mock_api): + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json=_readiness_ok()) + ) + mock_api.post("/run/state/sketch_to_3d_v2").mock( + return_value=httpx.Response(202, json=_start_ok()) + ) + # First status poll returns running, second returns completed + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + side_effect=[ + httpx.Response(200, json=_status_running()), + httpx.Response(200, json=_status_completed()), + ] + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + # Patch sleep to avoid actual waiting in tests + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + result = await client.generate( + prompt="a toaster with removable tray", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + ) + + client_module.asyncio.sleep = original_sleep + + assert result.failed is False + assert result.glb_url == "https://nova3d.xyz/assets/abc123.glb" + assert result.joint_count == 1 + assert result.joints[0]["name"] == "door_hinge" + assert result.workflow_id == WORKFLOW_ID + + +@pytest.mark.asyncio +async def test_generate_not_ready(mock_api): + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json={ + "ready": False, + "reason": "generation_service_unavailable", + }) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + with pytest.raises(Nova3DError, match="unavailable"): + await client.generate( + prompt="a robot", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + ) + + +@pytest.mark.asyncio +async def test_generate_credits_error(mock_api): + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json=_readiness_ok()) + ) + mock_api.post("/run/state/sketch_to_3d_v2").mock( + return_value=httpx.Response(402, json={ + "code": "credits_or_user_key_required", + "message": "Add credits or provide your own provider API key to generate.", + }) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + with pytest.raises(Nova3DCreditsError): + await client.generate( + prompt="a robot", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + ) + + +@pytest.mark.asyncio +async def test_result_parsing_glb_url(): + result = GenerationResult.from_api(_result_ok(), WORKFLOW_ID) + assert result.glb_url == "https://nova3d.xyz/assets/abc123.glb" + assert result.joint_count == 1 + assert result.failed is False + + +@pytest.mark.asyncio +async def test_result_parsing_failure(): + data = { + "fail_generation": [ + { + "status": "failed", + "error_category": "blender_generation_failed", + "user_message": "The script could not produce a valid model.", + } + ] + } + result = GenerationResult.from_api(data, WORKFLOW_ID) + assert result.failed is True + assert result.glb_url is None + assert "model" in result.error_message.lower() + + +def test_workflow_state_parse(): + assert WorkflowState.parse("completed").is_terminal is True + assert WorkflowState.parse("succeeded").is_terminal is True + assert WorkflowState.parse("running").is_terminal is False + assert WorkflowState.parse("pending").is_terminal is False + assert WorkflowState.parse("budget_exhausted").is_terminal is True + assert WorkflowState.parse(None) == WorkflowState.UNKNOWN + + +def test_recoverable_errors(): + from nova3d_mcp.client import _is_recoverable + assert _is_recoverable("workflow not found (404)") is True + assert _is_recoverable("request failed (502)") is True + assert _is_recoverable("still starting") is True + assert _is_recoverable("sign in again") is False + assert _is_recoverable("budget was exhausted") is False + assert _is_recoverable("invalid api key") is False + + + +def test_parse_auth_error_revoked(): + resp = httpx.Response(401, json={ + "detail": {"code": "api_key_revoked", "message": "Key revoked."} + }) + code, message = _parse_auth_error(resp) + assert code == "api_key_revoked" + assert "revoked" in message.lower() + assert "app.nova3d.xyz/api-key" in message + + +def test_parse_auth_error_invalid_key(): + resp = httpx.Response(401, json={ + "detail": {"code": "invalid_api_key", "message": "Bad key."} + }) + code, message = _parse_auth_error(resp) + assert code == "invalid_api_key" + assert "invalid" in message.lower() + assert "app.nova3d.xyz/api-key" in message + + +def test_parse_auth_error_detail_string(): + resp = httpx.Response(401, json={"detail": "Not authenticated"}) + code, message = _parse_auth_error(resp) + assert code is None + assert message == "Not authenticated" + + +def test_parse_auth_error_no_json(): + resp = httpx.Response(401, content=b"Unauthorized") + code, message = _parse_auth_error(resp) + assert code is None + assert "app.nova3d.xyz/api-key" in message + + +@pytest.mark.asyncio +async def test_generate_raises_auth_error_with_code(mock_api): + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json=_readiness_ok()) + ) + mock_api.post("/run/state/sketch_to_3d_v2").mock( + return_value=httpx.Response(401, json={ + "detail": {"code": "api_key_revoked", "message": "Revoked."} + }) + ) + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + with pytest.raises(Nova3DAuthError, match="revoked"): + await client.generate( + prompt="a robot", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + ) + + +@pytest.mark.asyncio +async def test_get_me_success(mock_api): + mock_api.get("/me").mock( + return_value=httpx.Response(200, json={ + "user_id": "b333345d-a36c-487e-9096-5f7fd9a2901b", + "email": "hassan@raresense.so", + "available_credits": 1000, + "tenant_id": "ten_e06a051bdb1f43b7b9d5bfaea1e07bf0", + }) + ) + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + me = await client.get_me() + assert me["email"] == "hassan@raresense.so" + assert me["available_credits"] == 1000 + + +@pytest.mark.asyncio +async def test_get_me_invalid_key(mock_api): + mock_api.get("/me").mock( + return_value=httpx.Response(401, json={ + "detail": {"code": "invalid_api_key", "message": "Bad key."} + }) + ) + async with Nova3DClient(token="n3d_bad", base_url=BASE_URL) as client: + with pytest.raises(Nova3DAuthError, match="invalid"): + await client.get_me() + + +@pytest.mark.asyncio +async def test_get_mcp_status_success(mock_api): + mock_api.get("/mcp/status").mock( + return_value=httpx.Response(200, json=_mcp_status_ready()) + ) + + async with Nova3DClient(token=None, base_url=BASE_URL) as client: + status = await client.get_mcp_status() + + assert status.authenticated is True + assert status.generation_ready is True + assert status.next_action is None + assert status.identity.email == "user@example.com" + assert status.credits.available == 300 + + +@pytest.mark.asyncio +async def test_exchange_mcp_session_code_success(mock_api): + mock_api.post("/mcp/session/exchange").mock( + return_value=httpx.Response(200, json={"token": "n3d_test_session", "expires_at": "2026-09-10T14:32:00Z"}) + ) + + async with Nova3DClient(token=None, base_url=BASE_URL) as client: + token = await client.exchange_mcp_session_code("session-code") + + assert token == "n3d_test_session" + + +@pytest.mark.asyncio +async def test_exchange_mcp_session_returns_expires_at(mock_api): + mock_api.post("/mcp/session/exchange").mock( + return_value=httpx.Response(200, json={"token": "n3d_test_session", "expires_at": "2026-09-10T14:32:00Z"}) + ) + + async with Nova3DClient(token=None, base_url=BASE_URL) as client: + exchange = await client.exchange_mcp_session("session-code") + + assert exchange.token == "n3d_test_session" + assert exchange.expires_at == "2026-09-10T14:32:00Z" + + +@pytest.mark.asyncio +async def test_exchange_mcp_session_code_missing_token_raises(mock_api): + mock_api.post("/mcp/session/exchange").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + + async with Nova3DClient(token=None, base_url=BASE_URL) as client: + with pytest.raises(Nova3DError, match="did not return a Nova3D credential"): + await client.exchange_mcp_session_code("session-code") + + +@pytest.mark.asyncio +async def test_create_conversation_success(mock_api): + mock_api.post("/conversations").mock( + return_value=httpx.Response(201, json={ + "id": "conv-abc123", + "tenant_id": "ten_abc", + "user_id": "user-1", + "source": "mcp", + "kind": "generation", + "status": "open", + "title": "a toaster with removable tray", + "external_conversation_id": None, + "conversation_metadata": None, + "created_at": "2026-05-30T12:00:00Z", + "updated_at": "2026-05-30T12:00:00Z", + "last_message_at": None, + }) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + conv_id = await client.create_conversation(title="a toaster with removable tray") + + assert conv_id == "conv-abc123" + + +@pytest.mark.asyncio +async def test_create_conversation_auth_error(mock_api): + mock_api.post("/conversations").mock( + return_value=httpx.Response(401, json={ + "detail": {"code": "invalid_api_key", "message": "Bad key."} + }) + ) + + async with Nova3DClient(token="n3d_bad", base_url=BASE_URL) as client: + with pytest.raises(Nova3DAuthError): + await client.create_conversation(title="a robot") + + +@pytest.mark.asyncio +async def test_create_conversation_missing_id_raises(mock_api): + mock_api.post("/conversations").mock( + return_value=httpx.Response(201, json={"source": "mcp", "kind": "generation"}) + ) + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + with pytest.raises(Nova3DError, match="did not return an ID"): + await client.create_conversation(title="a robot") + + +@pytest.mark.asyncio +async def test_update_conversation_snapshot_sends_flutter_metadata(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(200, json={"id": "conv-abc123"}) + + mock_api.patch("/conversations/conv-abc123").mock( + side_effect=capture_and_respond + ) + messages = [ + { + "id": "cad-state-123", + "role": "assistant", + "text": "Your 3D model is ready.", + "created_at": "2026-06-07T00:00:00Z", + "is_streaming": False, + "model_url": "https://nova3d.xyz/assets/abc.glb", + } + ] + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + await client.update_conversation_snapshot( + "conv-abc123", + title="a robot", + messages=messages, + ) + + parsed = json.loads(captured_requests[0].content) + assert parsed["title"] == "a robot" + snapshot = parsed["conversation_metadata"]["nova3d_chat_snapshot"] + assert snapshot["schema_version"] == 1 + assert snapshot["messages"] == messages + + +@pytest.mark.asyncio +async def test_append_conversation_message_sends_content_json(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(201, json={"id": "msg-remote"}) + + mock_api.post("/conversations/conv-abc123/messages").mock( + side_effect=capture_and_respond + ) + message = { + "id": "cad-state-123", + "role": "assistant", + "text": "Your 3D model is ready.", + "created_at": "2026-06-07T00:00:00Z", + "is_streaming": False, + "workflow_id": "state-123", + } + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + remote_id = await client.append_conversation_message( + "conv-abc123", + message, + ) + + parsed = json.loads(captured_requests[0].content) + assert remote_id == "msg-remote" + assert parsed["client_message_id"] == "cad-state-123" + assert parsed["status"] == "completed" + assert parsed["content_json"] == message + + +@pytest.mark.asyncio +async def test_link_workflow_to_message_sends_mcp_metadata(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(201, json={"id": "link-1"}) + + mock_api.post("/conversations/conv-abc123/workflow-links").mock( + side_effect=capture_and_respond + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + await client.link_workflow_to_message( + "conv-abc123", + workflow_id="state-123", + remote_message_id="msg-remote", + operation="initial_generation", + ) + + parsed = json.loads(captured_requests[0].content) + assert parsed["workflow_id"] == "state-123" + assert parsed["message_id"] == "msg-remote" + assert parsed["relation_type"] == "message_result" + assert parsed["link_metadata"]["client"] == "mcp" + + +def test_result_parsing_api_key_source_present(): + data = { + "final_latest_valid": [ + { + "status": "completed", + "ok": True, + "glb_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "model_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "code_artifact": {"content": "import bpy"}, + "api_key_source": "request", + } + ] + } + result = GenerationResult.from_api(data, WORKFLOW_ID) + assert result.api_key_source == "request" + + +def test_result_parsing_api_key_source_absent(): + result = GenerationResult.from_api(_result_ok(), WORKFLOW_ID) + assert result.api_key_source is None + + +# ── Startup validation tests ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_validate_startup_no_token(monkeypatch, capsys): + import nova3d_mcp.server as server_module + server_module._startup_error = None + monkeypatch.delenv("NOVA3D_TOKEN", raising=False) + await _validate_startup() + assert server_module._startup_error is None + captured = capsys.readouterr() + assert captured.err == "" + server_module._startup_error = None + + +@pytest.mark.asyncio +async def test_validate_startup_success(mock_api, monkeypatch, capsys): + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_validkey") + mock_api.get("/me").mock( + return_value=httpx.Response(200, json={ + "user_id": "abc", + "email": "test@example.com", + "available_credits": 0, + "tenant_id": "ten_abc", + }) + ) + await _validate_startup() # must not raise + captured = capsys.readouterr() + assert "test@example.com" in captured.err + + +@pytest.mark.asyncio +async def test_validate_startup_revoked_key(mock_api, monkeypatch, capsys): + import nova3d_mcp.server as server_module + server_module._startup_error = None + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_revoked") + mock_api.get("/me").mock( + return_value=httpx.Response(401, json={ + "detail": {"code": "api_key_revoked", "message": "Revoked."} + }) + ) + await _validate_startup() + assert server_module._startup_error is None + captured = capsys.readouterr() + assert "revoked" in captured.err.lower() + server_module._startup_error = None + + +@pytest.mark.asyncio +async def test_validate_startup_network_error(monkeypatch, capsys): + import nova3d_mcp.server as server_module + server_module._startup_error = None + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + with respx.mock(base_url=BASE_URL) as mock: + mock.get("/me").mock(side_effect=httpx.NetworkError("Connection refused")) + await _validate_startup() + assert server_module._startup_error is not None + assert "connection" in server_module._startup_error.lower() or "network" in server_module._startup_error.lower() + captured = capsys.readouterr() + assert "connection" in captured.err.lower() or "network" in captured.err.lower() + server_module._startup_error = None + + +def test_result_parsing_parts_from_code_artifact(): + data = { + "final_latest_valid": [ + { + "status": "completed", + "ok": True, + "glb_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "model_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "code_artifact": { + "content": ( + "import bpy\n" + "bpy.ops.mesh.primitive_cube_add()\n" + "obj = bpy.context.active_object\n" + "obj.name = \"body\"\n" + "bpy.ops.mesh.primitive_cylinder_add()\n" + "wheel = bpy.context.active_object\n" + "wheel.name = \"wheel_fr\"\n" + ) + }, + } + ] + } + result = GenerationResult.from_api(data, WORKFLOW_ID) + assert result.parts == ["body", "wheel_fr"] + + +def test_result_parsing_parts_api_field_takes_precedence(): + data = { + "final_latest_valid": [ + { + "status": "completed", + "ok": True, + "glb_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "model_artifact": {"url": "https://nova3d.xyz/assets/abc123.glb"}, + "code_artifact": { + "content": 'obj.name = "should_not_appear"' + }, + "parts": ["door", "frame"], + } + ] + } + result = GenerationResult.from_api(data, WORKFLOW_ID) + assert result.parts == ["door", "frame"] + + +@pytest.mark.asyncio +async def test_generate_sends_conversation_id(mock_api): + """When conversation_id is provided, _start_workflow includes it in the request body.""" + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(202, json=_start_ok()) + + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json=_readiness_ok()) + ) + mock_api.post("/run/state/sketch_to_3d_v2").mock(side_effect=capture_and_respond) + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_status_completed()) + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + await client.generate( + prompt="a toaster", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + conversation_id="conv-abc123", + ) + + client_module.asyncio.sleep = original_sleep + + assert len(captured_requests) == 1 + parsed = json.loads(captured_requests[0].content) + assert parsed["conversation"]["conversation_id"] == "conv-abc123" + assert parsed["conversation"]["relation_type"] == "initial_generation" + assert parsed["conversation"]["link_metadata"]["operation"] == "sketch_to_3d_v2" + assert parsed["payload"]["code_llm_profile"] == "nova3d_code_generation" + assert parsed["payload"]["code_llm_tier"] == "gemini_3_1_pro_google" + assert parsed["return_nodes"] == [ + "final_validated_correction", + "final_latest_valid", + "fail_generation", + ] + + +@pytest.mark.asyncio +async def test_generate_omits_conversation_when_none(mock_api): + """When no conversation_id, the conversation key is absent from the request body.""" + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(202, json=_start_ok()) + + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(200, json=_readiness_ok()) + ) + mock_api.post("/run/state/sketch_to_3d_v2").mock(side_effect=capture_and_respond) + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_status_completed()) + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + await client.generate( + prompt="a toaster", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", + ) + + client_module.asyncio.sleep = original_sleep + + parsed = json.loads(captured_requests[0].content) + assert "conversation" not in parsed + + +@pytest.mark.asyncio +async def test_regenerate_part_sends_edit_conversation_metadata(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(202, json=_start_ok()) + + mock_api.post("/run/state/regenerate_3d_part").mock(side_effect=capture_and_respond) + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_status_completed()) + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + await client.regenerate_part( + code_artifact={"content": "import bpy"}, + part_type="door", + description="glass door", + provider="gemini", + llm="gemini", + conversation_id="conv-abc123", + ) + + client_module.asyncio.sleep = original_sleep + + parsed = json.loads(captured_requests[0].content) + assert parsed["conversation"]["relation_type"] == "regenerate_3d_part" + assert parsed["conversation"]["link_metadata"]["operation"] == "regenerate_3d_part" + + +@pytest.mark.asyncio +async def test_add_part_sends_edit_conversation_metadata(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(202, json=_start_ok()) + + mock_api.post("/run/state/add_3d_part").mock(side_effect=capture_and_respond) + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_status_completed()) + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + await client.add_part( + code_artifact={"content": "import bpy"}, + description="chrome handle", + provider="gemini", + llm="gemini", + conversation_id="conv-abc123", + ) + + client_module.asyncio.sleep = original_sleep + + parsed = json.loads(captured_requests[0].content) + assert parsed["conversation"]["relation_type"] == "add_3d_part" + assert parsed["conversation"]["link_metadata"]["operation"] == "add_3d_part" + + +@pytest.mark.asyncio +async def test_articulate_model_sends_edit_conversation_metadata(mock_api): + captured_requests = [] + + def capture_and_respond(request, route): + captured_requests.append(request) + return httpx.Response(202, json=_start_ok()) + + mock_api.post("/run/state/articulate_3d_model").mock(side_effect=capture_and_respond) + mock_api.get(f"/status/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_status_completed()) + ) + mock_api.get(f"/result/{WORKFLOW_ID}").mock( + return_value=httpx.Response(200, json=_result_ok()) + ) + + async with Nova3DClient(token=FAKE_TOKEN, base_url=BASE_URL) as client: + import nova3d_mcp.client as client_module + original_sleep = client_module.asyncio.sleep + client_module.asyncio.sleep = lambda _: original_sleep(0) + + await client.articulate_model( + code_artifact={"content": "import bpy"}, + articulation_request="make the door swing", + provider="gemini", + llm="gemini", + model_url="https://nova3d.xyz/assets/abc123.glb", + conversation_id="conv-abc123", + ) + + client_module.asyncio.sleep = original_sleep + + parsed = json.loads(captured_requests[0].content) + assert parsed["conversation"]["relation_type"] == "articulate_model" + assert parsed["conversation"]["link_metadata"]["operation"] == "articulate_3d_model" + + +def test_result_parsing_v2_corrected_output(): + result = GenerationResult.from_api(_result_corrected_ok(), WORKFLOW_ID) + assert result.failed is False + assert result.glb_url == "https://nova3d.xyz/assets/corrected.glb" + assert result.model_artifact["url"] == "https://nova3d.xyz/assets/corrected.glb" + + +def test_status_progress_label_for_v2_node(): + from nova3d_mcp.models import WorkflowStatus + + status = WorkflowStatus.from_api( + WORKFLOW_ID, + { + "runtime": {"state": "running", "last_exit_node_id": None}, + "node_visit_seq": {"validation_llm": 1}, + }, + ) + assert status.progress_label == "Reviewing the generated model..." diff --git a/mcp/tests/test_loopback.py b/mcp/tests/test_loopback.py new file mode 100644 index 0000000..3c14651 --- /dev/null +++ b/mcp/tests/test_loopback.py @@ -0,0 +1,69 @@ +import asyncio + +import pytest + +from nova3d_mcp.loopback import LoopbackServer, _render_success_page + + +def test_render_success_page_uses_neutral_client_fallback(): + html = _render_success_page(None) + + assert "Nova3D connected" in html + assert "Your local Nova3D connection is ready." in html + assert "return to your MCP client now" in html + assert "check Nova3D status again from your MCP client" in html + + +def test_render_success_page_uses_explicit_client_name(): + html = _render_success_page("Claude Code") + + assert "Nova3D connected to Claude Code" in html + assert "Your local Nova3D connection is ready in Claude Code." in html + assert "return to Claude Code now" in html + assert "check Nova3D status again from Claude Code" in html + + +@pytest.mark.asyncio +async def test_loopback_server_captures_callback_and_serves_styled_page(): + server = LoopbackServer(client_name="Codex") + + reader = asyncio.StreamReader() + reader.feed_data( + ( + "GET /?code=session-123&state=state-abc HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Connection: close\r\n\r\n" + ).encode("utf-8") + ) + reader.feed_eof() + + class DummyWriter: + def __init__(self) -> None: + self.buffer = bytearray() + + def write(self, data: bytes) -> None: + self.buffer.extend(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + return None + + async def wait_closed(self) -> None: + return None + + writer = DummyWriter() + + await server._handle_connection(reader, writer) + + callback = await server.wait_for_callback(0.2) + + response_text = writer.buffer.decode("utf-8") + assert "HTTP/1.1 200 OK" in response_text + assert "Nova3D connected to Codex" in response_text + assert "Your local Nova3D connection is ready in Codex." in response_text + assert "finished its local connection step on this machine" in response_text + assert "return to Codex now" in response_text + assert callback.code == "session-123" + assert callback.state == "state-abc" diff --git a/mcp/tests/test_server.py b/mcp/tests/test_server.py new file mode 100644 index 0000000..d4134c8 --- /dev/null +++ b/mcp/tests/test_server.py @@ -0,0 +1,762 @@ +""" +tests/test_server.py +──────────────────────────────────────────────────────────────── +Tests for server-level startup error propagation, setup tool, +and progress callback behaviour. +──────────────────────────────────────────────────────────────── +""" +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +import nova3d_mcp.server as server_module +from nova3d_mcp.auth import Nova3DLoginError, PendingLogin +from nova3d_mcp.models import WorkflowStatus, WorkflowState + + +@pytest.fixture(autouse=True) +def reset_startup_error(): + """Reset _startup_error before and after every test.""" + server_module._startup_error = None + server_module._pending_login = None + yield + server_module._startup_error = None + server_module._pending_login = None + + +@pytest.fixture(autouse=True) +def isolate_session_store(tmp_path, monkeypatch): + monkeypatch.setenv( + "NOVA3D_SESSION_PATH", + str(tmp_path / "mcp-session.json"), + ) + + +@pytest.fixture(autouse=True) +def allow_generation_readiness_by_default(monkeypatch): + monkeypatch.setattr( + server_module, + "_require_generation_ready", + AsyncMock(return_value=None), + ) + + +@pytest.mark.asyncio +async def test_generate_3d_returns_error_when_startup_failed(): + server_module._startup_error = ( + "Your Nova3D API key is invalid. " + "Check or replace it at https://app.nova3d.xyz/api-key" + ) + result = await server_module.generate_3d(prompt="a chair") + assert result["failed"] is True + assert "app.nova3d.xyz/api-key" in result["error_message"] + + +@pytest.mark.asyncio +async def test_regenerate_part_returns_error_when_startup_failed(): + server_module._startup_error = ( + "Your Nova3D API key is invalid. " + "Check or replace it at https://app.nova3d.xyz/api-key" + ) + result = await server_module.regenerate_part( + code_artifact={}, + part_type="door", + description="glass door", + ) + assert result["failed"] is True + assert "app.nova3d.xyz/api-key" in result["error_message"] + + +@pytest.mark.asyncio +async def test_add_part_returns_error_when_startup_failed(): + server_module._startup_error = ( + "Your Nova3D API key is invalid. " + "Check or replace it at https://app.nova3d.xyz/api-key" + ) + result = await server_module.add_part( + code_artifact={}, + description="a handle", + ) + assert result["failed"] is True + assert "app.nova3d.xyz/api-key" in result["error_message"] + + +@pytest.mark.asyncio +async def test_articulate_model_returns_error_when_startup_failed(): + server_module._startup_error = ( + "Your Nova3D API key is invalid. " + "Check or replace it at https://app.nova3d.xyz/api-key" + ) + result = await server_module.articulate_model( + code_artifact={}, + model_url="https://nova3d.xyz/assets/abc.glb", + articulation_request="make door swing", + ) + assert result["failed"] is True + assert "app.nova3d.xyz/api-key" in result["error_message"] + + +@pytest.mark.asyncio +async def test_get_generation_status_returns_error_when_startup_failed(): + server_module._startup_error = "Your Nova3D API key is invalid. Check or replace it at https://app.nova3d.xyz/api-key" + result = await server_module.get_generation_status(workflow_id="state-123") + assert result["failed"] is True + assert "app.nova3d.xyz/api-key" in result["error_message"] + + +@pytest.mark.asyncio +async def test_generate_3d_proceeds_when_no_startup_error(monkeypatch): + """Regression guard: no startup error means the tool runs normally, not short-circuited by the flag.""" + import respx + import httpx + + server_module._startup_error = None + monkeypatch.setenv("NOVA3D_TOKEN", "fake-token") + + with respx.mock(base_url="https://nova3d.xyz/api", assert_all_called=False) as mock: + mock.get("/mcp/status").mock( + return_value=httpx.Response( + 200, + json={ + "authenticated": True, + "identity": {"user_id": "u1", "email": "user@example.com", "tenant_id": "ten_1"}, + "mcp_session": {"established": False, "expires_at": None}, + "credits": {"balance": 10, "reserved": 0, "available": 10, "funded": True}, + "generation_ready": True, + "next_action": None, + "next_action_url": None, + }, + ) + ) + mock.get("/workflow/readiness/sketch_to_3d_v2").mock( + return_value=httpx.Response(401, json={"detail": {"code": "invalid_api_key", "message": "bad key"}}) + ) + mock.post("/conversations").mock( + return_value=httpx.Response(201, json={"id": "conv-test"}) + ) + from nova3d_mcp.client import Nova3DAuthError + with pytest.raises(Nova3DAuthError): + await server_module.generate_3d(prompt="a chair") + + +@pytest.mark.asyncio +async def test_generate_3d_creates_conversation_and_returns_url(monkeypatch): + """generate_3d creates a conversation and embeds its ID in code_artifact.""" + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + monkeypatch.delenv("NOVA3D_APP_URL", raising=False) + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = ["body", "door"] + fake_result.joint_count = 0 + fake_result.joints = [] + fake_result.code_artifact = {"content": "import bpy"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.workflow_id = "wf-123" + fake_result.api_key_source = "request" + + mock_client = AsyncMock() + mock_client.create_conversation = AsyncMock(return_value="conv-xyz") + mock_client.generate = AsyncMock(return_value=fake_result) + mock_client.update_conversation_snapshot = AsyncMock(return_value=None) + mock_client.append_conversation_message = AsyncMock( + side_effect=["remote-user", "remote-assistant"] + ) + mock_client.link_workflow_to_message = AsyncMock(return_value=None) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.generate_3d( + prompt="a washing machine", + ) + + assert result["failed"] is False + assert result["conversation_url"] == "https://app.nova3d.xyz/chat/conv-xyz" + assert result["history_persisted"] is True + assert result["code_artifact"]["_nova3d_conversation_id"] == "conv-xyz" + assert result["code_artifact"]["_nova3d_prompt"] == "a washing machine" + mock_client.create_conversation.assert_called_once_with(title="a washing machine") + mock_client.generate.assert_called_once() + mock_client.update_conversation_snapshot.assert_called_once() + assert mock_client.append_conversation_message.call_count == 2 + mock_client.link_workflow_to_message.assert_called_once() + snapshot_messages = mock_client.update_conversation_snapshot.call_args.kwargs["messages"] + assert snapshot_messages[0]["role"] == "user" + assert snapshot_messages[0]["text"] == "a washing machine" + assert snapshot_messages[1]["role"] == "assistant" + assert snapshot_messages[1]["model_url"] == fake_result.glb_url + assert snapshot_messages[1]["code_artifact"]["_nova3d_conversation_id"] == "conv-xyz" + call_kwargs = mock_client.generate.call_args.kwargs + assert call_kwargs["conversation_id"] == "conv-xyz" + assert call_kwargs["code_llm_profile"] == "nova3d_code_generation" + assert call_kwargs["code_llm_tier"] == "gemini_3_1_pro_google" + assert call_kwargs["image_artifact"] is None + + +@pytest.mark.asyncio +async def test_generate_3d_uses_configured_app_url(monkeypatch): + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + monkeypatch.setenv("NOVA3D_APP_URL", "http://127.0.0.1:5555") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = [] + fake_result.joint_count = 0 + fake_result.joints = [] + fake_result.code_artifact = {"content": "import bpy"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.workflow_id = "wf-local" + fake_result.api_key_source = None + + mock_client = AsyncMock() + mock_client.create_conversation = AsyncMock(return_value="conv-local") + mock_client.generate = AsyncMock(return_value=fake_result) + mock_client.update_conversation_snapshot = AsyncMock(return_value=None) + mock_client.append_conversation_message = AsyncMock( + side_effect=["remote-user", "remote-assistant"] + ) + mock_client.link_workflow_to_message = AsyncMock(return_value=None) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.generate_3d(prompt="a local model") + + assert result["conversation_url"] == "http://127.0.0.1:5555/chat/conv-local" + + +@pytest.mark.asyncio +async def test_generate_3d_conversation_failure_does_not_block_generation(monkeypatch): + """If create_conversation raises, generation still proceeds and conversation_url is absent.""" + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = ["body"] + fake_result.joint_count = 0 + fake_result.joints = [] + fake_result.code_artifact = {"content": "import bpy"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.workflow_id = "wf-123" + fake_result.api_key_source = "request" + + from nova3d_mcp.client import Nova3DError + mock_client = AsyncMock() + mock_client.create_conversation = AsyncMock(side_effect=Nova3DError("network error")) + mock_client.generate = AsyncMock(return_value=fake_result) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.generate_3d( + prompt="a chair", + ) + + assert result["failed"] is False + assert result["code_artifact"].get("_nova3d_prompt") == "a chair" + assert "conversation_url" not in result + assert result["history_persisted"] is False + assert "_nova3d_conversation_id" not in result.get("code_artifact", {}) + mock_client.generate.assert_called_once() + assert mock_client.generate.call_args.kwargs["conversation_id"] is None + + +# ── Edit tool conversation propagation tests ────────────────────────────────── + +@pytest.mark.asyncio +async def test_regenerate_part_propagates_conversation_id(monkeypatch): + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = ["body", "door"] + fake_result.code_artifact = {"content": "import bpy # updated"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.joints = [] + fake_result.workflow_id = "wf-456" + fake_result.api_key_source = "request" + + mock_client = AsyncMock() + mock_client.regenerate_part = AsyncMock(return_value=fake_result) + mock_client.append_conversation_message = AsyncMock(return_value="remote-edit") + mock_client.link_workflow_to_message = AsyncMock(return_value=None) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.regenerate_part( + code_artifact={ + "content": "import bpy", + "_nova3d_conversation_id": "conv-xyz", + "_nova3d_prompt": "original prompt", + }, + part_type="door", + description="glass door with chrome frame", + ) + + assert result["failed"] is False + assert result["conversation_url"] == "https://app.nova3d.xyz/chat/conv-xyz" + assert result["history_persisted"] is True + assert result["code_artifact"]["_nova3d_conversation_id"] == "conv-xyz" + assert result["code_artifact"]["_nova3d_prompt"] == "original prompt" + mock_client.append_conversation_message.assert_called_once() + edit_message = mock_client.append_conversation_message.call_args.args[1] + assert edit_message["message_type"] == "asset_version" + assert edit_message["operation"] == "regenerate_3d_part" + call_kwargs = mock_client.regenerate_part.call_args.kwargs + assert call_kwargs["conversation_id"] == "conv-xyz" + + +@pytest.mark.asyncio +async def test_regenerate_part_no_conversation_id_in_artifact(monkeypatch): + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = ["body"] + fake_result.code_artifact = {"content": "import bpy"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.joints = [] + fake_result.workflow_id = "wf-456" + fake_result.api_key_source = "request" + + mock_client = AsyncMock() + mock_client.regenerate_part = AsyncMock(return_value=fake_result) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.regenerate_part( + code_artifact={"content": "import bpy"}, + part_type="door", + description="glass door", + ) + + assert result["failed"] is False + assert "conversation_url" not in result + assert result["history_persisted"] is False + assert "_nova3d_conversation_id" not in result.get("code_artifact", {}) + call_kwargs = mock_client.regenerate_part.call_args.kwargs + assert call_kwargs.get("conversation_id") is None + + +@pytest.mark.asyncio +async def test_add_part_propagates_conversation_id(monkeypatch): + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/abc.glb" + fake_result.parts = ["body", "handle"] + fake_result.code_artifact = {"content": "import bpy # with handle"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.joints = [] + fake_result.workflow_id = "wf-789" + fake_result.api_key_source = "request" + + mock_client = AsyncMock() + mock_client.add_part = AsyncMock(return_value=fake_result) + mock_client.append_conversation_message = AsyncMock(return_value="remote-edit") + mock_client.link_workflow_to_message = AsyncMock(return_value=None) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.add_part( + code_artifact={"content": "import bpy", "_nova3d_conversation_id": "conv-xyz"}, + description="a chrome handle bar", + ) + + assert result["conversation_url"] == "https://app.nova3d.xyz/chat/conv-xyz" + assert result["code_artifact"]["_nova3d_conversation_id"] == "conv-xyz" + assert result["history_persisted"] is True + call_kwargs = mock_client.add_part.call_args.kwargs + assert call_kwargs["conversation_id"] == "conv-xyz" + + +# ── nova3d_setup tests ──────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_nova3d_setup_returns_url_and_command(): + result = await server_module.nova3d_setup() + assert "next step is not generation yet" in result["instructions"] + assert "Call nova3d_login from inside your MCP client" in result["instructions"] + assert "nova3d_login" in result["instructions"] + assert "nova3d_status" in result["instructions"] + assert "browser tab" in result["instructions"] + assert "claude mcp add nova3d" in result["instructions"] + + +@pytest.mark.asyncio +async def test_nova3d_setup_available_when_startup_error_set(): + """Setup instructions must be reachable even with no token configured.""" + server_module._startup_error = "NOVA3D_TOKEN is not set." + result = await server_module.nova3d_setup() + assert "nova3d_login" in result["instructions"] + assert "next step is not generation yet" in result["instructions"] + + +@pytest.mark.asyncio +async def test_nova3d_login_returns_status_recovery_on_ambiguous_completion(): + loop = asyncio.get_running_loop() + task = loop.create_future() + task.set_exception( + Nova3DLoginError( + "Nova3D browser sign-in was opened successfully, but the local MCP callback was not confirmed yet.", + browser_url="https://app.nova3d.xyz/mcp/connect?state=abc&port=5555", + should_check_status=True, + ) + ) + mock_auth = MagicMock() + mock_auth.begin_login = AsyncMock( + return_value=PendingLogin( + connect_url="https://app.nova3d.xyz/mcp/connect?state=abc&port=5555", + port=5555, + task=task, + ) + ) + + with patch("nova3d_mcp.server.Nova3DAuthenticator", return_value=mock_auth): + result = await server_module.nova3d_login() + + assert result["failed"] is True + assert result["browser_url"].startswith("https://app.nova3d.xyz/mcp/connect") + assert result["suggested_next_step"] == "call nova3d_status" + assert "retry nova3d_login" in result["recovery_instructions"] + assert "manual_fallback_available" not in result + + +@pytest.mark.asyncio +async def test_nova3d_login_marks_manual_fallback_only_for_loopback_unavailable(): + mock_auth = MagicMock() + mock_auth.begin_login = AsyncMock( + side_effect=Nova3DLoginError( + "Nova3D could not start the local callback listener needed for browser sign-in.", + manual_fallback_only=True, + ) + ) + + with patch("nova3d_mcp.server.Nova3DAuthenticator", return_value=mock_auth): + result = await server_module.nova3d_login() + + assert result["failed"] is True + assert result["manual_fallback_available"] is True + + +@pytest.mark.asyncio +async def test_nova3d_login_returns_pending_payload_when_background_auth_in_progress(): + loop = asyncio.get_running_loop() + task = loop.create_future() + mock_auth = MagicMock() + mock_auth.begin_login = AsyncMock( + return_value=PendingLogin( + connect_url="https://app.nova3d.xyz/mcp/connect?state=abc&port=5555", + port=5555, + task=task, + ) + ) + + with patch("nova3d_mcp.server.Nova3DAuthenticator", return_value=mock_auth): + result = await server_module.nova3d_login() + + assert result["login_started"] is True + assert result["login_pending_confirmation"] is True + assert result["suggested_next_step"] == "call nova3d_status" + assert result["browser_url"].startswith("https://app.nova3d.xyz/mcp/connect") + task.cancel() + + +@pytest.mark.asyncio +async def test_nova3d_status_reflects_pending_login(): + loop = asyncio.get_running_loop() + task = loop.create_future() + server_module._pending_login = server_module.PendingLoginState( + connect_url="https://app.nova3d.xyz/mcp/connect?state=abc&port=5555", + port=5555, + task=task, + ) + + status = MagicMock() + status.authenticated = False + status.generation_ready = False + status.next_action = "sign_in" + status.next_action_url = "https://nova3d.xyz/mcp/connect" + status.user_message = "Sign in to Nova3D to continue." + status.identity = None + status.credits = None + status.mcp_session = MagicMock() + status.mcp_session.model_dump.return_value = {"established": False, "expires_at": None} + + with patch("nova3d_mcp.server._get_mcp_status", AsyncMock(return_value=status)): + result = await server_module.nova3d_status() + + assert result["login_pending_confirmation"] is True + assert result["browser_url"].startswith("https://app.nova3d.xyz/mcp/connect") + assert result["suggested_next_step"] == "call nova3d_status" + task.cancel() + + +@pytest.mark.asyncio +async def test_nova3d_status_returns_backend_status_payload(): + status = MagicMock() + status.authenticated = True + status.generation_ready = False + status.next_action = "purchase_credits" + status.next_action_url = "https://nova3d.xyz/mcp/no-credits" + status.user_message = "Buy credits before generating." + status.identity = MagicMock() + status.identity.model_dump.return_value = {"email": "user@example.com"} + status.credits = MagicMock() + status.credits.model_dump.return_value = {"available": 0, "funded": False} + status.mcp_session = MagicMock() + status.mcp_session.model_dump.return_value = {"established": True, "expires_at": "2026-09-10T14:32:00Z"} + + with patch("nova3d_mcp.server._get_mcp_status", AsyncMock(return_value=status)): + result = await server_module.nova3d_status() + + assert result["authenticated"] is True + assert result["next_action"] == "purchase_credits" + assert result["next_action_url"] == "https://nova3d.xyz/mcp/no-credits" + assert result["identity"]["email"] == "user@example.com" + + +@pytest.mark.asyncio +async def test_nova3d_logout_clears_local_session(monkeypatch, tmp_path): + monkeypatch.setenv("NOVA3D_SESSION_PATH", str(tmp_path / "session.json")) + monkeypatch.delenv("NOVA3D_TOKEN", raising=False) + + store = server_module._get_session_store() + store.save_token("n3d_test_session") + + result = await server_module.nova3d_logout() + + assert result["logged_out"] is True + assert result["cleared_local_session"] is True + assert store.load_token() is None + + +@pytest.mark.asyncio +async def test_nova3d_status_includes_stored_session_hint(monkeypatch, tmp_path): + monkeypatch.setenv("NOVA3D_SESSION_PATH", str(tmp_path / "session.json")) + store = server_module._get_session_store() + store.save_session("n3d_test_session", "2026-06-13T12:00:00Z") + + status = MagicMock() + status.authenticated = True + status.generation_ready = True + status.next_action = None + status.next_action_url = None + status.user_message = "Nova3D is ready." + status.identity = None + status.credits = None + status.mcp_session = MagicMock() + status.mcp_session.model_dump.return_value = {"established": True, "expires_at": "2026-06-13T12:00:00Z"} + + with patch("nova3d_mcp.server._get_mcp_status", AsyncMock(return_value=status)): + result = await server_module.nova3d_status() + + assert result["stored_session_expires_at"] == "2026-06-13T12:00:00Z" + assert "session_reauth_recommended" in result + + +def test_session_store_round_trips_expires_at(tmp_path): + from nova3d_mcp.session_store import SessionStore + + store = SessionStore(tmp_path / "session.json") + store.save_session("n3d_test_session", "2026-09-10T14:32:00Z") + + assert store.load_token() == "n3d_test_session" + assert store.load_expires_at() == "2026-09-10T14:32:00Z" + + +@pytest.mark.asyncio +async def test_generate_3d_blocks_when_purchase_required(): + with patch( + "nova3d_mcp.server._require_generation_ready", + AsyncMock( + return_value={ + "failed": True, + "error_message": "Your Nova3D account is connected, but you need credits before generating.", + "next_action": "purchase_credits", + "next_action_url": "https://nova3d.xyz/mcp/no-credits", + } + ), + ): + result = await server_module.generate_3d(prompt="a chair") + + assert result["failed"] is True + assert result["next_action"] == "purchase_credits" + + +@pytest.mark.asyncio +async def test_validate_startup_without_any_token_does_not_set_error(monkeypatch, tmp_path): + monkeypatch.delenv("NOVA3D_TOKEN", raising=False) + monkeypatch.setenv("NOVA3D_SESSION_PATH", str(tmp_path / "missing.json")) + server_module._startup_error = "old" + + await server_module._validate_startup() + + assert server_module._startup_error is None + + +# ── Progress callback tests ─────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_progress_callback_reports_new_node(): + ctx = MagicMock() + ctx.report_progress = AsyncMock() + + callback = server_module._make_progress_callback(ctx) + status = WorkflowStatus( + workflow_id="state-123", + state=WorkflowState.RUNNING, + last_exit_node="blender_code_generator", + ) + await callback(status) + + ctx.report_progress.assert_called_once_with( + progress=1, total=None, message="Completed: blender_code_generator" + ) + + +@pytest.mark.asyncio +async def test_progress_callback_deduplicates_same_node(): + ctx = MagicMock() + ctx.report_progress = AsyncMock() + + callback = server_module._make_progress_callback(ctx) + status = WorkflowStatus( + workflow_id="state-123", + state=WorkflowState.RUNNING, + last_exit_node="blender_code_generator", + ) + await callback(status) + await callback(status) # same node — should not fire again + + ctx.report_progress.assert_called_once() + + +@pytest.mark.asyncio +async def test_progress_callback_reports_each_new_node(): + ctx = MagicMock() + ctx.report_progress = AsyncMock() + + callback = server_module._make_progress_callback(ctx) + for node in ["blender_code_generator", "mesh_validator", "glb_exporter"]: + await callback(WorkflowStatus( + workflow_id="state-123", + state=WorkflowState.RUNNING, + last_exit_node=node, + )) + + assert ctx.report_progress.call_count == 3 + messages = [call.kwargs["message"] for call in ctx.report_progress.call_args_list] + assert messages == [ + "Completed: blender_code_generator", + "Completed: mesh_validator", + "Completed: glb_exporter", + ] + + +@pytest.mark.asyncio +async def test_progress_callback_no_ctx_does_not_raise(): + """Regression guard: ctx=None (direct test calls) must not raise.""" + callback = server_module._make_progress_callback(None) + status = WorkflowStatus( + workflow_id="state-123", + state=WorkflowState.RUNNING, + last_exit_node="blender_code_generator", + ) + await callback(status) # should not raise + + +@pytest.mark.asyncio +async def test_progress_callback_skips_status_with_no_node(): + ctx = MagicMock() + ctx.report_progress = AsyncMock() + + callback = server_module._make_progress_callback(ctx) + status = WorkflowStatus( + workflow_id="state-123", + state=WorkflowState.RUNNING, + ) + await callback(status) # no node — nothing to report + + ctx.report_progress.assert_not_called() + + +@pytest.mark.asyncio +async def test_generate_3d_invalid_model(): + """Passing an unknown model name returns a helpful failed response immediately.""" + result = await server_module.generate_3d( + prompt="a chair", + model="bad-model", + ) + assert result["failed"] is True + assert "bad-model" in result["error_message"] + assert "gemini" in result["error_message"] + + +@pytest.mark.asyncio +async def test_articulate_model_with_model_artifact(monkeypatch): + """articulate_model accepts model_artifact in place of model_url.""" + monkeypatch.setenv("NOVA3D_TOKEN", "n3d_testkey") + monkeypatch.setenv("NOVA3D_API_URL", "https://nova3d.xyz/api") + + fake_result = MagicMock() + fake_result.failed = False + fake_result.glb_url = "https://nova3d.xyz/assets/articulated.glb" + fake_result.joints = [{"name": "door_hinge"}] + fake_result.joint_count = 1 + fake_result.code_artifact = {"content": "import bpy"} + fake_result.model_artifact = None + fake_result.joints_artifact = None + fake_result.workflow_id = "wf-art" + fake_result.api_key_source = None + + mock_client = AsyncMock() + mock_client.articulate_model = AsyncMock(return_value=fake_result) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + artifact = {"url": "https://nova3d.xyz/assets/abc.glb", "id": "art-123"} + + with patch("nova3d_mcp.server.Nova3DClient", return_value=mock_client): + result = await server_module.articulate_model( + code_artifact={"content": "import bpy"}, + articulation_request="make door swing", + model_artifact=artifact, + ) + + assert result["failed"] is False + call_kwargs = mock_client.articulate_model.call_args.kwargs + assert call_kwargs["model_artifact"] == artifact + assert call_kwargs["model_url"] is None + + +@pytest.mark.asyncio +async def test_articulate_model_neither_url_nor_artifact(): + """articulate_model without model_url or model_artifact returns a clear error.""" + result = await server_module.articulate_model( + code_artifact={"content": "import bpy"}, + articulation_request="make door swing", + ) + assert result["failed"] is True + assert "model_url" in result["error_message"] or "model_artifact" in result["error_message"]