@nifrajs/mcp-db 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -24,18 +24,21 @@
24
24
  */
25
25
 
26
26
  import { createMcpServer, defineMcpTool, type JsonRpcResponse, type McpServer } from "@nifrajs/mcp"
27
-
28
- /** The structural slice of `bun:sqlite`'s `Database` this package needs. */
29
- export interface SqliteDatabaseLike {
30
- /** Prepare a statement; `all` runs it and returns row objects. */
31
- prepare(sql: string): { all(...params: unknown[]): unknown[] }
32
- /** Execute a statement for its side effect (used only for `PRAGMA query_only`). */
33
- run(sql: string): unknown
34
- /** Optional path this database was opened from; a file-backed one can be reopened read-only in a
35
- * worker so `run_query` runs off the serving connection. `:memory:` and the empty anonymous
36
- * database cannot, and run in process. */
37
- readonly filename?: string
38
- }
27
+ import {
28
+ boundedSqliteQuery,
29
+ countSqliteQuery,
30
+ fitToBytes,
31
+ gateSqliteStatement,
32
+ type SqliteDatabaseLike,
33
+ unexposedPlanRelation,
34
+ } from "./internal/shared.ts"
35
+ import {
36
+ openQuerySession,
37
+ type QuerySession,
38
+ QueryTimeoutError,
39
+ } from "./internal/sqlite-session.ts"
40
+
41
+ export type { SqliteDatabaseLike } from "./internal/shared.ts"
39
42
 
40
43
  /** Context forwarded to `authorize` - the inbound HTTP Request carrying the `run_query` call. */
41
44
  export interface McpDbAuthorizeContext {
@@ -89,6 +92,12 @@ export interface ServeDatabaseAsMcpOptions {
89
92
  * yours to provide (e.g. open a second, read-only connection for the MCP mount - preferred).
90
93
  */
91
94
  readonly enforceQueryOnly?: boolean
95
+ /**
96
+ * Host allowlist for the HTTP transport (e.g. `["localhost", "127.0.0.1"]`). Set it when the server
97
+ * listens on localhost or a private network: the same-origin default alone does not stop a
98
+ * DNS-rebound browser page from reading the schema tools.
99
+ */
100
+ readonly allowedHosts?: readonly string[]
92
101
  }
93
102
 
94
103
  export class McpDbConfigError extends Error {
@@ -113,568 +122,12 @@ function sqlIdentifiers(strings: TemplateStringsArray, ...identifiers: readonly
113
122
  return sql
114
123
  }
115
124
 
116
- const stripSqlNoise = (sql: string): string =>
117
- sql
118
- // Strings first so comment markers inside literals don't count.
119
- .replace(/'(?:[^']|'')*'/g, "''")
120
- .replace(/"(?:[^"]|"")*"/g, '""')
121
- .replace(/--[^\n]*/g, " ")
122
- .replace(/\/\*[\s\S]*?\*\//g, " ")
123
-
124
- // SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
125
- // an identifier character; leaving it out would end a word early and split one table name into two.
126
- const WORD_START = /[A-Za-z_\u0080-\uffff]/
127
- const WORD_PART = /[A-Za-z0-9_$\u0080-\uffff]/
128
-
129
- /** One lexical unit of SQL. `bare` distinguishes a keyword-capable word from a quoted identifier, so
130
- * `FROM "from"` reads the second token as a table name and not as another clause. */
131
- interface SqlToken {
132
- readonly kind: "word" | "punct"
133
- /** Identifier text with quoting removed and escapes collapsed; the raw character for punctuation. */
134
- readonly value: string
135
- /** True for an unquoted word - only these can be SQL keywords. */
136
- readonly bare: boolean
137
- }
138
-
139
- /**
140
- * Tokenize far enough to name every relation. A regex cannot do this job: SQLite needs no separator
141
- * between a keyword and a quoted identifier (`FROM"users"` is legal), and it accepts four identifier
142
- * quotings - `"x"`, `[x]`, `` `x` ``, and bare. A `\s+`-anchored pattern silently reads none of
143
- * those as a table reference, which is exactly how an aliased `FROM"users"AS habits` slipped past
144
- * the allowlist and returned a non-exposed table's rows.
145
- *
146
- * String literals and comments become nothing, so a `FROM` inside attacker-controlled text is inert.
147
- */
148
- function tokenizeSql(sql: string): SqlToken[] {
149
- const tokens: SqlToken[] = []
150
- let i = 0
151
- /** Read a quoted identifier, collapsing the doubled-delimiter escape SQLite uses for `"` and `` ` ``. */
152
- const readQuoted = (close: string, escapable: boolean): void => {
153
- let value = ""
154
- i++ // past the opening delimiter
155
- while (i < sql.length) {
156
- if (sql[i] === close) {
157
- if (escapable && sql[i + 1] === close) {
158
- value += close
159
- i += 2
160
- continue
161
- }
162
- i++
163
- tokens.push({ kind: "word", value, bare: false })
164
- return
165
- }
166
- value += sql[i]
167
- i++
168
- }
169
- // Unterminated - emit what we have. The caller's single-statement and plan checks still run.
170
- tokens.push({ kind: "word", value, bare: false })
171
- }
172
- while (i < sql.length) {
173
- const char = sql[i] as string
174
- if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
175
- i++
176
- } else if (char === "-" && sql[i + 1] === "-") {
177
- const end = sql.indexOf("\n", i)
178
- i = end === -1 ? sql.length : end + 1
179
- } else if (char === "/" && sql[i + 1] === "*") {
180
- const end = sql.indexOf("*/", i + 2)
181
- i = end === -1 ? sql.length : end + 2
182
- } else if (char === "'") {
183
- // Literal: consumed and dropped, so its contents can never be read as SQL.
184
- i++
185
- while (i < sql.length) {
186
- if (sql[i] === "'") {
187
- if (sql[i + 1] === "'") {
188
- i += 2
189
- continue
190
- }
191
- i++
192
- break
193
- }
194
- i++
195
- }
196
- } else if (char === '"') {
197
- readQuoted('"', true)
198
- } else if (char === "`") {
199
- readQuoted("`", true)
200
- } else if (char === "[") {
201
- readQuoted("]", false) // MSSQL-style bracket quoting: no escape form
202
- } else if (WORD_START.test(char)) {
203
- const start = i
204
- while (i < sql.length && WORD_PART.test(sql[i] as string)) i++
205
- tokens.push({ kind: "word", value: sql.slice(start, i), bare: true })
206
- } else if (char >= "0" && char <= "9") {
207
- while (i < sql.length && /[0-9A-Za-z._]/.test(sql[i] as string)) i++
208
- } else {
209
- tokens.push({ kind: "punct", value: char, bare: false })
210
- i++
211
- }
212
- }
213
- return tokens
214
- }
215
-
216
- /** True when `token` is the unquoted keyword `word` (case-insensitive). */
217
- const isKeyword = (token: SqlToken | undefined, word: string): boolean =>
218
- token?.bare === true && token.value.toLowerCase() === word
219
-
220
- /**
221
- * Every relation the statement names after `FROM`/`JOIN`, lowercased, with CTE names excluded.
222
- * A schema qualifier resolves to its table (`main.habits` -> `habits`); a subquery contributes
223
- * nothing of its own because its inner `FROM` is tokenized alongside everything else.
224
- */
225
- function relationNames(sql: string): string[] {
226
- const tokens = tokenizeSql(sql)
227
- // `name AS (` only ever introduces a CTE - a column alias cannot be followed by a paren - so this
228
- // needs no `WITH` tracking to be exact.
229
- const ctes = new Set<string>()
230
- for (let i = 1; i < tokens.length - 1; i++) {
231
- const name = tokens[i - 1] as SqlToken
232
- if (isKeyword(tokens[i], "as") && tokens[i + 1]?.value === "(" && name.kind === "word") {
233
- ctes.add(name.value.toLowerCase())
234
- }
235
- }
236
- const names: string[] = []
237
- const relationAt = (
238
- index: number,
239
- ): { readonly name: string; readonly next: number } | undefined => {
240
- const first = tokens[index]
241
- if (first === undefined || first.kind !== "word") return undefined // `FROM (subquery)` - nothing to name here
242
- // A qualified reference names the table second; an unqualified one names it first.
243
- const qualified = tokens[index + 1]?.value === "." && tokens[index + 2]?.kind === "word"
244
- const table = qualified ? (tokens[index + 2] as SqlToken).value : first.value
245
- return { name: table.toLowerCase(), next: qualified ? index + 3 : index + 1 }
246
- }
247
- const addRelation = (index: number): number | undefined => {
248
- const relation = relationAt(index)
249
- if (relation === undefined) return undefined
250
- if (relation.name !== "" && !ctes.has(relation.name)) names.push(relation.name)
251
- return relation.next
252
- }
253
- // Comma joins are part of the FROM table-source grammar but do not have a JOIN keyword. Walk the
254
- // source list after every FROM and collect each top-level comma operand. The walk is deliberately
255
- // conservative: an uncertain token is left for SQLite's plan check, while an apparent relation is
256
- // always added so an alias cannot hide an unexposed table.
257
- const clauseBoundary = new Set([
258
- "where",
259
- "group",
260
- "order",
261
- "having",
262
- "limit",
263
- "offset",
264
- "union",
265
- "except",
266
- "intersect",
267
- "window",
268
- "returning",
269
- ])
270
- for (let i = 0; i < tokens.length; i++) {
271
- const from = isKeyword(tokens[i], "from")
272
- const join = isKeyword(tokens[i], "join")
273
- if (!from && !join) continue
274
- const next = addRelation(i + 1)
275
- if (join || next === undefined) continue
276
- let depth = 0
277
- for (let j = next; j < tokens.length; j++) {
278
- const token = tokens[j]
279
- if (token === undefined) break
280
- if (token.value === "(") {
281
- depth++
282
- continue
283
- }
284
- if (token.value === ")") {
285
- if (depth === 0) break
286
- depth--
287
- continue
288
- }
289
- if (depth !== 0) continue
290
- if (token.bare && clauseBoundary.has(token.value.toLowerCase())) break
291
- if (token.value !== ",") continue
292
- // A comma at the source-list level is another table reference. If it is a subquery, the nested
293
- // FROM scan below still finds its base tables; no relation is inferred from the opening paren.
294
- const afterComma = addRelation(j + 1)
295
- if (afterComma !== undefined) j = afterComma - 1
296
- }
297
- }
298
- return [...new Set(names)]
299
- }
300
-
301
- /** Reject multi-statement input: allow one terminator only when it is the final character. */
302
- const isSingleStatement = (sql: string): boolean => {
303
- let quote: "'" | '"' | null = null
304
- let lineComment = false
305
- let blockComment = false
306
- for (let i = 0; i < sql.length; i++) {
307
- const char = sql[i]
308
- const next = sql[i + 1]
309
- if (lineComment) {
310
- if (char === "\n") lineComment = false
311
- continue
312
- }
313
- if (blockComment) {
314
- if (char === "*" && next === "/") {
315
- blockComment = false
316
- i++
317
- }
318
- continue
319
- }
320
- if (quote !== null) {
321
- if (char === quote) {
322
- if (next === quote) i++
323
- else quote = null
324
- }
325
- continue
326
- }
327
- if (char === "'" || char === '"') {
328
- quote = char
329
- continue
330
- }
331
- if (char === "-" && next === "-") {
332
- lineComment = true
333
- i++
334
- continue
335
- }
336
- if (char === "/" && next === "*") {
337
- blockComment = true
338
- i++
339
- continue
340
- }
341
- if (char === ";") {
342
- return sql.slice(i + 1).trim() === ""
343
- }
344
- }
345
- return true
346
- }
347
-
348
- const isReadStatement = (sql: string): boolean => {
349
- const head = stripSqlNoise(sql).trim().toLowerCase()
350
- return head.startsWith("select") || head.startsWith("with")
351
- }
352
-
353
- /** Remove one trailing statement terminator (and anything after it) while preserving literals and
354
- * identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
355
- * wrapper's closing parenthesis. */
356
- function executableSql(sql: string): string {
357
- let quote: "'" | '"' | null = null
358
- let lineComment = false
359
- let lineCommentStart = -1
360
- let blockComment = false
361
- for (let i = 0; i < sql.length; i++) {
362
- const char = sql[i]
363
- const next = sql[i + 1]
364
- if (lineComment) {
365
- if (char === "\n") {
366
- lineComment = false
367
- lineCommentStart = -1
368
- }
369
- continue
370
- }
371
- if (blockComment) {
372
- if (char === "*" && next === "/") {
373
- blockComment = false
374
- i++
375
- }
376
- continue
377
- }
378
- if (quote !== null) {
379
- if (char === quote) {
380
- if (next === quote) i++
381
- else quote = null
382
- }
383
- continue
384
- }
385
- if (char === "'" || char === '"') {
386
- quote = char
387
- continue
388
- }
389
- if (char === "-" && next === "-") {
390
- lineComment = true
391
- lineCommentStart = i
392
- i++
393
- continue
394
- }
395
- if (char === "/" && next === "*") {
396
- blockComment = true
397
- i++
398
- continue
399
- }
400
- if (char === ";") return sql.slice(0, i).trim()
401
- }
402
- if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
403
- return sql.trim()
404
- }
405
-
406
125
  function assertResultLimit(value: number, name: string): void {
407
126
  if (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER) {
408
127
  throw new McpDbConfigError(`${name} must be a finite positive safe integer`)
409
128
  }
410
129
  }
411
130
 
412
- class QueryTimeoutError extends Error {
413
- constructor() {
414
- super("query exceeded the time limit")
415
- this.name = "QueryTimeoutError"
416
- }
417
- }
418
-
419
- /** One execution lane for `run_query`: `run` resolves rows, or rejects once the deadline passes. */
420
- interface QuerySession {
421
- run(sql: string, deadline: number): Promise<unknown[]>
422
- close(): Promise<void>
423
- }
424
-
425
- /**
426
- * The isolated lane. The worker reopens the SAME database FILE read-only and answers one statement
427
- * at a time; it is spawned once and reused for the life of the server. It deliberately does not
428
- * take a `serialize()` snapshot - that copies the whole database into memory on EVERY call, which
429
- * is a larger availability problem than the slow query the deadline exists to bound.
430
- */
431
- const SQLITE_WORKER_SOURCE = `
432
- const { Database } = require("bun:sqlite")
433
- let db
434
- self.onmessage = (event) => {
435
- const { id, filename, sql, type } = event.data
436
- if (type === "close") {
437
- let response
438
- try {
439
- // prepare().all() leaves a statement alive until it is finalized or collected. Bun's
440
- // default close(false) preserves those statements and can keep the database file locked;
441
- // close(true) finalizes every outstanding statement before the worker exits.
442
- db?.close(true)
443
- db = undefined
444
- response = { id, closed: true, ok: true }
445
- } catch (error) {
446
- response = { id, closed: true, ok: false, error: String(error) }
447
- }
448
- // A forced parent-side terminate can leave native SQLite handles locked on Windows even after
449
- // close() returns. Let the worker exit its own event turn so the runtime can finish releasing
450
- // those handles before the parent considers graceful cleanup complete.
451
- self.postMessage(response)
452
- self.close()
453
- return
454
- }
455
- try {
456
- if (db === undefined) {
457
- db = new Database(filename, { readonly: true })
458
- db.run("PRAGMA query_only = ON")
459
- }
460
- self.postMessage({ id, ok: true, rows: db.prepare(sql).all() })
461
- } catch (error) {
462
- self.postMessage({ id, ok: false, error: String(error) })
463
- }
464
- }
465
- `
466
-
467
- function isWorkerAvailable(): boolean {
468
- return typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined"
469
- }
470
-
471
- /** Only a file-backed database can be reopened in a worker. `:memory:` and the anonymous temp
472
- * database live in this process alone, so they take the in-process lane instead. */
473
- function reopenableFilename(db: SqliteDatabaseLike): string | undefined {
474
- const filename = db.filename
475
- if (typeof filename !== "string" || filename === "" || filename === ":memory:") return undefined
476
- return filename.startsWith("file::memory:") ? undefined : filename
477
- }
478
-
479
- interface PendingQuery {
480
- readonly deadline: number
481
- resolve(rows: unknown[]): void
482
- reject(error: Error): void
483
- }
484
-
485
- interface PendingClose {
486
- readonly id: number
487
- resolve(graceful?: boolean): void
488
- }
489
-
490
- const WORKER_CLOSE_TIMEOUT_MS = 1_000
491
-
492
- function workerSession(filename: string): QuerySession {
493
- let worker: Worker | undefined
494
- let sourceUrl: string | undefined
495
- let nextId = 0
496
- const pending = new Map<number, PendingQuery>()
497
- let lane: Promise<unknown> = Promise.resolve()
498
- let closed = false
499
- let closePromise: Promise<void> | undefined
500
- let pendingClose: PendingClose | undefined
501
-
502
- const detachWorker = (active: Worker | undefined = worker): void => {
503
- if (active === undefined) return
504
- if (worker !== active) return
505
- worker = undefined
506
- if (sourceUrl !== undefined) URL.revokeObjectURL(sourceUrl)
507
- sourceUrl = undefined
508
- }
509
-
510
- const releaseWorker = (active: Worker | undefined = worker): void => {
511
- if (active === undefined) return
512
- active.terminate()
513
- detachWorker(active)
514
- }
515
-
516
- const rejectPending = (error: Error): void => {
517
- const waiters = [...pending.values()]
518
- pending.clear()
519
- for (const waiter of waiters) waiter.reject(error)
520
- }
521
-
522
- /** Drop the worker and fail everything riding on it. The only bound this side can enforce over a
523
- * statement already running inside native SQLite is to stop owning the connection it runs on. */
524
- const discard = (error: Error): void => {
525
- const closing = pendingClose
526
- pendingClose = undefined
527
- releaseWorker()
528
- rejectPending(error)
529
- closing?.resolve()
530
- }
531
-
532
- const ensureWorker = (): Worker => {
533
- if (worker !== undefined) return worker
534
- sourceUrl = URL.createObjectURL(new Blob([SQLITE_WORKER_SOURCE], { type: "text/javascript" }))
535
- const spawned = new Worker(sourceUrl)
536
- spawned.onmessage = (
537
- event: MessageEvent<{
538
- id?: number
539
- ok?: boolean
540
- rows?: unknown[]
541
- error?: string
542
- closed?: boolean
543
- }>,
544
- ) => {
545
- const { id, ok, rows, error } = event.data
546
- if (typeof id !== "number") return
547
- if (event.data.closed === true) {
548
- const closing = pendingClose
549
- if (closing?.id !== id) return
550
- pendingClose = undefined
551
- closing.resolve(event.data.ok === true)
552
- return
553
- }
554
- const waiter = pending.get(id)
555
- if (waiter === undefined) return
556
- if (Date.now() >= waiter.deadline) {
557
- discard(new QueryTimeoutError())
558
- return
559
- }
560
- pending.delete(id)
561
- if (ok === true) waiter.resolve(rows ?? [])
562
- else waiter.reject(new Error(error ?? "query failed in worker"))
563
- }
564
- spawned.onerror = () => discard(new Error("query failed in worker"))
565
- // An in-flight query holds its own deadline timer on the loop, so an IDLE worker must not be
566
- // what keeps the process alive.
567
- ;(spawned as { unref?: () => void }).unref?.()
568
- worker = spawned
569
- return spawned
570
- }
571
-
572
- const dispatch = (sql: string, deadline: number): Promise<unknown[]> => {
573
- if (closed) return Promise.reject(new Error("query session is closed"))
574
- const active = ensureWorker()
575
- const remaining = deadline - Date.now()
576
- if (remaining <= 0) {
577
- discard(new QueryTimeoutError())
578
- return Promise.reject(new QueryTimeoutError())
579
- }
580
- const id = ++nextId
581
- return new Promise<unknown[]>((resolve, reject) => {
582
- const timer = setTimeout(() => discard(new QueryTimeoutError()), remaining)
583
- pending.set(id, {
584
- deadline,
585
- resolve: (rows) => {
586
- clearTimeout(timer)
587
- resolve(rows)
588
- },
589
- reject: (error) => {
590
- clearTimeout(timer)
591
- reject(error)
592
- },
593
- })
594
- try {
595
- active.postMessage({ id, filename, sql })
596
- } catch (error) {
597
- pending.delete(id)
598
- clearTimeout(timer)
599
- reject(error instanceof Error ? error : new Error(String(error)))
600
- }
601
- })
602
- }
603
-
604
- return {
605
- run(sql, deadline) {
606
- // One worker owns the connection, so overlapping calls queue behind each other rather than
607
- // interleaving statements on it.
608
- const result = lane.then(() => dispatch(sql, deadline))
609
- lane = result.catch(() => undefined)
610
- return result
611
- },
612
- close() {
613
- if (closePromise !== undefined) return closePromise
614
- closed = true
615
- closePromise = (async () => {
616
- const active = worker
617
- if (active !== undefined) {
618
- await new Promise<void>((resolve) => {
619
- const id = ++nextId
620
- let settled = false
621
- const finish = (error?: Error, graceful = false): void => {
622
- if (settled) return
623
- settled = true
624
- clearTimeout(timer)
625
- if (pendingClose?.id === id) pendingClose = undefined
626
- if (error !== undefined) rejectPending(error)
627
- if (graceful) detachWorker(active)
628
- else releaseWorker(active)
629
- resolve()
630
- }
631
- const timer = setTimeout(() => {
632
- finish(new Error("query session is closed"))
633
- }, WORKER_CLOSE_TIMEOUT_MS)
634
- pendingClose = { id, resolve: (graceful) => finish(undefined, graceful) }
635
- try {
636
- active.postMessage({ id, type: "close" })
637
- } catch {
638
- finish(new Error("query session is closed"))
639
- }
640
- })
641
- }
642
- await lane
643
- })()
644
- return closePromise
645
- },
646
- }
647
- }
648
-
649
- /**
650
- * The in-process lane, used when the database cannot be reopened elsewhere. The deadline is checked
651
- * around the statement and nothing more: `prepare().all()` is synchronous, so it holds the only
652
- * thread there is and no timer of ours can fire while it runs. That makes this lane BEST EFFORT -
653
- * it reports an overrun, it does not cut one short.
654
- */
655
- function inProcessSession(db: SqliteDatabaseLike): QuerySession {
656
- return {
657
- async run(sql, deadline) {
658
- if (Date.now() >= deadline) throw new QueryTimeoutError()
659
- const rows = db.prepare(sql).all()
660
- if (Date.now() > deadline) throw new QueryTimeoutError()
661
- return rows
662
- },
663
- async close() {},
664
- }
665
- }
666
-
667
- /**
668
- * Pick the lane once, at construction. A file-backed database is isolated in a reusable read-only
669
- * worker, which is the only arrangement that can actually stop a running statement. Anything else
670
- * runs in process - every database keeps working, none is refused.
671
- */
672
- function openQuerySession(db: SqliteDatabaseLike): QuerySession {
673
- const filename = reopenableFilename(db)
674
- if (filename !== undefined && isWorkerAvailable()) return workerSession(filename)
675
- return inProcessSession(db)
676
- }
677
-
678
131
  /**
679
132
  * Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
680
133
  * security model. Throws {@link McpDbConfigError} on any unsafe configuration - always at
@@ -780,33 +233,16 @@ export function serveDatabaseAsMcp(
780
233
  }
781
234
  activeQueries += 1
782
235
  try {
783
- const sql = String(args.sql ?? "").trim()
784
- if (sql === "") return { isError: true, text: "empty query" }
785
- if (!isSingleStatement(sql)) {
786
- return { isError: true, text: "only a single statement is allowed" }
787
- }
788
- if (!isReadStatement(sql)) {
789
- return { isError: true, text: "only SELECT (or WITH…SELECT) statements are allowed" }
790
- }
236
+ const gate = gateSqliteStatement(String(args.sql ?? ""), (relation) =>
237
+ allowlist.has(relation),
238
+ )
239
+ if (!gate.ok) return { isError: true, text: gate.text }
791
240
 
792
241
  const deadline = Date.now() + queryTimeoutMs
793
242
 
794
- // Verify SQL relation tokens before the plan check. SQLite's plan output uses aliases as
795
- // scan targets (e.g. `users AS habits` becomes `SCAN habits`), so plan-only validation can
796
- // mistake an attacker-controlled alias for an allowlisted table.
797
- const executable = executableSql(sql)
798
- for (const relation of relationNames(executable)) {
799
- if (!allowlist.has(relation)) {
800
- return {
801
- isError: true,
802
- text: `query touches ${JSON.stringify(relation)}, which is not exposed`,
803
- }
804
- }
805
- }
806
-
807
243
  // Verify via the query plan that only allowlisted tables are touched. SQLite names every
808
244
  // scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
809
- const query = executable
245
+ const query = gate.query
810
246
  let planRows: Array<{ detail?: unknown }>
811
247
  try {
812
248
  planRows = (await querySession.run(`EXPLAIN QUERY PLAN ${query}`, deadline)) as Array<{
@@ -816,31 +252,11 @@ export function serveDatabaseAsMcp(
816
252
  if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
817
253
  return { isError: true, text: `query failed to plan: ${String(error)}` }
818
254
  }
819
- for (const row of planRows) {
820
- const detail = typeof row.detail === "string" ? row.detail : ""
821
- // SQLite also emits non-table nodes such as `SCAN CONSTANT ROW` for a
822
- // constant-only SELECT. Only treat a scan/search target as a relation
823
- // when it is not one of those planner pseudo-nodes.
824
- if (
825
- /^SCAN\s+CONSTANT\s+ROW$/i.test(detail.trim()) ||
826
- /^SCAN\s+SUBQUERY\s+\d+$/i.test(detail.trim())
827
- ) {
828
- continue
829
- }
830
- // A schema-qualified reference plans as `SCAN main.habits`. The table is the last dotted
831
- // segment; reading the first would reject `main.habits` as a table literally named "main".
832
- const match =
833
- /(?:SCAN|SEARCH)\s+(?:TABLE\s+)?(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(
834
- detail,
835
- )
836
- if (match !== null) {
837
- const relation = match[1]?.toLowerCase() ?? ""
838
- if (!allowlist.has(relation)) {
839
- return {
840
- isError: true,
841
- text: `query touches ${JSON.stringify(match[1])}, which is not exposed`,
842
- }
843
- }
255
+ const unexposed = unexposedPlanRelation(planRows, (relation) => allowlist.has(relation))
256
+ if (unexposed !== undefined) {
257
+ return {
258
+ isError: true,
259
+ text: `query touches ${JSON.stringify(unexposed)}, which is not exposed`,
844
260
  }
845
261
  }
846
262
 
@@ -848,11 +264,7 @@ export function serveDatabaseAsMcp(
848
264
  try {
849
265
  // Fetch at most one row beyond the advertised cap. This keeps the database driver's
850
266
  // materialization bounded even when the caller submits `SELECT * FROM a_huge_table`.
851
- // nifra-expect sql-dynamic: wraps the caller's already-allowlisted read-only query (guarded above) in a bounding subselect; the query text is the tool's input, run on a read-only session
852
- limitedRows = await querySession.run(
853
- `SELECT * FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`,
854
- deadline,
855
- )
267
+ limitedRows = await querySession.run(boundedSqliteQuery(query, maxRows), deadline)
856
268
  } catch (error) {
857
269
  if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
858
270
  return { isError: true, text: `query failed: ${String(error)}` }
@@ -862,9 +274,8 @@ export function serveDatabaseAsMcp(
862
274
  let total: number | null = limitedRows.length
863
275
  if (wasLimited && options.runQuery?.exactTotal === true) {
864
276
  try {
865
- // nifra-expect sql-dynamic: wraps the caller's already-allowlisted read-only query (guarded above) in a counting subselect; the query text is the tool's input, run on a read-only session
866
277
  const countRows = (await querySession.run(
867
- `SELECT count(*) AS "__nifra_total" FROM (${query}) AS "__nifra_count"`,
278
+ countSqliteQuery(query),
868
279
  deadline,
869
280
  )) as Array<{ __nifra_total?: unknown }>
870
281
  const count = countRows[0]?.__nifra_total
@@ -879,27 +290,18 @@ export function serveDatabaseAsMcp(
879
290
  } else if (wasLimited) {
880
291
  total = null
881
292
  }
882
- let shown = Math.min(limitedRows.length, maxRows)
883
- let payload = limitedRows.slice(0, shown)
884
- let result: Record<string, unknown> = {
885
- rows: payload,
886
- ...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
887
- }
888
- let serialized = JSON.stringify(result)
889
- const encoded = new TextEncoder()
890
- while (encoded.encode(serialized).byteLength > maxResultBytes && shown > 0) {
891
- shown = Math.max(0, Math.floor(shown / 2))
892
- payload = limitedRows.slice(0, shown)
893
- result = {
894
- rows: payload,
293
+ const fit = fitToBytes(
294
+ Math.min(limitedRows.length, maxRows),
295
+ (shown): Record<string, unknown> => ({
296
+ rows: limitedRows.slice(0, shown),
895
297
  ...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
896
- }
897
- serialized = JSON.stringify(result)
898
- }
899
- if (encoded.encode(serialized).byteLength > maxResultBytes) {
298
+ }),
299
+ maxResultBytes,
300
+ )
301
+ if (fit === undefined) {
900
302
  return { isError: true, text: "query result exceeds maxResultBytes" }
901
303
  }
902
- return { text: serialized, structuredContent: result }
304
+ return { text: fit.serialized, structuredContent: fit.value }
903
305
  } finally {
904
306
  activeQueries -= 1
905
307
  }
@@ -912,6 +314,7 @@ export function serveDatabaseAsMcp(
912
314
  name: options.name ?? "nifra-db",
913
315
  version: options.version ?? "1.0.0",
914
316
  tools,
317
+ ...(options.allowedHosts !== undefined ? { allowedHosts: options.allowedHosts } : {}),
915
318
  })
916
319
 
917
320
  const runQueryOptions = options.runQuery