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);
});