import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { loadManifest, needsHydration, getRouteHydration, type SpecHydrationStrategy, type HydrationPriority, type HydrationConfig, } from "@mandujs/core"; import { buildClientBundles, formatSize, type BundleManifest } from "@mandujs/core/bundler"; import { getProjectPaths, readJsonFile, writeJsonFile } from "../utils/project.js"; import path from "path"; export const hydrationToolDefinitions: Tool[] = [ { name: "mandu.build", description: "Build client bundles for hydration. Compiles client slots (.client.ts) into browser-ready JavaScript bundles.", annotations: { destructiveHint: true, readOnlyHint: false, idempotentHint: true, }, inputSchema: { type: "object", properties: { minify: { type: "boolean", description: "Minify the output bundles (default: true in production)", }, sourcemap: { type: "boolean", description: "Generate source maps for debugging", }, targetRouteIds: { type: "array", items: { type: "string" }, description: "Only rebuild specific islands by routeId. Skips Runtime/Router/Vendor rebuild for faster incremental updates. Omit to rebuild everything.", }, }, required: [], additionalProperties: false, }, }, { name: "mandu.build.status", description: "Get the current build status, bundle manifest, and statistics for client bundles.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false, }, }, { name: "mandu.island.list", description: "Legacy alias for page client mount diagnostics. Prefer mandu.pageClientMount.list for terminology that separates page-level hydration from nested islands.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false, }, }, { name: "mandu.pageClientMount.list", description: "List page-level client mounts: page routes that need a route-level clientModule and bundle. This is distinct from nested islands/partials.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false, }, }, { name: "mandu.hydration.set", description: "Set hydration configuration for a specific route. Updates the route's hydration strategy and priority.", annotations: { readOnlyHint: false, }, inputSchema: { type: "object", properties: { routeId: { type: "string", description: "The route ID to configure", }, strategy: { type: "string", enum: ["none", "island", "full", "progressive"], description: "Hydration strategy: none (static), island (partial), full (entire page), progressive (lazy)", }, priority: { type: "string", enum: ["immediate", "visible", "idle", "interaction"], description: "Hydration priority: immediate (on load), visible (in viewport), idle (when idle), interaction (on user action)", }, preload: { type: "boolean", description: "Whether to preload the bundle with modulepreload", }, }, required: ["routeId"], additionalProperties: false, }, }, { name: "mandu.hydration.addClientSlot", description: "Add a client slot file for a route to enable hydration. Creates the .client.ts file and updates the manifest.", annotations: { destructiveHint: false, readOnlyHint: false, }, inputSchema: { type: "object", properties: { routeId: { type: "string", description: "The route ID to add client slot for", }, strategy: { type: "string", enum: ["island", "full", "progressive"], description: "Hydration strategy (default: island)", }, priority: { type: "string", enum: ["immediate", "visible", "idle", "interaction"], description: "Hydration priority (default: visible)", }, }, required: ["routeId"], additionalProperties: false, }, }, ]; export function hydrationTools(projectRoot: string) { const paths = getProjectPaths(projectRoot); const handlers: Record) => Promise> = { "mandu.build": async (args: Record) => { const { minify, sourcemap, targetRouteIds } = args as { minify?: boolean; sourcemap?: boolean; targetRouteIds?: string[]; }; // Load manifest const manifestResult = await loadManifest(paths.manifestPath); if (!manifestResult.success || !manifestResult.data) { return { error: manifestResult.errors }; } // Build bundles const result = await buildClientBundles(manifestResult.data, projectRoot, { minify, sourcemap, targetRouteIds, }); return { success: result.success, bundleCount: result.stats.bundleCount, totalSize: formatSize(result.stats.totalSize), totalGzipSize: formatSize(result.stats.totalGzipSize), buildTime: `${result.stats.buildTime.toFixed(0)}ms`, bundles: result.outputs.map((output) => ({ routeId: output.routeId, path: output.outputPath, size: formatSize(output.size), gzipSize: formatSize(output.gzipSize), })), errors: result.errors, largestBundle: result.stats.largestBundle.routeId ? { routeId: result.stats.largestBundle.routeId, size: formatSize(result.stats.largestBundle.size), } : null, }; }, "mandu.build.status": async () => { // Read bundle manifest const manifestPath = path.join(projectRoot, ".mandu/manifest.json"); const manifest = await readJsonFile(manifestPath); if (!manifest) { return { hasBundles: false, message: "No bundle manifest found. Run mandu.build first.", }; } const bundleCount = Object.keys(manifest.bundles).length; return { hasBundles: true, version: manifest.version, buildTime: manifest.buildTime, environment: manifest.env, bundleCount, shared: { runtime: manifest.shared.runtime, vendor: manifest.shared.vendor, }, bundles: Object.entries(manifest.bundles).map(([routeId, bundle]) => ({ routeId, js: bundle.js, css: bundle.css || null, priority: bundle.priority, dependencies: bundle.dependencies, })), }; }, "mandu.pageClientMount.list": async () => { // Load manifest const manifestResult = await loadManifest(paths.manifestPath); if (!manifestResult.success || !manifestResult.data) { return { error: manifestResult.errors }; } const bundleManifest = await readJsonFile( path.join(projectRoot, ".mandu", "manifest.json"), ); const partialBoundaryCount = Object.keys(bundleManifest?.partials ?? {}).length; const pageClientMounts = manifestResult.data.routes .filter((route) => route.kind === "page") .map((route) => { const hydration = getRouteHydration(route); const needsClientMount = needsHydration(route); const clientBoundaryCount = route.boundaries?.length ?? 0; const hasRouteLevelClientMount = needsClientMount && !!route.clientModule; const warning = needsClientMount && !route.clientModule ? `Route has hydration strategy '${hydration.strategy}' but no clientModule; build would emit no route bundle.` : null; return { routeId: route.id, pattern: route.pattern, hasClientModule: !!route.clientModule, clientModule: route.clientModule || null, needsClientMount, hasRouteLevelClientMount, clientBoundaryCount, // Backward compatibility for older agents that read `isIsland`. isIsland: needsClientMount, status: needsClientMount ? (route.clientModule ? "ready" : "broken") : "static", warning, hydration: { strategy: hydration.strategy, priority: hydration.priority, preload: hydration.preload, }, }; }); const pageClientMountCount = pageClientMounts.filter((i) => i.needsClientMount).length; const staticCount = pageClientMounts.filter((i) => !i.needsClientMount).length; const activeMounts = pageClientMounts.filter((i) => i.needsClientMount); const staticPages = pageClientMounts.filter((i) => !i.needsClientMount); const clientBoundaryCount = pageClientMounts.reduce( (count, route) => count + route.clientBoundaryCount, 0, ); return { terminology: { pageClientMount: "A page route whose whole page is hydrated from a route-level clientModule and route bundle.", clientBoundary: "A compiler-discovered nested client component marker recorded on RouteSpec.boundaries.", partialBoundary: "A legacy/runtime partial bundle listed in .mandu/manifest.json partials.", island: "Legacy umbrella term. Prefer pageClientMount, clientBoundary, or partialBoundary for precise reports.", }, boundarySummary: { clientBoundaryCount, partialBoundaryCount, pageClientMountCount, }, totalPages: pageClientMounts.length, pageClientMountCount, clientBoundaryCount, partialBoundaryCount, staticCount, pageClientMounts: activeMounts, staticPages, // Backward compatibility for older clients. islandCount: pageClientMountCount, islands: activeMounts, }; }, "mandu.island.list": async () => handlers["mandu.pageClientMount.list"]({}), "mandu.hydration.set": async (args: Record) => { const validationError = validateRouteIdArgs( args, "mandu.hydration.set", ["routeId", "strategy", "priority", "preload"], ); if (validationError) return { error: validationError }; const { routeId, strategy, priority, preload } = args as { routeId: string; strategy?: SpecHydrationStrategy; priority?: HydrationPriority; preload?: boolean; }; // Load manifest const manifestResult = await loadManifest(paths.manifestPath); if (!manifestResult.success || !manifestResult.data) { return { error: manifestResult.errors }; } const manifest = manifestResult.data; const routeIndex = manifest.routes.findIndex((r) => r.id === routeId); if (routeIndex === -1) { return { error: `Route not found: ${routeId}` }; } const route = manifest.routes[routeIndex]; if (route.kind !== "page") { return { error: `Route ${routeId} is not a page route (kind: ${route.kind})` }; } // Update hydration config const currentHydration: Partial = route.hydration || {}; const newHydration = { strategy: strategy || currentHydration.strategy || "island", priority: priority || currentHydration.priority || "visible", preload: preload !== undefined ? preload : currentHydration.preload || false, }; // Validate: can't have clientModule with strategy: none if (newHydration.strategy === "none" && route.clientModule) { return { error: `Cannot set strategy to 'none' when clientModule is defined. Remove clientModule first or choose a different strategy.`, }; } manifest.routes[routeIndex] = { ...route, hydration: newHydration, }; // Write updated manifest await writeJsonFile(paths.manifestPath, manifest); return { success: true, routeId, previousHydration: route.hydration || { strategy: "none" }, newHydration, message: `Updated hydration config for ${routeId}`, }; }, "mandu.hydration.addClientSlot": async (args: Record) => { const validationError = validateRouteIdArgs( args, "mandu.hydration.addClientSlot", ["routeId", "strategy", "priority"], ); if (validationError) return { error: validationError }; const { routeId, strategy = "island", priority = "visible" } = args as { routeId: string; strategy?: SpecHydrationStrategy; priority?: HydrationPriority; }; // Load manifest const manifestResult = await loadManifest(paths.manifestPath); if (!manifestResult.success || !manifestResult.data) { return { error: manifestResult.errors }; } const manifest = manifestResult.data; const routeIndex = manifest.routes.findIndex((r) => r.id === routeId); if (routeIndex === -1) { return { error: `Route not found: ${routeId}` }; } const route = manifest.routes[routeIndex]; if (route.kind !== "page") { return { error: `Route ${routeId} is not a page route` }; } if (route.clientModule) { return { error: `Route ${routeId} already has a client module: ${route.clientModule}`, }; } // Create client slot file in spec/slots/ const clientModulePath = `spec/slots/${routeId}.client.tsx`; const clientFilePath = path.join(projectRoot, clientModulePath); // Check if file already exists const clientFile = Bun.file(clientFilePath); if (await clientFile.exists()) { return { error: `Client slot file already exists: ${clientModulePath}`, }; } // Generate client slot template const template = generateClientSlotTemplate(routeId, route.slotModule); // Write client slot file await Bun.write(clientFilePath, template); // Update manifest manifest.routes[routeIndex] = { ...route, clientModule: clientModulePath, hydration: { strategy: strategy as SpecHydrationStrategy, priority: priority as HydrationPriority, preload: false, }, }; await writeJsonFile(paths.manifestPath, manifest); return { success: true, routeId, clientModule: clientModulePath, hydration: { strategy, priority, preload: false, }, message: `Created client slot: ${clientModulePath}`, nextSteps: [ `Edit ${clientModulePath} to add client-side logic`, `Run mandu.build to compile the client bundle`, `The page will now hydrate in the browser`, ], }; }, }; // Backward-compatible aliases (deprecated) handlers["mandu_build"] = handlers["mandu.build"]; handlers["mandu_build_status"] = handlers["mandu.build.status"]; handlers["mandu_list_islands"] = handlers["mandu.island.list"]; handlers["mandu_page_client_mount_list"] = handlers["mandu.pageClientMount.list"]; handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"]; handlers["mandu_hydration_set"] = handlers["mandu.hydration.set"]; handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"]; handlers["mandu_hydration_add_client_slot"] = handlers["mandu.hydration.addClientSlot"]; return handlers; } function validateRouteIdArgs( args: Record, toolName: string, allowedKeys: readonly string[], ): string | null { if (typeof args.routeId !== "string" || args.routeId.trim().length === 0) { const allowed = new Set(allowedKeys); const unknownKeys = Object.keys(args).filter((key) => !allowed.has(key)); const got = unknownKeys.length > 0 ? ` (got unknown key${unknownKeys.length === 1 ? "" : "s"} '${unknownKeys.join("', '")}')` : ""; return `${toolName}: missing required parameter 'routeId'${got}`; } return null; } /** * Generate a client slot template */ function generateClientSlotTemplate(routeId: string, slotModule?: string): string { const pascalCase = routeId .split(/[-_]/) .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join(""); const typeImport = slotModule ? `// Import types from server slot if needed (adjust path to your project) // import type { LoaderData } from "../../../spec/slots/${routeId}.slot"; ` : ""; return `/** * ${pascalCase} Client Slot * 브라우저에서 실행되는 클라이언트 로직 */ import { ManduClient } from "@mandujs/core/client"; import { useState, useCallback } from "react"; ${typeImport}// 서버에서 전달받는 데이터 타입 interface ServerData { // TODO: Define your server data type [key: string]: unknown; } export default ManduClient.island({ /** * Setup Phase * - 서버 데이터를 받아 클라이언트 상태 초기화 * - React hooks 사용 가능 */ setup: (serverData) => { // 서버 데이터로 상태 초기화 const [data, setData] = useState(serverData); const [loading, setLoading] = useState(false); // 예시: 데이터 새로고침 const refresh = useCallback(async () => { setLoading(true); try { // API 호출 예시 // const res = await fetch("/api/${routeId}"); // const newData = await res.json(); // setData(newData); } finally { setLoading(false); } }, []); return { data, loading, refresh, }; }, /** * Render Phase * - setup 반환값을 props로 받음 * - 순수 렌더링 로직 */ render: ({ data, loading, refresh }) => (
{loading &&
로딩 중...
} {/* TODO: Implement your UI */}
{JSON.stringify(data, null, 2)}
), }); `; }