sceneview-mcp 3.6.2 → 3.6.5

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
@@ -38,6 +38,8 @@ Connect it to Claude, Cursor, Windsurf, or any MCP client. The assistant gets 26
38
38
  npx sceneview-mcp
39
39
  ```
40
40
 
41
+ > **Anonymous telemetry** is enabled on the free tier (MCP client name/version and tool names — no personal data, no prompt content). Opt out with `SCENEVIEW_TELEMETRY=0`. See [PRIVACY.md](./PRIVACY.md#telemetry-free-tier) for the full payload shape.
42
+
41
43
  ### Claude Desktop
42
44
 
43
45
  Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
@@ -110,6 +112,43 @@ Same JSON config as above. The server communicates via **stdio** using the stand
110
112
  | `get_animation_guide` | Guide for model animations, Spring physics, Compose property animations, SmoothTransform |
111
113
  | `get_gesture_guide` | Guide for gestures: isEditable, onTouchEvent, tap-to-place, drag-to-rotate, pinch-to-scale |
112
114
  | `get_performance_tips` | Performance optimization: LOD, texture compression, instancing, profiling with Systrace/AGI |
115
+ | `search_models` | Searches Sketchfab for free 3D models matching a query (BYOK — set `SKETCHFAB_API_KEY`) |
116
+ | `analyze_project` | Scans a local SceneView project on disk — detects platform, extracts version, flags outdated deps and known anti-patterns (threading, LightNode bug, 2.x APIs) |
117
+
118
+ #### `search_models` — find real 3D assets from the AI
119
+
120
+ Generated SceneView code is only useful if it points at an asset that actually exists. `search_models` queries Sketchfab's public search API and returns a shortlist with names, authors, licenses, thumbnails, triangle counts, and viewer/embed URLs that the assistant can drop straight into `rememberModelInstance(modelLoader, ...)` or embed as a live preview.
121
+
122
+ **Bring your own key (BYOK).** SceneView never proxies the request — you keep the rate limit and the cost stays at zero. To set it up:
123
+
124
+ 1. Create a free account at [sketchfab.com/register](https://sketchfab.com/register)
125
+ 2. Copy your API token from [sketchfab.com/settings/password](https://sketchfab.com/settings/password)
126
+ 3. Set `SKETCHFAB_API_KEY` in your MCP client config:
127
+
128
+ ```json
129
+ {
130
+ "mcpServers": {
131
+ "sceneview": {
132
+ "command": "npx",
133
+ "args": ["-y", "sceneview-mcp"],
134
+ "env": { "SKETCHFAB_API_KEY": "YOUR_TOKEN_HERE" }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ 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.
141
+
142
+ #### `analyze_project` — local project scan
143
+
144
+ 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:
145
+
146
+ - Detects the project type by looking for `build.gradle(.kts)` with `io.github.sceneview:sceneview` (Android), `Package.swift` with `SceneViewSwift` (iOS), or `package.json` with `sceneview-web` (Web).
147
+ - Extracts the SceneView dependency version and compares it against the latest release known to this MCP build, flagging outdated projects.
148
+ - Walks up to **30** source files (`.kt`, `.kts`, `.swift`, `.js`, `.ts`) and up to **500 KB** total, scanning for well-known anti-patterns: Filament/ModelLoader calls inside background coroutines, the `LightNode(...) { ... }` trailing-lambda bug, deprecated 2.x APIs (`ArSceneView`, `TransformableNode`, `PlacementNode`, `ViewRenderable`, `loadModelAsync`), and `com.google.ar.sceneform.*` imports.
149
+ - Returns a structured `{ projectType, sceneViewVersion, latestVersion, isOutdated, warnings, suggestions }` report, plus a Markdown summary.
150
+
151
+ The tool is read-only, never writes to disk, and gracefully handles missing directories. Use it when the user asks "is my project up to date?" or as a quick sanity check before generating new code for an existing codebase.
113
152
 
114
153
  ### 2 resources
115
154
 
@@ -0,0 +1,500 @@
1
+ /**
2
+ * analyze-project — local filesystem scan of a SceneView project.
3
+ *
4
+ * Because the SceneView MCP server runs on the user's machine, we can read
5
+ * their project files directly: detect the platform (Android / iOS / Web),
6
+ * extract the SceneView dependency version, compare against the latest known
7
+ * release, and scan source files for well-known anti-patterns (threading
8
+ * violations, LightNode trailing-lambda bug, deprecated 2.x APIs, …).
9
+ *
10
+ * This is the first real *agentic* tool in the MCP — previous tools were
11
+ * static getters, this one actually inspects the caller's filesystem to
12
+ * produce a tailored report.
13
+ *
14
+ * Hard safety limits:
15
+ * - at most {@link MAX_FILES_SCANNED} source files read
16
+ * - at most {@link MAX_BYTES_SCANNED} bytes read in total
17
+ * - at most {@link MAX_DIR_DEPTH} directory levels traversed
18
+ *
19
+ * When any limit is hit we stop walking and return a warning so the caller
20
+ * knows the scan was truncated — we never throw.
21
+ */
22
+ import { promises as fs } from "node:fs";
23
+ import path from "node:path";
24
+ // ─── Constants ───────────────────────────────────────────────────────────────
25
+ /** The latest SceneView release known to this build of the MCP. */
26
+ export const LATEST_SCENEVIEW_VERSION = "3.6.2";
27
+ /** Hard cap on the number of source files inspected per call. */
28
+ export const MAX_FILES_SCANNED = 30;
29
+ /** Hard cap on total bytes read across all source files per call (500 KB). */
30
+ export const MAX_BYTES_SCANNED = 500 * 1024;
31
+ /** Max directory tree depth traversed from the project root. */
32
+ export const MAX_DIR_DEPTH = 12;
33
+ /** Directories we never recurse into (build outputs, caches, vendored deps). */
34
+ const SKIP_DIRS = new Set([
35
+ "node_modules",
36
+ ".git",
37
+ "build",
38
+ "dist",
39
+ ".gradle",
40
+ ".idea",
41
+ ".vscode",
42
+ ".claude",
43
+ "Pods",
44
+ "DerivedData",
45
+ ".build",
46
+ "out",
47
+ "target",
48
+ ]);
49
+ /** File extensions we consider "source" for anti-pattern scanning. */
50
+ const SOURCE_EXTENSIONS = new Set([".kt", ".kts", ".swift", ".js", ".mjs", ".ts", ".tsx"]);
51
+ /**
52
+ * The curated list of anti-patterns we scan for. This is a deliberately
53
+ * smaller subset of the full `validator.ts` rules — the goal here is "fast
54
+ * sanity check of an entire project", not a per-file lint pass.
55
+ *
56
+ * Covered:
57
+ * - threading: Filament/ModelLoader calls inside a background launch block
58
+ * - API misuse: LightNode trailing-lambda bug
59
+ * - 2.x deprecated APIs: ArSceneView, TransformableNode, PlacementNode,
60
+ * ViewRenderable, loadModelAsync, Sceneform imports
61
+ */
62
+ const ANTI_PATTERNS = [
63
+ // ─── Threading: modelLoader.createModel* inside a non-main launch ──────────
64
+ {
65
+ id: "threading/filament-off-main-thread",
66
+ languages: new Set(["kotlin"]),
67
+ pattern: /modelLoader\s*\.\s*createModel\w*\s*\(/,
68
+ message: "`modelLoader.createModel*` is called in a file that also uses a background coroutine " +
69
+ "(`launch {` / `Dispatchers.IO` / `Dispatchers.Default`). Filament JNI calls must run on " +
70
+ "the main thread. Use `rememberModelInstance(modelLoader, path)` in composables, or " +
71
+ "`withContext(Dispatchers.Main)` in imperative code.",
72
+ fileGuard: (contents) => /\blaunch\s*\(?\s*Dispatchers\.(IO|Default)\b/.test(contents) ||
73
+ /\blaunch\s*\{/.test(contents) ||
74
+ /\bwithContext\s*\(\s*Dispatchers\.(IO|Default)\b/.test(contents),
75
+ },
76
+ // ─── API: LightNode(...) { ... } trailing-lambda bug ───────────────────────
77
+ {
78
+ id: "api/light-node-trailing-lambda",
79
+ languages: new Set(["kotlin"]),
80
+ pattern: /\bLightNode\s*\([^)]*\)\s*\{/,
81
+ message: "`LightNode`'s configuration block is a **named parameter** `apply`, not a trailing " +
82
+ "lambda. Write `LightNode(engine = engine, type = ..., apply = { intensity(100_000f) })`. " +
83
+ "Without `apply =` the block is silently ignored.",
84
+ },
85
+ // ─── Deprecated 2.x APIs ───────────────────────────────────────────────────
86
+ {
87
+ id: "migration/ar-scene-view-rename",
88
+ languages: new Set(["kotlin"]),
89
+ pattern: /\bArSceneView\s*\(/,
90
+ message: "`ArSceneView` was renamed to `ARSceneView` in SceneView 3.0. Update the call site " +
91
+ "(capital R). Run `migrate_code` for an automatic rewrite.",
92
+ },
93
+ {
94
+ id: "migration/transformable-node-removed",
95
+ languages: new Set(["kotlin"]),
96
+ pattern: /\bTransformableNode\b/,
97
+ message: "`TransformableNode` was removed in SceneView 3.0. Set `isEditable = true` on a " +
98
+ "`ModelNode` or `GeometryNode` instead.",
99
+ },
100
+ {
101
+ id: "migration/placement-node-removed",
102
+ languages: new Set(["kotlin"]),
103
+ pattern: /\bPlacementNode\b/,
104
+ message: "`PlacementNode` was removed in SceneView 3.0. Use `AnchorNode` + `HitResultNode` " +
105
+ "inside an `ARSceneView` instead.",
106
+ },
107
+ {
108
+ id: "migration/view-renderable-removed",
109
+ languages: new Set(["kotlin"]),
110
+ pattern: /\bViewRenderable\b/,
111
+ message: "`ViewRenderable` was removed in SceneView 3.0. Use `ViewNode` with a `@Composable` " +
112
+ "content lambda instead.",
113
+ },
114
+ {
115
+ id: "migration/load-model-async",
116
+ languages: new Set(["kotlin"]),
117
+ pattern: /\bmodelLoader\s*\.\s*loadModelAsync\s*\(/,
118
+ message: "`modelLoader.loadModelAsync` was removed in SceneView 3.0. Use " +
119
+ "`rememberModelInstance(modelLoader, path)` in composables, or " +
120
+ "`modelLoader.loadModelInstanceAsync(path)` in imperative code.",
121
+ },
122
+ {
123
+ id: "migration/sceneform-import",
124
+ languages: new Set(["kotlin"]),
125
+ pattern: /\bimport\s+com\.google\.ar\.sceneform\b/,
126
+ message: "`com.google.ar.sceneform.*` was deprecated by Google in 2021. Replace with " +
127
+ "`io.github.sceneview.*` imports — SceneView is the official successor.",
128
+ },
129
+ {
130
+ id: "migration/scene-view-renamed",
131
+ languages: new Set(["kotlin"]),
132
+ pattern: /\bimport\s+com\.google\.ar\.sceneform\.ux\.ArFragment\b/,
133
+ message: "`ArFragment` (Sceneform) removed. Replace with `ARSceneView { … }` composable from " +
134
+ "`io.github.sceneview.ar`.",
135
+ },
136
+ ];
137
+ function languageOf(filePath) {
138
+ const ext = path.extname(filePath).toLowerCase();
139
+ if (ext === ".kt" || ext === ".kts")
140
+ return "kotlin";
141
+ if (ext === ".swift")
142
+ return "swift";
143
+ if (ext === ".js" || ext === ".mjs" || ext === ".ts" || ext === ".tsx")
144
+ return "js";
145
+ return null;
146
+ }
147
+ /** Walk `dir` depth-first and collect source files, respecting skip-dirs and depth. */
148
+ async function collectSourceFiles(rootDir, dir, depth, out) {
149
+ if (depth > MAX_DIR_DEPTH)
150
+ return;
151
+ if (out.length >= MAX_FILES_SCANNED)
152
+ return;
153
+ let entries;
154
+ try {
155
+ entries = await fs.readdir(dir, { withFileTypes: true });
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ // Deterministic order for reproducible tests.
161
+ entries.sort((a, b) => a.name.localeCompare(b.name));
162
+ for (const entry of entries) {
163
+ if (out.length >= MAX_FILES_SCANNED)
164
+ return;
165
+ const abs = path.join(dir, entry.name);
166
+ if (entry.isDirectory()) {
167
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
168
+ continue;
169
+ await collectSourceFiles(rootDir, abs, depth + 1, out);
170
+ continue;
171
+ }
172
+ if (!entry.isFile())
173
+ continue;
174
+ if (SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
175
+ out.push(abs);
176
+ }
177
+ }
178
+ }
179
+ async function readFileSafe(filePath) {
180
+ try {
181
+ return await fs.readFile(filePath, "utf-8");
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ }
187
+ async function tryRead(dir, ...relativeCandidates) {
188
+ for (const rel of relativeCandidates) {
189
+ const abs = path.join(dir, rel);
190
+ const contents = await readFileSafe(abs);
191
+ if (contents != null)
192
+ return { path: abs, contents };
193
+ }
194
+ return null;
195
+ }
196
+ /** Extract `X.Y.Z` from a Gradle-style `io.github.sceneview:sceneview:X.Y.Z` reference. */
197
+ function extractGradleVersion(contents) {
198
+ const match = contents.match(/io\.github\.sceneview:(?:sceneview|arsceneview|sceneview-core)[:"']?\s*[:"']?\s*(?:version\s*=\s*["'])?(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
199
+ if (match)
200
+ return match[1];
201
+ return null;
202
+ }
203
+ /** Extract `X.Y.Z` from a `Package.swift` `SceneViewSwift` dependency. */
204
+ function extractSwiftVersion(contents) {
205
+ // Matches `.package(url: ".../sceneview"..., from: "3.6.2")` or `.upToNextMajor(from: "3.6.0")`.
206
+ const fromMatch = contents.match(/sceneview[^"]*"[^)]*from:\s*"(\d+\.\d+\.\d+(?:-[\w.]+)?)"/i);
207
+ if (fromMatch)
208
+ return fromMatch[1];
209
+ const exactMatch = contents.match(/sceneview[^"]*"[^)]*exact:\s*"(\d+\.\d+\.\d+(?:-[\w.]+)?)"/i);
210
+ if (exactMatch)
211
+ return exactMatch[1];
212
+ const branchOrVersion = contents.match(/\.package\([^)]*sceneview[^)]*"(\d+\.\d+\.\d+)"/i);
213
+ if (branchOrVersion)
214
+ return branchOrVersion[1];
215
+ return null;
216
+ }
217
+ /** Extract the `sceneview-web` version from a `package.json`. */
218
+ function extractNpmVersion(contents) {
219
+ try {
220
+ const pkg = JSON.parse(contents);
221
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
222
+ for (const [name, spec] of Object.entries(deps)) {
223
+ if (name === "sceneview-web" || name === "@sceneview/sceneview-web") {
224
+ // Strip leading ^ / ~ / >= etc.
225
+ const cleaned = spec.replace(/^[^\d]*/, "");
226
+ const match = cleaned.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/);
227
+ if (match)
228
+ return match[0];
229
+ }
230
+ }
231
+ }
232
+ catch {
233
+ // Not JSON — fall through.
234
+ }
235
+ return null;
236
+ }
237
+ async function detectProject(rootDir) {
238
+ // Android: build.gradle or build.gradle.kts anywhere reachable from root.
239
+ // We first check the root for speed, then one level down for typical module layouts.
240
+ const androidCandidates = [
241
+ "build.gradle.kts",
242
+ "build.gradle",
243
+ "app/build.gradle.kts",
244
+ "app/build.gradle",
245
+ "sample/build.gradle.kts",
246
+ "sample/build.gradle",
247
+ ];
248
+ for (const rel of androidCandidates) {
249
+ const read = await tryRead(rootDir, rel);
250
+ if (read && /io\.github\.sceneview:(sceneview|arsceneview|sceneview-core)/.test(read.contents)) {
251
+ return {
252
+ projectType: "android",
253
+ sceneViewVersion: extractGradleVersion(read.contents),
254
+ sourceFile: read.path,
255
+ };
256
+ }
257
+ }
258
+ // iOS: Package.swift at root mentioning SceneViewSwift.
259
+ const packageSwift = await tryRead(rootDir, "Package.swift");
260
+ if (packageSwift && /SceneViewSwift|sceneview\/sceneview/i.test(packageSwift.contents)) {
261
+ return {
262
+ projectType: "ios",
263
+ sceneViewVersion: extractSwiftVersion(packageSwift.contents),
264
+ sourceFile: packageSwift.path,
265
+ };
266
+ }
267
+ // Web: package.json referencing sceneview-web.
268
+ const packageJson = await tryRead(rootDir, "package.json");
269
+ if (packageJson && /"(?:@sceneview\/)?sceneview-web"/.test(packageJson.contents)) {
270
+ return {
271
+ projectType: "web",
272
+ sceneViewVersion: extractNpmVersion(packageJson.contents),
273
+ sourceFile: packageJson.path,
274
+ };
275
+ }
276
+ return { projectType: "unknown", sceneViewVersion: null, sourceFile: null };
277
+ }
278
+ // ─── Semver comparison (simple X.Y.Z) ────────────────────────────────────────
279
+ /**
280
+ * Returns `true` when `version` is strictly older than `LATEST_SCENEVIEW_VERSION`.
281
+ * Falls back to `false` (don't flag) if either side fails to parse cleanly —
282
+ * we'd rather underreport than harass a user with a pre-release build.
283
+ */
284
+ function isVersionOutdated(version) {
285
+ if (!version)
286
+ return false;
287
+ const parse = (v) => {
288
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
289
+ if (!m)
290
+ return null;
291
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
292
+ };
293
+ const a = parse(version);
294
+ const b = parse(LATEST_SCENEVIEW_VERSION);
295
+ if (!a || !b)
296
+ return false;
297
+ for (let i = 0; i < 3; i++) {
298
+ if (a[i] < b[i])
299
+ return true;
300
+ if (a[i] > b[i])
301
+ return false;
302
+ }
303
+ return false;
304
+ }
305
+ // ─── Public entrypoint ───────────────────────────────────────────────────────
306
+ /**
307
+ * Scan a local SceneView project and return a structured analysis.
308
+ *
309
+ * Never throws for filesystem errors — missing directory, permission denied,
310
+ * broken file: all surface as a warning in the returned result.
311
+ */
312
+ export async function analyzeProject(input = {}) {
313
+ const rawPath = input.path ?? process.cwd();
314
+ const scannedPath = path.resolve(rawPath);
315
+ const result = {
316
+ projectType: "unknown",
317
+ sceneViewVersion: null,
318
+ latestVersion: LATEST_SCENEVIEW_VERSION,
319
+ isOutdated: false,
320
+ warnings: [],
321
+ suggestions: [],
322
+ scannedPath,
323
+ filesScanned: 0,
324
+ bytesScanned: 0,
325
+ truncated: false,
326
+ };
327
+ // Verify the path exists and is a directory.
328
+ let stat;
329
+ try {
330
+ stat = await fs.stat(scannedPath);
331
+ }
332
+ catch (err) {
333
+ result.warnings.push({
334
+ file: scannedPath,
335
+ type: "scan/path-not-found",
336
+ message: `Could not stat project path: ${err.message}. ` +
337
+ "Pass an existing directory in the `path` argument, or run the tool from inside your project.",
338
+ });
339
+ return result;
340
+ }
341
+ if (!stat.isDirectory()) {
342
+ result.warnings.push({
343
+ file: scannedPath,
344
+ type: "scan/not-a-directory",
345
+ message: `Project path exists but is not a directory: ${scannedPath}.`,
346
+ });
347
+ return result;
348
+ }
349
+ // Step 1: project type + version.
350
+ const detection = await detectProject(scannedPath);
351
+ result.projectType = detection.projectType;
352
+ result.sceneViewVersion = detection.sceneViewVersion;
353
+ result.isOutdated = isVersionOutdated(detection.sceneViewVersion);
354
+ if (detection.projectType === "unknown") {
355
+ result.warnings.push({
356
+ file: scannedPath,
357
+ type: "scan/no-sceneview-dependency",
358
+ message: "No SceneView dependency was detected. Looked for `io.github.sceneview:sceneview` in " +
359
+ "Gradle files, `SceneViewSwift` in `Package.swift`, and `sceneview-web` in `package.json`.",
360
+ });
361
+ }
362
+ else if (!detection.sceneViewVersion) {
363
+ result.warnings.push({
364
+ file: detection.sourceFile ?? scannedPath,
365
+ type: "scan/version-unresolved",
366
+ message: "SceneView dependency was found but the version could not be extracted. This usually " +
367
+ "means the version is declared through a variable or a version catalog — check your " +
368
+ "`libs.versions.toml` or a shared `ext.sceneview_version` definition.",
369
+ });
370
+ }
371
+ if (result.isOutdated && detection.sceneViewVersion) {
372
+ result.suggestions.push({
373
+ type: "suggestion/upgrade-sceneview",
374
+ message: `You are on SceneView ${detection.sceneViewVersion}. The latest known release is ` +
375
+ `${LATEST_SCENEVIEW_VERSION}. Consider upgrading — run \`get_migration_guide\` for ` +
376
+ "the 2.x → 3.x migration notes, or `migrate_code` for automatic Kotlin rewrites.",
377
+ });
378
+ }
379
+ // Step 2: anti-pattern scan (only if we have a known project type).
380
+ if (detection.projectType !== "unknown") {
381
+ const sourceFiles = [];
382
+ await collectSourceFiles(scannedPath, scannedPath, 0, sourceFiles);
383
+ const truncatedByFileCount = sourceFiles.length >= MAX_FILES_SCANNED;
384
+ let totalBytes = 0;
385
+ for (const file of sourceFiles) {
386
+ if (result.filesScanned >= MAX_FILES_SCANNED) {
387
+ result.truncated = true;
388
+ break;
389
+ }
390
+ if (totalBytes >= MAX_BYTES_SCANNED) {
391
+ result.truncated = true;
392
+ break;
393
+ }
394
+ const contents = await readFileSafe(file);
395
+ if (contents == null)
396
+ continue;
397
+ const byteLen = Buffer.byteLength(contents, "utf-8");
398
+ // Would this file push us over the byte cap? If yes, still scan once
399
+ // (a single giant file shouldn't silently fail) then stop the loop.
400
+ totalBytes += byteLen;
401
+ result.filesScanned += 1;
402
+ result.bytesScanned = totalBytes;
403
+ const lang = languageOf(file);
404
+ if (!lang)
405
+ continue;
406
+ scanFileForAntiPatterns(file, lang, contents, result.warnings);
407
+ if (totalBytes >= MAX_BYTES_SCANNED) {
408
+ result.truncated = true;
409
+ break;
410
+ }
411
+ }
412
+ if (truncatedByFileCount) {
413
+ result.truncated = true;
414
+ }
415
+ if (result.truncated) {
416
+ result.warnings.push({
417
+ file: scannedPath,
418
+ type: "scan/truncated",
419
+ message: `Scan truncated at ${result.filesScanned} files / ${result.bytesScanned} bytes ` +
420
+ `(limits: ${MAX_FILES_SCANNED} files, ${MAX_BYTES_SCANNED} bytes). Some files may ` +
421
+ "not have been inspected — run `validate_code` on specific snippets for a deeper check.",
422
+ });
423
+ }
424
+ }
425
+ // Step 3: tailored suggestions.
426
+ if (result.warnings.some((w) => w.type.startsWith("migration/"))) {
427
+ result.suggestions.push({
428
+ type: "suggestion/run-migrate-code",
429
+ message: "Deprecated 2.x APIs were detected. Run the `migrate_code` tool on the offending " +
430
+ "files for an automatic rewrite to 3.x.",
431
+ });
432
+ }
433
+ if (result.warnings.some((w) => w.type === "threading/filament-off-main-thread")) {
434
+ result.suggestions.push({
435
+ type: "suggestion/fix-threading",
436
+ message: "Filament JNI calls were detected alongside background coroutines. Switch to " +
437
+ "`rememberModelInstance` in composables, or wrap imperative loads in " +
438
+ "`withContext(Dispatchers.Main)`.",
439
+ });
440
+ }
441
+ if (result.projectType !== "unknown" && result.warnings.filter((w) => !w.type.startsWith("scan/")).length === 0) {
442
+ result.suggestions.push({
443
+ type: "suggestion/no-issues",
444
+ message: "No known anti-patterns detected in the scanned files. Call `validate_code` on " +
445
+ "individual snippets for a deeper per-file check.",
446
+ });
447
+ }
448
+ return result;
449
+ }
450
+ /** Run every registered detector on one file and append findings in place. */
451
+ function scanFileForAntiPatterns(file, language, contents, warnings) {
452
+ const lines = contents.split("\n");
453
+ for (const detector of ANTI_PATTERNS) {
454
+ if (!detector.languages.has(language))
455
+ continue;
456
+ if (detector.fileGuard && !detector.fileGuard(contents))
457
+ continue;
458
+ for (let i = 0; i < lines.length; i++) {
459
+ if (detector.pattern.test(lines[i])) {
460
+ warnings.push({
461
+ file,
462
+ line: i + 1,
463
+ type: detector.id,
464
+ message: detector.message,
465
+ });
466
+ }
467
+ }
468
+ }
469
+ }
470
+ // ─── Formatting helper (used by the MCP handler) ────────────────────────────
471
+ /** Render an {@link AnalysisResult} as a Markdown report for MCP clients. */
472
+ export function formatAnalysisReport(result) {
473
+ const lines = [];
474
+ lines.push(`## SceneView project analysis`);
475
+ lines.push(``);
476
+ lines.push(`**Path:** \`${result.scannedPath}\``);
477
+ lines.push(`**Project type:** ${result.projectType}`);
478
+ lines.push(`**SceneView version:** ${result.sceneViewVersion ?? "(not detected)"} — latest: ${result.latestVersion}${result.isOutdated ? " ⚠️ outdated" : ""}`);
479
+ lines.push(`**Scan:** ${result.filesScanned} file(s), ${result.bytesScanned} byte(s)${result.truncated ? " (truncated)" : ""}`);
480
+ lines.push(``);
481
+ if (result.warnings.length === 0) {
482
+ lines.push(`### Warnings`);
483
+ lines.push(`No anti-patterns detected.`);
484
+ }
485
+ else {
486
+ lines.push(`### Warnings (${result.warnings.length})`);
487
+ for (const w of result.warnings) {
488
+ const loc = w.line ? `${w.file}:${w.line}` : w.file;
489
+ lines.push(`- **${w.type}** — \`${loc}\`: ${w.message}`);
490
+ }
491
+ }
492
+ lines.push(``);
493
+ if (result.suggestions.length > 0) {
494
+ lines.push(`### Suggestions`);
495
+ for (const s of result.suggestions) {
496
+ lines.push(`- **${s.type}** — ${s.message}`);
497
+ }
498
+ }
499
+ return lines.join("\n");
500
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Authentication middleware for the SceneView MCP server.
3
+ *
4
+ * Provides tool-level access control based on free/pro tiers,
5
+ * API key validation, and MCP-formatted denial responses.
6
+ */
7
+ import { isProTool, PRO_UPGRADE_MESSAGE } from "./tiers.js";
8
+ import { getConfiguredApiKey, validateApiKey } from "./billing.js";
9
+ // ─── Main middleware ─────────────────────────────────────────────────────────
10
+ /**
11
+ * Checks whether the current user is allowed to invoke the given tool.
12
+ *
13
+ * - Free-tier tools are always allowed.
14
+ * - Pro-tier tools require a valid API key with an active subscription.
15
+ */
16
+ export async function checkToolAccess(toolName) {
17
+ // Free-tier tools are always accessible
18
+ if (!isProTool(toolName)) {
19
+ return { allowed: true, tier: "free" };
20
+ }
21
+ // Pro tool — check for API key
22
+ const apiKey = getConfiguredApiKey();
23
+ if (!apiKey) {
24
+ return {
25
+ allowed: false,
26
+ tier: "free",
27
+ message: PRO_UPGRADE_MESSAGE,
28
+ };
29
+ }
30
+ // Validate the key against the billing service
31
+ const validation = await validateApiKey(apiKey);
32
+ if (validation.valid) {
33
+ return { allowed: true, tier: "pro" };
34
+ }
35
+ // Key exists but is invalid or subscription expired
36
+ return {
37
+ allowed: false,
38
+ tier: "free",
39
+ message: validation.error ?? "Your API key is invalid or your Pro subscription has expired.",
40
+ };
41
+ }
42
+ // ─── Tool list filtering ─────────────────────────────────────────────────────
43
+ /**
44
+ * Annotates the tool list based on the caller's tier.
45
+ *
46
+ * - Free users see every tool, but pro-only tools get a "[PRO]" prefix on
47
+ * their description so the AI (and the human) know an upgrade is needed.
48
+ * - Pro users see the list unmodified.
49
+ */
50
+ export async function filterToolsForTier(tools) {
51
+ const apiKey = getConfiguredApiKey();
52
+ let isPro = false;
53
+ if (apiKey) {
54
+ const validation = await validateApiKey(apiKey);
55
+ isPro = validation.valid;
56
+ }
57
+ if (isPro) {
58
+ return tools;
59
+ }
60
+ // Free tier — prefix pro tool descriptions so users can discover them
61
+ return tools.map((tool) => {
62
+ if (!isProTool(tool.name)) {
63
+ return tool;
64
+ }
65
+ const description = typeof tool.description === "string" ? tool.description : "";
66
+ return {
67
+ ...tool,
68
+ description: `[PRO] ${description}`,
69
+ };
70
+ });
71
+ }
72
+ // ─── MCP response helpers ────────────────────────────────────────────────────
73
+ /**
74
+ * Builds an MCP-formatted error response for access-denied scenarios.
75
+ *
76
+ * The returned object can be used directly as the handler return value
77
+ * for a `CallToolRequestSchema` handler.
78
+ */
79
+ export function createAccessDeniedResponse(toolName, message) {
80
+ return {
81
+ content: [{ type: "text", text: message }],
82
+ isError: true,
83
+ };
84
+ }