@mandujs/mcp 0.37.4 → 0.38.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.37.4",
3
+ "version": "0.38.1",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@mandujs/core": "^0.54.5",
37
+ "@mandujs/core": "^0.54.7",
38
38
  "@mandujs/ate": "^0.26.1",
39
39
  "@mandujs/skills": "^0.20.1",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
@@ -1,13 +1,13 @@
1
1
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { type ManduError } from "@mandujs/core/error";
3
- import {
4
- checkDirectory,
5
- getDefaultFsRoutesGuardPolicy,
6
- validateAndReport,
7
- loadManifest,
8
- runGuardCheck,
9
- runAutoCorrect,
10
- type GeneratedMap,
3
+ import {
4
+ checkDirectory,
5
+ getDefaultFsRoutesGuardPolicy,
6
+ validateAndReport,
7
+ loadManifest,
8
+ runGuardCheck,
9
+ runAutoCorrect,
10
+ type GeneratedMap,
11
11
  // Self-Healing Guard imports
12
12
  checkWithHealing,
13
13
  healAll,
@@ -15,19 +15,19 @@ import {
15
15
  // Follow-up E — type-aware lint bridge
16
16
  runTsgolint,
17
17
  type GuardConfig,
18
- type ViolationType,
19
- type GuardPreset,
20
- type Violation,
21
- } from "@mandujs/core";
22
- import { getProjectPaths, readJsonFile, readConfig } from "../utils/project.js";
23
- import fs from "fs/promises";
24
- import path from "path";
18
+ type ViolationType,
19
+ type GuardPreset,
20
+ type Violation,
21
+ } from "@mandujs/core";
22
+ import { getProjectPaths, readJsonFile, readConfig } from "../utils/project.js";
23
+ import fs from "fs/promises";
24
+ import path from "path";
25
25
 
26
26
  export const guardToolDefinitions: Tool[] = [
27
27
  {
28
- name: "mandu.guard.check",
29
- description:
30
- "Run the same architecture guard used by `mandu guard`, plus legacy spec/generated/slot checks. Set typeAware=true to additionally run `oxlint --type-aware` (tsgolint) and merge its results.",
28
+ name: "mandu.guard.check",
29
+ description:
30
+ "Run the same architecture guard used by `mandu guard`, plus legacy spec/generated/slot checks. Set typeAware=true to additionally run `oxlint --type-aware` (tsgolint) and merge its results.",
31
31
  annotations: {
32
32
  readOnlyHint: true,
33
33
  },
@@ -126,37 +126,37 @@ export const guardToolDefinitions: Tool[] = [
126
126
  },
127
127
  ];
128
128
 
129
- export function guardTools(projectRoot: string) {
130
- const paths = getProjectPaths(projectRoot);
131
-
132
- const pathExists = async (candidate: string): Promise<boolean> => {
133
- try {
134
- await fs.access(candidate);
135
- return true;
136
- } catch {
137
- return false;
138
- }
139
- };
140
-
141
- const summarizeArchitectureViolation = (violation: Violation) => ({
142
- ruleId: violation.ruleName,
143
- type: violation.type,
144
- file: path.relative(projectRoot, violation.filePath).replace(/\\/g, "/") || violation.filePath,
145
- line: violation.line,
146
- column: violation.column,
147
- message: violation.ruleDescription,
148
- suggestion: violation.suggestions[0],
149
- fromLayer: violation.fromLayer,
150
- toLayer: violation.toLayer,
151
- importStatement: violation.importStatement,
152
- });
153
-
154
- const summarizeLegacyViolation = (v: Awaited<ReturnType<typeof runGuardCheck>>["violations"][number]) => ({
155
- ruleId: v.ruleId,
156
- file: v.file,
157
- message: v.message,
158
- suggestion: v.suggestion,
159
- });
129
+ export function guardTools(projectRoot: string) {
130
+ const paths = getProjectPaths(projectRoot);
131
+
132
+ const pathExists = async (candidate: string): Promise<boolean> => {
133
+ try {
134
+ await fs.access(candidate);
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ };
140
+
141
+ const summarizeArchitectureViolation = (violation: Violation) => ({
142
+ ruleId: violation.ruleName,
143
+ type: violation.type,
144
+ file: path.relative(projectRoot, violation.filePath).replace(/\\/g, "/") || violation.filePath,
145
+ line: violation.line,
146
+ column: violation.column,
147
+ message: violation.ruleDescription,
148
+ suggestion: violation.suggestions[0],
149
+ fromLayer: violation.fromLayer,
150
+ toLayer: violation.toLayer,
151
+ importStatement: violation.importStatement,
152
+ });
153
+
154
+ const summarizeLegacyViolation = (v: Awaited<ReturnType<typeof runGuardCheck>>["violations"][number]) => ({
155
+ ruleId: v.ruleId,
156
+ file: v.file,
157
+ message: v.message,
158
+ suggestion: v.suggestion,
159
+ });
160
160
 
161
161
  const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
162
162
  "mandu.guard.check": async (args: Record<string, unknown>) => {
@@ -172,44 +172,44 @@ export function guardTools(projectRoot: string) {
172
172
  error: "Failed to load manifest",
173
173
  details: manifestResult.errors,
174
174
  };
175
- }
176
-
177
- const projectConfig = await validateAndReport(projectRoot);
178
- const guardConfigFromFile = (projectConfig?.guard ?? {}) as GuardConfig;
179
- const preset = guardConfigFromFile.preset ?? "mandu";
180
- const enableFsRoutes = await pathExists(paths.appDir);
181
-
182
- const architectureReport = await checkDirectory(
183
- {
184
- preset,
185
- srcDir: guardConfigFromFile.srcDir ?? "src",
186
- exclude: guardConfigFromFile.exclude,
187
- fsRoutes: getDefaultFsRoutesGuardPolicy(enableFsRoutes),
188
- },
189
- projectRoot
190
- );
191
- const architecturePassed = architectureReport.bySeverity.error === 0;
192
- const architectureViolations = architectureReport.violations.map(
193
- summarizeArchitectureViolation
194
- );
195
-
196
- // Run guard check
197
- const checkResult = await runGuardCheck(manifestResult.data, projectRoot);
175
+ }
176
+
177
+ const projectConfig = await validateAndReport(projectRoot);
178
+ const guardConfigFromFile = (projectConfig?.guard ?? {}) as GuardConfig;
179
+ const preset = guardConfigFromFile.preset ?? "mandu";
180
+ const enableFsRoutes = await pathExists(paths.appDir);
181
+
182
+ const architectureReport = await checkDirectory(
183
+ {
184
+ preset,
185
+ srcDir: guardConfigFromFile.srcDir ?? "src",
186
+ exclude: guardConfigFromFile.exclude,
187
+ fsRoutes: getDefaultFsRoutesGuardPolicy(enableFsRoutes),
188
+ },
189
+ projectRoot
190
+ );
191
+ const architecturePassed = architectureReport.bySeverity.error === 0;
192
+ const architectureViolations = architectureReport.violations.map(
193
+ summarizeArchitectureViolation
194
+ );
195
+
196
+ // Run guard check
197
+ const checkResult = await runGuardCheck(manifestResult.data, projectRoot);
198
198
 
199
199
  // Follow-up E — resolve type-aware defaulting from config, then
200
200
  // run the bridge when enabled. Result envelope is always included
201
201
  // in the tool response so MCP clients have a single stable shape.
202
- let rawProjectConfig: Awaited<ReturnType<typeof readConfig>> | undefined;
203
- try {
204
- rawProjectConfig = projectConfig ?? await readConfig(projectRoot);
205
- } catch {
206
- rawProjectConfig = projectConfig ?? undefined;
207
- }
208
- const typeAwareCfg = (
209
- rawProjectConfig?.guard as
210
- | { typeAware?: Record<string, unknown> }
211
- | undefined
212
- )?.typeAware;
202
+ let rawProjectConfig: Awaited<ReturnType<typeof readConfig>> | undefined;
203
+ try {
204
+ rawProjectConfig = projectConfig ?? await readConfig(projectRoot);
205
+ } catch {
206
+ rawProjectConfig = projectConfig ?? undefined;
207
+ }
208
+ const typeAwareCfg = (
209
+ rawProjectConfig?.guard as
210
+ | { typeAware?: Record<string, unknown> }
211
+ | undefined
212
+ )?.typeAware;
213
213
  const typeAwareEnabled =
214
214
  typeAwareArg !== undefined ? typeAwareArg : typeAwareCfg !== undefined;
215
215
 
@@ -229,40 +229,40 @@ export function guardTools(projectRoot: string) {
229
229
  skipped: bridge.skipped,
230
230
  summary: bridge.summary,
231
231
  violations: bridge.violations,
232
- };
233
- }
234
-
235
- const typeAwareViolations = typeAwareResponse
236
- ? typeAwareResponse.violations as Array<{ severity: string }>
237
- : [];
238
- const typeAwareErrorCount = typeAwareViolations.filter(
239
- (v) => v.severity === "error",
240
- ).length;
241
- const legacyViolations = checkResult.violations.map(summarizeLegacyViolation);
242
- const combinedViolations = [
243
- ...architectureViolations,
244
- ...legacyViolations,
245
- ];
246
- const allPassed = checkResult.passed && architecturePassed && typeAwareErrorCount === 0;
247
- const blockingViolationCount = combinedViolations.length + typeAwareErrorCount;
248
-
249
- if (allPassed) {
250
- return {
251
- passed: true,
252
- violations: [],
253
- message: "All guard checks passed",
254
- architecture: {
255
- passed: true,
256
- totalViolations: architectureReport.totalViolations,
257
- bySeverity: architectureReport.bySeverity,
258
- },
259
- legacy: {
260
- passed: true,
261
- violations: 0,
262
- },
263
- relatedSkills: ["mandu-guard-guide", "mandu-debug"],
264
- ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
265
- };
232
+ };
233
+ }
234
+
235
+ const typeAwareViolations = typeAwareResponse
236
+ ? typeAwareResponse.violations as Array<{ severity: string }>
237
+ : [];
238
+ const typeAwareErrorCount = typeAwareViolations.filter(
239
+ (v) => v.severity === "error",
240
+ ).length;
241
+ const legacyViolations = checkResult.violations.map(summarizeLegacyViolation);
242
+ const combinedViolations = [
243
+ ...architectureViolations,
244
+ ...legacyViolations,
245
+ ];
246
+ const allPassed = checkResult.passed && architecturePassed && typeAwareErrorCount === 0;
247
+ const blockingViolationCount = combinedViolations.length + typeAwareErrorCount;
248
+
249
+ if (allPassed) {
250
+ return {
251
+ passed: true,
252
+ violations: [],
253
+ message: "All guard checks passed",
254
+ architecture: {
255
+ passed: true,
256
+ totalViolations: architectureReport.totalViolations,
257
+ bySeverity: architectureReport.bySeverity,
258
+ },
259
+ legacy: {
260
+ passed: true,
261
+ violations: 0,
262
+ },
263
+ relatedSkills: ["mandu-guard-guide", "mandu-debug"],
264
+ ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
265
+ };
266
266
  }
267
267
 
268
268
  // If auto-correct requested and there are violations
@@ -273,54 +273,54 @@ export function guardTools(projectRoot: string) {
273
273
  projectRoot
274
274
  );
275
275
 
276
- return {
277
- passed:
278
- autoCorrectResult.fixed &&
279
- architecturePassed &&
280
- typeAwareErrorCount === 0,
281
- violations: [
282
- ...architectureViolations,
283
- ...autoCorrectResult.remainingViolations.map(summarizeLegacyViolation),
284
- ],
285
- autoCorrect: {
286
- attempted: true,
287
- fixed: autoCorrectResult.fixed,
276
+ return {
277
+ passed:
278
+ autoCorrectResult.fixed &&
279
+ architecturePassed &&
280
+ typeAwareErrorCount === 0,
281
+ violations: [
282
+ ...architectureViolations,
283
+ ...autoCorrectResult.remainingViolations.map(summarizeLegacyViolation),
284
+ ],
285
+ autoCorrect: {
286
+ attempted: true,
287
+ fixed: autoCorrectResult.fixed,
288
288
  steps: autoCorrectResult.steps,
289
289
  retriedCount: autoCorrectResult.retriedCount,
290
290
  rolledBack: autoCorrectResult.rolledBack,
291
- changeId: autoCorrectResult.changeId,
292
- },
293
- architecture: {
294
- passed: architecturePassed,
295
- totalViolations: architectureReport.totalViolations,
296
- bySeverity: architectureReport.bySeverity,
297
- violations: architectureViolations,
298
- },
299
- legacy: {
300
- passed: autoCorrectResult.fixed,
301
- violations: autoCorrectResult.remainingViolations.length,
302
- },
303
- ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
304
- };
305
- }
306
-
307
- return {
308
- passed: false,
309
- violations: combinedViolations,
310
- message: `Found ${blockingViolationCount} violation(s)`,
311
- architecture: {
312
- passed: architecturePassed,
313
- totalViolations: architectureReport.totalViolations,
314
- bySeverity: architectureReport.bySeverity,
315
- violations: architectureViolations,
316
- },
317
- legacy: {
318
- passed: checkResult.passed,
319
- violations: legacyViolations.length,
320
- },
321
- tip: "Use autoCorrect: true to attempt automatic fixes",
322
- relatedSkills: ["mandu-guard-guide", "mandu-debug"],
323
- ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
291
+ changeId: autoCorrectResult.changeId,
292
+ },
293
+ architecture: {
294
+ passed: architecturePassed,
295
+ totalViolations: architectureReport.totalViolations,
296
+ bySeverity: architectureReport.bySeverity,
297
+ violations: architectureViolations,
298
+ },
299
+ legacy: {
300
+ passed: autoCorrectResult.fixed,
301
+ violations: autoCorrectResult.remainingViolations.length,
302
+ },
303
+ ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
304
+ };
305
+ }
306
+
307
+ return {
308
+ passed: false,
309
+ violations: combinedViolations,
310
+ message: `Found ${blockingViolationCount} violation(s)`,
311
+ architecture: {
312
+ passed: architecturePassed,
313
+ totalViolations: architectureReport.totalViolations,
314
+ bySeverity: architectureReport.bySeverity,
315
+ violations: architectureViolations,
316
+ },
317
+ legacy: {
318
+ passed: checkResult.passed,
319
+ violations: legacyViolations.length,
320
+ },
321
+ tip: "Use autoCorrect: true to attempt automatic fixes",
322
+ relatedSkills: ["mandu-guard-guide", "mandu-debug"],
323
+ ...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
324
324
  };
325
325
  },
326
326
 
@@ -18,17 +18,17 @@ export const hydrationToolDefinitions: Tool[] = [
18
18
  name: "mandu.build",
19
19
  description:
20
20
  "Build client bundles for hydration. Compiles client slots (.client.ts) into browser-ready JavaScript bundles.",
21
- annotations: {
22
- destructiveHint: true,
23
- readOnlyHint: false,
24
- idempotentHint: true,
25
- },
26
- inputSchema: {
27
- type: "object",
28
- properties: {
29
- minify: {
30
- type: "boolean",
31
- description: "Minify the output bundles (default: true in production)",
21
+ annotations: {
22
+ destructiveHint: true,
23
+ readOnlyHint: false,
24
+ idempotentHint: true,
25
+ },
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ minify: {
30
+ type: "boolean",
31
+ description: "Minify the output bundles (default: true in production)",
32
32
  },
33
33
  sourcemap: {
34
34
  type: "boolean",
@@ -39,50 +39,53 @@ export const hydrationToolDefinitions: Tool[] = [
39
39
  items: { type: "string" },
40
40
  description:
41
41
  "Only rebuild specific islands by routeId. Skips Runtime/Router/Vendor rebuild for faster incremental updates. Omit to rebuild everything.",
42
- },
43
- },
44
- required: [],
45
- },
46
- },
42
+ },
43
+ },
44
+ required: [],
45
+ additionalProperties: false,
46
+ },
47
+ },
47
48
  {
48
49
  name: "mandu.build.status",
49
50
  description:
50
51
  "Get the current build status, bundle manifest, and statistics for client bundles.",
51
- annotations: {
52
- readOnlyHint: true,
53
- },
54
- inputSchema: {
55
- type: "object",
56
- properties: {},
57
- required: [],
58
- },
59
- },
60
- {
61
- name: "mandu.island.list",
52
+ annotations: {
53
+ readOnlyHint: true,
54
+ },
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {},
58
+ required: [],
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ {
63
+ name: "mandu.island.list",
62
64
  description:
63
65
  "List all routes that have client-side hydration (islands). Shows hydration strategy and priority for each.",
64
- annotations: {
65
- readOnlyHint: true,
66
- },
67
- inputSchema: {
68
- type: "object",
69
- properties: {},
70
- required: [],
71
- },
72
- },
73
- {
74
- name: "mandu.hydration.set",
66
+ annotations: {
67
+ readOnlyHint: true,
68
+ },
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {},
72
+ required: [],
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ {
77
+ name: "mandu.hydration.set",
75
78
  description:
76
79
  "Set hydration configuration for a specific route. Updates the route's hydration strategy and priority.",
77
- annotations: {
78
- readOnlyHint: false,
79
- },
80
- inputSchema: {
81
- type: "object",
82
- properties: {
83
- routeId: {
84
- type: "string",
85
- description: "The route ID to configure",
80
+ annotations: {
81
+ readOnlyHint: false,
82
+ },
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ routeId: {
87
+ type: "string",
88
+ description: "The route ID to configure",
86
89
  },
87
90
  strategy: {
88
91
  type: "string",
@@ -100,24 +103,25 @@ export const hydrationToolDefinitions: Tool[] = [
100
103
  type: "boolean",
101
104
  description: "Whether to preload the bundle with modulepreload",
102
105
  },
103
- },
104
- required: ["routeId"],
105
- },
106
- },
107
- {
106
+ },
107
+ required: ["routeId"],
108
+ additionalProperties: false,
109
+ },
110
+ },
111
+ {
108
112
  name: "mandu.hydration.addClientSlot",
109
113
  description:
110
114
  "Add a client slot file for a route to enable hydration. Creates the .client.ts file and updates the manifest.",
111
- annotations: {
112
- destructiveHint: false,
113
- readOnlyHint: false,
114
- },
115
- inputSchema: {
116
- type: "object",
117
- properties: {
118
- routeId: {
119
- type: "string",
120
- description: "The route ID to add client slot for",
115
+ annotations: {
116
+ destructiveHint: false,
117
+ readOnlyHint: false,
118
+ },
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ routeId: {
123
+ type: "string",
124
+ description: "The route ID to add client slot for",
121
125
  },
122
126
  strategy: {
123
127
  type: "string",
@@ -129,13 +133,14 @@ export const hydrationToolDefinitions: Tool[] = [
129
133
  enum: ["immediate", "visible", "idle", "interaction"],
130
134
  description: "Hydration priority (default: visible)",
131
135
  },
132
- },
133
- required: ["routeId"],
134
- },
135
- },
136
- ];
137
-
138
- export function hydrationTools(projectRoot: string) {
136
+ },
137
+ required: ["routeId"],
138
+ additionalProperties: false,
139
+ },
140
+ },
141
+ ];
142
+
143
+ export function hydrationTools(projectRoot: string) {
139
144
  const paths = getProjectPaths(projectRoot);
140
145
 
141
146
  const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
@@ -223,20 +228,25 @@ export function hydrationTools(projectRoot: string) {
223
228
  }
224
229
 
225
230
  const islands = manifestResult.data.routes
226
- .filter((route) => route.kind === "page")
227
- .map((route) => {
228
- const hydration = getRouteHydration(route);
229
- const isIsland = needsHydration(route);
230
-
231
- return {
232
- routeId: route.id,
233
- pattern: route.pattern,
234
- hasClientModule: !!route.clientModule,
235
- clientModule: route.clientModule || null,
236
- isIsland,
237
- hydration: {
238
- strategy: hydration.strategy,
239
- priority: hydration.priority,
231
+ .filter((route) => route.kind === "page")
232
+ .map((route) => {
233
+ const hydration = getRouteHydration(route);
234
+ const isIsland = needsHydration(route);
235
+ const warning = isIsland && !route.clientModule
236
+ ? `Route has hydration strategy '${hydration.strategy}' but no clientModule; build would emit no route bundle.`
237
+ : null;
238
+
239
+ return {
240
+ routeId: route.id,
241
+ pattern: route.pattern,
242
+ hasClientModule: !!route.clientModule,
243
+ clientModule: route.clientModule || null,
244
+ isIsland,
245
+ status: isIsland ? (route.clientModule ? "ready" : "broken") : "static",
246
+ warning,
247
+ hydration: {
248
+ strategy: hydration.strategy,
249
+ priority: hydration.priority,
240
250
  preload: hydration.preload,
241
251
  },
242
252
  };
@@ -252,12 +262,19 @@ export function hydrationTools(projectRoot: string) {
252
262
  islands: islands.filter((i) => i.isIsland),
253
263
  staticPages: islands.filter((i) => !i.isIsland),
254
264
  };
255
- },
256
-
257
- "mandu.hydration.set": async (args: Record<string, unknown>) => {
258
- const { routeId, strategy, priority, preload } = args as {
259
- routeId: string;
260
- strategy?: SpecHydrationStrategy;
265
+ },
266
+
267
+ "mandu.hydration.set": async (args: Record<string, unknown>) => {
268
+ const validationError = validateRouteIdArgs(
269
+ args,
270
+ "mandu.hydration.set",
271
+ ["routeId", "strategy", "priority", "preload"],
272
+ );
273
+ if (validationError) return { error: validationError };
274
+
275
+ const { routeId, strategy, priority, preload } = args as {
276
+ routeId: string;
277
+ strategy?: SpecHydrationStrategy;
261
278
  priority?: HydrationPriority;
262
279
  preload?: boolean;
263
280
  };
@@ -311,12 +328,19 @@ export function hydrationTools(projectRoot: string) {
311
328
  newHydration,
312
329
  message: `Updated hydration config for ${routeId}`,
313
330
  };
314
- },
315
-
316
- "mandu.hydration.addClientSlot": async (args: Record<string, unknown>) => {
317
- const { routeId, strategy = "island", priority = "visible" } = args as {
318
- routeId: string;
319
- strategy?: SpecHydrationStrategy;
331
+ },
332
+
333
+ "mandu.hydration.addClientSlot": async (args: Record<string, unknown>) => {
334
+ const validationError = validateRouteIdArgs(
335
+ args,
336
+ "mandu.hydration.addClientSlot",
337
+ ["routeId", "strategy", "priority"],
338
+ );
339
+ if (validationError) return { error: validationError };
340
+
341
+ const { routeId, strategy = "island", priority = "visible" } = args as {
342
+ routeId: string;
343
+ strategy?: SpecHydrationStrategy;
320
344
  priority?: HydrationPriority;
321
345
  };
322
346
 
@@ -396,14 +420,30 @@ export function hydrationTools(projectRoot: string) {
396
420
  };
397
421
 
398
422
  // Backward-compatible aliases (deprecated)
399
- handlers["mandu_build"] = handlers["mandu.build"];
400
- handlers["mandu_build_status"] = handlers["mandu.build.status"];
401
- handlers["mandu_list_islands"] = handlers["mandu.island.list"];
402
- handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
403
- handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
404
-
405
- return handlers;
406
- }
423
+ handlers["mandu_build"] = handlers["mandu.build"];
424
+ handlers["mandu_build_status"] = handlers["mandu.build.status"];
425
+ handlers["mandu_list_islands"] = handlers["mandu.island.list"];
426
+ handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
427
+ handlers["mandu_hydration_set"] = handlers["mandu.hydration.set"];
428
+ handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
429
+ handlers["mandu_hydration_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
430
+
431
+ return handlers;
432
+ }
433
+
434
+ function validateRouteIdArgs(
435
+ args: Record<string, unknown>,
436
+ toolName: string,
437
+ allowedKeys: readonly string[],
438
+ ): string | null {
439
+ if (typeof args.routeId !== "string" || args.routeId.trim().length === 0) {
440
+ const allowed = new Set(allowedKeys);
441
+ const unknownKeys = Object.keys(args).filter((key) => !allowed.has(key));
442
+ const got = unknownKeys.length > 0 ? ` (got unknown key${unknownKeys.length === 1 ? "" : "s"} '${unknownKeys.join("', '")}')` : "";
443
+ return `${toolName}: missing required parameter 'routeId'${got}`;
444
+ }
445
+ return null;
446
+ }
407
447
 
408
448
  /**
409
449
  * Generate a client slot template
@@ -11,9 +11,9 @@ import { mcpToolRegistry } from "../registry/mcp-tool-registry.js";
11
11
  import { moduleToPlugins } from "../adapters/tool-adapter.js";
12
12
  import { type McpProfile, getProfileCategories } from "../profiles.js";
13
13
 
14
- // 도구 모듈 export
15
- export { agentTools, agentToolDefinitions } from "./agent.js";
16
- export { specTools, specToolDefinitions } from "./spec.js";
14
+ // 도구 모듈 export
15
+ export { agentTools, agentToolDefinitions } from "./agent.js";
16
+ export { specTools, specToolDefinitions } from "./spec.js";
17
17
  export { generateTools, generateToolDefinitions } from "./generate.js";
18
18
  export { transactionTools, transactionToolDefinitions } from "./transaction.js";
19
19
  export { historyTools, historyToolDefinitions } from "./history.js";
@@ -86,9 +86,9 @@ export {
86
86
  extractContractToolDefinitions,
87
87
  } from "./extract-contract.js";
88
88
 
89
- // 도구 모듈 import (등록용)
90
- import { agentTools, agentToolDefinitions } from "./agent.js";
91
- import { specTools, specToolDefinitions } from "./spec.js";
89
+ // 도구 모듈 import (등록용)
90
+ import { agentTools, agentToolDefinitions } from "./agent.js";
91
+ import { specTools, specToolDefinitions } from "./spec.js";
92
92
  import { generateTools, generateToolDefinitions } from "./generate.js";
93
93
  import { transactionTools, transactionToolDefinitions } from "./transaction.js";
94
94
  import { historyTools, historyToolDefinitions } from "./history.js";
@@ -164,10 +164,10 @@ import {
164
164
  /**
165
165
  * 도구 모듈 정보
166
166
  */
167
- export interface ToolModule {
168
- category: string;
169
- definitions: Tool[];
170
- handlers: (
167
+ export interface ToolModule {
168
+ category: string;
169
+ definitions: Tool[];
170
+ handlers: (
171
171
  projectRoot: string,
172
172
  server?: Server,
173
173
  monitor?: ActivityMonitor
@@ -190,9 +190,9 @@ export interface ToolModule {
190
190
  /**
191
191
  * 빌트인 도구 모듈 목록
192
192
  */
193
- export const TOOL_MODULES: ToolModule[] = [
194
- { category: "agent", definitions: agentToolDefinitions, handlers: agentTools },
195
- { category: "spec", definitions: specToolDefinitions, handlers: specTools },
193
+ export const TOOL_MODULES: ToolModule[] = [
194
+ { category: "agent", definitions: agentToolDefinitions, handlers: agentTools },
195
+ { category: "spec", definitions: specToolDefinitions, handlers: specTools },
196
196
  { category: "generate", definitions: generateToolDefinitions, handlers: generateTools },
197
197
  { category: "transaction", definitions: transactionToolDefinitions, handlers: transactionTools },
198
198
  { category: "history", definitions: historyToolDefinitions, handlers: historyTools },
@@ -203,18 +203,18 @@ export const TOOL_MODULES: ToolModule[] = [
203
203
  { category: "slot", definitions: slotToolDefinitions, handlers: slotTools },
204
204
  { category: "hydration", definitions: hydrationToolDefinitions, handlers: hydrationTools },
205
205
  { category: "contract", definitions: contractToolDefinitions, handlers: contractTools },
206
- { category: "brain", definitions: brainToolDefinitions, handlers: brainTools, requiresServer: true },
206
+ { category: "brain", definitions: brainToolDefinitions, handlers: brainTools, requiresServer: true },
207
207
  { category: "runtime", definitions: runtimeToolDefinitions, handlers: runtimeTools },
208
208
  { category: "seo", definitions: seoToolDefinitions, handlers: seoTools },
209
- { category: "project", definitions: projectToolDefinitions, handlers: projectTools, requiresServer: true },
209
+ { category: "project", definitions: projectToolDefinitions, handlers: projectTools, requiresServer: true },
210
210
  // ate + ate-run accept an optional Server so notifications/progress
211
211
  // can flow (issue #238). `acceptsServer: true` forwards the server
212
212
  // when available but still registers when it isn't — callers that
213
213
  // boot without an MCP transport get progress no-oped silently.
214
- { category: "ate", definitions: ateToolDefinitions, handlers: ateTools, acceptsServer: true },
215
- { category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers },
214
+ { category: "ate", definitions: ateToolDefinitions, handlers: ateTools, acceptsServer: true },
215
+ { category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers },
216
216
  { category: "ate-context", definitions: ateContextToolDefinitions, handlers: ateContextTools },
217
- { category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools, acceptsServer: true },
217
+ { category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools, acceptsServer: true },
218
218
  { category: "ate-flakes", definitions: ateFlakesToolDefinitions, handlers: ateFlakesTools },
219
219
  { category: "ate-prompt", definitions: atePromptToolDefinitions, handlers: atePromptTools },
220
220
  { category: "ate-exemplar", definitions: ateExemplarToolDefinitions, handlers: ateExemplarTools },
@@ -285,41 +285,41 @@ export const TOOL_MODULES: ToolModule[] = [
285
285
  definitions: extractContractToolDefinitions,
286
286
  handlers: extractContractTools,
287
287
  },
288
- ];
289
-
290
- export function validateBuiltinToolModules(
291
- modules: readonly ToolModule[] = TOOL_MODULES
292
- ): string[] {
293
- const issues: string[] = [];
294
- const categories = new Set<string>();
295
- const toolNames = new Map<string, string>();
296
-
297
- for (const module of modules) {
298
- if (categories.has(module.category)) {
299
- issues.push(`duplicate tool category: ${module.category}`);
300
- }
301
- categories.add(module.category);
302
-
303
- if (module.definitions.length === 0) {
304
- issues.push(`tool category has no definitions: ${module.category}`);
305
- }
306
-
307
- for (const definition of module.definitions) {
308
- const previousCategory = toolNames.get(definition.name);
309
- if (previousCategory) {
310
- issues.push(
311
- `duplicate tool definition: ${definition.name} in ${previousCategory} and ${module.category}`
312
- );
313
- }
314
- toolNames.set(definition.name, module.category);
315
- }
316
- }
317
-
318
- return issues;
319
- }
320
-
321
- /**
322
- * 빌트인 도구들을 레지스트리에 등록
288
+ ];
289
+
290
+ export function validateBuiltinToolModules(
291
+ modules: readonly ToolModule[] = TOOL_MODULES
292
+ ): string[] {
293
+ const issues: string[] = [];
294
+ const categories = new Set<string>();
295
+ const toolNames = new Map<string, string>();
296
+
297
+ for (const module of modules) {
298
+ if (categories.has(module.category)) {
299
+ issues.push(`duplicate tool category: ${module.category}`);
300
+ }
301
+ categories.add(module.category);
302
+
303
+ if (module.definitions.length === 0) {
304
+ issues.push(`tool category has no definitions: ${module.category}`);
305
+ }
306
+
307
+ for (const definition of module.definitions) {
308
+ const previousCategory = toolNames.get(definition.name);
309
+ if (previousCategory) {
310
+ issues.push(
311
+ `duplicate tool definition: ${definition.name} in ${previousCategory} and ${module.category}`
312
+ );
313
+ }
314
+ toolNames.set(definition.name, module.category);
315
+ }
316
+ }
317
+
318
+ return issues;
319
+ }
320
+
321
+ /**
322
+ * 빌트인 도구들을 레지스트리에 등록
323
323
  *
324
324
  * @param projectRoot - 프로젝트 루트 경로
325
325
  * @param server - MCP Server 인스턴스 (선택, brain/project 도구에 필요)
@@ -340,15 +340,15 @@ export function registerBuiltinTools(
340
340
  monitor?: ActivityMonitor,
341
341
  options?: { profile?: McpProfile }
342
342
  ): void {
343
- const allowedCategories = options?.profile
344
- ? getProfileCategories(options.profile)
345
- : null;
346
- const definitionIssues = validateBuiltinToolModules();
347
- if (definitionIssues.length > 0) {
348
- throw new Error(`Invalid MCP tool module registry:\n${definitionIssues.join("\n")}`);
349
- }
350
-
351
- for (const module of TOOL_MODULES) {
343
+ const allowedCategories = options?.profile
344
+ ? getProfileCategories(options.profile)
345
+ : null;
346
+ const definitionIssues = validateBuiltinToolModules();
347
+ if (definitionIssues.length > 0) {
348
+ throw new Error(`Invalid MCP tool module registry:\n${definitionIssues.join("\n")}`);
349
+ }
350
+
351
+ for (const module of TOOL_MODULES) {
352
352
  // Profile filtering: skip categories not in the allowed list
353
353
  if (allowedCategories && !allowedCategories.includes(module.category)) {
354
354
  continue;
@@ -359,13 +359,13 @@ export function registerBuiltinTools(
359
359
  continue;
360
360
  }
361
361
 
362
- try {
363
- let handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
364
- if (module.requiresServer) {
365
- handlers = module.handlers(projectRoot, server, monitor);
366
- } else if (module.acceptsServer) {
367
- // Forward the Server when available; fall back to just projectRoot.
368
- handlers = server
362
+ try {
363
+ let handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
364
+ if (module.requiresServer) {
365
+ handlers = module.handlers(projectRoot, server, monitor);
366
+ } else if (module.acceptsServer) {
367
+ // Forward the Server when available; fall back to just projectRoot.
368
+ handlers = server
369
369
  ? module.handlers(projectRoot, server)
370
370
  : module.handlers(projectRoot);
371
371
  } else {
@@ -3,10 +3,12 @@
3
3
  * Query and manage runtime configuration: logger settings and contract normalize options.
4
4
  */
5
5
 
6
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
7
- import { getProjectPaths } from "../utils/project.js";
8
- import { loadManifest } from "@mandujs/core";
9
- import path from "path";
6
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
7
+ import { getProjectPaths } from "../utils/project.js";
8
+ import { loadManduConfig, loadManifest, needsHydration } from "@mandujs/core";
9
+ import { getDevServerState } from "./project.js";
10
+ import { readRuntimeControl } from "../utils/runtime-control.js";
11
+ import path from "path";
10
12
 
11
13
 
12
14
  export const runtimeToolDefinitions: Tool[] = [
@@ -26,8 +28,49 @@ export const runtimeToolDefinitions: Tool[] = [
26
28
  required: [],
27
29
  },
28
30
  },
29
- {
30
- name: "mandu.runtime.contractOptions",
31
+ {
32
+ name: "mandu.runtime.probe",
33
+ annotations: {
34
+ readOnlyHint: true,
35
+ },
36
+ description:
37
+ "Probe a running Mandu dev/start server by fetching real page HTML and island bundle URLs. " +
38
+ "Catches silent runtime failures that static manifest/guard checks miss, especially empty data-mandu-src island markers.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ baseURL: {
43
+ type: "string",
44
+ description: "Explicit dev server URL. Overrides runtime-control/config discovery.",
45
+ },
46
+ port: {
47
+ type: "number",
48
+ description: "Explicit dev server port. Overrides runtime-control/config discovery.",
49
+ },
50
+ routeIds: {
51
+ type: "array",
52
+ items: { type: "string" },
53
+ description: "Optional route IDs to probe. Omit to probe all page routes that can be sampled.",
54
+ },
55
+ includeDynamic: {
56
+ type: "boolean",
57
+ description: "Probe dynamic routes by substituting __mandu_probe__ for params. Defaults to false.",
58
+ },
59
+ checkBundleUrls: {
60
+ type: "boolean",
61
+ description: "Fetch every non-empty data-mandu-src URL and require a 2xx response. Defaults to true.",
62
+ },
63
+ timeoutMs: {
64
+ type: "number",
65
+ description: "Per-request timeout in milliseconds. Defaults to 3000.",
66
+ },
67
+ },
68
+ required: [],
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ {
73
+ name: "mandu.runtime.contractOptions",
31
74
  annotations: {
32
75
  readOnlyHint: true,
33
76
  },
@@ -150,10 +193,10 @@ async function readFileContent(filePath: string): Promise<string | null> {
150
193
  }
151
194
  }
152
195
 
153
- export function runtimeTools(projectRoot: string) {
154
- const paths = getProjectPaths(projectRoot);
155
-
156
- const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
196
+ export function runtimeTools(projectRoot: string) {
197
+ const paths = getProjectPaths(projectRoot);
198
+
199
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
157
200
  "mandu.runtime.config": async () => {
158
201
  return {
159
202
  defaults: {
@@ -216,10 +259,78 @@ export default Mandu.contract({
216
259
  response: { ... },
217
260
  });`,
218
261
  },
219
- };
220
- },
221
-
222
- "mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
262
+ };
263
+ },
264
+
265
+ "mandu.runtime.probe": async (args: Record<string, unknown>) => {
266
+ const baseUrl = await resolveDevServerBaseUrl(projectRoot, args);
267
+ const timeoutMs = normalizeTimeout(args.timeoutMs);
268
+ const checkBundleUrls = args.checkBundleUrls !== false;
269
+ const includeDynamic = args.includeDynamic === true;
270
+ const routeIdFilter = Array.isArray(args.routeIds)
271
+ ? new Set(args.routeIds.filter((id): id is string => typeof id === "string"))
272
+ : null;
273
+
274
+ const result = await loadManifest(paths.manifestPath);
275
+ if (!result.success || !result.data) {
276
+ return {
277
+ success: false,
278
+ baseUrl,
279
+ error: result.errors,
280
+ };
281
+ }
282
+
283
+ const pageRoutes = result.data.routes
284
+ .filter((route) => route.kind === "page")
285
+ .filter((route) => !routeIdFilter || routeIdFilter.has(route.id))
286
+ .map((route) => ({
287
+ route,
288
+ samplePath: samplePathForPattern(route.pattern, includeDynamic),
289
+ }));
290
+
291
+ const skipped = pageRoutes
292
+ .filter((entry) => entry.samplePath === null)
293
+ .map((entry) => ({
294
+ routeId: entry.route.id,
295
+ pattern: entry.route.pattern,
296
+ reason: "dynamic_route",
297
+ }));
298
+ const probes = await Promise.all(
299
+ pageRoutes
300
+ .filter((entry): entry is typeof entry & { samplePath: string } => entry.samplePath !== null)
301
+ .map((entry) =>
302
+ probeRoute({
303
+ baseUrl,
304
+ route: entry.route,
305
+ samplePath: entry.samplePath,
306
+ timeoutMs,
307
+ checkBundleUrls,
308
+ })
309
+ )
310
+ );
311
+
312
+ const failures = probes.flatMap((probe) =>
313
+ probe.failures.map((failure) => ({
314
+ routeId: probe.routeId,
315
+ pattern: probe.pattern,
316
+ path: probe.path,
317
+ ...failure,
318
+ }))
319
+ );
320
+
321
+ return {
322
+ success: failures.length === 0,
323
+ baseUrl,
324
+ checkedRoutes: probes.length,
325
+ skippedRoutes: skipped.length,
326
+ failureCount: failures.length,
327
+ failures,
328
+ routes: probes,
329
+ skipped,
330
+ };
331
+ },
332
+
333
+ "mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
223
334
  const { routeId } = args as { routeId: string };
224
335
 
225
336
  const result = await loadManifest(paths.manifestPath);
@@ -521,13 +632,205 @@ export const appLogger = logger(${JSON.stringify(config, null, 2)});
521
632
  // Backward-compatible aliases (deprecated)
522
633
  handlers["mandu_get_runtime_config"] = handlers["mandu.runtime.config"];
523
634
  handlers["mandu_set_contract_normalize"] = handlers["mandu.runtime.setNormalize"];
524
- handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
525
- handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
526
- handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
527
-
528
- return handlers;
529
- }
530
-
531
- function insertAfter(content: string, search: string): boolean {
532
- return content.includes(search);
533
- }
635
+ handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
636
+ handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
637
+ handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
638
+ handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
639
+
640
+ return handlers;
641
+ }
642
+
643
+ function insertAfter(content: string, search: string): boolean {
644
+ return content.includes(search);
645
+ }
646
+
647
+ async function resolveDevServerBaseUrl(
648
+ projectRoot: string,
649
+ args: { baseURL?: unknown; port?: unknown } = {},
650
+ ): Promise<string> {
651
+ if (typeof args.baseURL === "string" && args.baseURL.trim()) {
652
+ return args.baseURL.trim().replace(/\/+$/, "");
653
+ }
654
+
655
+ const explicitPort = normalizePort(args.port);
656
+ if (explicitPort) {
657
+ return `http://localhost:${explicitPort}`;
658
+ }
659
+
660
+ const control = await readRuntimeControl(projectRoot);
661
+ if (control?.baseUrl) {
662
+ return control.baseUrl.replace(/\/+$/, "");
663
+ }
664
+
665
+ let port: number | undefined;
666
+ const serverState = getDevServerState();
667
+ if (serverState) {
668
+ for (const line of serverState.output) {
669
+ const portMatch = line.match(/https?:\/\/localhost:(\d+)/);
670
+ if (portMatch) {
671
+ port = Number.parseInt(portMatch[1], 10);
672
+ }
673
+ }
674
+ }
675
+
676
+ if (!port) {
677
+ const config = await loadManduConfig(projectRoot);
678
+ port = config.server?.port ?? 3333;
679
+ }
680
+
681
+ return `http://localhost:${port}`;
682
+ }
683
+
684
+ function normalizePort(value: unknown): number | undefined {
685
+ const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
686
+ if (!Number.isInteger(raw) || raw < 1 || raw > 65535) return undefined;
687
+ return raw;
688
+ }
689
+
690
+ function normalizeTimeout(value: unknown): number {
691
+ const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
692
+ if (!Number.isInteger(raw) || raw < 100 || raw > 30_000) return 3000;
693
+ return raw;
694
+ }
695
+
696
+ function samplePathForPattern(pattern: string, includeDynamic: boolean): string | null {
697
+ if (!includeDynamic && /(^|\/):[^/]+/.test(pattern)) return null;
698
+ const sampled = pattern
699
+ .replace(/:([A-Za-z0-9_]+)/g, "__mandu_probe__")
700
+ .replace(/\*+/g, "__mandu_probe__");
701
+ return sampled.startsWith("/") ? sampled : `/${sampled}`;
702
+ }
703
+
704
+ interface IslandMarker {
705
+ id: string | null;
706
+ src: string | null;
707
+ }
708
+
709
+ function extractIslandMarkers(html: string): IslandMarker[] {
710
+ const markers: IslandMarker[] = [];
711
+ const tagPattern = /<[^>]*\bdata-mandu-island(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?[^>]*>/gi;
712
+ for (const match of html.matchAll(tagPattern)) {
713
+ const tag = match[0];
714
+ markers.push({
715
+ id: readHtmlAttr(tag, "data-mandu-island"),
716
+ src: readHtmlAttr(tag, "data-mandu-src"),
717
+ });
718
+ }
719
+ return markers;
720
+ }
721
+
722
+ function readHtmlAttr(tag: string, attr: string): string | null {
723
+ const pattern = new RegExp(
724
+ `\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
725
+ "i",
726
+ );
727
+ const match = tag.match(pattern);
728
+ return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
729
+ }
730
+
731
+ async function probeRoute({
732
+ baseUrl,
733
+ route,
734
+ samplePath,
735
+ timeoutMs,
736
+ checkBundleUrls,
737
+ }: {
738
+ baseUrl: string;
739
+ route: { id: string; pattern: string; clientModule?: string; hydration?: unknown };
740
+ samplePath: string;
741
+ timeoutMs: number;
742
+ checkBundleUrls: boolean;
743
+ }): Promise<{
744
+ routeId: string;
745
+ pattern: string;
746
+ path: string;
747
+ status: number | null;
748
+ islandCount: number;
749
+ failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }>;
750
+ }> {
751
+ const failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }> = [];
752
+ const url = new URL(samplePath, `${baseUrl}/`);
753
+ let response: Response;
754
+ try {
755
+ response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
756
+ } catch (error) {
757
+ return {
758
+ routeId: route.id,
759
+ pattern: route.pattern,
760
+ path: samplePath,
761
+ status: null,
762
+ islandCount: 0,
763
+ failures: [{
764
+ code: "page_fetch_failed",
765
+ message: error instanceof Error ? error.message : String(error),
766
+ status: null,
767
+ }],
768
+ };
769
+ }
770
+
771
+ if (!response.ok) {
772
+ failures.push({
773
+ code: "page_status",
774
+ message: `Page responded with HTTP ${response.status}`,
775
+ status: response.status,
776
+ });
777
+ }
778
+
779
+ const html = await response.text();
780
+ const markers = extractIslandMarkers(html);
781
+ const hasRouteIsland = markers.some((marker) => marker.id === route.id);
782
+ if (route.clientModule && needsHydration(route as Parameters<typeof needsHydration>[0]) && !hasRouteIsland) {
783
+ failures.push({
784
+ code: "missing_route_island_marker",
785
+ message: `Route has clientModule but HTML does not contain data-mandu-island="${route.id}"`,
786
+ islandId: route.id,
787
+ });
788
+ }
789
+
790
+ for (const marker of markers) {
791
+ if (!marker.src || marker.src.trim().length === 0) {
792
+ failures.push({
793
+ code: "empty_island_src",
794
+ message: "Island marker has empty data-mandu-src",
795
+ islandId: marker.id,
796
+ src: marker.src,
797
+ });
798
+ continue;
799
+ }
800
+ if (!checkBundleUrls) continue;
801
+
802
+ const bundleUrl = new URL(marker.src, `${baseUrl}/`);
803
+ try {
804
+ const bundleResponse = await fetch(bundleUrl, {
805
+ method: "GET",
806
+ signal: AbortSignal.timeout(timeoutMs),
807
+ });
808
+ if (!bundleResponse.ok) {
809
+ failures.push({
810
+ code: "bundle_status",
811
+ message: `Island bundle responded with HTTP ${bundleResponse.status}`,
812
+ islandId: marker.id,
813
+ src: marker.src,
814
+ status: bundleResponse.status,
815
+ });
816
+ }
817
+ } catch (error) {
818
+ failures.push({
819
+ code: "bundle_fetch_failed",
820
+ message: error instanceof Error ? error.message : String(error),
821
+ islandId: marker.id,
822
+ src: marker.src,
823
+ status: null,
824
+ });
825
+ }
826
+ }
827
+
828
+ return {
829
+ routeId: route.id,
830
+ pattern: route.pattern,
831
+ path: samplePath,
832
+ status: response.status,
833
+ islandCount: markers.length,
834
+ failures,
835
+ };
836
+ }