@mandujs/mcp 0.38.1 → 0.38.2

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.38.1",
3
+ "version": "0.38.2",
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.7",
37
+ "@mandujs/core": "^0.54.8",
38
38
  "@mandujs/ate": "^0.26.1",
39
39
  "@mandujs/skills": "^0.20.1",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
@@ -61,8 +61,22 @@ export const hydrationToolDefinitions: Tool[] = [
61
61
  },
62
62
  {
63
63
  name: "mandu.island.list",
64
- description:
65
- "List all routes that have client-side hydration (islands). Shows hydration strategy and priority for each.",
64
+ description:
65
+ "Legacy alias for page client mount diagnostics. Prefer mandu.pageClientMount.list for terminology that separates page-level hydration from nested islands.",
66
+ annotations: {
67
+ readOnlyHint: true,
68
+ },
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {},
72
+ required: [],
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ {
77
+ name: "mandu.pageClientMount.list",
78
+ description:
79
+ "List page-level client mounts: page routes that need a route-level clientModule and bundle. This is distinct from nested islands/partials.",
66
80
  annotations: {
67
81
  readOnlyHint: true,
68
82
  },
@@ -220,19 +234,19 @@ export function hydrationTools(projectRoot: string) {
220
234
  };
221
235
  },
222
236
 
223
- "mandu.island.list": async () => {
224
- // Load manifest
225
- const manifestResult = await loadManifest(paths.manifestPath);
226
- if (!manifestResult.success || !manifestResult.data) {
227
- return { error: manifestResult.errors };
228
- }
229
-
230
- const islands = manifestResult.data.routes
237
+ "mandu.pageClientMount.list": async () => {
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 pageClientMounts = manifestResult.data.routes
231
245
  .filter((route) => route.kind === "page")
232
246
  .map((route) => {
233
247
  const hydration = getRouteHydration(route);
234
- const isIsland = needsHydration(route);
235
- const warning = isIsland && !route.clientModule
248
+ const needsClientMount = needsHydration(route);
249
+ const warning = needsClientMount && !route.clientModule
236
250
  ? `Route has hydration strategy '${hydration.strategy}' but no clientModule; build would emit no route bundle.`
237
251
  : null;
238
252
 
@@ -241,29 +255,44 @@ export function hydrationTools(projectRoot: string) {
241
255
  pattern: route.pattern,
242
256
  hasClientModule: !!route.clientModule,
243
257
  clientModule: route.clientModule || null,
244
- isIsland,
245
- status: isIsland ? (route.clientModule ? "ready" : "broken") : "static",
258
+ needsClientMount,
259
+ // Backward compatibility for older agents that read `isIsland`.
260
+ isIsland: needsClientMount,
261
+ status: needsClientMount ? (route.clientModule ? "ready" : "broken") : "static",
246
262
  warning,
247
263
  hydration: {
248
264
  strategy: hydration.strategy,
249
265
  priority: hydration.priority,
250
266
  preload: hydration.preload,
251
- },
252
- };
253
- });
254
-
255
- const islandCount = islands.filter((i) => i.isIsland).length;
256
- const staticCount = islands.filter((i) => !i.isIsland).length;
257
-
258
- return {
259
- totalPages: islands.length,
260
- islandCount,
261
- staticCount,
262
- islands: islands.filter((i) => i.isIsland),
263
- staticPages: islands.filter((i) => !i.isIsland),
264
- };
267
+ },
268
+ };
269
+ });
270
+
271
+ const pageClientMountCount = pageClientMounts.filter((i) => i.needsClientMount).length;
272
+ const staticCount = pageClientMounts.filter((i) => !i.needsClientMount).length;
273
+ const activeMounts = pageClientMounts.filter((i) => i.needsClientMount);
274
+ const staticPages = pageClientMounts.filter((i) => !i.needsClientMount);
275
+
276
+ return {
277
+ terminology: {
278
+ pageClientMount:
279
+ "A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
280
+ island:
281
+ "A nested or route-local island bundle, distinct from page client mounts. Use mandu.runtime.status for both collections.",
282
+ },
283
+ totalPages: pageClientMounts.length,
284
+ pageClientMountCount,
285
+ staticCount,
286
+ pageClientMounts: activeMounts,
287
+ staticPages,
288
+ // Backward compatibility for older clients.
289
+ islandCount: pageClientMountCount,
290
+ islands: activeMounts,
291
+ };
265
292
  },
266
293
 
294
+ "mandu.island.list": async () => handlers["mandu.pageClientMount.list"]({}),
295
+
267
296
  "mandu.hydration.set": async (args: Record<string, unknown>) => {
268
297
  const validationError = validateRouteIdArgs(
269
298
  args,
@@ -423,6 +452,7 @@ export function hydrationTools(projectRoot: string) {
423
452
  handlers["mandu_build"] = handlers["mandu.build"];
424
453
  handlers["mandu_build_status"] = handlers["mandu.build.status"];
425
454
  handlers["mandu_list_islands"] = handlers["mandu.island.list"];
455
+ handlers["mandu_page_client_mount_list"] = handlers["mandu.pageClientMount.list"];
426
456
  handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
427
457
  handlers["mandu_hydration_set"] = handlers["mandu.hydration.set"];
428
458
  handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
@@ -5,7 +5,13 @@
5
5
 
6
6
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { getProjectPaths } from "../utils/project.js";
8
- import { loadManduConfig, loadManifest, needsHydration } from "@mandujs/core";
8
+ import {
9
+ getRouteHydration,
10
+ loadManduConfig,
11
+ loadManifest,
12
+ needsHydration,
13
+ type BundleManifest,
14
+ } from "@mandujs/core";
9
15
  import { getDevServerState } from "./project.js";
10
16
  import { readRuntimeControl } from "../utils/runtime-control.js";
11
17
  import path from "path";
@@ -69,6 +75,20 @@ export const runtimeToolDefinitions: Tool[] = [
69
75
  additionalProperties: false,
70
76
  },
71
77
  },
78
+ {
79
+ name: "mandu.runtime.status",
80
+ annotations: {
81
+ readOnlyHint: true,
82
+ },
83
+ description:
84
+ "Single source of truth for Mandu client runtime state. Separates page client mounts from nested islands and compares routes manifest, bundle manifest, and generated route artifacts.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {},
88
+ required: [],
89
+ additionalProperties: false,
90
+ },
91
+ },
72
92
  {
73
93
  name: "mandu.runtime.contractOptions",
74
94
  annotations: {
@@ -330,6 +350,67 @@ export default Mandu.contract({
330
350
  };
331
351
  },
332
352
 
353
+ "mandu.runtime.status": async () => {
354
+ const result = await loadManifest(paths.manifestPath);
355
+ if (!result.success || !result.data) {
356
+ return {
357
+ success: false,
358
+ error: result.errors,
359
+ };
360
+ }
361
+
362
+ const bundleManifest = await readBundleManifest(projectRoot);
363
+ const pageRoutes = result.data.routes.filter((route) => route.kind === "page");
364
+ const pageClientMounts = await Promise.all(
365
+ pageRoutes.map(async (route) =>
366
+ describePageClientMount(projectRoot, route, bundleManifest),
367
+ ),
368
+ );
369
+ const nestedIslands = Object.entries(bundleManifest?.islands ?? {}).map(([id, island]) => ({
370
+ islandId: id,
371
+ routeId: island.route,
372
+ bundleUrl: island.js,
373
+ priority: island.priority,
374
+ }));
375
+ const partials = Object.entries(bundleManifest?.partials ?? {}).map(([id, partial]) => ({
376
+ partialId: id,
377
+ bundleUrl: partial.js,
378
+ priority: partial.priority,
379
+ }));
380
+ const consistencyChecks = buildRuntimeConsistencyChecks(pageClientMounts, bundleManifest);
381
+ const failedChecks = consistencyChecks.filter((check) => check.status === "fail");
382
+ const brokenMounts = pageClientMounts.filter((mount) => mount.status === "broken");
383
+
384
+ return {
385
+ success: failedChecks.length === 0 && brokenMounts.length === 0,
386
+ terminology: {
387
+ pageClientMount:
388
+ "A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
389
+ island:
390
+ "A nested or route-local island bundle listed in .mandu/manifest.json islands, distinct from page client mounts.",
391
+ partial:
392
+ "An inline partial hydration boundary listed in .mandu/manifest.json partials.",
393
+ },
394
+ sources: {
395
+ routesManifest: ".mandu/routes.manifest.json",
396
+ bundleManifest: bundleManifest ? ".mandu/manifest.json" : null,
397
+ generatedRoutes: ".mandu/generated/web/routes/*.route.tsx",
398
+ },
399
+ summary: {
400
+ totalPages: pageRoutes.length,
401
+ pageClientMountCount: pageClientMounts.filter((mount) => mount.needsClientMount).length,
402
+ brokenPageClientMountCount: brokenMounts.length,
403
+ nestedIslandCount: nestedIslands.length,
404
+ partialCount: partials.length,
405
+ failedConsistencyCheckCount: failedChecks.length,
406
+ },
407
+ pageClientMounts,
408
+ islands: nestedIslands,
409
+ partials,
410
+ consistencyChecks,
411
+ };
412
+ },
413
+
333
414
  "mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
334
415
  const { routeId } = args as { routeId: string };
335
416
 
@@ -636,10 +717,181 @@ export const appLogger = logger(${JSON.stringify(config, null, 2)});
636
717
  handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
637
718
  handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
638
719
  handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
720
+ handlers["mandu_runtime_status"] = handlers["mandu.runtime.status"];
639
721
 
640
722
  return handlers;
641
723
  }
642
724
 
725
+ type PageRouteForStatus = Parameters<typeof needsHydration>[0] & {
726
+ id: string;
727
+ pattern: string;
728
+ clientModule?: string;
729
+ };
730
+
731
+ type PageClientMountStatus = {
732
+ routeId: string;
733
+ pattern: string;
734
+ needsClientMount: boolean;
735
+ hasClientModule: boolean;
736
+ clientModule: string | null;
737
+ hydration: ReturnType<typeof getRouteHydration>;
738
+ bundleUrl: string | null;
739
+ status: "static" | "pending" | "healthy" | "broken";
740
+ reasons: string[];
741
+ generatedRoute: GeneratedRouteInspection;
742
+ };
743
+
744
+ type GeneratedRouteInspection = {
745
+ path: string;
746
+ exists: boolean;
747
+ kind: "missing" | "client_mount" | "placeholder" | "custom";
748
+ referencesClientModule: boolean;
749
+ callsIslandRender: boolean;
750
+ };
751
+
752
+ type RuntimeConsistencyCheck = {
753
+ check: string;
754
+ status: "pass" | "fail" | "skip";
755
+ failingRoutes?: string[];
756
+ reason?: string;
757
+ };
758
+
759
+ async function readBundleManifest(projectRoot: string): Promise<BundleManifest | null> {
760
+ const filePath = path.join(projectRoot, ".mandu/manifest.json");
761
+ try {
762
+ const raw = await Bun.file(filePath).text();
763
+ return JSON.parse(raw) as BundleManifest;
764
+ } catch {
765
+ return null;
766
+ }
767
+ }
768
+
769
+ async function describePageClientMount(
770
+ projectRoot: string,
771
+ route: PageRouteForStatus,
772
+ bundleManifest: BundleManifest | null,
773
+ ): Promise<PageClientMountStatus> {
774
+ const hydration = getRouteHydration(route);
775
+ const needsClientMount = needsHydration(route);
776
+ const bundle = bundleManifest?.bundles?.[route.id] ?? null;
777
+ const generatedRoute = await inspectGeneratedRoute(projectRoot, route);
778
+ const reasons: string[] = [];
779
+
780
+ if (needsClientMount && !route.clientModule) {
781
+ reasons.push("missing_client_module");
782
+ }
783
+ if (needsClientMount && route.clientModule && bundleManifest && !bundle) {
784
+ reasons.push("missing_bundle");
785
+ }
786
+ if (needsClientMount && route.clientModule && generatedRoute.exists && !generatedRoute.referencesClientModule) {
787
+ reasons.push("generated_route_not_using_client_module");
788
+ }
789
+ if (needsClientMount && !route.clientModule && generatedRoute.kind === "placeholder") {
790
+ reasons.push("generated_placeholder_for_hydrating_route");
791
+ }
792
+
793
+ const status = !needsClientMount
794
+ ? "static"
795
+ : reasons.length > 0
796
+ ? "broken"
797
+ : bundleManifest
798
+ ? "healthy"
799
+ : "pending";
800
+
801
+ return {
802
+ routeId: route.id,
803
+ pattern: route.pattern,
804
+ needsClientMount,
805
+ hasClientModule: !!route.clientModule,
806
+ clientModule: route.clientModule ?? null,
807
+ hydration,
808
+ bundleUrl: bundle?.js ?? null,
809
+ status,
810
+ reasons,
811
+ generatedRoute,
812
+ };
813
+ }
814
+
815
+ async function inspectGeneratedRoute(
816
+ projectRoot: string,
817
+ route: PageRouteForStatus,
818
+ ): Promise<GeneratedRouteInspection> {
819
+ const relPath = `.mandu/generated/web/routes/${route.id}.route.tsx`;
820
+ const filePath = path.join(projectRoot, relPath);
821
+ let source: string;
822
+ try {
823
+ source = await Bun.file(filePath).text();
824
+ } catch {
825
+ return {
826
+ path: relPath,
827
+ exists: false,
828
+ kind: "missing",
829
+ referencesClientModule: false,
830
+ callsIslandRender: false,
831
+ };
832
+ }
833
+
834
+ const normalized = source.replace(/\\/g, "/");
835
+ const clientModule = route.clientModule?.replace(/\\/g, "/") ?? null;
836
+ const referencesClientModule = clientModule ? normalized.includes(`Client Module: ${clientModule}`) : false;
837
+ const callsIslandRender = normalized.includes("islandModule.definition.render");
838
+ const placeholder =
839
+ normalized.includes('React.createElement("h1", null') &&
840
+ normalized.includes(`Route ID: ${route.id}`);
841
+
842
+ return {
843
+ path: relPath,
844
+ exists: true,
845
+ kind: referencesClientModule && callsIslandRender
846
+ ? "client_mount"
847
+ : placeholder
848
+ ? "placeholder"
849
+ : "custom",
850
+ referencesClientModule,
851
+ callsIslandRender,
852
+ };
853
+ }
854
+
855
+ function buildRuntimeConsistencyChecks(
856
+ pageClientMounts: PageClientMountStatus[],
857
+ bundleManifest: BundleManifest | null,
858
+ ): RuntimeConsistencyCheck[] {
859
+ const activeMounts = pageClientMounts.filter((mount) => mount.needsClientMount);
860
+ const missingClientModule = activeMounts
861
+ .filter((mount) => !mount.hasClientModule)
862
+ .map((mount) => mount.routeId);
863
+ const missingBundle = activeMounts
864
+ .filter((mount) => mount.hasClientModule && bundleManifest && !mount.bundleUrl)
865
+ .map((mount) => mount.routeId);
866
+ const generatedMismatch = activeMounts
867
+ .filter((mount) => mount.generatedRoute.exists && mount.generatedRoute.kind !== "client_mount")
868
+ .map((mount) => mount.routeId);
869
+
870
+ return [
871
+ {
872
+ check: "hydrating-routes-have-client-module",
873
+ status: missingClientModule.length > 0 ? "fail" : "pass",
874
+ failingRoutes: missingClientModule,
875
+ },
876
+ {
877
+ check: "client-modules-have-route-bundles",
878
+ status: !bundleManifest ? "skip" : missingBundle.length > 0 ? "fail" : "pass",
879
+ failingRoutes: missingBundle,
880
+ reason: !bundleManifest ? "No .mandu/manifest.json found. Run mandu.build first." : undefined,
881
+ },
882
+ {
883
+ check: "generated-routes-match-client-modules",
884
+ status: generatedMismatch.length > 0 ? "fail" : "pass",
885
+ failingRoutes: generatedMismatch,
886
+ },
887
+ {
888
+ check: "terminology-separated",
889
+ status: "pass",
890
+ reason: "pageClientMounts, islands, and partials are reported as separate collections.",
891
+ },
892
+ ];
893
+ }
894
+
643
895
  function insertAfter(content: string, search: string): boolean {
644
896
  return content.includes(search);
645
897
  }