callman-mcp 0.3.0 → 0.4.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -4
  3. package/dist/client/api.d.ts +15 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/api.js +21 -1
  6. package/dist/client/api.js.map +1 -1
  7. package/dist/client/types.d.ts +14 -0
  8. package/dist/client/types.d.ts.map +1 -1
  9. package/dist/domain/diagram/build.d.ts +17 -17
  10. package/dist/domain/diagram/build.d.ts.map +1 -1
  11. package/dist/domain/diagram/build.js +3 -3
  12. package/dist/domain/diagram/build.js.map +1 -1
  13. package/dist/domain/diagram/layout.d.ts +3 -1
  14. package/dist/domain/diagram/layout.d.ts.map +1 -1
  15. package/dist/domain/diagram/layout.js +9 -3
  16. package/dist/domain/diagram/layout.js.map +1 -1
  17. package/dist/domain/diagram/patch.d.ts +721 -20
  18. package/dist/domain/diagram/patch.d.ts.map +1 -1
  19. package/dist/domain/diagram/patch.js +53 -0
  20. package/dist/domain/diagram/patch.js.map +1 -1
  21. package/dist/domain/diagram/summarize.d.ts +1 -0
  22. package/dist/domain/diagram/summarize.d.ts.map +1 -1
  23. package/dist/domain/diagram/summarize.js +1 -0
  24. package/dist/domain/diagram/summarize.js.map +1 -1
  25. package/dist/resources/docs/diagramSchema.d.ts +1 -1
  26. package/dist/resources/docs/diagramSchema.d.ts.map +1 -1
  27. package/dist/resources/docs/diagramSchema.js +33 -1
  28. package/dist/resources/docs/diagramSchema.js.map +1 -1
  29. package/dist/resources/docs/overview.d.ts +1 -1
  30. package/dist/resources/docs/overview.d.ts.map +1 -1
  31. package/dist/resources/docs/overview.js +4 -1
  32. package/dist/resources/docs/overview.js.map +1 -1
  33. package/dist/resources/generated/aiPacks.d.ts +1 -1
  34. package/dist/resources/generated/aiPacks.js +2 -2
  35. package/dist/tools/catalog.d.ts.map +1 -1
  36. package/dist/tools/catalog.js +5 -0
  37. package/dist/tools/catalog.js.map +1 -1
  38. package/dist/tools/diagrams.d.ts.map +1 -1
  39. package/dist/tools/diagrams.js +328 -42
  40. package/dist/tools/diagrams.js.map +1 -1
  41. package/dist/tools/lookups.d.ts +5 -1
  42. package/dist/tools/lookups.d.ts.map +1 -1
  43. package/dist/tools/lookups.js +41 -0
  44. package/dist/tools/lookups.js.map +1 -1
  45. package/dist-cjs/client/api.js +21 -1
  46. package/dist-cjs/domain/diagram/build.js +3 -3
  47. package/dist-cjs/domain/diagram/layout.js +10 -3
  48. package/dist-cjs/domain/diagram/patch.js +54 -1
  49. package/dist-cjs/domain/diagram/summarize.js +1 -0
  50. package/dist-cjs/resources/docs/diagramSchema.js +33 -1
  51. package/dist-cjs/resources/docs/overview.js +4 -1
  52. package/dist-cjs/resources/generated/aiPacks.js +2 -2
  53. package/dist-cjs/tools/catalog.js +5 -0
  54. package/dist-cjs/tools/diagrams.js +323 -37
  55. package/dist-cjs/tools/lookups.js +46 -1
  56. package/package.json +2 -2
@@ -1,19 +1,19 @@
1
- import { applyDiagramOps, BPMN_GRAMMAR_VERSION, bpmnXmlFromDocument, createEmptyDocument, isMermaidFlowchart, looksLikeBpmn, looksLikeMermaid, MERMAID_GRAMMAR_VERSION, normalizeDocument, parseBpmn, parseMermaid, placeBpmnNodesOps, } from "callman-core";
1
+ import { applyDatabaseEdits, applyDiagramOps, BPMN_GRAMMAR_VERSION, bpmnXmlFromDocument, createEmptyDocument, DATABASE_GRAMMAR_VERSION, DATABASE_LAYOUT_REQUEST, DatabaseEditError, databaseModelFromDocument, databaseModelFromSchema, databaseSchemaView, emptyDatabaseModel, isDatabaseType, isMermaidFlowchart, looksLikeBpmn, looksLikeMermaid, MERMAID_GRAMMAR_VERSION, normalizeDocument, parseBpmn, parseDatabaseModel, parseMermaid, placeBpmnNodesOps, serializeDatabaseModel, } from "callman-core";
2
2
  import { z } from "zod";
3
3
  import { CallmanApiError, ToolInputError } from "../client/errors.js";
4
4
  import { buildDiagramDocument, diagramInputSchema, toToolError } from "../domain/diagram/build.js";
5
- import { layoutDocument, placeNewNodes } from "../domain/diagram/layout.js";
6
- import { applyDiagramPatch, diagramPatchOpSchema } from "../domain/diagram/patch.js";
5
+ import { layoutDocument, placeDatabaseNodes, placeNewNodes } from "../domain/diagram/layout.js";
6
+ import { applyDiagramPatch, dbRelationshipInputSchema, dbTableInputSchema, diagramPatchOpSchema } from "../domain/diagram/patch.js";
7
7
  import { diagramFullView, diagramGraphView, diagramListView, diagramMermaidView, diagramSourceView } from "../domain/diagram/summarize.js";
8
8
  import { resolvedSummary } from "../domain/resolve.js";
9
9
  import { normalizeName } from "../lib/ids.js";
10
10
  import { sliceWithCursor } from "../lib/pagination.js";
11
- import { loadDiagrams, resolveDiagram } from "./lookups.js";
12
- import { confirmArg, cursorArgs, DESTRUCTIVE, dryRunArg, guarded, IDEMPOTENT_WRITE, ok, READ, refArg, requireConfirm, WRITE } from "./shared.js";
11
+ import { diagramFolderPath, diagramFolderRows, loadDiagrams, resolveDiagram, resolveDiagramFolder, workspacesForListing, workspaceTag } from "./lookups.js";
12
+ import { confirmArg, cursorArgs, DESTRUCTIVE, dryRunArg, guarded, IDEMPOTENT_WRITE, ok, READ, refArg, requireConfirm, workspaceArg, WRITE } from "./shared.js";
13
13
  const directionArg = z.enum(["DOWN", "RIGHT", "UP", "LEFT"]).optional().describe("Layout flow direction (default DOWN)");
14
14
  const sourceArg = z
15
15
  .object({
16
- format: z.enum(["mermaid", "bpmn"]).describe('"mermaid": Mermaid text; "bpmn": a BPMN 2.0 XML file'),
16
+ format: z.enum(["mermaid", "bpmn", "database"]).describe('"mermaid": Mermaid text; "bpmn": a BPMN 2.0 XML file; "database": the JSON database model of a Database Diagram (get_diagram view "source" of one, or the desktop\'s schema export)'),
17
17
  text: z
18
18
  .string()
19
19
  .min(1)
@@ -22,7 +22,7 @@ const sourceArg = z
22
22
  })
23
23
  .strict()
24
24
  .optional()
25
- .describe("Create the diagram from Mermaid text or BPMN 2.0 XML instead of nodes/edges");
25
+ .describe("Create the diagram from Mermaid text, BPMN 2.0 XML or a database model instead of nodes/edges");
26
26
  // A BPMN diagram keeps its truth in BPMN 2.0 XML: the model is derived from it.
27
27
  const bpmnSource = (text) => ({ format: "bpmn", text, diagramType: "bpmn", grammar: BPMN_GRAMMAR_VERSION });
28
28
  // Typed bpmn.* nodes → a BPMN diagram: the model becomes XML, the XML becomes
@@ -33,6 +33,83 @@ const bpmnDocumentFromModel = (model, unpositionedNodeIds, base) => {
33
33
  const document = applyDiagramOps({ ...base, nodes: [], edges: [], source: undefined }, [{ op: "setSource", source: bpmnSource(xml), resync: true }], { now: now() }).doc;
34
34
  return { document, layoutNote: unpositionedNodeIds.length > 0 ? `Written as BPMN 2.0 XML; ${unpositionedNodeIds.length} node(s) placed left to right after their predecessors.` : "Written as BPMN 2.0 XML with the positions given." };
35
35
  };
36
+ // A database diagram keeps its truth in the JSON database model.
37
+ const databaseSource = (text) => ({ format: "database", text, diagramType: "database", grammar: DATABASE_GRAMMAR_VERSION, valid: true });
38
+ const DATABASE_DIALECTS = ["postgres", "mysql", "mssql", "oracle", "mongo", "generic"];
39
+ const toolErrorFromDatabaseEdit = (error) => {
40
+ if (error instanceof DatabaseEditError)
41
+ throw new ToolInputError(error.code, error.message);
42
+ throw error;
43
+ };
44
+ // A database model → a database diagram: the model becomes the source, the
45
+ // tables are derived from it and laid out (unrelated tables packed in rows).
46
+ const databaseDocumentFromModel = async (model, base, keepPositions = false) => {
47
+ const applied = applyDiagramOps({ ...base, ...(keepPositions ? {} : { nodes: [], edges: [] }), source: undefined }, [{ op: "setSource", source: databaseSource(serializeDatabaseModel(model)), resync: true }], { now: now() });
48
+ let document = applied.doc;
49
+ if (keepPositions) {
50
+ const fresh = applied.added.nodes.filter((id) => document.nodes.some((node) => node.id === id));
51
+ if (fresh.length > 0)
52
+ document = placeDatabaseNodes(document, fresh);
53
+ return { document, layoutNote: fresh.length > 0 ? `${fresh.length} new table(s) placed to the right of the existing ones.` : "Positions kept." };
54
+ }
55
+ if (document.nodes.length === 0)
56
+ return { document, layoutNote: "An empty database diagram." };
57
+ const laid = await layoutDocument(document, { request: DATABASE_LAYOUT_REQUEST });
58
+ return { document: laid.document, layoutNote: laid.warning ?? `${document.nodes.length} table(s) laid out with ${laid.engine} (relationships left to right).` };
59
+ };
60
+ // Tables + relationships as the create tool takes them → a model.
61
+ const databaseModelFromInput = (tables, relationships, dialect) => {
62
+ const edits = [
63
+ ...tables.map((table) => ({ kind: "addTable", table: { ...table, ...(table.columns ? { columns: table.columns.map((column) => ({ ...column, defaultValue: column.defaultValue ?? undefined, comment: column.comment ?? undefined })) } : {}) } })),
64
+ ...relationships.map((relationship) => ({
65
+ kind: "addRelationship",
66
+ ...(relationship.id ? { id: relationship.id } : {}),
67
+ source: relationship.source,
68
+ target: relationship.target,
69
+ ...(relationship.cardinality ? { cardinality: relationship.cardinality } : {}),
70
+ ...(relationship.kind ? { relationshipKind: relationship.kind } : {}),
71
+ ...(relationship.name ? { name: relationship.name } : {}),
72
+ ...(relationship.label ? { label: relationship.label } : {}),
73
+ })),
74
+ ];
75
+ try {
76
+ return applyDatabaseEdits(emptyDatabaseModel(dialect), edits).model;
77
+ }
78
+ catch (error) {
79
+ return toolErrorFromDatabaseEdit(error);
80
+ }
81
+ };
82
+ // A database connection by id or name, in one workspace or across all.
83
+ const findDatabaseConnection = async (ctx, workspace, ref) => {
84
+ const wanted = ref.trim().toLowerCase();
85
+ const workspaces = await workspacesForListing(ctx, workspace);
86
+ const matches = [];
87
+ for (const ws of workspaces) {
88
+ for (const connection of await ctx.api.listConnections(ws.id, "database")) {
89
+ if (connection.id === ref.trim() || connection.name.trim().toLowerCase() === wanted)
90
+ matches.push({ ws, connection });
91
+ }
92
+ }
93
+ if (matches.length === 0)
94
+ throw new ToolInputError("CONNECTION_NOT_FOUND", `No database connection "${ref}"${workspace ? ` in workspace ${workspace}` : " in your workspaces"}. list_connections shows them (type: database).`);
95
+ const exact = matches.filter((match) => match.connection.id === ref.trim());
96
+ if (exact.length === 1)
97
+ return exact[0];
98
+ if (matches.length > 1) {
99
+ throw new ToolInputError("AMBIGUOUS_CONNECTION", `"${ref}" matches ${matches.length} database connections; pass its id or a \`workspace\`.`, {
100
+ candidates: matches.map((match) => ({ id: match.connection.id, name: match.connection.name, workspace: workspaceTag(match.ws) })),
101
+ });
102
+ }
103
+ return matches[0];
104
+ };
105
+ const schemaQuery = (options) => ({
106
+ ...(options.schemas && options.schemas.length > 0 ? { schemas: options.schemas.join(",") } : {}),
107
+ ...(options.tables && options.tables.length > 0 ? { tables: options.tables.join(",") } : {}),
108
+ ...(options.includeViews !== undefined ? { includeViews: String(options.includeViews) } : {}),
109
+ ...(options.objectsOnly ? { objectsOnly: "true" } : {}),
110
+ ...(options.maxTables !== undefined ? { maxTables: options.maxTables } : {}),
111
+ });
112
+ const PROVIDER_LABEL = { postgresql: "PostgreSQL", mysql: "MySQL", mssql: "SQL Server", oracle: "Oracle", mongodb: "MongoDB" };
36
113
  // Every document entering a tool goes through the shared gate (legacy fields
37
114
  // stripped, ids deduped, parents healed) — the same one the studio applies.
38
115
  const loadDocument = (diagram) => {
@@ -55,6 +132,21 @@ const assertEditableStructure = (doc) => {
55
132
  // nodes/edges (positions come from layout); anything else is kept as
56
133
  // source only (the desktop previews it, tools read it back).
57
134
  const documentFromSource = async (title, text, existing, direction, format = "mermaid") => {
135
+ if (format === "database") {
136
+ const parsed = parseDatabaseModel(text);
137
+ if (!parsed.ok)
138
+ throw new ToolInputError("DIAGRAM_SOURCE_INVALID", "The text is not a readable database model.", { errors: parsed.errors.slice(0, 20) });
139
+ const base = existing ?? createEmptyDocument(title);
140
+ const keep = Boolean(existing && existing.source?.format === "database");
141
+ let built;
142
+ try {
143
+ built = await databaseDocumentFromModel(parsed.model, keep ? existing : { ...base, nodes: [], edges: [] }, keep);
144
+ }
145
+ catch (error) {
146
+ return toToolError(error);
147
+ }
148
+ return { document: built.document, layoutNote: built.layoutNote, warnings: parsed.warnings };
149
+ }
58
150
  if (format === "bpmn") {
59
151
  if (!looksLikeBpmn(text))
60
152
  throw new ToolInputError("DIAGRAM_SOURCE_INVALID", "The text is not a BPMN 2.0 document (no <definitions> root).");
@@ -124,39 +216,59 @@ const isConflict = (error) => error instanceof CallmanApiError && error.status =
124
216
  export const registerDiagramTools = (server, ctx) => {
125
217
  server.registerTool("list_diagrams", {
126
218
  title: "List diagrams",
127
- description: "List your system-design diagrams and the ones shared with you (owner, node/edge counts, whether you can edit). Diagrams belong to users and are shared into workspaces — not workspace-scoped.",
128
- inputSchema: { query: z.string().max(200).optional(), ...cursorArgs },
219
+ description: "List your system-design diagrams and the ones shared with you (owner, node/edge counts, whether you can edit), with the folder tree of your library: `folders` are your own folders as paths (\"Architecture/Payments\"), each diagram carries its `folder` path (null = the top level; always null for a diagram shared with you — folders are private to their owner). Diagrams belong to users and are shared into workspaces — not workspace-scoped.",
220
+ inputSchema: {
221
+ query: z.string().max(200).optional(),
222
+ folder: z.string().max(300).optional().describe('Only the diagrams in this folder of your library (id, name or path such as "Architecture/Payments"); "/" = the top level only'),
223
+ ...cursorArgs,
224
+ },
129
225
  annotations: READ,
130
- }, guarded(async ({ query, cursor, limit }) => {
131
- const all = await loadDiagrams(ctx);
226
+ }, guarded(async ({ query, folder, cursor, limit }) => {
227
+ const [all, rows] = await Promise.all([loadDiagrams(ctx), diagramFolderRows(ctx)]);
132
228
  const needle = query?.trim().toLowerCase();
133
- const filtered = needle ? all.filter((diagram) => diagram.name.toLowerCase().includes(needle)) : all;
134
- const page = sliceWithCursor(filtered, cursor, limit, needle ?? "");
135
- return ok({ diagrams: page.items.map(diagramListView), total: page.total, nextCursor: page.nextCursor });
229
+ const scope = folder?.trim() === "/" ? { item: null, annotation: undefined } : folder ? await resolveDiagramFolder(ctx, folder) : undefined;
230
+ const filtered = all.filter((diagram) => {
231
+ if (needle && !diagram.name.toLowerCase().includes(needle))
232
+ return false;
233
+ if (scope === undefined)
234
+ return true;
235
+ return (diagram.folderId ?? null) === (scope.item?.id ?? null);
236
+ });
237
+ const page = sliceWithCursor(filtered, cursor, limit, `${needle ?? ""}|${scope?.item?.id ?? (scope ? "/" : "")}`);
238
+ return ok({
239
+ diagrams: page.items.map((diagram) => ({ ...diagramListView(diagram), folder: diagramFolderPath(rows, diagram.folderId) })),
240
+ folders: rows.map((row) => ({ id: row.id, name: row.name, parentId: row.parentId, path: row.path })),
241
+ total: page.total,
242
+ nextCursor: page.nextCursor,
243
+ ...(scope?.item && scope.annotation ? { resolved: [resolvedSummary(folder, scope)] } : {}),
244
+ });
136
245
  }));
137
246
  server.registerTool("get_diagram", {
138
247
  title: "Get a diagram",
139
- description: "Read a diagram: `summary` (counts, owner, sharing), `graph` (default — nodes with type/label/parent/style and edges with semantic/color/routing/sides, in the same shape the write tools accept: enough to describe, restyle or edit it), `mermaid` (the diagram rendered as Mermaid flowchart text), `source` (the Mermaid text or BPMN 2.0 XML a source-backed diagram keeps) or `full` (the complete stored DiagramDocument with positions, sizes and routes, up to 512 KB).",
140
- inputSchema: { diagram: refArg("Diagram"), view: z.enum(["summary", "graph", "mermaid", "source", "full"]).default("graph") },
248
+ description: "Read a diagram: `summary` (counts, owner, sharing), `graph` (default — nodes with type/label/parent/style and edges with semantic/color/routing/sides, in the same shape the write tools accept: enough to describe, restyle or edit it), `mermaid` (the diagram rendered as Mermaid flowchart text), `source` (the Mermaid text, BPMN 2.0 XML or JSON database model a source-backed diagram keeps), `schema` (a database diagram's tables with columns, keys and relationships as table.column pairs — read it before database ops) or `full` (the complete stored DiagramDocument with positions, sizes and routes, up to 512 KB).",
249
+ inputSchema: { diagram: refArg("Diagram"), view: z.enum(["summary", "graph", "mermaid", "source", "schema", "full"]).default("graph") },
141
250
  annotations: READ,
142
251
  }, guarded(async ({ diagram, view }) => {
143
252
  const resolved = await resolveDiagram(ctx, diagram);
144
253
  const doc = loadDocument(resolved.item);
145
254
  const summary = diagramListView(resolved.item);
255
+ const summaryWithFolder = { ...summary, folder: diagramFolderPath(await diagramFolderRows(ctx), resolved.item.folderId) };
146
256
  const body = view === "summary"
147
- ? summary
257
+ ? summaryWithFolder
148
258
  : view === "graph"
149
259
  ? { ...summary, ...diagramGraphView(doc) }
150
260
  : view === "mermaid"
151
261
  ? { ...summary, ...diagramMermaidView(doc) }
152
262
  : view === "source"
153
263
  ? { ...summary, ...diagramSourceView(doc) }
154
- : { ...summary, ...diagramFullView(doc) };
264
+ : view === "schema"
265
+ ? { ...summary, schema: databaseSchemaView(doc) }
266
+ : { ...summary, ...diagramFullView(doc) };
155
267
  return ok({ diagram: body, ...(resolved.annotation ? { resolved: [resolvedSummary(diagram, resolved)] } : {}) });
156
268
  }));
157
269
  server.registerTool("create_diagram", {
158
- title: "Create a system-design diagram",
159
- description: "Create a diagram from typed nodes and edges — or from Mermaid text / BPMN 2.0 XML (`source`). A BPMN process (`kind: \"bpmn\"`, or nodes typed bpmn.*) is stored as BPMN 2.0 XML the desktop opens in its BPMN editor. Node `type` must come from callman://diagram/components (e.g. net.service, net.api-gateway, db.postgres, mq.kafka, aws.lambda, flow.process); unknown types are rejected with suggestions unless strictTypes: false. Group nodes inside a container node — frame, pool.horizontal or pool.vertical — with `parent`. Style nodes with `style` (fill / stroke / textColor as CSS colors, strokeWidth, dashed, radius, bold, fontSize, opacity…) and edges with `semantic` (sync default, async, fallback, bus), an optional `color`, `routing` and fixed `sourceSide`/`targetSide`. Layout is automatic when nodes have no positions. Read callman://docs/diagram-schema for every field. Keep it readable: 8–25 nodes, short labels.",
270
+ title: "Create a system-design, process or database diagram",
271
+ description: "Create a diagram from typed nodes and edges — or from Mermaid text / BPMN 2.0 XML / a database model (`source`). A BPMN process (`kind: \"bpmn\"`, or nodes typed bpmn.*) is stored as BPMN 2.0 XML the desktop opens in its BPMN editor. A DATABASE diagram (entity-relationship: `kind: \"database\"`, `tables` + `relationships`, nodes typed db.table/db.view/db.collection with the columns in data: { db: { columns } }, or `fromConnection` to read a real database's schema) is stored as the JSON database model the desktop opens on its ERD canvas — tables with columns and keys, relationships with cardinality, laid out left to right. Node `type` must come from callman://diagram/components (e.g. net.service, net.api-gateway, db.postgres, mq.kafka, aws.lambda, flow.process); unknown types are rejected with suggestions unless strictTypes: false. Group nodes inside a container node — frame, pool.horizontal or pool.vertical — with `parent`. Style nodes with `style` (fill / stroke / textColor as CSS colors, strokeWidth, dashed, radius, bold, fontSize, opacity…) and edges with `semantic` (sync default, async, fallback, bus), an optional `color`, `routing` and fixed `sourceSide`/`targetSide`. Layout is automatic when nodes have no positions. Read callman://docs/diagram-schema for every field. Keep it readable: 8–25 nodes, short labels.",
160
272
  inputSchema: {
161
273
  name: z.string().min(1).max(200).describe("Diagram name (library entry)"),
162
274
  title: diagramInputSchema.shape.title.optional().describe("Diagram title (defaults to the name)"),
@@ -164,28 +276,73 @@ export const registerDiagramTools = (server, ctx) => {
164
276
  edges: diagramInputSchema.shape.edges.optional(),
165
277
  canvas: diagramInputSchema.shape.canvas,
166
278
  source: sourceArg,
167
- kind: z.enum(["canvas", "bpmn"]).optional().describe('"bpmn": model a business process — nodes use bpmn.* types and the diagram is stored as BPMN 2.0 XML (default: canvas; inferred as bpmn when every node type starts with "bpmn.")'),
279
+ kind: z.enum(["canvas", "bpmn", "database"]).optional().describe('"bpmn": model a business process — nodes use bpmn.* types and the diagram is stored as BPMN 2.0 XML; "database": an entity-relationship diagram stored as the JSON database model (default: canvas; inferred from the node types, from `tables` and from `fromConnection`)'),
280
+ tables: z.array(dbTableInputSchema).max(500).optional().describe("Database diagram: the tables (name, schema?, kind?, columns with dataType / primaryKey / nullable / unique / defaultValue / comment)"),
281
+ relationships: z.array(dbRelationshipInputSchema).max(2000).optional().describe('Database diagram: relationships between the tables — source is the child (foreign-key) side as "table.column", target the parent; cardinality source:target (default N:1)'),
282
+ dialect: z.enum(DATABASE_DIALECTS).optional().describe("Database diagram: which database the types are written for"),
283
+ fromConnection: z
284
+ .object({
285
+ connection: z.string().min(1).max(200).describe("A database connection by id or name (list_connections, type: database)"),
286
+ schemas: z.array(z.string().min(1).max(120)).max(50).optional().describe("Schemas to read (default: every user schema; MySQL: the connection's database; Oracle: the current schema)"),
287
+ tables: z.array(z.string().min(1).max(240)).max(2000).optional().describe('Only these tables ("schema.table" or "table"); default: all'),
288
+ includeViews: z.boolean().optional(),
289
+ })
290
+ .strict()
291
+ .optional()
292
+ .describe("Database diagram: read a real database's schema through one of the workspace's connections and draw it — tables, columns, keys and the relationships the database declares"),
293
+ workspace: workspaceArg,
294
+ folder: z.string().max(300).optional().describe('File it in a folder of your library (id, name or path such as "Architecture/Payments"; create one with create_diagram_folder). Default: the top level'),
168
295
  autoLayout: z.boolean().optional().describe("Force layout even when positions were given"),
169
296
  direction: directionArg,
170
297
  strictTypes: z.boolean().optional(),
171
298
  dryRun: dryRunArg,
172
299
  },
173
300
  annotations: WRITE,
174
- }, guarded(async ({ name, title, nodes, edges, canvas, source, kind, autoLayout, direction, strictTypes, dryRun }) => {
175
- if (!nodes && !source)
176
- throw new ToolInputError("NOTHING_TO_CREATE", "Pass nodes (+ edges) or source { format: \"mermaid\" | \"bpmn\", text }.");
177
- if (nodes && source)
178
- throw new ToolInputError("CONFLICTING_INPUT", "Pass either nodes or source, not both.");
301
+ }, guarded(async ({ name, title, nodes, edges, canvas, source, kind, tables, relationships, dialect, fromConnection, workspace, folder, autoLayout, direction, strictTypes, dryRun }) => {
302
+ const targetFolder = folder ? await resolveDiagramFolder(ctx, folder) : null;
303
+ const inputs = [nodes ? "nodes" : null, source ? "source" : null, tables ? "tables" : null, fromConnection ? "fromConnection" : null].filter(Boolean);
304
+ if (inputs.length === 0)
305
+ throw new ToolInputError("NOTHING_TO_CREATE", 'Pass nodes (+ edges), source { format: "mermaid" | "bpmn" | "database", text }, tables (+ relationships) or fromConnection.');
306
+ if (inputs.length > 1)
307
+ throw new ToolInputError("CONFLICTING_INPUT", `Pass one of nodes, source, tables or fromConnection — not ${inputs.join(" + ")}.`);
308
+ if (relationships && !tables)
309
+ throw new ToolInputError("CONFLICTING_INPUT", "relationships go with tables.");
179
310
  let document;
180
311
  let layoutNote;
181
312
  let warnings = [];
182
313
  const bpmnKind = kind === "bpmn" || (kind === undefined && Boolean(nodes && nodes.length > 0 && nodes.every((node) => node.type.startsWith("bpmn."))));
314
+ const databaseKind = kind === "database" || Boolean(tables) || Boolean(fromConnection) || (kind === undefined && Boolean(nodes && nodes.length > 0 && nodes.every((node) => isDatabaseType(node.type))));
315
+ let connectionNote = {};
183
316
  if (source) {
184
317
  const fromSource = await documentFromSource(title ?? name, source.text, null, direction, source.format);
185
318
  document = canvas ? { ...fromSource.document, canvas: { ...fromSource.document.canvas, ...canvas } } : fromSource.document;
186
319
  layoutNote = fromSource.layoutNote;
187
320
  warnings = fromSource.warnings;
188
321
  }
322
+ else if (fromConnection) {
323
+ const found = await findDatabaseConnection(ctx, workspace, fromConnection.connection);
324
+ const schema = await ctx.api.getConnectionSchema(found.ws.id, found.connection.id, schemaQuery(fromConnection));
325
+ const model = databaseModelFromSchema(schema, { connection: { connectionId: found.connection.id, name: found.connection.name, provider: found.connection.provider } });
326
+ const built = await databaseDocumentFromModel(model, createEmptyDocument(title ?? name));
327
+ document = built.document;
328
+ layoutNote = built.layoutNote;
329
+ warnings = [...schema.warnings, ...(schema.truncated ? ["The database has more tables than the cap; the list was cut."] : [])];
330
+ connectionNote = { connection: { id: found.connection.id, name: found.connection.name, provider: PROVIDER_LABEL[found.connection.provider] ?? found.connection.provider, workspace: workspaceTag(found.ws) }, tables: model.entities.length, relationships: model.relationships.length };
331
+ }
332
+ else if (tables) {
333
+ const model = databaseModelFromInput(tables, relationships ?? [], dialect);
334
+ const built = await databaseDocumentFromModel(model, createEmptyDocument(title ?? name));
335
+ document = built.document;
336
+ layoutNote = built.layoutNote;
337
+ }
338
+ else if (databaseKind) {
339
+ const built = buildDiagramDocument({ title: title ?? name, nodes: nodes ?? [], edges: edges ?? [], canvas }, { strictTypes, kind: "database" });
340
+ const model = databaseModelFromDocument({ ...built.document, source: undefined }, dialect);
341
+ const database = await databaseDocumentFromModel(model, createEmptyDocument(title ?? name));
342
+ document = database.document;
343
+ layoutNote = database.layoutNote;
344
+ warnings = built.warnings;
345
+ }
189
346
  else if (bpmnKind) {
190
347
  const built = buildDiagramDocument({ title: title ?? name, nodes: nodes ?? [], edges: edges ?? [], canvas }, { strictTypes, kind: "bpmn" });
191
348
  const bpmn = bpmnDocumentFromModel(built.document, built.unpositionedNodeIds, createEmptyDocument(title ?? name));
@@ -203,21 +360,26 @@ export const registerDiagramTools = (server, ctx) => {
203
360
  layoutNote = laid.warning ?? `Laid out with ${laid.engine}.`;
204
361
  }
205
362
  }
363
+ if (document.source?.format === "database" && canvas && !source)
364
+ document = { ...document, canvas: { ...document.canvas, ...canvas } };
365
+ const view = document.source?.format === "database" ? { ...diagramGraphView(document), schema: databaseSchemaView(document) } : diagramGraphView(document);
206
366
  if (dryRun) {
207
- return ok({ dryRun: true, diagram: { name, ...diagramGraphView(document) }, warnings, ...(layoutNote ? { layout: layoutNote } : {}) });
367
+ return ok({ dryRun: true, diagram: { name, folder: targetFolder?.item.path ?? null, ...view }, warnings, ...connectionNote, ...(layoutNote ? { layout: layoutNote } : {}) });
208
368
  }
209
- const created = await ctx.api.createDiagram({ name: name.trim(), document });
369
+ const created = await ctx.api.createDiagram({ name: name.trim(), document, ...(targetFolder ? { folderId: targetFolder.item.id } : {}) });
210
370
  ctx.invalidate("diagrams");
211
371
  return ok({
212
- diagram: { ...diagramListView(created), ...diagramGraphView(document) },
372
+ diagram: { ...diagramListView(created), folder: targetFolder?.item.path ?? null, ...view },
213
373
  warnings,
374
+ ...connectionNote,
214
375
  ...(layoutNote ? { layout: layoutNote } : {}),
215
- note: "Saved. Open it in the desktop app (Diagrams → Refresh). Edit with patch_diagram; share with share_diagram.",
376
+ ...(targetFolder?.annotation ? { resolved: [resolvedSummary(folder, targetFolder)] } : {}),
377
+ note: `Saved${targetFolder ? ` in ${targetFolder.item.path}` : ""}. Open it in the desktop app (Diagrams → Refresh). Edit with patch_diagram; share with share_diagram.`,
216
378
  });
217
379
  }));
218
380
  server.registerTool("update_diagram", {
219
381
  title: "Replace a diagram's content",
220
- description: "Replace a diagram's whole document (nodes + edges, same fields as create_diagram), replace or attach its Mermaid text or BPMN 2.0 XML `source`, and/or rename it, retitle it or change its canvas — keeping its identity and sharing. Passing nodes replaces ALL nodes and edges (omitted edges are removed). Passing `source` re-derives nodes and edges from the text (existing ones keep their place; new ones are placed). For small edits — including restyling — prefer patch_diagram.",
382
+ description: "Replace a diagram's whole document (nodes + edges, same fields as create_diagram), replace or attach its Mermaid text, BPMN 2.0 XML or database-model `source`, rename it, retitle it, change its canvas and/or move it to a folder of your library (`folder`; null = the top level; only the owner can move) — keeping its identity and sharing. Passing nodes replaces ALL nodes and edges (omitted edges are removed). Passing `source` re-derives nodes and edges from the text (existing ones keep their place; new ones are placed). For small edits — including restyling — prefer patch_diagram.",
221
383
  inputSchema: {
222
384
  diagram: refArg("Diagram"),
223
385
  name: z.string().min(1).max(200).optional(),
@@ -226,17 +388,19 @@ export const registerDiagramTools = (server, ctx) => {
226
388
  edges: diagramInputSchema.shape.edges.optional(),
227
389
  canvas: diagramInputSchema.shape.canvas,
228
390
  source: sourceArg,
391
+ folder: z.string().max(300).nullable().optional().describe('Move the diagram into this folder of your library (id, name or path); null = the top level'),
229
392
  autoLayout: z.boolean().optional(),
230
393
  direction: directionArg,
231
394
  strictTypes: z.boolean().optional(),
232
395
  },
233
396
  annotations: IDEMPOTENT_WRITE,
234
- }, guarded(async ({ diagram, name, title, nodes, edges, canvas, source, autoLayout, direction, strictTypes }) => {
235
- if (name === undefined && nodes === undefined && title === undefined && canvas === undefined && source === undefined) {
236
- throw new ToolInputError("NOTHING_TO_UPDATE", "Pass name, title, canvas, source and/or nodes (+ edges).");
397
+ }, guarded(async ({ diagram, name, title, nodes, edges, canvas, source, folder, autoLayout, direction, strictTypes }) => {
398
+ if (name === undefined && nodes === undefined && title === undefined && canvas === undefined && source === undefined && folder === undefined) {
399
+ throw new ToolInputError("NOTHING_TO_UPDATE", "Pass name, title, canvas, source, folder and/or nodes (+ edges).");
237
400
  }
238
401
  if (nodes && source)
239
402
  throw new ToolInputError("CONFLICTING_INPUT", "Pass either nodes or source, not both.");
403
+ const targetFolder = folder ? await resolveDiagramFolder(ctx, folder) : null;
240
404
  const resolved = await resolveDiagram(ctx, diagram);
241
405
  if (!resolved.item.canEdit)
242
406
  throw new ToolInputError("DIAGRAM_READ_ONLY", "You have read-only access to this diagram.");
@@ -244,7 +408,19 @@ export const registerDiagramTools = (server, ctx) => {
244
408
  let document;
245
409
  let layoutNote;
246
410
  let warnings = [];
247
- if (nodes && existing.source?.format === "bpmn") {
411
+ if (nodes && existing.source?.format === "database") {
412
+ // A database diagram stays one: the tables given become the new model.
413
+ const built = buildDiagramDocument({ title: title ?? existing.meta.title, nodes, edges: edges ?? [], canvas: canvas ?? existing.canvas }, { strictTypes, existingId: existing.id, createdAt: existing.meta.createdAt, kind: "database" });
414
+ const previous = parseDatabaseModel(existing.source.text).model;
415
+ const model = databaseModelFromDocument({ ...built.document, source: undefined }, previous.dialect);
416
+ if (previous.connection)
417
+ model.connection = previous.connection;
418
+ const database = await databaseDocumentFromModel(model, existing, true);
419
+ document = database.document;
420
+ layoutNote = database.layoutNote;
421
+ warnings = [...built.warnings, "The database model was rewritten from the tables given; tables not in the input are gone. Prefer patch_diagram (addTable, addColumn, addRelationship …) for edits."];
422
+ }
423
+ else if (nodes && existing.source?.format === "bpmn") {
248
424
  // A BPMN diagram stays BPMN: the new model is written as fresh XML.
249
425
  const built = buildDiagramDocument({ title: title ?? existing.meta.title, nodes, edges: edges ?? [], canvas: canvas ?? existing.canvas }, { strictTypes, existingId: existing.id, createdAt: existing.meta.createdAt, kind: "bpmn" });
250
426
  const bpmn = bpmnDocumentFromModel(built.document, built.unpositionedNodeIds, existing);
@@ -261,7 +437,7 @@ export const registerDiagramTools = (server, ctx) => {
261
437
  warnings.push(`edges was omitted, so the ${existing.edges.length} existing edge(s) were removed. Pass edges to keep connections, or use patch_diagram for partial edits.`);
262
438
  }
263
439
  if (existing.source)
264
- warnings.push("The diagram's Mermaid source was detached: nodes and edges were replaced from the input.");
440
+ warnings.push(`The diagram's ${existing.source.format === "mermaid" ? "Mermaid" : existing.source.format} source was detached: nodes and edges were replaced from the input.`);
265
441
  if (autoLayout || built.unpositionedNodeIds.length > 0) {
266
442
  const laid = await layoutDocument(document, { direction });
267
443
  document = laid.document;
@@ -291,13 +467,20 @@ export const registerDiagramTools = (server, ctx) => {
291
467
  const updated = await ctx.api.updateDiagram(resolved.item.id, {
292
468
  ...(name !== undefined ? { name: name.trim() } : {}),
293
469
  ...(document ? { document, ...(typeof existing.meta.revision === "number" ? { baseRevision: existing.meta.revision } : {}) } : {}),
470
+ ...(folder !== undefined ? { folderId: targetFolder?.item.id ?? null } : {}),
294
471
  });
295
472
  ctx.invalidate("diagrams");
296
- return ok({ diagram: { ...diagramListView(updated), ...diagramGraphView(document ?? existing) }, warnings, ...(layoutNote ? { layout: layoutNote } : {}), resolved: [resolvedSummary(diagram, resolved)] });
473
+ const rows = await diagramFolderRows(ctx);
474
+ return ok({
475
+ diagram: { ...diagramListView(updated), folder: diagramFolderPath(rows, updated.folderId), ...diagramGraphView(document ?? existing) },
476
+ warnings,
477
+ ...(layoutNote ? { layout: layoutNote } : {}),
478
+ resolved: [resolvedSummary(diagram, resolved), ...(targetFolder?.annotation ? [resolvedSummary(folder, targetFolder)] : [])],
479
+ });
297
480
  }));
298
481
  server.registerTool("patch_diagram", {
299
482
  title: "Edit a diagram incrementally",
300
- description: "Apply operations to a diagram without resending it: addNode, updateNode, removeNode (drops its edges, lifts its children out), addEdge, updateEdge, removeEdge, setTitle, moveNode, reparent, setEdgeRoute (routing, fixed sides, bends), reorder (stacking), setCanvas, group (wrap nodes in a container), ungroup, relayout (a hint: new | all). updateNode `changes` MERGE: style and data keys you pass change, a key set to null is removed. updateEdge can change semantic, color, label, arrows, sides and ends. Moving a node re-routes its edges. `relayout`: none | new (place only new nodes, default) | all (re-run automatic layout). Call get_diagram (view: graph) first to see node ids and current styles. Ops are documented in callman://docs/diagram-schema.",
483
+ description: "Apply operations to a diagram without resending it: addNode, updateNode, removeNode (drops its edges, lifts its children out), addEdge, updateEdge, removeEdge, setTitle, moveNode, reparent, setEdgeRoute (routing, fixed sides, bends), reorder (stacking), setCanvas, group (wrap nodes in a container), ungroup, relayout (a hint: new | all). On a DATABASE diagram use the schema ops — addTable, renameTable, removeTable, setTableComment, addColumn, alterColumn, removeColumn, setPrimaryKey, addRelationship (source = child \"table.column\", target = parent), updateRelationship, removeRelationship, hideTable, showTable — read get_diagram view schema first. updateNode `changes` MERGE: style and data keys you pass change, a key set to null is removed. updateEdge can change semantic, color, label, arrows, sides and ends. Moving a node re-routes its edges. `relayout`: none | new (place only new nodes, default) | all (re-run automatic layout). Call get_diagram (view: graph) first to see node ids and current styles. Ops are documented in callman://docs/diagram-schema.",
301
484
  inputSchema: {
302
485
  diagram: refArg("Diagram"),
303
486
  ops: z.array(diagramPatchOpSchema).min(1).max(200),
@@ -321,15 +504,21 @@ export const registerDiagramTools = (server, ctx) => {
321
504
  const wantsAll = relayout === "all" || patched.relayout?.scope === "all";
322
505
  const layoutDirection = direction ?? patched.relayout?.direction;
323
506
  const bpmn = document.source?.format === "bpmn";
507
+ const database = document.source?.format === "database";
324
508
  if (wantsAll) {
325
- // A process reads left to right; the BPMN XML follows the moves.
326
- const laid = await layoutDocument(document, { direction: layoutDirection ?? (bpmn ? "RIGHT" : undefined) });
509
+ // A process reads left to right; the BPMN XML follows the moves. An
510
+ // ERD packs unrelated tables into rows.
511
+ const laid = await layoutDocument(document, { direction: layoutDirection ?? (bpmn || database ? "RIGHT" : undefined), ...(database ? { request: DATABASE_LAYOUT_REQUEST } : {}) });
327
512
  document = laid.document;
328
513
  layoutNote = laid.warning ?? `Laid out with ${laid.engine}.`;
329
514
  }
330
515
  else if (relayout === "new" && patched.addedNodeIds.length > 0) {
331
516
  const ids = patched.addedNodeIds.filter((id) => document.nodes.some((node) => node.id === id));
332
- if (bpmn) {
517
+ if (database) {
518
+ document = placeDatabaseNodes(document, ids);
519
+ layoutNote = `Placed ${ids.length} new table(s) to the right of the existing ones.`;
520
+ }
521
+ else if (bpmn) {
333
522
  document = applyDiagramOps(document, placeBpmnNodesOps(document, ids), { now: now() }).doc;
334
523
  layoutNote = `Placed ${ids.length} new node(s) after their predecessors.`;
335
524
  }
@@ -358,7 +547,7 @@ export const registerDiagramTools = (server, ctx) => {
358
547
  ctx.invalidate("diagrams");
359
548
  const { updated, document, patched, layoutNote } = outcome;
360
549
  return ok({
361
- diagram: { ...diagramListView(updated), ...diagramGraphView(document) },
550
+ diagram: { ...diagramListView(updated), ...diagramGraphView(document), ...(document.source?.format === "database" ? { schema: databaseSchemaView(document) } : {}) },
362
551
  applied: ops.length,
363
552
  added: patched.addedNodeIds,
364
553
  removedNodes: patched.removedNodeIds,
@@ -370,6 +559,103 @@ export const registerDiagramTools = (server, ctx) => {
370
559
  resolved: [resolvedSummary(diagram, resolved)],
371
560
  });
372
561
  }));
562
+ server.registerTool("introspect_database", {
563
+ title: "Read a database's schema through a connection",
564
+ description: "Read the database behind one of the workspace's database connections (list_connections, type: database): schemas, tables and views (MongoDB: collections with fields inferred from a sample), columns with types, nullability, defaults and comments, primary and unique keys, foreign keys with referential actions, indexes. Metadata only — never credentials, never data. Use `objectsOnly` to list names first, then read the tables you need; `create_diagram` with `fromConnection` draws the result as a Database Diagram in one step. Runs on the Callman backend, which must be able to reach the database.",
565
+ inputSchema: {
566
+ connection: z.string().min(1).max(200).describe("Connection id or name"),
567
+ workspace: workspaceArg,
568
+ schemas: z.array(z.string().min(1).max(120)).max(50).optional(),
569
+ tables: z.array(z.string().min(1).max(240)).max(2000).optional().describe('"schema.table" or "table"'),
570
+ includeViews: z.boolean().optional(),
571
+ objectsOnly: z.boolean().optional().describe("Names only (no columns / keys) — for a first look at a large database"),
572
+ maxTables: z.number().int().min(1).max(20000).optional(),
573
+ },
574
+ annotations: READ,
575
+ }, guarded(async ({ connection, workspace, schemas, tables, includeViews, objectsOnly, maxTables }) => {
576
+ const found = await findDatabaseConnection(ctx, workspace, connection);
577
+ const schema = await ctx.api.getConnectionSchema(found.ws.id, found.connection.id, schemaQuery({ schemas, tables, includeViews, objectsOnly, maxTables }));
578
+ return ok({
579
+ connection: { id: found.connection.id, name: found.connection.name, provider: PROVIDER_LABEL[found.connection.provider] ?? found.connection.provider, workspace: workspaceTag(found.ws) },
580
+ schema,
581
+ ...(schema.truncated ? { note: "The database has more tables than the cap; pass schemas/tables to narrow the read or raise maxTables." } : {}),
582
+ });
583
+ }));
584
+ // ── library folders (private to the token's owner) ──────────────────
585
+ const folderView = (row) => ({ id: row.id, name: row.name, parentId: row.parentId, path: row.path });
586
+ server.registerTool("create_diagram_folder", {
587
+ title: "Create a diagram folder",
588
+ description: "Create a folder in your diagram library, optionally inside a parent folder (id, name or path). Folders are yours alone (nobody else sees them; a diagram you share shows at the top level for others), nest up to 8 levels, and sibling names are unique — an existing folder with that name is returned instead of a duplicate. File diagrams with create_diagram / update_diagram `folder`.",
589
+ inputSchema: {
590
+ name: z.string().min(1).max(120),
591
+ parent: z.string().max(300).optional().describe("Parent folder id, name or path; omit for the top level"),
592
+ },
593
+ annotations: WRITE,
594
+ }, guarded(async ({ name, parent }) => {
595
+ const parentFolder = parent ? await resolveDiagramFolder(ctx, parent) : null;
596
+ const rows = await diagramFolderRows(ctx);
597
+ const trimmed = name.trim();
598
+ const sibling = rows.find((row) => row.parentId === (parentFolder?.item.id ?? null) && normalizeName(row.name) === normalizeName(trimmed));
599
+ if (sibling) {
600
+ return ok({ folder: folderView(sibling), existed: true, note: "A folder with that name already exists there — returned instead of creating a duplicate." });
601
+ }
602
+ const created = await ctx.api.createDiagramFolder({ name: trimmed, parentId: parentFolder?.item.id ?? null });
603
+ ctx.invalidate("diagram-folders");
604
+ const path = parentFolder ? `${parentFolder.item.path}/${created.name}` : created.name;
605
+ return ok({
606
+ folder: { id: created.id, name: created.name, parentId: created.parentId, path },
607
+ ...(parentFolder?.annotation ? { resolved: [resolvedSummary(parent, parentFolder)] } : {}),
608
+ });
609
+ }));
610
+ server.registerTool("update_diagram_folder", {
611
+ title: "Rename or move a diagram folder",
612
+ description: "Rename a folder of your diagram library and/or move it under another folder (parent: null = the top level). A folder cannot be moved into itself or one of its subfolders.",
613
+ inputSchema: {
614
+ folder: refArg("Diagram folder", "(or path)"),
615
+ name: z.string().min(1).max(120).optional(),
616
+ parent: z.string().max(300).nullable().optional().describe("New parent folder (id, name or path); null = the top level"),
617
+ },
618
+ annotations: IDEMPOTENT_WRITE,
619
+ }, guarded(async ({ folder, name, parent }) => {
620
+ if (name === undefined && parent === undefined)
621
+ throw new ToolInputError("NOTHING_TO_UPDATE", "Pass name and/or parent.");
622
+ const target = await resolveDiagramFolder(ctx, folder);
623
+ const parentFolder = parent ? await resolveDiagramFolder(ctx, parent) : null;
624
+ if (parentFolder && (parentFolder.item.id === target.item.id || parentFolder.item.path.startsWith(`${target.item.path}/`))) {
625
+ throw new ToolInputError("FOLDER_CYCLE", "A folder cannot be moved into itself or one of its subfolders.");
626
+ }
627
+ const updated = await ctx.api.updateDiagramFolder(target.item.id, {
628
+ ...(name !== undefined ? { name: name.trim() } : {}),
629
+ ...(parent !== undefined ? { parentId: parentFolder?.item.id ?? null } : {}),
630
+ });
631
+ ctx.invalidate("diagram-folders");
632
+ const rows = await diagramFolderRows(ctx);
633
+ const row = rows.find((entry) => entry.id === updated.id);
634
+ return ok({
635
+ folder: row ? folderView(row) : { id: updated.id, name: updated.name, parentId: updated.parentId, path: updated.name },
636
+ resolved: [resolvedSummary(folder, target), ...(parentFolder?.annotation ? [resolvedSummary(parent, parentFolder)] : [])],
637
+ });
638
+ }));
639
+ server.registerTool("delete_diagram_folder", {
640
+ title: "Delete a diagram folder",
641
+ description: "Delete a folder of your diagram library and its subfolders. No diagram is deleted: everything inside moves to the deleted folder's parent. Confirm with the user first.",
642
+ inputSchema: { folder: refArg("Diagram folder", "(or path)"), confirm: confirmArg },
643
+ annotations: DESTRUCTIVE,
644
+ }, guarded(async ({ folder, confirm }) => {
645
+ requireConfirm(confirm, "a diagram folder");
646
+ const target = await resolveDiagramFolder(ctx, folder, { exact: true });
647
+ const result = await ctx.api.deleteDiagramFolder(target.item.id);
648
+ ctx.invalidate("diagram-folders");
649
+ ctx.invalidate("diagrams");
650
+ const rows = await diagramFolderRows(ctx);
651
+ return ok({
652
+ deleted: folderView(target.item),
653
+ deletedFolderCount: result.deletedFolderIds.length,
654
+ movedDiagramCount: result.movedDiagramCount,
655
+ movedTo: diagramFolderPath(rows, result.movedToFolderId) ?? "the top level",
656
+ resolved: [resolvedSummary(folder, target)],
657
+ });
658
+ }));
373
659
  server.registerTool("delete_diagram", {
374
660
  title: "Delete a diagram",
375
661
  description: "Delete a diagram you own. Permanent — confirm with the user first.",