import { emptyDiagnostics } from "@/lib/diagnostics"; import { analyzeWebsiteWithPlaywright, assertPublicWebsiteUrl, redactWebsiteUrlForLog, type PageControl } from "@/lib/runtime/playwright-website"; import type { GeneratedMcpServer, GenerationDiagnostics, McpTool, OpenApiEndpoint } from "@/lib/types"; export type WebsiteMcpResult = { generated: GeneratedMcpServer; endpointMap: OpenApiEndpoint[]; diagnostics: GenerationDiagnostics; sourceUrl: string; pageTitle: string; }; const MAX_TOOLS = 12; function slug(value: string) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 42) || "element"; } function safeArgumentName(value: string, fallback: string, seen: Set) { const base = slug(value).replace(/^(\d)/, "field_$1") || fallback; const reserved = new Set(["__proto__", "constructor", "prototype", "instruction"]); const safeBase = reserved.has(base) ? `${base}_value` : base; let candidate = safeBase; let suffix = 2; while (seen.has(candidate)) { candidate = `${safeBase}_${suffix}`; suffix += 1; } seen.add(candidate); return candidate; } function inputSchemaFor(control: PageControl) { const properties: Record = { instruction: { type: "string", description: "Optional instruction for the browser runtime before executing this website workflow.", }, }; const required: string[] = []; const seen = new Set(Object.keys(properties)); const inputs = control.inputs ?? []; for (let index = 0; index < inputs.length; index += 1) { const input = inputs[index]; const originalName = input.name || `field_${index + 1}`; const name = safeArgumentName(originalName, `field_${index + 1}`, seen); properties[name] = { type: input.type === "number" ? "number" : "string", description: `Value for ${originalName}.`, "x-astrail-name": originalName, "x-astrail-in": "browser_form", }; if (input.required) required.push(name); } return { type: "object", properties, required }; } function buildTools(controls: PageControl[], title: string): { tools: McpTool[]; endpointMap: OpenApiEndpoint[] } { const deduped = new Map(); for (const control of controls) { const key = `${control.kind}:${slug(control.label)}`; if (!deduped.has(key)) deduped.set(key, control); } const selected = Array.from(deduped.values()).slice(0, MAX_TOOLS); const tools = selected.map((control) => { const name = `browser_${control.kind}_${slug(control.label)}`; const readOnly = control.kind === "open_page" || control.kind === "follow_link" || (control.kind === "submit_form" && (control.method ?? "GET").toUpperCase() === "GET"); return { name, description: `Browser workflow for ${title}: ${control.label}. Safe public reads execute in Playwright; interactive/auth flows require runtime review.`, input_schema: inputSchemaFor(control), method: "BROWSER", path: control.selector ?? control.href ?? "/", annotations: { readOnlyHint: readOnly }, policy: readOnly ? "allow" as const : "approval" as const, }; }); const endpointMap = selected.map((control, index) => { const readOnly = control.kind === "open_page" || control.kind === "follow_link" || (control.kind === "submit_form" && (control.method ?? "GET").toUpperCase() === "GET"); return { method: "BROWSER", path: control.selector ?? control.href ?? "/", runtime_kind: "browser" as const, browser_action: control.kind, selector: control.selector, target_url: control.href ?? control.action ?? null, tool_name: tools[index].name, operation_id: tools[index].name, summary: control.label, description: tools[index].description, parameters: control.inputs?.map((input) => ({ name: input.name, in: "browser_form", required: input.required, schema: { type: input.type === "number" ? "number" : "string" }, })) ?? [], request_body: control.kind === "submit_form" ? { method: control.method ?? "GET" } : undefined, responses: { website_browser_runtime: { description: "Playwright-backed public page read for open/link/GET form workflows." }, browser_runtime_required: { description: "Requires reviewed isolated Playwright execution for interactive workflows." }, }, requires_auth: false, operation_kind: readOnly ? "read" as const : "write" as const, action_class: readOnly ? "read" as const : "write" as const, policy: readOnly ? "allow" as const : "approval" as const, }; }); return { tools, endpointMap }; } function generatedCodeFor(name: string, tools: McpTool[]) { const toolNames = tools.map((tool) => tool.name).join(", "); return `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: ${JSON.stringify(name)}, version: "0.1.0" }); // Website-to-MCP hosted runtime template. // Tools discovered: ${toolNames} // Hosted execution uses Astrail's Playwright-backed runtime, not generated JS eval. server.tool("browser_runtime_status", "Check whether this website MCP server is attached to Astrail's browser runtime.", z.object({}), async () => ({ content: [{ type: "text", text: "Website to MCP template installed. Hosted execution is handled by Astrail's Playwright runtime." }], })); const transport = new StdioServerTransport(); await server.connect(transport); `; } export async function inspectWebsiteForMcp(inputUrl: string): Promise { const input = assertPublicWebsiteUrl(inputUrl); const traceId = `analysis_${Date.now().toString(36)}`; const diagnostics = emptyDiagnostics(input.toString()); diagnostics.discovery_method = "playwright_website_inspection"; const analysis = await analyzeWebsiteWithPlaywright(input.toString(), traceId); const controls: PageControl[] = [ { kind: "open_page", label: "open page", selector: "body", href: analysis.finalUrl }, ...analysis.forms, ...analysis.buttons, ...analysis.links, ]; const { tools, endpointMap } = buildTools(controls, analysis.title); diagnostics.spec_size_bytes = analysis.visibleTextSummary.length; diagnostics.endpoint_count = endpointMap.length; diagnostics.tools_generated = tools.length; diagnostics.selected_group = "Browser workflows"; diagnostics.warnings.push("Website-to-MCP is experimental. Playwright can execute safe public reads; auth, sessions, JS-only clicks, and complex workflows require sandbox review."); diagnostics.raw = [ `Input URL checked: ${redactWebsiteUrlForLog(input.toString())}.`, `Final URL: ${redactWebsiteUrlForLog(analysis.finalUrl)}.`, `Page title: ${analysis.title}.`, `Links found: ${analysis.links.length}.`, `Forms found: ${analysis.forms.length}.`, `Buttons found: ${analysis.buttons.length}.`, `Screenshot: ${analysis.screenshotPath ?? "unavailable"}.`, `Visible text summary: ${analysis.visibleTextSummary.slice(0, 240)}.`, ...analysis.actionHistory.map((item) => `Action: ${item}`), ]; diagnostics.trace.push( { label: "Website opened with Playwright", status: "passed", detail: analysis.finalUrl }, { label: `${controls.length} browser actions detected`, status: "passed", detail: "links, buttons, forms, inputs" }, { label: `${tools.length} MCP tools generated`, status: "passed" }, { label: "Screenshot captured", status: analysis.screenshotPath ? "passed" : "warning", detail: analysis.screenshotPath ?? "Screenshot unavailable" }, { label: "Experimental Playwright runtime enabled", status: "warning", detail: "Safe public reads work; complex workflows need sandbox review." } ); diagnostics.timestamps.completed_at = new Date().toISOString(); const name = `${slug(analysis.title).replace(/_/g, "-") || input.hostname}-browser-server`; return { generated: { name, description: `Playwright-backed MCP workflow candidates discovered from ${analysis.title}.`, tools, generated_code: generatedCodeFor(name, tools), }, endpointMap, diagnostics, sourceUrl: input.toString(), pageTitle: analysis.title, }; }