Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { normalizeCloneRequestOptions } from "@cloner/core";
import type { Backend } from "./backend.js";
import { filterMetas, metaOf, paginate, readFiles } from "./files.js";
const optionsShape = {
mode: z.enum(["single", "multi"]).optional(),
styling: z.enum(["tailwind", "css"]).optional(),
framework: z.enum(["next", "vite"]).optional(),
verify: z.boolean().optional(),
asyncVerify: z.boolean().optional(),
maxRoutes: z.number().int().positive().optional(),
maxCollection: z.number().int().positive().optional(),
captureConcurrency: z.number().int().positive().optional(),
validationConcurrency: z.number().int().positive().optional(),
viewportConcurrency: z.number().int().positive().optional(),
multiPage: z.boolean().optional(),
humanizeMode: z.enum(["tailwind", "css"]).optional(),
viewports: z.array(z.number().int().positive()).optional(),
interactions: z.boolean().optional(),
components: z.boolean().optional(),
motion: z.boolean().optional(),
noCache: z.boolean().optional(),
};
const json = (data: unknown, isError = false) => ({
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
...(isError ? { isError: true } : {}),
});
/**
* MCP server exposing the same service functions as the REST API. The key
* principle: never flood the agent's context. Tools return references +
* manifests; the agent pulls only the files it needs (list-then-read), and
* binaries/bundles are always URLs, never bytes.
*/
export function createMcpServer(backend: Backend, opts?: { baseUrl?: string }): McpServer {
const baseUrl = opts?.baseUrl ?? "";
const abs = (u: string) => (u.startsWith("/") && baseUrl ? baseUrl + u : u);
const server = new McpServer({ name: "ditto.site", version: "0.1.0" });
// clone_website → start a job; returns immediately, never blocks.
server.registerTool(
"clone_website",
{
description: "Clone a website by URL. Returns { jobId, status } immediately — poll get_clone_status, then browse with list_clone_files / read_clone_files. Never returns file contents.",
inputSchema: { url: z.string().url(), options: z.object(optionsShape).optional() },
},
async ({ url, options }) => {
if (!/^https?:\/\//i.test(url)) return json({ error: "url must be http(s)" }, true);
const out = await backend.submit(url, normalizeCloneRequestOptions(options ?? {}));
return json({ jobId: out.jobId, status: out.status });
},
);
server.registerTool(
"get_clone_status",
{ description: "Poll a clone job's status.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const v = await backend.status(jobId);
if (!v) return json({ error: "not found", jobId }, true);
return json({ jobId, status: v.status, timings: v.timings, capture: v.capture, error: v.error });
},
);
// get_clone_result → METADATA ONLY (no file contents): the cheap overview.
server.registerTool(
"get_clone_result",
{ description: "Get a clone's result metadata (status, timings, routes, verify summary, capture sanity, fileCount, totalBytes, bundleUrl) — NO file contents.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const v = await backend.status(jobId);
if (!v) return json({ error: "not found", jobId }, true);
if (v.status !== "succeeded" && v.status !== "cached") return json({ jobId, status: v.status, error: v.error });
return json({
jobId,
status: v.status,
url: v.url,
kind: v.kind,
timings: v.timings,
routes: v.routes,
capture: v.capture,
verify: v.verify,
fileCount: v.fileCount,
totalBytes: v.totalBytes,
bundleUrl: abs(`/v1/clones/${jobId}/bundle?format=tgz`),
});
},
);
// list_clone_files → the manifest (paths only, no content), filterable + paginated.
server.registerTool(
"list_clone_files",
{
description: "List a clone's files as a manifest [{ path, type, bytes, sha256 }] with NO content. Filter by glob (e.g. \"**/*.tsx\") or route; paginated via cursor.",
inputSchema: { jobId: z.string(), glob: z.string().optional(), route: z.string().optional(), cursor: z.string().optional(), limit: z.number().int().positive().max(1000).optional() },
},
async ({ jobId, glob, route, cursor, limit }) => {
const facets = await backend.facets(jobId);
if (!facets) return json({ error: "not found or not ready", jobId }, true);
const metas = filterMetas(metaOf(facets), { glob, route }).sort((a, b) => (a.path < b.path ? -1 : 1));
const page = paginate(metas, cursor, limit ?? 200);
return json({ jobId, files: page.items, nextCursor: page.nextCursor, total: metas.length });
},
);
// read_clone_files → contents for SPECIFIC files (text inline, binaries as URLs),
// with a per-call size budget so a careless request can't blow the window.
server.registerTool(
"read_clone_files",
{
description: "Read specific files by exact path. Text inline; binaries as URLs (never bytes). Enforces a per-call size budget (default 256KB); oversized text is flagged skipped.",
inputSchema: { jobId: z.string(), paths: z.array(z.string()).min(1), maxBytes: z.number().int().positive().optional() },
},
async ({ jobId, paths, maxBytes }) => {
const facets = await backend.facets(jobId);
if (!facets) return json({ error: "not found or not ready", jobId }, true);
const res = await readFiles(facets, paths, { maxBytes, resolveUrl: abs });
return json({ jobId, ...res });
},
);
// get_clone_bundle → a DOWNLOAD REFERENCE to the whole app (URL, not bytes).
server.registerTool(
"get_clone_bundle",
{ description: "Get a download reference to the whole clone as one compressed archive: { url, format, bytes, sha256 } — a URL, not the bytes. Fetch it out-of-band and expand to a runnable generated app.", inputSchema: { jobId: z.string(), format: z.enum(["tgz", "zip"]).optional() } },
async ({ jobId, format }) => {
const b = await backend.bundle(jobId, format ?? "tgz");
if (!b) return json({ error: "not found or not ready", jobId }, true);
const url = b.url ?? abs(`/v1/clones/${jobId}/bundle?format=${b.format}`);
return json({ jobId, url, format: b.format, bytes: b.bytes.length, sha256: b.sha256 });
},
);
server.registerTool(
"list_clones",
{ description: "List recent clone jobs (metadata only).", inputSchema: {} },
async () => {
const clones = await backend.list();
return json({ clones: clones.map((c) => ({ jobId: c.jobId, url: c.url, kind: c.kind, status: c.status })) });
},
);
server.registerTool(
"cancel_clone",
{ description: "Cancel/purge a clone job and its artifacts.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const ok = await backend.remove(jobId);
return json({ jobId, cancelled: ok });
},
);
return server;
}