Skip to content

Commit 5c6ca94

Browse files
Thomas Gorisseclaude
andauthored
feat(mcp): add get_node_reference tool (#676)
Adds a new MCP tool that looks up the full API reference for a specific SceneView node type (e.g. ModelNode, LightNode, ARScene) by parsing the repo-local llms.txt. Returns the relevant ### section as structured markdown; falls back to listing all available types when the requested name is not found. - New module: mcp/src/node-reference.ts — parseNodeSections / findNodeSection / listNodeTypes - New test file: mcp/src/node-reference.test.ts — 26 tests (all passing) - mcp/src/index.ts — tool registration + handler wired in - mcp/package.json — dist/node-reference.js added to published files Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a36bce2 commit 5c6ca94

4 files changed

Lines changed: 280 additions & 0 deletions

File tree

mcp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"dist/index.js",
3535
"dist/issues.js",
3636
"dist/migration.js",
37+
"dist/node-reference.js",
3738
"dist/samples.js",
3839
"dist/validator.js",
3940
"llms.txt"

mcp/src/index.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getSample, SAMPLE_IDS, SAMPLES } from "./samples.js";
1515
import { validateCode, formatValidationReport } from "./validator.js";
1616
import { MIGRATION_GUIDE } from "./migration.js";
1717
import { fetchKnownIssues } from "./issues.js";
18+
import { parseNodeSections, findNodeSection, listNodeTypes } from "./node-reference.js";
1819

1920
const __dirname = dirname(fileURLToPath(import.meta.url));
2021

@@ -25,6 +26,8 @@ try {
2526
API_DOCS = "SceneView API docs not found. Run `npm run prepare` to bundle llms.txt.";
2627
}
2728

29+
const NODE_SECTIONS = parseNodeSections(API_DOCS);
30+
2831
const server = new Server(
2932
{ name: "@sceneview/mcp", version: "3.1.1" },
3033
{ capabilities: { resources: {}, tools: {} } }
@@ -147,6 +150,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
147150
required: [],
148151
},
149152
},
153+
{
154+
name: "get_node_reference",
155+
description:
156+
"Returns the full API reference for a specific SceneView node type or composable — parameters, types, and a usage example — parsed directly from the official llms.txt. Use this when you need the exact signature or options for a node (e.g. ModelNode, LightNode, ARScene). If the requested type is not found, the response lists all available types.",
157+
inputSchema: {
158+
type: "object",
159+
properties: {
160+
nodeType: {
161+
type: "string",
162+
description:
163+
'The node type or composable name to look up, e.g. "ModelNode", "LightNode", "ARScene", "AnchorNode". Case-insensitive.',
164+
},
165+
},
166+
required: ["nodeType"],
167+
},
168+
},
150169
],
151170
}));
152171

@@ -305,6 +324,49 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
305324
return { content: [{ type: "text", text: MIGRATION_GUIDE }] };
306325
}
307326

327+
// ── get_node_reference ────────────────────────────────────────────────────
328+
case "get_node_reference": {
329+
const nodeType = request.params.arguments?.nodeType as string;
330+
if (!nodeType || typeof nodeType !== "string") {
331+
return {
332+
content: [{ type: "text", text: "Missing required parameter: `nodeType`" }],
333+
isError: true,
334+
};
335+
}
336+
337+
const section = findNodeSection(NODE_SECTIONS, nodeType);
338+
339+
if (!section) {
340+
const available = listNodeTypes(NODE_SECTIONS).join(", ");
341+
return {
342+
content: [
343+
{
344+
type: "text",
345+
text: [
346+
`No reference found for node type \`${nodeType}\`.`,
347+
``,
348+
`**Available node types:**`,
349+
available,
350+
].join("\n"),
351+
},
352+
],
353+
};
354+
}
355+
356+
return {
357+
content: [
358+
{
359+
type: "text",
360+
text: [
361+
`## \`${section.name}\` — API Reference`,
362+
``,
363+
section.content,
364+
].join("\n"),
365+
},
366+
],
367+
};
368+
}
369+
308370
default:
309371
return {
310372
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],

mcp/src/node-reference.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { dirname, resolve } from "path";
4+
import { fileURLToPath } from "url";
5+
import { parseNodeSections, findNodeSection, listNodeTypes } from "./node-reference.js";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
9+
// Load the real llms.txt (copied alongside the compiled output or at repo root during dev)
10+
let LLMS_TXT: string;
11+
try {
12+
// Running from dist/ (after build): llms.txt is one level up
13+
LLMS_TXT = readFileSync(resolve(__dirname, "../llms.txt"), "utf-8");
14+
} catch {
15+
// Running via tsx from src/ in dev: llms.txt is two levels up (repo root)
16+
LLMS_TXT = readFileSync(resolve(__dirname, "../../llms.txt"), "utf-8");
17+
}
18+
19+
const SECTIONS = parseNodeSections(LLMS_TXT);
20+
21+
// ─── parseNodeSections ────────────────────────────────────────────────────────
22+
23+
describe("parseNodeSections", () => {
24+
it("returns a non-empty map", () => {
25+
expect(SECTIONS.size).toBeGreaterThan(0);
26+
});
27+
28+
it("contains ModelNode", () => {
29+
expect(SECTIONS.has("modelnode")).toBe(true);
30+
});
31+
32+
it("contains LightNode", () => {
33+
expect(SECTIONS.has("lightnode")).toBe(true);
34+
});
35+
36+
it("contains Scene", () => {
37+
expect(SECTIONS.has("scene")).toBe(true);
38+
});
39+
40+
it("contains ARScene", () => {
41+
expect(SECTIONS.has("arscene")).toBe(true);
42+
});
43+
44+
it("contains AnchorNode", () => {
45+
expect(SECTIONS.has("anchornode")).toBe(true);
46+
});
47+
48+
it("each section has a non-empty content string", () => {
49+
for (const [, section] of SECTIONS) {
50+
expect(section.content.length).toBeGreaterThan(0);
51+
}
52+
});
53+
54+
it("section content starts with ### heading", () => {
55+
for (const [, section] of SECTIONS) {
56+
expect(section.content).toMatch(/^###\s/);
57+
}
58+
});
59+
60+
it("ModelNode content includes scaleToUnits parameter", () => {
61+
const section = SECTIONS.get("modelnode")!;
62+
expect(section.content).toContain("scaleToUnits");
63+
});
64+
65+
it("LightNode content mentions apply named parameter", () => {
66+
const section = SECTIONS.get("lightnode")!;
67+
expect(section.content).toContain("apply");
68+
});
69+
});
70+
71+
// ─── findNodeSection ──────────────────────────────────────────────────────────
72+
73+
describe("findNodeSection", () => {
74+
it("finds ModelNode case-insensitively", () => {
75+
expect(findNodeSection(SECTIONS, "ModelNode")).toBeDefined();
76+
expect(findNodeSection(SECTIONS, "modelnode")).toBeDefined();
77+
expect(findNodeSection(SECTIONS, "MODELNODE")).toBeDefined();
78+
});
79+
80+
it("finds ARScene case-insensitively", () => {
81+
expect(findNodeSection(SECTIONS, "ARScene")).toBeDefined();
82+
expect(findNodeSection(SECTIONS, "arscene")).toBeDefined();
83+
});
84+
85+
it("returns undefined for an unknown type", () => {
86+
expect(findNodeSection(SECTIONS, "NonExistentNode")).toBeUndefined();
87+
});
88+
89+
it("returned section has the correct canonical name", () => {
90+
const s = findNodeSection(SECTIONS, "lightnode");
91+
expect(s!.name).toBe("LightNode");
92+
});
93+
94+
it("returned section content is non-empty markdown", () => {
95+
const s = findNodeSection(SECTIONS, "ModelNode");
96+
expect(s!.content.length).toBeGreaterThan(10);
97+
});
98+
});
99+
100+
// ─── listNodeTypes ────────────────────────────────────────────────────────────
101+
102+
describe("listNodeTypes", () => {
103+
it("returns a sorted array", () => {
104+
const types = listNodeTypes(SECTIONS);
105+
const sorted = [...types].sort();
106+
expect(types).toEqual(sorted);
107+
});
108+
109+
it("includes key node types", () => {
110+
const types = listNodeTypes(SECTIONS);
111+
expect(types).toContain("ModelNode");
112+
expect(types).toContain("LightNode");
113+
expect(types).toContain("Scene");
114+
expect(types).toContain("ARScene");
115+
expect(types).toContain("AnchorNode");
116+
});
117+
118+
it("returns at least 10 entries (enough node types in llms.txt)", () => {
119+
expect(listNodeTypes(SECTIONS).length).toBeGreaterThanOrEqual(10);
120+
});
121+
});

mcp/src/node-reference.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* node-reference.ts
3+
*
4+
* Parses llms.txt to extract per-node-type API reference sections.
5+
* Each section starts at a `###` heading and ends just before the next `###` or `##` heading.
6+
*/
7+
8+
export interface NodeSection {
9+
/** Canonical name as it appears in the `###` heading (e.g. "ModelNode") */
10+
name: string;
11+
/** Full heading line (e.g. "ModelNode" or "LightNode") */
12+
heading: string;
13+
/** Raw markdown content of the section (heading + body) */
14+
content: string;
15+
}
16+
17+
// ─── Parsing ─────────────────────────────────────────────────────────────────
18+
19+
/**
20+
* Extract the bare node-type name from a `###` heading line.
21+
* Examples:
22+
* "Scene — 3D viewport" → "Scene"
23+
* "HitResultNode — surface cursor" → "HitResultNode"
24+
* "Primitive geometry nodes" → "Primitive geometry nodes"
25+
*/
26+
function headingToName(heading: string): string {
27+
return heading.split(/\s*[-]\s*/)[0].trim();
28+
}
29+
30+
/**
31+
* Parse all `###`-level sections from the full llms.txt content.
32+
* Returns a map keyed by the bare node-type name (case-insensitive lookup
33+
* handled separately).
34+
*/
35+
export function parseNodeSections(llmsTxt: string): Map<string, NodeSection> {
36+
const sections = new Map<string, NodeSection>();
37+
const lines = llmsTxt.split("\n");
38+
39+
let i = 0;
40+
while (i < lines.length) {
41+
const line = lines[i];
42+
43+
if (line.startsWith("### ")) {
44+
const headingText = line.slice(4).trim(); // text after "### "
45+
const name = headingToName(headingText);
46+
47+
// Collect lines until the next `##` or `###` heading
48+
const bodyLines: string[] = [line];
49+
i++;
50+
while (i < lines.length && !lines[i].startsWith("## ") && !lines[i].startsWith("### ")) {
51+
bodyLines.push(lines[i]);
52+
i++;
53+
}
54+
55+
// Trim trailing blank lines
56+
while (bodyLines.length > 1 && bodyLines[bodyLines.length - 1].trim() === "") {
57+
bodyLines.pop();
58+
}
59+
60+
sections.set(name.toLowerCase(), {
61+
name,
62+
heading: headingText,
63+
content: bodyLines.join("\n"),
64+
});
65+
} else {
66+
i++;
67+
}
68+
}
69+
70+
return sections;
71+
}
72+
73+
// ─── Public API ──────────────────────────────────────────────────────────────
74+
75+
/**
76+
* Look up a node type section by name (case-insensitive).
77+
*
78+
* @param sections - The map returned by `parseNodeSections`
79+
* @param nodeType - User-supplied name, e.g. "ModelNode", "lightnode", "ARScene"
80+
* @returns The matching `NodeSection`, or `undefined` if not found.
81+
*/
82+
export function findNodeSection(
83+
sections: Map<string, NodeSection>,
84+
nodeType: string
85+
): NodeSection | undefined {
86+
return sections.get(nodeType.toLowerCase());
87+
}
88+
89+
/**
90+
* Returns a sorted array of all known node-type names.
91+
*/
92+
export function listNodeTypes(sections: Map<string, NodeSection>): string[] {
93+
return Array.from(sections.values())
94+
.map((s) => s.name)
95+
.sort();
96+
}

0 commit comments

Comments
 (0)