sceneview-mcp 4.0.13 → 4.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -10
- package/dist/generate-model.js +402 -0
- package/dist/generated/llms-txt.js +1 -1
- package/dist/generated/version.js +2 -2
- package/dist/guides.js +1 -1
- package/dist/platform-setup.js +1 -2
- package/dist/tiers.js +26 -3
- package/dist/tools/definitions.js +28 -0
- package/dist/tools/handler.js +14 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/sceneview-mcp)
|
|
6
6
|
[](https://www.npmjs.com/package/sceneview-mcp)
|
|
7
|
-
[](#quality)
|
|
8
8
|
[](https://modelcontextprotocol.io/)
|
|
9
9
|
[](https://registry.modelcontextprotocol.io)
|
|
10
10
|
[](./LICENSE)
|
|
@@ -111,7 +111,7 @@ Every developer tool is **free**: setup guides for every platform, code samples,
|
|
|
111
111
|
|
|
112
112
|
| Tool | What it does |
|
|
113
113
|
|---|---|
|
|
114
|
-
| `get_node_reference` | Full API reference for any of
|
|
114
|
+
| `get_node_reference` | Full API reference for any of 44+ node types — exact signatures, defaults, examples |
|
|
115
115
|
| `list_platforms` | Supported platforms with their status, renderer, and framework |
|
|
116
116
|
| `get_platform_roadmap` | Multi-platform status and timeline |
|
|
117
117
|
|
|
@@ -124,6 +124,7 @@ Every developer tool is **free**: setup guides for every platform, code samples,
|
|
|
124
124
|
| Tool | What it does |
|
|
125
125
|
|---|---|
|
|
126
126
|
| `search_models` | Searches Sketchfab for free 3D models (BYOK — set `SKETCHFAB_API_KEY`) |
|
|
127
|
+
| `generate_3d_model` | Generates a brand-new GLB from a text prompt or image via Tripo AI (BYOK — set `TRIPO_API_KEY`) |
|
|
127
128
|
| `analyze_project` | Scans a local SceneView project on disk — detects platform, extracts version, flags outdated deps and known anti-patterns |
|
|
128
129
|
| `search_android_docs` | Searches Google's stock Android docs knowledge base (needs the `android` CLI on PATH) |
|
|
129
130
|
| `fetch_android_doc` | Fetches a full Android docs entry by its `kb://...` URI (needs the `android` CLI on PATH) |
|
|
@@ -161,6 +162,38 @@ Generated SceneView code is only useful if it points at an asset that actually e
|
|
|
161
162
|
|
|
162
163
|
Call it like `search_models({ query: "red sports car", category: "cars-vehicles", maxResults: 6 })`. If the key is missing, the tool returns a clear message explaining how to get one instead of failing silently.
|
|
163
164
|
|
|
165
|
+
## `generate_3d_model` — create brand-new 3D assets from the AI
|
|
166
|
+
|
|
167
|
+
When no existing model fits, `generate_3d_model` closes the other half of the asset loop: it generates a fresh GLB from a **text prompt** (text→3D) or a **source image** (image→3D) via the [Tripo AI](https://www.tripo3d.ai) API, then returns a direct GLB download URL plus license/attribution metadata — ready for `rememberModelInstance(modelLoader, ...)` and AR placement.
|
|
168
|
+
|
|
169
|
+
Two quality tiers:
|
|
170
|
+
|
|
171
|
+
| `quality` | Tripo model | Topology | Latency | Approx. cost (July 2026) |
|
|
172
|
+
|---|---|---|---|---|
|
|
173
|
+
| `"fast"` (default) | P1 (`P1-20260311`) | low-poly, AR-ready | ~25–30 s | ~$0.10–0.25 of your credits |
|
|
174
|
+
| `"hd"` | H3.1 (`v3.1-20260211`) | quad mesh, detailed geometry + textures | up to ~100 s | ~$0.41 of your credits |
|
|
175
|
+
|
|
176
|
+
**Bring your own key (BYOK).** Exactly like `search_models`: SceneView never proxies the request or holds your key — generations are billed to **your** Tripo account. To set it up:
|
|
177
|
+
|
|
178
|
+
1. Create an API key at [platform.tripo3d.ai/api-keys](https://platform.tripo3d.ai/api-keys) (new accounts get free trial credits)
|
|
179
|
+
2. Set `TRIPO_API_KEY` in your MCP client config:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"mcpServers": {
|
|
184
|
+
"sceneview": {
|
|
185
|
+
"command": "npx",
|
|
186
|
+
"args": ["-y", "sceneview-mcp"],
|
|
187
|
+
"env": { "TRIPO_API_KEY": "YOUR_KEY_HERE" }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Call it like `generate_3d_model({ prompt: "a low-poly cactus in a striped pot" })` or `generate_3d_model({ imageUrl: "https://example.com/chair.jpg", quality: "hd" })`. Provide exactly one of `prompt` / `imageUrl`.
|
|
194
|
+
|
|
195
|
+
**⚠️ The GLB download URL expires ~5 minutes after generation** — download the file immediately and self-host it (e.g. copy it into your app's `assets/models/`). The tool result repeats this warning. Missing key, task failures, rate limits, and poll timeouts (2 min fast / 4 min hd cap) all return clear, actionable messages instead of hanging or crashing.
|
|
196
|
+
|
|
164
197
|
## `analyze_project` — local project scan
|
|
165
198
|
|
|
166
199
|
Because the MCP server runs on the user's machine, `analyze_project` can read their project files directly. Given a `path` (default: `process.cwd()`), it:
|
|
@@ -208,7 +241,7 @@ The assistant calls `validate_code` with the generated snippet and checks it aga
|
|
|
208
241
|
- Always use the current SceneView 4.0.x API surface
|
|
209
242
|
- Generate correct **Compose-native** 3D/AR code for Android
|
|
210
243
|
- Generate correct **SwiftUI-native** code for iOS/macOS/visionOS
|
|
211
|
-
- Know about all
|
|
244
|
+
- Know about all 44+ node types and their exact parameters
|
|
212
245
|
- Validate code against 15+ rules before presenting it
|
|
213
246
|
- Provide working, tested sample code for 33 scenarios
|
|
214
247
|
|
|
@@ -216,7 +249,7 @@ The assistant calls `validate_code` with the generated snippet and checks it aga
|
|
|
216
249
|
|
|
217
250
|
## Quality
|
|
218
251
|
|
|
219
|
-
The MCP server is tested with **
|
|
252
|
+
The MCP server is tested with **1,898 unit tests** across 81 test suites covering:
|
|
220
253
|
|
|
221
254
|
- Every tool response (correct output, error handling, edge cases)
|
|
222
255
|
- All 33 code samples (compilable structure, correct imports, no deprecated APIs)
|
|
@@ -225,11 +258,11 @@ The MCP server is tested with **2,918 unit tests** across 132 test suites coveri
|
|
|
225
258
|
- Resource responses (API reference, GitHub issues integration)
|
|
226
259
|
|
|
227
260
|
```
|
|
228
|
-
Test Files
|
|
229
|
-
Tests
|
|
261
|
+
Test Files 81 passed (81)
|
|
262
|
+
Tests 1898 passed (1898)
|
|
230
263
|
```
|
|
231
264
|
|
|
232
|
-
All tools work **fully offline** except `sceneview://known-issues` (GitHub API, cached 10 min)
|
|
265
|
+
All tools work **fully offline** except `sceneview://known-issues` (GitHub API, cached 10 min), `search_models` (Sketchfab, BYOK), and `generate_3d_model` (Tripo AI, BYOK).
|
|
233
266
|
|
|
234
267
|
---
|
|
235
268
|
|
|
@@ -253,7 +286,7 @@ Install Node.js from [nodejs.org](https://nodejs.org/) (LTS recommended). npm an
|
|
|
253
286
|
|
|
254
287
|
### Firewall or proxy issues
|
|
255
288
|
|
|
256
|
-
The only network calls are to the GitHub API (for known issues)
|
|
289
|
+
The only network calls are to the GitHub API (for known issues), Sketchfab (when `SKETCHFAB_API_KEY` is set), and Tripo AI (when `TRIPO_API_KEY` is set and `generate_3d_model` is called). Everything else works offline.
|
|
257
290
|
|
|
258
291
|
```json
|
|
259
292
|
{
|
|
@@ -304,7 +337,7 @@ Enabled by default on the free tier (MCP client name/version and tool names —
|
|
|
304
337
|
cd mcp
|
|
305
338
|
npm install
|
|
306
339
|
npm run prepare # Copy llms.txt + build TypeScript
|
|
307
|
-
npm test #
|
|
340
|
+
npm test # 1898 tests
|
|
308
341
|
npm run dev # Start with tsx (hot reload)
|
|
309
342
|
```
|
|
310
343
|
|
|
@@ -325,6 +358,7 @@ mcp/
|
|
|
325
358
|
artifact.ts # HTML artifact generator (model-viewer, charts, product 360)
|
|
326
359
|
issues.ts # GitHub issues fetcher (cached)
|
|
327
360
|
search-models.ts # Sketchfab BYOK search
|
|
361
|
+
generate-model.ts # Tripo BYOK text/image -> GLB generation
|
|
328
362
|
analyze-project.ts # Local project scanner
|
|
329
363
|
proxy.ts # Pro-tool proxy to hosted gateway
|
|
330
364
|
llms.txt # Bundled API reference (copied from repo root)
|
|
@@ -335,7 +369,7 @@ mcp/
|
|
|
335
369
|
1. Fork the repository
|
|
336
370
|
2. Create a feature branch
|
|
337
371
|
3. Add tests for new tools or rules
|
|
338
|
-
4. Run `npm test` — all
|
|
372
|
+
4. Run `npm test` — all 1898+ tests must pass
|
|
339
373
|
5. Submit a pull request
|
|
340
374
|
|
|
341
375
|
See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full guide.
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate_3d_model — Tripo BYOK text/image→GLB generation tool.
|
|
3
|
+
*
|
|
4
|
+
* `search_models` finds assets that already exist; this tool closes the other
|
|
5
|
+
* half of the agentic asset loop: when no existing model fits, the assistant
|
|
6
|
+
* generates a brand-new GLB from a text prompt or a source image using the
|
|
7
|
+
* Tripo AI API (api.tripo3d.ai), then loads it with
|
|
8
|
+
* `rememberModelInstance(modelLoader, ...)` and places it in AR.
|
|
9
|
+
*
|
|
10
|
+
* The tool is BYOK — users bring their own `TRIPO_API_KEY` (create one at
|
|
11
|
+
* platform.tripo3d.ai/api-keys). We never ship or proxy a key, so there is no
|
|
12
|
+
* server-side key custody, no cost to us, and no rate-limit sharing across
|
|
13
|
+
* users — exactly the `SKETCHFAB_API_KEY` pattern used by `search_models`.
|
|
14
|
+
*
|
|
15
|
+
* Quality tiers (July 2026 Tripo model catalog):
|
|
16
|
+
* - "fast" (default) → P1 low-poly (`P1-20260311`) — AR-ready meshes,
|
|
17
|
+
* ~25–30 s, roughly $0.10–0.25 of Tripo credits per generation.
|
|
18
|
+
* - "hd" → H3.1 (`v3.1-20260211`) with quad topology + detailed geometry
|
|
19
|
+
* and textures — up to ~100 s, roughly $0.41 of Tripo credits.
|
|
20
|
+
*
|
|
21
|
+
* API contract (grounded against docs.tripo3d.ai, July 2026):
|
|
22
|
+
* 1. POST https://api.tripo3d.ai/v2/openapi/task
|
|
23
|
+
* Authorization: Bearer <key>
|
|
24
|
+
* { "type": "text_to_model", "prompt": "...", "model_version": "..." }
|
|
25
|
+
* or
|
|
26
|
+
* { "type": "image_to_model", "file": { "type": "jpg|png|...", "url": "..." }, ... }
|
|
27
|
+
* → { "code": 0, "data": { "task_id": "..." } }
|
|
28
|
+
* 2. GET https://api.tripo3d.ai/v2/openapi/task/{task_id} — poll until the
|
|
29
|
+
* status is finalized. Status enum: queued | running (ongoing) and
|
|
30
|
+
* success | failed | banned | expired | cancelled | unknown (finalized).
|
|
31
|
+
* On success, `data.output` carries the model URLs (`pbr_model`,
|
|
32
|
+
* `model`, `base_model`) plus `rendered_image`. Download URLs expire
|
|
33
|
+
* after ~5 minutes — the caller must download the GLB immediately.
|
|
34
|
+
*
|
|
35
|
+
* All network errors, missing keys, task failures, and poll timeouts are
|
|
36
|
+
* translated to a structured `GenerateModelError` so the MCP handler can
|
|
37
|
+
* render a clear message without crashing the server.
|
|
38
|
+
*/
|
|
39
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
40
|
+
const TRIPO_TASK_ENDPOINT = "https://api.tripo3d.ai/v2/openapi/task";
|
|
41
|
+
const API_KEYS_URL = "https://platform.tripo3d.ai/api-keys";
|
|
42
|
+
const MAX_PROMPT_LENGTH = 1024; // Tripo's documented prompt limit.
|
|
43
|
+
/** How often to re-poll the task while it is queued/running. */
|
|
44
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
45
|
+
/**
|
|
46
|
+
* Bounded polling caps. "fast" (P1) typically finishes in ~25–30 s; "hd"
|
|
47
|
+
* (H3.1) can take ~100 s, so it gets a 4-minute ceiling. Beyond the cap the
|
|
48
|
+
* tool returns a `timeout` error instead of hanging the MCP call forever.
|
|
49
|
+
*/
|
|
50
|
+
const QUALITY_TIERS = {
|
|
51
|
+
fast: {
|
|
52
|
+
modelVersion: "P1-20260311",
|
|
53
|
+
label: "fast (Tripo P1 — low-poly, AR-ready)",
|
|
54
|
+
timeoutMs: 120_000,
|
|
55
|
+
},
|
|
56
|
+
hd: {
|
|
57
|
+
modelVersion: "v3.1-20260211",
|
|
58
|
+
label: "hd (Tripo H3.1 — quad topology, detailed geometry & textures)",
|
|
59
|
+
timeoutMs: 240_000,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const LICENSE_NOTE = 'Generated with your own Tripo API key — usage rights follow your Tripo plan\'s terms (https://www.tripo3d.ai/api). No third-party author attribution is required, but crediting "Made with Tripo AI" is appreciated.';
|
|
63
|
+
const ATTRIBUTION = "Tripo AI (https://www.tripo3d.ai)";
|
|
64
|
+
/** Finalized-but-not-successful Tripo task statuses. */
|
|
65
|
+
const FAILED_STATUSES = new Set(["failed", "banned", "expired", "cancelled", "unknown"]);
|
|
66
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
67
|
+
function missingKeyError() {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
code: "missing_key",
|
|
71
|
+
message: [
|
|
72
|
+
"generate_3d_model needs a Tripo API key (BYOK — generations are billed to YOUR Tripo account, nothing is charged by SceneView).",
|
|
73
|
+
"",
|
|
74
|
+
`1. Create an API key at ${API_KEYS_URL} (new accounts get free trial credits)`,
|
|
75
|
+
"2. Set the TRIPO_API_KEY environment variable in your MCP client config:",
|
|
76
|
+
"",
|
|
77
|
+
" Claude Desktop / Cursor / Windsurf:",
|
|
78
|
+
" {",
|
|
79
|
+
' "mcpServers": {',
|
|
80
|
+
' "sceneview": {',
|
|
81
|
+
' "command": "npx",',
|
|
82
|
+
' "args": ["-y", "sceneview-mcp"],',
|
|
83
|
+
' "env": { "TRIPO_API_KEY": "YOUR_KEY_HERE" }',
|
|
84
|
+
" }",
|
|
85
|
+
" }",
|
|
86
|
+
" }",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derive the Tripo `file.type` hint from the image URL extension. Tripo
|
|
92
|
+
* documents the field as advisory ("currently not validated") — JPEG and PNG
|
|
93
|
+
* are the officially supported input formats.
|
|
94
|
+
*/
|
|
95
|
+
function imageTypeFromUrl(url) {
|
|
96
|
+
const path = url.split(/[?#]/)[0]?.toLowerCase() ?? "";
|
|
97
|
+
if (path.endsWith(".png"))
|
|
98
|
+
return "png";
|
|
99
|
+
if (path.endsWith(".webp"))
|
|
100
|
+
return "webp";
|
|
101
|
+
if (path.endsWith(".jpeg"))
|
|
102
|
+
return "jpeg";
|
|
103
|
+
return "jpg";
|
|
104
|
+
}
|
|
105
|
+
function isHttpUrl(value) {
|
|
106
|
+
return /^https?:\/\//i.test(value.trim());
|
|
107
|
+
}
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the task-submission body for the requested mode + quality tier.
|
|
113
|
+
* Exported for tests only.
|
|
114
|
+
*/
|
|
115
|
+
export function buildTaskBody(options) {
|
|
116
|
+
const tier = QUALITY_TIERS[options.quality];
|
|
117
|
+
const body = {
|
|
118
|
+
model_version: tier.modelVersion,
|
|
119
|
+
texture: true,
|
|
120
|
+
pbr: true,
|
|
121
|
+
};
|
|
122
|
+
if (options.quality === "hd") {
|
|
123
|
+
// H3.1 add-ons: quad-mesh topology + detailed geometry & textures.
|
|
124
|
+
body.quad = true;
|
|
125
|
+
body.geometry_quality = "detailed";
|
|
126
|
+
body.texture_quality = "detailed";
|
|
127
|
+
}
|
|
128
|
+
if (options.prompt !== undefined) {
|
|
129
|
+
body.type = "text_to_model";
|
|
130
|
+
body.prompt = options.prompt;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
body.type = "image_to_model";
|
|
134
|
+
body.file = {
|
|
135
|
+
type: imageTypeFromUrl(options.imageUrl ?? ""),
|
|
136
|
+
url: options.imageUrl,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return body;
|
|
140
|
+
}
|
|
141
|
+
/** Map a non-OK Tripo HTTP response to a structured error. */
|
|
142
|
+
function httpError(status, statusText) {
|
|
143
|
+
if (status === 401 || status === 403) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
code: "unauthorized",
|
|
147
|
+
message: [
|
|
148
|
+
`Tripo rejected the API key (HTTP ${status}).`,
|
|
149
|
+
`Double-check the key at ${API_KEYS_URL}, or create a new one — keys look like "tsk_...".`,
|
|
150
|
+
].join(" "),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (status === 429) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "rate_limited",
|
|
157
|
+
message: "Tripo rate limit reached (HTTP 429). Wait a minute and retry, or check your plan's concurrency limits at https://platform.tripo3d.ai.",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
code: "bad_response",
|
|
163
|
+
message: `Tripo returned HTTP ${status} ${statusText || ""}`.trim(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Generate a 3D model (GLB) from a text prompt or a source image via the
|
|
169
|
+
* Tripo API: submit a task, then poll until it finalizes or the bounded
|
|
170
|
+
* deadline expires.
|
|
171
|
+
*
|
|
172
|
+
* Reads `TRIPO_API_KEY` from the environment. All error paths return a
|
|
173
|
+
* `GenerateModelError` rather than throwing, so the MCP dispatcher can render
|
|
174
|
+
* a friendly message without wrapping the call in a try/catch.
|
|
175
|
+
*/
|
|
176
|
+
export async function generateModel(options) {
|
|
177
|
+
// ── Input validation (no network before this passes) ──────────────────────
|
|
178
|
+
const prompt = typeof options?.prompt === "string" ? options.prompt.trim() : undefined;
|
|
179
|
+
const imageUrl = typeof options?.imageUrl === "string" ? options.imageUrl.trim() : undefined;
|
|
180
|
+
const hasPrompt = prompt !== undefined && prompt.length > 0;
|
|
181
|
+
const hasImage = imageUrl !== undefined && imageUrl.length > 0;
|
|
182
|
+
if (!hasPrompt && !hasImage) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: "invalid_input",
|
|
186
|
+
message: "Provide exactly one of `prompt` (text→3D) or `imageUrl` (image→3D). Both are currently empty.",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (hasPrompt && hasImage) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
code: "invalid_input",
|
|
193
|
+
message: "Provide exactly one of `prompt` or `imageUrl`, not both. Use `prompt` for text→3D or `imageUrl` for image→3D.",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (hasPrompt && prompt.length > MAX_PROMPT_LENGTH) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "invalid_input",
|
|
200
|
+
message: `\`prompt\` is too long (${prompt.length} chars). Tripo accepts at most ${MAX_PROMPT_LENGTH} characters.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (hasImage && !isHttpUrl(imageUrl)) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
code: "invalid_input",
|
|
207
|
+
message: "`imageUrl` must be a public http(s) URL to a JPEG or PNG image (max 20 MB).",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const quality = options.quality === "hd" ? "hd" : "fast"; // default + unknown values → fast
|
|
211
|
+
const tier = QUALITY_TIERS[quality];
|
|
212
|
+
const apiKey = process.env.TRIPO_API_KEY;
|
|
213
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
214
|
+
return missingKeyError();
|
|
215
|
+
}
|
|
216
|
+
const headers = {
|
|
217
|
+
Authorization: `Bearer ${apiKey}`,
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
Accept: "application/json",
|
|
220
|
+
};
|
|
221
|
+
// ── 1. Submit the generation task ──────────────────────────────────────────
|
|
222
|
+
const body = buildTaskBody({
|
|
223
|
+
prompt: hasPrompt ? prompt : undefined,
|
|
224
|
+
imageUrl: hasImage ? imageUrl : undefined,
|
|
225
|
+
quality,
|
|
226
|
+
});
|
|
227
|
+
let submitResponse;
|
|
228
|
+
try {
|
|
229
|
+
submitResponse = await fetch(TRIPO_TASK_ENDPOINT, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers,
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: "network",
|
|
240
|
+
message: `Could not reach Tripo (${cause}). Check your internet connection and try again.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (!submitResponse.ok) {
|
|
244
|
+
return httpError(submitResponse.status, submitResponse.statusText);
|
|
245
|
+
}
|
|
246
|
+
let submitPayload;
|
|
247
|
+
try {
|
|
248
|
+
submitPayload = (await submitResponse.json());
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
code: "bad_response",
|
|
255
|
+
message: `Tripo returned invalid JSON: ${cause}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (typeof submitPayload.code === "number" && submitPayload.code !== 0) {
|
|
259
|
+
const detail = [submitPayload.message, submitPayload.suggestion].filter(Boolean).join(" — ");
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
code: "bad_response",
|
|
263
|
+
message: `Tripo rejected the task (code ${submitPayload.code})${detail ? `: ${detail}` : "."}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const taskId = submitPayload.data?.task_id;
|
|
267
|
+
if (!taskId || typeof taskId !== "string") {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
code: "bad_response",
|
|
271
|
+
message: "Tripo accepted the request but returned no task_id.",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// ── 2. Poll until the task finalizes (bounded) ─────────────────────────────
|
|
275
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
276
|
+
const timeoutMs = options.timeoutMs ?? tier.timeoutMs;
|
|
277
|
+
const deadline = Date.now() + timeoutMs;
|
|
278
|
+
let lastStatus = "queued";
|
|
279
|
+
let lastProgress = 0;
|
|
280
|
+
for (;;) {
|
|
281
|
+
let pollResponse;
|
|
282
|
+
try {
|
|
283
|
+
pollResponse = await fetch(`${TRIPO_TASK_ENDPOINT}/${taskId}`, { headers });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: "network",
|
|
290
|
+
message: `Lost connection to Tripo while polling task ${taskId} (${cause}). The generation may still complete — retry later or check https://platform.tripo3d.ai.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (pollResponse.status === 401 || pollResponse.status === 403) {
|
|
294
|
+
return httpError(pollResponse.status, pollResponse.statusText);
|
|
295
|
+
}
|
|
296
|
+
// Transient poll hiccups (429 burst, 5xx, malformed JSON) are tolerated:
|
|
297
|
+
// keep polling until the bounded deadline instead of failing the task.
|
|
298
|
+
if (pollResponse.ok) {
|
|
299
|
+
let pollPayload;
|
|
300
|
+
try {
|
|
301
|
+
pollPayload = (await pollResponse.json());
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
pollPayload = undefined;
|
|
305
|
+
}
|
|
306
|
+
const task = pollPayload?.data;
|
|
307
|
+
const status = task?.status;
|
|
308
|
+
if (typeof status === "string") {
|
|
309
|
+
lastStatus = status;
|
|
310
|
+
if (typeof task?.progress === "number")
|
|
311
|
+
lastProgress = task.progress;
|
|
312
|
+
if (status === "success") {
|
|
313
|
+
const output = task?.output ?? {};
|
|
314
|
+
const modelUrl = output.pbr_model || output.model || output.base_model || "";
|
|
315
|
+
if (!modelUrl) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
code: "bad_response",
|
|
319
|
+
message: `Tripo task ${taskId} succeeded but returned no model URL.`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
model: {
|
|
325
|
+
taskId,
|
|
326
|
+
modelUrl,
|
|
327
|
+
previewImageUrl: output.rendered_image ?? "",
|
|
328
|
+
quality,
|
|
329
|
+
modelVersion: tier.modelVersion,
|
|
330
|
+
mode: hasPrompt ? "text" : "image",
|
|
331
|
+
input: hasPrompt ? prompt : imageUrl,
|
|
332
|
+
creditsConsumed: typeof task?.consumed_credit === "number" ? task.consumed_credit : null,
|
|
333
|
+
license: LICENSE_NOTE,
|
|
334
|
+
attribution: ATTRIBUTION,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (FAILED_STATUSES.has(status)) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
code: "task_failed",
|
|
342
|
+
message: [
|
|
343
|
+
`Tripo task ${taskId} finalized with status "${status}".`,
|
|
344
|
+
status === "failed"
|
|
345
|
+
? "Try rephrasing the prompt (or a clearer source image) and generate again."
|
|
346
|
+
: "Check the task on https://platform.tripo3d.ai for details.",
|
|
347
|
+
].join(" "),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// queued / running → keep polling.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (Date.now() >= deadline) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
code: "timeout",
|
|
357
|
+
message: [
|
|
358
|
+
`Tripo task ${taskId} did not finish within ${Math.round(timeoutMs / 1000)}s`,
|
|
359
|
+
`(last status: "${lastStatus}", progress ${lastProgress}%).`,
|
|
360
|
+
"The generation may still complete on Tripo's side — credits may be consumed.",
|
|
361
|
+
"Retry, or check the task on https://platform.tripo3d.ai.",
|
|
362
|
+
].join(" "),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
await sleep(pollIntervalMs);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Render a `GenerateModelResult` as the markdown text block the MCP
|
|
370
|
+
* dispatcher returns to the client. Kept here (not in handler.ts) so unit
|
|
371
|
+
* tests can verify formatting without touching the dispatch layer.
|
|
372
|
+
*/
|
|
373
|
+
export function formatGenerateResult(result) {
|
|
374
|
+
if (!result.ok) {
|
|
375
|
+
return result.message;
|
|
376
|
+
}
|
|
377
|
+
const m = result.model;
|
|
378
|
+
const sourceLabel = m.mode === "text" ? "Prompt" : "Source image";
|
|
379
|
+
const lines = [
|
|
380
|
+
`## Generated 3D model (${QUALITY_TIERS[m.quality].label})`,
|
|
381
|
+
"",
|
|
382
|
+
`- **GLB download:** ${m.modelUrl}`,
|
|
383
|
+
`- **⚠️ URL expiry:** the download link expires ~5 minutes after generation — download the file NOW and self-host it (e.g. copy it into your app's \`assets/models/\`).`,
|
|
384
|
+
...(m.previewImageUrl ? [`- **Preview:** ${m.previewImageUrl}`] : []),
|
|
385
|
+
`- **${sourceLabel}:** ${m.input}`,
|
|
386
|
+
`- **Model version:** ${m.modelVersion}`,
|
|
387
|
+
`- **Task ID:** \`${m.taskId}\``,
|
|
388
|
+
...(m.creditsConsumed !== null ? [`- **Tripo credits consumed:** ${m.creditsConsumed}`] : []),
|
|
389
|
+
`- **License:** ${m.license}`,
|
|
390
|
+
`- **Generator:** ${m.attribution}`,
|
|
391
|
+
"",
|
|
392
|
+
"### Load it in SceneView",
|
|
393
|
+
"",
|
|
394
|
+
"```kotlin",
|
|
395
|
+
"// After downloading the GLB into your app's assets:",
|
|
396
|
+
'val model = rememberModelInstance(modelLoader, "models/generated.glb")',
|
|
397
|
+
"```",
|
|
398
|
+
"",
|
|
399
|
+
'Place it in AR with `AnchorNode` + `ModelNode` — see the llms.txt recipe "Generate a 3D model with AI (Tripo) and place it in AR".',
|
|
400
|
+
];
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|