Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"dist/index.js",
"dist/issues.js",
"dist/migration.js",
"dist/node-reference.js",
"dist/samples.js",
"dist/validator.js",
"llms.txt"
Expand Down
62 changes: 62 additions & 0 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getSample, SAMPLE_IDS, SAMPLES } from "./samples.js";
import { validateCode, formatValidationReport } from "./validator.js";
import { MIGRATION_GUIDE } from "./migration.js";
import { fetchKnownIssues } from "./issues.js";
import { parseNodeSections, findNodeSection, listNodeTypes } from "./node-reference.js";

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

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

const NODE_SECTIONS = parseNodeSections(API_DOCS);

const server = new Server(
{ name: "@sceneview/mcp", version: "3.1.1" },
{ capabilities: { resources: {}, tools: {} } }
Expand Down Expand Up @@ -147,6 +150,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
required: [],
},
},
{
name: "get_node_reference",
description:
"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.",
inputSchema: {
type: "object",
properties: {
nodeType: {
type: "string",
description:
'The node type or composable name to look up, e.g. "ModelNode", "LightNode", "ARScene", "AnchorNode". Case-insensitive.',
},
},
required: ["nodeType"],
},
},
],
}));

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

// ── get_node_reference ────────────────────────────────────────────────────
case "get_node_reference": {
const nodeType = request.params.arguments?.nodeType as string;
if (!nodeType || typeof nodeType !== "string") {
return {
content: [{ type: "text", text: "Missing required parameter: `nodeType`" }],
isError: true,
};
}

const section = findNodeSection(NODE_SECTIONS, nodeType);

if (!section) {
const available = listNodeTypes(NODE_SECTIONS).join(", ");
return {
content: [
{
type: "text",
text: [
`No reference found for node type \`${nodeType}\`.`,
``,
`**Available node types:**`,
available,
].join("\n"),
},
],
};
}

return {
content: [
{
type: "text",
text: [
`## \`${section.name}\` — API Reference`,
``,
section.content,
].join("\n"),
},
],
};
}

default:
return {
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
Expand Down
121 changes: 121 additions & 0 deletions mcp/src/node-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { parseNodeSections, findNodeSection, listNodeTypes } from "./node-reference.js";

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

// Load the real llms.txt (copied alongside the compiled output or at repo root during dev)
let LLMS_TXT: string;
try {
// Running from dist/ (after build): llms.txt is one level up
LLMS_TXT = readFileSync(resolve(__dirname, "../llms.txt"), "utf-8");
} catch {
// Running via tsx from src/ in dev: llms.txt is two levels up (repo root)
LLMS_TXT = readFileSync(resolve(__dirname, "../../llms.txt"), "utf-8");
}

const SECTIONS = parseNodeSections(LLMS_TXT);

// ─── parseNodeSections ────────────────────────────────────────────────────────

describe("parseNodeSections", () => {
it("returns a non-empty map", () => {
expect(SECTIONS.size).toBeGreaterThan(0);
});

it("contains ModelNode", () => {
expect(SECTIONS.has("modelnode")).toBe(true);
});

it("contains LightNode", () => {
expect(SECTIONS.has("lightnode")).toBe(true);
});

it("contains Scene", () => {
expect(SECTIONS.has("scene")).toBe(true);
});

it("contains ARScene", () => {
expect(SECTIONS.has("arscene")).toBe(true);
});

it("contains AnchorNode", () => {
expect(SECTIONS.has("anchornode")).toBe(true);
});

it("each section has a non-empty content string", () => {
for (const [, section] of SECTIONS) {
expect(section.content.length).toBeGreaterThan(0);
}
});

it("section content starts with ### heading", () => {
for (const [, section] of SECTIONS) {
expect(section.content).toMatch(/^###\s/);
}
});

it("ModelNode content includes scaleToUnits parameter", () => {
const section = SECTIONS.get("modelnode")!;
expect(section.content).toContain("scaleToUnits");
});

it("LightNode content mentions apply named parameter", () => {
const section = SECTIONS.get("lightnode")!;
expect(section.content).toContain("apply");
});
});

// ─── findNodeSection ──────────────────────────────────────────────────────────

describe("findNodeSection", () => {
it("finds ModelNode case-insensitively", () => {
expect(findNodeSection(SECTIONS, "ModelNode")).toBeDefined();
expect(findNodeSection(SECTIONS, "modelnode")).toBeDefined();
expect(findNodeSection(SECTIONS, "MODELNODE")).toBeDefined();
});

it("finds ARScene case-insensitively", () => {
expect(findNodeSection(SECTIONS, "ARScene")).toBeDefined();
expect(findNodeSection(SECTIONS, "arscene")).toBeDefined();
});

it("returns undefined for an unknown type", () => {
expect(findNodeSection(SECTIONS, "NonExistentNode")).toBeUndefined();
});

it("returned section has the correct canonical name", () => {
const s = findNodeSection(SECTIONS, "lightnode");
expect(s!.name).toBe("LightNode");
});

it("returned section content is non-empty markdown", () => {
const s = findNodeSection(SECTIONS, "ModelNode");
expect(s!.content.length).toBeGreaterThan(10);
});
});

// ─── listNodeTypes ────────────────────────────────────────────────────────────

describe("listNodeTypes", () => {
it("returns a sorted array", () => {
const types = listNodeTypes(SECTIONS);
const sorted = [...types].sort();
expect(types).toEqual(sorted);
});

it("includes key node types", () => {
const types = listNodeTypes(SECTIONS);
expect(types).toContain("ModelNode");
expect(types).toContain("LightNode");
expect(types).toContain("Scene");
expect(types).toContain("ARScene");
expect(types).toContain("AnchorNode");
});

it("returns at least 10 entries (enough node types in llms.txt)", () => {
expect(listNodeTypes(SECTIONS).length).toBeGreaterThanOrEqual(10);
});
});
96 changes: 96 additions & 0 deletions mcp/src/node-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* node-reference.ts
*
* Parses llms.txt to extract per-node-type API reference sections.
* Each section starts at a `###` heading and ends just before the next `###` or `##` heading.
*/

export interface NodeSection {
/** Canonical name as it appears in the `###` heading (e.g. "ModelNode") */
name: string;
/** Full heading line (e.g. "ModelNode" or "LightNode") */
heading: string;
/** Raw markdown content of the section (heading + body) */
content: string;
}

// ─── Parsing ─────────────────────────────────────────────────────────────────

/**
* Extract the bare node-type name from a `###` heading line.
* Examples:
* "Scene — 3D viewport" → "Scene"
* "HitResultNode — surface cursor" → "HitResultNode"
* "Primitive geometry nodes" → "Primitive geometry nodes"
*/
function headingToName(heading: string): string {
return heading.split(/\s*[—–-]\s*/)[0].trim();
}

/**
* Parse all `###`-level sections from the full llms.txt content.
* Returns a map keyed by the bare node-type name (case-insensitive lookup
* handled separately).
*/
export function parseNodeSections(llmsTxt: string): Map<string, NodeSection> {
const sections = new Map<string, NodeSection>();
const lines = llmsTxt.split("\n");

let i = 0;
while (i < lines.length) {
const line = lines[i];

if (line.startsWith("### ")) {
const headingText = line.slice(4).trim(); // text after "### "
const name = headingToName(headingText);

// Collect lines until the next `##` or `###` heading
const bodyLines: string[] = [line];
i++;
while (i < lines.length && !lines[i].startsWith("## ") && !lines[i].startsWith("### ")) {
bodyLines.push(lines[i]);
i++;
}

// Trim trailing blank lines
while (bodyLines.length > 1 && bodyLines[bodyLines.length - 1].trim() === "") {
bodyLines.pop();
}

sections.set(name.toLowerCase(), {
name,
heading: headingText,
content: bodyLines.join("\n"),
});
} else {
i++;
}
}

return sections;
}

// ─── Public API ──────────────────────────────────────────────────────────────

/**
* Look up a node type section by name (case-insensitive).
*
* @param sections - The map returned by `parseNodeSections`
* @param nodeType - User-supplied name, e.g. "ModelNode", "lightnode", "ARScene"
* @returns The matching `NodeSection`, or `undefined` if not found.
*/
export function findNodeSection(
sections: Map<string, NodeSection>,
nodeType: string
): NodeSection | undefined {
return sections.get(nodeType.toLowerCase());
}

/**
* Returns a sorted array of all known node-type names.
*/
export function listNodeTypes(sections: Map<string, NodeSection>): string[] {
return Array.from(sections.values())
.map((s) => s.name)
.sort();
}
Loading