release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# @wrnexus/mcp
|
||||
|
||||
Editor-neutral Model Context Protocol server for AI development tools.
|
||||
|
||||
```bash
|
||||
bunx wrnexus-mcp --root=.
|
||||
```
|
||||
|
||||
It exposes current routes, components with props/events, database schema files, compiler diagnostics,
|
||||
runtime errors, dev-server health, framework documentation and installed packages. Files are resolved
|
||||
inside the configured application root and returned as bounded structured JSON.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@wrnexus/mcp",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Model Context Protocol server exposing WRNexus application and framework context.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./stdio": "./src/stdio.ts"
|
||||
},
|
||||
"bin": {
|
||||
"wrnexus-mcp": "src/stdio.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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" },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createInterface } from "node:readline";
|
||||
import { createFrameworkMcpServer } from "./index.ts";
|
||||
|
||||
export async function runMcpStdio(root = process.cwd()): Promise<void> {
|
||||
const server = createFrameworkMcpServer(root);
|
||||
const lines = createInterface({ input: process.stdin, terminal: false });
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } })}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const response = await server.handle(message);
|
||||
if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const root = process.argv.find((value) => value.startsWith("--root="))?.slice(7) ?? process.cwd();
|
||||
await runMcpStdio(root);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFrameworkMcpServer } from "../src/index.ts";
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-mcp-"));
|
||||
for (const dir of ["app/pages", "app/api", "app/components", "app/db/migrations", "docs"])
|
||||
mkdirSync(join(root, dir), { recursive: true });
|
||||
writeFileSync(join(root, "app/pages/index.wrn"), "<h1>Home</h1>");
|
||||
writeFileSync(join(root, "app/api/users.ts"), "export const GET = () => Response.json([])");
|
||||
writeFileSync(
|
||||
join(root, "app/components/Button.wrn"),
|
||||
"prop label: string\nevent click\n<button>{label}</button>",
|
||||
);
|
||||
writeFileSync(join(root, "app/db/migrations/001.sql"), "CREATE TABLE users(id TEXT);");
|
||||
writeFileSync(join(root, "docs/GUIDE.md"), "# Guide");
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ dependencies: { "@wrnexus/core": "0.8.0", other: "1.0.0" } }),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("WRNexus MCP", () => {
|
||||
test("exposes application framework context through bounded tools", async () => {
|
||||
const server = createFrameworkMcpServer(fixture(), {
|
||||
fetch: (async (input: URL | RequestInfo) =>
|
||||
new Response(null, {
|
||||
status: String(input).endsWith("readyz") ? 503 : 200,
|
||||
})) as unknown as typeof fetch,
|
||||
runtimeErrors: () => [{ message: "bad", token: "secret" }],
|
||||
});
|
||||
expect(((await server.call("wrnexus_routes")) as any[]).map((route) => route.path)).toEqual([
|
||||
"/",
|
||||
"/users",
|
||||
]);
|
||||
expect(await server.call("wrnexus_components")).toEqual([
|
||||
{ name: "Button", file: "app/components/Button.wrn", props: ["label"], events: ["click"] },
|
||||
]);
|
||||
expect(((await server.call("wrnexus_database_schema")) as any[])[0].content).toContain(
|
||||
"CREATE TABLE",
|
||||
);
|
||||
expect(await server.call("wrnexus_packages")).toEqual([
|
||||
{ name: "@wrnexus/core", version: "0.8.0" },
|
||||
]);
|
||||
expect(await server.call("wrnexus_runtime_errors")).toEqual([
|
||||
{ message: "bad", token: "[REDACTED]" },
|
||||
]);
|
||||
expect(await server.call("wrnexus_dev_status")).toMatchObject({
|
||||
liveness: { ok: true },
|
||||
readiness: { ok: false },
|
||||
});
|
||||
});
|
||||
test("speaks MCP JSON-RPC initialize, list and call", async () => {
|
||||
const server = createFrameworkMcpServer(fixture());
|
||||
expect(await server.handle({ jsonrpc: "2.0", id: 1, method: "initialize" })).toHaveProperty(
|
||||
"result.serverInfo.name",
|
||||
"wrnexus",
|
||||
);
|
||||
expect(await server.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" })).toHaveProperty(
|
||||
"result.tools.0.name",
|
||||
"wrnexus_routes",
|
||||
);
|
||||
const result = await server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_docs", arguments: {} },
|
||||
});
|
||||
expect(result).toHaveProperty("result.structuredContent.value.0", "docs/GUIDE.md");
|
||||
});
|
||||
test("rejects documentation traversal", async () => {
|
||||
const server = createFrameworkMcpServer(fixture());
|
||||
await expect(server.call("wrnexus_docs", { file: "../secret" })).rejects.toThrow(
|
||||
"WRN-MCP-DOC-PATH",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user