diff --git a/README.md b/README.md index 062c0bd..91e056d 100644 --- a/README.md +++ b/README.md @@ -54,23 +54,12 @@ currently ship an embedded IDE-native 3D viewport. ## Quickstart -### 1. Get an API key - -Get an API key at: https://app.nova3d.xyz/api-key - -```bash -export NOVA3D_TOKEN="n3d_your-api-key-here" -``` - -API keys never expire unless revoked. The MCP server validates your key on startup -and prints a clear error if it's missing or invalid. - -### 2. Install in your MCP client +### 1. Install in your MCP client #### Codex ```bash -codex mcp add nova3d --env NOVA3D_TOKEN=n3d_your-api-key-here -- uvx nova3d-mcp +codex mcp add nova3d -- uvx nova3d-mcp ``` Codex also supports MCP configuration through `~/.codex/config.toml`. If you @@ -80,7 +69,7 @@ at the same stdio command: `uvx nova3d-mcp`. #### Claude Code ```bash -claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-api-key-here -- uvx nova3d-mcp +claude mcp add nova3d -- uvx nova3d-mcp ``` #### Cursor @@ -93,10 +82,7 @@ install: "mcpServers": { "nova3d": { "command": "uvx", - "args": ["nova3d-mcp"], - "env": { - "NOVA3D_TOKEN": "n3d_your-api-key-here" - } + "args": ["nova3d-mcp"] } } } @@ -107,7 +93,7 @@ install: Option A: add the server from the command line: ```bash -code --add-mcp "{\"name\":\"nova3d\",\"command\":\"uvx\",\"args\":[\"nova3d-mcp\"],\"env\":{\"NOVA3D_TOKEN\":\"n3d_your-api-key-here\"}}" +code --add-mcp "{\"name\":\"nova3d\",\"command\":\"uvx\",\"args\":[\"nova3d-mcp\"]}" ``` Option B: create `.vscode/mcp.json` in your workspace: @@ -117,10 +103,7 @@ Option B: create `.vscode/mcp.json` in your workspace: "servers": { "nova3d": { "command": "uvx", - "args": ["nova3d-mcp"], - "env": { - "NOVA3D_TOKEN": "n3d_your-api-key-here" - } + "args": ["nova3d-mcp"] } } } @@ -137,18 +120,14 @@ Create `/.mcp.json` or `%USERPROFILE%/.mcp.json`: "servers": { "nova3d": { "command": "uvx", - "args": ["nova3d-mcp"], - "env": { - "NOVA3D_TOKEN": "n3d_your-api-key-here" - } + "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"]` and the `NOVA3D_TOKEN` -environment variable. +stdio command `uvx` with args `["nova3d-mcp"]`. ### 3. Generate @@ -192,10 +171,11 @@ The agent calls `generate_3d`. You get back: ## 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. - 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. + and inject `NOVA3D_TOKEN` per-user only for the advanced/manual fallback path. --- @@ -203,8 +183,9 @@ The agent calls `generate_3d`. You get back: | Problem | What to check | |---|---| -| `NOVA3D_TOKEN is not set` | Add `NOVA3D_TOKEN` to your MCP server environment and restart the client | -| Auth failure on startup | Confirm the key at https://app.nova3d.xyz/api-key | +| Prompted to sign in before generation | Call `nova3d_login`, then re-check with `nova3d_status` | +| 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 | @@ -215,12 +196,14 @@ The agent calls `generate_3d`. You get back: ### `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 | | `"gemini"` (default) · `"claude-sonnet"` · `"claude-opus"` · `"claude-opus-latest"` · `"gpt-5.5"` | -| `image_base64` | string | | Reference image as plain base64 | +| `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. @@ -281,6 +264,26 @@ Check the status of a running workflow by ID. --- +### `nova3d_login` + +Start the preferred browser-based Nova3D sign-in flow and store a local MCP session. + +--- + +### `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 ``` @@ -320,7 +323,7 @@ All edit tools accept the `code_artifact` from any prior result and return an up | Variable | Required | Description | |---|---|---| -| `NOVA3D_TOKEN` | ✓ | API key from https://app.nova3d.xyz/api-key (recommended) or session JWT | +| `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`) | diff --git a/docs/frontend-followup-note.md b/docs/frontend-followup-note.md new file mode 100644 index 0000000..1aa1c0a --- /dev/null +++ b/docs/frontend-followup-note.md @@ -0,0 +1,127 @@ +# Nova3D MCP Frontend Follow-Up Note + +Backend has now responded with a concrete preferred launch architecture, and we want to align frontend against that locked direction. + +## Locked Backend Direction + +Preferred launch auth flow: + +1. MCP starts a local loopback listener on an available port. +2. MCP opens browser to an MCP-aware Nova3D route with `state` and `port`. +3. User signs in through the normal Nova3D account flow. +4. Frontend calls `POST /mcp/session/create` with the user JWT. +5. Backend creates a short-lived one-time `session_code`. +6. Frontend redirects browser to the MCP loopback callback with `code` and original `state`. +7. MCP validates `state`, exchanges the `code` through `POST /mcp/session/exchange`, and stores the returned backend-issued `n3d_` credential locally. +8. MCP calls `GET /mcp/status` as the canonical onboarding/readiness contract. + +Important clarifications: + +- browser/web auth session and MCP local session are distinct +- the MCP local session is established through the one-time code exchange +- web and MCP should share one Nova3D account and one wallet +- `GET /mcp/status` is the central machine-readable state contract + +## Locked Backend Contract Highlights + +### `GET /mcp/status` + +This is the canonical MCP state contract. + +It always returns HTTP `200` with a parseable body for onboarding/readiness use cases. + +Key fields: + +- `authenticated` +- `identity` +- `mcp_session.established` +- `mcp_session.expires_at` +- `credits` +- `generation_ready` +- `next_action` +- `next_action_url` + +Locked `next_action` enum: + +- `null` +- `"sign_in"` +- `"session_expired"` +- `"purchase_credits"` +- `"service_unavailable"` + +### Checkout Context + +Backend preference is: + +- bounded checkout context via `source: "web" | "mcp"` +- not arbitrary caller-supplied success URLs + +## What We Need From Frontend Now + +Given that backend direction, we need frontend to align the route flow and user-facing browser journey. + +### Route / Flow Questions To Finalize + +Please confirm the preferred route set and naming. + +Current likely shape: + +- `/mcp/connect` +- MCP-aware login completion page such as `/mcp/ready` or `/mcp/complete` +- `/mcp/no-credits` +- MCP-aware purchase success page such as `/mcp/purchase-success` +- MCP-aware handling in OAuth callback + +We do not need exact names to match this note, but we do need one stable route model. + +### Specific Frontend Responsibilities + +We expect frontend to handle: + +- MCP-aware browser entry page +- already-signed-in fast path +- MCP-aware OAuth completion path +- post-login confirmation/readiness state +- explicit zero-credit state +- checkout initiation with `source: "mcp"` +- MCP-aware purchase return page +- explicit return-to-editor guidance + +### The Key Browser-State UX We Need + +After login: + +- show signed-in identity +- show whether MCP handoff is complete or in progress +- show whether credits are sufficient +- if funded, clearly say Nova3D is ready in the editor + +If zero credits: + +- clearly say the account is connected +- clearly say generation is not ready yet +- show current credits +- give primary purchase CTA + +After purchase: + +- confirm funding state +- give one clear instruction: + - return to your editor + - or close this tab and continue in your editor + +## Open Frontend Decisions + +Please respond with: + +1. the route names you want to standardize on +2. whether `/mcp/connect` should immediately auto-progress for already-signed-in users +3. where MCP-aware OAuth completion should branch from the current callback flow +4. whether the post-login “ready” page and post-purchase “success” page should be separate or merged +5. any UI/UX constraints that would change the recommended route model + +## Reference Docs + +- `/home/hassan/Desktop/nova3d-mcp/docs/mcp-auth-onboarding-master-spec.md` +- `/home/hassan/Desktop/nova3d-mcp/docs/mcp-auth-onboarding-handoff-summary.md` +- `/home/hassan/Desktop/nova3d-mcp/docs/mcp-auth-onboarding-implementation-plan.md` diff --git a/docs/mcp-auth-onboarding-handoff-summary.md b/docs/mcp-auth-onboarding-handoff-summary.md new file mode 100644 index 0000000..a783602 --- /dev/null +++ b/docs/mcp-auth-onboarding-handoff-summary.md @@ -0,0 +1,181 @@ +# Nova3D MCP Auth And Onboarding Handoff Summary + +## What This Is + +We are preparing the first public launch of the Nova3D MCP server. + +A current version of the MCP server already exists. It works like a typical developer integration: + +- user installs the MCP server locally +- user is told to fetch a Nova3D API key +- user puts that key into MCP config +- user only discovers missing credits later when generation fails + +We do **not** want to launch with that experience. + +## What We Want Instead + +We want the MCP onboarding flow to feel like a normal product, not a backend integration. + +Target experience: + +1. User installs Nova3D MCP in Codex, Cursor, Claude Code, VS Code, Visual Studio, or a similar client. +2. User is prompted to sign in to Nova3D through the browser. +3. The MCP server establishes and stores its own local Nova3D session after sign-in. +4. The MCP server checks: + - who the user is + - whether the session is valid + - whether MCP local-session handoff completed + - whether they have credits + - whether generation is ready +5. If the user has zero credits, they are told that clearly before first generation. +6. The user is routed into a simple purchase flow. +7. After purchase, the MCP server re-checks readiness. +8. Once funded, the user can generate normally. + +The intended public onboarding path is **account sign-in**, not **manual API key setup**. + +Preferred launch auth model: + +- browser sign-in +- local loopback callback +- one-time backend session-code exchange +- MCP stores a backend-issued `n3d_` credential locally +- MCP uses `GET /mcp/status` as the canonical onboarding/readiness state contract + +## Why We Are Doing This + +The MCP server is meant for broad editor/agent use. + +If users must: + +- learn what an API key is +- fetch a token manually +- paste it into MCP config +- then separately discover they also need credits + +the experience is too fragile and high-friction for launch. + +We want: + +- browser sign-in +- early credit awareness +- minimal confusion +- minimal “failed generation as onboarding” + +## Important Product Decisions Already Made + +- BYOK is out of scope for this launch flow. +- API keys should not be the primary onboarding path. +- We are optimizing for the smoothest first-time user experience. +- We do not need to preserve old onboarding for an existing user base. + +## What We Need From Backend + +We need backend support for a local MCP/CLI-style sign-in flow. + +At minimum, the MCP needs to be able to: + +- initiate login in the browser +- detect when login has completed +- establish an explicit secure MCP local-session handoff after browser auth +- store a local session credential +- validate that session later +- fetch account identity +- fetch credit/funding status +- tell whether the account is ready for paid generation +- re-check status after a purchase + +If feasible, we want a combined MCP onboarding/readiness status response instead +of forcing the client to stitch together many fragmented checks. + +Backend has now proposed that this combined state contract should be `GET /mcp/status`, +and that it should always return a parseable body rather than a generic 401 for +MCP onboarding/readiness use cases. + +The backend team does **not** need to accept a prescribed internal design. +We need them to help define the cleanest workable contract. + +Open backend design space includes: + +- the backend-preferred launch flow is local loopback + one-time session-code exchange +- stored local MCP credential is a backend-issued `n3d_` key +- API key internals remain hidden from the ordinary user-facing flow + +We care about the behavior more than the exact token format. + +## What We Need From Frontend + +We need the web/client side to support an MCP-aware auth and purchase journey. + +At minimum, the frontend should help support: + +- a browser login flow initiated by MCP +- a clear “you are now signed in” completion state +- a clear “MCP session handoff complete” state or checkpoint +- a clear “your account is connected but you have 0 credits” state +- a simple purchase path for first-time MCP users +- a useful post-purchase confirmation state + +The frontend should assume that these users may have come from an editor and may not know Nova3D’s billing/auth model yet. + +We want the frontend experience to explain: + +- your account is connected +- credits are required for generation +- here is the next action +- then return to your editor + +We also expect the frontend flow to use one shared Nova3D account and one shared +wallet, not separate “web” and “MCP” balances. + +Current frontend recommendation is to standardize on: + +- `/mcp/connect` +- `/mcp/complete` +- `/mcp/no-credits` +- `/mcp/purchase-success` + +with MCP-aware branching from the shared `/oauth-callback` route. + +## What We Need From Both Teams + +We need agreement on the shared user journey and shared contracts. + +We do **not** want to dictate repo-level implementation details. + +We do want all teams aligned on: + +- how login is initiated +- how login completion is detected +- how browser auth establishes MCP local authenticated state +- how the MCP loopback callback and one-time code exchange complete +- how the MCP learns account identity +- how the MCP learns credit state +- how the MCP detects purchase completion or purchase state refresh +- how auth failures differ from no-credit failures + +## What The MCP Team Will Handle + +On the MCP side, we will handle: + +- login-first onboarding behavior +- local session storage +- user-facing status/setup messages inside the MCP flow +- readiness checks before generation +- generation behavior once the backend/frontend contracts exist + +## What We Are Asking For Right Now + +Please read the master spec and respond with: + +1. whether the target experience is sound +2. what auth/purchase completion flow you think is most practical in your repo +3. what contracts/endpoints/pages you think are needed +4. what constraints or pitfalls we should account for before implementation + +## Master Spec + +Full shared spec: + +[mcp-auth-onboarding-master-spec.md](/home/hassan/Desktop/nova3d-mcp/docs/mcp-auth-onboarding-master-spec.md) diff --git a/docs/mcp-auth-onboarding-implementation-plan.md b/docs/mcp-auth-onboarding-implementation-plan.md new file mode 100644 index 0000000..56b7a40 --- /dev/null +++ b/docs/mcp-auth-onboarding-implementation-plan.md @@ -0,0 +1,379 @@ +# Nova3D MCP Auth And Onboarding Implementation Plan + +## Purpose + +This is the MCP-repo implementation plan against the currently locked cross-system contract. + +It assumes: + +- browser-first onboarding +- local loopback callback +- one-time backend session-code exchange +- locally stored backend-issued `n3d_` credential +- `GET /mcp/status` as the canonical onboarding/readiness contract + +This plan is intentionally scoped to the MCP repo only. + +## Locked External Assumptions + +### Backend + +Assumed backend contracts: + +- `POST /mcp/session/create` +- `POST /mcp/session/exchange` +- `GET /mcp/status` + +Assumed `GET /mcp/status` response semantics: + +- always returns HTTP `200` with a parseable body for MCP onboarding/readiness use +- expresses machine-readable `next_action` +- distinguishes: + - `sign_in` + - `session_expired` + - `purchase_credits` + - `service_unavailable` + - `null` meaning ready + +### Frontend + +Assumed frontend routing shape: + +- `/mcp/connect` +- `/mcp/complete` +- `/mcp/no-credits` +- `/mcp/purchase-success` +- shared `/oauth-callback` with MCP-aware branching + +The MCP should still treat backend-provided `next_action_url` as authoritative +for routing decisions where possible. + +## Goals In This Repo + +1. Replace API-key-first onboarding as the primary MCP flow. +2. Support login-first onboarding through browser sign-in and loopback callback. +3. Persist a local MCP session credential. +4. Use `GET /mcp/status` to drive onboarding and readiness state. +5. Surface clear user-facing messages for sign-in, no credits, expired session, and service unavailability. +6. Preserve advanced/manual fallback for environments where loopback/browser flow is unavailable. + +## Non-Goals In This Repo + +- Do not redesign GraphFlow generation payloads as part of this work. +- Do not add BYOK support. +- Do not redesign edit workflows beyond auth/readiness integration if not needed. +- Do not require frontend/backend implementation details to match this repo’s internal abstractions. + +## Workstreams + +### 1. Local Session Storage + +Add a local credential store for the MCP’s backend-issued `n3d_` credential. + +Requirements: + +- persistent across runs +- clearable via logout +- readable for all authenticated MCP requests +- able to represent missing vs present credential state + +Implementation choices left open: + +- config file under user home +- existing Codex/CLI-compatible local config path +- OS keychain if desired later + +Launch recommendation: + +- simple file-backed storage with restrictive permissions is acceptable if secure enough for a local developer tool + +### 2. Browser Login Flow + +Add MCP-side login orchestration: + +1. Generate random `state` nonce. +2. Start loopback listener on an available local port. +3. Open browser to the Nova3D MCP connect route with `state` and `port`. +4. Receive loopback callback containing `code` and `state`. +5. Validate `state`. +6. Exchange `code` through `POST /mcp/session/exchange`. +7. Store returned `n3d_` credential locally. +8. Call `GET /mcp/status`. + +Key behaviors: + +- retry a small port range or bind random available port +- detect bind failure cleanly +- timeout gracefully if browser flow never completes +- never store or expose the one-time code after exchange +- distinguish auth-completion failure from post-purchase polling behavior + +### 3. Status Client + +Add a dedicated client path for `GET /mcp/status`. + +This should become the MCP’s source of truth for: + +- whether the user is signed in +- whether the MCP local session is established +- current account identity +- current funding state +- generation readiness +- next recommended user action + +The status client should not force callers to infer onboarding state from: + +- generic `401`s +- `/me` +- scattered billing endpoints +- generation failures + +### 4. Onboarding State Machine + +Introduce an explicit MCP-side state model driven by `GET /mcp/status`. + +Required states: + +- not signed in +- signing in +- signed in / status unknown +- signed in / zero credits +- signed in / funded / ready +- session expired +- service unavailable +- manual fallback required + +The state machine should map directly from the backend contract rather than from ad hoc error parsing. + +### 5. User-Facing Messaging + +Update setup and readiness messaging so the MCP says: + +- sign in to Nova3D +- connected as X +- credits available Y +- buy credits before first generation +- session expired, sign in again +- service unavailable, try again later + +Avoid leading with: + +- `NOVA3D_TOKEN` +- API-key copy/paste +- generic auth failures during first-run setup + +### 6. Manual / Advanced Fallback + +Preserve an advanced/manual path for environments where the loopback flow fails. + +Examples: + +- browser cannot be opened +- local port bind fails +- sandbox/container restrictions + +The fallback does not need to be the primary UX, but it must be clearly explained. + +At minimum, the MCP should: + +- detect loopback failure +- surface a clear message +- point to the advanced/manual path + +### 7. Generation Preconditions + +Before `generate_3d`, the MCP should consult stored session state or call `GET /mcp/status`. + +Behavior: + +- if `next_action == "sign_in"`: initiate or instruct sign-in +- if `next_action == "session_expired"`: instruct re-login +- if `next_action == "purchase_credits"`: do not proceed to generation; direct user to the purchase URL +- if `next_action == "service_unavailable"`: surface service outage +- if `next_action == null` and `generation_ready == true`: proceed + +### 8. Logout / Session Clearing + +Add MCP-side logout behavior. + +Minimum launch behavior: + +- clear locally stored credential +- confirm user is disconnected locally + +Optional future enhancement: + +- call backend revocation for the MCP-issued key if a convenient contract exists + +## Concrete File-Level Plan + +### `nova3d_mcp/client.py` + +Add support for: + +- `GET /mcp/status` +- `POST /mcp/session/exchange` + +Potentially add a separate auth/onboarding client helper if keeping the existing generation client cleaner is preferable. + +### `nova3d_mcp/server.py` + +Add or update tooling/surfaces for: + +- login +- logout +- status +- setup/help text + +Update generation preflight so it consults the status contract before attempting paid generation. + +### New auth/onboarding module(s) + +Likely introduce a new module for: + +- loopback server lifecycle +- browser launch orchestration +- state nonce generation/validation +- local credential store +- onboarding state transitions + +Suggested shape: + +- `nova3d_mcp/auth.py` +- `nova3d_mcp/session_store.py` +- `nova3d_mcp/loopback.py` + +Exact naming is flexible. + +### Tests + +Add tests for: + +- missing local credential +- login callback success +- state mismatch rejection +- session-code exchange success +- `GET /mcp/status` parsing for each `next_action` +- generation blocked on `purchase_credits` +- generation blocked on `session_expired` +- loopback bind failure fallback messaging +- auth completion path that reaches `/mcp/complete` +- purchase refresh path that relies on status polling after `/mcp/purchase-success` + +## Suggested Tool / Surface Changes + +This repo should likely evolve toward these user-visible setup surfaces: + +- `nova3d_setup` +- `nova3d_status` +- `nova3d_login` +- `nova3d_logout` + +Exact MCP exposure can be decided later, but the functionality should exist. + +If keeping the number of MCP tools minimal is important, login/status/logout can be implemented as CLI-level commands plus a richer `nova3d_setup` tool. + +## Test Strategy + +### Unit Tests + +- credential storage +- state nonce generation/validation +- status response parsing +- preflight decision logic + +### Integration Tests + +- browser login callback path with mocked backend exchange +- status-driven no-credit path +- status-driven expired-session path +- ready path allows generation + +### Manual Verification + +At minimum verify: + +- install in local MCP client +- browser opens +- login completes +- local credential persists +- zero-credit state blocks generation cleanly +- funded state enables generation +- expired session re-prompts login +- loopback bind failure produces advanced/manual guidance + +## Sequencing + +### Phase 1: Contract Client + +1. Add `GET /mcp/status` client and response models. +2. Add tests for all `next_action` values. + +### Phase 2: Local Session Plumbing + +3. Add local credential storage. +4. Add auth header injection from stored credential. + +### Phase 3: Login Flow + +5. Add loopback listener and browser launch orchestration. +6. Add `POST /mcp/session/exchange` client path. +7. Add end-to-end mocked login-flow tests. + +### Phase 4: Setup / UX Surfaces + +8. Update setup/help messaging. +9. Add login/status/logout surfaces. + +### Phase 5: Generation Gating + +10. Gate paid generation on `GET /mcp/status`. +11. Ensure no-credit and expired-session states are surfaced before generation. + +### Phase 6: Manual Fallback + +12. Add clear bind-failure/browser-failure fallback messaging. + +## Risks + +### Loopback Environment Risk + +Some environments may not permit: + +- opening a browser +- binding a local loopback port +- routing browser callback back to the MCP process + +Mitigation: + +- keep advanced/manual fallback +- surface bind/open failures clearly + +### Contract Drift Risk + +If backend changes `GET /mcp/status` shape during implementation, MCP logic could drift. + +Mitigation: + +- treat the backend response shape as locked +- add fixture-based tests + +### Session Lifetime Risk + +If backend expiry semantics change, MCP re-auth behavior may break. + +Mitigation: + +- rely on `session_expired` / `expires_at` from status contract +- avoid duplicating expiry logic locally + +## Deliverables + +This repo should ultimately deliver: + +- login-first onboarding flow +- locally stored MCP session credential +- `GET /mcp/status`-driven state handling +- no-credit pre-generation blocking +- explicit expired-session handling +- advanced/manual fallback guidance diff --git a/docs/mcp-auth-onboarding-master-spec.md b/docs/mcp-auth-onboarding-master-spec.md new file mode 100644 index 0000000..8959a80 --- /dev/null +++ b/docs/mcp-auth-onboarding-master-spec.md @@ -0,0 +1,766 @@ +# Nova3D MCP Auth And Onboarding Master Spec + +## Goal + +Design a smooth first-time Nova3D MCP experience for users in Codex, Cursor, +Claude Code, VS Code, Visual Studio, and similar MCP clients. + +The intended experience is: + +1. User installs the Nova3D MCP server. +2. User is prompted to sign in to Nova3D immediately or on first meaningful use. +3. Browser-based sign-in completes without requiring the user to copy an API key. +4. The MCP server stores a local Nova3D session credential. +5. The MCP server checks account identity, generation readiness, and credit status. +6. If the user has zero credits, the system routes them into a simple purchase flow + before first generation. +7. Once funded, generation works without further auth setup. + +This spec is a shared product-and-contract document. It is not a repo-specific +implementation plan. + +## Scope + +This spec covers: + +- Nova3D MCP onboarding and auth UX +- backend support required for browser sign-in and session validation +- frontend support required for login, post-login confirmation, and no-credit purchase flow +- shared contracts and state transitions between MCP, backend, and frontend + +This spec does not prescribe: + +- exact file-level code changes in the Flutter or backend repos +- internal storage mechanism details beyond required behavior +- BYOK onboarding +- a full refactor of existing generation/edit workflows beyond what is needed for auth and onboarding + +## Non-Goals + +- Do not make raw API-key setup the primary onboarding path. +- Do not include BYOK in the launch onboarding flow. +- Do not require users to understand Nova3D backend credential mechanics. +- Do not force frontend/backend teams into a specific internal architecture where contract behavior is sufficient. + +## Product Principles + +- Account-first, not API-key-first. +- Browser sign-in is the primary onboarding method. +- Credit readiness should be surfaced before first paid generation failure. +- The user should understand their current state at all times: + not signed in, signed in but unfunded, funded and ready, session expired, backend unavailable. +- The MCP server should remain compatible with local stdio installation across editors. + +## Primary User Journey + +### Install + +1. User adds the Nova3D MCP server in their editor or agent client. +2. Installation should not require the user to fetch or paste a Nova3D API key. + +### First Startup / First Use + +3. The MCP server detects whether a valid local Nova3D session exists. +4. If no valid session exists, the MCP server prompts the user to sign in to Nova3D. +5. The MCP server launches a browser sign-in flow. + +### Sign-In + +6. The user signs in through the Nova3D web experience. +7. On success, the MCP server receives or retrieves a local session credential + through an explicit secure handoff. +8. The MCP server stores that credential locally and securely. + +The preferred launch implementation is: + +1. MCP starts a local loopback HTTP listener on an available port. +2. MCP opens the browser to an MCP-aware Nova3D route with a `state` nonce and local port. +3. After browser auth, the frontend creates a short-lived one-time session code through the backend. +4. The frontend redirects the browser to the MCP loopback callback with the one-time code and original `state`. +5. MCP validates `state`, exchanges the one-time code with the backend, and receives a locally stored `n3d_` credential. + +### Immediate Readiness Check + +9. After sign-in, the MCP server checks: + - account identity + - session validity + - current credit balance + - MCP local-session handoff state + - generation readiness +10. The MCP server reports a clear status to the user. + +### If Credits Are Zero + +11. The MCP server should not wait for first generation to fail. +12. It should tell the user that Nova3D credits are required for generation. +13. It should direct the user into a simple purchase flow. +14. After purchase, the MCP server should be able to re-check readiness cleanly. + +### First Successful Generation + +15. Once the user is funded and ready, `generate_3d` works without further auth setup. + +### Returning User + +16. Returning users should not be asked to sign in again if the local session is still valid. +17. If the session is expired or invalid, the MCP server should ask the user to sign in again. + +## Alternate / Advanced Path + +Raw API-key auth may continue to exist as an advanced or internal path for: + +- automation +- CI +- debugging +- non-interactive environments + +It is not the default onboarding path and should not dominate public docs or setup flows. + +## User States + +The system should treat the following as explicit user states. + +### State: Not Signed In + +Meaning: +- no local Nova3D session is stored +- or the stored session is missing and cannot be refreshed + +Expected MCP behavior: +- prompt sign-in +- offer login action +- do not tell the user to fetch an API key + +### State: Signing In + +Meaning: +- browser login flow has been initiated + +Expected MCP behavior: +- show that Nova3D sign-in is in progress +- provide retry/cancel guidance if completion does not occur + +### State: Signed In, Status Unknown + +Meaning: +- session exists +- readiness, handoff state, or credit status has not yet been fully confirmed + +Expected MCP behavior: +- validate session +- fetch account identity, funding state, and MCP handoff/readiness state + +### State: Signed In, Zero Credits + +Meaning: +- identity is valid +- session is valid +- credits are insufficient for paid generation + +Expected MCP behavior: +- clearly say generation requires Nova3D credits +- offer billing/purchase next step +- avoid waiting for generation failure to surface this + +### State: Signed In, Funded, Ready + +Meaning: +- identity is valid +- credits are sufficient +- backend readiness check passes + +Expected MCP behavior: +- generation can proceed normally + +### State: Session Expired + +Meaning: +- locally stored session is no longer valid + +Expected MCP behavior: +- tell the user they must sign in again +- offer a re-login path + +### State: Backend Unavailable + +Meaning: +- login or readiness service is temporarily unavailable + +Expected MCP behavior: +- show a service-availability message +- distinguish this from invalid login or insufficient credits + +## Shared UX Requirements + +### Onboarding Messaging + +The default user-facing setup message should describe: + +- Nova3D requires a Nova3D account sign-in +- paid generation requires Nova3D credits +- sign-in happens through the browser + +It should not lead with: + +- raw token environment variables +- API key copy/paste instructions + +### Post-Login Messaging + +After login, the user should see: + +- signed-in identity +- current credit state +- whether MCP local-session setup completed +- whether the system is ready to generate + +Example shape: + +- Connected as `user@example.com` +- Credits available: `0` +- Buy credits to start generating 3D assets + +### Purchase Messaging + +The no-credit state should guide the user into a minimal, intentional purchase flow. + +The message should feel like: + +- setup complete +- account connected +- credits required for generation + +It should avoid looking like: + +- a broken generation attempt +- a generic auth failure +- an API-key issue + +### Return-To-Editor Messaging + +The completion pages in the browser flow must give one clear next instruction. + +Examples: + +- Return to your editor now +- You can close this tab and continue in Codex +- Nova3D is ready in your editor + +## MCP Requirements + +### Required Behavior + +The MCP server must: + +- support browser-based Nova3D sign-in as the primary onboarding method +- store a local user session credential +- validate session state +- report signed-in identity +- report credit status +- report readiness state +- prompt the user to buy credits before first paid generation if credits are zero +- re-prompt for sign-in when the session expires + +The MCP server must not assume that an existing browser session alone is enough. +It needs its own explicit authenticated local state after browser login completes. + +### Suggested MCP Commands / Surfaces + +The MCP product should support the equivalent of: + +- `nova3d login` +- `nova3d logout` +- `nova3d status` + +These may be implemented as CLI commands, MCP-exposed setup/status tools, or both. + +### Setup Tooling + +The MCP setup surface should evolve from API-key instructions to account-based onboarding. + +The setup surface should be able to communicate: + +- not signed in +- signed in as X +- MCP local session established or not +- credits available Y +- ready / not ready + +### Generation Preconditions + +Before `generate_3d`, the MCP server should be able to determine: + +- whether the session is valid +- whether the account is funded +- whether the generation service is ready + +The intent is to prevent avoidable first-generation failures caused by known lack of credits. + +### Credential Storage + +The MCP server must store its local session credential securely enough for a desktop developer tool. + +This spec does not require a specific storage library or mechanism, but it requires: + +- persistence across sessions +- ability to revoke or clear the session +- no need for the user to manually re-enter credentials every run + +For launch, the preferred local credential is a backend-issued `n3d_` Nova3D key +obtained through the browser login handoff, not a browser JWT copied into local config. + +### API Key Fallback + +If API-key auth remains available as an advanced path, it should: + +- be treated as secondary +- not dominate public onboarding docs +- not be required for ordinary users + +## Backend Requirements + +### Auth Flow Support + +The backend must support a browser-based sign-in flow suitable for a local MCP or CLI client. + +Acceptable implementation patterns include: + +- redirect-based login completion +- device-code style flow +- browser login followed by local session polling or exchange + +This spec does not mandate one auth-flow shape, but it does require that: + +- the local MCP can initiate login +- the user can complete login in browser +- the MCP can reliably detect login completion +- the browser login can establish an explicit local MCP-authenticated session through a secure handoff + +Preferred launch auth flow: + +- local loopback callback +- one-time session code exchange +- backend-issued `n3d_` MCP credential persisted locally by the MCP server + +### Session Model + +The backend must support a session credential that the MCP can use for: + +- identity lookup +- credit status lookup +- readiness checks +- generation/status/result calls + +The backend team may choose whether this credential is: + +- JWT-based +- refresh-token based +- opaque-token based +- derived from an existing auth/session model + +This spec cares about behavior, not token format. + +The backend must treat: + +- browser/web auth session +- MCP local session + +as related but distinct states. The browser may already know the user, but the +MCP still requires a deliberate local authenticated state. + +For launch, the recommended backend session model is: + +- browser/web auth for the human sign-in flow +- one-time `session_code` stored server-side with short TTL +- one-time code exchange into a local `n3d_` credential for MCP use + +### Required Backend Capabilities + +The backend must support the logical equivalent of: + +- start login +- complete login +- establish MCP local-session handoff +- validate current session +- get current user/account identity +- get current credit balance or paid-generation funding status +- support purchase success refresh / re-check + +If feasible, the backend should provide a combined MCP onboarding/readiness +status response instead of requiring the client to combine many fragmented +checks. That response should ideally cover: + +- authenticated or not +- identity +- MCP handoff/session established or not +- credit balance or funded state +- generation readiness +- recommended next action + +Recommended backend primitives for launch: + +- `POST /mcp/session/create` +- `POST /mcp/session/exchange` +- `GET /mcp/status` + +### Generation Auth + +The backend must define how MCP-authenticated generation requests are authorized. + +The user-facing model should be: + +- the generation runs under the signed-in Nova3D account +- credits are consumed from that account + +The implementation may use: + +- direct session-bound auth +- scoped backend-issued token exchange +- another internal authorization model + +### Readiness / Billing Awareness + +The backend should make it possible for the MCP to distinguish: + +- invalid/expired session +- zero credits / insufficient paid-generation funding +- service unavailable +- generation readiness false for non-billing reasons + +`GET /mcp/status` is the canonical MCP state contract and should return a +machine-readable body even for unauthenticated and expired-session states. + +### Purchase State Refresh + +The backend should make it easy for the MCP to re-check readiness after the user completes a purchase. + +This can be implemented by: + +- explicit re-check endpoint use +- polling +- callback-linked flow + +The spec does not mandate the mechanism. + +The backend should also support MCP-aware completion handling for: + +- OAuth/login completion +- Stripe/purchase completion + +## Frontend Requirements + +### MCP-Aware Login Flow + +The Nova3D frontend should support a login journey that works well when initiated by a local MCP process. + +It should clearly communicate: + +- that the user is connecting Nova3D to their editor/agent +- when login is complete +- what the user should do next + +The recommended approach is an MCP-aware route flow inside the existing Nova3D +frontend, not a separate site and not a separate auth system. + +The preferred route model currently is: + +- `/mcp/connect` +- `/mcp/complete` +- `/mcp/no-credits` +- `/mcp/purchase-success` + +The current frontend recommendation is: + +- keep `/oauth-callback` as the shared OAuth landing route +- branch into MCP mode there when auth originated from `/mcp/connect` +- keep MCP pages outside the normal authenticated app shell so they can manage transitional states cleanly + +### Post-Login Confirmation + +After browser login, the frontend should support a clear completion state that can tell the user: + +- you are signed in +- the editor connection can continue +- whether MCP session handoff is ready +- whether credits are sufficient + +If feasible, this page should also help the MCP detect completion cleanly. + +### No-Credits Experience + +The frontend should support an MCP-aware no-credits state that makes purchase the obvious next step. + +It should clearly communicate: + +- your account is connected +- you need credits to generate +- here is the purchase action + +The no-credit state should show, where appropriate: + +- account email +- current credits +- short explanation that credits are account-wide and required before generation + +### Purchase Flow + +The Stripe purchase path should be optimized for first-time MCP users where possible. + +The desired experience is: + +- minimal confusion +- minimal navigation detours +- clear return path back to the editor flow + +Use the same Nova3D credit packages and the same shared wallet unless there is +some hard technical reason not to. The preferred launch model is one shared +account wallet across web and MCP, not channel-specific balances. + +Checkout initiation should use a bounded MCP/web source signal, not arbitrary +caller-provided success URLs. + +### Post-Purchase Confirmation + +After successful purchase, the frontend should support a confirmation state that allows the MCP to re-check readiness smoothly. + +The page should ideally communicate: + +- Nova3D is ready in your editor +- updated credit balance +- clear instruction to return to the editor or close the tab + +### Deep Links / Polling / Completion Strategy + +The frontend and backend teams should jointly decide the cleanest completion mechanism for: + +- login completion +- purchase completion + +This spec does not mandate whether that is: + +- polling +- callback URL +- local loopback redirect +- browser message relay + +The frontend and backend should aim to avoid brittle fragmented polling if a +combined onboarding/readiness status flow is feasible. + +## Shared Contracts + +The following cross-system contracts need to exist, whether formalized as HTTP endpoints, app routes, or another integration shape. + +### Contract: Login Initiation + +MCP must be able to initiate a browser login flow. + +### Contract: Login Completion + +MCP must be able to determine that login has completed successfully or failed. + +### Contract: MCP Local-Session Handoff + +MCP must be able to establish its own authenticated local session after browser +login completes. + +### Contract: Session Validation + +MCP must be able to validate whether the current local session is valid. + +### Contract: Identity Lookup + +MCP must be able to obtain account identity for user-facing status messaging. + +### Contract: Credit Status + +MCP must be able to determine whether the user is funded for paid generation. + +The preferred model is a shared Nova3D account wallet across app and MCP. + +### Contract: Purchase Refresh + +MCP must be able to determine whether a purchase has changed the user's funded state. + +### Contract: Generation Authorization + +Generation requests must run under the signed-in Nova3D account without requiring raw API-key setup. + +### Contract: Canonical MCP Status + +`GET /mcp/status` is the canonical state contract for MCP onboarding and readiness. + +Preferred launch behavior: + +- always return `200` with a parseable state body +- encode unauthenticated, expired-session, zero-credit, and service-unavailable + states in the response body instead of making the MCP infer them from generic auth failures + +Preferred response shape: + +```json +{ + "authenticated": true, + "identity": { + "user_id": "...", + "email": "user@example.com", + "tenant_id": "ten_..." + }, + "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": null, + "next_action_url": null +} +``` + +Preferred `next_action` enum: + +- `null` +- `"sign_in"` +- `"session_expired"` +- `"purchase_credits"` +- `"service_unavailable"` + +## Acceptance Criteria + +The end-to-end system is acceptable when all of the following are true. + +### Install And Sign-In + +- A new user can install Nova3D MCP without obtaining a raw API key. +- A new user is prompted to sign in through the browser. +- A signed-in session persists locally for later use. +- Browser sign-in completion is not treated as sufficient until MCP local-session handoff is complete. +- MCP can derive all onboarding and readiness state from `GET /mcp/status`. + +### No-Credits Path + +- A signed-in but unfunded user is told they need credits before first paid generation. +- The system directs them to a purchase path without requiring a failed generation first. +- After purchase, the MCP can re-check and recognize funded state. +- The browser purchase flow gives explicit return-to-editor guidance. + +### Funded Path + +- A signed-in funded user can generate successfully without any manual token setup. + +### Expired Session + +- An expired session produces a re-login prompt, not a confusing generation failure. + +### Availability Failures + +- Service unavailability is distinguishable from auth failure and billing failure. + +## Regression Strategy + +Regression prevention should be layered rather than purely unit-test-driven. + +### Contract Tests + +Test the cross-system contracts for: + +- login completion +- session validation +- identity lookup +- credit status lookup +- purchase refresh + +### Unit Tests + +Each repo should cover its own internal auth/session logic. + +### Integration Tests + +Recommended integration scenarios: + +- first-time login success +- first-time login abandoned/failed +- browser login success but MCP local-session handoff incomplete/failing +- signed-in zero-credit state +- purchase then readiness refresh +- funded generation path +- expired session path +- loopback listener bind failure with manual fallback guidance + +### Manual Acceptance Checklist + +A shared manual checklist should cover: + +- Codex +- Cursor +- Claude Code +- VS Code +- Visual Studio + +At minimum, validate: + +- browser opens +- sign-in completes +- status becomes connected +- zero-credit message appears when appropriate +- purchase path works +- post-purchase generation works + +## Recommended Team Workflow + +This master spec should be shared with: + +- MCP owner +- backend owner(s) +- frontend owner(s) + +Recommended process: + +1. Agree on the desired user journey and state machine. +2. Agree on the minimum required shared contracts. +3. Let each owning team derive its own implementation plan from this spec. +4. Re-converge on acceptance criteria and test coverage. + +The frontend and backend teams should use their own repo knowledge to design implementations that satisfy this spec without being forced into dogmatic internal structures. + +## Open Questions + +These must be resolved before implementation is locked. + +1. What exact purchase completion detection mechanism should the MCP use for launch? + Current backend recommendation is polling against `GET /mcp/status` after purchase initiation. Confirm whether that is the final launch behavior. + +2. What should frontend and MCP do if browser auth succeeds but loopback handoff is delayed or cannot complete immediately? + Clarify whether any polling-style fallback applies to auth completion, or whether polling is purchase-refresh-only and auth handoff failure should route users into explicit retry/manual guidance. + +3. Should sign-in be prompted immediately at install/startup, or first time the server is actually invoked by the client? + Product preference currently leans toward immediate or near-immediate prompting. + +4. How prominently, if at all, should the advanced API-key path remain documented? + +## Rollout Recommendation + +Recommended rollout order: + +1. Finalize shared contracts and auth-flow choice. +2. Backend implements login/session/credit-status capabilities. +3. Frontend implements MCP-aware login and no-credit purchase experience. +4. MCP server implements login-first onboarding and status checks. +5. Cross-system integration testing. +6. Editor/client manual verification. + +## Summary + +The intended launch experience is: + +- install Nova3D MCP +- sign in through browser +- immediately understand whether the account is ready +- if no credits, purchase credits before first generation +- once funded, generate without token plumbing + +This is the intended product shape for launch. API-key-first setup is not. diff --git a/llms.txt b/llms.txt index 83cc168..927d090 100644 --- a/llms.txt +++ b/llms.txt @@ -13,17 +13,25 @@ Most AI 3D generators produce diffusion-based mesh blobs that look good in rende Nova3D is available as an MCP (Model Context Protocol) server. Install it with: ``` -claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-key -- uvx nova3d-mcp +claude mcp add nova3d -- uvx nova3d-mcp ``` -Get an API key at: https://app.nova3d.xyz/api-key +Preferred onboarding after install: + +- call `nova3d_login` to sign in through the browser +- call `nova3d_status` to confirm credits and readiness + +Advanced/manual fallback: set `NOVA3D_TOKEN` if you cannot complete the browser flow. Full documentation and source: https://github.com/RareSense/nova3d-mcp ## Tools available via MCP - `nova3d_setup` - get setup instructions if the user hasn't configured a token yet -- `generate_3d` - generate a structured GLB from a text prompt or reference image; returns glb_url, conversation_url, named parts list, and code_artifact +- `nova3d_login` - start the preferred browser-based Nova3D sign-in flow and store a local MCP session +- `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 @@ -42,10 +50,10 @@ Always pass the `code_artifact` from the most recent result into the next edit c ## Authentication -Nova3D requires one credential: `NOVA3D_TOKEN` — a Nova3D API key set in your MCP server config. Get one at https://app.nova3d.xyz/api-key +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"`. Omit to use Gemini. +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/nova3d_mcp/auth.py b/nova3d_mcp/auth.py new file mode 100644 index 0000000..d54a194 --- /dev/null +++ b/nova3d_mcp/auth.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import secrets +import webbrowser +from dataclasses import dataclass +from typing import 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 + + +@dataclass +class LoginResult: + token: str + expires_at: Optional[str] + status: MCPStatus + connect_url: str + port: int + + +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: + state = secrets.token_urlsafe(32) + loopback = LoopbackServer() + try: + port = await loopback.start() + except OSError as e: + raise Nova3DError( + "Nova3D sign-in could not start a local callback listener. " + "Use the advanced NOVA3D_TOKEN setup path in this environment." + ) from e + + connect_url = self._build_connect_url(state=state, port=port) + opened = webbrowser.open(connect_url) + if not opened: + await loopback.close() + raise Nova3DError( + "Nova3D could not open your browser automatically. " + f"Open this URL manually: {connect_url}" + ) + + try: + callback = await loopback.wait_for_callback(LOGIN_TIMEOUT_SECONDS) + finally: + await loopback.close() + + if callback.state != state: + raise Nova3DError("Nova3D sign-in callback state mismatch. Try signing in again.") + if not callback.code: + raise Nova3DError("Nova3D sign-in callback did not include a session code.") + + async with Nova3DClient(token=None, base_url=self._base_url) as client: + exchange = await client.exchange_mcp_session(callback.code) + + self._session_store.save_session(exchange.token, exchange.expires_at) + + 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}" diff --git a/nova3d_mcp/client.py b/nova3d_mcp/client.py index 3ee367e..27dbc0b 100644 --- a/nova3d_mcp/client.py +++ b/nova3d_mcp/client.py @@ -20,6 +20,8 @@ from nova3d_mcp.models import ( GenerationReadiness, GenerationResult, + MCPSessionExchange, + MCPStatus, WorkflowStatus, ) @@ -27,7 +29,7 @@ NOVA3D_API_BASE = "https://nova3d.xyz/api" -WORKFLOW_SKETCH_TO_3D = "sketch_to_3d" +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" @@ -92,7 +94,7 @@ class Nova3DClient: def __init__( self, - token: str, + token: Optional[str], base_url: str = NOVA3D_API_BASE, ): self._token = token @@ -100,6 +102,11 @@ def __init__( 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( @@ -108,10 +115,7 @@ async def __aenter__(self) -> "Nova3DClient": write=30.0, pool=5.0, ), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self._token}", - }, + headers=headers, ) return self @@ -130,10 +134,9 @@ async def check_readiness(self) -> GenerationReadiness: async def generate( self, prompt: str, - provider: str, - llm: str, - image_base64: Optional[str] = None, - image_mime: Optional[str] = None, + 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: @@ -147,20 +150,27 @@ async def generate( payload: Dict[str, Any] = { "prompt": prompt.strip(), - "llm": llm, - "provider": provider, - "validate": False, + "code_llm_profile": code_llm_profile, + "code_llm_tier": code_llm_tier, } - if image_base64: - payload["image_base64"] = image_base64 - if image_mime: - payload["image_mime"] = image_mime + 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_node="sketch_to_3d_generator", + 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) @@ -190,8 +200,13 @@ async def regenerate_part( workflow_id = await self._start_workflow( workflow=WORKFLOW_REGENERATE_PART, payload=payload, - return_node="regenerate_3d_part", + 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) @@ -217,8 +232,13 @@ async def add_part( workflow_id = await self._start_workflow( workflow=WORKFLOW_ADD_PART, payload=payload, - return_node="add_3d_part", + 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) @@ -254,8 +274,13 @@ async def articulate_model( workflow_id = await self._start_workflow( workflow=WORKFLOW_ARTICULATE, payload=payload, - return_node="articulate_3d_model", + 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) @@ -276,6 +301,27 @@ 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( @@ -352,20 +398,25 @@ async def _start_workflow( self, workflow: str, payload: Dict[str, Any], - return_node: str, + 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_node], + "return_nodes": return_nodes, } if conversation_id: - body["conversation"] = { + conversation: Dict[str, Any] = { "conversation_id": conversation_id, - "relation_type": "triggered_by", + "relation_type": relation_type, } + if link_metadata: + conversation["link_metadata"] = link_metadata + body["conversation"] = conversation try: resp = await self._post( f"/run/state/{workflow}", @@ -541,6 +592,14 @@ def _parse_auth_error(resp: httpx.Response) -> tuple[Optional[str], str]: ) +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 ( diff --git a/nova3d_mcp/loopback.py b/nova3d_mcp/loopback.py new file mode 100644 index 0000000..8ce5780 --- /dev/null +++ b/nova3d_mcp/loopback.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +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) -> 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 + + @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 = ( + "

Nova3D connected

" + "

You can return to your editor now.

" + ) + 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() diff --git a/nova3d_mcp/models.py b/nova3d_mcp/models.py index b429d66..87611a9 100644 --- a/nova3d_mcp/models.py +++ b/nova3d_mcp/models.py @@ -55,10 +55,33 @@ def is_terminal(self) -> bool: ) -TERMINAL_NODES = {"success_final", "success_original_glb", "failed_final"} +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...", @@ -125,13 +148,80 @@ def user_message(self) -> str: 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", ] @@ -168,7 +258,7 @@ def from_api( unwrapped = _unwrap_result(payload) glb_url = _extract_glb_url(unwrapped) - model_artifact = _extract_map(unwrapped, ["model_artifact", "model"]) + model_artifact = _extract_map(unwrapped, ["model_artifact", "model", "glb_artifact"]) code_artifact = _extract_map( unwrapped, ["code_artifact", "source_code_artifact", "input_code_artifact"], @@ -223,7 +313,7 @@ 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_artifact", "model"]: + for key in ["model", "model_artifact", "glb_artifact"]: artifact = unwrapped.get(key) if isinstance(artifact, dict): u = artifact.get("url") diff --git a/nova3d_mcp/server.py b/nova3d_mcp/server.py index 58f2980..f2b0a72 100644 --- a/nova3d_mcp/server.py +++ b/nova3d_mcp/server.py @@ -7,7 +7,7 @@ callable from Claude Code, Cursor, and any MCP-compatible agent. Configuration (environment variables): - NOVA3D_TOKEN — JWT from nova3d.xyz (required) + 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) @@ -22,19 +22,21 @@ import asyncio import os import sys +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 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 -from nova3d_mcp.models import WorkflowStatus +from nova3d_mcp.models import GenerationResult, MCPStatus, WorkflowStatus +from nova3d_mcp.session_store import SessionStore load_dotenv() @@ -48,27 +50,37 @@ "gemini": { "provider": "gemini", "llm": "gemini", - "option_id": "gemini_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", - "option_id": "anthropic_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", - "option_id": "anthropic_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", - "option_id": "anthropic_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", - "option_id": "openai_gpt55", + "code_llm_profile": "nova3d_code_generation", + "code_llm_tier": "gpt_5_5_openrouter", + "option_id": "credits_gpt_5_5_openrouter", }, } _DEFAULT_MODEL = "gemini" @@ -90,6 +102,9 @@ def _resolve_model(model: Optional[str]) -> Optional[Dict[str, str]]: "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" @@ -97,11 +112,11 @@ def _resolve_model(model: Optional[str]) -> Optional[Dict[str, str]]: "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: This server requires one credential:\n" - "NOVA3D_TOKEN — a Nova3D API key. If the user has not set this, " - "proactively tell them: 'To use Nova3D, you need an API key. " - "Get one at https://app.nova3d.xyz/api-key, then run: " - "claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-key -- uvx nova3d-mcp'\n" + "SETUP:\n" + "1. Preferred: Call nova3d_login to sign in through the browser.\n" + "2. Check nova3d_status to confirm credits and readiness before generation.\n" + "3. 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." ), @@ -110,15 +125,26 @@ def _resolve_model(model: Optional[str]) -> Optional[Dict[str, str]]: # ── Auth helper ─────────────────────────────────────────────────────────────── -def _get_token() -> str: +def _get_session_store() -> SessionStore: + return SessionStore() + + +def _get_manual_token() -> Optional[str]: token = os.environ.get("NOVA3D_TOKEN", "").strip() - if not token: - raise Nova3DError( - "NOVA3D_TOKEN is not set. " - "Get an API key at https://app.nova3d.xyz/api-key " - "and add it with: claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-key -- uvx nova3d-mcp" - ) - return token + 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: @@ -130,20 +156,12 @@ def _get_app_url() -> str: async def _validate_startup() -> None: - """Validate NOVA3D_TOKEN against GET /api/me. Stores error in _startup_error instead of exiting.""" + """Validate an existing configured credential if one is present.""" global _startup_error - token = os.environ.get("NOVA3D_TOKEN", "").strip() + token = _get_session_store().load_token() or _get_manual_token() if not token: - _startup_error = ( - "NOVA3D_TOKEN is not set. " - "Get an API key at https://app.nova3d.xyz/api-key, " - "then set it as NOVA3D_TOKEN in your MCP config and restart." - ) - print( - f"Nova3D: {_startup_error}", - file=sys.stderr, - ) + _startup_error = None return base_url = _get_api_url() @@ -152,8 +170,8 @@ async def _validate_startup() -> None: me = await client.get_me() print(f"✓ Nova3D authenticated: {me['email']}", file=sys.stderr) except Nova3DAuthError as e: - _startup_error = str(e) - print(_startup_error, file=sys.stderr) + _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" @@ -162,6 +180,60 @@ async def _validate_startup() -> None: 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( @@ -224,6 +296,16 @@ def _conversation_url(app_url: str, conversation_id: Optional[str]) -> Optional[ 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, *, @@ -304,6 +386,23 @@ async def _append_and_link_messages( ) +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 + + # ── Tools ───────────────────────────────────────────────────────────────────── @mcp.tool() @@ -311,21 +410,73 @@ async def nova3d_setup() -> Dict[str, Any]: """ Get setup instructions for Nova3D. - Call this if the user asks how to get started, needs an API key, - or hasn't configured NOVA3D_TOKEN yet. + 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 = ( - "To use Nova3D you need one thing:\n" - "A Nova3D API key — get one at https://app.nova3d.xyz/api-key\n\n" - "Once you have it, run:\n" + "Preferred setup:\n" + "1. Call nova3d_login to sign in through your browser.\n" + "2. Call nova3d_status to confirm credits and readiness.\n" + "3. If credits are required, use the purchase link returned by nova3d_status.\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. + """ + status = await _get_mcp_status() + return _status_payload(status) + + +@mcp.tool() +async def nova3d_login() -> 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, + exchanges the one-time session code for an MCP credential, stores it locally, + and returns the resulting readiness state. + """ + authenticator = Nova3DAuthenticator( + base_url=_get_api_url(), + app_url=_get_app_url(), + session_store=_get_session_store(), + ) + result = await authenticator.login() + payload = _status_payload(result.status) + payload["browser_url"] = result.connect_url + payload["local_session_path"] = str(_get_session_store().path) + 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, @@ -337,6 +488,10 @@ async def generate_3d( """ 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. @@ -345,9 +500,11 @@ async def generate_3d( prompt: Description of the 3D asset. Be specific about parts. Example: "a washing machine with drum, door, control panel, and hose connectors" - model: LLM model to use. One of: "gemini" (default), "claude-sonnet", - "claude-opus", "claude-opus-latest", "gpt-5.5". - image_base64: Optional reference image as plain base64 (not a data URL). + 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: @@ -366,6 +523,9 @@ async def generate_3d( """ 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) @@ -383,10 +543,9 @@ async def generate_3d( result = await client.generate( prompt=prompt, - provider=model_opts["provider"], - llm=model_opts["llm"], - image_base64=image_base64, - image_mime=image_mime, + 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), ) diff --git a/nova3d_mcp/session_store.py b/nova3d_mcp/session_store.py new file mode 100644 index 0000000..aa92303 --- /dev/null +++ b/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/tests/test_client.py b/tests/test_client.py index f24029a..6cb9d88 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -52,36 +52,73 @@ def _status_running(): def _status_completed(): return { - "runtime": {"state": "completed", "last_exit_node_id": "success_final"}, - "node_visit_seq": {"sketch_to_3d_generator": 1}, + "runtime": {"state": "completed", "last_exit_node_id": "final_latest_valid"}, + "node_visit_seq": {"final_latest_valid": 1}, } def _result_ok(): return { - "sketch_to_3d_generator": [ + "final_latest_valid": [ { - "result": { - "model_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": "sketch_to_3d", - } + "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").mock( + 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").mock( + 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 @@ -103,8 +140,8 @@ async def test_generate_success(mock_api): result = await client.generate( prompt="a toaster with removable tray", - provider="gemini", - llm="gemini", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", ) client_module.asyncio.sleep = original_sleep @@ -118,7 +155,7 @@ async def test_generate_success(mock_api): @pytest.mark.asyncio async def test_generate_not_ready(mock_api): - mock_api.get("/workflow/readiness/sketch_to_3d").mock( + mock_api.get("/workflow/readiness/sketch_to_3d_v2").mock( return_value=httpx.Response(200, json={ "ready": False, "reason": "generation_service_unavailable", @@ -129,17 +166,17 @@ async def test_generate_not_ready(mock_api): with pytest.raises(Nova3DError, match="unavailable"): await client.generate( prompt="a robot", - provider="gemini", - llm="gemini", + 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").mock( + 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").mock( + 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.", @@ -150,8 +187,8 @@ async def test_generate_credits_error(mock_api): with pytest.raises(Nova3DCreditsError): await client.generate( prompt="a robot", - provider="gemini", - llm="gemini", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", ) @@ -166,13 +203,11 @@ async def test_result_parsing_glb_url(): @pytest.mark.asyncio async def test_result_parsing_failure(): data = { - "sketch_to_3d_generator": [ + "fail_generation": [ { - "result": { - "status": "failed", - "error_category": "blender_generation_failed", - "user_message": "The script could not produce a valid model.", - } + "status": "failed", + "error_category": "blender_generation_failed", + "user_message": "The script could not produce a valid model.", } ] } @@ -238,10 +273,10 @@ def test_parse_auth_error_no_json(): @pytest.mark.asyncio async def test_generate_raises_auth_error_with_code(mock_api): - mock_api.get("/workflow/readiness/sketch_to_3d").mock( + 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").mock( + mock_api.post("/run/state/sketch_to_3d_v2").mock( return_value=httpx.Response(401, json={ "detail": {"code": "api_key_revoked", "message": "Revoked."} }) @@ -250,8 +285,8 @@ async def test_generate_raises_auth_error_with_code(mock_api): with pytest.raises(Nova3DAuthError, match="revoked"): await client.generate( prompt="a robot", - provider="gemini", - llm="gemini", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", ) @@ -283,6 +318,58 @@ async def test_get_me_invalid_key(mock_api): 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( @@ -429,14 +516,14 @@ def capture_and_respond(request, route): def test_result_parsing_api_key_source_present(): data = { - "sketch_to_3d_generator": [ + "final_latest_valid": [ { - "result": { - "model_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", - } + "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", } ] } @@ -458,10 +545,9 @@ async def test_validate_startup_no_token(monkeypatch, capsys): server_module._startup_error = None monkeypatch.delenv("NOVA3D_TOKEN", raising=False) await _validate_startup() - assert server_module._startup_error is not None - assert "NOVA3D_TOKEN is not set" in server_module._startup_error + assert server_module._startup_error is None captured = capsys.readouterr() - assert "NOVA3D_TOKEN is not set" in captured.err + assert captured.err == "" server_module._startup_error = None @@ -492,8 +578,7 @@ async def test_validate_startup_revoked_key(mock_api, monkeypatch, capsys): }) ) await _validate_startup() - assert server_module._startup_error is not None - assert "revoked" in server_module._startup_error.lower() + assert server_module._startup_error is None captured = capsys.readouterr() assert "revoked" in captured.err.lower() server_module._startup_error = None @@ -516,23 +601,23 @@ async def test_validate_startup_network_error(monkeypatch, capsys): def test_result_parsing_parts_from_code_artifact(): data = { - "sketch_to_3d_generator": [ + "final_latest_valid": [ { - "result": { - "model_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" - ) - }, - } + "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" + ) + }, } ] } @@ -542,16 +627,16 @@ def test_result_parsing_parts_from_code_artifact(): def test_result_parsing_parts_api_field_takes_precedence(): data = { - "sketch_to_3d_generator": [ + "final_latest_valid": [ { - "result": { - "model_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"], - } + "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"], } ] } @@ -568,10 +653,10 @@ 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").mock( + 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").mock(side_effect=capture_and_respond) + 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()) ) @@ -586,8 +671,8 @@ def capture_and_respond(request, route): await client.generate( prompt="a toaster", - provider="gemini", - llm="gemini", + code_llm_profile="nova3d_code_generation", + code_llm_tier="gemini_3_1_pro_google", conversation_id="conv-abc123", ) @@ -596,7 +681,15 @@ def capture_and_respond(request, route): assert len(captured_requests) == 1 parsed = json.loads(captured_requests[0].content) assert parsed["conversation"]["conversation_id"] == "conv-abc123" - assert parsed["conversation"]["relation_type"] == "triggered_by" + 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 @@ -608,10 +701,10 @@ 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").mock( + 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").mock(side_effect=capture_and_respond) + 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()) ) @@ -626,11 +719,141 @@ def capture_and_respond(request, route): 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 "conversation" not in parsed + 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/tests/test_server.py b/tests/test_server.py index 1e27889..75badc7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,15 @@ def reset_startup_error(): server_module._startup_error = None +@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 = ( @@ -92,7 +101,21 @@ async def test_generate_3d_proceeds_when_no_startup_error(monkeypatch): monkeypatch.setenv("NOVA3D_TOKEN", "fake-token") with respx.mock(base_url="https://nova3d.xyz/api", assert_all_called=False) as mock: - mock.get("/workflow/readiness/sketch_to_3d").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( @@ -156,8 +179,9 @@ async def test_generate_3d_creates_conversation_and_returns_url(monkeypatch): 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["provider"] == "gemini" - assert call_kwargs["llm"] == "gemini" + 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 @@ -360,9 +384,9 @@ async def test_add_part_propagates_conversation_id(monkeypatch): @pytest.mark.asyncio async def test_nova3d_setup_returns_url_and_command(): result = await server_module.nova3d_setup() - assert "app.nova3d.xyz/api-key" in result["instructions"] + assert "nova3d_login" in result["instructions"] + assert "nova3d_status" in result["instructions"] assert "claude mcp add nova3d" in result["instructions"] - assert "n3d_your-key" in result["instructions"] @pytest.mark.asyncio @@ -370,7 +394,110 @@ 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 "app.nova3d.xyz/api-key" in result["instructions"] + assert "nova3d_login" in result["instructions"] + + +@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 ───────────────────────────────────────────────────