sceneview-mcp 3.0.1 → 3.0.3

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/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { readFileSync } from "fs";
6
6
  import { dirname, resolve } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { getSample, SAMPLE_IDS, SAMPLES } from "./samples.js";
9
+ import { validateCode, formatValidationReport } from "./validator.js";
10
+ import { MIGRATION_GUIDE } from "./migration.js";
11
+ import { fetchKnownIssues } from "./issues.js";
9
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
13
  let API_DOCS;
11
14
  try {
@@ -14,7 +17,8 @@ try {
14
17
  catch {
15
18
  API_DOCS = "SceneView API docs not found. Run `npm run prepare` to bundle llms.txt.";
16
19
  }
17
- const server = new Server({ name: "@sceneview/mcp", version: "3.0.1" }, { capabilities: { resources: {}, tools: {} } });
20
+ const server = new Server({ name: "@sceneview/mcp", version: "3.0.2" }, { capabilities: { resources: {}, tools: {} } });
21
+ // ─── Resources ───────────────────────────────────────────────────────────────
18
22
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
19
23
  resources: [
20
24
  {
@@ -23,21 +27,36 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
23
27
  description: "Complete SceneView 3.0.0 API — Scene, ARScene, SceneScope DSL, ARSceneScope DSL, node types, resource loading, camera, gestures, math types, threading rules, and common patterns. Read this before writing any SceneView code.",
24
28
  mimeType: "text/markdown",
25
29
  },
30
+ {
31
+ uri: "sceneview://known-issues",
32
+ name: "SceneView Open GitHub Issues",
33
+ description: "Live list of open issues from the SceneView GitHub repository. Check this before reporting a bug or when something isn't working — there may already be a known workaround.",
34
+ mimeType: "text/markdown",
35
+ },
26
36
  ],
27
37
  }));
28
38
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
29
- if (request.params.uri === "sceneview://api") {
30
- return {
31
- contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
32
- };
39
+ switch (request.params.uri) {
40
+ case "sceneview://api":
41
+ return {
42
+ contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
43
+ };
44
+ case "sceneview://known-issues": {
45
+ const issues = await fetchKnownIssues();
46
+ return {
47
+ contents: [{ uri: "sceneview://known-issues", mimeType: "text/markdown", text: issues }],
48
+ };
49
+ }
50
+ default:
51
+ throw new Error(`Unknown resource: ${request.params.uri}`);
33
52
  }
34
- throw new Error(`Unknown resource: ${request.params.uri}`);
35
53
  });
54
+ // ─── Tools ───────────────────────────────────────────────────────────────────
36
55
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
37
56
  tools: [
38
57
  {
39
58
  name: "get_sample",
40
- description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising.",
59
+ description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising. Call `list_samples` first if you are unsure which scenario fits.",
41
60
  inputSchema: {
42
61
  type: "object",
43
62
  properties: {
@@ -50,6 +69,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
69
  required: ["scenario"],
51
70
  },
52
71
  },
72
+ {
73
+ name: "list_samples",
74
+ description: "Lists all available SceneView code samples with their IDs, descriptions, and tags. Use this to find the right sample before calling `get_sample`, or to show the user what SceneView can do.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ tag: {
79
+ type: "string",
80
+ description: "Optional tag to filter by (e.g. \"ar\", \"3d\", \"anchor\", \"face-tracking\", \"geometry\", \"animation\"). Omit to list all samples.",
81
+ },
82
+ },
83
+ required: [],
84
+ },
85
+ },
53
86
  {
54
87
  name: "get_setup",
55
88
  description: "Returns the Gradle dependency and AndroidManifest snippet required to use SceneView in an Android project.",
@@ -65,16 +98,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
98
  required: ["type"],
66
99
  },
67
100
  },
101
+ {
102
+ name: "validate_code",
103
+ description: "Checks a Kotlin SceneView snippet for common mistakes: threading violations, wrong destroy order, missing null-checks on rememberModelInstance, LightNode trailing-lambda bug, deprecated 2.x APIs, and more. Always call this before presenting generated SceneView code to the user.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ code: {
108
+ type: "string",
109
+ description: "The Kotlin source code to validate (composable function, class, or file).",
110
+ },
111
+ },
112
+ required: ["code"],
113
+ },
114
+ },
115
+ {
116
+ name: "get_migration_guide",
117
+ description: "Returns the full SceneView 2.x → 3.0 migration guide. Use this when a user reports code that worked in 2.x but breaks in 3.0, or when helping someone upgrade.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {},
121
+ required: [],
122
+ },
123
+ },
68
124
  ],
69
125
  }));
126
+ // ─── Tool handlers ────────────────────────────────────────────────────────────
70
127
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
71
128
  switch (request.params.name) {
129
+ // ── get_sample ────────────────────────────────────────────────────────────
72
130
  case "get_sample": {
73
131
  const scenario = request.params.arguments?.scenario;
74
132
  const sample = getSample(scenario);
75
133
  if (!sample) {
76
134
  return {
77
- content: [{ type: "text", text: `Unknown scenario "${scenario}". Available: ${SAMPLE_IDS.join(", ")}` }],
135
+ content: [
136
+ {
137
+ type: "text",
138
+ text: `Unknown scenario "${scenario}". Call \`list_samples\` to see available options.`,
139
+ },
140
+ ],
78
141
  isError: true,
79
142
  };
80
143
  }
@@ -85,6 +148,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
85
148
  text: [
86
149
  `## ${sample.title}`,
87
150
  ``,
151
+ `**Tags:** ${sample.tags.join(", ")}`,
152
+ ``,
88
153
  `**Gradle dependency:**`,
89
154
  `\`\`\`kotlin`,
90
155
  `implementation("${sample.dependency}")`,
@@ -102,6 +167,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
102
167
  ],
103
168
  };
104
169
  }
170
+ // ── list_samples ──────────────────────────────────────────────────────────
171
+ case "list_samples": {
172
+ const filterTag = request.params.arguments?.tag;
173
+ const entries = Object.values(SAMPLES).filter((s) => !filterTag || s.tags.includes(filterTag));
174
+ if (entries.length === 0) {
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: `No samples found with tag "${filterTag}". Available tags: 3d, ar, model, geometry, animation, camera, environment, anchor, plane-detection, image-tracking, face-tracking, placement, gestures`,
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ const header = filterTag
185
+ ? `## SceneView samples tagged \`${filterTag}\` (${entries.length})\n`
186
+ : `## All SceneView samples (${entries.length})\n`;
187
+ const rows = entries
188
+ .map((s) => `### \`${s.id}\`\n**${s.title}**\n${s.description}\n*Tags:* ${s.tags.join(", ")}\n*Dependency:* \`${s.dependency}\`\n\nCall \`get_sample("${s.id}")\` for the full code.`)
189
+ .join("\n\n---\n\n");
190
+ return { content: [{ type: "text", text: header + rows }] };
191
+ }
192
+ // ── get_setup ─────────────────────────────────────────────────────────────
105
193
  case "get_setup": {
106
194
  const type = request.params.arguments?.type;
107
195
  if (type === "3d") {
@@ -158,6 +246,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
158
246
  isError: true,
159
247
  };
160
248
  }
249
+ // ── validate_code ─────────────────────────────────────────────────────────
250
+ case "validate_code": {
251
+ const code = request.params.arguments?.code;
252
+ if (!code || typeof code !== "string") {
253
+ return {
254
+ content: [{ type: "text", text: "Missing required parameter: `code`" }],
255
+ isError: true,
256
+ };
257
+ }
258
+ const issues = validateCode(code);
259
+ const report = formatValidationReport(issues);
260
+ return { content: [{ type: "text", text: report }] };
261
+ }
262
+ // ── get_migration_guide ───────────────────────────────────────────────────
263
+ case "get_migration_guide": {
264
+ return { content: [{ type: "text", text: MIGRATION_GUIDE }] };
265
+ }
161
266
  default:
162
267
  return {
163
268
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
package/dist/issues.js ADDED
@@ -0,0 +1,72 @@
1
+ // Cache for 10 minutes — avoids hammering the GitHub API on every tool call
2
+ const CACHE_TTL_MS = 10 * 60 * 1000;
3
+ let cache = null;
4
+ export async function fetchKnownIssues() {
5
+ const now = Date.now();
6
+ if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
7
+ return cache.data;
8
+ }
9
+ let issues = [];
10
+ let fetchError = null;
11
+ try {
12
+ const response = await fetch("https://api.github.com/repos/SceneView/sceneview-android/issues?state=open&per_page=30", {
13
+ headers: {
14
+ Accept: "application/vnd.github+json",
15
+ "User-Agent": "sceneview-mcp/3.0",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ },
18
+ });
19
+ if (!response.ok) {
20
+ fetchError = `GitHub API returned ${response.status}: ${response.statusText}`;
21
+ }
22
+ else {
23
+ issues = (await response.json());
24
+ }
25
+ }
26
+ catch (err) {
27
+ fetchError = `Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`;
28
+ }
29
+ const result = formatIssues(issues, fetchError);
30
+ cache = { data: result, fetchedAt: now };
31
+ return result;
32
+ }
33
+ function formatIssues(issues, fetchError) {
34
+ const lines = ["# SceneView — Open GitHub Issues\n"];
35
+ if (fetchError) {
36
+ lines.push(`> ⚠️ ${fetchError}. Showing cached data if available.\n`);
37
+ }
38
+ else {
39
+ lines.push(`*Fetched live from GitHub — ${issues.length} open issue(s). Cached for 10 minutes.*\n`);
40
+ }
41
+ if (issues.length === 0) {
42
+ lines.push("No open issues. 🎉");
43
+ return lines.join("\n");
44
+ }
45
+ // Group by label: bugs first, then questions/other
46
+ const bugs = issues.filter((i) => i.labels.some((l) => l.name === "bug"));
47
+ const others = issues.filter((i) => !i.labels.some((l) => l.name === "bug"));
48
+ const renderGroup = (title, group) => {
49
+ if (group.length === 0)
50
+ return;
51
+ lines.push(`## ${title}\n`);
52
+ for (const issue of group) {
53
+ const labelStr = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : "";
54
+ lines.push(`### #${issue.number} — ${issue.title}${labelStr}`);
55
+ lines.push(`*Opened by @${issue.user.login} · ${issue.updated_at.slice(0, 10)}*`);
56
+ lines.push(issue.html_url);
57
+ // Include first 300 chars of body as context
58
+ if (issue.body) {
59
+ const excerpt = issue.body
60
+ .replace(/\r\n/g, "\n")
61
+ .replace(/```[\s\S]*?```/g, "[code block]") // strip code blocks for brevity
62
+ .trim()
63
+ .slice(0, 300);
64
+ lines.push(`\n> ${excerpt.replace(/\n/g, "\n> ")}${issue.body.length > 300 ? "…" : ""}`);
65
+ }
66
+ lines.push("");
67
+ }
68
+ };
69
+ renderGroup("🐛 Bug Reports", bugs);
70
+ renderGroup("📋 Other Issues", others);
71
+ return lines.join("\n");
72
+ }
@@ -0,0 +1,248 @@
1
+ export const MIGRATION_GUIDE = `# SceneView 2.x → 3.0 Migration Guide
2
+
3
+ SceneView 3.0 is a full rewrite from Android Views to **Jetpack Compose**. Nearly every public API changed. This guide covers every breaking change and how to fix it.
4
+
5
+ ---
6
+
7
+ ## 1. Gradle dependency
8
+
9
+ | 2.x | 3.0 |
10
+ |-----|-----|
11
+ | \`io.github.sceneview:sceneview:2.x.x\` | \`io.github.sceneview:sceneview:3.0.0\` |
12
+ | \`io.github.sceneview:arsceneview:2.x.x\` | \`io.github.sceneview:arsceneview:3.0.0\` |
13
+
14
+ ---
15
+
16
+ ## 2. Root composable names
17
+
18
+ | 2.x | 3.0 |
19
+ |-----|-----|
20
+ | \`SceneView(…)\` | \`Scene(…)\` |
21
+ | \`ArSceneView(…)\` | \`ARScene(…)\` |
22
+
23
+ **Before:**
24
+ \`\`\`kotlin
25
+ SceneView(modifier = Modifier.fillMaxSize())
26
+ \`\`\`
27
+
28
+ **After:**
29
+ \`\`\`kotlin
30
+ val engine = rememberEngine()
31
+ Scene(modifier = Modifier.fillMaxSize(), engine = engine)
32
+ \`\`\`
33
+
34
+ ---
35
+
36
+ ## 3. Engine lifecycle
37
+
38
+ In 2.x the engine was managed internally. In 3.0 you own it — use \`rememberEngine()\` which ties it to the composition lifecycle.
39
+
40
+ | 2.x | 3.0 |
41
+ |-----|-----|
42
+ | Engine implicit | \`val engine = rememberEngine()\` |
43
+ | Never destroy manually | Never call \`engine.destroy()\` — \`rememberEngine\` does it |
44
+
45
+ ---
46
+
47
+ ## 4. Model loading
48
+
49
+ | 2.x | 3.0 |
50
+ |-----|-----|
51
+ | \`modelLoader.loadModelAsync(path) { … }\` | \`rememberModelInstance(modelLoader, path)\` (returns \`null\` while loading) |
52
+ | \`modelLoader.loadModel(path)\` | \`modelLoader.loadModelInstanceAsync(path)\` (imperative) |
53
+ | \`ModelRenderable.builder()\` | Removed — use GLB/glTF assets |
54
+
55
+ **Before:**
56
+ \`\`\`kotlin
57
+ var modelInstance by remember { mutableStateOf<ModelInstance?>(null) }
58
+ LaunchedEffect(Unit) {
59
+ modelInstance = modelLoader.loadModelAsync("models/chair.glb")
60
+ }
61
+ \`\`\`
62
+
63
+ **After:**
64
+ \`\`\`kotlin
65
+ Scene(engine = engine, modelLoader = modelLoader) {
66
+ rememberModelInstance(modelLoader, "models/chair.glb")?.let { instance ->
67
+ ModelNode(modelInstance = instance, scaleToUnits = 1.0f)
68
+ }
69
+ }
70
+ \`\`\`
71
+
72
+ ---
73
+
74
+ ## 5. Node hierarchy — imperative → declarative DSL
75
+
76
+ In 2.x nodes were added imperatively (\`scene.addChild(node)\`). In 3.0 nodes are declared as composables inside \`Scene { }\` or \`ARScene { }\`.
77
+
78
+ **Before:**
79
+ \`\`\`kotlin
80
+ val modelNode = ModelNode().apply {
81
+ loadModelGlbAsync(
82
+ glbFileLocation = "models/chair.glb",
83
+ scaleToUnits = 1f,
84
+ )
85
+ }
86
+ sceneView.addChildNode(modelNode)
87
+ \`\`\`
88
+
89
+ **After:**
90
+ \`\`\`kotlin
91
+ Scene(engine = engine, modelLoader = modelLoader) {
92
+ rememberModelInstance(modelLoader, "models/chair.glb")?.let { instance ->
93
+ ModelNode(modelInstance = instance, scaleToUnits = 1f)
94
+ }
95
+ }
96
+ \`\`\`
97
+
98
+ ---
99
+
100
+ ## 6. Removed nodes and replacements
101
+
102
+ | 2.x | 3.0 replacement |
103
+ |-----|-----------------|
104
+ | \`TransformableNode\` | Set \`isEditable = true\` on \`ModelNode\` |
105
+ | \`PlacementNode\` | \`AnchorNode(anchor = hitResult.createAnchor())\` + \`HitResultNode\` |
106
+ | \`ViewRenderable\` | \`ViewNode\` with a \`@Composable\` content lambda |
107
+ | \`AnchorNode()\` (no-arg) | \`AnchorNode(anchor = hitResult.createAnchor())\` |
108
+
109
+ **TransformableNode:**
110
+ \`\`\`kotlin
111
+ // Before
112
+ val node = TransformableNode(transformationSystem).apply {
113
+ setParent(anchorNode)
114
+ }
115
+
116
+ // After
117
+ ModelNode(
118
+ modelInstance = instance,
119
+ scaleToUnits = 1f,
120
+ isEditable = true // enables pinch-to-scale + drag-to-rotate
121
+ )
122
+ \`\`\`
123
+
124
+ **ViewRenderable → ViewNode:**
125
+ \`\`\`kotlin
126
+ // Before
127
+ ViewRenderable.builder()
128
+ .setView(context, R.layout.my_layout)
129
+ .build()
130
+ .thenAccept { renderable -> … }
131
+
132
+ // After
133
+ val windowManager = rememberViewNodeManager()
134
+ Scene(viewNodeWindowManager = windowManager) {
135
+ ViewNode(windowManager = windowManager) {
136
+ Card { Text("Hello 3D World!") }
137
+ }
138
+ }
139
+ \`\`\`
140
+
141
+ ---
142
+
143
+ ## 7. Light configuration
144
+
145
+ LightNode's \`apply\` is a **named parameter** (not a trailing lambda). This is the most common silent breakage after migrating.
146
+
147
+ \`\`\`kotlin
148
+ // WRONG — trailing lambda is silently ignored
149
+ LightNode(engine = engine, type = LightManager.Type.DIRECTIONAL) {
150
+ intensity(100_000f)
151
+ }
152
+
153
+ // CORRECT
154
+ LightNode(
155
+ engine = engine,
156
+ type = LightManager.Type.DIRECTIONAL,
157
+ apply = {
158
+ intensity(100_000f)
159
+ castShadows(true)
160
+ }
161
+ )
162
+ \`\`\`
163
+
164
+ ---
165
+
166
+ ## 8. Environment / IBL loading
167
+
168
+ | 2.x | 3.0 |
169
+ |-----|-----|
170
+ | \`environmentLoader.loadEnvironment(path)\` | \`environmentLoader.createHDREnvironment(path)\` |
171
+
172
+ \`\`\`kotlin
173
+ // 3.0
174
+ val environmentLoader = rememberEnvironmentLoader(engine)
175
+ Scene(
176
+ environment = rememberEnvironment(environmentLoader) {
177
+ environmentLoader.createHDREnvironment("environments/sky_2k.hdr")!!
178
+ }
179
+ ) { … }
180
+ \`\`\`
181
+
182
+ ---
183
+
184
+ ## 9. AR session configuration
185
+
186
+ In 3.0 \`sessionConfiguration\` is a lambda parameter on \`ARScene\` (not a separate builder).
187
+
188
+ \`\`\`kotlin
189
+ ARScene(
190
+ engine = engine,
191
+ modelLoader = modelLoader,
192
+ sessionConfiguration = { session, config ->
193
+ config.depthMode =
194
+ if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC))
195
+ Config.DepthMode.AUTOMATIC else Config.DepthMode.DISABLED
196
+ config.instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
197
+ config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
198
+ }
199
+ ) { … }
200
+ \`\`\`
201
+
202
+ ---
203
+
204
+ ## 10. AR anchors (no more worldPosition hacks)
205
+
206
+ | 2.x pattern | 3.0 |
207
+ |-------------|-----|
208
+ | \`node.worldPosition = hitResult.hitPose.position\` | \`AnchorNode(anchor = hitResult.createAnchor())\` |
209
+
210
+ Plain nodes whose \`worldPosition\` is set manually will drift when ARCore remaps its coordinate system. \`AnchorNode\` compensates automatically.
211
+
212
+ ---
213
+
214
+ ## 11. Shadows
215
+
216
+ In 3.0, \`ARScene\` has shadows enabled by default via \`createARView()\`. For \`Scene\` (3D only), shadows are disabled by default — enable with:
217
+
218
+ \`\`\`kotlin
219
+ Scene(
220
+ view = rememberView(engine).also { it.setShadowingEnabled(true) },
221
+
222
+ )
223
+ \`\`\`
224
+
225
+ ---
226
+
227
+ ## 12. Camera
228
+
229
+ | 2.x | 3.0 |
230
+ |-----|-----|
231
+ | \`CameraManipulator\` set on the View | \`cameraManipulator = rememberCameraManipulator()\` on \`Scene\` |
232
+ | Custom camera via \`setCameraNode\` | \`cameraNode = rememberCameraNode(engine) { … }\` on \`Scene\` |
233
+
234
+ ---
235
+
236
+ ## Checklist
237
+
238
+ - [ ] Replace \`SceneView(…)\` → \`Scene(engine = rememberEngine(), …)\`
239
+ - [ ] Replace \`ArSceneView(…)\` → \`ARScene(engine = rememberEngine(), …)\`
240
+ - [ ] Replace \`modelLoader.loadModelAsync\` → \`rememberModelInstance\`
241
+ - [ ] Add null-check on every \`rememberModelInstance\` result
242
+ - [ ] Replace \`TransformableNode\` → \`isEditable = true\`
243
+ - [ ] Replace \`PlacementNode\` → \`AnchorNode\` + \`HitResultNode\`
244
+ - [ ] Replace \`ViewRenderable\` → \`ViewNode\` with Compose lambda
245
+ - [ ] Fix \`LightNode { … }\` → \`LightNode(apply = { … })\`
246
+ - [ ] Remove manual \`engine.destroy()\` calls
247
+ - [ ] Replace manual \`worldPosition\` in AR → \`AnchorNode\`
248
+ `;
package/dist/samples.js CHANGED
@@ -3,6 +3,7 @@ export const SAMPLES = {
3
3
  id: "model-viewer",
4
4
  title: "3D Model Viewer",
5
5
  description: "Full-screen 3D scene with a GLB model, HDR environment, and orbit camera",
6
+ tags: ["3d", "model", "environment", "camera"],
6
7
  dependency: "io.github.sceneview:sceneview:3.0.0",
7
8
  prompt: "Create an Android Compose screen called `ModelViewerScreen` that loads a GLB file from assets/models/my_model.glb and displays it in a full-screen 3D scene with an orbit camera (drag to rotate, pinch to zoom). Add an HDR environment from assets/environments/sky_2k.hdr for realistic lighting. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
8
9
  code: `@Composable
@@ -36,6 +37,7 @@ fun ModelViewerScreen() {
36
37
  id: "geometry-scene",
37
38
  title: "3D Geometry Scene",
38
39
  description: "Procedural 3D scene using primitive geometry nodes (cube, sphere, plane) — no GLB required",
40
+ tags: ["3d", "geometry", "animation"],
39
41
  dependency: "io.github.sceneview:sceneview:3.0.0",
40
42
  prompt: "Create an Android Compose screen called `GeometrySceneScreen` that renders a full-screen 3D scene with a red rotating cube, a metallic blue sphere, and a green floor plane. No model files — use SceneView built-in geometry nodes. Orbit camera. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
41
43
  code: `@Composable
@@ -91,6 +93,7 @@ fun GeometrySceneScreen() {
91
93
  id: "ar-tap-to-place",
92
94
  title: "AR Tap-to-Place",
93
95
  description: "AR scene where each tap places a GLB model on a detected surface. Placed models are pinch-to-scale and drag-to-rotate.",
96
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "gestures"],
94
97
  dependency: "io.github.sceneview:arsceneview:3.0.0",
95
98
  prompt: "Create an Android Compose screen called `TapToPlaceScreen` that opens the camera in AR mode. Show a plane detection grid. When the user taps a detected surface, place a 3D GLB model from assets/models/chair.glb at that point. The user should be able to pinch-to-scale and drag-to-rotate after placing. Multiple taps = multiple objects. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
96
99
  code: `@Composable
@@ -134,6 +137,7 @@ fun TapToPlaceScreen() {
134
137
  id: "ar-placement-cursor",
135
138
  title: "AR Placement Cursor",
136
139
  description: "AR scene with a reticle that follows the surface at screen center. Tap to confirm placement.",
140
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "camera"],
137
141
  dependency: "io.github.sceneview:arsceneview:3.0.0",
138
142
  prompt: "Create an Android Compose AR screen called `ARCursorScreen`. Show a small reticle that snaps to the nearest detected surface at the center of the screen as the user moves the camera. When the user taps, place a GLB model from assets/models/object.glb at that position and hide the reticle. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
139
143
  code: `@Composable
@@ -180,6 +184,7 @@ fun ARCursorScreen() {
180
184
  id: "ar-augmented-image",
181
185
  title: "AR Augmented Image",
182
186
  description: "Detects a reference image in the camera feed and overlays a 3D model above it.",
187
+ tags: ["ar", "model", "anchor", "image-tracking"],
183
188
  dependency: "io.github.sceneview:arsceneview:3.0.0",
184
189
  prompt: "Create an Android Compose AR screen called `AugmentedImageScreen` that detects a printed reference image (from R.drawable.target_image, physical width 15 cm) and places a 3D GLB model from assets/models/overlay.glb above it, scaled to match the image width. The model should disappear when the image is lost. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
185
190
  code: `@Composable
@@ -222,6 +227,7 @@ fun AugmentedImageScreen() {
222
227
  id: "ar-face-filter",
223
228
  title: "AR Face Filter",
224
229
  description: "Front-camera AR that detects faces and renders a 3D mesh material over them.",
230
+ tags: ["ar", "face-tracking", "camera"],
225
231
  dependency: "io.github.sceneview:arsceneview:3.0.0",
226
232
  prompt: "Create an Android Compose AR screen called `FaceFilterScreen` using the front camera. Detect all visible faces and apply a custom material from assets/materials/face_mask.filamat to the face mesh. Use SceneView `io.github.sceneview:arsceneview:3.0.0` with `Session.Feature.FRONT_CAMERA` and `AugmentedFaceMode.MESH3D`.",
227
233
  code: `@Composable
@@ -0,0 +1,251 @@
1
+ function findLines(lines, pattern) {
2
+ return lines
3
+ .map((l, i) => (pattern.test(l) ? i + 1 : -1))
4
+ .filter((n) => n !== -1);
5
+ }
6
+ const RULES = [
7
+ // ─── Threading ────────────────────────────────────────────────────────────
8
+ {
9
+ id: "threading/filament-off-main-thread",
10
+ severity: "error",
11
+ check(code, lines) {
12
+ const issues = [];
13
+ if (!/Dispatchers\.(IO|Default)/.test(code))
14
+ return issues;
15
+ const filamentCallPatterns = [
16
+ [/modelLoader\.createModel/, "modelLoader.createModel*"],
17
+ [/modelLoader\.loadModel/, "modelLoader.loadModel*"],
18
+ [/materialLoader\./, "materialLoader.*"],
19
+ [/Texture\.Builder/, "Texture.Builder"],
20
+ [/engine\.createTexture/, "engine.createTexture"],
21
+ ];
22
+ for (const [pat, name] of filamentCallPatterns) {
23
+ if (pat.test(code)) {
24
+ findLines(lines, pat).forEach((line) => issues.push({
25
+ severity: "error",
26
+ rule: "threading/filament-off-main-thread",
27
+ message: `\`${name}\` detected alongside a background dispatcher. Filament JNI calls must run on the **main thread**. Use \`rememberModelInstance\` in composables, or wrap imperative code in \`withContext(Dispatchers.Main)\`.`,
28
+ line,
29
+ }));
30
+ }
31
+ }
32
+ return issues;
33
+ },
34
+ },
35
+ // ─── AR: plain Node worldPosition instead of AnchorNode ──────────────────
36
+ {
37
+ id: "ar/node-not-anchor",
38
+ severity: "warning",
39
+ check(code, lines) {
40
+ const issues = [];
41
+ if (!code.includes("ARScene") && !code.includes("AnchorNode"))
42
+ return issues;
43
+ if (/\.worldPosition\s*=/.test(code)) {
44
+ findLines(lines, /\.worldPosition\s*=/).forEach((line) => issues.push({
45
+ severity: "warning",
46
+ rule: "ar/node-not-anchor",
47
+ message: "Manually setting `worldPosition` inside an AR scene causes drift — ARCore remaps coordinates during tracking and plain nodes don't compensate. Use `AnchorNode(anchor = hitResult.createAnchor())` instead.",
48
+ line,
49
+ }));
50
+ }
51
+ return issues;
52
+ },
53
+ },
54
+ // ─── Missing null-check on rememberModelInstance ──────────────────────────
55
+ {
56
+ id: "composable/model-instance-null-check",
57
+ severity: "error",
58
+ check(code, lines) {
59
+ const issues = [];
60
+ const assignMatch = code.match(/val\s+(\w+)\s*=\s*rememberModelInstance\(/);
61
+ if (!assignMatch)
62
+ return issues;
63
+ const varName = assignMatch[1];
64
+ // Flag ModelNode uses where the variable is passed without null-guard (no ?. !! or ?: )
65
+ const unsafeUse = new RegExp(`ModelNode\\s*\\([^)]*modelInstance\\s*=\\s*${varName}(?![?!])`);
66
+ if (unsafeUse.test(code)) {
67
+ findLines(lines, new RegExp(`modelInstance\\s*=\\s*${varName}(?![?!])`)).forEach((line) => issues.push({
68
+ severity: "error",
69
+ rule: "composable/model-instance-null-check",
70
+ message: `\`${varName}\` from \`rememberModelInstance\` is \`null\` while the asset loads. Guard it: \`${varName}?.let { ModelNode(modelInstance = it, ...) }\`.`,
71
+ line,
72
+ }));
73
+ }
74
+ return issues;
75
+ },
76
+ },
77
+ // ─── LightNode trailing lambda instead of named `apply =` ────────────────
78
+ {
79
+ id: "api/light-node-trailing-lambda",
80
+ severity: "error",
81
+ check(code, lines) {
82
+ const issues = [];
83
+ // Matches: LightNode(...) { — trailing lambda, not apply = { inside parens
84
+ if (/LightNode\s*\([^)]*\)\s*\{/.test(code)) {
85
+ findLines(lines, /LightNode\s*\([^)]*\)\s*\{/).forEach((line) => issues.push({
86
+ severity: "error",
87
+ rule: "api/light-node-trailing-lambda",
88
+ message: "`LightNode`'s configuration block is a **named parameter** `apply`, not a trailing lambda. Write `LightNode(engine = engine, type = ..., apply = { intensity(100_000f) })`. Without `apply =` the block is silently ignored and your light has default (zero) settings.",
89
+ line,
90
+ }));
91
+ }
92
+ return issues;
93
+ },
94
+ },
95
+ // ─── Engine created manually in composable ────────────────────────────────
96
+ {
97
+ id: "lifecycle/manual-engine-create",
98
+ severity: "error",
99
+ check(code, lines) {
100
+ const issues = [];
101
+ findLines(lines, /Engine\.create\(/).forEach((line) => issues.push({
102
+ severity: "error",
103
+ rule: "lifecycle/manual-engine-create",
104
+ message: "`Engine.create()` called directly. In composables use `rememberEngine()` — it ties the Engine to the composition lifecycle and destroys it automatically on disposal, preventing leaks and double-destroy SIGABRTs.",
105
+ line,
106
+ }));
107
+ return issues;
108
+ },
109
+ },
110
+ // ─── Manual engine.destroy() alongside rememberEngine ────────────────────
111
+ {
112
+ id: "lifecycle/manual-engine-destroy",
113
+ severity: "warning",
114
+ check(code, lines) {
115
+ const issues = [];
116
+ if (!code.includes("rememberEngine"))
117
+ return issues;
118
+ findLines(lines, /engine\.(safeD|d)estroy\(\)/).forEach((line) => issues.push({
119
+ severity: "warning",
120
+ rule: "lifecycle/manual-engine-destroy",
121
+ message: "`engine.destroy()` called manually alongside `rememberEngine()`. The engine is already destroyed on composition disposal — calling it again triggers a SIGABRT. Remove the manual call.",
122
+ line,
123
+ }));
124
+ return issues;
125
+ },
126
+ },
127
+ // ─── Texture destroy order (issue #630) ──────────────────────────────────
128
+ {
129
+ id: "lifecycle/texture-destroy-order",
130
+ severity: "error",
131
+ check(code, lines) {
132
+ const issues = [];
133
+ const texIdx = code.indexOf("safeDestroyTexture");
134
+ const matIdx = code.indexOf("destroyMaterialInstance");
135
+ if (texIdx !== -1 && matIdx !== -1 && texIdx < matIdx) {
136
+ findLines(lines, /safeDestroyTexture|engine\.destroyTexture/).forEach((line) => issues.push({
137
+ severity: "error",
138
+ rule: "lifecycle/texture-destroy-order",
139
+ message: "Texture destroyed **before** the MaterialInstance that references it → SIGABRT (\"Invalid texture still bound to MaterialInstance\"). Destroy the MaterialInstance first: `materialLoader.destroyMaterialInstance(instance)`, then `engine.safeDestroyTexture(texture)`.",
140
+ line,
141
+ }));
142
+ }
143
+ return issues;
144
+ },
145
+ },
146
+ // ─── createModelInstance in composable (should be rememberModelInstance) ──
147
+ {
148
+ id: "composable/prefer-remember-model-instance",
149
+ severity: "warning",
150
+ check(code, lines) {
151
+ const issues = [];
152
+ findLines(lines, /modelLoader\.createModelInstance\(/).forEach((line) => issues.push({
153
+ severity: "warning",
154
+ rule: "composable/prefer-remember-model-instance",
155
+ message: "`modelLoader.createModelInstance()` blocks the main thread. In composables, use `rememberModelInstance(modelLoader, path)` — it loads asynchronously, returns `null` while loading, and recomposes when ready.",
156
+ line,
157
+ }));
158
+ return issues;
159
+ },
160
+ },
161
+ // ─── Empty AnchorNode() ────────────────────────────────────────────────────
162
+ {
163
+ id: "ar/anchor-node-missing-anchor",
164
+ severity: "error",
165
+ check(code, lines) {
166
+ const issues = [];
167
+ findLines(lines, /AnchorNode\s*\(\s*\)/).forEach((line) => issues.push({
168
+ severity: "error",
169
+ rule: "ar/anchor-node-missing-anchor",
170
+ message: "`AnchorNode()` requires an `anchor` from a hit result. Use `AnchorNode(anchor = hitResult.createAnchor())` inside `onTouchEvent` or `onSessionUpdated`.",
171
+ line,
172
+ }));
173
+ return issues;
174
+ },
175
+ },
176
+ // ─── 2.x → 3.0 renamed/removed APIs ─────────────────────────────────────
177
+ {
178
+ id: "migration/old-api",
179
+ severity: "error",
180
+ check(code, lines) {
181
+ const issues = [];
182
+ const renames = [
183
+ [/\bSceneView\s*\(/, "`SceneView(…)` → renamed to `Scene(…)` in 3.0"],
184
+ [/\bArSceneView\s*\(/, "`ArSceneView(…)` → renamed to `ARScene(…)` in 3.0"],
185
+ [/\bPlacementNode\b/, "`PlacementNode` removed → use `AnchorNode` + `HitResultNode` in 3.0"],
186
+ [/\bTransformableNode\b/, "`TransformableNode` removed → set `isEditable = true` on `ModelNode` in 3.0"],
187
+ [/\bViewRenderable\b/, "`ViewRenderable` removed → use `ViewNode` with a `@Composable` content lambda in 3.0"],
188
+ [/\bmodelLoader\.loadModelAsync\b/, "`loadModelAsync` removed → use `rememberModelInstance` in composables (3.0)"],
189
+ [/\bmodelLoader\.loadModel\b(?!Instance)/, "`loadModel` → use `rememberModelInstance` or `loadModelInstanceAsync` (3.0)"],
190
+ ];
191
+ for (const [pat, msg] of renames) {
192
+ if (pat.test(code)) {
193
+ findLines(lines, pat).forEach((line) => issues.push({ severity: "error", rule: "migration/old-api", message: msg, line }));
194
+ }
195
+ }
196
+ return issues;
197
+ },
198
+ },
199
+ // ─── Scene missing engine param ───────────────────────────────────────────
200
+ {
201
+ id: "api/scene-missing-engine",
202
+ severity: "error",
203
+ check(code, lines) {
204
+ const issues = [];
205
+ // Scene( or ARScene( without engine = somewhere nearby
206
+ const sceneCallLines = findLines(lines, /\b(AR)?Scene\s*\(/);
207
+ sceneCallLines.forEach((line) => {
208
+ // Look at the next 10 lines for engine =
209
+ const block = lines.slice(line - 1, line + 10).join("\n");
210
+ if (!block.includes("engine")) {
211
+ issues.push({
212
+ severity: "error",
213
+ rule: "api/scene-missing-engine",
214
+ message: "`Scene` / `ARScene` requires an `engine` parameter. Create one with `val engine = rememberEngine()` and pass it: `Scene(engine = engine, …)`.",
215
+ line,
216
+ });
217
+ }
218
+ });
219
+ return issues;
220
+ },
221
+ },
222
+ ];
223
+ export function validateCode(kotlinCode) {
224
+ const lines = kotlinCode.split("\n");
225
+ return RULES.flatMap((rule) => rule.check(kotlinCode, lines));
226
+ }
227
+ export function formatValidationReport(issues) {
228
+ if (issues.length === 0) {
229
+ return "✅ No issues found. The snippet follows SceneView best practices.";
230
+ }
231
+ const errors = issues.filter((i) => i.severity === "error");
232
+ const warnings = issues.filter((i) => i.severity === "warning");
233
+ const infos = issues.filter((i) => i.severity === "info");
234
+ const icon = {
235
+ error: "🔴",
236
+ warning: "🟡",
237
+ info: "🔵",
238
+ };
239
+ const header = `Found **${issues.length} issue(s)**: ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s).\n`;
240
+ const body = issues
241
+ .map((issue, i) => {
242
+ const loc = issue.line ? ` (line ${issue.line})` : "";
243
+ return [
244
+ `### ${i + 1}. ${icon[issue.severity]} ${issue.severity.toUpperCase()}${loc}`,
245
+ `**Rule:** \`${issue.rule}\``,
246
+ issue.message,
247
+ ].join("\n");
248
+ })
249
+ .join("\n\n");
250
+ return header + "\n" + body;
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sceneview-mcp",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "MCP server for SceneView — 3D and AR with Jetpack Compose for Android. Give Claude the full SceneView SDK so it writes correct, compilable Kotlin.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -31,7 +31,11 @@
31
31
  "sceneview-mcp": "dist/index.js"
32
32
  },
33
33
  "files": [
34
- "dist",
34
+ "dist/index.js",
35
+ "dist/issues.js",
36
+ "dist/migration.js",
37
+ "dist/samples.js",
38
+ "dist/validator.js",
35
39
  "llms.txt"
36
40
  ],
37
41
  "engines": {
@@ -41,7 +45,8 @@
41
45
  "build": "tsc",
42
46
  "prepare": "cp ../llms.txt ./llms.txt && tsc",
43
47
  "start": "node dist/index.js",
44
- "dev": "tsx src/index.ts"
48
+ "dev": "tsx src/index.ts",
49
+ "test": "vitest run"
45
50
  },
46
51
  "dependencies": {
47
52
  "@modelcontextprotocol/sdk": "^1.12.0"
@@ -49,6 +54,7 @@
49
54
  "devDependencies": {
50
55
  "@types/node": "^22.0.0",
51
56
  "tsx": "^4.0.0",
52
- "typescript": "^5.8.0"
57
+ "typescript": "^5.8.0",
58
+ "vitest": "^4.1.0"
53
59
  }
54
60
  }