276 lines
9.6 KiB
TypeScript
276 lines
9.6 KiB
TypeScript
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { extname, join, relative, resolve } from "node:path";
|
|
|
|
export interface McpTool {
|
|
name: string;
|
|
description: string;
|
|
inputSchema: {
|
|
type: "object";
|
|
properties?: Record<string, unknown>;
|
|
additionalProperties?: boolean;
|
|
};
|
|
}
|
|
export interface McpServerOptions {
|
|
maxFiles?: number;
|
|
maxFileBytes?: number;
|
|
fetch?: typeof fetch;
|
|
devServerUrl?: string;
|
|
runtimeErrors?: () => unknown[] | Promise<unknown[]>;
|
|
}
|
|
export interface McpServer {
|
|
tools(): McpTool[];
|
|
call(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
handle(message: unknown): Promise<Record<string, unknown> | null>;
|
|
}
|
|
|
|
const TOOLS: McpTool[] = [
|
|
{
|
|
name: "wrnexus_routes",
|
|
description: "List current application routes and source files.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_components",
|
|
description: "List components with declared props and public events.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_database_schema",
|
|
description: "Read bounded database schema and migration sources.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_diagnostics",
|
|
description: "Read current compiler diagnostics and build report.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_runtime_errors",
|
|
description: "Read captured runtime errors without secrets.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_dev_status",
|
|
description: "Check development server liveness and readiness.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
{
|
|
name: "wrnexus_docs",
|
|
description: "List framework documentation or read one documentation file.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { file: { type: "string" } },
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "wrnexus_packages",
|
|
description: "List installed WRNexus packages and versions.",
|
|
inputSchema: { type: "object", additionalProperties: false },
|
|
},
|
|
];
|
|
|
|
function inside(root: string, path: string): boolean {
|
|
const rel = relative(root, path);
|
|
return rel === "" || (!rel.startsWith("..") && !resolve(rel).startsWith(".."));
|
|
}
|
|
function walk(
|
|
root: string,
|
|
directories: string[],
|
|
options: Required<Pick<McpServerOptions, "maxFiles" | "maxFileBytes">>,
|
|
extensions?: string[],
|
|
) {
|
|
const output: Array<{ file: string; content: string }> = [];
|
|
const visit = (directory: string) => {
|
|
if (!existsSync(directory) || output.length >= options.maxFiles) return;
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (
|
|
output.length >= options.maxFiles ||
|
|
entry.name.startsWith(".") ||
|
|
entry.name === "node_modules" ||
|
|
entry.name === "dist"
|
|
)
|
|
continue;
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) visit(path);
|
|
else if (!extensions || extensions.includes(extname(entry.name)))
|
|
output.push({
|
|
file: relative(root, path).replace(/\\/g, "/"),
|
|
content: readFileSync(path, "utf8").slice(0, options.maxFileBytes),
|
|
});
|
|
}
|
|
};
|
|
for (const directory of directories) visit(resolve(root, directory));
|
|
return output;
|
|
}
|
|
function routePath(file: string) {
|
|
const value = file
|
|
.replace(/^app\/(pages|api)\//, "")
|
|
.replace(/\.(wrn|tsx?|jsx?)$/, "")
|
|
.replace(/\/index$/, "")
|
|
.replace(/\[\.\.\.(\w+)\]/g, "*$1")
|
|
.replace(/\[(\w+)\]/g, ":$1");
|
|
if (value === "index") return "/";
|
|
return `/${value}`.replace(/\/$/, "") || "/";
|
|
}
|
|
function componentContract(content: string) {
|
|
return {
|
|
props: [...content.matchAll(/^\s*prop\s+([A-Za-z_$][\w$]*)/gm)].map((match) => match[1]),
|
|
events: [...content.matchAll(/^\s*(?:event|emit)\s+([A-Za-z_$][\w$.-]*)/gm)].map(
|
|
(match) => match[1],
|
|
),
|
|
};
|
|
}
|
|
function redact(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(redact);
|
|
if (value && typeof value === "object")
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, item]) => [
|
|
/token|secret|password|authorization|cookie/i.test(key) ? key : key,
|
|
/token|secret|password|authorization|cookie/i.test(key) ? "[REDACTED]" : redact(item),
|
|
]),
|
|
);
|
|
return typeof value === "string" ? value.slice(0, 4_000) : value;
|
|
}
|
|
|
|
export function createFrameworkMcpServer(
|
|
appRoot: string,
|
|
options: McpServerOptions = {},
|
|
): McpServer {
|
|
const root = resolve(appRoot);
|
|
const limits = {
|
|
maxFiles: options.maxFiles ?? 200,
|
|
maxFileBytes: options.maxFileBytes ?? 128 * 1024,
|
|
};
|
|
const files = (directories: string[], extensions?: string[]) =>
|
|
walk(root, directories, limits, extensions);
|
|
const call = async (name: string, args: Record<string, unknown> = {}): Promise<unknown> => {
|
|
if (name === "wrnexus_routes")
|
|
return files(["app/pages", "app/api"], [".wrn", ".ts", ".tsx", ".js", ".jsx"])
|
|
.map(({ file }) => ({
|
|
path: routePath(file),
|
|
kind: file.startsWith("app/api/") ? "api" : "page",
|
|
file,
|
|
}))
|
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
if (name === "wrnexus_components")
|
|
return files(["app/components"], [".wrn"]).map(({ file, content }) => ({
|
|
name: file
|
|
.split("/")
|
|
.at(-1)!
|
|
.replace(/\.wrn$/, ""),
|
|
file,
|
|
...componentContract(content),
|
|
}));
|
|
if (name === "wrnexus_database_schema") return files(["app/db"], [".sql", ".ts", ".json"]);
|
|
if (name === "wrnexus_diagnostics")
|
|
return files([".wrnexus", "dist"], [".json"])
|
|
.filter((entry) => /diagnostic|report|contract/i.test(entry.file))
|
|
.map((entry) => ({
|
|
file: entry.file,
|
|
data: (() => {
|
|
try {
|
|
return redact(JSON.parse(entry.content));
|
|
} catch {
|
|
return entry.content;
|
|
}
|
|
})(),
|
|
}));
|
|
if (name === "wrnexus_runtime_errors") return redact((await options.runtimeErrors?.()) ?? []);
|
|
if (name === "wrnexus_dev_status") {
|
|
const base = (options.devServerUrl ?? "http://localhost:3000").replace(/\/$/, "");
|
|
const check = async (path: string) => {
|
|
try {
|
|
const response = await (options.fetch ?? fetch)(`${base}${path}`);
|
|
return { status: response.status, ok: response.ok };
|
|
} catch (error) {
|
|
return {
|
|
status: 0,
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : "Unavailable",
|
|
};
|
|
}
|
|
};
|
|
return { url: base, liveness: await check("/healthz"), readiness: await check("/readyz") };
|
|
}
|
|
if (name === "wrnexus_docs") {
|
|
const docs = files(["docs"], [".md"]);
|
|
if (typeof args.file !== "string") return docs.map(({ file }) => file);
|
|
const path = resolve(root, args.file);
|
|
if (!inside(root, path) || !path.startsWith(resolve(root, "docs")) || !existsSync(path))
|
|
throw new Error(
|
|
"WRN-MCP-DOC-PATH: documentation file is outside the application docs directory.",
|
|
);
|
|
return {
|
|
file: relative(root, path).replace(/\\/g, "/"),
|
|
content: readFileSync(path, "utf8").slice(0, limits.maxFileBytes),
|
|
};
|
|
}
|
|
if (name === "wrnexus_packages") {
|
|
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
};
|
|
return Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })
|
|
.filter(([packageName]) => packageName.startsWith("@wrnexus/"))
|
|
.map(([packageName, version]) => ({ name: packageName, version }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
throw new Error(`WRN-MCP-TOOL: unknown tool '${name}'.`);
|
|
};
|
|
return {
|
|
tools: () => TOOLS.map((tool) => ({ ...tool })),
|
|
call,
|
|
async handle(message) {
|
|
if (!message || typeof message !== "object")
|
|
return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request" } };
|
|
const request = message as { id?: unknown; method?: unknown; params?: any };
|
|
if (request.method === "notifications/initialized") return null;
|
|
if (request.method === "initialize")
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id ?? null,
|
|
result: {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: { tools: { listChanged: false } },
|
|
serverInfo: { name: "wrnexus", version: "0.8.0" },
|
|
},
|
|
};
|
|
if (request.method === "tools/list")
|
|
return { jsonrpc: "2.0", id: request.id ?? null, result: { tools: TOOLS } };
|
|
if (request.method === "tools/call") {
|
|
try {
|
|
const value = await call(
|
|
String(request.params?.name ?? ""),
|
|
request.params?.arguments ?? {},
|
|
);
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id ?? null,
|
|
result: {
|
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
structuredContent: { value },
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id ?? null,
|
|
result: {
|
|
isError: true,
|
|
content: [
|
|
{ type: "text", text: error instanceof Error ? error.message : "Tool failed" },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id ?? null,
|
|
error: { code: -32601, message: "Method not found" },
|
|
};
|
|
},
|
|
};
|
|
}
|