@mandujs/mcp 0.4.0 → 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 +1 -1
- package/src/server.ts +3 -0
- package/src/tools/hydration.ts +452 -0
- package/src/tools/index.ts +1 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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";
|