@dforge-core/dforge-mcp 0.2.27 → 0.2.29

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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
+ ## 0.2.28
8
+
9
+ **`dforge_module_validate` reported a clean module that `pack` then rejected**
10
+ (dForge-core#1184). A field code starting with `_`, a trigger firing a `batch` action, and a
11
+ trigger whose action is bound to another entity all passed. The tool now also runs the
12
+ CLI's static checks (`dforge module validate --json`), the same set `pack` runs, so a rule
13
+ added to the platform reaches this tool without a copy here. A failed check is an error
14
+ under `cli: <check name>`; the CLI's warnings are passed through.
15
+
16
+ - Needs `@dforge-core/dforge-cli` 0.2.22 or later, which adds `--json`; the dependency is
17
+ now `^0.2.22`. With an older CLI, or none, the tool still runs its offline checks and adds
18
+ a `cli` warning that the CLI checks were skipped.
19
+
7
20
  ## 0.2.27
8
21
 
9
22
  **`dforge://reference/formulas` told authors that one-hop navigation is synchronous and
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @dforge-core/dforge-mcp
2
2
 
3
- MCP server for dForge module authoring. Exposes 34 composable tools and the canonical schemas so AI agents (Claude Code, Cursor, Zed, etc.) can drive the full module lifecycle — scaffold → entities → actions → views → security → install — through structured tool calls instead of free-form JSON generation.
3
+ MCP server for dForge module authoring. Exposes 35 composable tools and the canonical schemas so AI agents (Claude Code, Cursor, Zed, etc.) can drive the full module lifecycle — scaffold → entities → actions → views → security → install — through structured tool calls instead of free-form JSON generation.
4
4
 
5
5
  Ships with **four Claude Skills** (`skills/`): a router plus one skill per lifecycle stage — design (Phase 0), build (Phases 1-5), and ship (Phase 6). The router directory carries 25 reference files (field types, flags, traits, formulas, DSL, security, etc.) and two annotated example modules.
6
6
 
@@ -74,7 +74,7 @@ claude mcp list
74
74
  # Should show: dforge — npx -y @dforge-core/dforge-mcp — connected
75
75
  ```
76
76
 
77
- Or inside a Claude Code session, type `/mcp` to see all connected servers + their tools. The 34 `dforge_*` tools should be listed.
77
+ Or inside a Claude Code session, type `/mcp` to see all connected servers + their tools. The 35 `dforge_*` tools should be listed.
78
78
 
79
79
  ### Cursor / Zed
80
80
 
@@ -157,6 +157,7 @@ The field/entity tools validate against the `@dforge-core/metadata` registry: an
157
157
  | Tool | Behavior |
158
158
  |---|---|
159
159
  | `dforge_dependency_add` | Add a dep on another dForge module |
160
+ | `dforge_diagram_add` | Create or extend a named entity diagram, `docs/diagrams/<code>.json` (design-time only) — one per functional area of a large module. Takes entity keys and/or `fromFolder` (a folder's entities plus its descendants'); appends only, keeping placed positions. Rejects a key that resolves to no own or dependency entity |
160
161
  | `dforge_dbml_import` | **DBML front-end** to `module_import` — parses DBML (Table blocks, typed columns, inline + top-level refs) into the table-spec, drops the source PK (identity provides `{entity}_id`), and imports. Pass `module` for a greenfield import |
161
162
 
162
163
  ### Writing files: preview vs `apply`
@@ -179,7 +180,7 @@ Served over MCP with descriptions and mime types, so the agent can tell what a U
179
180
 
180
181
  | URI pattern | Content |
181
182
  |---|---|
182
- | `dforge://schema/<name>` | JSON Schemas: `manifest`, `entity`, `domains`, `data-views`, `folders`, `menus`, `roles`, `jobs`, `seed-data`, `traits`, `webhooks`, `triggers`, `print-templates`, `settings`, `reports` |
183
+ | `dforge://schema/<name>` | JSON Schemas: `manifest`, `entity`, `domains`, `data-views`, `folders`, `diagram`, `menus`, `roles`, `jobs`, `seed-data`, `traits`, `webhooks`, `triggers`, `print-templates`, `settings`, `reports` |
183
184
  | `dforge://reference/<name>` | 25 per-element authoring references — schema shape + worked example + common-mistakes list for one element type |
184
185
  | `dforge://example/<path>` | Files from the canonical `simple-todo` module (mandatory structure validators) |
185
186
  | `dforge://example/matrix-budget/<path>` | Files from the `matrix` (pivot) view example |
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.20/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.22/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31283,7 +31283,7 @@ function buildZedTasks() {
31283
31283
  var fs = __toESM(require("fs"));
31284
31284
  var path = __toESM(require("path"));
31285
31285
 
31286
- // node_modules/.pnpm/@dforge-core+metadata@0.0.29/node_modules/@dforge-core/metadata/dist/index.js
31286
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.30/node_modules/@dforge-core/metadata/dist/index.js
31287
31287
  var AggType = {
31288
31288
  Sum: "sum",
31289
31289
  Count: "count",
@@ -31296,9 +31296,9 @@ var AGG_TYPE_LIST = Object.values(AggType);
31296
31296
  var fieldTypes = [
31297
31297
  { cd: "text", name: "Text", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31298
31298
  { cd: "avatar", name: "Avatar Name", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31299
- { cd: "email", name: "Email", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31300
- { cd: "phone", name: "Phone", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31301
- { cd: "url", name: "URL", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31299
+ { cd: "email", name: "Email", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true, defParams: { pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$" } },
31300
+ { cd: "phone", name: "Phone", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true, defParams: { pattern: "^\\+?[0-9\\s\\-().]+$" } },
31301
+ { cd: "url", name: "URL", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true, defParams: { pattern: "^https?://.+", patternFlags: "i" } },
31302
31302
  { cd: "textarea", name: "Multiline Text", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
31303
31303
  { cd: "number", name: "Number", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: null, useStringInFilter: false },
31304
31304
  { cd: "currency", name: "Currency", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: 2, useStringInFilter: false },
@@ -31538,6 +31538,7 @@ function modulePaths(moduleDir) {
31538
31538
  translationsDir: path.join(root, "translations"),
31539
31539
  dataViews: path.join(root, "ui", "data_views.json"),
31540
31540
  folders: path.join(root, "ui", "folders.json"),
31541
+ diagramsDir: path.join(root, "docs", "diagrams"),
31541
31542
  menus: path.join(root, "ui", "menus.json"),
31542
31543
  actions: path.join(root, "ui", "actions.json"),
31543
31544
  reports: path.join(root, "ui", "reports.json"),
@@ -31569,6 +31570,52 @@ function readJsonOrDefault(absPath, dflt) {
31569
31570
  throw new Error(`${absPath}: ${e.message}`);
31570
31571
  }
31571
31572
  }
31573
+ function diagramFiles(diagramsDir) {
31574
+ if (!fs.existsSync(diagramsDir)) return [];
31575
+ return fs.readdirSync(diagramsDir).filter((f) => f.endsWith(".json")).sort().map((f) => path.join(diagramsDir, f));
31576
+ }
31577
+ function isPlannedDiagramKey(key, entityMap) {
31578
+ return !key.includes(".") && !Object.prototype.hasOwnProperty.call(entityMap, key);
31579
+ }
31580
+ function likelyMisspelledEntity(key, entityMap) {
31581
+ let best;
31582
+ for (const code2 of Object.keys(entityMap)) {
31583
+ if (code2.includes(".")) continue;
31584
+ const d = editDistance(key, code2);
31585
+ if (d > 2 || d >= Math.min(key.length, code2.length) / 3) continue;
31586
+ if (!best || d < best.d) best = { code: code2, d };
31587
+ }
31588
+ return best?.code;
31589
+ }
31590
+ function editDistance(a, b) {
31591
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
31592
+ for (let i = 1; i <= a.length; i++) {
31593
+ const cur = [i];
31594
+ for (let j = 1; j <= b.length; j++) {
31595
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
31596
+ }
31597
+ prev = cur;
31598
+ }
31599
+ return prev[b.length];
31600
+ }
31601
+ function diagramKeyProblem(key, entityMap, deps, root) {
31602
+ if (Object.prototype.hasOwnProperty.call(entityMap, key)) return void 0;
31603
+ const dep = [...deps].filter((d) => key.startsWith(`${d}.`) && key.length > d.length + 1).sort((a, b) => b.length - a.length)[0];
31604
+ if (!dep) {
31605
+ return key.includes(".") ? `'${key}' is not an entity of this module, and no declared dependency matches its module prefix. Declare the dependency, or drop the entity from the diagram.` : `'${key}' is not an entity in manifest.entities. Drop it from the diagram, or fix the code.`;
31606
+ }
31607
+ const contractPath = path.join(root, "deps", `${dep}.json`);
31608
+ if (!fs.existsSync(contractPath)) return void 0;
31609
+ let consumed;
31610
+ try {
31611
+ consumed = JSON.parse(fs.readFileSync(contractPath, "utf8"))?.entities;
31612
+ } catch {
31613
+ return void 0;
31614
+ }
31615
+ if (!consumed || typeof consumed !== "object") return void 0;
31616
+ const entity = key.slice(dep.length + 1);
31617
+ return Object.prototype.hasOwnProperty.call(consumed, entity) ? void 0 : `'${entity}' is not among the entities deps/${dep}.json consumes from '${dep}'. Add it to the contract if the module uses it, or drop it from the diagram.`;
31618
+ }
31572
31619
  function jsonText(obj) {
31573
31620
  return JSON.stringify(obj, null, " ") + "\n";
31574
31621
  }
@@ -33208,6 +33255,22 @@ function run(args, cwd) {
33208
33255
  command: [cli.display, ...args].join(" ")
33209
33256
  };
33210
33257
  }
33258
+ var cliValidate = (moduleDir) => {
33259
+ let r;
33260
+ try {
33261
+ r = run(["module", "validate", moduleDir, "--json"]);
33262
+ } catch (e) {
33263
+ return { unavailable: e.message };
33264
+ }
33265
+ try {
33266
+ const report = JSON.parse(r.stdout.trim());
33267
+ if (Array.isArray(report.checks)) return { report };
33268
+ } catch {
33269
+ }
33270
+ return {
33271
+ unavailable: `${r.command} did not print a JSON report (exit ${r.code}) \u2014 the CLI is probably older than --json support. Update @dforge-core/dforge-cli.`
33272
+ };
33273
+ };
33211
33274
  var packModuleSchema = {
33212
33275
  moduleDir: external_exports.string().describe("Path to module directory (containing manifest.json)."),
33213
33276
  outPath: external_exports.string().optional().describe("Output file or directory. Defaults to cwd, naming the file <code>-<version>.dforge.")
@@ -34191,6 +34254,20 @@ function entityRename(args) {
34191
34254
  const entityMap = manifest.entities ?? {};
34192
34255
  if (!(oldE in entityMap)) throw new Error(`Entity '${oldE}' is not in the manifest.`);
34193
34256
  if (newE in entityMap) throw new Error(`Entity '${newE}' already exists in this module.`);
34257
+ const collisions = diagramFiles(paths.diagramsDir).filter((fp) => {
34258
+ let d;
34259
+ try {
34260
+ d = JSON.parse(fs6.readFileSync(fp, "utf8"));
34261
+ } catch {
34262
+ return false;
34263
+ }
34264
+ return !!d && typeof d === "object" && diagramMentions(d, oldE) && diagramMentions(d, newE);
34265
+ }).map((fp) => `docs/diagrams/${path8.basename(fp)}`);
34266
+ if (collisions.length > 0) {
34267
+ throw new Error(
34268
+ `${collisions.join(", ")} already draw${collisions.length === 1 ? "s" : ""} a '${newE}' alongside '${oldE}' \u2014 renaming would merge the two nodes. Remove or rename '${newE}' in the diagram first.`
34269
+ );
34270
+ }
34194
34271
  const files = {};
34195
34272
  const deletes = [];
34196
34273
  const changes = [];
@@ -34296,6 +34373,36 @@ function entityRename(args) {
34296
34373
  files[rel(paths.root, paths.folders)] = jsonText(folders);
34297
34374
  changes.push("folders.json (entity bindings)");
34298
34375
  }
34376
+ for (const fp of diagramFiles(paths.diagramsDir)) {
34377
+ let d;
34378
+ try {
34379
+ d = JSON.parse(fs6.readFileSync(fp, "utf8"));
34380
+ } catch {
34381
+ continue;
34382
+ }
34383
+ if (!d || typeof d !== "object") continue;
34384
+ const what = [];
34385
+ const ents = d.entities;
34386
+ if (ents && typeof ents === "object" && oldE in ents) {
34387
+ d.entities = renameKey(ents, oldE, newE);
34388
+ what.push("entity key");
34389
+ }
34390
+ let relTouched = false;
34391
+ for (const r of diagramRelations(d)) {
34392
+ if (r.from === oldE) {
34393
+ r.from = newE;
34394
+ relTouched = true;
34395
+ }
34396
+ if (r.to === oldE) {
34397
+ r.to = newE;
34398
+ relTouched = true;
34399
+ }
34400
+ }
34401
+ if (relTouched) what.push("relations");
34402
+ if (what.length === 0) continue;
34403
+ files[rel(paths.root, fp)] = jsonText(d);
34404
+ changes.push(`docs/diagrams/${path8.basename(fp)} (${what.join(", ")})`);
34405
+ }
34299
34406
  if (fs6.existsSync(paths.seedDataDir)) {
34300
34407
  for (const file2 of fs6.readdirSync(paths.seedDataDir).filter((f) => f.endsWith(".json"))) {
34301
34408
  const fp = path8.join(paths.seedDataDir, file2);
@@ -34322,6 +34429,17 @@ function entityRename(args) {
34322
34429
  deletes
34323
34430
  );
34324
34431
  }
34432
+ function diagramRelations(d) {
34433
+ if (!Array.isArray(d.relations)) return [];
34434
+ return d.relations.filter(
34435
+ (r) => !!r && typeof r === "object" && !Array.isArray(r)
34436
+ );
34437
+ }
34438
+ function diagramMentions(d, key) {
34439
+ const ents = d.entities;
34440
+ if (ents && typeof ents === "object" && Object.prototype.hasOwnProperty.call(ents, key)) return true;
34441
+ return diagramRelations(d).some((r) => r.from === key || r.to === key);
34442
+ }
34325
34443
  var entityDeleteSchema = {
34326
34444
  moduleDir: external_exports.string().describe("Path to the module root."),
34327
34445
  entityName: code.describe("Entity code to delete.")
@@ -34389,6 +34507,29 @@ function entityDelete(args) {
34389
34507
  files[rel(paths.root, paths.folders)] = jsonText(folders);
34390
34508
  changes.push("folders.json (binding)");
34391
34509
  }
34510
+ for (const fp of diagramFiles(paths.diagramsDir)) {
34511
+ let d;
34512
+ try {
34513
+ d = JSON.parse(fs6.readFileSync(fp, "utf8"));
34514
+ } catch {
34515
+ continue;
34516
+ }
34517
+ if (!d || typeof d !== "object") continue;
34518
+ const what = [];
34519
+ const ents = d.entities;
34520
+ if (ents && typeof ents === "object" && target in ents) {
34521
+ delete ents[target];
34522
+ what.push("entity key");
34523
+ }
34524
+ const doomed = new Set(diagramRelations(d).filter((r) => r.from === target || r.to === target));
34525
+ if (doomed.size > 0) {
34526
+ d.relations = d.relations.filter((r) => !doomed.has(r));
34527
+ what.push("relations");
34528
+ }
34529
+ if (what.length === 0) continue;
34530
+ files[rel(paths.root, fp)] = jsonText(d);
34531
+ changes.push(`docs/diagrams/${path8.basename(fp)} (${what.join(", ")})`);
34532
+ }
34392
34533
  const views = readJsonOrDefault(paths.dataViews, {});
34393
34534
  let viewsTouched = false;
34394
34535
  const removedViews = [];
@@ -34449,7 +34590,7 @@ function entityDelete(args) {
34449
34590
  // src/tools/action-add.ts
34450
34591
  var path9 = __toESM(require("path"));
34451
34592
 
34452
- // node_modules/.pnpm/@dforge-core+metadata@0.0.29/node_modules/@dforge-core/metadata/dist/dsl/index.js
34593
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.30/node_modules/@dforge-core/metadata/dist/dsl/index.js
34453
34594
  var PUNCT3 = ["===", "!=="];
34454
34595
  var PUNCT2 = [
34455
34596
  "==",
@@ -36723,6 +36864,81 @@ function folderAdd(args) {
36723
36864
  }
36724
36865
  );
36725
36866
  }
36867
+ var diagramAddSchema = {
36868
+ moduleDir: external_exports.string(),
36869
+ code: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Diagram code \u2014 becomes the file name docs/diagrams/<code>.json."),
36870
+ label: external_exports.string().optional().describe("Display name. Defaults to the fromFolder's label, else the code."),
36871
+ description: external_exports.string().optional().describe("What part of the model the diagram covers."),
36872
+ entities: external_exports.array(external_exports.string().min(1)).default([]).describe(
36873
+ "Entity keys to draw: the manifest's own entity code ('invoice'), or 'module.entity' for an entity of a declared dependency ('parties.party') \u2014 the same form a field link's `entity` takes."
36874
+ ),
36875
+ fromFolder: external_exports.string().optional().describe(
36876
+ "A folder code from ui/folders.json (anywhere in the tree). Adds the entities bound to that folder AND to all its descendants."
36877
+ ),
36878
+ showBoundary: external_exports.boolean().optional().describe(
36879
+ "Also draw the entities outside the diagram that its entities reference or are referenced by, as header-only stubs. Default true."
36880
+ )
36881
+ };
36882
+ function diagramAdd(args) {
36883
+ if (!/^[a-z][a-z0-9_]*$/.test(args.code)) {
36884
+ throw new Error(`Diagram code '${args.code}' must be snake_case (^[a-z][a-z0-9_]*$) \u2014 it becomes the file name.`);
36885
+ }
36886
+ const { paths, manifest } = loadManifest(args.moduleDir);
36887
+ const entityMap = manifest.entities ?? {};
36888
+ const deps = new Set(Object.keys(manifest.dependencies ?? {}));
36889
+ const keys = [...args.entities];
36890
+ let folderLabel;
36891
+ if (args.fromFolder !== void 0) {
36892
+ const root = readJsonOrDefault(paths.folders, {});
36893
+ const folder = walkFolders(root).find((f) => f.code === args.fromFolder);
36894
+ if (!folder) {
36895
+ throw new Error(
36896
+ `Folder '${args.fromFolder}' is not in ui/folders.json. Pass a sub-folder's code (codes are unique across the tree); for every entity in the module, list them in 'entities' instead.`
36897
+ );
36898
+ }
36899
+ if (typeof folder.node.label === "string") folderLabel = folder.node.label;
36900
+ for (const node of [folder.node, ...walkFolders(folder.node).map((f) => f.node)]) {
36901
+ keys.push(...Object.keys(node.entities ?? {}));
36902
+ }
36903
+ }
36904
+ const wanted = [...new Set(keys)];
36905
+ const problems = wanted.map((k) => diagramKeyProblem(k, entityMap, deps, paths.root)).filter((p) => p !== void 0);
36906
+ if (problems.length > 0) {
36907
+ throw new Error(`Nothing written \u2014 ${problems.join(" ")}`);
36908
+ }
36909
+ const file2 = path13.join(paths.diagramsDir, `${args.code}.json`);
36910
+ const exists = fs10.existsSync(file2);
36911
+ let diagram;
36912
+ if (exists) {
36913
+ const existing = readJson(file2);
36914
+ if (!existing || typeof existing !== "object" || Array.isArray(existing) || !existing.entities || typeof existing.entities !== "object" || Array.isArray(existing.entities)) {
36915
+ throw new Error(
36916
+ `${rel(paths.root, file2)} is not a diagram (an object with an 'entities' map) \u2014 fix or delete it first.`
36917
+ );
36918
+ }
36919
+ diagram = existing;
36920
+ if (args.label !== void 0) diagram.label = args.label;
36921
+ if (args.description !== void 0) diagram.description = args.description;
36922
+ if (args.showBoundary !== void 0) diagram.showBoundary = args.showBoundary;
36923
+ } else {
36924
+ diagram = { label: args.label ?? folderLabel ?? args.code };
36925
+ if (args.description !== void 0) diagram.description = args.description;
36926
+ diagram.entities = {};
36927
+ if (args.showBoundary !== void 0) diagram.showBoundary = args.showBoundary;
36928
+ }
36929
+ const entities = diagram.entities;
36930
+ const added = wanted.filter((k) => !Object.prototype.hasOwnProperty.call(entities, k));
36931
+ for (const k of added) entities[k] = {};
36932
+ const relPath = rel(paths.root, file2);
36933
+ const present = wanted.length - added.length;
36934
+ return makeResult(
36935
+ exists ? `Updated diagram '${args.code}' (${relPath}): added ${added.length} entit${added.length === 1 ? "y" : "ies"}${present ? `, ${present} already drawn` : ""}.` : `Created diagram '${args.code}' (${relPath}) with ${added.length} entit${added.length === 1 ? "y" : "ies"}.`,
36936
+ {
36937
+ [relPath]: jsonText(diagram),
36938
+ "manifest.json": jsonText(withTodayStamp(manifest))
36939
+ }
36940
+ );
36941
+ }
36726
36942
  var SYSTEM_MODULES = /* @__PURE__ */ new Set(["admin", "metadata", "workspace"]);
36727
36943
  var dependencyAddSchema = {
36728
36944
  moduleDir: external_exports.string(),
@@ -36884,6 +37100,18 @@ function moduleInspect(args) {
36884
37100
  }));
36885
37101
  const foldersTree = readJsonOrDefault(paths.folders, {});
36886
37102
  const folderDepth = computeDepth(foldersTree);
37103
+ const diagramSummaries = diagramFiles(paths.diagramsDir).map((fp) => {
37104
+ let d = {};
37105
+ try {
37106
+ d = JSON.parse(fs11.readFileSync(fp, "utf8"));
37107
+ } catch {
37108
+ }
37109
+ return {
37110
+ code: path14.basename(fp, ".json"),
37111
+ label: typeof d?.label === "string" ? d.label : void 0,
37112
+ entities: Object.keys(d?.entities ?? {})
37113
+ };
37114
+ });
36887
37115
  const menus = readJsonOrDefault(paths.menus, {});
36888
37116
  const menuSummaries = Object.entries(menus).map(([code2, m]) => ({
36889
37117
  code: code2,
@@ -36951,6 +37179,7 @@ function moduleInspect(args) {
36951
37179
  entities: entitySummaries,
36952
37180
  views: viewSummaries,
36953
37181
  folders: { tree: foldersTree, depth: folderDepth },
37182
+ diagrams: diagramSummaries,
36954
37183
  menus: menuSummaries,
36955
37184
  roles: roleSummaries,
36956
37185
  actions: actionSummaries,
@@ -37024,7 +37253,7 @@ function hasConstraintOverride(root, entityCd, constraintCd) {
37024
37253
  const msg = ck.message;
37025
37254
  return typeof msg === "string" && msg.trim() !== "";
37026
37255
  }
37027
- function moduleValidate(args) {
37256
+ function moduleValidate(args, cli) {
37028
37257
  const { paths, manifest } = loadManifest(args.moduleDir);
37029
37258
  const issues = [];
37030
37259
  const err = (where, message) => issues.push({ level: "error", where, message });
@@ -37533,6 +37762,47 @@ function moduleValidate(args) {
37533
37762
  }
37534
37763
  }
37535
37764
  }
37765
+ for (const fp of diagramFiles(paths.diagramsDir)) {
37766
+ const where = `docs/diagrams/${path15.basename(fp)}`;
37767
+ let diagram;
37768
+ try {
37769
+ diagram = JSON.parse(fs12.readFileSync(fp, "utf8"));
37770
+ } catch (ex) {
37771
+ err(where, `invalid JSON: ${ex.message}`);
37772
+ continue;
37773
+ }
37774
+ if (!diagram || typeof diagram !== "object" || Array.isArray(diagram)) {
37775
+ warn(where, "must be an object \u2014 the diagram itself, with an 'entities' map.");
37776
+ continue;
37777
+ }
37778
+ const diagramEntities = diagram.entities;
37779
+ if (!diagramEntities || typeof diagramEntities !== "object" || Array.isArray(diagramEntities)) {
37780
+ warn(where, "has no 'entities' object \u2014 list the entities the diagram draws, keyed by entity code.");
37781
+ continue;
37782
+ }
37783
+ for (const [key, placement] of Object.entries(diagramEntities)) {
37784
+ const at = `${where} \u2192 entities.${key}`;
37785
+ if (isPlannedDiagramKey(key, entityMap)) {
37786
+ const meant = likelyMisspelledEntity(key, entityMap);
37787
+ if (meant) {
37788
+ warn(
37789
+ at,
37790
+ `'${key}' is not built yet, so the diagram draws it as a planned entity. If you meant the existing '${meant}', fix the key.`
37791
+ );
37792
+ }
37793
+ } else {
37794
+ const unresolved = diagramKeyProblem(key, entityMap, deps, paths.root);
37795
+ if (unresolved) warn(at, unresolved);
37796
+ }
37797
+ const p = placement && typeof placement === "object" ? placement : {};
37798
+ if ("x" in p !== "y" in p) {
37799
+ warn(
37800
+ at,
37801
+ `sets '${"x" in p ? "x" : "y"}' without '${"x" in p ? "y" : "x"}'. Give both to pin the entity, or neither to let the diagram place it.`
37802
+ );
37803
+ }
37804
+ }
37805
+ }
37536
37806
  for (const [rcode, rawReport] of Object.entries(reports)) {
37537
37807
  const report = rawReport ?? {};
37538
37808
  const where = `ui/reports.json \u2192 ${rcode}`;
@@ -37684,6 +37954,19 @@ function moduleValidate(args) {
37684
37954
  );
37685
37955
  }
37686
37956
  }
37957
+ if (cli) {
37958
+ const res = cli(args.moduleDir);
37959
+ if ("unavailable" in res) {
37960
+ warn("cli", `CLI checks skipped, so pack may still fail: ${res.unavailable}`);
37961
+ } else {
37962
+ if (res.report.error) err("cli: package", res.report.error);
37963
+ for (const c of res.report.checks) if (!c.ok) err(`cli: ${c.name}`, c.message ?? "failed");
37964
+ for (const w of res.report.warnings) {
37965
+ const dup = issues.some((i) => i.where === w.where && i.message.includes("toString") && w.message.includes("toString"));
37966
+ if (!dup) warn(w.where, w.message);
37967
+ }
37968
+ }
37969
+ }
37687
37970
  const errors = issues.filter((i) => i.level === "error");
37688
37971
  const warnings = issues.filter((i) => i.level === "warning");
37689
37972
  const clean = errors.length === 0 && warnings.length === 0;
@@ -37993,6 +38276,11 @@ var resources = [
37993
38276
  "Folders JSON schema",
37994
38277
  "JSON Schema for ui/folders.json \u2014 root folder tree with per-entity view bindings."
37995
38278
  ),
38279
+ schema(
38280
+ "diagram",
38281
+ "Diagram JSON schema",
38282
+ "JSON Schema for docs/diagrams/<code>.json \u2014 one entity diagram per file, the file name is its code (a subset of entities + optional x/y placement each). Design-time only: install ignores it."
38283
+ ),
37996
38284
  schema(
37997
38285
  "menus",
37998
38286
  "Menus JSON schema",
@@ -38216,11 +38504,11 @@ server.registerTool(
38216
38504
  "dforge_module_validate",
38217
38505
  {
38218
38506
  title: "Validate module (offline)",
38219
- description: "Validate the whole module OFFLINE before packing/installing. Runs every check the per-field tools can't see: dangling FK/reference targets, a missing hidden-FK column, view dataSources/columns pointing at unknown entities/fields, menu dataViewCode \u2192 missing view, role rights on unknown objects, entities with no Select grant, field-spec rules re-run across EVERY field (catching anything that entered via import or a hand edit), toString templates, Formula-vs-Generated set aggregates over virtual child columns, action script files missing from disk, triggers/jobs firing actions that don't exist, DSL static checks, and translation completeness (role labels are install-blocking). Returns errors + warnings in _validate.json. Fix every error BEFORE dforge_module_pack \u2014 it saves a slow pack/install round trip.",
38507
+ description: "Validate the whole module OFFLINE before packing/installing. Runs every check the per-field tools can't see: dangling FK/reference targets, a missing hidden-FK column, view dataSources/columns pointing at unknown entities/fields, menu dataViewCode \u2192 missing view, role rights on unknown objects, entities with no Select grant, field-spec rules re-run across EVERY field (catching anything that entered via import or a hand edit), toString templates, Formula-vs-Generated set aggregates over virtual child columns, action script files missing from disk, triggers/jobs firing actions that don't exist, DSL static checks, translation completeness (role labels are install-blocking), and the CLI's own static checks \u2014 the same set dforge_module_pack runs (errors under 'cli: <check>'). Returns errors + warnings in _validate.json. Fix every error BEFORE dforge_module_pack \u2014 it saves a slow pack/install round trip.",
38220
38508
  inputSchema: moduleValidateSchema,
38221
38509
  annotations: READ_ONLY
38222
38510
  },
38223
- serialize(moduleValidate)
38511
+ serialize((a) => moduleValidate(a, cliValidate))
38224
38512
  );
38225
38513
  server.registerTool(
38226
38514
  "dforge_module_pack",
@@ -38280,7 +38568,7 @@ server.registerTool(
38280
38568
  "dforge_entity_rename",
38281
38569
  {
38282
38570
  title: "Rename an entity (refactor-safe)",
38283
- description: "Refactor-safe rename of an entity code. Moves the entity file (old is listed in the response's `deletes` \u2014 delete it), renames the manifest key, cascades the identity PK {old}_id \u2192 {new}_id wherever an FK targets it, and repoints every reference: other entities' link.entity / references.to, view entityCode, role rights keys, action entity, folder bindings, and seed-data entityCode + PK keys. Reports/translations/menu labels/DSL are NOT rewritten (warned). Apply `files` AND `deletes` (or pass apply: true), then run dforge_module_validate.",
38571
+ description: "Refactor-safe rename of an entity code. Moves the entity file (old is listed in the response's `deletes` \u2014 delete it), renames the manifest key, cascades the identity PK {old}_id \u2192 {new}_id wherever an FK targets it, and repoints every reference: other entities' link.entity / references.to, view entityCode, role rights keys, action entity, folder bindings, diagram entity keys (docs/diagrams/*.json), and seed-data entityCode + PK keys. Reports/translations/menu labels/DSL are NOT rewritten (warned). Apply `files` AND `deletes` (or pass apply: true), then run dforge_module_validate.",
38284
38572
  inputSchema: { ...entityRenameSchema, ...applyInput },
38285
38573
  annotations: DESTRUCTIVE
38286
38574
  },
@@ -38290,7 +38578,7 @@ server.registerTool(
38290
38578
  "dforge_entity_delete",
38291
38579
  {
38292
38580
  title: "Delete an entity (refactor-safe)",
38293
- description: "Refactor-safe deletion of an entity. Removes the entity file + its seed files (listed in `deletes`), drops the manifest entry, role rights key, folder binding, and data-view sources (deleting a view left with no source). Cross-entity FKs targeting it, actions on it, and menus pointing at removed views are surfaced as warnings \u2014 fix those by hand. Apply `files` AND `deletes` (or pass apply: true), then run dforge_module_validate.",
38581
+ description: "Refactor-safe deletion of an entity. Removes the entity file + its seed files (listed in `deletes`), drops the manifest entry, role rights key, folder binding, diagram entries (docs/diagrams/*.json), and data-view sources (deleting a view left with no source). Cross-entity FKs targeting it, actions on it, and menus pointing at removed views are surfaced as warnings \u2014 fix those by hand. Apply `files` AND `deletes` (or pass apply: true), then run dforge_module_validate.",
38294
38582
  inputSchema: { ...entityDeleteSchema, ...applyInput },
38295
38583
  annotations: DESTRUCTIVE
38296
38584
  },
@@ -38505,6 +38793,15 @@ server.registerTool(
38505
38793
  },
38506
38794
  envelope(dependencyAdd)
38507
38795
  );
38796
+ server.registerTool(
38797
+ "dforge_diagram_add",
38798
+ {
38799
+ title: "Add or extend an entity diagram",
38800
+ description: "Create or extend a named entity diagram, docs/diagrams/<code>.json \u2014 one file per diagram, drawing a chosen subset of entities. Design-time only (install ignores it). Use it on a LARGE module to give each functional area its own readable diagram (e.g. 'billing', 'inventory'); a small module needs none. Pass `entities` (own code, or 'module.entity' for a declared dependency) and/or `fromFolder` to take a folder's entities plus all its descendants'. On an existing diagram it only appends missing entities \u2014 positions already set are kept and nothing is removed. Leave layout to the editor: new entities get no x/y. An entity key that resolves to nothing is rejected and nothing is written.",
38801
+ inputSchema: { ...diagramAddSchema, ...applyInput }
38802
+ },
38803
+ envelope(diagramAdd)
38804
+ );
38508
38805
  server.registerTool(
38509
38806
  "dforge_dbml_import",
38510
38807
  {
@@ -160,5 +160,5 @@ VS Code with the [dForge extension](https://github.com/dforge-core/dforge-editor
160
160
  - [dforge-module-design](../skills/dforge-module-design/SKILL.md) — Phase 0, including the intake guardrails and gap scans
161
161
  - [dforge-module-build](../skills/dforge-module-build/SKILL.md) — Phases 1-5, the loading-policy table, core-rules cheat sheet, and the deterministic backtrack protocol
162
162
  - [dforge-module-ship](../skills/dforge-module-ship/SKILL.md) — Phase 6 and the install-fix loop
163
- - [README.md](../README.md) — full tool reference (34 tools) + maintainer docs
163
+ - [README.md](../README.md) — full tool reference (35 tools) + maintainer docs
164
164
  - [iash44/dForge-core](https://github.com/iash44/dForge-core) — the platform itself: source of truth for the schemas + DSL conventions
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.27",
4
- "description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
3
+ "version": "0.2.29",
4
+ "description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
7
7
  "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
@@ -35,8 +35,8 @@
35
35
  "install-skills": "node scripts/install-skills.mjs"
36
36
  },
37
37
  "dependencies": {
38
- "@dforge-core/dforge-cli": "^0.2.20",
39
- "@dforge-core/metadata": "^0.0.29",
38
+ "@dforge-core/dforge-cli": "^0.2.22",
39
+ "@dforge-core/metadata": "^0.0.30",
40
40
  "@modelcontextprotocol/sdk": "^1.30.0",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -0,0 +1,101 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://dforge.dev/schemas/diagram.schema.json",
4
+ "title": "dForge Diagram",
5
+ "description": "One named entity diagram of a dForge module: docs/diagrams/<code>.json, the file name being the diagram's code. A diagram draws a chosen subset of entities, so a large module can be read one area at a time. Design-time only: the platform installs the module without reading it.",
6
+ "type": "object",
7
+ "required": ["entities"],
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "label": {
13
+ "type": "string",
14
+ "description": "Diagram display name. Defaults to the diagram code."
15
+ },
16
+ "description": {
17
+ "type": "string",
18
+ "description": "What part of the model the diagram covers."
19
+ },
20
+ "entities": {
21
+ "type": "object",
22
+ "description": "Entity key → placement. A key is the entity code as the module's manifest names it, or `module.entity` for an entity of a dependency — the same form a field link's `entity` takes. A key no entity has yet is a planned entity, drawn as a name only. An empty object places the entity automatically.",
23
+ "propertyNames": {
24
+ "minLength": 1
25
+ },
26
+ "additionalProperties": {
27
+ "$ref": "#/$defs/diagramEntity"
28
+ }
29
+ },
30
+ "showBoundary": {
31
+ "type": "boolean",
32
+ "description": "Also draw the entities outside the diagram that its entities reference or are referenced by, as header-only stubs, so relations leaving the diagram stay visible. Default: true."
33
+ },
34
+ "detail": {
35
+ "type": "string",
36
+ "enum": ["conceptual", "keys", "full"],
37
+ "description": "How much of each entity to draw: `conceptual` — the name only, relations drawn entity to entity; `keys` — primary and foreign key columns; `full` — every column. Default: full."
38
+ },
39
+ "relations": {
40
+ "type": "array",
41
+ "description": "Relations drawn by hand, for entities with no foreign key between them yet — a planned entity, or one not built that far. Where a real foreign key links the same two entities, the key is drawn and the relation is not.",
42
+ "items": {
43
+ "$ref": "#/$defs/diagramRelation"
44
+ }
45
+ }
46
+ },
47
+ "additionalProperties": false,
48
+ "$defs": {
49
+ "diagramEntity": {
50
+ "type": "object",
51
+ "properties": {
52
+ "x": {
53
+ "type": "number",
54
+ "description": "Left edge of the entity node, in diagram units. Set together with `y`; both omitted means automatic placement."
55
+ },
56
+ "y": {
57
+ "type": "number",
58
+ "description": "Top edge of the entity node, in diagram units. Set together with `x`."
59
+ },
60
+ "label": {
61
+ "type": "string",
62
+ "description": "Display name of a planned entity. An existing entity shows its own label, and this is ignored."
63
+ },
64
+ "description": {
65
+ "type": "string",
66
+ "description": "What a planned entity will hold. An existing entity shows its own description, and this is ignored."
67
+ }
68
+ },
69
+ "dependencies": {
70
+ "x": ["y"],
71
+ "y": ["x"]
72
+ },
73
+ "additionalProperties": false
74
+ },
75
+ "diagramRelation": {
76
+ "type": "object",
77
+ "required": ["from", "to"],
78
+ "properties": {
79
+ "from": {
80
+ "type": "string",
81
+ "minLength": 1,
82
+ "description": "Entity key of the many side — the entity that will carry the foreign key."
83
+ },
84
+ "to": {
85
+ "type": "string",
86
+ "minLength": 1,
87
+ "description": "Entity key of the one side — the entity referenced."
88
+ },
89
+ "label": {
90
+ "type": "string",
91
+ "description": "Verb read from `from` to `to`, e.g. `placed by`."
92
+ },
93
+ "required": {
94
+ "type": "boolean",
95
+ "description": "Every `from` row must have a `to` row. Default: false."
96
+ }
97
+ },
98
+ "additionalProperties": false
99
+ }
100
+ }
101
+ }
@@ -36,7 +36,7 @@
36
36
  "description": "SQL SELECT statement for view-backed entities (isView=true). Executed as CREATE OR REPLACE VIEW during module install."
37
37
  },
38
38
  "toString": {
39
- "description": "Display pattern using column placeholders, e.g. \"{first_name} {last_name}\". Optional in extension entities (with `extends`). No `type` constraint here because `toString` collides with Object.prototype.toString in JSON validators — they read the inherited function and reject it, even when the property is absent from the JSON."
39
+ "description": "Display pattern using column placeholders, e.g. \"{first_name} {last_name}\" — the caption of a record wherever it is referenced. Every `{placeholder}` must name a field of the entity (trait columns included) and not a set; pack and install fail otherwise. Omitted, the install generates one from the fields and warns. Not declared on extension entities (with `extends`). No `type` constraint here because `toString` collides with Object.prototype.toString in JSON validators — they read the inherited function and reject it, even when the property is absent from the JSON."
40
40
  },
41
41
  "comments": {
42
42
  "type": "boolean",
@@ -68,7 +68,7 @@ Full descriptions are on the tools themselves — this is the map.
68
68
  | UI | `dforge_view_add`, `dforge_view_modify`, `dforge_menu_add`, `dforge_report_add` |
69
69
  | Polish | `dforge_setting_add`, `dforge_translation_sync`, `dforge_seed_add` |
70
70
  | Security | `dforge_role_add`, `dforge_role_right_set`, `dforge_folder_add` |
71
- | Cross-cutting | `dforge_dependency_add` |
71
+ | Cross-cutting | `dforge_dependency_add`, `dforge_diagram_add` |
72
72
 
73
73
  **Composite tools first.** A relation, a roll-up, and a status column each span
74
74
  several coordinated keys, and hand-assembling them from `entity_field_add` is
@@ -35,6 +35,9 @@ otherwise only surfaces at install:
35
35
  don't exist; `[field]` record-context in a job-invoked action
36
36
  - **DSL static checks** on every action body
37
37
  - **translation completeness** — a missing `roles.<code>.label` fails install
38
+ - **the CLI's static checks** — the same set `dforge_module_pack` runs, reported
39
+ as `cli: <check name>`. A `cli` warning means they were skipped (CLI missing or
40
+ too old), so pack can still fail
38
41
 
39
42
  Read `files["_validate.json"]`. **Every `error` must be fixed** (apply the
40
43
  backtrack protocol in `dforge-module-build`) before continuing. Review the