db-diagram-tool-mcp 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,16 +1,21 @@
1
1
  # db-diagram-tool-mcp
2
2
 
3
- A **read-only** [Model Context Protocol](https://modelcontextprotocol.io) server
4
- (stdio) that lets MCP clients — **Claude Code** and **OpenAI Codex** — read your
5
- [DB Diagram Tool](https://github.com/kai-exnodes) diagrams: list them, and fetch a
6
- diagram's schema as JSON, DBML, or SQL DDL.
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server (stdio) that
4
+ lets MCP clients — **Claude Code** and **OpenAI Codex** — **read** your
5
+ [DB Diagram Tool](https://github.com/kai-exnodes) diagrams (list them; fetch a
6
+ schema as JSON, DBML, or SQL DDL) and, with a **write-scoped** token, **create and
7
+ update** them from DBML.
7
8
 
8
9
  It talks to the DB Diagram Tool backend over its normal REST API, authenticated
9
- with a **Personal Access Token (PAT)**. It performs **only GET requests** — it can
10
- never modify your diagrams.
10
+ with a **Personal Access Token (PAT)**. A **`read`** PAT can only read; the write
11
+ tools require a **`write`** PAT. Writes merge into the live document like a
12
+ collaborator typing (concurrent editors are never clobbered), and **deletions are
13
+ never applied without your explicit confirmation** (see below).
11
14
 
12
15
  ## Tools
13
16
 
17
+ **Read** (a `read` or `write` PAT):
18
+
14
19
  | Tool | Args | Returns |
15
20
  |------|------|---------|
16
21
  | `list_diagrams` | — | The diagrams your token can access (id, name, role, visibility, updated time). |
@@ -18,23 +23,39 @@ never modify your diagrams.
18
23
  | `get_diagram_dbml` | `id` | The diagram rendered as [DBML](https://dbml.dbdiagram.io) (dbdiagram.io text format). |
19
24
  | `get_diagram_sql` | `id`, `dialect?` (`postgres` \| `mysql`, default `postgres`) | Server-generated `CREATE TABLE` SQL DDL. |
20
25
 
26
+ **Write** (a `write` PAT):
27
+
28
+ | Tool | Args | Returns |
29
+ |------|------|---------|
30
+ | `create_diagram` | `name`, `dbml` | Creates a new diagram from DBML (you own it); returns the new id + an editor URL. |
31
+ | `update_diagram` | `id`, `dbml`, `confirmDeletions?` | Merges the DBML into the live diagram. Additions/changes apply at once; **table/column deletions are withheld** and returned as `pendingDeletions` until you re-call with `confirmDeletions: true`. |
32
+
33
+ > **Delete safety (two-call handshake).** If your DBML drops a table or column,
34
+ > `update_diagram` applies nothing destructive and lists what *would* be deleted.
35
+ > The agent shows you those, and only after you approve does it call again with
36
+ > `confirmDeletions: true`. A paste that forgets a table can't silently destroy it.
37
+
21
38
  ## Requirements
22
39
 
23
40
  - **Node.js ≥ 18** (uses the built-in `fetch`).
24
41
  - A **Personal Access Token** for your DB Diagram Tool account (create one in the
25
- app's Settings → Personal Access Tokens). It looks like `ddt_pat_…`.
42
+ app's Settings → Personal Access Tokens). It looks like `ddt_pat_…`. Choose the
43
+ **`write`** scope if you want the create/update tools; **`read`** is enough (and
44
+ safer) for read-only use.
26
45
  - The backend **origin** URL (e.g. `http://localhost:8090` for local dev, or your
27
46
  hosted instance).
28
47
 
29
48
  ## Configuration
30
49
 
31
- The server reads two environment variables (set them in your MCP client's server
50
+ The server reads these environment variables (set them in your MCP client's server
32
51
  config, below):
33
52
 
34
53
  | Variable | Required | Example | Notes |
35
54
  |----------|----------|---------|-------|
36
55
  | `DDT_BASE_URL` | yes | `http://localhost:8090` | Backend **origin** only — scheme + host[:port], **no** `/api/v1` path. |
37
56
  | `DDT_PAT` | yes | `ddt_pat_abc123…` | Your personal access token. Treat it like a password. |
57
+ | `DDT_WS_URL` | no | `wss://relay.example.com` | Relay WS origin for write-back. Defaults to `DDT_BASE_URL` with `http→ws` / `https→wss` — only set it when the realtime relay is on a different host than the REST API. |
58
+ | `DDT_APP_URL` | no | `https://app.example.com` | Web app origin, used for the editor URL `create_diagram` returns. Defaults to `DDT_BASE_URL`. |
38
59
 
39
60
  ## Build
40
61
 
@@ -119,15 +140,31 @@ Once configured, ask your agent things like:
119
140
  - "List my DB Diagram Tool diagrams."
120
141
  - "Show the DBML for the diagram named _Blog_."
121
142
  - "Give me the Postgres DDL for diagram `<id>`."
143
+ - "Create a diagram called _Orders_ from this DBML: …" *(write PAT)*
144
+ - "Add a `status` column to `orders` in diagram `<id>`." *(write PAT)*
122
145
 
123
146
  ## Security
124
147
 
125
- - **Read-only.** Only GET endpoints are ever called.
126
- - Your PAT grants read access to your diagrams — store it like a password, prefer
127
- `${DDT_PAT}` env expansion (Claude Code) over inlining, and never commit it.
148
+ - **Scoped tokens.** A `read` PAT reaches only the four diagram-read endpoints; a
149
+ `write` PAT additionally creates/updates diagrams. Neither can read member
150
+ emails or comments, or manage tokens. Prefer `read` unless you want write-back.
151
+ - **The PAT never touches the relay.** For a live write, the server exchanges the
152
+ write PAT for a short-lived (~120s), single-diagram `aud:"ws"` token and uses
153
+ *that* for the realtime handshake — a leaked handshake token can't act as a REST
154
+ session or reach another diagram.
155
+ - **Deletions need your confirmation.** `update_diagram` never drops a table or
156
+ column on the first call — it reports `pendingDeletions` and applies them only
157
+ when you re-call with `confirmDeletions: true`.
158
+ - Store your PAT like a password — prefer `${DDT_PAT}` env expansion (Claude Code)
159
+ over inlining, and never commit it.
128
160
 
129
161
  ## Notes
130
162
 
131
163
  - `diagramToDbml.ts` is ported verbatim from the web app
132
164
  (`db-diagram-tool-fe/src/lib/dbml/diagramToDbml.ts`); keep the two in sync.
133
- - Built against `@modelcontextprotocol/sdk` v1.30.
165
+ - `src/core/` **vendors** the web app's DBML↔diagram + Yjs bridge modules verbatim
166
+ (only import paths rewritten). A drift-guard test (`src/core/vendored.drift.test.ts`)
167
+ fails if they diverge from the FE source — re-vendor and re-run the golden when
168
+ the FE changes.
169
+ - Built against `@modelcontextprotocol/sdk` v1.30; write-back builds to REST
170
+ contract **v1.7.0** (`/pats/ws-token`, write-scoped PATs).
package/dist/client.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Read-only REST client for the DB Diagram Tool backend (frozen REST contract;
3
- * same endpoints the web app uses). All requests are `Authorization: Bearer <pat>`
4
- * and hit `${baseUrl}/api/v1<path>`. Only GETs — this package never writes.
2
+ * REST client for the DB Diagram Tool backend (frozen REST contract; same
3
+ * endpoints the web app uses). Every request is `Authorization: Bearer <pat>` and
4
+ * hits `${baseUrl}/api/v1<path>`. Reads accept a read or write PAT; the write
5
+ * methods (create / update / ws-token — REST v1.7.0) require a WRITE-scoped PAT.
5
6
  */
6
7
  const API_PREFIX = "/api/v1";
7
8
  /** A structured API error carrying the HTTP status + the backend's error message. */
@@ -15,21 +16,37 @@ export class ApiError extends Error {
15
16
  this.name = "ApiError";
16
17
  }
17
18
  }
19
+ /** A short remediation hint appended to an error message, by status / scope. */
20
+ function hintFor(status, code) {
21
+ if (code === "insufficient_scope")
22
+ return " (this token can't do that — create / update needs a WRITE-scoped PAT)";
23
+ if (status === 401)
24
+ return " (check DDT_PAT — the token may be invalid or expired)";
25
+ if (status === 403)
26
+ return " (the token lacks access to this diagram)";
27
+ if (status === 404)
28
+ return " (no such diagram, or it isn't shared with this token)";
29
+ return "";
30
+ }
18
31
  export class DdtClient {
19
32
  config;
20
33
  constructor(config) {
21
34
  this.config = config;
22
35
  }
23
- async get(path) {
36
+ async request(method, path, body) {
24
37
  const url = `${this.config.baseUrl}${API_PREFIX}${path}`;
38
+ const headers = {
39
+ Authorization: `Bearer ${this.config.pat}`,
40
+ Accept: "application/json",
41
+ };
42
+ const init = { method, headers };
43
+ if (body !== undefined) {
44
+ headers["Content-Type"] = "application/json";
45
+ init.body = JSON.stringify(body);
46
+ }
25
47
  let res;
26
48
  try {
27
- res = await fetch(url, {
28
- headers: {
29
- Authorization: `Bearer ${this.config.pat}`,
30
- Accept: "application/json",
31
- },
32
- });
49
+ res = await fetch(url, init);
33
50
  }
34
51
  catch (e) {
35
52
  throw new ApiError(0, `could not reach the backend at ${this.config.baseUrl} (${e.message}). Check DDT_BASE_URL and that the server is running.`);
@@ -37,21 +54,20 @@ export class DdtClient {
37
54
  const isJson = res.headers
38
55
  .get("content-type")
39
56
  ?.includes("application/json");
40
- const payload = isJson ? await res.json().catch(() => undefined) : undefined;
57
+ const payload = isJson
58
+ ? await res.json().catch(() => undefined)
59
+ : undefined;
41
60
  if (!res.ok) {
42
61
  const env = payload;
62
+ const code = env?.error?.code;
43
63
  const message = env?.error?.message ?? env?.message ?? res.statusText ?? "request failed";
44
- const hint = res.status === 401
45
- ? " (check DDT_PAT — the token may be invalid or expired)"
46
- : res.status === 403
47
- ? " (the token lacks access to this diagram)"
48
- : res.status === 404
49
- ? " (no such diagram, or it isn't shared with this token)"
50
- : "";
51
- throw new ApiError(res.status, `${res.status} ${message}${hint}`, env?.error?.code);
64
+ throw new ApiError(res.status, `${res.status} ${message}${hintFor(res.status, code)}`, code);
52
65
  }
53
66
  return payload;
54
67
  }
68
+ get(path) {
69
+ return this.request("GET", path);
70
+ }
55
71
  /** GET /diagrams → the token's accessible diagrams. */
56
72
  listDiagrams() {
57
73
  return this.get("/diagrams");
@@ -68,4 +84,23 @@ export class DdtClient {
68
84
  getDdl(id, dialect) {
69
85
  return this.get(`/diagrams/${encodeURIComponent(id)}/export/ddl?dialect=${encodeURIComponent(dialect)}`);
70
86
  }
87
+ // --- writes (REST v1.7.0; require a WRITE-scoped PAT) ---
88
+ /** POST /diagrams { name } → the created diagram's metadata (owner = token owner). */
89
+ createDiagram(name) {
90
+ return this.request("POST", "/diagrams", { name });
91
+ }
92
+ /** PUT /diagrams/{id}/snapshot — durable save of the full Diagram JSON (editor+). */
93
+ putSnapshot(id, diagram) {
94
+ return this.request("PUT", `/diagrams/${encodeURIComponent(id)}/snapshot`, diagram);
95
+ }
96
+ /**
97
+ * POST /pats/ws-token { diagramId } → a short-lived `aud:"ws"` JWT bound to the
98
+ * diagram, for the relay handshake (editor+). The raw PAT is never sent to the
99
+ * relay; this scoped token is (§8 of the PAT contract).
100
+ */
101
+ mintWsToken(id) {
102
+ return this.request("POST", "/pats/ws-token", {
103
+ diagramId: id,
104
+ });
105
+ }
71
106
  }
package/dist/config.js CHANGED
@@ -13,5 +13,11 @@ export function loadConfig(env = process.env) {
13
13
  missing.map((m) => ` - ${m}`).join("\n") +
14
14
  `\nSet them in your MCP client's server config (see the README).`);
15
15
  }
16
- return { baseUrl, pat };
16
+ // WS origin: explicit override, else the REST origin with the scheme swapped
17
+ // (http→ws, https→wss) — same host/port in the common single-origin dev setup.
18
+ const wsOverride = (env.DDT_WS_URL ?? "").trim().replace(/\/+$/, "");
19
+ const wsUrl = wsOverride || baseUrl.replace(/^http(s?):\/\//, "ws$1://");
20
+ // App origin for the editor URL; defaults to the REST origin (same in dev).
21
+ const appUrl = (env.DDT_APP_URL ?? "").trim().replace(/\/+$/, "") || baseUrl;
22
+ return { baseUrl, wsUrl, appUrl, pat };
17
23
  }
@@ -0,0 +1,126 @@
1
+ // --- canonical structural views (ignore ids + positions) ---
2
+ function tableCanon(t) {
3
+ return JSON.stringify({
4
+ note: t.note ?? null,
5
+ columns: t.columns.map((c) => ({
6
+ name: c.name,
7
+ type: c.type,
8
+ pk: c.isPrimaryKey,
9
+ fk: c.isForeignKey,
10
+ nn: !c.isNullable,
11
+ uq: c.isUnique,
12
+ def: c.default ?? null,
13
+ note: c.note ?? null,
14
+ })),
15
+ });
16
+ }
17
+ /** Name-based relationship strings incident to each table (so a cardinality/FK
18
+ * edit shows the two touched tables as "changed"). */
19
+ function incidentRels(d) {
20
+ const tName = new Map(d.tables.map((t) => [t.id, t.name]));
21
+ const cName = new Map(d.tables.flatMap((t) => t.columns.map((c) => [c.id, c.name])));
22
+ const out = new Map();
23
+ for (const r of d.relationships) {
24
+ const ft = tName.get(r.fromTableId);
25
+ const tt = tName.get(r.toTableId);
26
+ const s = `${ft}.${cName.get(r.fromColumnId)} ${r.cardinality} ${tt}.${cName.get(r.toColumnId)}`;
27
+ for (const n of [ft, tt]) {
28
+ if (!n)
29
+ continue;
30
+ const list = out.get(n) ?? [];
31
+ list.push(s);
32
+ out.set(n, list);
33
+ }
34
+ }
35
+ for (const list of out.values())
36
+ list.sort();
37
+ return out;
38
+ }
39
+ /**
40
+ * Structural diff between the current canvas and the parsed DBML, matched by table
41
+ * NAME. `removedTables` feeds the destructive-apply confirm; `unchanged` gates the
42
+ * Apply button.
43
+ */
44
+ export function dbmlDiff(current, next) {
45
+ const curNames = new Set(current.tables.map((t) => t.name));
46
+ const nextNames = new Set(next.tables.map((t) => t.name));
47
+ const addedTables = [...nextNames].filter((n) => !curNames.has(n));
48
+ const removedTables = [...curNames].filter((n) => !nextNames.has(n));
49
+ const curCanon = new Map(current.tables.map((t) => [t.name, tableCanon(t)]));
50
+ const nextCanon = new Map(next.tables.map((t) => [t.name, tableCanon(t)]));
51
+ const curRels = incidentRels(current);
52
+ const nextRels = incidentRels(next);
53
+ const changedTables = [];
54
+ for (const name of curNames) {
55
+ if (!nextNames.has(name))
56
+ continue;
57
+ const structDiff = curCanon.get(name) !== nextCanon.get(name);
58
+ const relDiff = JSON.stringify(curRels.get(name) ?? []) !==
59
+ JSON.stringify(nextRels.get(name) ?? []);
60
+ if (structDiff || relDiff)
61
+ changedTables.push(name);
62
+ }
63
+ const unchanged = addedTables.length === 0 &&
64
+ removedTables.length === 0 &&
65
+ changedTables.length === 0;
66
+ return {
67
+ addedTables: addedTables.sort(),
68
+ removedTables: removedTables.sort(),
69
+ changedTables: changedTables.sort(),
70
+ unchanged,
71
+ };
72
+ }
73
+ // Rough table footprint for staging math (self-refines once React Flow measures).
74
+ const EST_W = 240;
75
+ const GUTTER = 64;
76
+ const COL_GAP = 40;
77
+ const ROW_GAP = 40;
78
+ const estHeight = (t) => 44 + t.columns.length * 28;
79
+ function bbox(tables) {
80
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
81
+ for (const t of tables) {
82
+ minX = Math.min(minX, t.position.x);
83
+ minY = Math.min(minY, t.position.y);
84
+ maxX = Math.max(maxX, t.position.x + EST_W);
85
+ maxY = Math.max(maxY, t.position.y + estHeight(t));
86
+ }
87
+ return { minX, minY, maxX, maxY, height: maxY - minY };
88
+ }
89
+ /** Stage new tables in a column past the preserved bounding box's right edge,
90
+ * stacking vertically (in DBML order) and wrapping into further columns when the
91
+ * stack grows taller than the box — guarantees no overlap with preserved OR among
92
+ * new tables. Mutates `newTables[].position`. */
93
+ function stageNewTables(preserved, newTables) {
94
+ const box = bbox(preserved);
95
+ const startX = box.maxX + GUTTER;
96
+ let x = startX;
97
+ let y = box.minY;
98
+ for (const t of newTables) {
99
+ if (y > box.minY && y + estHeight(t) > box.minY + box.height) {
100
+ x += EST_W + COL_GAP; // wrap to the next staging column
101
+ y = box.minY;
102
+ }
103
+ t.position = { x, y };
104
+ y += estHeight(t) + ROW_GAP;
105
+ }
106
+ }
107
+ /**
108
+ * Build the apply plan: carry each table's position from the current canvas by
109
+ * NAME, stage new tables to the side, and flag the all-new case for the caller.
110
+ */
111
+ export function planApply(next, current) {
112
+ const curPos = new Map(current.tables.map((t) => [t.name, t.position]));
113
+ const newTables = [];
114
+ for (const t of next.tables) {
115
+ const pos = curPos.get(t.name);
116
+ if (pos)
117
+ t.position = { ...pos };
118
+ else
119
+ newTables.push(t);
120
+ }
121
+ const preserved = next.tables.filter((t) => !newTables.includes(t));
122
+ const allNew = preserved.length === 0;
123
+ if (!allNew && newTables.length > 0)
124
+ stageNewTables(preserved, newTables);
125
+ return { diagram: next, newTableNames: newTables.map((t) => t.name), allNew };
126
+ }
Binary file
@@ -0,0 +1,99 @@
1
+ import * as Y from "yjs";
2
+ import { columnToY, relToY, tableToY } from "./snapshot.js";
3
+ export function applyIncremental(doc, change) {
4
+ doc.transact(() => {
5
+ const tables = doc.getArray("tables");
6
+ const rels = doc.getArray("relationships");
7
+ const tableById = new Map();
8
+ for (let i = 0; i < tables.length; i++) {
9
+ const m = tables.get(i);
10
+ tableById.set(str(m.get("id")), m);
11
+ }
12
+ // 1. modify existing tables in place (patch fields + upsert columns).
13
+ for (const t of change.modify ?? []) {
14
+ const m = tableById.get(t.id);
15
+ if (!m)
16
+ continue; // gone in a merge race — nothing to patch
17
+ m.set("name", t.name);
18
+ setOrDelete(m, "note", nonEmpty(t.note) ? t.note : undefined);
19
+ upsertColumns(m, t);
20
+ }
21
+ // 2. append new tables + relationships.
22
+ if (change.add?.length)
23
+ tables.push(change.add.map(tableToY));
24
+ if (change.addRelationships?.length) {
25
+ rels.push(change.addRelationships.map(relToY));
26
+ }
27
+ // 3. confirmed removals (columns first, then tables, then relationships).
28
+ const rm = change.remove;
29
+ if (rm) {
30
+ for (const { tableId, columnId } of rm.columns ?? []) {
31
+ const m = tableById.get(tableId);
32
+ if (m)
33
+ removeColumn(m, columnId);
34
+ }
35
+ if (rm.tables?.length)
36
+ deleteByIds(tables, new Set(rm.tables));
37
+ if (rm.relationships?.length) {
38
+ deleteByIds(rels, new Set(rm.relationships));
39
+ }
40
+ }
41
+ });
42
+ }
43
+ // --- helpers ---
44
+ function upsertColumns(tableMap, table) {
45
+ let cols = tableMap.get("columns");
46
+ if (!cols) {
47
+ cols = new Y.Array();
48
+ tableMap.set("columns", cols);
49
+ }
50
+ const liveById = new Map();
51
+ for (let i = 0; i < cols.length; i++) {
52
+ const cm = cols.get(i);
53
+ liveById.set(str(cm.get("id")), cm);
54
+ }
55
+ for (const c of table.columns) {
56
+ const existing = liveById.get(c.id);
57
+ if (existing)
58
+ patchColumn(existing, c);
59
+ else
60
+ cols.push([columnToY(c)]);
61
+ }
62
+ }
63
+ function patchColumn(m, c) {
64
+ m.set("name", c.name);
65
+ m.set("type", c.type);
66
+ m.set("isPrimaryKey", c.isPrimaryKey);
67
+ m.set("isForeignKey", c.isForeignKey);
68
+ m.set("isNullable", c.isNullable);
69
+ m.set("isUnique", c.isUnique);
70
+ setOrDelete(m, "default", nonEmpty(c.default) ? c.default : undefined);
71
+ setOrDelete(m, "note", nonEmpty(c.note) ? c.note : undefined);
72
+ }
73
+ function removeColumn(tableMap, columnId) {
74
+ const cols = tableMap.get("columns");
75
+ if (!cols)
76
+ return;
77
+ for (let i = cols.length - 1; i >= 0; i--) {
78
+ if (str(cols.get(i).get("id")) === columnId)
79
+ cols.delete(i, 1);
80
+ }
81
+ }
82
+ function deleteByIds(arr, ids) {
83
+ for (let i = arr.length - 1; i >= 0; i--) {
84
+ if (ids.has(str(arr.get(i).get("id"))))
85
+ arr.delete(i, 1);
86
+ }
87
+ }
88
+ function setOrDelete(m, key, value) {
89
+ if (value !== undefined)
90
+ m.set(key, value);
91
+ else if (m.has(key))
92
+ m.delete(key);
93
+ }
94
+ function nonEmpty(v) {
95
+ return typeof v === "string" && v.length > 0;
96
+ }
97
+ function str(v) {
98
+ return typeof v === "string" ? v : "";
99
+ }
@@ -0,0 +1,58 @@
1
+ import * as Y from "yjs";
2
+ import { hydrate } from "./snapshot.js";
3
+ /**
4
+ * Deterministic doc seeding (M6).
5
+ *
6
+ * The WebSocket relay is doc-less and never materializes CRDT content (contract
7
+ * 3 §5), so the FE — not the server — owns seeding a diagram's initial content
8
+ * from its REST snapshot. Naively hydrating a live doc on every client would
9
+ * UNION-duplicate tables, because each client's Y items get distinct
10
+ * `(clientID, clock)` ids.
11
+ *
12
+ * Fix: build the seed in a throwaway doc pinned to a RESERVED clientID, so any
13
+ * two clients seeding the SAME snapshot produce items with IDENTICAL ids →
14
+ * Yjs merge is idempotent and never duplicates, even on a concurrent cold-open
15
+ * or when the relay replays the seed from its log. Real edits afterward use the
16
+ * live doc's own (random) clientID and merge normally on top.
17
+ */
18
+ /** Reserved clientID for the deterministic seed (see module doc). */
19
+ export const SEED_CLIENT_ID = 0;
20
+ /** A deterministic Yjs update that reproduces the snapshot's content. */
21
+ export function seedUpdate(diagram) {
22
+ const seed = new Y.Doc();
23
+ seed.clientID = SEED_CLIENT_ID;
24
+ // Match the live doc containers so encoded items address the same shared types.
25
+ seed.getMap("meta");
26
+ seed.getArray("tables");
27
+ seed.getArray("relationships");
28
+ hydrate(seed, diagram);
29
+ return Y.encodeStateAsUpdate(seed);
30
+ }
31
+ /**
32
+ * Seed a live doc from the snapshot.
33
+ *
34
+ * The reserved-clientID trick makes this idempotent against ANOTHER deterministic
35
+ * seed. It does NOT protect against the relay's log when that log was produced by
36
+ * the doc's REAL edits (random clientID): the snapshot's tables and the relay's
37
+ * originals then have the same `id` field but distinct Yjs item identity, so
38
+ * merging both UNION-DUPLICATES every table. An already-collaborated diagram's
39
+ * relay carries exactly such random-clientID items — so DO NOT seed eagerly on
40
+ * open; gate on the relay being empty (see `seedIfEmpty` + useCollab).
41
+ */
42
+ export function seedDiagram(doc, diagram) {
43
+ Y.applyUpdate(doc, seedUpdate(diagram));
44
+ }
45
+ /**
46
+ * Relay-empty seed gate. Seed from the snapshot ONLY when the doc still has no
47
+ * tables — i.e. the relay delivered no state (a brand-new diagram). For a doc the
48
+ * relay already populated, the relay is authoritative and seeding on top would
49
+ * union-duplicate every table (see `seedDiagram`). Returns whether it seeded.
50
+ * Call this AFTER the provider's first sync (or an offline-fallback timeout), not
51
+ * eagerly on open.
52
+ */
53
+ export function seedIfEmpty(doc, diagram) {
54
+ if (doc.getArray("tables").length > 0)
55
+ return false;
56
+ seedDiagram(doc, diagram);
57
+ return true;
58
+ }
@@ -0,0 +1,145 @@
1
+ import * as Y from "yjs";
2
+ import { SCHEMA_VERSION, } from "./types/diagram.js";
3
+ /**
4
+ * Yjs ↔ snapshot bridge.
5
+ *
6
+ * The Y.Doc is the live collaborative editing document; the contract-1 Diagram
7
+ * is the durable snapshot. `materialize` produces a CLEAN field-by-field
8
+ * projection — only contract keys, columns in array order — so it passes BE's
9
+ * closed-object PUT validation and never leaks transient UI state (D5).
10
+ * `hydrate` rebuilds the doc from a snapshot (e.g. on open / import).
11
+ *
12
+ * Doc shape: meta (Y.Map: id, name) · tables (Y.Array<Y.Map>) ·
13
+ * relationships (Y.Array<Y.Map>). A table's position is a nested Y.Map {x,y}
14
+ * and its columns a Y.Array<Y.Map> (order preserved → composite-PK order, D6).
15
+ */
16
+ // ---------------------------------------------------------------------------
17
+ // hydrate: Diagram → Y.Doc
18
+ // ---------------------------------------------------------------------------
19
+ export function hydrate(doc, diagram) {
20
+ doc.transact(() => {
21
+ const meta = doc.getMap("meta");
22
+ meta.set("id", diagram.id);
23
+ meta.set("name", diagram.name);
24
+ const tables = doc.getArray("tables");
25
+ tables.delete(0, tables.length);
26
+ tables.push(diagram.tables.map(tableToY));
27
+ const rels = doc.getArray("relationships");
28
+ rels.delete(0, rels.length);
29
+ rels.push(diagram.relationships.map(relToY));
30
+ });
31
+ }
32
+ export function tableToY(t) {
33
+ const m = new Y.Map();
34
+ m.set("id", t.id);
35
+ m.set("name", t.name);
36
+ const pos = new Y.Map();
37
+ pos.set("x", t.position.x);
38
+ pos.set("y", t.position.y);
39
+ m.set("position", pos);
40
+ if (nonEmpty(t.note))
41
+ m.set("note", t.note);
42
+ const cols = new Y.Array();
43
+ cols.push(t.columns.map(columnToY));
44
+ m.set("columns", cols);
45
+ return m;
46
+ }
47
+ export function columnToY(c) {
48
+ const m = new Y.Map();
49
+ m.set("id", c.id);
50
+ m.set("name", c.name);
51
+ m.set("type", c.type);
52
+ m.set("isPrimaryKey", c.isPrimaryKey);
53
+ m.set("isForeignKey", c.isForeignKey);
54
+ m.set("isNullable", c.isNullable);
55
+ m.set("isUnique", c.isUnique);
56
+ if (nonEmpty(c.default))
57
+ m.set("default", c.default);
58
+ if (nonEmpty(c.note))
59
+ m.set("note", c.note);
60
+ return m;
61
+ }
62
+ export function relToY(r) {
63
+ const m = new Y.Map();
64
+ m.set("id", r.id);
65
+ m.set("fromTableId", r.fromTableId);
66
+ m.set("fromColumnId", r.fromColumnId);
67
+ m.set("toTableId", r.toTableId);
68
+ m.set("toColumnId", r.toColumnId);
69
+ m.set("cardinality", r.cardinality);
70
+ return m;
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // materialize: Y.Doc → Diagram (clean projection)
74
+ // ---------------------------------------------------------------------------
75
+ export function materialize(doc) {
76
+ const meta = doc.getMap("meta");
77
+ const tables = doc.getArray("tables");
78
+ const rels = doc.getArray("relationships");
79
+ return {
80
+ schemaVersion: SCHEMA_VERSION,
81
+ id: str(meta.get("id")),
82
+ name: str(meta.get("name")),
83
+ tables: tables.map(yToTable),
84
+ relationships: rels.map(yToRelationship),
85
+ };
86
+ }
87
+ function yToTable(m) {
88
+ const cols = m.get("columns");
89
+ const table = {
90
+ id: str(m.get("id")),
91
+ name: str(m.get("name")),
92
+ position: yToPosition(m.get("position")),
93
+ columns: cols ? cols.map(yToColumn) : [],
94
+ };
95
+ const note = m.get("note");
96
+ if (nonEmpty(note))
97
+ table.note = note;
98
+ return table;
99
+ }
100
+ export function yToColumn(m) {
101
+ const col = {
102
+ id: str(m.get("id")),
103
+ name: str(m.get("name")),
104
+ type: str(m.get("type")),
105
+ isPrimaryKey: bool(m.get("isPrimaryKey")),
106
+ isForeignKey: bool(m.get("isForeignKey")),
107
+ isNullable: bool(m.get("isNullable")),
108
+ isUnique: bool(m.get("isUnique")),
109
+ };
110
+ const def = m.get("default");
111
+ if (nonEmpty(def))
112
+ col.default = def;
113
+ const note = m.get("note");
114
+ if (nonEmpty(note))
115
+ col.note = note;
116
+ return col;
117
+ }
118
+ function yToRelationship(m) {
119
+ return {
120
+ id: str(m.get("id")),
121
+ fromTableId: str(m.get("fromTableId")),
122
+ fromColumnId: str(m.get("fromColumnId")),
123
+ toTableId: str(m.get("toTableId")),
124
+ toColumnId: str(m.get("toColumnId")),
125
+ cardinality: m.get("cardinality") ?? "1:N",
126
+ };
127
+ }
128
+ function yToPosition(pos) {
129
+ return { x: num(pos?.get("x")), y: num(pos?.get("y")) };
130
+ }
131
+ // ---------------------------------------------------------------------------
132
+ // helpers
133
+ // ---------------------------------------------------------------------------
134
+ function nonEmpty(v) {
135
+ return typeof v === "string" && v.length > 0;
136
+ }
137
+ function str(v) {
138
+ return typeof v === "string" ? v : "";
139
+ }
140
+ function bool(v) {
141
+ return v === true;
142
+ }
143
+ function num(v) {
144
+ return typeof v === "number" ? v : 0;
145
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Contract-1 typed models — the canonical Diagram document.
3
+ *
4
+ * Mirrors the FROZEN Diagram JSON schema v1.0.0
5
+ * (docs/contracts/diagram.schema.json @ a60549f). Every object is CLOSED in the
6
+ * contract (additionalProperties:false) — the materializer must emit ONLY these
7
+ * keys, or BE's snapshot PUT validation rejects the save.
8
+ */
9
+ export const SCHEMA_VERSION = "1.0";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * UUID v4 that works in ANY browser context.
3
+ *
4
+ * `crypto.randomUUID()` exists only in a SECURE context (HTTPS or localhost).
5
+ * When the app is opened from another device on the LAN over plain HTTP (e.g.
6
+ * `http://192.168.1.6:3000`), that origin is NOT a secure context, so
7
+ * `crypto.randomUUID` is `undefined` and calling it throws. `crypto.getRandomValues`,
8
+ * by contrast, IS available in non-secure contexts — so we fall back to building
9
+ * an RFC 4122 v4 UUID from it. This keeps table/column/guest id generation (and
10
+ * therefore the editor + realtime collab) working for LAN visitors.
11
+ */
12
+ export function randomUUID() {
13
+ const c = globalThis.crypto;
14
+ if (c && typeof c.randomUUID === "function") {
15
+ return c.randomUUID();
16
+ }
17
+ const b = new Uint8Array(16);
18
+ c.getRandomValues(b);
19
+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
20
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
21
+ const hex = [];
22
+ for (let i = 0; i < 16; i++)
23
+ hex.push(b[i].toString(16).padStart(2, "0"));
24
+ return (hex.slice(0, 4).join("") +
25
+ "-" +
26
+ hex.slice(4, 6).join("") +
27
+ "-" +
28
+ hex.slice(6, 8).join("") +
29
+ "-" +
30
+ hex.slice(8, 10).join("") +
31
+ "-" +
32
+ hex.slice(10, 16).join(""));
33
+ }
package/dist/index.js CHANGED
@@ -5,16 +5,19 @@ import { z } from "zod";
5
5
  import { loadConfig } from "./config.js";
6
6
  import { ApiError, DdtClient } from "./client.js";
7
7
  import { diagramToDbml } from "./diagramToDbml.js";
8
+ import { withRoom } from "./relayPeer.js";
9
+ import { DbmlError, createDiagram as runCreateDiagram, updateDiagram as runUpdateDiagram, } from "./writeback.js";
8
10
  /**
9
- * db-diagram-tool-mcp — a read-only stdio MCP server exposing your DB Diagram Tool
10
- * diagrams to MCP clients (Claude Code, Codex). Four tools: list_diagrams,
11
- * get_diagram (schema JSON), get_diagram_dbml, get_diagram_sql. Auth is a PAT.
11
+ * db-diagram-tool-mcp — a stdio MCP server exposing your DB Diagram Tool diagrams
12
+ * to MCP clients (Claude Code, Codex). Read tools: list_diagrams, get_diagram
13
+ * (schema JSON), get_diagram_dbml, get_diagram_sql. Write tools (a WRITE-scoped
14
+ * PAT): create_diagram, update_diagram. Auth is a PAT.
12
15
  *
13
16
  * stdio protocol note: stdout is the JSON-RPC channel — NEVER write to stdout.
14
17
  * All diagnostics go to stderr.
15
18
  */
16
19
  const SERVER_NAME = "db-diagram-tool";
17
- const SERVER_VERSION = "0.1.0";
20
+ const SERVER_VERSION = "0.2.0";
18
21
  /** A successful text result. */
19
22
  function text(body) {
20
23
  return { content: [{ type: "text", text: body }] };
@@ -22,19 +25,41 @@ function text(body) {
22
25
  /** A tool-level error result (isError so the client shows it as a failure, but the
23
26
  * server keeps running for the next call). */
24
27
  function errorResult(e) {
25
- const msg = e instanceof ApiError
26
- ? e.message
27
- : e instanceof Error
28
- ? e.message
29
- : String(e);
28
+ let msg;
29
+ if (e instanceof DbmlError) {
30
+ const at = e.detail.line != null
31
+ ? ` at line ${e.detail.line}${e.detail.column != null ? `, column ${e.detail.column}` : ""}`
32
+ : "";
33
+ msg = `DBML parse error${at}: ${e.detail.message}`;
34
+ }
35
+ else if (e instanceof ApiError || e instanceof Error) {
36
+ msg = e.message;
37
+ }
38
+ else {
39
+ msg = String(e);
40
+ }
30
41
  return {
31
42
  content: [{ type: "text", text: `Error: ${msg}` }],
32
43
  isError: true,
33
44
  };
34
45
  }
35
- function buildServer(client) {
46
+ function buildServer(client, config) {
36
47
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
37
48
  const readOnly = { readOnlyHint: true, openWorldHint: true };
49
+ // Write-back wiring (Approach C): create is REST-only; update mints a scoped
50
+ // ws-token and joins the relay room as a live peer to merge the edit.
51
+ const writeback = {
52
+ createDiagram: (name) => client.createDiagram(name),
53
+ getSnapshot: (id) => client.getSnapshot(id),
54
+ putSnapshot: (id, diagram) => client.putSnapshot(id, diagram),
55
+ mintWsToken: (id) => client.mintWsToken(id),
56
+ runInRoom: (id, wsToken, fn) => withRoom({
57
+ wsUrl: config.wsUrl,
58
+ getSnapshot: (i) => client.getSnapshot(i),
59
+ putSnapshot: (i, d) => client.putSnapshot(i, d),
60
+ }, id, wsToken, fn),
61
+ appUrl: config.appUrl,
62
+ };
38
63
  server.registerTool("list_diagrams", {
39
64
  title: "List diagrams",
40
65
  description: "List the DB Diagram Tool diagrams this token can access. Returns id, name, your role, visibility, and last-updated time for each. Use an id with the other tools.",
@@ -120,12 +145,84 @@ function buildServer(client) {
120
145
  return errorResult(e);
121
146
  }
122
147
  });
148
+ // --- write tools (require a WRITE-scoped PAT) ---
149
+ server.registerTool("create_diagram", {
150
+ title: "Create diagram (from DBML)",
151
+ description: "Create a NEW diagram from DBML (the dbdiagram.io text format: Table/Ref blocks). Parses the DBML, creates the diagram (you become its owner), lays the tables out on a simple grid, and saves the schema. Returns the new diagram id and an editor URL to open it. Requires a WRITE-scoped Personal Access Token. Unmodeled DBML features (enums, TableGroups, secondary indexes, referential actions) are ignored and reported as warnings — everything else (tables, columns with types/keys/nullability, and relationships) is created.",
152
+ inputSchema: {
153
+ name: z
154
+ .string()
155
+ .min(1)
156
+ .describe("A name for the new diagram (shown in the dashboard)."),
157
+ dbml: z
158
+ .string()
159
+ .min(1)
160
+ .describe("The schema as DBML — Table blocks and Ref relationships."),
161
+ },
162
+ annotations: { readOnlyHint: false, openWorldHint: true },
163
+ }, async ({ name, dbml }) => {
164
+ try {
165
+ const res = await runCreateDiagram(writeback, { name, dbml });
166
+ const lines = [`Created diagram "${name}".`, `id: ${res.id}`, `Open: ${res.url}`];
167
+ if (res.warnings.length) {
168
+ lines.push("", "Warnings:", ...res.warnings.map((w) => `- ${w}`));
169
+ }
170
+ return text(lines.join("\n"));
171
+ }
172
+ catch (e) {
173
+ return errorResult(e);
174
+ }
175
+ });
176
+ server.registerTool("update_diagram", {
177
+ title: "Update diagram (from DBML)",
178
+ description: "Update an existing diagram's schema from DBML by MERGING your changes into the live document — concurrent editors are preserved (nothing is clobbered). Additions and modifications (new tables, new/changed columns, new relationships) apply immediately. IMPORTANT — deletions are gated: if the DBML drops any table or column, the tool applies NOTHING destructive and returns them under `pendingDeletions`. You MUST show the user exactly what would be deleted and, ONLY after they explicitly approve, call update_diagram AGAIN with the SAME `dbml` and `confirmDeletions: true` to apply the deletions. Never pass confirmDeletions: true on the first call. Requires a WRITE-scoped Personal Access Token.",
179
+ inputSchema: {
180
+ id: z.string().min(1).describe("The diagram id to update (from list_diagrams)."),
181
+ dbml: z
182
+ .string()
183
+ .min(1)
184
+ .describe("The intended schema as DBML; diffed against the live diagram."),
185
+ confirmDeletions: z
186
+ .boolean()
187
+ .optional()
188
+ .describe("Set true ONLY on a second call, after the user approved the pendingDeletions from a prior call, to apply table/column removals."),
189
+ },
190
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
191
+ }, async ({ id, dbml, confirmDeletions }) => {
192
+ try {
193
+ const res = await runUpdateDiagram(writeback, { id, dbml, confirmDeletions });
194
+ const { addedTables, modifiedTables } = res.applied;
195
+ const lines = [];
196
+ if (addedTables.length)
197
+ lines.push(`Added tables: ${addedTables.join(", ")}`);
198
+ if (modifiedTables.length)
199
+ lines.push(`Modified tables: ${modifiedTables.join(", ")}`);
200
+ if (!lines.length && !res.pendingDeletions)
201
+ lines.push("No changes to apply.");
202
+ if (res.pendingDeletions) {
203
+ const { tables, columns } = res.pendingDeletions;
204
+ lines.push("", "⚠️ PENDING DELETIONS — NOT applied (awaiting your confirmation):");
205
+ if (tables.length)
206
+ lines.push(` tables: ${tables.join(", ")}`);
207
+ if (columns.length)
208
+ lines.push(` columns: ${columns.join(", ")}`);
209
+ lines.push("Show these to the user. If they approve, call update_diagram again with the SAME dbml and confirmDeletions: true.");
210
+ }
211
+ if (res.warnings.length) {
212
+ lines.push("", "Warnings:", ...res.warnings.map((w) => `- ${w}`));
213
+ }
214
+ return text(lines.join("\n"));
215
+ }
216
+ catch (e) {
217
+ return errorResult(e);
218
+ }
219
+ });
123
220
  return server;
124
221
  }
125
222
  async function main() {
126
223
  const config = loadConfig(); // throws with a clear message if unset
127
224
  const client = new DdtClient(config);
128
- const server = buildServer(client);
225
+ const server = buildServer(client, config);
129
226
  await server.connect(new StdioServerTransport());
130
227
  // stderr only — stdout carries the MCP protocol.
131
228
  process.stderr.write(`db-diagram-tool-mcp v${SERVER_VERSION} connected (backend: ${config.baseUrl})\n`);
@@ -0,0 +1,99 @@
1
+ import * as Y from "yjs";
2
+ import { WebsocketProvider } from "y-websocket";
3
+ import { WebSocket as NodeWebSocket } from "ws";
4
+ import { materialize } from "./core/snapshot.js";
5
+ import { seedIfEmpty } from "./core/seed.js";
6
+ /**
7
+ * Headless relay peer for write-back (task 3.4, Approach C).
8
+ *
9
+ * `withRoom` briefly joins a diagram's live Yjs relay room as a peer, lets `fn`
10
+ * merge an edit into the shared doc, then persists via the durable snapshot save
11
+ * — exactly like a collaborator typing. It authenticates the handshake with the
12
+ * short-lived `aud:"ws"` token (never the PAT), as the `bearer.<jwt>` subprotocol
13
+ * the FE provider uses.
14
+ *
15
+ * ⚠️ The relay is DOC-LESS (it relays frames; it never materializes CRDT state).
16
+ * A lone first peer therefore never receives sync-step-2, so y-websocket's `sync`
17
+ * event NEVER fires — we must NOT await it. We wait on `wsconnected` + a settle
18
+ * window (for the relay to replay its log, if any), then reconcile against the
19
+ * durable REST snapshot: `seedIfEmpty` seeds only when the room was empty.
20
+ */
21
+ /** Subprotocol version marker offered on the handshake (contract 3 §2.1). */
22
+ export const WS_SUBPROTOCOL = "ddt.v1";
23
+ const WS_PATH = "/api/v1/ws/diagrams";
24
+ /** WS origin + the relay endpoint path (room id appended by WebsocketProvider). */
25
+ export function wsServerUrl(wsUrl) {
26
+ return wsUrl.replace(/\/+$/, "") + WS_PATH;
27
+ }
28
+ const DEFAULT_SETTLE_MS = 750;
29
+ const DEFAULT_FLUSH_MS = 300;
30
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
31
+ /**
32
+ * Join `diagramId`'s relay room, run `fn(doc)` to merge an edit, persist, and
33
+ * tear down. Returns the materialized (saved) Diagram. Throws if the relay never
34
+ * connects within the timeout.
35
+ */
36
+ export async function withRoom(deps, diagramId, wsToken, fn) {
37
+ const doc = new Y.Doc();
38
+ const WsImpl = deps.WebSocketImpl ?? NodeWebSocket;
39
+ const provider = new WebsocketProvider(wsServerUrl(deps.wsUrl), diagramId, doc, {
40
+ disableBc: true,
41
+ protocols: [WS_SUBPROTOCOL, `bearer.${wsToken}`],
42
+ WebSocketPolyfill: WsImpl,
43
+ });
44
+ try {
45
+ await waitForConnected(provider, deps.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
46
+ // Doc-less relay: `sync` never fires for a lone peer. Give the relay a moment
47
+ // to replay its log (if the room was live), then reconcile with the snapshot.
48
+ await sleep(deps.settleMs ?? DEFAULT_SETTLE_MS);
49
+ const snapshot = await deps.getSnapshot(diagramId);
50
+ seedIfEmpty(doc, snapshot);
51
+ await fn(doc);
52
+ // Let our update frame flush to the relay so live peers see it, then persist.
53
+ await sleep(deps.flushMs ?? DEFAULT_FLUSH_MS);
54
+ const result = materialize(doc);
55
+ await deps.putSnapshot(diagramId, result);
56
+ return result;
57
+ }
58
+ finally {
59
+ teardown(provider, doc);
60
+ }
61
+ }
62
+ function waitForConnected(provider, timeoutMs) {
63
+ if (provider.wsconnected)
64
+ return Promise.resolve();
65
+ return new Promise((resolve, reject) => {
66
+ const onStatus = (e) => {
67
+ if (e.status === "connected") {
68
+ cleanup();
69
+ resolve();
70
+ }
71
+ };
72
+ const timer = setTimeout(() => {
73
+ cleanup();
74
+ reject(new Error(`relay connect timed out after ${timeoutMs}ms (${provider.url})`));
75
+ }, timeoutMs);
76
+ const cleanup = () => {
77
+ clearTimeout(timer);
78
+ provider.off("status", onStatus);
79
+ };
80
+ provider.on("status", onStatus);
81
+ });
82
+ }
83
+ function teardown(provider, doc) {
84
+ try {
85
+ provider.destroy(); // disconnects + clears timers/listeners
86
+ }
87
+ catch {
88
+ /* best-effort */
89
+ }
90
+ try {
91
+ doc.destroy();
92
+ }
93
+ catch {
94
+ /* best-effort */
95
+ }
96
+ }
97
+ function sleep(ms) {
98
+ return new Promise((r) => setTimeout(r, ms));
99
+ }
@@ -0,0 +1,233 @@
1
+ import * as Y from "yjs";
2
+ import { hydrate, materialize } from "./core/snapshot.js";
3
+ import { planApply } from "./core/apply.js";
4
+ import { applyIncremental } from "./core/ops.js";
5
+ import { dbmlToDiagram } from "./core/dbmlToDiagram.js";
6
+ import { SCHEMA_VERSION, } from "./core/types/diagram.js";
7
+ /** A DBML parse failure — surfaced to the tool with line/column, never a create/update. */
8
+ export class DbmlError extends Error {
9
+ detail;
10
+ constructor(detail) {
11
+ super(detail.message);
12
+ this.detail = detail;
13
+ this.name = "DbmlError";
14
+ }
15
+ }
16
+ // --- create ------------------------------------------------------------------
17
+ export async function createDiagram(deps, input) {
18
+ const parsed = dbmlToDiagram(input.dbml);
19
+ if (parsed.error)
20
+ throw new DbmlError(parsed.error);
21
+ const { id } = await deps.createDiagram(input.name);
22
+ const built = {
23
+ schemaVersion: SCHEMA_VERSION,
24
+ id,
25
+ name: input.name,
26
+ tables: parsed.diagram.tables,
27
+ relationships: parsed.diagram.relationships,
28
+ };
29
+ layoutGrid(built.tables); // headless: no React Flow measurements — a simple grid
30
+ // Route through hydrate → materialize so the saved snapshot is the same CLEAN,
31
+ // contract-closed projection the editor's save produces.
32
+ const doc = new Y.Doc();
33
+ hydrate(doc, built);
34
+ await deps.putSnapshot(id, materialize(doc));
35
+ doc.destroy();
36
+ const base = deps.appUrl.replace(/\/+$/, "");
37
+ return { id, url: `${base}/editor/${id}`, warnings: parsed.warnings };
38
+ }
39
+ // --- update ------------------------------------------------------------------
40
+ export async function updateDiagram(deps, input) {
41
+ const parsed = dbmlToDiagram(input.dbml);
42
+ if (parsed.error)
43
+ throw new DbmlError(parsed.error);
44
+ const next = parsed.diagram;
45
+ const confirm = input.confirmDeletions === true;
46
+ const { token } = await deps.mintWsToken(input.id);
47
+ let resolved;
48
+ await deps.runInRoom(input.id, token, (doc) => {
49
+ const current = materialize(doc);
50
+ // Carry positions over by table name; stage genuinely-new tables to the side.
51
+ const plan = planApply({ ...next, id: current.id, name: current.name }, current);
52
+ if (plan.allNew)
53
+ layoutGrid(plan.diagram.tables);
54
+ resolved = resolveOps(current, plan.diagram, confirm);
55
+ applyIncremental(doc, resolved.ops);
56
+ });
57
+ const result = {
58
+ applied: resolved.applied,
59
+ warnings: parsed.warnings,
60
+ };
61
+ if (resolved.pendingDeletions)
62
+ result.pendingDeletions = resolved.pendingDeletions;
63
+ return result;
64
+ }
65
+ /**
66
+ * Reconcile the intended `next` schema against the `current` live schema
67
+ * (matched by NAME) into id-keyed incremental ops. Additive changes (new tables,
68
+ * new/changed columns, new FK lines) are always applied; table/column deletions
69
+ * are applied only when `confirmDeletions`, else returned as `pendingDeletions`.
70
+ * Kept entities reuse their LIVE ids so the merge patches in place (stable for
71
+ * relationships + a concurrent human's cursor), and new entities keep fresh ids.
72
+ */
73
+ export function resolveOps(current, next, confirmDeletions) {
74
+ const liveByName = new Map(current.tables.map((t) => [t.name, t]));
75
+ const nextByName = new Map(next.tables.map((t) => [t.name, t]));
76
+ const addTables = [];
77
+ const modifyTables = [];
78
+ const removeTableIds = [];
79
+ const removeColumns = [];
80
+ const addedTables = [];
81
+ const modifiedTables = [];
82
+ const pendingTables = [];
83
+ const pendingColumns = [];
84
+ const finalTableId = (name) => liveByName.get(name)?.id ?? nextByName.get(name)?.id ?? "";
85
+ const finalColumnId = (tableName, colName) => {
86
+ const lc = liveByName.get(tableName)?.columns.find((c) => c.name === colName);
87
+ if (lc)
88
+ return lc.id;
89
+ return nextByName.get(tableName)?.columns.find((c) => c.name === colName)?.id ?? "";
90
+ };
91
+ const markModified = (name) => {
92
+ if (!modifiedTables.includes(name))
93
+ modifiedTables.push(name);
94
+ };
95
+ // Added tables + kept-table column reconciliation.
96
+ for (const t of next.tables) {
97
+ const live = liveByName.get(t.name);
98
+ if (!live) {
99
+ addTables.push(t); // fresh ids + staged position (planApply set it)
100
+ addedTables.push(t.name);
101
+ continue;
102
+ }
103
+ const liveByCol = new Map(live.columns.map((c) => [c.name, c]));
104
+ const nextNames = new Set(t.columns.map((c) => c.name));
105
+ let additive = false;
106
+ const modifyCols = t.columns.map((c) => {
107
+ const lc = liveByCol.get(c.name);
108
+ if (!lc) {
109
+ additive = true; // a new column
110
+ return c;
111
+ }
112
+ if (!columnFieldsEqual(lc, c))
113
+ additive = true; // a changed column
114
+ return { ...c, id: lc.id }; // reuse the live id → patch in place
115
+ });
116
+ if (additive) {
117
+ modifyTables.push({ ...t, id: live.id, columns: modifyCols });
118
+ markModified(t.name);
119
+ }
120
+ for (const lc of live.columns) {
121
+ if (nextNames.has(lc.name))
122
+ continue; // kept
123
+ if (confirmDeletions) {
124
+ removeColumns.push({ tableId: live.id, columnId: lc.id });
125
+ markModified(t.name);
126
+ }
127
+ else {
128
+ pendingColumns.push(`${t.name}.${lc.name}`);
129
+ }
130
+ }
131
+ }
132
+ // Removed tables.
133
+ for (const t of current.tables) {
134
+ if (nextByName.has(t.name))
135
+ continue;
136
+ if (confirmDeletions)
137
+ removeTableIds.push(t.id);
138
+ else
139
+ pendingTables.push(t.name);
140
+ }
141
+ // Relationships (matched by endpoint NAME + cardinality; not gated, but a line
142
+ // touching a WITHHELD table drop is kept so nothing dangles mid-approval).
143
+ const curKeys = new Set(current.relationships.map((r) => relKey(r, current)));
144
+ const desiredKeys = new Set(next.relationships.map((r) => relKey(r, next)));
145
+ const withheld = confirmDeletions ? new Set() : new Set(pendingTables);
146
+ const addRelationships = [];
147
+ for (const r of next.relationships) {
148
+ if (curKeys.has(relKey(r, next)))
149
+ continue;
150
+ const [ft, fc, tt, tc] = relEndpointNames(r, next);
151
+ addRelationships.push({
152
+ id: r.id,
153
+ fromTableId: finalTableId(ft),
154
+ fromColumnId: finalColumnId(ft, fc),
155
+ toTableId: finalTableId(tt),
156
+ toColumnId: finalColumnId(tt, tc),
157
+ cardinality: r.cardinality,
158
+ });
159
+ }
160
+ const removeRelationshipIds = [];
161
+ for (const r of current.relationships) {
162
+ if (desiredKeys.has(relKey(r, current)))
163
+ continue;
164
+ const [ft, , tt] = relEndpointNames(r, current);
165
+ if (withheld.has(ft) || withheld.has(tt))
166
+ continue; // keep while the drop is pending
167
+ removeRelationshipIds.push(r.id);
168
+ }
169
+ const remove = removeTableIds.length || removeColumns.length || removeRelationshipIds.length
170
+ ? { tables: removeTableIds, columns: removeColumns, relationships: removeRelationshipIds }
171
+ : undefined;
172
+ const ops = {
173
+ add: addTables.length ? addTables : undefined,
174
+ modify: modifyTables.length ? modifyTables : undefined,
175
+ addRelationships: addRelationships.length ? addRelationships : undefined,
176
+ remove,
177
+ };
178
+ const pendingDeletions = pendingTables.length || pendingColumns.length
179
+ ? { tables: pendingTables, columns: pendingColumns }
180
+ : null;
181
+ return { ops, applied: { addedTables, modifiedTables }, pendingDeletions };
182
+ }
183
+ // --- helpers -----------------------------------------------------------------
184
+ function columnFieldsEqual(a, b) {
185
+ return (a.type === b.type &&
186
+ a.isPrimaryKey === b.isPrimaryKey &&
187
+ a.isForeignKey === b.isForeignKey &&
188
+ a.isNullable === b.isNullable &&
189
+ a.isUnique === b.isUnique &&
190
+ (a.default ?? null) === (b.default ?? null) &&
191
+ (a.note ?? null) === (b.note ?? null));
192
+ }
193
+ function relEndpointNames(r, d) {
194
+ const tName = new Map(d.tables.map((t) => [t.id, t.name]));
195
+ const cName = new Map(d.tables.flatMap((t) => t.columns.map((c) => [c.id, c.name])));
196
+ return [
197
+ tName.get(r.fromTableId) ?? "",
198
+ cName.get(r.fromColumnId) ?? "",
199
+ tName.get(r.toTableId) ?? "",
200
+ cName.get(r.toColumnId) ?? "",
201
+ ];
202
+ }
203
+ function relKey(r, d) {
204
+ const [ft, fc, tt, tc] = relEndpointNames(r, d);
205
+ return `${ft}.${fc}>${tt}.${tc}#${r.cardinality}`;
206
+ }
207
+ // Rough table footprint (mirrors apply.ts) for a simple headless grid — the user
208
+ // can auto-arrange in the editor; this just avoids stacking everything at (0,0).
209
+ const CELL_W = 300;
210
+ const GAP_X = 64;
211
+ const GAP_Y = 48;
212
+ const estHeight = (t) => 44 + t.columns.length * 28;
213
+ function layoutGrid(tables) {
214
+ const perRow = Math.max(1, Math.ceil(Math.sqrt(tables.length)));
215
+ let x = 0;
216
+ let y = 0;
217
+ let rowHeight = 0;
218
+ let col = 0;
219
+ for (const t of tables) {
220
+ t.position = { x, y };
221
+ rowHeight = Math.max(rowHeight, estHeight(t));
222
+ col += 1;
223
+ if (col >= perRow) {
224
+ col = 0;
225
+ x = 0;
226
+ y += rowHeight + GAP_Y;
227
+ rowHeight = 0;
228
+ }
229
+ else {
230
+ x += CELL_W + GAP_X;
231
+ }
232
+ }
233
+ }
package/package.json CHANGED
@@ -1,28 +1,39 @@
1
1
  {
2
2
  "name": "db-diagram-tool-mcp",
3
- "version": "0.1.0",
4
- "description": "Read-only Model Context Protocol (stdio) server for DB Diagram Tool — lets Claude Code / Codex read your diagrams (list, schema JSON, DBML, SQL DDL).",
3
+ "version": "0.2.0",
4
+ "description": "Model Context Protocol (stdio) server for DB Diagram Tool — lets Claude Code / Codex read (list, schema JSON, DBML, SQL DDL) and, with a write-scoped token, create and update your diagrams from DBML.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "db-diagram-tool-mcp": "dist/index.js"
8
8
  },
9
- "files": ["dist", "README.md"],
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
10
13
  "engines": {
11
14
  "node": ">=18"
12
15
  },
13
16
  "scripts": {
14
- "build": "tsc -p tsconfig.json",
17
+ "build": "tsc -p tsconfig.build.json",
15
18
  "dev": "tsx src/index.ts",
16
19
  "start": "node dist/index.js",
17
- "typecheck": "tsc -p tsconfig.json --noEmit"
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
18
23
  },
19
24
  "dependencies": {
25
+ "@dbml/core": "10.1.1",
20
26
  "@modelcontextprotocol/sdk": "^1.30.0",
27
+ "ws": "^8.21.3",
28
+ "y-websocket": "^3.1.0",
29
+ "yjs": "^13.6.32",
21
30
  "zod": "^3.25.0"
22
31
  },
23
32
  "devDependencies": {
24
33
  "@types/node": "^22",
34
+ "@types/ws": "^8.18.1",
25
35
  "tsx": "^4.19.0",
26
- "typescript": "^5.6.0"
36
+ "typescript": "^5.6.0",
37
+ "vitest": "^3.2.7"
27
38
  }
28
39
  }