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 +49 -12
- package/dist/client.js +54 -19
- package/dist/config.js +7 -1
- package/dist/core/apply.js +126 -0
- package/dist/core/dbmlToDiagram.js +0 -0
- package/dist/core/ops.js +99 -0
- package/dist/core/seed.js +58 -0
- package/dist/core/snapshot.js +145 -0
- package/dist/core/types/diagram.js +9 -0
- package/dist/core/uuid.js +33 -0
- package/dist/index.js +108 -11
- package/dist/relayPeer.js +99 -0
- package/dist/writeback.js +233 -0
- package/package.json +17 -6
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# db-diagram-tool-mcp
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
[DB Diagram Tool](https://github.com/kai-exnodes) diagrams
|
|
6
|
-
|
|
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)**.
|
|
10
|
-
|
|
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
|
|
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
|
-
- **
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/core/ops.js
ADDED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
? e.
|
|
29
|
-
:
|
|
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.
|
|
4
|
-
"description": "
|
|
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": [
|
|
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
|
}
|