@mandujs/mcp 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@mandujs/core": "^0.3.4",
35
+ "@mandujs/core": "^0.4.0",
36
36
  "@modelcontextprotocol/sdk": "^1.25.3"
37
37
  },
38
38
  "engines": {
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import { transactionTools, transactionToolDefinitions } from "./tools/transactio
15
15
  import { historyTools, historyToolDefinitions } from "./tools/history.js";
16
16
  import { guardTools, guardToolDefinitions } from "./tools/guard.js";
17
17
  import { slotTools, slotToolDefinitions } from "./tools/slot.js";
18
+ import { hydrationTools, hydrationToolDefinitions } from "./tools/hydration.js";
18
19
  import { resourceHandlers, resourceDefinitions } from "./resources/handlers.js";
19
20
  import { findProjectRoot } from "./utils/project.js";
20
21
 
@@ -49,6 +50,7 @@ export class ManduMcpServer {
49
50
  ...historyToolDefinitions,
50
51
  ...guardToolDefinitions,
51
52
  ...slotToolDefinitions,
53
+ ...hydrationToolDefinitions,
52
54
  ];
53
55
  }
54
56
 
@@ -60,6 +62,7 @@ export class ManduMcpServer {
60
62
  ...historyTools(this.projectRoot),
61
63
  ...guardTools(this.projectRoot),
62
64
  ...slotTools(this.projectRoot),
65
+ ...hydrationTools(this.projectRoot),
63
66
  };
64
67
  }
65
68
 
@@ -0,0 +1,452 @@
1
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ import {
3
+ loadManifest,
4
+ buildClientBundles,
5
+ formatSize,
6
+ needsHydration,
7
+ getRouteHydration,
8
+ type BundleManifest,
9
+ type HydrationStrategy,
10
+ type HydrationPriority,
11
+ } from "@mandujs/core";
12
+ import { getProjectPaths, readJsonFile, writeJsonFile } from "../utils/project.js";
13
+ import path from "path";
14
+
15
+ export const hydrationToolDefinitions: Tool[] = [
16
+ {
17
+ name: "mandu_build",
18
+ description:
19
+ "Build client bundles for hydration. Compiles client slots (.client.ts) into browser-ready JavaScript bundles.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ minify: {
24
+ type: "boolean",
25
+ description: "Minify the output bundles (default: true in production)",
26
+ },
27
+ sourcemap: {
28
+ type: "boolean",
29
+ description: "Generate source maps for debugging",
30
+ },
31
+ },
32
+ required: [],
33
+ },
34
+ },
35
+ {
36
+ name: "mandu_build_status",
37
+ description:
38
+ "Get the current build status, bundle manifest, and statistics for client bundles.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {},
42
+ required: [],
43
+ },
44
+ },
45
+ {
46
+ name: "mandu_list_islands",
47
+ description:
48
+ "List all routes that have client-side hydration (islands). Shows hydration strategy and priority for each.",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {},
52
+ required: [],
53
+ },
54
+ },
55
+ {
56
+ name: "mandu_set_hydration",
57
+ description:
58
+ "Set hydration configuration for a specific route. Updates the route's hydration strategy and priority.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ routeId: {
63
+ type: "string",
64
+ description: "The route ID to configure",
65
+ },
66
+ strategy: {
67
+ type: "string",
68
+ enum: ["none", "island", "full", "progressive"],
69
+ description:
70
+ "Hydration strategy: none (static), island (partial), full (entire page), progressive (lazy)",
71
+ },
72
+ priority: {
73
+ type: "string",
74
+ enum: ["immediate", "visible", "idle", "interaction"],
75
+ description:
76
+ "Hydration priority: immediate (on load), visible (in viewport), idle (when idle), interaction (on user action)",
77
+ },
78
+ preload: {
79
+ type: "boolean",
80
+ description: "Whether to preload the bundle with modulepreload",
81
+ },
82
+ },
83
+ required: ["routeId"],
84
+ },
85
+ },
86
+ {
87
+ name: "mandu_add_client_slot",
88
+ description:
89
+ "Add a client slot file for a route to enable hydration. Creates the .client.ts file and updates the manifest.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ routeId: {
94
+ type: "string",
95
+ description: "The route ID to add client slot for",
96
+ },
97
+ strategy: {
98
+ type: "string",
99
+ enum: ["island", "full", "progressive"],
100
+ description: "Hydration strategy (default: island)",
101
+ },
102
+ priority: {
103
+ type: "string",
104
+ enum: ["immediate", "visible", "idle", "interaction"],
105
+ description: "Hydration priority (default: visible)",
106
+ },
107
+ },
108
+ required: ["routeId"],
109
+ },
110
+ },
111
+ ];
112
+
113
+ export function hydrationTools(projectRoot: string) {
114
+ const paths = getProjectPaths(projectRoot);
115
+
116
+ return {
117
+ mandu_build: async (args: Record<string, unknown>) => {
118
+ const { minify, sourcemap } = args as {
119
+ minify?: boolean;
120
+ sourcemap?: boolean;
121
+ };
122
+
123
+ // Load manifest
124
+ const manifestResult = await loadManifest(paths.manifestPath);
125
+ if (!manifestResult.success || !manifestResult.data) {
126
+ return { error: manifestResult.errors };
127
+ }
128
+
129
+ // Build bundles
130
+ const result = await buildClientBundles(manifestResult.data, projectRoot, {
131
+ minify,
132
+ sourcemap,
133
+ });
134
+
135
+ return {
136
+ success: result.success,
137
+ bundleCount: result.stats.bundleCount,
138
+ totalSize: formatSize(result.stats.totalSize),
139
+ totalGzipSize: formatSize(result.stats.totalGzipSize),
140
+ buildTime: `${result.stats.buildTime.toFixed(0)}ms`,
141
+ bundles: result.outputs.map((output) => ({
142
+ routeId: output.routeId,
143
+ path: output.outputPath,
144
+ size: formatSize(output.size),
145
+ gzipSize: formatSize(output.gzipSize),
146
+ })),
147
+ errors: result.errors,
148
+ largestBundle: result.stats.largestBundle.routeId
149
+ ? {
150
+ routeId: result.stats.largestBundle.routeId,
151
+ size: formatSize(result.stats.largestBundle.size),
152
+ }
153
+ : null,
154
+ };
155
+ },
156
+
157
+ mandu_build_status: async () => {
158
+ // Read bundle manifest
159
+ const manifestPath = path.join(projectRoot, ".mandu/manifest.json");
160
+ const manifest = await readJsonFile<BundleManifest>(manifestPath);
161
+
162
+ if (!manifest) {
163
+ return {
164
+ hasBundles: false,
165
+ message: "No bundle manifest found. Run mandu_build first.",
166
+ };
167
+ }
168
+
169
+ const bundleCount = Object.keys(manifest.bundles).length;
170
+
171
+ return {
172
+ hasBundles: true,
173
+ version: manifest.version,
174
+ buildTime: manifest.buildTime,
175
+ environment: manifest.env,
176
+ bundleCount,
177
+ shared: {
178
+ runtime: manifest.shared.runtime,
179
+ vendor: manifest.shared.vendor,
180
+ },
181
+ bundles: Object.entries(manifest.bundles).map(([routeId, bundle]) => ({
182
+ routeId,
183
+ js: bundle.js,
184
+ css: bundle.css || null,
185
+ priority: bundle.priority,
186
+ dependencies: bundle.dependencies,
187
+ })),
188
+ };
189
+ },
190
+
191
+ mandu_list_islands: async () => {
192
+ // Load manifest
193
+ const manifestResult = await loadManifest(paths.manifestPath);
194
+ if (!manifestResult.success || !manifestResult.data) {
195
+ return { error: manifestResult.errors };
196
+ }
197
+
198
+ const islands = manifestResult.data.routes
199
+ .filter((route) => route.kind === "page")
200
+ .map((route) => {
201
+ const hydration = getRouteHydration(route);
202
+ const isIsland = needsHydration(route);
203
+
204
+ return {
205
+ routeId: route.id,
206
+ pattern: route.pattern,
207
+ hasClientModule: !!route.clientModule,
208
+ clientModule: route.clientModule || null,
209
+ isIsland,
210
+ hydration: {
211
+ strategy: hydration.strategy,
212
+ priority: hydration.priority,
213
+ preload: hydration.preload,
214
+ },
215
+ };
216
+ });
217
+
218
+ const islandCount = islands.filter((i) => i.isIsland).length;
219
+ const staticCount = islands.filter((i) => !i.isIsland).length;
220
+
221
+ return {
222
+ totalPages: islands.length,
223
+ islandCount,
224
+ staticCount,
225
+ islands: islands.filter((i) => i.isIsland),
226
+ staticPages: islands.filter((i) => !i.isIsland),
227
+ };
228
+ },
229
+
230
+ mandu_set_hydration: async (args: Record<string, unknown>) => {
231
+ const { routeId, strategy, priority, preload } = args as {
232
+ routeId: string;
233
+ strategy?: HydrationStrategy;
234
+ priority?: HydrationPriority;
235
+ preload?: boolean;
236
+ };
237
+
238
+ // Load manifest
239
+ const manifestResult = await loadManifest(paths.manifestPath);
240
+ if (!manifestResult.success || !manifestResult.data) {
241
+ return { error: manifestResult.errors };
242
+ }
243
+
244
+ const manifest = manifestResult.data;
245
+ const routeIndex = manifest.routes.findIndex((r) => r.id === routeId);
246
+
247
+ if (routeIndex === -1) {
248
+ return { error: `Route not found: ${routeId}` };
249
+ }
250
+
251
+ const route = manifest.routes[routeIndex];
252
+
253
+ if (route.kind !== "page") {
254
+ return { error: `Route ${routeId} is not a page route (kind: ${route.kind})` };
255
+ }
256
+
257
+ // Update hydration config
258
+ const currentHydration = route.hydration || {};
259
+ const newHydration = {
260
+ strategy: strategy || currentHydration.strategy || "island",
261
+ priority: priority || currentHydration.priority || "visible",
262
+ preload: preload !== undefined ? preload : currentHydration.preload || false,
263
+ };
264
+
265
+ // Validate: can't have clientModule with strategy: none
266
+ if (newHydration.strategy === "none" && route.clientModule) {
267
+ return {
268
+ error: `Cannot set strategy to 'none' when clientModule is defined. Remove clientModule first or choose a different strategy.`,
269
+ };
270
+ }
271
+
272
+ manifest.routes[routeIndex] = {
273
+ ...route,
274
+ hydration: newHydration,
275
+ };
276
+
277
+ // Write updated manifest
278
+ await writeJsonFile(paths.manifestPath, manifest);
279
+
280
+ return {
281
+ success: true,
282
+ routeId,
283
+ previousHydration: route.hydration || { strategy: "none" },
284
+ newHydration,
285
+ message: `Updated hydration config for ${routeId}`,
286
+ };
287
+ },
288
+
289
+ mandu_add_client_slot: async (args: Record<string, unknown>) => {
290
+ const { routeId, strategy = "island", priority = "visible" } = args as {
291
+ routeId: string;
292
+ strategy?: HydrationStrategy;
293
+ priority?: HydrationPriority;
294
+ };
295
+
296
+ // Load manifest
297
+ const manifestResult = await loadManifest(paths.manifestPath);
298
+ if (!manifestResult.success || !manifestResult.data) {
299
+ return { error: manifestResult.errors };
300
+ }
301
+
302
+ const manifest = manifestResult.data;
303
+ const routeIndex = manifest.routes.findIndex((r) => r.id === routeId);
304
+
305
+ if (routeIndex === -1) {
306
+ return { error: `Route not found: ${routeId}` };
307
+ }
308
+
309
+ const route = manifest.routes[routeIndex];
310
+
311
+ if (route.kind !== "page") {
312
+ return { error: `Route ${routeId} is not a page route` };
313
+ }
314
+
315
+ if (route.clientModule) {
316
+ return {
317
+ error: `Route ${routeId} already has a client module: ${route.clientModule}`,
318
+ };
319
+ }
320
+
321
+ // Create client slot file
322
+ const clientModulePath = `spec/slots/${routeId}.client.ts`;
323
+ const clientFilePath = path.join(projectRoot, clientModulePath);
324
+
325
+ // Check if file already exists
326
+ const clientFile = Bun.file(clientFilePath);
327
+ if (await clientFile.exists()) {
328
+ return {
329
+ error: `Client slot file already exists: ${clientModulePath}`,
330
+ };
331
+ }
332
+
333
+ // Generate client slot template
334
+ const template = generateClientSlotTemplate(routeId, route.slotModule);
335
+
336
+ // Write client slot file
337
+ await Bun.write(clientFilePath, template);
338
+
339
+ // Update manifest
340
+ manifest.routes[routeIndex] = {
341
+ ...route,
342
+ clientModule: clientModulePath,
343
+ hydration: {
344
+ strategy: strategy as HydrationStrategy,
345
+ priority: priority as HydrationPriority,
346
+ preload: false,
347
+ },
348
+ };
349
+
350
+ await writeJsonFile(paths.manifestPath, manifest);
351
+
352
+ return {
353
+ success: true,
354
+ routeId,
355
+ clientModule: clientModulePath,
356
+ hydration: {
357
+ strategy,
358
+ priority,
359
+ preload: false,
360
+ },
361
+ message: `Created client slot: ${clientModulePath}`,
362
+ nextSteps: [
363
+ `Edit ${clientModulePath} to add client-side logic`,
364
+ `Run mandu_build to compile the client bundle`,
365
+ `The page will now hydrate in the browser`,
366
+ ],
367
+ };
368
+ },
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Generate a client slot template
374
+ */
375
+ function generateClientSlotTemplate(routeId: string, slotModule?: string): string {
376
+ const pascalCase = routeId
377
+ .split(/[-_]/)
378
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
379
+ .join("");
380
+
381
+ const typeImport = slotModule
382
+ ? `// Import types from server slot if needed
383
+ // import type { LoaderData } from "./${routeId}.slot";
384
+
385
+ `
386
+ : "";
387
+
388
+ return `/**
389
+ * ${pascalCase} Client Slot
390
+ * 브라우저에서 실행되는 클라이언트 로직
391
+ */
392
+
393
+ import { Mandu } from "@mandujs/core/client";
394
+ import { useState, useCallback } from "react";
395
+
396
+ ${typeImport}// 서버에서 전달받는 데이터 타입
397
+ interface ServerData {
398
+ // TODO: Define your server data type
399
+ [key: string]: unknown;
400
+ }
401
+
402
+ export default Mandu.island<ServerData>({
403
+ /**
404
+ * Setup Phase
405
+ * - 서버 데이터를 받아 클라이언트 상태 초기화
406
+ * - React hooks 사용 가능
407
+ */
408
+ setup: (serverData) => {
409
+ // 서버 데이터로 상태 초기화
410
+ const [data, setData] = useState(serverData);
411
+ const [loading, setLoading] = useState(false);
412
+
413
+ // 예시: 데이터 새로고침
414
+ const refresh = useCallback(async () => {
415
+ setLoading(true);
416
+ try {
417
+ // API 호출 예시
418
+ // const res = await fetch("/api/${routeId}");
419
+ // const newData = await res.json();
420
+ // setData(newData);
421
+ } finally {
422
+ setLoading(false);
423
+ }
424
+ }, []);
425
+
426
+ return {
427
+ data,
428
+ loading,
429
+ refresh,
430
+ };
431
+ },
432
+
433
+ /**
434
+ * Render Phase
435
+ * - setup 반환값을 props로 받음
436
+ * - 순수 렌더링 로직
437
+ */
438
+ render: ({ data, loading, refresh }) => (
439
+ <div className="${routeId}-island">
440
+ {loading && <div className="loading">로딩 중...</div>}
441
+
442
+ {/* TODO: Implement your UI */}
443
+ <pre>{JSON.stringify(data, null, 2)}</pre>
444
+
445
+ <button onClick={refresh} disabled={loading}>
446
+ 새로고침
447
+ </button>
448
+ </div>
449
+ ),
450
+ });
451
+ `;
452
+ }
@@ -4,3 +4,4 @@ export { transactionTools, transactionToolDefinitions } from "./transaction.js";
4
4
  export { historyTools, historyToolDefinitions } from "./history.js";
5
5
  export { guardTools, guardToolDefinitions } from "./guard.js";
6
6
  export { slotTools, slotToolDefinitions } from "./slot.js";
7
+ export { hydrationTools, hydrationToolDefinitions } from "./hydration.js";