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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/sceneview-mcp?color=6c35aa)](https://www.npmjs.com/package/sceneview-mcp)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/sceneview-mcp?color=blue)](https://www.npmjs.com/package/sceneview-mcp)
7
- [![Tests](https://img.shields.io/badge/tests-2918%20passing-brightgreen)](#quality)
7
+ [![Tests](https://img.shields.io/badge/tests-1898%20passing-brightgreen)](#quality)
8
8
  [![MCP](https://img.shields.io/badge/MCP-v1.12-blue)](https://modelcontextprotocol.io/)
9
9
  [![Registry](https://img.shields.io/badge/MCP%20Registry-listed-blueviolet)](https://registry.modelcontextprotocol.io)
10
10
  [![License](https://img.shields.io/badge/License-MIT-green)](./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 42+ node types — exact signatures, defaults, examples |
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 42+ node types and their exact parameters
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 **2,918 unit tests** across 132 test suites covering:
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 132 passed (132)
229
- Tests 2918 passed (2918)
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) and `search_models` (Sketchfab, BYOK).
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) and Sketchfab (when `SKETCHFAB_API_KEY` is set). Everything else works offline.
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 # 2918 tests
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 2918+ tests must pass
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
+ }