Skip to main content
Glama
index.ts16.7 kB
import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { createRequire } from "node:module"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; /** * react-uswds-mcp * * Index strategy: * - Resolve @trussworks/react-uswds/package.json from the host project (node resolution). * - Use package.json "types" (preferred), else probe common d.ts entrypoints. * - Parse the d.ts entry to find exported component symbols. * - For each symbol, try to locate a nearby .d.ts file and extract Props surface heuristically. * * This is intentionally heuristic (fast + dependency-light). You can later upgrade to a TypeScript * AST-based approach (e.g., ts-morph) if you need deeper type introspection. */ type ComponentInfo = { name: string; importPath: string; // usually "@trussworks/react-uswds" props?: { propsTypeNames: string[]; excerpt?: string; }; docs?: { storybookUrl?: string; }; }; type IndexState = { packageRoot: string; packageName: string; typesEntry: string; // absolute path to .d.ts entry components: Map<string, ComponentInfo>; }; const PACKAGE_NAME_DEFAULT = "@trussworks/react-uswds"; function readText(p: string): string { return fs.readFileSync(p, "utf8"); } function fileExists(p: string): boolean { try { fs.accessSync(p, fs.constants.R_OK); return true; } catch { return false; } } function resolvePackageRoot(pkgName: string): { packageRoot: string; packageJsonPath: string } { const req = createRequire(process.cwd() + path.sep); const packageJsonPath = req.resolve(`${pkgName}/package.json`); return { packageRoot: path.dirname(packageJsonPath), packageJsonPath }; } function resolveTypesEntry(packageRoot: string, packageJsonPath: string): string { const pkgJson = JSON.parse(readText(packageJsonPath)) as { types?: string; typings?: string }; const candidates: string[] = []; if (pkgJson.types) candidates.push(path.join(packageRoot, pkgJson.types)); if (pkgJson.typings) candidates.push(path.join(packageRoot, pkgJson.typings)); // common fallbacks candidates.push(path.join(packageRoot, "lib", "index.d.ts")); candidates.push(path.join(packageRoot, "dist", "index.d.ts")); candidates.push(path.join(packageRoot, "index.d.ts")); for (const c of candidates) { if (fileExists(c)) return c; } throw new Error( `Could not find a types entrypoint (.d.ts). Tried: ${candidates.join(", ")}` ); } function extractExportsFromDts(dtsText: string): string[] { // Patterns: // export { Button } from './components/Button'; // export { Alert, Accordion } from ... // export { default as X } from ... const names = new Set<string>(); const namedExportRe = /export\s*\{\s*([^}]+)\s*\}\s*from\s*['"][^'"]+['"]/g; for (const m of dtsText.matchAll(namedExportRe)) { const inside = m[1]; inside .split(",") .map((s) => s.trim()) .filter(Boolean) .forEach((part) => { const asParts = part.split(/\s+as\s+/i).map((s) => s.trim()); const exported = (asParts[1] ?? asParts[0]).replace(/\s+/g, ""); if (exported && /^[A-Z]/.test(exported)) names.add(exported); }); } const defaultAsRe = /export\s*\{\s*default\s+as\s+([A-Za-z0-9_]+)\s*\}\s*from\s*['"][^'"]+['"]/g; for (const m of dtsText.matchAll(defaultAsRe)) { const exported = m[1]; if (exported && /^[A-Z]/.test(exported)) names.add(exported); } return [...names].sort((a, b) => a.localeCompare(b)); } function guessComponentDtsPath( packageRoot: string, typesEntry: string, componentName: string ): string | undefined { const rootsToTry = [ path.dirname(typesEntry), path.join(packageRoot, "lib"), path.join(packageRoot, "dist"), packageRoot, ]; const candidates: string[] = []; for (const r of rootsToTry) { candidates.push(path.join(r, "components", componentName, `${componentName}.d.ts`)); candidates.push(path.join(r, "components", componentName, "index.d.ts")); candidates.push(path.join(r, "components", `${componentName}.d.ts`)); candidates.push(path.join(r, `${componentName}.d.ts`)); } for (const c of candidates) { if (fileExists(c)) return c; } return undefined; } function extractPropsFromComponentDts( dtsText: string ): { propsTypeNames: string[]; excerpt?: string } | undefined { const propsTypeNames = new Set<string>(); const ifaceRe = /export\s+interface\s+([A-Za-z0-9_]*Props)\b/g; for (const m of dtsText.matchAll(ifaceRe)) propsTypeNames.add(m[1]); const typeRe = /export\s+type\s+([A-Za-z0-9_]*Props)\b/g; for (const m of dtsText.matchAll(typeRe)) propsTypeNames.add(m[1]); const names = [...propsTypeNames]; if (names.length === 0) return undefined; const first = names[0]; const idx = dtsText.indexOf(first); if (idx === -1) return { propsTypeNames: names }; const start = Math.max(0, idx - 200); const end = Math.min(dtsText.length, idx + 800); const excerpt = dtsText.slice(start, end); return { propsTypeNames: names, excerpt }; } function buildStorybookUrl(componentName: string): string { // Use Storybook search rather than guessing story IDs. const q = encodeURIComponent(componentName); return `https://trussworks.github.io/react-uswds/?path=/docs&search=${q}`; } function buildIndex(pkgName: string): IndexState { const { packageRoot, packageJsonPath } = resolvePackageRoot(pkgName); const typesEntry = resolveTypesEntry(packageRoot, packageJsonPath); const entryText = readText(typesEntry); const exports = extractExportsFromDts(entryText); const components = new Map<string, ComponentInfo>(); for (const name of exports) { const info: ComponentInfo = { name, importPath: pkgName, docs: { storybookUrl: buildStorybookUrl(name) }, }; const componentDtsPath = guessComponentDtsPath(packageRoot, typesEntry, name); if (componentDtsPath) { const dtsText = readText(componentDtsPath); const props = extractPropsFromComponentDts(dtsText); if (props) info.props = props; } components.set(name, info); } return { packageRoot, packageName: pkgName, typesEntry, components }; } function mdComponent(info: ComponentInfo): string { const lines: string[] = []; lines.push(`# ${info.name}`); lines.push(""); lines.push(`**Import:** \`import { ${info.name} } from '${info.importPath}'\``); lines.push(""); if (info.props?.propsTypeNames?.length) { lines.push(`## Props`); lines.push( `Detected props types: ${info.props.propsTypeNames.map((p) => `\`${p}\``).join(", ")}` ); if (info.props.excerpt) { lines.push(""); lines.push("```ts"); lines.push(info.props.excerpt.trim()); lines.push("```"); } lines.push(""); } if (info.docs?.storybookUrl) { lines.push(`## Storybook`); lines.push(info.docs.storybookUrl); lines.push(""); } return lines.join("\n"); } const ListSchema = z.object({ q: z.string().optional(), limit: z.number().int().min(1).max(500).default(200), }); const GetSchema = z.object({ name: z.string(), }); const SuggestSchema = z.object({ useCase: z.string(), constraints: z .object({ formFactor: z.enum(["page", "form", "modal", "unknown"]).optional(), a11yStrict: z.boolean().optional(), preferSimple: z.boolean().optional(), }) .optional(), }); const UsageSchema = z.object({ name: z.string(), variantHint: z.string().optional(), children: z.string().optional(), }); function normalize(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); } function suggestByHeuristics(index: IndexState, useCase: string): ComponentInfo[] { const n = normalize(useCase); const keywords: Array<{ match: RegExp; picks: string[] }> = [ { match: /(alert|warning|error|success|info)/, picks: ["Alert"] }, { match: /(banner|official|government|https)/, picks: ["Banner", "Header"] }, { match: /(accordion|expand|collapse)/, picks: ["Accordion"] }, { match: /(step|progress|status)/, picks: ["StepIndicator"] }, { match: /(table|grid|rows|columns)/, picks: ["Table"] }, { match: /(date|calendar)/, picks: ["DatePicker", "DateRangePicker", "DateInput"] }, { match: /(select|dropdown|combo|autocomplete)/, picks: ["Select", "ComboBox"] }, { match: /(textarea|multiline)/, picks: ["Textarea"] }, { match: /(checkbox)/, picks: ["Checkbox"] }, { match: /(radio)/, picks: ["Radio"] }, { match: /(file upload|upload|attachment)/, picks: ["FileInput"] }, { match: /(modal|dialog)/, picks: ["Modal"] }, { match: /(tooltip|hint)/, picks: ["Tooltip"] }, { match: /(pagination|pager)/, picks: ["Pagination"] }, { match: /(breadcrumb)/, picks: ["Breadcrumb"] }, { match: /(sidenav|navigation|nav)/, picks: ["SideNav", "NavMenuButton", "PrimaryNav"] }, { match: /(button|cta)/, picks: ["Button"] }, { match: /(form|input|field)/, picks: ["TextInput", "Label", "FormGroup", "Fieldset"] }, ]; const pickedNames = new Set<string>(); for (const k of keywords) { if (k.match.test(n)) k.picks.forEach((p) => pickedNames.add(p)); } if (pickedNames.size === 0) { const tokens = n.split(" ").filter(Boolean); for (const c of index.components.values()) { const cn = normalize(c.name); if (tokens.some((t) => cn.includes(t))) pickedNames.add(c.name); } } const results: ComponentInfo[] = []; for (const name of pickedNames) { const info = index.components.get(name); if (info) results.push(info); } if (results.length === 0) { ["Alert", "Button", "TextInput", "Select", "Table", "Header"].forEach((n) => { const info = index.components.get(n); if (info) results.push(info); }); } return results.slice(0, 8); } async function main() { const server = new McpServer({ name: "react-uswds-mcp", version: "0.1.0", }); const pkgName = process.env.REACT_USWDS_PACKAGE ?? PACKAGE_NAME_DEFAULT; let index: IndexState; try { index = buildIndex(pkgName); } catch (e: any) { index = { packageRoot: "", packageName: pkgName, typesEntry: "", components: new Map(), }; console.error(`Index build failed: ${e?.message ?? String(e)}`); } // Resources server.resource("react-uswds-index", "resource://react-uswds/index", async () => { const list = [...index.components.values()].map((c) => ({ name: c.name, importPath: c.importPath, storybookUrl: c.docs?.storybookUrl, })); return { contents: [ { uri: "resource://react-uswds/index", mimeType: "application/json", text: JSON.stringify( { packageName: index.packageName, count: list.length, components: list, }, null, 2 ), }, ], }; }); server.resource( "react-uswds-component", "resource://react-uswds/components/{name}", async (uri, vars: Record<string, unknown>) => { const name = String(vars["name"]); const info = index.components.get(name); if (!info) { return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: `# Not found\n\nComponent \`${name}\` is not in the current index. Try \`list_components\` or \`search_components\`.`, }, ], }; } return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: mdComponent(info), }, ], }; } ); // Tools server.registerTool( "list_components", { description: "List available @trussworks/react-uswds components (optionally filter by a search string).", inputSchema: ListSchema, }, async ({ q, limit }) => { if (index.components.size === 0) { return { content: [ { type: "text", text: "Component index is empty. Ensure @trussworks/react-uswds is installed in the host project, or set REACT_USWDS_PACKAGE to a resolvable package name.", }, ], }; } const query = (q ?? "").trim(); const items = [...index.components.values()] .filter((c) => { if (!query) return true; const hay = normalize(`${c.name} ${c.docs?.storybookUrl ?? ""}`); return hay.includes(normalize(query)); }) .slice(0, limit) .map((c) => ({ name: c.name, importPath: c.importPath, storybookUrl: c.docs?.storybookUrl, })); return { content: [{ type: "text", text: JSON.stringify({ count: items.length, components: items }, null, 2) }], }; } ); server.registerTool( "search_components", { description: "Fuzzy search for components by keyword. Returns ranked matches with Storybook links.", inputSchema: z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(15) }), }, async ({ q, limit }) => { const query = normalize(q); const scored = [...index.components.values()].map((c) => { const name = normalize(c.name); const score = (name === query ? 100 : 0) + (name.includes(query) ? 50 : 0) + query.split(" ").filter(Boolean).reduce((acc, t) => acc + (name.includes(t) ? 10 : 0), 0); return { c, score }; }); const results = scored .filter((x) => x.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit) .map((x) => ({ name: x.c.name, importPath: x.c.importPath, storybookUrl: x.c.docs?.storybookUrl, score: x.score, })); return { content: [{ type: "text", text: JSON.stringify({ q, results }, null, 2) }] }; } ); server.registerTool( "get_component", { description: "Return a component detail payload (import path, detected props types + excerpt, Storybook URL).", inputSchema: GetSchema, }, async ({ name }) => { const info = index.components.get(name); if (!info) { return { content: [{ type: "text", text: `Component "${name}" not found. Use list_components or search_components.` }] }; } return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] }; } ); server.registerTool( "get_component_usage", { description: "Generate a ready-to-paste TSX snippet for the component with a simple children/variant hint.", inputSchema: UsageSchema, }, async ({ name, variantHint, children }) => { const info = index.components.get(name); if (!info) { return { content: [{ type: "text", text: `Component "${name}" not found. Use search_components first.` }] }; } const child = children ?? (name === "Button" ? "Continue" : `${name} content`); const comment = variantHint ? `// Variant hint: ${variantHint}` : ""; const snippet = [ `import React from "react";`, `import { ${name} } from "${info.importPath}";`, ``, `export function Example() {`, comment ? ` ${comment}` : null, ` return (`, ` <${name}>${child}</${name}>`, ` );`, `}`, ] .filter(Boolean) .join("\n"); return { content: [{ type: "text", text: snippet }] }; } ); server.registerTool( "suggest_components", { description: "Suggest likely ReactUSWDS components for a use case (with alternatives). Uses heuristics + your local component inventory.", inputSchema: SuggestSchema, }, async ({ useCase }) => { const suggestions = suggestByHeuristics(index, useCase).map((c) => ({ name: c.name, importPath: c.importPath, storybookUrl: c.docs?.storybookUrl, })); return { content: [ { type: "text", text: JSON.stringify( { useCase, suggestions, note: "Suggestions are heuristic. Confirm details in Storybook and the component's props types.", }, null, 2 ), }, ], }; } ); const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((err) => { console.error(err); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/focus-digital/react-uswds-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server