tablewalk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,2840 @@
1
+ /**
2
+ * tablewalk as an MCP server: the database, offered to a coding agent.
3
+ *
4
+ * During development an agent guesses at schema from ORM models and migration
5
+ * files; the actual foreign-key graph — both directions — is exactly what it
6
+ * lacks and exactly what tablewalk already computes. This file is a thin
7
+ * protocol layer over machinery that all exists: introspection, the query
8
+ * language, the reference counts, the shape analysis. Nothing here talks to a
9
+ * database directly.
10
+ *
11
+ * Hand-rolled JSON-RPC rather than a protocol SDK, for the same reason the
12
+ * client has no framework: the surface actually used — initialize, ping,
13
+ * tools/list, tools/call, over newline-delimited stdio — is five methods that
14
+ * have been stable across every protocol revision, and a dependency would be
15
+ * larger than the code it replaced.
16
+ *
17
+ * Read-only by construction. The tool surface has no write path: `sql` never
18
+ * sets the write flag, so it runs against the read-only handle every
19
+ * connection opens with. Every answer is bounded by the same clamp the HTTP
20
+ * API uses, because an agent that asks for everything should get a page and a
21
+ * total, not everything.
22
+ */
23
+ import { createInterface } from 'node:readline';
24
+ import { changeImpact } from './changeimpact.js';
25
+ import { history, lastRevertible, recordWrite, revert, summarise } from './journal.js';
26
+ import { clampLimit } from '../adapters/adapter.js';
27
+ import { findTable, labelPath, primaryKey, referencesFrom, referencesTo, renderDDL, resolveOrder, tableNamed, } from '../shared/schema.js';
28
+ import { distance, explain, parseQuery, quoteValue } from '../shared/query.js';
29
+ import { textToView } from '../shared/viewtext.js';
30
+ import { looksLikeBreakdown, textToBreakdown } from '../shared/breakdowntext.js';
31
+ import { deleteImpact } from './impact.js';
32
+ import { explainWriteFailure } from './constraints.js';
33
+ import { lintCounts, lintSchema } from '../shared/lint.js';
34
+ import { diffSchemas } from '../shared/diff.js';
35
+ import { insertOrder } from '../shared/order.js';
36
+ import { extractFixture } from './fixture.js';
37
+ import { deleteGraph, flattenTables, insertGraph } from './graph.js';
38
+ import { queriesFor } from '../shared/sqlsaved.js';
39
+ import { Refusal } from '../adapters/adapter.js';
40
+ // Plain JS and pure on purpose — the same analysis the landing page says out
41
+ // loud, importable here because it takes a schema and touches nothing else.
42
+ import { describeSchema } from '../client/shape.js';
43
+ // Pure like shape.js, and split out for exactly this reason: the same brief
44
+ // the browser downloads and the CLI prints, offered as ambient context.
45
+ import { toMarkdown } from '../client/schematext.js';
46
+ /** Protocol revisions this server knows. Answered back when the client asks
47
+ for one of them; otherwise the newest we speak, per the spec's rule. */
48
+ const KNOWN_VERSIONS = ['2025-03-26', '2025-06-18', '2025-11-25'];
49
+ const DEFAULT_VERSION = '2025-06-18';
50
+ /* ---------- the tools ---------- */
51
+ /**
52
+ * The hints a client acts on without reading prose: reading is all the read
53
+ * tools do, asking twice is the same as asking once, and the world they touch
54
+ * is the configured database, not the open internet. Declared once and spread
55
+ * — hints that drifted per-tool would lie about exactly the thing they exist
56
+ * to make machine-readable. A client that trusts them can stop asking
57
+ * permission for every look.
58
+ */
59
+ const READS = {
60
+ readOnlyHint: true,
61
+ destructiveHint: false,
62
+ idempotentHint: true,
63
+ openWorldHint: false,
64
+ };
65
+ /** The stanza the dispatch loop appends to every answer, declared once for
66
+ the output schemas below. */
67
+ const STATS = {
68
+ type: 'object',
69
+ description: 'What the call cost: wall ms, and rows read/written/deleted when rows moved.',
70
+ properties: {
71
+ ms: { type: 'number' },
72
+ rowsRead: { type: 'number' },
73
+ rowsWritten: { type: 'number' },
74
+ rowsInserted: { type: 'number' },
75
+ rowsDeleted: { type: 'number' },
76
+ },
77
+ };
78
+ /**
79
+ * Declared data-first: name, what it answers, and the shape of the question.
80
+ * The descriptions are written for a model deciding which tool to call, which
81
+ * is a reader that rewards saying what a thing is *for*. The output schemas
82
+ * describe without demanding — no `required` — because several tools answer
83
+ * in deliberate variants: a query that does not parse returns errors where
84
+ * rows would go, a record that does not exist returns `found: false` and
85
+ * little else, and a schema that failed both would turn a designed answer
86
+ * into a client-side validation error.
87
+ */
88
+ const TOOLS = [
89
+ {
90
+ name: 'connections',
91
+ description: 'List the configured database connections: id, name, dialect, and table count. '
92
+ + 'Call this first when unsure which database to ask about; every other tool takes '
93
+ + 'an optional "connection" (the id) and defaults to the active one.',
94
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
95
+ annotations: { title: 'List connections', ...READS },
96
+ outputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ connections: {
100
+ type: 'array',
101
+ items: {
102
+ type: 'object',
103
+ properties: {
104
+ id: { type: 'string' },
105
+ name: { type: 'string' },
106
+ dialect: { type: 'string' },
107
+ tables: { type: 'number' },
108
+ active: { type: 'boolean', description: 'The default when no connection is named.' },
109
+ writable: { type: 'boolean' },
110
+ },
111
+ },
112
+ },
113
+ stats: STATS,
114
+ },
115
+ },
116
+ },
117
+ {
118
+ name: 'refresh',
119
+ description: 'Re-read a connection\u2019s schema from the database catalog. The schema is introspected '
120
+ + 'once when a connection opens and cached; after running a migration or any DDL, call '
121
+ + 'this so every other tool stops answering from the world before it.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: { connection: { type: 'string' } },
125
+ additionalProperties: false,
126
+ },
127
+ annotations: { title: 'Refresh the schema', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
128
+ outputSchema: {
129
+ type: 'object',
130
+ properties: {
131
+ connection: { type: 'string' },
132
+ tables: { type: 'number' },
133
+ foreignKeys: { type: 'number' },
134
+ readAt: { type: 'string' },
135
+ stats: STATS,
136
+ },
137
+ },
138
+ },
139
+ {
140
+ name: 'schema_summary',
141
+ description: 'Orient in an unfamiliar schema in one call: which tables the domain centres on '
142
+ + '(hubs), which record events, which are join tables, the deepest reference chain, '
143
+ + 'and what stands alone. Derived from the foreign-key graph, so it cannot name a '
144
+ + 'table that does not exist. Start here before reaching for table lists or DDL.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: { connection: { type: 'string', description: 'Connection id; defaults to the active connection.' } },
148
+ additionalProperties: false,
149
+ },
150
+ annotations: { title: 'Orient in the schema', ...READS },
151
+ outputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ connection: { type: 'string' },
155
+ label: { type: 'string' },
156
+ dialect: { type: 'string' },
157
+ tables: { type: 'number' },
158
+ foreignKeys: { type: 'number' },
159
+ centresOn: {
160
+ type: 'array',
161
+ description: 'The hubs the domain is about.',
162
+ items: { type: 'object', properties: { table: { type: 'string' }, pointedAtBy: { type: 'number' } } },
163
+ },
164
+ recordsEventsIn: { type: 'array', items: { type: 'string' } },
165
+ joinTables: {
166
+ type: 'array',
167
+ items: { type: 'object', properties: { table: { type: 'string' }, connects: { type: 'array', items: { type: 'string' } } } },
168
+ },
169
+ deepestWalk: { type: 'array', items: { type: 'string' } },
170
+ standingAlone: { type: 'array', items: { type: 'string' } },
171
+ startAt: { type: 'string' },
172
+ stats: STATS,
173
+ },
174
+ },
175
+ },
176
+ {
177
+ name: 'tables',
178
+ description: 'Every table on a connection: id, column count, approximate rows, and whether it is a view.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: { connection: { type: 'string' } },
182
+ additionalProperties: false,
183
+ },
184
+ annotations: { title: 'List tables', ...READS },
185
+ outputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ connection: { type: 'string' },
189
+ tables: {
190
+ type: 'array',
191
+ items: {
192
+ type: 'object',
193
+ properties: {
194
+ id: { type: 'string' },
195
+ columns: { type: 'number' },
196
+ approxRows: { type: 'number', description: 'An estimate, not a count; views go unestimated.' },
197
+ isView: { type: 'boolean' },
198
+ },
199
+ },
200
+ },
201
+ stats: STATS,
202
+ },
203
+ },
204
+ },
205
+ {
206
+ name: 'table',
207
+ description: 'One table in full: columns with types, nullability, defaults and — where the '
208
+ + 'database pins them — the values a column is allowed to hold, plus the primary '
209
+ + 'key, DDL, and the part not in any ORM file: its foreign keys in BOTH directions, '
210
+ + 'what it points at and every table that points at it, with ON DELETE rules.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ table: { type: 'string', description: 'Table id, e.g. "invoice".' },
215
+ /* Accepted because this tool asked for `name` before every other
216
+ table-scoped tool settled on `table`. `profile`, `lint`,
217
+ `change_impact`, `record` and `fixture` all take `table`, so this
218
+ one was the odd argument out and cost a wasted call to find out.
219
+ `table` is what the schema asks for now; `name` still works. */
220
+ name: { type: 'string', description: 'Deprecated alias for `table`.' },
221
+ connection: { type: 'string' },
222
+ },
223
+ required: ['table'],
224
+ additionalProperties: false,
225
+ },
226
+ annotations: { title: 'One table in full', ...READS },
227
+ outputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ connection: { type: 'string' },
231
+ table: { type: 'string' },
232
+ isView: { type: 'boolean' },
233
+ approxRows: { type: 'number' },
234
+ primaryKey: { type: 'array', items: { type: 'string' } },
235
+ columns: {
236
+ type: 'array',
237
+ items: {
238
+ type: 'object',
239
+ properties: {
240
+ name: { type: 'string' },
241
+ type: { type: 'string' },
242
+ nullable: { type: 'boolean' },
243
+ primaryKey: { type: 'boolean' },
244
+ references: { type: 'object', properties: { table: { type: 'string' }, column: { type: 'string' } } },
245
+ default: { type: 'string' },
246
+ allowed: {
247
+ type: 'array',
248
+ items: { type: 'string' },
249
+ description: 'The values the database accepts here — an enum, or a CHECK the adapter could read.',
250
+ },
251
+ },
252
+ },
253
+ },
254
+ pointsAt: {
255
+ type: 'array',
256
+ items: {
257
+ type: 'object',
258
+ properties: {
259
+ via: { type: 'array', items: { type: 'string' } },
260
+ table: { type: 'string' },
261
+ columns: { type: 'array', items: { type: 'string' } },
262
+ onDelete: { type: 'string' },
263
+ },
264
+ },
265
+ },
266
+ pointedAtBy: {
267
+ type: 'array',
268
+ items: {
269
+ type: 'object',
270
+ properties: {
271
+ table: { type: 'string' },
272
+ via: { type: 'array', items: { type: 'string' } },
273
+ onDelete: { type: 'string' },
274
+ },
275
+ },
276
+ },
277
+ ddl: { type: 'string' },
278
+ link: { type: 'string', description: 'Open this table in the tablewalk UI.' },
279
+ stats: STATS,
280
+ },
281
+ },
282
+ },
283
+ {
284
+ name: 'find',
285
+ description: 'Where something lives. `name` searches table and column names, case-insensitive '
286
+ + 'substring — "which tables carry warehouse_id". `value` searches the data itself: '
287
+ + 'the text columns of every table, plus uuid columns when the value is a whole '
288
+ + 'uuid — "which table mentions Nakamura". Give one or the other, not both.\n'
289
+ + 'The sweep is bounded and says how it ended: `searched` counts tables actually '
290
+ + 'read, `failed` names any it could not read and why, `moreIn` names tables that '
291
+ + 'had more matches than it returned, and `stopped` with `unreached` says when a '
292
+ + 'cap or the time budget ended it rather than the data. No `failed` and no '
293
+ + '`stopped` is the only reading of "not in this database". Matches count as rows read.',
294
+ inputSchema: {
295
+ type: 'object',
296
+ properties: {
297
+ name: { type: 'string', description: 'A name or fragment, matched against table and column names.' },
298
+ value: { type: 'string', description: 'A value, matched against the text columns of every table.' },
299
+ /* `name` and `value` are both optional and mutually exclusive, which
300
+ JSON Schema can say and few clients enforce — the tool refuses in
301
+ words as well. */
302
+ connection: { type: 'string' },
303
+ },
304
+ additionalProperties: false,
305
+ },
306
+ annotations: { title: 'Find a name or a value', ...READS },
307
+ outputSchema: {
308
+ type: 'object',
309
+ properties: {
310
+ connection: { type: 'string' },
311
+ tables: {
312
+ type: 'array',
313
+ items: { type: 'object', properties: { id: { type: 'string' }, columns: { type: 'number' }, isView: { type: 'boolean' } } },
314
+ },
315
+ columns: {
316
+ type: 'array',
317
+ items: {
318
+ type: 'object',
319
+ properties: {
320
+ table: { type: 'string' },
321
+ column: { type: 'string' },
322
+ type: { type: 'string' },
323
+ references: { type: 'object', properties: { table: { type: 'string' }, column: { type: 'string' } } },
324
+ },
325
+ },
326
+ },
327
+ truncated: {
328
+ type: 'object',
329
+ description: 'Name search only: present when the caps cut the lists; the real totals.',
330
+ properties: { tables: { type: 'number' }, columns: { type: 'number' } },
331
+ },
332
+ matches: {
333
+ type: 'array',
334
+ description: 'Value search only: one entry per matching row.',
335
+ items: {
336
+ type: 'object',
337
+ properties: {
338
+ table: { type: 'string' },
339
+ columns: { type: 'array', items: { type: 'string' } },
340
+ key: { type: 'object', description: 'The row key, so the match can be walked to.' },
341
+ values: { type: 'object' },
342
+ },
343
+ },
344
+ },
345
+ searched: { type: 'number', description: 'Tables actually read. Tables that failed are not counted here.' },
346
+ withoutText: { type: 'number', description: 'Tables skipped for having nothing a text search can ask about.' },
347
+ failed: {
348
+ type: 'array',
349
+ description: 'Tables that could not be searched, with the reason. Any entry here means the answer is partial.',
350
+ items: { type: 'object', properties: { table: { type: 'string' }, reason: { type: 'string' } } },
351
+ },
352
+ moreIn: {
353
+ type: 'array',
354
+ description: 'Tables with more matches than were returned.',
355
+ items: { type: 'string' },
356
+ },
357
+ stopped: {
358
+ type: 'string',
359
+ description: 'Set when a cap or the time budget ended the sweep rather than the data.',
360
+ },
361
+ unreached: { type: 'number', description: 'Tables never looked at, because the sweep stopped.' },
362
+ stats: STATS,
363
+ },
364
+ },
365
+ },
366
+ {
367
+ name: 'query',
368
+ description: 'Run a query in tablewalk’s query language and get rows, an exact-ish total, and '
369
+ + 'the SQL it compiled to. The language: table first, then filters — '
370
+ + '`invoice total >= 500 and status != paid sort total desc limit 20`. Operators: '
371
+ + '= != < <= > >= contains startswith endswith in, `is empty`/`is not empty`; dates as '
372
+ + 'phrases (`invoice_date = last 30 days`); dotted paths walk references '
373
+ + '(`invoice customer_id.name contains Acme show id, total, customer_id.name`). '
374
+ + 'Aggregate clauses summarise children onto each row: '
375
+ + '`customer show name count invoice via customer_id as invoices sum invoice.total via customer_id as revenue` '
376
+ + '— count/sum/min/max/avg, `via` names the child\u2019s reference column, and `sort invoices desc` '
377
+ + 'sorts by the alias. Filtering or projecting by an alias is not supported and says so. '
378
+ + 'Errors come back with suggestions — a misspelled column names its nearest match, and '
379
+ + 'an unknown column answers with the table\u2019s real columns — so fix and retry. Prefer '
380
+ + '`show` on wide tables: sixteen columns of every row is tokens spent on nothing. '
381
+ + 'Prefer this over `sql`: every value is bound, never spliced.',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {
385
+ q: { type: 'string', description: 'The query text, starting with a table name.' },
386
+ limit: { type: 'number', description: 'Rows to return (default 20, clamped).' },
387
+ offset: { type: 'number' },
388
+ connection: { type: 'string' },
389
+ },
390
+ required: ['q'],
391
+ additionalProperties: false,
392
+ },
393
+ annotations: { title: 'Query rows', ...READS },
394
+ outputSchema: {
395
+ type: 'object',
396
+ properties: {
397
+ connection: { type: 'string' },
398
+ rows: { type: 'array', items: { type: 'object' } },
399
+ columns: { type: 'array', items: { type: 'string' } },
400
+ total: { type: 'number', description: 'Rows matching the filter, not just the page.' },
401
+ offset: { type: 'number' },
402
+ sql: { type: 'object', description: 'What actually ran: text and bound params.' },
403
+ explain: { type: 'string', description: 'The query, said back in words.' },
404
+ link: { type: 'string', description: 'Open this query in the tablewalk UI.' },
405
+ errors: {
406
+ type: 'array',
407
+ description: 'A query that did not parse: messages with suggestions, in place of rows.',
408
+ items: { type: 'string' },
409
+ },
410
+ stats: STATS,
411
+ },
412
+ },
413
+ },
414
+ {
415
+ name: 'breakdown',
416
+ description: 'Counts and sums, grouped by something — the GROUP BY question, in the same language '
417
+ + 'and with the same guarantees as `query`. Measures first, then `by`, then what to '
418
+ + 'group by: `invoice count by status`, `invoice count, sum total as billed by month '
419
+ + 'invoice_date sort billed desc limit 12`. Measures are count / sum / avg / min / max, '
420
+ + 'plus `count distinct <column>`; a bare `count` counts rows, `count <column>` skips '
421
+ + 'its nulls. Group keys walk references like any path — `by customer_id.country_code.name` '
422
+ + '— and a date column can be cut to a period with `by day|month|quarter|year <column>`, '
423
+ + 'which comes back as a label (`2026-08`) that sorts correctly as text. `where`, `sort` '
424
+ + 'and `limit` work as they do in `query`, except that `sort` names a column of the '
425
+ + 'answer rather than a path. Reach for this before `sql`: every value is bound, the '
426
+ + 'paths are checked against the catalog, and a mistake comes back as a message rather '
427
+ + 'than a database exception.',
428
+ inputSchema: {
429
+ type: 'object',
430
+ properties: {
431
+ q: {
432
+ type: 'string',
433
+ description: 'The breakdown, starting with a table name. `invoice count by status`.',
434
+ },
435
+ limit: { type: 'number', description: 'Groups to return (default 20, clamped).' },
436
+ offset: { type: 'number' },
437
+ connection: { type: 'string' },
438
+ },
439
+ required: ['q'],
440
+ additionalProperties: false,
441
+ },
442
+ annotations: { title: 'Group and count', ...READS },
443
+ outputSchema: {
444
+ type: 'object',
445
+ properties: {
446
+ connection: { type: 'string' },
447
+ rows: { type: 'array', items: { type: 'object' }, description: 'One row per group.' },
448
+ columns: { type: 'array', items: { type: 'string' } },
449
+ resolved: {
450
+ type: 'array',
451
+ description: 'Of each column: whether it is a group key or a measure, and of what.',
452
+ items: {
453
+ type: 'object',
454
+ properties: {
455
+ name: { type: 'string' }, kind: { type: 'string' },
456
+ fn: { type: 'string' }, path: { type: 'string' }, type: { type: 'string' },
457
+ },
458
+ },
459
+ },
460
+ total: { type: 'number', description: 'How many groups there are, not just the page.' },
461
+ offset: { type: 'number' },
462
+ sql: { type: 'object', description: 'What actually ran: text and bound params.' },
463
+ errors: { type: 'array', items: { type: 'string' } },
464
+ stats: STATS,
465
+ },
466
+ },
467
+ },
468
+ {
469
+ name: 'sql',
470
+ description: 'Run a read-only SQL SELECT when the query language cannot say it — window functions, '
471
+ + 'CTEs, self-joins. Ordinary grouping is `breakdown` now, and that is the one to reach '
472
+ + 'for first: it binds every value and checks every path against the catalog, where this '
473
+ + 'hands the database text it did not build. The connection is read-only: writes fail at the database. '
474
+ + 'Results are row-limited; say LIMIT yourself for anything else. '
475
+ + '`timeout_ms` lowers the cap for this statement — honoured on Postgres; SQLite runs '
476
+ + 'in-process and cannot be interrupted. Probing five query shapes wants fast failure.',
477
+ inputSchema: {
478
+ type: 'object',
479
+ properties: {
480
+ statement: { type: 'string' },
481
+ limit: { type: 'number' },
482
+ timeout_ms: { type: 'number', description: 'Lower the statement cap, ms. Floor 100; never raises the session cap.' },
483
+ connection: { type: 'string' },
484
+ },
485
+ required: ['statement'],
486
+ additionalProperties: false,
487
+ },
488
+ annotations: { title: 'Read-only SQL', ...READS },
489
+ outputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ connection: { type: 'string' },
493
+ columns: { type: 'array', items: { type: 'string' } },
494
+ rows: { type: 'array', items: { type: 'object' } },
495
+ truncated: { type: 'boolean' },
496
+ errors: { type: 'array', description: 'The database’s words, when it refused.', items: { type: 'string' } },
497
+ sql: { type: 'object' },
498
+ stats: STATS,
499
+ },
500
+ },
501
+ },
502
+ {
503
+ name: 'explain',
504
+ description: 'The planner’s account of how a statement would run, without running it — the '
505
+ + '"does this use the index or walk the table" answer `sql` cannot give, because its '
506
+ + 'bounding wrap changes the plan. Feed it the `sql` a `query` answer returned — text '
507
+ + 'and params both, placeholders included — or any statement of your own. Plans only, '
508
+ + 'never executes: the analysing variants that run the statement are refused by '
509
+ + 'construction, so explaining a write is safe and tells you what the write would do '
510
+ + 'to the indexes.',
511
+ inputSchema: {
512
+ type: 'object',
513
+ properties: {
514
+ statement: { type: 'string', description: 'The SQL to explain, placeholders allowed.' },
515
+ params: {
516
+ type: 'array',
517
+ description: 'Bound values for the placeholders, in order — a `query` answer’s `sql.params`, verbatim.',
518
+ items: { type: ['string', 'number', 'boolean', 'null'] },
519
+ },
520
+ connection: { type: 'string' },
521
+ },
522
+ required: ['statement'],
523
+ additionalProperties: false,
524
+ },
525
+ annotations: { title: 'Explain a statement', ...READS },
526
+ outputSchema: {
527
+ type: 'object',
528
+ properties: {
529
+ connection: { type: 'string' },
530
+ plan: {
531
+ type: 'array',
532
+ description: 'One line per plan node, indented as the tree nests, in the engine’s own words.',
533
+ items: { type: 'string' },
534
+ },
535
+ stats: STATS,
536
+ },
537
+ },
538
+ },
539
+ {
540
+ name: 'profile',
541
+ description: 'What a table’s columns actually hold: null share, distinct count, range, and the '
542
+ + 'values that repeat most — the look before writing a filter, so `status = closed` '
543
+ + 'is a lookup, not a guess. On Postgres this reads the planner’s own statistics and '
544
+ + 'costs nothing; elsewhere it scans, and `source` says which happened, so the numbers '
545
+ + 'carry their own credibility.',
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ table: { type: 'string', description: 'Table id, e.g. "invoice".' },
550
+ connection: { type: 'string' },
551
+ },
552
+ required: ['table'],
553
+ additionalProperties: false,
554
+ },
555
+ annotations: { title: 'Profile a table', ...READS },
556
+ outputSchema: {
557
+ type: 'object',
558
+ properties: {
559
+ connection: { type: 'string' },
560
+ table: { type: 'string' },
561
+ rows: { type: 'number', description: 'Estimated under `statistics`, exact otherwise.' },
562
+ sampled: { type: 'number', description: 'Rows the fractions are relative to, when `source` is sample.' },
563
+ source: {
564
+ type: 'string',
565
+ enum: ['statistics', 'scan'],
566
+ description: 'statistics: the planner’s kept numbers, sample-based and free. scan: computed by reading the table. sample: a bounded slice of a table too large to scan.',
567
+ },
568
+ columns: {
569
+ type: 'array',
570
+ items: {
571
+ type: 'object',
572
+ properties: {
573
+ column: { type: 'string' },
574
+ type: { type: 'string' },
575
+ nullFrac: { type: 'number', description: 'Share of rows where this column is null, 0..1.' },
576
+ distinct: { type: 'number' },
577
+ min: {},
578
+ max: {},
579
+ common: {
580
+ type: 'array',
581
+ description: 'The values that repeat most, each with its share of rows. Absent when nothing repeats.',
582
+ items: { type: 'object', properties: { value: {}, frac: { type: 'number' } } },
583
+ },
584
+ },
585
+ },
586
+ },
587
+ link: { type: 'string' },
588
+ stats: STATS,
589
+ },
590
+ },
591
+ },
592
+ {
593
+ name: 'diff',
594
+ description: 'Two connections, and what is different about their shape: tables on one side only, '
595
+ + 'columns added or dropped, types, nullability, defaults and allowed values that '
596
+ + 'changed, foreign keys gained or lost, and delete rules that now do something else. '
597
+ + 'Compares the model rather than two dumps, so formatting is never a difference and a '
598
+ + 'column that became nullable always is. Answers "is staging the same shape as '
599
+ + 'production" and "what did that migration actually change".',
600
+ inputSchema: {
601
+ type: 'object',
602
+ properties: {
603
+ from: { type: 'string', description: 'Connection id you have. Defaults to the active one.' },
604
+ to: { type: 'string', description: 'Connection id to compare against.' },
605
+ },
606
+ required: ['to'],
607
+ additionalProperties: false,
608
+ },
609
+ annotations: { title: 'Compare two schemas', ...READS },
610
+ outputSchema: {
611
+ type: 'object',
612
+ properties: {
613
+ from: { type: 'string' },
614
+ to: { type: 'string' },
615
+ same: { type: 'boolean', description: 'True when the two describe the same shape.' },
616
+ tables: {
617
+ type: 'array',
618
+ items: {
619
+ type: 'object',
620
+ properties: {
621
+ table: { type: 'string' },
622
+ onlyIn: { type: 'string', description: '"left" is `from`, "right" is `to`.' },
623
+ columnsOnlyLeft: { type: 'array', items: { type: 'string' } },
624
+ columnsOnlyRight: { type: 'array', items: { type: 'string' } },
625
+ changed: {
626
+ type: 'array',
627
+ items: {
628
+ type: 'object',
629
+ properties: {
630
+ column: { type: 'string' }, what: { type: 'string' },
631
+ left: { type: 'string' }, right: { type: 'string' },
632
+ },
633
+ },
634
+ },
635
+ keysOnlyLeft: { type: 'array', items: { type: 'string' } },
636
+ keysOnlyRight: { type: 'array', items: { type: 'string' } },
637
+ deleteRuleChanged: { type: 'array', items: { type: 'object' } },
638
+ },
639
+ },
640
+ },
641
+ stats: STATS,
642
+ },
643
+ },
644
+ },
645
+ {
646
+ name: 'order',
647
+ description: 'What has to exist first. Every foreign key is a statement about time — a row '
648
+ + 'cannot point at a row that is not there yet — so this answers the order to insert '
649
+ + 'in, the reverse order to delete in, and what each table requires with the columns '
650
+ + 'that require it. Cycles are named rather than broken arbitrarily, along with the '
651
+ + 'nullable reference that is the way through one; a table that points at itself is '
652
+ + 'reported separately, because it is an order inside the table rather than between '
653
+ + 'tables. Building fixtures, tearing them down, and planning a migration all ask '
654
+ + 'this same question.',
655
+ inputSchema: {
656
+ type: 'object',
657
+ properties: {
658
+ tables: {
659
+ type: 'array',
660
+ items: { type: 'string' },
661
+ description: 'The tables to order. Omit for every table in the schema.',
662
+ },
663
+ connection: { type: 'string' },
664
+ },
665
+ additionalProperties: false,
666
+ },
667
+ annotations: { title: 'What has to exist first', ...READS },
668
+ outputSchema: {
669
+ type: 'object',
670
+ properties: {
671
+ connection: { type: 'string' },
672
+ insert: { type: 'array', items: { type: 'string' }, description: 'Parents first.' },
673
+ remove: { type: 'array', items: { type: 'string' }, description: 'Children first.' },
674
+ requires: {
675
+ type: 'object',
676
+ description: 'Table id → what must exist before it, with the columns and whether it can wait.',
677
+ },
678
+ cycles: {
679
+ type: 'array',
680
+ items: { type: 'array', items: { type: 'string' } },
681
+ description: 'Tables with no order between them. Present only when there are any.',
682
+ },
683
+ selfReferencing: {
684
+ type: 'array',
685
+ items: { type: 'string' },
686
+ description: 'Tables whose rows have an order among themselves.',
687
+ },
688
+ stats: STATS,
689
+ },
690
+ },
691
+ },
692
+ {
693
+ name: 'fixture',
694
+ description: 'One real row, and everything it needs to exist. Walks the references away from a '
695
+ + 'row — its customer, that customer\u2019s country — and optionally the rows pointing '
696
+ + 'at it, then returns them grouped by table in the order they must be inserted, plus '
697
+ + 'ready-to-paste INSERT statements. The rule the output satisfies: inserted in the '
698
+ + 'order given, nothing dangles. Bounded, and it says what it cut. This is test data '
699
+ + 'taken from a database that already has the shape you need, rather than invented.',
700
+ inputSchema: {
701
+ type: 'object',
702
+ properties: {
703
+ table: { type: 'string' },
704
+ key: { type: 'object', description: 'The row to start from: {"id": 1885}.' },
705
+ children: {
706
+ type: 'boolean',
707
+ description: 'Also take rows that point at it, with their own references resolved.',
708
+ },
709
+ depth: { type: 'number', description: 'Reference hops to follow. Default 3.' },
710
+ per_child: { type: 'number', description: 'Rows per child relationship. Default 5.' },
711
+ max: { type: 'number', description: 'Ceiling on the whole fixture. Default 100.' },
712
+ connection: { type: 'string' },
713
+ },
714
+ required: ['table', 'key'],
715
+ additionalProperties: false,
716
+ },
717
+ annotations: { title: 'Take a row as a fixture', ...READS },
718
+ outputSchema: {
719
+ type: 'object',
720
+ properties: {
721
+ connection: { type: 'string' },
722
+ table: { type: 'string' },
723
+ key: { type: 'object' },
724
+ tables: {
725
+ type: 'array',
726
+ description: 'Tables in insert order, each with its rows.',
727
+ items: {
728
+ type: 'object',
729
+ properties: {
730
+ table: { type: 'string' },
731
+ rows: { type: 'array', items: { type: 'object' } },
732
+ },
733
+ },
734
+ },
735
+ order: { type: 'array', items: { type: 'string' } },
736
+ sql: { type: 'string', description: 'The same rows as INSERT statements, in order.' },
737
+ truncated: {
738
+ type: 'array',
739
+ items: { type: 'string' },
740
+ description: 'What was left out. Absent when nothing was.',
741
+ },
742
+ stats: STATS,
743
+ },
744
+ },
745
+ },
746
+ {
747
+ name: 'lint',
748
+ description: 'What this shape will cost: tables with no primary key (no row can be linked to or '
749
+ + 'edited), foreign keys with no index leading on them (every walk back is a full '
750
+ + 'scan, and so is deleting a parent), references with no ON DELETE rule, and '
751
+ + 'timestamps with no time zone. Catalog facts only — no queries, no guesses about '
752
+ + 'intent. Useful before a migration, and before writing code against a table you '
753
+ + 'have not met. Summary-first: complete counts, the rule explanations once each in '
754
+ + '`rules`, and at most five example findings per rule — `all: true`, `table` or '
755
+ + '`severity` for the rest.',
756
+ inputSchema: {
757
+ type: 'object',
758
+ properties: {
759
+ table: { type: 'string', description: 'Only this table. Omit for the whole schema.' },
760
+ severity: {
761
+ type: 'string',
762
+ enum: ['high', 'medium', 'low'],
763
+ description: 'At least this severe. Default: everything.',
764
+ },
765
+ all: {
766
+ type: 'boolean',
767
+ description: 'Every finding. Default is at most 5 per rule, which is what "what kind of trouble '
768
+ + 'is this schema in" needs — the counts are always complete either way.',
769
+ },
770
+ connection: { type: 'string' },
771
+ },
772
+ additionalProperties: false,
773
+ },
774
+ annotations: { title: 'Lint the shape', ...READS },
775
+ outputSchema: {
776
+ type: 'object',
777
+ properties: {
778
+ connection: { type: 'string' },
779
+ counts: {
780
+ type: 'object',
781
+ properties: { high: { type: 'number' }, medium: { type: 'number' }, low: { type: 'number' } },
782
+ },
783
+ rules: {
784
+ type: 'object',
785
+ description: 'Each rule that fired, once: its severity and what it costs. Findings name their '
786
+ + 'rule rather than repeating this.',
787
+ additionalProperties: {
788
+ type: 'object',
789
+ properties: { severity: { type: 'string' }, why: { type: 'string' } },
790
+ },
791
+ },
792
+ total: { type: 'number', description: 'Findings matching the filters.' },
793
+ shown: { type: 'number', description: 'How many are listed below.' },
794
+ note: { type: 'string', description: 'Present when findings were withheld, and how to get them.' },
795
+ findings: {
796
+ type: 'array',
797
+ items: {
798
+ type: 'object',
799
+ properties: {
800
+ rule: { type: 'string', description: 'Look it up in `rules` for what it costs.' },
801
+ severity: { type: 'string' },
802
+ table: { type: 'string' },
803
+ columns: { type: 'array', items: { type: 'string' } },
804
+ message: { type: 'string', description: 'What is true of this table.' },
805
+ },
806
+ },
807
+ },
808
+ stats: STATS,
809
+ },
810
+ },
811
+ },
812
+ {
813
+ name: 'change_impact',
814
+ description: 'What breaks if you change this table or column — asked BEFORE writing the migration. '
815
+ + 'Every table pointing at it with its ON DELETE rule, whether those referencing columns '
816
+ + 'are NOT NULL (so their rows cannot be left pointing at nothing), whether an index leads '
817
+ + 'on them (so whether each constraint check is a scan), and how many rows each holds. '
818
+ + 'For a named column: its type, default, allowed values, and — the one that decides '
819
+ + 'whether an ALTER succeeds — how many rows actually hold null. Answers "can I add NOT '
820
+ + 'NULL to this", "what does dropping this cascade into", "why is this migration slow". '
821
+ + 'These are the facts that live in the database rather than the repository, so grep '
822
+ + 'cannot answer any of them. Read-only: it never alters anything.',
823
+ inputSchema: {
824
+ type: 'object',
825
+ properties: {
826
+ table: { type: 'string', description: 'The table being changed.' },
827
+ column: {
828
+ type: 'string',
829
+ description: 'Narrow to one column. Omit to ask about the whole table.',
830
+ },
831
+ connection: { type: 'string' },
832
+ },
833
+ required: ['table'],
834
+ additionalProperties: false,
835
+ },
836
+ annotations: { title: 'What a change would break', ...READS },
837
+ outputSchema: {
838
+ type: 'object',
839
+ properties: {
840
+ table: { type: 'string' },
841
+ column: { type: 'string' },
842
+ rows: { type: 'number', description: 'Rows in the table being changed.' },
843
+ columnFacts: {
844
+ type: 'object',
845
+ properties: {
846
+ name: { type: 'string' },
847
+ type: { type: 'string' },
848
+ nullable: { type: 'boolean' },
849
+ primaryKey: { type: 'boolean' },
850
+ default: { type: 'string' },
851
+ allowed: { type: 'array', items: { type: 'string' } },
852
+ references: { type: 'object' },
853
+ indexed: { type: 'boolean', description: 'Absent when the adapter does not report indexes.' },
854
+ nulls: { type: 'number', description: 'Rows holding null. Counted only where it can be non-zero.' },
855
+ },
856
+ },
857
+ referencedBy: {
858
+ type: 'array',
859
+ items: {
860
+ type: 'object',
861
+ properties: {
862
+ constraint: { type: 'string' },
863
+ table: { type: 'string' },
864
+ columns: { type: 'array', items: { type: 'string' } },
865
+ toColumns: { type: 'array', items: { type: 'string' } },
866
+ onDelete: { type: 'string', description: 'Absent is "not reported", not "no action".' },
867
+ nullable: { type: 'boolean' },
868
+ indexed: { type: 'boolean' },
869
+ rows: { type: 'number' },
870
+ },
871
+ },
872
+ },
873
+ references: { type: 'array', items: { type: 'object' } },
874
+ notes: {
875
+ type: 'array',
876
+ items: { type: 'string' },
877
+ description: 'The consequences in sentences, worst first. Every claim is checkable against the fields.',
878
+ },
879
+ stats: STATS,
880
+ },
881
+ },
882
+ },
883
+ {
884
+ name: 'record',
885
+ description: 'One row and everything about it: the fields, the row’s human name (resolved through '
886
+ + 'identity references, e.g. an employee named by its party), and every table that points '
887
+ + 'at this row with a count — the reverse walk no SQL client surfaces. '
888
+ + 'Key values: `{"id": 42}`, or every column of a composite key. '
889
+ + 'Several records in one call with `keys: [{id: 1}, {id: 2}]` (up to 20); '
890
+ + '`expand: 3` inlines the first rows of each non-empty relationship, so record-then-'
891
+ + 'children stops costing a call per relationship.',
892
+ inputSchema: {
893
+ type: 'object',
894
+ properties: {
895
+ table: { type: 'string' },
896
+ key: { type: 'object', additionalProperties: { type: ['string', 'number'] } },
897
+ keys: {
898
+ type: 'array',
899
+ description: 'Several keys at once; answers arrive as `records`, in this order.',
900
+ items: { type: 'object', additionalProperties: { type: ['string', 'number'] } },
901
+ },
902
+ expand: { type: 'number', description: 'Inline up to this many rows per non-empty relationship (max 10).' },
903
+ connection: { type: 'string' },
904
+ },
905
+ required: ['table'],
906
+ additionalProperties: false,
907
+ },
908
+ annotations: { title: 'One row, walked', ...READS },
909
+ outputSchema: {
910
+ type: 'object',
911
+ properties: {
912
+ connection: { type: 'string' },
913
+ table: { type: 'string' },
914
+ key: { type: 'object' },
915
+ found: { type: 'boolean', description: 'false is an answer: nothing lives at this key.' },
916
+ label: { type: 'string', description: 'The row’s human name, resolved through identity references.' },
917
+ row: { type: 'object' },
918
+ pointedAtBy: {
919
+ type: 'array',
920
+ items: {
921
+ type: 'object',
922
+ properties: {
923
+ table: { type: 'string' },
924
+ via: { type: 'array', items: { type: 'string' } },
925
+ count: { type: 'number' },
926
+ query: { type: 'string', description: 'Feed back to `query` verbatim to see the rows behind the count.' },
927
+ },
928
+ },
929
+ },
930
+ link: { type: 'string', description: 'Open this record in the tablewalk UI.' },
931
+ stats: STATS,
932
+ },
933
+ },
934
+ },
935
+ ];
936
+ /**
937
+ * Offered only when some connection's config says `"writable": true` — the
938
+ * same opt-in the browser's write mode rides on. A server over read-only
939
+ * connections does not list tools it would refuse, because a tool that
940
+ * always errors teaches the model to stop trusting the list.
941
+ */
942
+ const WRITE_TOOLS = [
943
+ {
944
+ name: 'update',
945
+ description: 'Set columns on exactly one row, identified by its whole primary key: '
946
+ + '`{table, key: {id: 7}, set: {status: "closed", priority: 2}}`. Several columns go '
947
+ + 'in one statement — three fields as three calls is three chances to fail half way. '
948
+ + 'The connection must be marked writable in tablewalk.json; values are bound, coerced '
949
+ + 'to their column types, and echoed back as applied.',
950
+ inputSchema: {
951
+ type: 'object',
952
+ properties: {
953
+ table: { type: 'string' },
954
+ key: { type: 'object', additionalProperties: { type: ['string', 'number'] } },
955
+ set: {
956
+ type: 'object',
957
+ description: 'Column name to new value. null clears a nullable column.',
958
+ additionalProperties: { type: ['string', 'number', 'boolean', 'null'] },
959
+ },
960
+ connection: { type: 'string' },
961
+ },
962
+ required: ['table', 'key', 'set'],
963
+ additionalProperties: false,
964
+ },
965
+ /* destructiveHint is true because it is: the value overwritten is gone.
966
+ Idempotent, though — the same call landing twice leaves the same row. */
967
+ annotations: { title: 'Update one row', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
968
+ outputSchema: {
969
+ type: 'object',
970
+ properties: {
971
+ connection: { type: 'string' },
972
+ table: { type: 'string' },
973
+ key: { type: 'object' },
974
+ affected: { type: 'number' },
975
+ applied: { type: 'object', description: 'The values as the column types coerced them.' },
976
+ sql: { type: 'object' },
977
+ link: { type: 'string' },
978
+ stats: STATS,
979
+ },
980
+ },
981
+ },
982
+ {
983
+ name: 'insert',
984
+ description: 'Add one row: `{table, values: {status: "open", customer_id: 7}}`. Leave out what '
985
+ + 'the database should fill — defaults and auto-assigned keys — and it answers with '
986
+ + 'the row as stored, defaults included, plus its new key. A constraint the database '
987
+ + 'refuses comes back in its own words as `errors`; fix and retry. The connection '
988
+ + 'must be marked writable in tablewalk.json.',
989
+ inputSchema: {
990
+ type: 'object',
991
+ properties: {
992
+ table: { type: 'string' },
993
+ values: {
994
+ type: 'object',
995
+ description: 'Column name to value for the new row.',
996
+ additionalProperties: { type: ['string', 'number', 'boolean', 'null'] },
997
+ },
998
+ connection: { type: 'string' },
999
+ },
1000
+ required: ['table', 'values'],
1001
+ additionalProperties: false,
1002
+ },
1003
+ /* Not destructive: an insert adds and destroys nothing — the hint a
1004
+ client uses to decide how loudly to ask. Not idempotent either: the
1005
+ same call twice is two rows, or one row and a UNIQUE refusal. */
1006
+ annotations: { title: 'Insert one row', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1007
+ outputSchema: {
1008
+ type: 'object',
1009
+ properties: {
1010
+ connection: { type: 'string' },
1011
+ table: { type: 'string' },
1012
+ inserted: { type: 'boolean' },
1013
+ key: { type: 'object', description: 'The new row\'s primary key, read back from the database.' },
1014
+ row: { type: 'object', description: 'The row as stored — defaults filled, types settled.' },
1015
+ sql: { type: 'object' },
1016
+ errors: { type: 'array', description: 'The database\'s own words for a constraint it refused.' },
1017
+ link: { type: 'string' },
1018
+ stats: STATS,
1019
+ },
1020
+ },
1021
+ },
1022
+ {
1023
+ name: 'delete',
1024
+ description: 'Remove one row — in two calls, deliberately. Without `confirm: true` this only '
1025
+ + 'reports the impact: every table pointing at the row, with counts and ON DELETE '
1026
+ + 'rules, because the same three referencing rows are three deletions under cascade, '
1027
+ + 'three holes under set null, and a refusal under restrict. Read the impact, then '
1028
+ + 'call again with `confirm: true`. The connection must be marked writable.',
1029
+ inputSchema: {
1030
+ type: 'object',
1031
+ properties: {
1032
+ table: { type: 'string' },
1033
+ key: { type: 'object', additionalProperties: { type: ['string', 'number'] } },
1034
+ confirm: { type: 'boolean', description: 'Actually delete. Absent means report only.' },
1035
+ connection: { type: 'string' },
1036
+ },
1037
+ required: ['table', 'key'],
1038
+ additionalProperties: false,
1039
+ },
1040
+ annotations: { title: 'Delete one row', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1041
+ outputSchema: {
1042
+ type: 'object',
1043
+ properties: {
1044
+ connection: { type: 'string' },
1045
+ table: { type: 'string' },
1046
+ key: { type: 'object' },
1047
+ deleted: { type: 'boolean' },
1048
+ impact: {
1049
+ type: 'array',
1050
+ description: 'Every table pointing at the row, with counts and ON DELETE rules.',
1051
+ items: {
1052
+ type: 'object',
1053
+ properties: { table: { type: 'string' }, count: { type: 'number' }, onDelete: { type: 'string' } },
1054
+ },
1055
+ },
1056
+ affected: { type: 'number' },
1057
+ note: { type: 'string' },
1058
+ errors: { type: 'array', items: { type: 'string' } },
1059
+ stats: STATS,
1060
+ },
1061
+ },
1062
+ },
1063
+ {
1064
+ name: 'insert_graph',
1065
+ description: 'Insert several related rows as one thing, in one transaction: either every row '
1066
+ + 'lands or none does. This is how to create test data — a fixture is a graph, and '
1067
+ + 'ten separate `insert` calls that fail at the seventh leave six rows and a dirty '
1068
+ + 'database. Rows go in the order given; ask `order` if you are unsure what it is. '
1069
+ + 'A row that does not know its parent\u2019s key names it by label instead: give the '
1070
+ + 'parent `as: "c1"` and write `{"customer_id": {"ref": "c1"}}` on the child — that '
1071
+ + 'resolves to the parent\u2019s new primary key, or to `{"ref": "c1", "column": "code"}` '
1072
+ + 'for any other column of the row as stored. `fixture`\u2019s `tables` can be passed '
1073
+ + 'straight through as `tables`. The answer carries a `teardown` list, in reverse '
1074
+ + 'order, ready for `delete_graph`. The connection must be marked writable.',
1075
+ inputSchema: {
1076
+ type: 'object',
1077
+ properties: {
1078
+ rows: {
1079
+ type: 'array',
1080
+ description: 'The rows, in the order they must be inserted.',
1081
+ items: {
1082
+ type: 'object',
1083
+ properties: {
1084
+ table: { type: 'string' },
1085
+ as: { type: 'string', description: 'A label, so a later row can point at this one.' },
1086
+ values: {
1087
+ type: 'object',
1088
+ description: 'Column to value. A value may be {"ref": "label"} or {"ref": "label", "column": "name"}.',
1089
+ },
1090
+ },
1091
+ required: ['table', 'values'],
1092
+ },
1093
+ },
1094
+ tables: {
1095
+ type: 'array',
1096
+ description: '`fixture`\u2019s shape — [{table, rows: [...]}] — flattened in the order given.',
1097
+ items: {
1098
+ type: 'object',
1099
+ properties: { table: { type: 'string' }, rows: { type: 'array', items: { type: 'object' } } },
1100
+ required: ['table', 'rows'],
1101
+ },
1102
+ },
1103
+ connection: { type: 'string' },
1104
+ },
1105
+ additionalProperties: false,
1106
+ },
1107
+ /* Not destructive — it only adds — and emphatically not idempotent: the
1108
+ same call twice is a second copy of the whole graph. */
1109
+ annotations: { title: 'Insert a graph of rows', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1110
+ outputSchema: {
1111
+ type: 'object',
1112
+ properties: {
1113
+ connection: { type: 'string' },
1114
+ inserted: {
1115
+ type: 'array',
1116
+ description: 'Each row as stored, with its label and new key.',
1117
+ items: {
1118
+ type: 'object',
1119
+ properties: {
1120
+ table: { type: 'string' }, as: { type: 'string' },
1121
+ key: { type: 'object' }, row: { type: 'object' },
1122
+ },
1123
+ },
1124
+ },
1125
+ teardown: {
1126
+ type: 'array',
1127
+ description: 'The same rows reversed — children first — for `delete_graph`.',
1128
+ items: { type: 'object', properties: { table: { type: 'string' }, key: { type: 'object' } } },
1129
+ },
1130
+ errors: { type: 'array', items: { type: 'string' } },
1131
+ stats: STATS,
1132
+ },
1133
+ },
1134
+ },
1135
+ {
1136
+ name: 'delete_graph',
1137
+ description: 'Remove several rows as one thing, in one transaction, in the order given — which '
1138
+ + 'is children before parents. Hand back the `teardown` list from `insert_graph` and '
1139
+ + 'the fixture is gone; a teardown that half-succeeded would leave exactly the mess '
1140
+ + 'the insert was careful not to. Each row still needs its whole primary key, and a '
1141
+ + 'key matching anything other than one row is refused. Two calls, like `delete`: '
1142
+ + 'without `confirm: true` it reports what points at every row and removes nothing. '
1143
+ + 'The rows are ones the caller listed, but what cascades off them is not — that is '
1144
+ + 'the part worth seeing before a transaction takes it all at once. The connection '
1145
+ + 'must be marked writable.',
1146
+ inputSchema: {
1147
+ type: 'object',
1148
+ properties: {
1149
+ rows: {
1150
+ type: 'array',
1151
+ description: 'The rows to remove, in the order they must go.',
1152
+ items: {
1153
+ type: 'object',
1154
+ properties: {
1155
+ table: { type: 'string' },
1156
+ key: { type: 'object', additionalProperties: { type: ['string', 'number'] } },
1157
+ },
1158
+ required: ['table', 'key'],
1159
+ },
1160
+ },
1161
+ confirm: { type: 'boolean', description: 'Actually delete. Absent means report only.' },
1162
+ connection: { type: 'string' },
1163
+ },
1164
+ required: ['rows'],
1165
+ additionalProperties: false,
1166
+ },
1167
+ annotations: { title: 'Delete a graph of rows', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1168
+ outputSchema: {
1169
+ type: 'object',
1170
+ properties: {
1171
+ connection: { type: 'string' },
1172
+ removed: {
1173
+ type: 'array',
1174
+ items: {
1175
+ type: 'object',
1176
+ properties: { table: { type: 'string' }, key: { type: 'object' }, affected: { type: 'number' } },
1177
+ },
1178
+ },
1179
+ errors: { type: 'array', items: { type: 'string' } },
1180
+ stats: STATS,
1181
+ },
1182
+ },
1183
+ },
1184
+ {
1185
+ name: 'revert',
1186
+ description: 'Undo the last write, or see what there is to undo. Without `confirm: true` it lists '
1187
+ + 'this session\u2019s writes, newest first, and changes nothing. A transaction protects a '
1188
+ + 'call from failing halfway; nothing protected you from a call that succeeded and '
1189
+ + 'should not have, which is the one that actually happens. Every step is re-read '
1190
+ + 'before anything is written and the whole revert is refused if a row has moved on '
1191
+ + 'since — putting an old value back over a newer one is not an undo. In-session '
1192
+ + 'only: a stack that outlives the process invites undoing something from an hour ago, '
1193
+ + 'by which time the row has moved on and the undo is an overwrite with a friendly '
1194
+ + 'name. Raw SQL writes are listed and cannot be undone — arbitrary SQL has no general '
1195
+ + 'inverse.',
1196
+ inputSchema: {
1197
+ type: 'object',
1198
+ properties: {
1199
+ confirm: { type: 'boolean', description: 'Actually undo the last revertible write. Absent means list only.' },
1200
+ limit: { type: 'number', description: 'How many past writes to list. Default 10.' },
1201
+ connection: { type: 'string' },
1202
+ },
1203
+ additionalProperties: false,
1204
+ },
1205
+ annotations: { title: 'Undo the last write', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1206
+ outputSchema: {
1207
+ type: 'object',
1208
+ properties: {
1209
+ connection: { type: 'string' },
1210
+ reverted: { type: 'boolean' },
1211
+ writes: {
1212
+ type: 'array',
1213
+ description: 'This session\u2019s writes, newest first.',
1214
+ items: {
1215
+ type: 'object',
1216
+ properties: {
1217
+ id: { type: 'number' },
1218
+ kind: { type: 'string' },
1219
+ at: { type: 'string' },
1220
+ summary: { type: 'string' },
1221
+ revertible: { type: 'boolean' },
1222
+ reason: { type: 'string', description: 'Why not, when it is not.' },
1223
+ },
1224
+ },
1225
+ },
1226
+ next: { type: 'object', description: 'The write `confirm: true` would undo.' },
1227
+ steps: { type: 'array', items: { type: 'object' } },
1228
+ note: { type: 'string' },
1229
+ errors: { type: 'array', items: { type: 'string' } },
1230
+ stats: STATS,
1231
+ },
1232
+ },
1233
+ },
1234
+ ];
1235
+ async function openFor(registry, asked) {
1236
+ const wanted = typeof asked === 'string' && asked ? asked : registry.active;
1237
+ const entry = registry.list().find((c) => c.id === wanted || c.name === wanted);
1238
+ if (!entry) {
1239
+ const known = registry.list().map((c) => c.id).join(', ');
1240
+ throw new Error(`No connection called "${String(wanted)}". Known: ${known}.`);
1241
+ }
1242
+ const { adapter, schema } = await registry.open(entry.id);
1243
+ return { adapter, schema, id: entry.id };
1244
+ }
1245
+ /**
1246
+ * The table an id or a bare name points at — textToView's own rule, applied
1247
+ * to tool arguments. Postgres ids are schema-qualified (`public.customer`),
1248
+ * and an agent that says `customer` should not pay a round trip to learn the
1249
+ * spelling when only one table answers to the name.
1250
+ */
1251
+ /** The deep link a result can be opened at, for the human beside the agent. */
1252
+ /**
1253
+ * One row by its whole primary key, or undefined.
1254
+ *
1255
+ * The before-image a revert needs. Its own helper because three write paths
1256
+ * want it and each writing its own filter is three chances to spell a
1257
+ * composite key differently.
1258
+ */
1259
+ async function rowByKey(adapter, table, key) {
1260
+ const groups = [Object.entries(key).map(([column, value]) => ({ column, op: '=', value }))];
1261
+ const result = await adapter.query({ table, filter: { groups }, limit: 1, offset: 0 });
1262
+ return result.rows[0];
1263
+ }
1264
+ const linkTo = (conn, segment) => `/${encodeURIComponent(conn)}/${segment}`;
1265
+ async function callTool(registry, name, args) {
1266
+ if (name === 'connections') {
1267
+ return {
1268
+ connections: registry.list().map((c) => ({
1269
+ id: c.id,
1270
+ name: c.name,
1271
+ dialect: c.dialect,
1272
+ tables: c.tables,
1273
+ active: c.id === registry.active,
1274
+ writable: c.writable,
1275
+ /* The trust boundary as a number: data rows this session has handed
1276
+ to the caller from this connection. Zero is an auditable answer. */
1277
+ rowsReadThisSession: sessionEgress.get(c.id) ?? 0,
1278
+ })),
1279
+ };
1280
+ }
1281
+ const { adapter, schema, id } = await openFor(registry, args.connection);
1282
+ if (name === 'refresh') {
1283
+ const { schema: fresh } = await registry.refresh(id);
1284
+ return {
1285
+ connection: id,
1286
+ tables: fresh.tables.length,
1287
+ foreignKeys: fresh.foreignKeys.length,
1288
+ readAt: fresh.readAt,
1289
+ };
1290
+ }
1291
+ if (name === 'schema_summary') {
1292
+ const shape = describeSchema(schema);
1293
+ return {
1294
+ connection: id,
1295
+ label: schema.label,
1296
+ dialect: schema.dialect,
1297
+ tables: schema.tables.length,
1298
+ foreignKeys: schema.foreignKeys.length,
1299
+ centresOn: shape.hubs.map((h) => ({ table: h.table.name, pointedAtBy: h.from.length })),
1300
+ recordsEventsIn: shape.leaves.map((t) => t.name),
1301
+ joinTables: shape.joins.map((j) => ({ table: j.table.name, connects: j.connects })),
1302
+ deepestWalk: shape.chain.map((t) => t.name),
1303
+ standingAlone: shape.islands.map((t) => t.name),
1304
+ startAt: shape.start?.name,
1305
+ };
1306
+ }
1307
+ if (name === 'tables') {
1308
+ return {
1309
+ connection: id,
1310
+ tables: schema.tables.map((t) => ({
1311
+ id: t.id,
1312
+ columns: t.columns.length,
1313
+ approxRows: t.approxRows,
1314
+ isView: t.isView || undefined,
1315
+ })),
1316
+ };
1317
+ }
1318
+ if (name === 'find') {
1319
+ /* A number is a perfectly ordinary thing to search for, and a client that
1320
+ does not coerce to the declared type sends one. Coerced here rather
1321
+ than type-checked, or `find {value: 42}` answers "you gave me
1322
+ nothing" — about an argument it was given. */
1323
+ const value = args.value === undefined || args.value === null ? '' : String(args.value).trim();
1324
+ const needle = (args.name === undefined || args.name === null ? '' : String(args.name)).trim();
1325
+ /* The description says one or the other; saying it and then quietly
1326
+ preferring one is worse than either rule. */
1327
+ if (value && needle) {
1328
+ throw new Error('Give `name` or `value`, not both. `name` searches table and column names; '
1329
+ + '`value` searches the data. Two different questions, two calls.');
1330
+ }
1331
+ if (value)
1332
+ return findValue(adapter, schema, id, value);
1333
+ if (!needle)
1334
+ throw new Error('Nothing to look for. Give `name` or `value`.');
1335
+ const lowered = needle.toLowerCase();
1336
+ /* Capped and said so: a one-letter needle on a wide schema matches half
1337
+ of it, and a silent cut reads as "that is all there is". */
1338
+ const CAP = 40;
1339
+ const tables = schema.tables.filter((t) => t.id.toLowerCase().includes(lowered));
1340
+ const columns = schema.tables.flatMap((t) => t.columns
1341
+ .filter((c) => c.name.toLowerCase().includes(lowered))
1342
+ .map((c) => ({
1343
+ table: t.id,
1344
+ column: c.name,
1345
+ type: c.type,
1346
+ references: c.references,
1347
+ })));
1348
+ return {
1349
+ connection: id,
1350
+ tables: tables.slice(0, CAP).map((t) => ({ id: t.id, columns: t.columns.length, isView: t.isView || undefined })),
1351
+ columns: columns.slice(0, CAP),
1352
+ truncated: tables.length > CAP || columns.length > CAP ? { tables: tables.length, columns: columns.length } : undefined,
1353
+ };
1354
+ }
1355
+ if (name === 'table') {
1356
+ /* Either spelling. See the note on the input schema: this tool wanted
1357
+ `name` before the rest settled on `table`. */
1358
+ const asked = String(args.table ?? args.name ?? '');
1359
+ if (!asked)
1360
+ throw new Error('Which table? Pass `table`, e.g. {"table": "invoice"}.');
1361
+ const table = tableNamed(schema, asked);
1362
+ if (!table)
1363
+ throw new Error(`Unknown table "${asked}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1364
+ const stored = adapter.ddl ? await adapter.ddl(table.id) : undefined;
1365
+ return {
1366
+ connection: id,
1367
+ table: table.id,
1368
+ isView: table.isView || undefined,
1369
+ approxRows: table.approxRows,
1370
+ primaryKey: primaryKey(table),
1371
+ columns: table.columns.map((c) => ({
1372
+ name: c.name,
1373
+ type: c.type,
1374
+ nullable: c.nullable,
1375
+ primaryKey: c.primaryKey || undefined,
1376
+ references: c.references,
1377
+ default: c.default,
1378
+ /* The states the system has. A `status` column typed `text` says
1379
+ nothing, and an agent that guesses `'OPEN'` gets an empty result
1380
+ that reads like an empty table rather than a spelling mistake. */
1381
+ allowed: c.allowed,
1382
+ })),
1383
+ pointsAt: referencesFrom(schema, table.id).map((fk) => ({
1384
+ via: fk.from.columns,
1385
+ table: fk.to.table,
1386
+ columns: fk.to.columns,
1387
+ onDelete: fk.onDelete,
1388
+ })),
1389
+ pointedAtBy: referencesTo(schema, table.id).map((fk) => ({
1390
+ table: fk.from.table,
1391
+ via: fk.from.columns,
1392
+ onDelete: fk.onDelete,
1393
+ })),
1394
+ ddl: stored ?? renderDDL(schema, table.id),
1395
+ link: linkTo(id, encodeURIComponent(table.id)),
1396
+ };
1397
+ }
1398
+ if (name === 'breakdown') {
1399
+ if (!adapter.runBreakdown) {
1400
+ throw new Error(`${schema.dialect} connections cannot group in this build.`);
1401
+ }
1402
+ const text = String(args.q ?? '');
1403
+ const offset = Math.max(0, Number(args.offset ?? 0) || 0);
1404
+ const parsed = textToBreakdown(schema, text);
1405
+ if (!parsed.breakdown) {
1406
+ /* Answered as a result rather than a protocol error, like `query`: a
1407
+ breakdown that does not parse is the caller's to fix, and the message
1408
+ names the path or the missing clause. */
1409
+ return { connection: id, errors: parsed.errors.map((e) => e.message) };
1410
+ }
1411
+ const def = { ...parsed.breakdown, limit: clampLimit(args.limit ?? parsed.breakdown.limit ?? 20) };
1412
+ const result = await adapter.runBreakdown(def, offset);
1413
+ const total = adapter.countBreakdown ? await adapter.countBreakdown(def) : undefined;
1414
+ return {
1415
+ connection: id,
1416
+ rows: result.rows,
1417
+ columns: result.columns,
1418
+ resolved: result.resolved,
1419
+ total,
1420
+ offset,
1421
+ sql: result.sql,
1422
+ };
1423
+ }
1424
+ if (name === 'query') {
1425
+ const text = String(args.q ?? '');
1426
+ const offset = Math.max(0, Number(args.offset ?? 0) || 0);
1427
+ const parsed = parseQuery(text, { schema });
1428
+ /* The query's own `limit 3` wins over the tool's default, exactly as the
1429
+ query bar treats it — an explicit argument beats both. */
1430
+ const limit = clampLimit(args.limit ?? parsed.query?.limit ?? 20);
1431
+ /* A dotted path is not a legal single-table query and is a perfectly good
1432
+ view — the same one-way fall-through the query bar takes, so the
1433
+ language means the same thing here as there. */
1434
+ if ((!parsed.query || parsed.errors.length) && adapter.runView) {
1435
+ const asView = textToView(schema, text, { id: 'mcp', name: '' });
1436
+ /* Text that clearly reached for an aggregate gets the view parser's
1437
+ errors, which teach — "a filter runs before the aggregate exists" —
1438
+ rather than the plain parser's "not a column", which is true of the
1439
+ alias and useless about it. */
1440
+ if (asView.errors.length && /\b(count|sum|min|max|avg)\s+\S+\s+via\b/i.test(text)) {
1441
+ const about = findTable(schema, parsed.query?.table ?? text.trim().split(/\s+/)[0] ?? '');
1442
+ return {
1443
+ connection: id,
1444
+ errors: asView.errors.map((e) => e.message),
1445
+ columns: about ? about.columns.map((c) => c.name) : undefined,
1446
+ };
1447
+ }
1448
+ if (asView.view && !asView.errors.length) {
1449
+ const viewLimit = clampLimit(args.limit ?? asView.view.limit ?? 20);
1450
+ const result = await adapter.runView({ ...asView.view, limit: viewLimit }, offset);
1451
+ const viewTotal = adapter.countView ? await adapter.countView(asView.view) : undefined;
1452
+ return {
1453
+ connection: id,
1454
+ rows: result.rows,
1455
+ columns: result.columns,
1456
+ total: viewTotal,
1457
+ sql: result.sql,
1458
+ link: linkTo(id, encodeURIComponent(text).replace(/!/g, '%21')),
1459
+ };
1460
+ }
1461
+ }
1462
+ if ((!parsed.query || parsed.errors.length) && looksLikeBreakdown(text)) {
1463
+ /* Pointed at the right tool rather than run here. `query` falls through
1464
+ to a view because a view still answers one row per record — the same
1465
+ kind of answer. A breakdown does not: its rows are groups, they
1466
+ cannot be opened, and its `resolved` says of each column whether it
1467
+ is a key or a measure. Quietly returning that from `query` would be
1468
+ a different shape under the same name. */
1469
+ return {
1470
+ connection: id,
1471
+ errors: [
1472
+ `"${text.trim()}" groups rows, which \`query\` does not do. Call \`breakdown\` with the same text.`,
1473
+ ],
1474
+ };
1475
+ }
1476
+ if (!parsed.query || parsed.errors.length) {
1477
+ /* The errors carry suggestions — "no column contry_code, nearest is
1478
+ country_code" — which is what lets a model correct itself in one
1479
+ step instead of flailing. Answered as a result, not a protocol
1480
+ error: a query that does not parse is the caller's to fix.
1481
+
1482
+ And when the table is known, its real columns ride along: a name too
1483
+ far from any column for a nearest-match ("sales_order_id" against
1484
+ "order_id") was a dead end that cost a second round trip to
1485
+ \`table\`. */
1486
+ const about = findTable(schema, parsed.query?.table ?? text.trim().split(/\s+/)[0] ?? '');
1487
+ return {
1488
+ connection: id,
1489
+ errors: parsed.errors.map((e) => e.message ?? e),
1490
+ columns: about ? about.columns.map((c) => c.name) : undefined,
1491
+ };
1492
+ }
1493
+ const q = parsed.query;
1494
+ const table = findTable(schema, q.table);
1495
+ const sorted = resolveOrder(table, q.orderBy);
1496
+ const result = await adapter.query({ ...q, orderBy: sorted.order, limit, offset });
1497
+ const total = await adapter.count(q.table, q.filter);
1498
+ return {
1499
+ connection: id,
1500
+ rows: result.rows,
1501
+ columns: result.columns,
1502
+ total,
1503
+ // total is exact: adapter.count is a filtered COUNT(*).
1504
+ offset,
1505
+ sql: result.sql,
1506
+ explain: explain(q),
1507
+ link: linkTo(id, encodeURIComponent(text).replace(/!/g, '%21')),
1508
+ };
1509
+ }
1510
+ if (name === 'sql') {
1511
+ if (!adapter.runSql) {
1512
+ throw new Error(`${schema.dialect} connections cannot run SQL in this build. Use \`query\`.`);
1513
+ }
1514
+ const statement = String(args.statement ?? '').trim();
1515
+ if (!statement)
1516
+ throw new Error('Nothing to run.');
1517
+ // Never a write. The read-only handle refuses one anyway; not asking is
1518
+ // what makes this tool safe to hand to something that retries.
1519
+ const result = await teaching(schema, () => adapter.runSql({
1520
+ text: statement,
1521
+ limit: clampLimit(args.limit ?? 100),
1522
+ write: false,
1523
+ timeoutMs: args.timeout_ms === undefined ? undefined : Number(args.timeout_ms),
1524
+ }));
1525
+ return { connection: id, ...result };
1526
+ }
1527
+ if (name === 'explain') {
1528
+ if (!adapter.explainSql) {
1529
+ throw new Error(`${schema.dialect} connections cannot explain in this build.`);
1530
+ }
1531
+ const statement = String(args.statement ?? '').trim();
1532
+ if (!statement)
1533
+ throw new Error('Nothing to explain.');
1534
+ const params = Array.isArray(args.params) ? args.params : [];
1535
+ const result = await teaching(schema, () => adapter.explainSql({ text: statement, params }));
1536
+ return { connection: id, plan: result.plan };
1537
+ }
1538
+ if (name === 'profile') {
1539
+ if (!adapter.profile) {
1540
+ throw new Error(`${schema.dialect} connections cannot profile in this build.`);
1541
+ }
1542
+ const table = tableNamed(schema, String(args.table ?? ''));
1543
+ if (!table)
1544
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1545
+ const result = await adapter.profile(table.id);
1546
+ return { connection: id, ...result, link: linkTo(id, encodeURIComponent(table.id)) };
1547
+ }
1548
+ if (name === 'diff') {
1549
+ const other = String(args.to ?? '').trim();
1550
+ if (!other)
1551
+ throw new Error('`to` names the connection to compare against. Ask `connections` for what exists.');
1552
+ if (other === id)
1553
+ throw new Error(`"${id}" compared with itself is always the same shape. Name a different connection.`);
1554
+ /* Opened here rather than through the caller's resolved pair: a diff is
1555
+ the one read that is *about* two connections, and the second one may
1556
+ never have been opened this session. */
1557
+ const { schema: theirs } = await openFor(registry, other);
1558
+ /* The labels are dropped: `from` and `to` are the connection ids a caller
1559
+ can act on, and two names for the same pair invites using the wrong
1560
+ one in the next call. */
1561
+ const { left: _left, right: _right, ...rest } = diffSchemas(schema, theirs);
1562
+ return { from: id, to: other, ...rest };
1563
+ }
1564
+ if (name === 'order') {
1565
+ /* An array, or the comma-separated string clients send when the schema
1566
+ says array — the `pre-migration-check` prompt takes tables that way,
1567
+ and being strict here would refuse the shape this server's own prompt
1568
+ teaches. */
1569
+ const asked = Array.isArray(args.tables)
1570
+ ? args.tables.map(String)
1571
+ : typeof args.tables === 'string' && args.tables.trim()
1572
+ ? args.tables.split(',').map((t) => t.trim()).filter(Boolean)
1573
+ : undefined;
1574
+ for (const wanted of asked ?? []) {
1575
+ if (!tableNamed(schema, wanted)) {
1576
+ throw new Error(`Unknown table "${wanted}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1577
+ }
1578
+ }
1579
+ const plan = insertOrder(schema, asked?.map((t) => tableNamed(schema, t).id));
1580
+ return {
1581
+ connection: id,
1582
+ insert: plan.insert,
1583
+ remove: plan.remove,
1584
+ requires: plan.requires,
1585
+ /* Absent rather than empty: "there are no cycles" is the ordinary case
1586
+ and an empty array in every answer is noise the reader learns to
1587
+ skip past — which is how the one that is not empty gets missed. */
1588
+ cycles: plan.cycles.length ? plan.cycles : undefined,
1589
+ selfReferencing: plan.selfReferencing.length ? plan.selfReferencing : undefined,
1590
+ };
1591
+ }
1592
+ if (name === 'lint') {
1593
+ const only = args.table === undefined ? undefined : tableNamed(schema, String(args.table));
1594
+ if (args.table !== undefined && !only) {
1595
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1596
+ }
1597
+ const levels = ['high', 'medium', 'low'];
1598
+ const floor = args.severity === undefined ? levels.length - 1 : levels.indexOf(String(args.severity));
1599
+ if (floor === -1)
1600
+ throw new Error('`severity` is high, medium or low.');
1601
+ const wanted = new Set(levels.slice(0, floor + 1));
1602
+ const findings = lintSchema(schema)
1603
+ .filter((f) => (only ? f.table === only.id : true))
1604
+ .filter((f) => wanted.has(f.severity));
1605
+ /* `why` is a property of the rule, not of the finding — the type that
1606
+ carries it says so, and it was still being written out once per
1607
+ finding. On a 66-table schema that is 265 findings sharing three
1608
+ sentences: a third of a 24,000-token answer spent repeating them, on a
1609
+ tool an agent calls to *orient*, before it has formed an opinion.
1610
+
1611
+ So the sentences go out once, keyed by rule, and each finding names its
1612
+ rule. Nothing is lost — the same text is still one lookup away — and
1613
+ the answer stops charging the reader for the same paragraph 88 times. */
1614
+ const rules = {};
1615
+ for (const f of findings)
1616
+ rules[f.rule] ??= { severity: f.severity, why: f.why };
1617
+ /* And the list itself is summary-first. A caller that wants everything
1618
+ says so; the common case is "what kind of trouble is this schema in",
1619
+ which the counts and a few examples per rule answer completely. The
1620
+ filters that narrow it already existed — only the default was wrong. */
1621
+ const PER_RULE = 5;
1622
+ const all = args.all === true;
1623
+ const kept = [];
1624
+ const seen = {};
1625
+ for (const f of findings) {
1626
+ seen[f.rule] = (seen[f.rule] ?? 0) + 1;
1627
+ if (all || seen[f.rule] <= PER_RULE)
1628
+ kept.push(f);
1629
+ }
1630
+ const withheld = findings.length - kept.length;
1631
+ return {
1632
+ connection: id,
1633
+ counts: lintCounts(findings),
1634
+ rules,
1635
+ findings: kept.map(({ why: _why, ...rest }) => rest),
1636
+ total: findings.length,
1637
+ shown: kept.length,
1638
+ /* Said out loud rather than left to be inferred from two numbers: a
1639
+ truncated list that does not admit it reads as a complete one. */
1640
+ ...(withheld
1641
+ ? {
1642
+ note: `${withheld} more finding${withheld === 1 ? '' : 's'} not listed — showing at most `
1643
+ + `${PER_RULE} per rule. Ask with \`all: true\` for every one, or narrow with `
1644
+ + '`table` or `severity`.',
1645
+ }
1646
+ : {}),
1647
+ };
1648
+ }
1649
+ if (name === 'change_impact') {
1650
+ const table = tableNamed(schema, String(args.table ?? ''));
1651
+ if (!table) {
1652
+ throw new Error(`Unknown table "${String(args.table ?? '')}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1653
+ }
1654
+ return {
1655
+ connection: id,
1656
+ ...(await changeImpact(adapter, schema, table.id, args.column === undefined ? undefined : String(args.column))),
1657
+ };
1658
+ }
1659
+ if (name === 'record') {
1660
+ const table = tableNamed(schema, String(args.table ?? ''));
1661
+ if (!table)
1662
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1663
+ const keys = Array.isArray(args.keys)
1664
+ ? args.keys
1665
+ : args.key ? [args.key] : [];
1666
+ if (!keys.length)
1667
+ throw new Error('Give `key` for one record, or `keys` for several.');
1668
+ if (keys.length > 20)
1669
+ throw new Error(`${keys.length} keys is more than one call carries. Take 20 at a time.`);
1670
+ const expand = Math.max(0, Math.min(10, Number(args.expand ?? 0) || 0));
1671
+ const wanted = primaryKey(table);
1672
+ for (const key of keys) {
1673
+ const missing = wanted.filter((c) => key[c] === undefined);
1674
+ if (missing.length) {
1675
+ throw new Error(`A key is missing ${missing.join(', ')} — a ${table.name} is identified by (${wanted.join(', ')}).`);
1676
+ }
1677
+ }
1678
+ const records = [];
1679
+ for (const key of keys)
1680
+ records.push(await recordAnswer(adapter, schema, id, table, key, expand));
1681
+ /* One key keeps the shape every earlier caller learned; several arrive
1682
+ as `records`, in the order the keys came. */
1683
+ return Array.isArray(args.keys)
1684
+ ? { connection: id, table: table.id, records }
1685
+ : { connection: id, table: table.id, ...records[0] };
1686
+ }
1687
+ /* The gate the server can actually enforce, in the words the HTTP
1688
+ endpoint uses: the person who hits this is usually the person who can
1689
+ change it, and "forbidden" would send them hunting for why. */
1690
+ const writableOrThrow = () => {
1691
+ const config = registry.configOf(id);
1692
+ if (!config?.writable) {
1693
+ throw new Error(`"${config?.name ?? id}" is read-only. `
1694
+ + 'Add "writable": true to its entry in your tablewalk.json to allow edits.');
1695
+ }
1696
+ };
1697
+ const wholeKeyOrThrow = (table, key) => {
1698
+ const wanted = primaryKey(findTable(schema, table.id));
1699
+ const missing = wanted.filter((c) => key[c] === undefined);
1700
+ if (missing.length) {
1701
+ throw new Error(`The key is missing ${missing.join(', ')} — a ${table.name} is identified by (${wanted.join(', ')}).`);
1702
+ }
1703
+ };
1704
+ if (name === 'fixture') {
1705
+ const table = tableNamed(schema, String(args.table ?? ''));
1706
+ if (!table)
1707
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1708
+ const key = (args.key ?? {});
1709
+ wholeKeyOrThrow(table, key);
1710
+ const taken = await extractFixture(adapter, schema, table.id, key, {
1711
+ children: args.children === true,
1712
+ depth: args.depth === undefined ? undefined : Number(args.depth),
1713
+ perChild: args.per_child === undefined ? undefined : Number(args.per_child),
1714
+ max: args.max === undefined ? undefined : Number(args.max),
1715
+ });
1716
+ return { connection: id, ...taken };
1717
+ }
1718
+ if (name === 'update') {
1719
+ writableOrThrow();
1720
+ if (!adapter.update)
1721
+ throw new Error(`${schema.dialect} connections are read-only in this build.`);
1722
+ const table = tableNamed(schema, String(args.table ?? ''));
1723
+ if (!table)
1724
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1725
+ const key = (args.key ?? {});
1726
+ wholeKeyOrThrow(table, key);
1727
+ const values = (args.set ?? {});
1728
+ if (!Object.keys(values).length)
1729
+ throw new Error('Nothing to set.');
1730
+ /* The adapter independently verifies the key matches exactly one row and
1731
+ refuses anything else — the same guarantee the browser's edits get. */
1732
+ /* Read before writing, so the write can be undone. One extra read of one
1733
+ row by its whole primary key, which is the price of `revert` existing —
1734
+ and cheap beside the delete path, which already reads the whole impact
1735
+ before it will touch anything. */
1736
+ const beforeRow = await rowByKey(adapter, table.id, key);
1737
+ const result = await adapter.update({ table: table.id, key, values }).catch(async (err) => {
1738
+ if (err instanceof Refusal)
1739
+ throw err;
1740
+ throw new Error(await explainWriteFailure(adapter, schema, table.id, values, err.message));
1741
+ });
1742
+ if (beforeRow) {
1743
+ const touched = Object.keys(result.applied ?? values);
1744
+ const steps = [{
1745
+ table: table.id,
1746
+ key,
1747
+ before: Object.fromEntries(touched.map((c) => [c, beforeRow[c] ?? null])),
1748
+ after: { ...(result.applied ?? values) },
1749
+ }];
1750
+ recordWrite(id, { kind: 'update', summary: summarise('update', steps), revertible: true, steps });
1751
+ }
1752
+ const body = Object.keys(key).length === 1
1753
+ ? String(Object.values(key)[0])
1754
+ : Object.entries(key).map(([c, v]) => `${c}=${v}`).join(',');
1755
+ return {
1756
+ connection: id,
1757
+ table: table.id,
1758
+ key,
1759
+ affected: result.affected,
1760
+ applied: result.applied,
1761
+ sql: result.sql,
1762
+ link: linkTo(id, `${encodeURIComponent(table.id)}/~${encodeURIComponent(body)}`),
1763
+ };
1764
+ }
1765
+ if (name === 'insert') {
1766
+ writableOrThrow();
1767
+ if (!adapter.insert)
1768
+ throw new Error(`${schema.dialect} connections are read-only in this build.`);
1769
+ const table = tableNamed(schema, String(args.table ?? ''));
1770
+ if (!table)
1771
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1772
+ const values = (args.values ?? {});
1773
+ try {
1774
+ const result = await adapter.insert({ table: table.id, values });
1775
+ /* Undone by removing what it added, which needs the key the database
1776
+ assigned rather than the values that were sent. A row inserted into a
1777
+ keyless table cannot be found again, so it is recorded as history and
1778
+ not as something revertible. */
1779
+ const insertSteps = [{ table: table.id, key: result.key, after: { ...result.row } }];
1780
+ recordWrite(id, {
1781
+ kind: 'insert',
1782
+ summary: summarise('insert', insertSteps),
1783
+ revertible: Object.keys(result.key ?? {}).length > 0,
1784
+ ...(Object.keys(result.key ?? {}).length ? {} : { reason: `${table.id} has no primary key, so the row cannot be found again.` }),
1785
+ steps: insertSteps,
1786
+ });
1787
+ const body = Object.keys(result.key).length === 1
1788
+ ? String(Object.values(result.key)[0])
1789
+ : Object.entries(result.key).map(([c, v]) => `${c}=${v}`).join(',');
1790
+ return {
1791
+ connection: id,
1792
+ table: table.id,
1793
+ inserted: true,
1794
+ key: result.key,
1795
+ row: result.row,
1796
+ sql: result.sql,
1797
+ link: Object.keys(result.key).length
1798
+ ? linkTo(id, `${encodeURIComponent(table.id)}/~${encodeURIComponent(body)}`)
1799
+ : undefined,
1800
+ };
1801
+ }
1802
+ catch (err) {
1803
+ /* A Refusal is the caller misusing the tool and reads best as a tool
1804
+ error; a constraint violation is the *data* refusing, in the
1805
+ database's words, and rides in `errors` the way a query's do —
1806
+ something to fix and retry, not a broken server. */
1807
+ if (err instanceof Refusal)
1808
+ throw err;
1809
+ /* And the graph turns the database's words into the missing row: one
1810
+ lookup, on the failure path only. */
1811
+ const message = await explainWriteFailure(adapter, schema, table.id, values, err.message);
1812
+ return { connection: id, table: table.id, inserted: false, errors: [message] };
1813
+ }
1814
+ }
1815
+ if (name === 'delete') {
1816
+ writableOrThrow();
1817
+ if (!adapter.remove)
1818
+ throw new Error(`${schema.dialect} connections cannot delete in this build.`);
1819
+ const table = tableNamed(schema, String(args.table ?? ''));
1820
+ if (!table)
1821
+ throw new Error(`Unknown table "${String(args.table)}". Ask \`tables\` for what exists — or \`refresh\` if it was just created.`);
1822
+ const key = (args.key ?? {});
1823
+ wholeKeyOrThrow(table, key);
1824
+ const impact = await deleteImpact(adapter, schema, table.id, key);
1825
+ if (args.confirm !== true) {
1826
+ return {
1827
+ connection: id,
1828
+ table: table.id,
1829
+ key,
1830
+ impact,
1831
+ deleted: false,
1832
+ note: 'Nothing was removed. Read the impact, then call again with confirm: true to delete.',
1833
+ };
1834
+ }
1835
+ /* restrict and no action are left to the database, whose message names
1836
+ the constraint; a second implementation of the same rule is how the
1837
+ two come to disagree, and tablewalk would be the one that was wrong. */
1838
+ try {
1839
+ /* The whole row, before it goes: a delete is undone by putting it back,
1840
+ and nothing else in the answer carries what it held. */
1841
+ const goneRow = await rowByKey(adapter, table.id, key);
1842
+ const result = await adapter.remove({ table: table.id, key });
1843
+ if (goneRow) {
1844
+ const steps = [{ table: table.id, key, before: { ...goneRow } }];
1845
+ recordWrite(id, { kind: 'delete', summary: summarise('delete', steps), revertible: true, steps });
1846
+ }
1847
+ return { connection: id, table: table.id, key, impact, deleted: true, affected: result.affected };
1848
+ }
1849
+ catch (err) {
1850
+ return { connection: id, table: table.id, key, impact, deleted: false, errors: [err.message] };
1851
+ }
1852
+ }
1853
+ if (name === 'insert_graph') {
1854
+ writableOrThrow();
1855
+ /* `fixture`'s own shape, accepted as it comes: extract a real row out of
1856
+ development, put it into a test database, without an agent rewriting
1857
+ the shape in between. Both together is a request with two minds about
1858
+ what it wants. */
1859
+ const listed = Array.isArray(args.rows) ? args.rows : undefined;
1860
+ const grouped = Array.isArray(args.tables)
1861
+ ? flattenTables(args.tables)
1862
+ : undefined;
1863
+ if (listed && grouped)
1864
+ throw new Error('Give either `rows` or `tables`, not both.');
1865
+ const rows = listed ?? grouped;
1866
+ if (!rows)
1867
+ throw new Error('Give the rows to insert, as `rows` or as `fixture`\u2019s `tables`.');
1868
+ try {
1869
+ /* `rowsWritten` is dropped here on purpose: every tool reports what it
1870
+ cost through `stats`, and a count in two places is a count that will
1871
+ one day disagree with itself. */
1872
+ const { rowsWritten: _written, ...result } = await insertGraph(adapter, schema, rows);
1873
+ return { connection: id, ...result };
1874
+ }
1875
+ catch (err) {
1876
+ /* A refusal about the request is a message; a refusal from the database
1877
+ is also a message — and both arrive having written nothing, which is
1878
+ the fact worth reporting either way. */
1879
+ return { connection: id, inserted: [], teardown: [], errors: [err.message] };
1880
+ }
1881
+ }
1882
+ if (name === 'revert') {
1883
+ writableOrThrow();
1884
+ const listed = history(id, typeof args.limit === 'number' ? args.limit : 10);
1885
+ const next = lastRevertible(id);
1886
+ if (args.confirm !== true) {
1887
+ return {
1888
+ connection: id,
1889
+ reverted: false,
1890
+ writes: listed.map(({ steps: _steps, connection: _c, ...rest }) => rest),
1891
+ ...(next ? { next: { id: next.id, kind: next.kind, at: next.at, summary: next.summary } } : {}),
1892
+ note: next
1893
+ ? `Nothing was undone. Call again with confirm: true to undo: ${next.summary}.`
1894
+ : 'Nothing in this session can be undone.',
1895
+ };
1896
+ }
1897
+ if (!next)
1898
+ return { connection: id, reverted: false, writes: [], errors: ['Nothing in this session can be undone.'] };
1899
+ const result = await revert(adapter, id, next);
1900
+ return {
1901
+ connection: id,
1902
+ reverted: result.reverted,
1903
+ undone: { id: next.id, kind: next.kind, summary: next.summary },
1904
+ ...(result.steps ? { steps: result.steps } : {}),
1905
+ ...(result.errors ? { errors: result.errors } : {}),
1906
+ };
1907
+ }
1908
+ if (name === 'delete_graph') {
1909
+ writableOrThrow();
1910
+ const rows = (Array.isArray(args.rows) ? args.rows : []);
1911
+ /* The same two-call gate `delete` has, and for a stronger reason.
1912
+
1913
+ `delete` removes one row by key and refuses to proceed until the caller
1914
+ has seen what points at it. `delete_graph` removes a whole list of them
1915
+ inside one transaction, and had no gate at all — so the tool that could
1916
+ take the most rows away was the one that took them on the first call.
1917
+ The gate was on the less dangerous of the two.
1918
+
1919
+ Impact is read before the transaction opens, per row, so what comes
1920
+ back is what `delete` would have said about each of them. */
1921
+ if (args.confirm !== true) {
1922
+ const impact = [];
1923
+ for (const [i, row] of rows.entries()) {
1924
+ const table = row?.table ? tableNamed(schema, row.table) : undefined;
1925
+ if (!table || !row?.key || !Object.keys(row.key).length) {
1926
+ return {
1927
+ connection: id,
1928
+ removed: [],
1929
+ deleted: false,
1930
+ errors: [`Row ${i + 1}${row?.table ? ` (${row.table})` : ''}: a delete needs a known table and a key.`],
1931
+ };
1932
+ }
1933
+ impact.push({ table: table.id, key: row.key, impact: await deleteImpact(adapter, schema, table.id, row.key) });
1934
+ }
1935
+ return {
1936
+ connection: id,
1937
+ removed: [],
1938
+ deleted: false,
1939
+ rows: impact,
1940
+ note: 'Nothing was removed. Read the impact of every row, then call again with confirm: true to delete them as one transaction.',
1941
+ };
1942
+ }
1943
+ try {
1944
+ const { rowsDeleted: _deleted, ...result } = await deleteGraph(adapter, schema, rows);
1945
+ return { connection: id, deleted: true, ...result };
1946
+ }
1947
+ catch (err) {
1948
+ return { connection: id, removed: [], deleted: false, errors: [err.message] };
1949
+ }
1950
+ }
1951
+ throw new Error(`No tool called "${name}".`);
1952
+ }
1953
+ /**
1954
+ * The tools as a client sees them: reads always, writes only when some
1955
+ * connection's config allows any. Exported for the agent console, which
1956
+ * shows exactly this list — a second list would be a second truth.
1957
+ */
1958
+ /**
1959
+ * Whether `tools/list` advertises each tool's `outputSchema`.
1960
+ *
1961
+ * Off by default, and that default is a measurement rather than a taste. On a
1962
+ * 66-table schema the whole listing is about 8,500 tokens — paid by every
1963
+ * agent, every session, before it has asked anything — and `outputSchema` is
1964
+ * exactly half of it. Fourteen per cent is one `stats` block, byte-identical
1965
+ * across every tool, because JSON Schema has no way for one document to point
1966
+ * at another's definition.
1967
+ *
1968
+ * It is optional in MCP and it is a contract rather than a prompt: a client
1969
+ * that validates `structuredContent` wants it, and most agents never read it.
1970
+ * `structuredContent` itself is unaffected either way — the answers carry the
1971
+ * same fields whether or not their shape was declared in advance. So the
1972
+ * contract stays available to anyone who needs it, and stops being charged to
1973
+ * everyone who does not.
1974
+ */
1975
+ /**
1976
+ * Which tools a session is given, chosen at launch.
1977
+ *
1978
+ * Twenty-two definitions is a fixed cost every agent pays on connect, before
1979
+ * it has asked anything — and most sessions are one job. An agent reading a
1980
+ * schema does not need the graph writers; an agent seeding a fixture does not
1981
+ * need `explain`. A profile is the caller saying which job this is.
1982
+ *
1983
+ * `full` stays the default. Narrowing what a server can do by default would
1984
+ * remove capability from every existing setup to save tokens in some of them,
1985
+ * which is the wrong way round: the cost is real, and so is the surprise of a
1986
+ * tool that used to be there.
1987
+ *
1988
+ * `connections` and `refresh` are in every profile. The first is documented as
1989
+ * the call to start with and the second is how an agent recovers from a schema
1990
+ * that changed underneath it — a session without them is one that cannot find
1991
+ * its footing after a migration.
1992
+ */
1993
+ const ALWAYS = ['connections', 'refresh'];
1994
+ export const TOOL_PROFILES = {
1995
+ /* Everything. */
1996
+ full: null,
1997
+ /* Reading a database you did not write: what is here, what points at what,
1998
+ what the columns actually hold. */
1999
+ explore: [
2000
+ ...ALWAYS, 'schema_summary', 'tables', 'table', 'find', 'query', 'breakdown',
2001
+ 'sql', 'record', 'explain', 'profile',
2002
+ ],
2003
+ /* Changing a shape safely: what breaks, what it costs, what order it has to
2004
+ happen in, and whether it matches somewhere else afterwards. */
2005
+ migrate: [
2006
+ ...ALWAYS, 'schema_summary', 'tables', 'table', 'change_impact', 'lint',
2007
+ 'order', 'diff', 'profile', 'explain',
2008
+ ],
2009
+ /* Test data that respects referential integrity, in and out. */
2010
+ seed: [
2011
+ ...ALWAYS, 'schema_summary', 'tables', 'table', 'order', 'fixture', 'record',
2012
+ 'query', 'insert', 'insert_graph', 'delete_graph', 'revert',
2013
+ ],
2014
+ };
2015
+ let toolProfile = null;
2016
+ /** @throws if the name is not a profile, naming the ones that are. */
2017
+ export function setToolProfile(name) {
2018
+ if (!name)
2019
+ return;
2020
+ if (!(name in TOOL_PROFILES)) {
2021
+ throw new Error(`"${name}" is not a tool profile. Choose one of: ${Object.keys(TOOL_PROFILES).join(', ')}.`);
2022
+ }
2023
+ toolProfile = TOOL_PROFILES[name];
2024
+ }
2025
+ let advertiseOutputSchema = false;
2026
+ export function setAdvertiseOutputSchema(on) {
2027
+ advertiseOutputSchema = on;
2028
+ }
2029
+ export function agentTools(registry) {
2030
+ const connections = registry.list();
2031
+ const anyWritable = connections.some((c) => c.writable);
2032
+ const listed = anyWritable ? [...TOOLS, ...WRITE_TOOLS] : [...TOOLS];
2033
+ /* If no connection will return a row, the tools that only return rows are
2034
+ not a capability this server has — and a tool an agent can see is a tool
2035
+ it will spend a call discovering. The same reasoning as the write tools,
2036
+ one boundary further out. `find` stays: its name mode is shape. */
2037
+ const anyRows = connections.some((c) => c.rows !== false);
2038
+ const byRows = anyRows ? listed : listed.filter((t) => !ROW_ONLY_TOOLS.has(t.name));
2039
+ /* The profile narrows what the connection already allows, rather than
2040
+ widening it: a read-only server given the `seed` profile still lists no
2041
+ write tools. Every filter here only ever removes. */
2042
+ const wanted = toolProfile;
2043
+ const visible = wanted ? byRows.filter((t) => wanted.includes(t.name)) : byRows;
2044
+ if (advertiseOutputSchema)
2045
+ return visible;
2046
+ return visible.map(({ outputSchema: _schema, ...tool }) => tool);
2047
+ }
2048
+ /** Tools whose whole answer is row data. */
2049
+ const ROW_ONLY_TOOLS = new Set(['query', 'sql', 'record', 'breakdown']);
2050
+ /**
2051
+ * Run one tool and shape the answer the way MCP shapes it — content, the
2052
+ * structured answer, stats, isError. The stdio loop and the agent console
2053
+ * both call this, which is what makes the console's claim true: what it
2054
+ * shows is byte-identical to what an agent receives.
2055
+ */
2056
+ /**
2057
+ * Rows that have left the machine this session, per connection.
2058
+ *
2059
+ * The README's trust boundary — "rows entering an agent's context leave the
2060
+ * machine" — as a number instead of a sentence. Counted where stats are
2061
+ * made, reported by `connections`, so one call answers "what has the agent
2062
+ * seen".
2063
+ */
2064
+ const sessionEgress = new Map();
2065
+ export async function runTool(registry, name, args) {
2066
+ try {
2067
+ const started = Date.now();
2068
+ const answered = await callTool(registry, name, args);
2069
+ /* Every answer says what it cost: wall time for the whole call,
2070
+ the data rows it returned, and — on the write tools — the rows
2071
+ it changed. The read/write split is deliberate: an update that
2072
+ read nothing and wrote one row should say exactly that. */
2073
+ const delivered = (record) => (record.found === true ? 1 : 0)
2074
+ + (Array.isArray(record.pointedAtBy)
2075
+ ? record.pointedAtBy.reduce((n, r) => n + (r.rows?.length ?? 0), 0)
2076
+ : 0);
2077
+ const rows = typeof answered.rowsRead === 'number'
2078
+ /* A fixture counts what it read, since its rows are grouped by table
2079
+ rather than being one list — and every one of them left the
2080
+ machine. */
2081
+ ? answered.rowsRead
2082
+ : Array.isArray(answered.rows)
2083
+ ? answered.rows.length
2084
+ : Array.isArray(answered.matches)
2085
+ ? answered.matches.length
2086
+ : Array.isArray(answered.records)
2087
+ ? answered.records.reduce((n, r) => n + delivered(r), 0)
2088
+ : answered.found === true || answered.found === false ? delivered(answered) : undefined;
2089
+ const affected = typeof answered.affected === 'number' ? answered.affected : undefined;
2090
+ /* A graph counts its own list rather than an `affected` it never has —
2091
+ and `inserted` is a boolean for `insert` and a list for `insert_graph`,
2092
+ which is what the array check tells apart. */
2093
+ const graphIn = name === 'insert_graph' && Array.isArray(answered.inserted)
2094
+ ? answered.inserted.length
2095
+ : undefined;
2096
+ const graphOut = name === 'delete_graph' && Array.isArray(answered.removed)
2097
+ ? answered.removed.reduce((n, r) => n + (r.affected ?? 0), 0)
2098
+ : undefined;
2099
+ const written = name === 'update' ? affected
2100
+ : name === 'delete' && answered.deleted === true ? affected
2101
+ : graphOut ?? graphIn;
2102
+ const removals = name === 'delete' || name === 'delete_graph';
2103
+ const inserted = name === 'insert' && answered.inserted === true ? 1 : graphIn;
2104
+ if (rows && typeof answered.connection === 'string') {
2105
+ sessionEgress.set(answered.connection, (sessionEgress.get(answered.connection) ?? 0) + rows);
2106
+ }
2107
+ const structured = {
2108
+ ...answered,
2109
+ stats: {
2110
+ ms: Date.now() - started,
2111
+ ...(rows === undefined ? {} : { rowsRead: rows }),
2112
+ ...(written === undefined ? {} : removals ? { rowsDeleted: written } : { rowsWritten: written }),
2113
+ ...(inserted === undefined ? {} : { rowsInserted: inserted }),
2114
+ },
2115
+ };
2116
+ return {
2117
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
2118
+ structuredContent: structured,
2119
+ isError: false,
2120
+ };
2121
+ }
2122
+ catch (err) {
2123
+ /* A tool that fails is a result, not a protocol error — the model
2124
+ reads the message and adjusts; a JSON-RPC error would read as
2125
+ the server being broken. */
2126
+ return {
2127
+ content: [{ type: 'text', text: err.message }],
2128
+ isError: true,
2129
+ };
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Where a value lives: a capped sweep of every table's text columns.
2134
+ *
2135
+ * Honest about being a sweep. Each table costs one query — the text columns
2136
+ * OR-ed into a single `contains` — a few rows per table, a cap on the total,
2137
+ * and the answer says how far it looked. On a shape this can afford it is
2138
+ * the most natural question an agent asks — "which table mentions Nakamura"
2139
+ * — and on one it cannot, the caps are what make asking survivable.
2140
+ */
2141
+ /* How each engine says "there is no such table", and where the name sits in
2142
+ what it said. Recognising the message rather than an error code because
2143
+ that is what the three drivers agree on having. */
2144
+ const NO_SUCH_TABLE = [
2145
+ /no such table:\s*([^\s;]+)/i, // sqlite
2146
+ /relation "([^"]+)" does not exist/i, // postgres
2147
+ /Table '(?:[^.']*\.)?([^']+)' doesn't exist/i, // mysql
2148
+ ];
2149
+ /**
2150
+ * Run raw SQL and, if the engine refuses it, answer the way the rest of these
2151
+ * tools do.
2152
+ *
2153
+ * `sql` and `explain` are the two tools that hand a statement to the engine
2154
+ * whole, so they are the two that answered "no such table: nonesuch" where
2155
+ * `query` answers with what exists and what to ask next. An agent retrying
2156
+ * against a message like that has nothing to retry *with* — the whole value
2157
+ * of an error here is the sentence after the diagnosis.
2158
+ */
2159
+ async function teaching(schema, run) {
2160
+ try {
2161
+ return await run();
2162
+ }
2163
+ catch (err) {
2164
+ const message = err.message ?? String(err);
2165
+ const named = NO_SUCH_TABLE.map((p) => p.exec(message)).find(Boolean)?.[1];
2166
+ if (!named)
2167
+ throw err;
2168
+ const bare = named.replace(/^.*\./, '').replace(/["`]/g, '');
2169
+ const near = schema.tables
2170
+ .map((t) => ({ id: t.id, d: distance(bare.toLowerCase(), t.name.toLowerCase()) }))
2171
+ .sort((a, b) => a.d - b.d)[0];
2172
+ const suggestion = near && near.d <= 3 ? ` Did you mean "${near.id}"?` : '';
2173
+ throw new Error(`${message}.${suggestion} Ask \`tables\` for what exists — or \`refresh\` if it was `
2174
+ + 'just created. Names here are as the database spells them, schema included.');
2175
+ }
2176
+ }
2177
+ /** A uuid, whole — the only shape of it a sweep can match honestly. */
2178
+ const WHOLE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2179
+ /**
2180
+ * The columns of one table a value sweep can actually ask about.
2181
+ *
2182
+ * The type list is not "which columns hold text" but "which columns this
2183
+ * database will accept a LIKE against", and the difference is the whole
2184
+ * finding: `character varying[]` contains "varchar" and `uuid` reads like a
2185
+ * string, and Postgres has no LIKE operator for either. One thrown statement
2186
+ * takes the whole table with it — its genuine text columns included — so a
2187
+ * table full of the value reports nothing while `scanned` counts it as
2188
+ * searched.
2189
+ *
2190
+ * uuid columns come back separately because equality does work on them: a
2191
+ * whole uuid is exactly the value someone pastes out of a log, and dropping
2192
+ * uuid keys from the sweep would miss the commonest primary key in modern
2193
+ * Postgres.
2194
+ */
2195
+ function searchableColumns(table, value) {
2196
+ const like = [];
2197
+ const exact = [];
2198
+ for (const column of table.columns) {
2199
+ const type = column.type;
2200
+ /* An array of text is not text: `text[] LIKE '%x%'` is an error, not an
2201
+ empty result. */
2202
+ if (/\[\]|^ARRAY$/i.test(type))
2203
+ continue;
2204
+ if (/^uuid$/i.test(type)) {
2205
+ if (WHOLE_UUID.test(value))
2206
+ exact.push(column.name);
2207
+ continue;
2208
+ }
2209
+ if (/char|text|varchar|string|clob|citext|enum/i.test(type))
2210
+ like.push(column.name);
2211
+ }
2212
+ return { like, exact };
2213
+ }
2214
+ /**
2215
+ * Where a value lives: a bounded sweep of every table's text columns.
2216
+ *
2217
+ * Honest about being a sweep, which means honest about three separate
2218
+ * things, because a sweep that hides any of them fabricates a negative —
2219
+ * `{matches: [], scanned: 12}` reads as "I searched twelve tables and it is
2220
+ * not there":
2221
+ *
2222
+ * - a table it could not read is named, with the reason, not counted as
2223
+ * searched;
2224
+ * - a table that had more matches than it reported is named;
2225
+ * - and when a budget rather than the data ended the sweep, it says so and
2226
+ * says how many tables it never reached.
2227
+ *
2228
+ * The budgets bound the work, not just the answer. A value that matches
2229
+ * nothing — the usual reason for searching — costs one unindexed scan per
2230
+ * table with no early exit, and on SQLite the driver is synchronous, so
2231
+ * those scans block the web UI and every other call with them.
2232
+ */
2233
+ export async function findValue(adapter, schema, id, value) {
2234
+ const PER_TABLE = 5;
2235
+ const TOTAL = 40;
2236
+ const BUDGET_MS = 5_000;
2237
+ const deadline = Date.now() + BUDGET_MS;
2238
+ const matches = [];
2239
+ const failed = [];
2240
+ const moreIn = [];
2241
+ const needle = value.toLowerCase();
2242
+ let searched = 0;
2243
+ let withoutText = 0;
2244
+ let unreached = 0;
2245
+ let stopped;
2246
+ for (const table of schema.tables) {
2247
+ if (stopped) {
2248
+ unreached += 1;
2249
+ continue;
2250
+ }
2251
+ if (matches.length >= TOTAL) {
2252
+ stopped = 'match cap';
2253
+ unreached += 1;
2254
+ continue;
2255
+ }
2256
+ if (Date.now() > deadline) {
2257
+ stopped = 'time budget';
2258
+ unreached += 1;
2259
+ continue;
2260
+ }
2261
+ const { like, exact } = searchableColumns(table, value);
2262
+ if (!like.length && !exact.length) {
2263
+ withoutText += 1;
2264
+ continue;
2265
+ }
2266
+ const key = primaryKey(table);
2267
+ const wanted = [...new Set([...key, ...like, ...exact])];
2268
+ /* One more than needed, so "there were exactly five" and "there were at
2269
+ least five" stay distinguishable. */
2270
+ const room = Math.min(PER_TABLE, TOTAL - matches.length);
2271
+ try {
2272
+ const found = await adapter.query({
2273
+ table: table.id,
2274
+ /* One group per column: groups are OR-ed, which is exactly "any of
2275
+ these columns holds it". */
2276
+ filter: {
2277
+ groups: [
2278
+ ...like.map((column) => [{ column, op: 'contains', value }]),
2279
+ ...exact.map((column) => [{ column, op: '=', value }]),
2280
+ ],
2281
+ },
2282
+ columns: wanted,
2283
+ limit: room + 1,
2284
+ offset: 0,
2285
+ });
2286
+ searched += 1;
2287
+ const rows = found.rows;
2288
+ if (rows.length > room)
2289
+ moreIn.push(table.id);
2290
+ for (const row of rows.slice(0, room)) {
2291
+ const where = wanted.filter((c) => {
2292
+ const held = row[c];
2293
+ if (held === null || held === undefined)
2294
+ return false;
2295
+ return exact.includes(c)
2296
+ ? String(held).toLowerCase() === needle
2297
+ : like.includes(c) && String(held).toLowerCase().includes(needle);
2298
+ });
2299
+ /* A match that cannot name the column it matched on is not evidence
2300
+ of anything — it is the row the database returned for a pattern
2301
+ this code no longer understands. */
2302
+ if (!where.length)
2303
+ continue;
2304
+ matches.push({
2305
+ table: table.id,
2306
+ columns: where,
2307
+ key: key.length ? Object.fromEntries(key.map((c) => [c, row[c]])) : undefined,
2308
+ values: Object.fromEntries(where.map((c) => [c, row[c]])),
2309
+ });
2310
+ }
2311
+ if (matches.length >= TOTAL)
2312
+ stopped = 'match cap';
2313
+ }
2314
+ catch (err) {
2315
+ /* Named, not swallowed. One unreadable table must not stop the sweep,
2316
+ and must not be reported as a searched one either. */
2317
+ failed.push({ table: table.id, reason: err.message });
2318
+ }
2319
+ }
2320
+ return {
2321
+ connection: id,
2322
+ value,
2323
+ matches,
2324
+ searched,
2325
+ withoutText,
2326
+ failed: failed.length ? failed : undefined,
2327
+ moreIn: moreIn.length ? moreIn : undefined,
2328
+ stopped,
2329
+ unreached: unreached || undefined,
2330
+ };
2331
+ }
2332
+ /**
2333
+ * One record's whole answer: the row, its walked-to name, and what points at
2334
+ * it — with the counts' rows inlined when the caller asked to expand. The
2335
+ * chattiest pattern in real sessions was record-then-one-query-per-
2336
+ * relationship; `expand` folds it into the call that already knew the
2337
+ * conditions.
2338
+ */
2339
+ async function recordAnswer(adapter, schema, id, table, key, expand) {
2340
+ const filter = {
2341
+ groups: [Object.entries(key).map(([column, value]) => ({ column, op: '=', value }))],
2342
+ };
2343
+ const found = await adapter.query({ table: table.id, filter, limit: 1, offset: 0 });
2344
+ const row = found.rows[0];
2345
+ if (!row)
2346
+ return { key, found: false };
2347
+ let label;
2348
+ const path = labelPath(schema, table);
2349
+ if (path && !path.includes('.'))
2350
+ label = row[path];
2351
+ else if (path && adapter.runView) {
2352
+ const named = await adapter.runView({
2353
+ id: 'mcp:name', name: table.name, base: table.id,
2354
+ columns: [{ path }],
2355
+ filter: { groups: [Object.entries(key).map(([p, value]) => ({ path: p, op: '=', value }))] },
2356
+ limit: 1,
2357
+ }, 0);
2358
+ label = named.rows[0]?.[path];
2359
+ }
2360
+ const pointedAtBy = [];
2361
+ for (const fk of referencesTo(schema, table.id)) {
2362
+ const conditions = fk.to.columns.map((toCol, i) => ({
2363
+ column: fk.from.columns[i] ?? fk.from.columns[0],
2364
+ op: '=',
2365
+ value: key[toCol],
2366
+ }));
2367
+ if (conditions.some((c) => c.value === undefined || c.value === null))
2368
+ continue;
2369
+ try {
2370
+ const count = await adapter.count(fk.from.table, { groups: [conditions] });
2371
+ const entry = {
2372
+ table: fk.from.table,
2373
+ via: fk.from.columns,
2374
+ count,
2375
+ query: count > 0
2376
+ ? `${fk.from.table} ${conditions.map((c) => `${c.column} = ${quoteValue(String(c.value))}`).join(' and ')}`
2377
+ : undefined,
2378
+ };
2379
+ if (expand > 0 && count > 0) {
2380
+ const children = await adapter.query({
2381
+ table: fk.from.table,
2382
+ filter: { groups: [conditions] },
2383
+ limit: expand,
2384
+ offset: 0,
2385
+ });
2386
+ entry.rows = children.rows;
2387
+ }
2388
+ pointedAtBy.push(entry);
2389
+ }
2390
+ catch {
2391
+ /* One unreadable table should not blank the answer. */
2392
+ }
2393
+ }
2394
+ const body = Object.keys(key).length === 1
2395
+ ? String(Object.values(key)[0])
2396
+ : Object.entries(key).map(([c, v]) => `${c}=${v}`).join(',');
2397
+ return {
2398
+ key,
2399
+ found: true,
2400
+ label: label ?? undefined,
2401
+ row,
2402
+ pointedAtBy,
2403
+ link: linkTo(id, `${encodeURIComponent(table.id)}/~${encodeURIComponent(body)}`),
2404
+ };
2405
+ }
2406
+ /**
2407
+ * One table as Markdown: the DDL, then the part no DDL states — what points
2408
+ * here. The same facts the `table` tool answers, shaped for attachment
2409
+ * rather than for parsing.
2410
+ */
2411
+ function tableBrief(schema, table) {
2412
+ const lines = [
2413
+ `## ${table.id}`,
2414
+ '',
2415
+ '```sql',
2416
+ renderDDL(schema, table.id).trim(),
2417
+ '```',
2418
+ '',
2419
+ ];
2420
+ /* The DDL above is a reconstruction and drops checks, so a vocabulary read
2421
+ out of one would vanish here if it were not said again. */
2422
+ const vocab = table.columns.filter((c) => c.allowed?.length);
2423
+ if (vocab.length) {
2424
+ lines.push('Allowed values:', ...vocab.map((c) => `- ${c.name}: ${c.allowed.map((v) => `\`${v}\``).join(', ')}`), '');
2425
+ }
2426
+ const out = referencesFrom(schema, table.id);
2427
+ if (out.length) {
2428
+ lines.push('Points at:', ...out.map((fk) => `- ${fk.from.columns.join(', ')} → ${fk.to.table}`), '');
2429
+ }
2430
+ const back = referencesTo(schema, table.id);
2431
+ if (back.length) {
2432
+ lines.push('Pointed at by:', ...back.map((fk) => `- ${fk.from.table} via ${fk.from.columns.join(', ')}${fk.onDelete ? ` (on delete ${fk.onDelete})` : ''}`), '');
2433
+ }
2434
+ if (!out.length && !back.length)
2435
+ lines.push('No foreign keys in either direction.', '');
2436
+ return lines.join('\n');
2437
+ }
2438
+ function queriesBrief(shelf, connection, name) {
2439
+ const mine = queriesFor(shelf.queries, connection, name);
2440
+ if (!mine.length)
2441
+ return `# Named queries — ${name}\n\nNone written down for this connection.`;
2442
+ const lines = [`# Named queries — ${name}`, '', `${mine.length} statement${mine.length === 1 ? '' : 's'} from your config. Run one with the \`sql\` tool.`, ''];
2443
+ for (const query of mine) {
2444
+ lines.push(`## ${query.name}`, '');
2445
+ if (query.description)
2446
+ lines.push(query.description, '');
2447
+ lines.push('```sql', query.sql.trim(), '```', '');
2448
+ }
2449
+ return lines.join('\n').trim();
2450
+ }
2451
+ function pagesBrief(pages, saved, name) {
2452
+ const all = [
2453
+ ...pages.map((p) => ({ id: p.id, name: p.name, base: p.base, sections: p.sections?.length ?? 0, from: 'config' })),
2454
+ ...Object.entries(saved).map(([id, page]) => {
2455
+ const def = page;
2456
+ return { id, name: def.name ?? id, base: def.base ?? '?', sections: def.sections?.length ?? 0, from: 'browser' };
2457
+ }),
2458
+ ];
2459
+ if (!all.length)
2460
+ return `# Pages — ${name}\n\nNone defined for this connection.`;
2461
+ const lines = [
2462
+ `# Pages — ${name}`, '',
2463
+ 'A page is one record with everything around it — counts, lists and related rows — '
2464
+ + 'assembled by whoever knows this schema. The root table is where a page starts.', '',
2465
+ '| page | root table | sections | from |', '| --- | --- | --- | --- |',
2466
+ ];
2467
+ for (const page of all)
2468
+ lines.push(`| ${page.name} | ${page.base} | ${page.sections} | ${page.from} |`);
2469
+ return lines.join('\n');
2470
+ }
2471
+ export function serveMcp(registry, version, shelf = { queries: [], pages: [] }) {
2472
+ const write = (message) => {
2473
+ process.stdout.write(`${JSON.stringify(message)}\n`);
2474
+ };
2475
+ /* Resource uris the client asked to hear about. Only the schema briefs
2476
+ change today — refresh rewrites them — and a subscription is the one
2477
+ honest channel for saying a *content* changed: the list did not. */
2478
+ const subscribed = new Set();
2479
+ const notify = (method, params) => write({ jsonrpc: '2.0', method, params });
2480
+ const rl = createInterface({ input: process.stdin });
2481
+ /* Requests run in the order they arrive, one at a time.
2482
+
2483
+ Dispatch used to fire per line without awaiting, which let a pipelining
2484
+ client race itself: an update followed immediately by the query that
2485
+ checks it could read the row from before the write. Interactive clients
2486
+ await each response and never notice; correctness for the client that
2487
+ does not costs one promise chain. A chain off a failure keeps going —
2488
+ one bad request must not silence every later one. */
2489
+ let inOrder = Promise.resolve();
2490
+ rl.on('line', (line) => {
2491
+ if (!line.trim())
2492
+ return;
2493
+ let msg;
2494
+ try {
2495
+ msg = JSON.parse(line);
2496
+ }
2497
+ catch {
2498
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'That line is not JSON.' } });
2499
+ return;
2500
+ }
2501
+ inOrder = inOrder.catch(() => { }).then(() => dispatch(msg));
2502
+ });
2503
+ /* Exit only after the work is done and stdout has drained — both, in that
2504
+ order. A client that writes its calls and closes stdin — a script, a
2505
+ test — has ended the *questions*, not the answers: the dispatch chain
2506
+ may still be opening a connection for the very call the client is
2507
+ waiting on, and exiting on close alone raced it. SQLite answered fast
2508
+ enough to win that race by accident; a Postgres connect did not. Then
2509
+ the empty write: process.exit truncates a large in-flight pipe write,
2510
+ and a reply cut mid-string is worse than a late one. */
2511
+ rl.on('close', () => {
2512
+ void inOrder.catch(() => { }).then(() => {
2513
+ process.stdout.write('', () => process.exit(0));
2514
+ });
2515
+ });
2516
+ async function dispatch(msg) {
2517
+ const { id, method, params = {} } = msg;
2518
+ // Notifications get no reply, including the one that ends the handshake.
2519
+ if (id === undefined || id === null)
2520
+ return;
2521
+ try {
2522
+ if (method === 'initialize') {
2523
+ const asked = String(params.protocolVersion ?? '');
2524
+ return write({
2525
+ jsonrpc: '2.0', id,
2526
+ result: {
2527
+ protocolVersion: KNOWN_VERSIONS.includes(asked) ? asked : DEFAULT_VERSION,
2528
+ capabilities: { tools: {}, resources: { subscribe: true }, completions: {}, prompts: {} },
2529
+ serverInfo: { name: 'tablewalk', version },
2530
+ instructions: 'tablewalk exposes relational databases read-only. Call `schema_summary` first '
2531
+ + 'to orient, `table` for definitions and both directions of foreign keys, '
2532
+ + '`query` for rows (its language is documented on the tool), and `record` to '
2533
+ + 'see one row with everything that points at it. Results carry a `link` a '
2534
+ + 'human can open in the tablewalk UI.',
2535
+ },
2536
+ });
2537
+ }
2538
+ if (method === 'ping')
2539
+ return write({ jsonrpc: '2.0', id, result: {} });
2540
+ /* Resources are the reference shelf: things a client can attach to a
2541
+ conversation without spending a tool call, @-mentionable where the
2542
+ client supports it. The schema brief per connection is concrete;
2543
+ tables come as a template, because listing sixty-six of them per
2544
+ connection would drown the one resource everyone wants. */
2545
+ if (method === 'resources/list') {
2546
+ return write({
2547
+ jsonrpc: '2.0', id,
2548
+ result: {
2549
+ resources: registry.list().flatMap((c) => [
2550
+ {
2551
+ uri: `tablewalk://${encodeURIComponent(c.id)}/schema`,
2552
+ name: `${c.name} — schema brief`,
2553
+ description: 'Every table with its columns and keys, and the foreign-key graph in both directions, as Markdown.',
2554
+ mimeType: 'text/markdown',
2555
+ },
2556
+ /* Listed only where there is something on the shelf: an empty
2557
+ document offered per connection is three lines of nothing in
2558
+ every client's resource picker. */
2559
+ ...(queriesFor(shelf.queries, c.id, c.name).length ? [{
2560
+ uri: `tablewalk://${encodeURIComponent(c.id)}/queries`,
2561
+ name: `${c.name} — named queries`,
2562
+ description: 'The statements this team wrote down, with their descriptions. Not run by reading them.',
2563
+ mimeType: 'text/markdown',
2564
+ }] : []),
2565
+ ...(shelf.pages.some((p) => !p.connection || p.connection === c.id || p.connection === c.name) ? [{
2566
+ uri: `tablewalk://${encodeURIComponent(c.id)}/pages`,
2567
+ name: `${c.name} — pages`,
2568
+ description: 'Record pages defined for this connection: the root table each starts from, and how much is on it.',
2569
+ mimeType: 'text/markdown',
2570
+ }] : []),
2571
+ ]),
2572
+ },
2573
+ });
2574
+ }
2575
+ if (method === 'resources/templates/list') {
2576
+ return write({
2577
+ jsonrpc: '2.0', id,
2578
+ result: {
2579
+ resourceTemplates: [{
2580
+ uriTemplate: 'tablewalk://{connection}/table/{table}',
2581
+ name: 'One table in full',
2582
+ description: 'DDL plus foreign keys in both directions, as Markdown. {connection} is a connection id, {table} a table id.',
2583
+ mimeType: 'text/markdown',
2584
+ }],
2585
+ },
2586
+ });
2587
+ }
2588
+ /* Prompts package the tool-chaining knowledge that otherwise lives
2589
+ only in whichever agent happens to have learned it. Each returns
2590
+ instructions plus the resources they lean on, embedded — the brief
2591
+ arrives with the plan instead of being a call the plan asks for. */
2592
+ if (method === 'prompts/list') {
2593
+ return write({
2594
+ jsonrpc: '2.0', id,
2595
+ result: {
2596
+ prompts: [
2597
+ {
2598
+ name: 'orient',
2599
+ title: 'Orient in this database',
2600
+ description: 'The schema brief plus how to start: hubs, event tables, and the first calls worth making.',
2601
+ arguments: [{ name: 'connection', description: 'Connection id; defaults to the active one.', required: false }],
2602
+ },
2603
+ {
2604
+ name: 'pre-migration-check',
2605
+ title: 'Check before a migration',
2606
+ description: 'Everything worth knowing before DDL touches these tables: both directions of their keys, delete rules, and what the columns actually hold.',
2607
+ arguments: [
2608
+ { name: 'tables', description: 'Comma-separated table ids the migration touches.', required: true },
2609
+ { name: 'connection', description: 'Connection id; defaults to the active one.', required: false },
2610
+ ],
2611
+ },
2612
+ {
2613
+ name: 'investigate-row',
2614
+ title: 'Investigate one row',
2615
+ description: 'Walk a row properly: the record with children inlined, which relationships to follow, and when to profile a suspicious column.',
2616
+ arguments: [
2617
+ { name: 'table', description: 'Table id.', required: true },
2618
+ { name: 'key', description: 'The primary key as JSON, e.g. {"id": 42}.', required: true },
2619
+ { name: 'connection', description: 'Connection id; defaults to the active one.', required: false },
2620
+ ],
2621
+ },
2622
+ ],
2623
+ },
2624
+ });
2625
+ }
2626
+ if (method === 'prompts/get') {
2627
+ const { name: promptName, arguments: promptArgs = {} } = params;
2628
+ try {
2629
+ const conn = promptArgs.connection;
2630
+ const { schema, id: connId } = await openFor(registry, conn);
2631
+ const brief = (uri, text) => ({
2632
+ role: 'user',
2633
+ content: { type: 'resource', resource: { uri, mimeType: 'text/markdown', text } },
2634
+ });
2635
+ if (promptName === 'orient') {
2636
+ return write({
2637
+ jsonrpc: '2.0', id,
2638
+ result: {
2639
+ description: `Orientation in ${schema.label}`,
2640
+ messages: [
2641
+ brief(`tablewalk://${encodeURIComponent(connId)}/schema`, toMarkdown(schema)),
2642
+ {
2643
+ role: 'user',
2644
+ content: {
2645
+ type: 'text',
2646
+ text: 'Read the attached schema brief before calling anything. Then call schema_summary '
2647
+ + 'for the hubs and the deepest walk, and open your first table with `table` rather '
2648
+ + 'than guessing columns. Prefer `query` with `show` over `sql`; use `find` when you '
2649
+ + 'only know a name; call `refresh` after any DDL.',
2650
+ },
2651
+ },
2652
+ ],
2653
+ },
2654
+ });
2655
+ }
2656
+ if (promptName === 'pre-migration-check') {
2657
+ const wanted = String(promptArgs.tables ?? '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 8);
2658
+ if (!wanted.length) {
2659
+ return write({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Which tables? Pass `tables` as a comma-separated list.' } });
2660
+ }
2661
+ const attached = wanted
2662
+ .map((t) => tableNamed(schema, t))
2663
+ .filter((t) => Boolean(t))
2664
+ .map((t) => brief(`tablewalk://${encodeURIComponent(connId)}/table/${encodeURIComponent(t.id)}`, tableBrief(schema, t)));
2665
+ return write({
2666
+ jsonrpc: '2.0', id,
2667
+ result: {
2668
+ description: `Pre-migration check: ${wanted.join(', ')}`,
2669
+ messages: [
2670
+ ...attached,
2671
+ {
2672
+ role: 'user',
2673
+ content: {
2674
+ type: 'text',
2675
+ text: `Before writing DDL for ${wanted.join(', ')}: the attached briefs carry both `
2676
+ + 'directions of each table\u2019s keys and every ON DELETE rule — anything pointing at '
2677
+ + 'a table you are changing is a migration constraint. For each column the migration '
2678
+ + 'actually touches, call `change_impact {table, column}`: it answers the questions '
2679
+ + 'that decide whether the DDL takes at all — how many rows hold null (so whether NOT '
2680
+ + 'NULL would be refused), what points at it under which delete rule, whether those '
2681
+ + 'referencing columns are NOT NULL, and whether an index leads on them. Those facts '
2682
+ + 'are in the database and nowhere in the repository, so they cannot be read off the '
2683
+ + 'code. Call `profile` on each table for the wider column shape (ranges and common '
2684
+ + 'values decide whether a narrower type can land), and `tables` for row counts that '
2685
+ + `decide whether the migration needs batching. \`order {tables: ${JSON.stringify(wanted)}}\` gives the `
2686
+ + 'order rows have to arrive and leave in, which is the order the DDL has to respect '
2687
+ + 'too, and names any cycle between them. `lint` on the same tables says what the '
2688
+ + 'shape already costs — an unindexed foreign key is a migration that scans, and a '
2689
+ + 'reference with no ON DELETE rule is a decision this migration could make instead of '
2690
+ + 'postponing again. If another connection already has the intended shape, `diff` says '
2691
+ + 'exactly what is missing. After the DDL runs, call `refresh` before anything else.',
2692
+ },
2693
+ },
2694
+ ],
2695
+ },
2696
+ });
2697
+ }
2698
+ if (promptName === 'investigate-row') {
2699
+ const table = String(promptArgs.table ?? '');
2700
+ const key = String(promptArgs.key ?? '');
2701
+ return write({
2702
+ jsonrpc: '2.0', id,
2703
+ result: {
2704
+ description: `Investigate ${table} ${key}`,
2705
+ messages: [{
2706
+ role: 'user',
2707
+ content: {
2708
+ type: 'text',
2709
+ text: `Investigate ${table} ${key} on connection ${connId}. Start with `
2710
+ + `\`record {table: "${table}", key: ${key || '{...}'}, expand: 5}\` — the row, its human `
2711
+ + 'name, and the first rows of everything pointing at it. Follow the relationships whose '
2712
+ + 'counts look wrong for the story the row tells; each carries a ready-to-run `query`. '
2713
+ + 'When a column\u2019s value looks suspicious, `profile` its table to see whether the value '
2714
+ + 'is an outlier or the norm. When a value on the row appears somewhere you cannot '
2715
+ + 'account for — an id, a reference, a string out of a log — `find {value: "…"}` says '
2716
+ + 'which other tables mention it. Every answer carries a `link` — cite it, so a human '
2717
+ + 'can open exactly what you saw.',
2718
+ },
2719
+ }],
2720
+ },
2721
+ });
2722
+ }
2723
+ return write({ jsonrpc: '2.0', id, error: { code: -32602, message: `No prompt called "${String(promptName)}".` } });
2724
+ }
2725
+ catch (err) {
2726
+ return write({ jsonrpc: '2.0', id, error: { code: -32602, message: err.message } });
2727
+ }
2728
+ }
2729
+ if (method === 'resources/subscribe' || method === 'resources/unsubscribe') {
2730
+ const uri = String(params.uri ?? '');
2731
+ if (method === 'resources/subscribe')
2732
+ subscribed.add(uri);
2733
+ else
2734
+ subscribed.delete(uri);
2735
+ return write({ jsonrpc: '2.0', id, result: {} });
2736
+ }
2737
+ /* Argument completion for the resource template: the same
2738
+ errors-teach spirit, moved before the mistake — a client that asks
2739
+ here never mistypes a table id at all. */
2740
+ if (method === 'completion/complete') {
2741
+ const { ref, argument, context } = params;
2742
+ const prefix = String(argument?.value ?? '').toLowerCase();
2743
+ let values = [];
2744
+ if (ref?.type === 'ref/resource' || ref?.type === 'ref/prompt') {
2745
+ if (argument?.name === 'connection') {
2746
+ values = registry.list().map((c) => c.id);
2747
+ }
2748
+ else if (argument?.name === 'table' || argument?.name === 'tables') {
2749
+ const conn = context?.arguments?.connection ?? registry.active ?? '';
2750
+ try {
2751
+ const { schema } = await openFor(registry, conn);
2752
+ values = schema.tables.map((t) => t.id);
2753
+ }
2754
+ catch {
2755
+ /* An unknown connection completes to nothing, not to an error —
2756
+ half-typed context is the normal state of a completion. */
2757
+ }
2758
+ }
2759
+ }
2760
+ const matched = values.filter((v) => v.toLowerCase().startsWith(prefix));
2761
+ return write({
2762
+ jsonrpc: '2.0', id,
2763
+ result: {
2764
+ completion: {
2765
+ values: matched.slice(0, 100),
2766
+ total: matched.length,
2767
+ hasMore: matched.length > 100,
2768
+ },
2769
+ },
2770
+ });
2771
+ }
2772
+ if (method === 'resources/read') {
2773
+ const uri = String(params.uri ?? '');
2774
+ const match = /^tablewalk:\/\/([^/]+)\/(schema|queries|pages|table\/(.+))$/.exec(uri);
2775
+ if (!match) {
2776
+ return write({ jsonrpc: '2.0', id, error: { code: -32002, message: `No resource at "${uri}".` } });
2777
+ }
2778
+ try {
2779
+ const connId = decodeURIComponent(match[1]);
2780
+ const named = registry.list().find((c) => c.id === connId);
2781
+ if (match[2] === 'queries' || match[2] === 'pages') {
2782
+ /* The shelf is config and files, not the database — answered
2783
+ without opening a connection, so a resource about a database
2784
+ that is down still reads. */
2785
+ const label = named?.name ?? connId;
2786
+ const text = match[2] === 'queries'
2787
+ ? queriesBrief(shelf, connId, label)
2788
+ : pagesBrief(shelf.pages.filter((p) => !p.connection || p.connection === connId || p.connection === label), (await shelf.savedPages?.(connId)) ?? {}, label);
2789
+ return write({
2790
+ jsonrpc: '2.0', id,
2791
+ result: { contents: [{ uri, mimeType: 'text/markdown', text }] },
2792
+ });
2793
+ }
2794
+ const { schema } = await openFor(registry, connId);
2795
+ if (match[2] === 'schema') {
2796
+ return write({
2797
+ jsonrpc: '2.0', id,
2798
+ result: { contents: [{ uri, mimeType: 'text/markdown', text: toMarkdown(schema) }] },
2799
+ });
2800
+ }
2801
+ const table = tableNamed(schema, decodeURIComponent(match[3]));
2802
+ if (!table) {
2803
+ return write({ jsonrpc: '2.0', id, error: { code: -32002, message: `No table "${decodeURIComponent(match[3])}" behind "${uri}".` } });
2804
+ }
2805
+ return write({
2806
+ jsonrpc: '2.0', id,
2807
+ result: { contents: [{ uri, mimeType: 'text/markdown', text: tableBrief(schema, table) }] },
2808
+ });
2809
+ }
2810
+ catch (err) {
2811
+ return write({ jsonrpc: '2.0', id, error: { code: -32002, message: err.message } });
2812
+ }
2813
+ }
2814
+ if (method === 'tools/list') {
2815
+ return write({ jsonrpc: '2.0', id, result: { tools: agentTools(registry) } });
2816
+ }
2817
+ if (method === 'tools/call') {
2818
+ const name = String(params.name ?? '');
2819
+ const args = (params.arguments ?? {});
2820
+ const result = await runTool(registry, name, args);
2821
+ write({ jsonrpc: '2.0', id, result });
2822
+ /* A refresh that succeeded has rewritten the connection's brief.
2823
+ Anyone subscribed hears it in the protocol's own words, after the
2824
+ answer — a client should never learn of the change before the call
2825
+ that caused it returns. */
2826
+ if (name === 'refresh' && !result.isError) {
2827
+ const conn = String(result.structuredContent?.connection ?? '');
2828
+ const uri = `tablewalk://${encodeURIComponent(conn)}/schema`;
2829
+ if (subscribed.has(uri))
2830
+ notify('notifications/resources/updated', { uri });
2831
+ }
2832
+ return;
2833
+ }
2834
+ return write({ jsonrpc: '2.0', id, error: { code: -32601, message: `No such method: ${method}` } });
2835
+ }
2836
+ catch (err) {
2837
+ return write({ jsonrpc: '2.0', id, error: { code: -32603, message: err.message } });
2838
+ }
2839
+ }
2840
+ }