sceneview-mcp 4.0.12 → 4.0.13

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
@@ -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 35+ node types — exact signatures, defaults, examples |
114
+ | `get_node_reference` | Full API reference for any of 42+ 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
 
@@ -125,6 +125,8 @@ Every developer tool is **free**: setup guides for every platform, code samples,
125
125
  |---|---|
126
126
  | `search_models` | Searches Sketchfab for free 3D models (BYOK — set `SKETCHFAB_API_KEY`) |
127
127
  | `analyze_project` | Scans a local SceneView project on disk — detects platform, extracts version, flags outdated deps and known anti-patterns |
128
+ | `search_android_docs` | Searches Google's stock Android docs knowledge base (needs the `android` CLI on PATH) |
129
+ | `fetch_android_doc` | Fetches a full Android docs entry by its `kb://...` URI (needs the `android` CLI on PATH) |
128
130
 
129
131
  ### 2 resources
130
132
 
@@ -206,7 +208,7 @@ The assistant calls `validate_code` with the generated snippet and checks it aga
206
208
  - Always use the current SceneView 4.0.x API surface
207
209
  - Generate correct **Compose-native** 3D/AR code for Android
208
210
  - Generate correct **SwiftUI-native** code for iOS/macOS/visionOS
209
- - Know about all 35+ node types and their exact parameters
211
+ - Know about all 42+ node types and their exact parameters
210
212
  - Validate code against 15+ rules before presenting it
211
213
  - Provide working, tested sample code for 33 scenarios
212
214
 
@@ -0,0 +1,206 @@
1
+ /**
2
+ * android-docs — `search_android_docs` / `fetch_android_doc` MCP tools.
3
+ *
4
+ * Google's `android` CLI (https://developer.android.com/tools/agents/android-cli)
5
+ * ships a `docs` subcommand backed by a knowledge base of ~4 800 stock Android
6
+ * documentation entries: Jetpack Compose, Camera2, ARCore SDK, Kotlin APIs,
7
+ * platform guides, and more. Wrapping it as MCP tools lets any MCP-aware
8
+ * assistant cross-reference stock Android docs with SceneView code without
9
+ * leaving the SceneView chat (issue #1083).
10
+ *
11
+ * - `search_android_docs(query)` → `android docs search <query>`
12
+ * - `fetch_android_doc(uri)` → `android docs fetch kb://<path>`
13
+ *
14
+ * Runtime dependency: the `android` CLI must be on the consumer's PATH. It is
15
+ * NOT a hard dependency of `sceneview-mcp` — most MCP hosts won't have it
16
+ * installed. Every code path here detects the binary up front and returns a
17
+ * structured, friendly error (never throws / crashes the MCP server) when it
18
+ * is absent.
19
+ *
20
+ * Hygiene ported from `.claude/scripts/lib/android-cli.sh`:
21
+ * - `--no-metrics` is passed on every invocation (keeps telemetry off and
22
+ * output clean).
23
+ * - The CLI prints a one-time terms-of-service blurb to stderr on its first
24
+ * invocation; we run a throwaway `--version` once per process to consume
25
+ * it so the real `docs` call returns clean stdout.
26
+ */
27
+ import { execFile } from "node:child_process";
28
+ // ─── Configuration ──────────────────────────────────────────────────────────
29
+ /** Global flags applied to every `android` invocation (telemetry off). */
30
+ const ANDROID_CLI_GLOBAL_FLAGS = ["--no-metrics"];
31
+ /** Hard cap on a single `android docs` call so a hung CLI can't wedge MCP. */
32
+ const ANDROID_CLI_TIMEOUT_MS = 20_000;
33
+ /** Cap the captured stdout/stderr so a pathological response can't blow memory. */
34
+ const ANDROID_CLI_MAX_BUFFER = 4 * 1024 * 1024; // 4 MB
35
+ const INSTALL_URL = "https://developer.android.com/tools/agents/android-cli";
36
+ // ─── CLI detection ───────────────────────────────────────────────────────────
37
+ /**
38
+ * Whether the first-run ToS blurb has already been consumed for this process.
39
+ * The `android` CLI prints its terms-of-service notice to stderr exactly once
40
+ * per host; running any command absorbs it. We do it lazily, once.
41
+ */
42
+ let tosConsumed = false;
43
+ /** Cached binary-presence result for the lifetime of the MCP process. */
44
+ let cliPresenceCache;
45
+ /**
46
+ * Promisified `execFile` returning stdout + stderr, never rejecting.
47
+ * `error` is non-null when the process exits non-zero, is killed, or cannot
48
+ * be spawned (`ENOENT` when the binary is absent).
49
+ */
50
+ function run(file, args) {
51
+ return new Promise((resolve) => {
52
+ execFile(file, args, { timeout: ANDROID_CLI_TIMEOUT_MS, maxBuffer: ANDROID_CLI_MAX_BUFFER, encoding: "utf8" }, (error, stdout, stderr) => {
53
+ resolve({
54
+ error: error ?? null,
55
+ stdout: stdout ?? "",
56
+ stderr: stderr ?? "",
57
+ });
58
+ });
59
+ });
60
+ }
61
+ /**
62
+ * Detect the `android` CLI on PATH. Result is cached for the process lifetime
63
+ * and also consumes the one-time ToS blurb on first success.
64
+ *
65
+ * Exported for tests; production callers go through `runAndroidDocs`.
66
+ */
67
+ export async function isAndroidCliAvailable() {
68
+ if (cliPresenceCache !== undefined)
69
+ return cliPresenceCache;
70
+ const { error } = await run("android", [...ANDROID_CLI_GLOBAL_FLAGS, "--version"]);
71
+ // ENOENT (binary not found) ⇒ unavailable. Any other exit still means the
72
+ // binary spawned, so it IS present — and that --version run has now consumed
73
+ // the first-run ToS notice.
74
+ const spawnFailed = error?.code === "ENOENT";
75
+ cliPresenceCache = !spawnFailed;
76
+ if (cliPresenceCache)
77
+ tosConsumed = true;
78
+ return cliPresenceCache;
79
+ }
80
+ /** Test-only: reset the process-scoped CLI detection / ToS caches. */
81
+ export function __resetAndroidCliCache() {
82
+ cliPresenceCache = undefined;
83
+ tosConsumed = false;
84
+ }
85
+ function cliMissingError() {
86
+ return {
87
+ ok: false,
88
+ code: "cli_missing",
89
+ message: [
90
+ "This tool needs Google's `android` CLI, which is not installed on this MCP host.",
91
+ "",
92
+ "`android docs` is an optional runtime dependency — `sceneview-mcp` works fine without it,",
93
+ "but `search_android_docs` / `fetch_android_doc` cannot run until the CLI is on PATH.",
94
+ "",
95
+ `Install it from ${INSTALL_URL} (or run \`bash .claude/scripts/android-env-check.sh --fix\``,
96
+ "from a SceneView checkout), then retry.",
97
+ ].join("\n"),
98
+ };
99
+ }
100
+ // ─── Core runner ─────────────────────────────────────────────────────────────
101
+ /**
102
+ * Run an `android docs <subcommand> <arg>` invocation and return its stdout
103
+ * as a structured result. Never throws.
104
+ */
105
+ async function runAndroidDocs(subcommand, arg) {
106
+ if (!(await isAndroidCliAvailable())) {
107
+ return cliMissingError();
108
+ }
109
+ // Belt-and-braces: ensure the first-run ToS notice is consumed. Normally
110
+ // `isAndroidCliAvailable()` already did this via its `--version` probe, but
111
+ // a caller could have populated `cliPresenceCache` some other way.
112
+ if (!tosConsumed) {
113
+ await run("android", [...ANDROID_CLI_GLOBAL_FLAGS, "--version"]);
114
+ tosConsumed = true;
115
+ }
116
+ const { error, stdout, stderr } = await run("android", [
117
+ ...ANDROID_CLI_GLOBAL_FLAGS,
118
+ "docs",
119
+ subcommand,
120
+ arg,
121
+ ]);
122
+ if (error) {
123
+ // `execFile` sets `killed` + `signal` on a timeout kill.
124
+ const killedByTimeout = error.killed === true ||
125
+ error.signal === "SIGTERM";
126
+ if (killedByTimeout) {
127
+ return {
128
+ ok: false,
129
+ code: "timeout",
130
+ message: `\`android docs ${subcommand}\` did not finish within ${ANDROID_CLI_TIMEOUT_MS / 1000}s.`,
131
+ };
132
+ }
133
+ const detail = (stderr || stdout || error.message).trim();
134
+ return {
135
+ ok: false,
136
+ code: "cli_error",
137
+ message: `\`android docs ${subcommand}\` failed: ${detail}`,
138
+ };
139
+ }
140
+ return { ok: true, output: stdout.trim() };
141
+ }
142
+ // ─── Public API: search ──────────────────────────────────────────────────────
143
+ /**
144
+ * Search the stock Android documentation knowledge base.
145
+ *
146
+ * @param query Free-text query, e.g. `"LazyColumn paging"`.
147
+ */
148
+ export async function searchAndroidDocs(query) {
149
+ if (typeof query !== "string" || query.trim().length === 0) {
150
+ return {
151
+ ok: false,
152
+ code: "invalid_input",
153
+ message: "Missing required parameter: `query` must be a non-empty string.",
154
+ };
155
+ }
156
+ return runAndroidDocs("search", query.trim());
157
+ }
158
+ // ─── Public API: fetch ───────────────────────────────────────────────────────
159
+ /**
160
+ * Fetch a single Android documentation entry by its knowledge-base URI.
161
+ *
162
+ * @param uri A `kb://...` URI as returned by `search_android_docs`. A bare
163
+ * path (no scheme) is tolerated and normalised to `kb://`.
164
+ */
165
+ export async function fetchAndroidDoc(uri) {
166
+ if (typeof uri !== "string" || uri.trim().length === 0) {
167
+ return {
168
+ ok: false,
169
+ code: "invalid_input",
170
+ message: "Missing required parameter: `uri` must be a non-empty `kb://...` string.",
171
+ };
172
+ }
173
+ let normalised = uri.trim();
174
+ if (!normalised.startsWith("kb://")) {
175
+ // Tolerate a bare path or a leading slash — normalise to a kb:// URI so an
176
+ // assistant that drops the scheme still gets a result.
177
+ normalised = `kb://${normalised.replace(/^\/+/, "")}`;
178
+ }
179
+ return runAndroidDocs("fetch", normalised);
180
+ }
181
+ // ─── Formatting ──────────────────────────────────────────────────────────────
182
+ /** Render a search result as the markdown text block the MCP dispatcher returns. */
183
+ export function formatAndroidDocsSearch(query, result) {
184
+ if (!result.ok)
185
+ return result.message;
186
+ if (result.output.length === 0) {
187
+ return `No Android documentation entries found for "${query}". Try a broader query.`;
188
+ }
189
+ return [
190
+ `## Android docs — search results for "${query}"`,
191
+ "",
192
+ result.output,
193
+ "",
194
+ "---",
195
+ "Use `fetch_android_doc` with a `kb://...` URI above to read a full entry.",
196
+ ].join("\n");
197
+ }
198
+ /** Render a fetch result as the markdown text block the MCP dispatcher returns. */
199
+ export function formatAndroidDocsFetch(uri, result) {
200
+ if (!result.ok)
201
+ return result.message;
202
+ if (result.output.length === 0) {
203
+ return `No Android documentation entry found at \`${uri}\`.`;
204
+ }
205
+ return [`## Android docs — \`${uri}\``, "", result.output].join("\n");
206
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Inline example resources exposed via MCP `examples://` URIs.
3
+ *
4
+ * Two resources, intentionally inline (no fs reads / no fetches) so the
5
+ * `sceneview-mcp` package stays a pure code dependency:
6
+ *
7
+ * - `examples://demo-with-settings` — DemoScaffold v2 pattern (PR #1169 under
8
+ * issue #1154). Full-screen 3D / AR scene + ModalBottomSheet controls
9
+ * + horizontal FilterChip picker row.
10
+ *
11
+ * - `examples://sketchfab-streaming` — `SketchfabAssetResolver` pattern
12
+ * (Stage 2 of umbrella issue #1152). Stream CC-BY models from Sketchfab
13
+ * with per-slug bundled fallback + LRU cache + bounds sanity check.
14
+ *
15
+ * These strings deliberately stay small (≤ 4 KB each) so the resource list
16
+ * doesn't bloat the client's context window. The full recipes live at
17
+ * `docs/docs/recipes/sketchfab-streaming.md` and
18
+ * `docs/docs/recipes/demo-settings-sheet.md`.
19
+ */
20
+ export const DEMO_WITH_SETTINGS_EXAMPLE = `# Example — DemoScaffold v2 (full-screen scene + ModalBottomSheet controls)
21
+
22
+ \`DemoScaffold\` v2 is the shared scaffold every SceneView sample-app demo uses. It renders the 3D / AR scene **full-screen** under the top bar, with a \`Tune\` FAB pinned bottom-right that opens a Material 3 ModalBottomSheet containing the controls.
23
+
24
+ \`\`\`kotlin
25
+ @Composable
26
+ fun MyDemo(onBack: () -> Unit) {
27
+ var iblIntensity by remember { mutableFloatStateOf(5_000f) }
28
+ var spinScene by remember { mutableStateOf(true) }
29
+ val engine = rememberEngine()
30
+ val modelLoader = rememberModelLoader(engine)
31
+
32
+ DemoScaffold(
33
+ title = "My Demo",
34
+ onBack = onBack,
35
+ controls = {
36
+ // Same Column scope you'd use in any settings sheet.
37
+ Text(
38
+ "IBL intensity: \${iblIntensity.toInt()} lux",
39
+ style = MaterialTheme.typography.labelLarge,
40
+ )
41
+ Slider(
42
+ value = iblIntensity,
43
+ onValueChange = { iblIntensity = it },
44
+ valueRange = 0f..10_000f,
45
+ )
46
+ Spacer(modifier = Modifier.height(8.dp))
47
+ Row(
48
+ modifier = Modifier.fillMaxWidth(),
49
+ horizontalArrangement = Arrangement.SpaceBetween,
50
+ verticalAlignment = Alignment.CenterVertically,
51
+ ) {
52
+ Text("Spin scene", style = MaterialTheme.typography.bodyMedium)
53
+ Switch(checked = spinScene, onCheckedChange = { spinScene = it })
54
+ }
55
+ },
56
+ ) {
57
+ // BoxScope — full-screen scene.
58
+ SceneView(
59
+ modifier = Modifier.fillMaxSize(),
60
+ engine = engine,
61
+ modelLoader = modelLoader,
62
+ ) {
63
+ // … nodes go here.
64
+ }
65
+ }
66
+ }
67
+ \`\`\`
68
+
69
+ **Gestures.** Tap FAB → opens the sheet. Tap peek chip ("Settings", above the FAB) → opens the sheet (discoverability — added under issue #951). Long-press peek chip → toggles \`DemoSettings.qaMode\` for deterministic screenshot captures. Drag handle / outside tap / back → dismiss. AR sessions keep tracking underneath.
70
+
71
+ **Picker pattern.** Combine with the FilterChip horizontal row for bundled-vs-streamed asset selection — see \`examples://sketchfab-streaming\`.
72
+
73
+ **Full doc:** \`docs/docs/recipes/demo-settings-sheet.md\` (PR #1169, issue #1154).
74
+ `;
75
+ export const SKETCHFAB_STREAMING_EXAMPLE = `# Example — Stream Sketchfab CC-BY models into a SceneView demo
76
+
77
+ The sample app (\`samples/android-demo\`) streams CC-BY licensed glTF models from Sketchfab on demand instead of bundling 30 MB of GLBs in the APK. The same pattern works in any SceneView consumer.
78
+
79
+ \`\`\`kotlin
80
+ @Composable
81
+ fun MyStreamedDemo(onBack: () -> Unit) {
82
+ val context = LocalContext.current
83
+ val resolver = remember { SketchfabAssetResolver.getInstance(context) }
84
+ val engine = rememberEngine()
85
+ val modelLoader = rememberModelLoader(engine)
86
+
87
+ // Warm the cache so the first frame doesn't pop in. Concurrent calls for
88
+ // the same slug deduplicate at the service layer.
89
+ LaunchedEffect(Unit) {
90
+ runCatching { resolver.prefetchAll("animation") }
91
+ }
92
+
93
+ // Pick a slug from the curated registry — categories: solar, gallery,
94
+ // animation, park, ar_placement, physics, materials.
95
+ val slug = remember { SampleAssets.byCategory["animation"].orEmpty().first() }
96
+
97
+ // Resolve to a local file (null while downloading / staging the fallback).
98
+ val file: File? by produceState<File?>(initialValue = null, key1 = slug.uid) {
99
+ value = runCatching { resolver.resolve(slug) }.getOrNull()
100
+ }
101
+
102
+ val instance = file?.let {
103
+ rememberModelInstance(modelLoader, "file://\${it.absolutePath}")
104
+ }
105
+
106
+ DemoScaffold(title = slug.displayName, onBack = onBack) {
107
+ SceneView(
108
+ modifier = Modifier.fillMaxSize(),
109
+ engine = engine,
110
+ modelLoader = modelLoader,
111
+ ) {
112
+ instance?.let {
113
+ ModelNode(
114
+ modelInstance = it,
115
+ scaleToUnits = slug.scaleToUnits,
116
+ autoAnimate = slug.hasBakedAnimation,
117
+ )
118
+ }
119
+ }
120
+ }
121
+ }
122
+ \`\`\`
123
+
124
+ **Hard rules.**
125
+
126
+ - **CC-BY 4.0 only.** \`SketchfabSlug\`'s constructor rejects non-CC-BY models so the registry can't silently regress.
127
+ - **No Sketchfab WebView / external link.** All loading is in-process; Sketchfab is an invisible CDN, not a UX surface.
128
+ - **Never ship a build that needs the network to render something.** The resolver returns a bundled fallback when \`SketchfabConfig.apiKey == null\` or the download fails.
129
+ - **Attribute the author.** Every streamed model surfaced in the UI must show \`slug.author\` — CC-BY 4.0 attribution requirement.
130
+
131
+ **LRU cache.** \`Context.cacheDir/sketchfab/\` (250 MB samples-side cap, evicted oldest-first by \`lastModified\`).
132
+
133
+ **Bounds sanity check.** The resolver verifies the \`glTF\` magic header + size ≥ 12 B before returning a streamed file. Truncated downloads / wrong-format payloads fall back to the bundled asset.
134
+
135
+ **Full doc:** \`docs/docs/recipes/sketchfab-streaming.md\` (umbrella issue #1152, Stage 1 PR #1168).
136
+ `;