@nifrajs/mcp-db 3.3.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/README.md +83 -0
- package/dist/engine.d.ts +71 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +494 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +8 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -552
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +113 -0
- package/dist/internal/pg-exec.d.ts.map +1 -0
- package/dist/internal/pg-exec.js +286 -0
- package/dist/internal/pg-exec.js.map +1 -0
- package/dist/internal/pg-lexer.d.ts +52 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -0
- package/dist/internal/pg-lexer.js +273 -0
- package/dist/internal/pg-lexer.js.map +1 -0
- package/dist/internal/shared.d.ts +161 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +538 -0
- package/dist/internal/shared.js.map +1 -0
- package/dist/internal/sqlite-session.d.ts +21 -0
- package/dist/internal/sqlite-session.d.ts.map +1 -0
- package/dist/internal/sqlite-session.js +254 -0
- package/dist/internal/sqlite-session.js.map +1 -0
- package/dist/postgres.d.ts +148 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +580 -0
- package/dist/postgres.js.map +1 -0
- package/package.json +15 -2
- package/src/engine.ts +664 -0
- package/src/index.ts +43 -588
- package/src/internal/pg-exec.ts +386 -0
- package/src/internal/pg-lexer.ts +279 -0
- package/src/internal/shared.ts +705 -0
- package/src/internal/sqlite-session.ts +273 -0
- package/src/postgres.ts +777 -0
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,516 +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
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
238
|
-
if (!isKeyword(tokens[i], "from") && !isKeyword(tokens[i], "join")) continue
|
|
239
|
-
const first = tokens[i + 1]
|
|
240
|
-
if (first === undefined || first.kind !== "word") continue // `FROM (subquery)` - nothing to name here
|
|
241
|
-
// A qualified reference names the table second; an unqualified one names it first.
|
|
242
|
-
const qualified = tokens[i + 2]?.value === "." && tokens[i + 3]?.kind === "word"
|
|
243
|
-
const table = qualified ? (tokens[i + 3] as SqlToken).value : first.value
|
|
244
|
-
if (table !== "" && !ctes.has(table.toLowerCase())) names.push(table.toLowerCase())
|
|
245
|
-
}
|
|
246
|
-
return names
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Reject multi-statement input: allow one terminator only when it is the final character. */
|
|
250
|
-
const isSingleStatement = (sql: string): boolean => {
|
|
251
|
-
let quote: "'" | '"' | null = null
|
|
252
|
-
let lineComment = false
|
|
253
|
-
let blockComment = false
|
|
254
|
-
for (let i = 0; i < sql.length; i++) {
|
|
255
|
-
const char = sql[i]
|
|
256
|
-
const next = sql[i + 1]
|
|
257
|
-
if (lineComment) {
|
|
258
|
-
if (char === "\n") lineComment = false
|
|
259
|
-
continue
|
|
260
|
-
}
|
|
261
|
-
if (blockComment) {
|
|
262
|
-
if (char === "*" && next === "/") {
|
|
263
|
-
blockComment = false
|
|
264
|
-
i++
|
|
265
|
-
}
|
|
266
|
-
continue
|
|
267
|
-
}
|
|
268
|
-
if (quote !== null) {
|
|
269
|
-
if (char === quote) {
|
|
270
|
-
if (next === quote) i++
|
|
271
|
-
else quote = null
|
|
272
|
-
}
|
|
273
|
-
continue
|
|
274
|
-
}
|
|
275
|
-
if (char === "'" || char === '"') {
|
|
276
|
-
quote = char
|
|
277
|
-
continue
|
|
278
|
-
}
|
|
279
|
-
if (char === "-" && next === "-") {
|
|
280
|
-
lineComment = true
|
|
281
|
-
i++
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
284
|
-
if (char === "/" && next === "*") {
|
|
285
|
-
blockComment = true
|
|
286
|
-
i++
|
|
287
|
-
continue
|
|
288
|
-
}
|
|
289
|
-
if (char === ";") {
|
|
290
|
-
return sql.slice(i + 1).trim() === ""
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return true
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const isReadStatement = (sql: string): boolean => {
|
|
297
|
-
const head = stripSqlNoise(sql).trim().toLowerCase()
|
|
298
|
-
return head.startsWith("select") || head.startsWith("with")
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Remove one trailing statement terminator (and anything after it) while preserving literals and
|
|
302
|
-
* identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
|
|
303
|
-
* wrapper's closing parenthesis. */
|
|
304
|
-
function executableSql(sql: string): string {
|
|
305
|
-
let quote: "'" | '"' | null = null
|
|
306
|
-
let lineComment = false
|
|
307
|
-
let lineCommentStart = -1
|
|
308
|
-
let blockComment = false
|
|
309
|
-
for (let i = 0; i < sql.length; i++) {
|
|
310
|
-
const char = sql[i]
|
|
311
|
-
const next = sql[i + 1]
|
|
312
|
-
if (lineComment) {
|
|
313
|
-
if (char === "\n") {
|
|
314
|
-
lineComment = false
|
|
315
|
-
lineCommentStart = -1
|
|
316
|
-
}
|
|
317
|
-
continue
|
|
318
|
-
}
|
|
319
|
-
if (blockComment) {
|
|
320
|
-
if (char === "*" && next === "/") {
|
|
321
|
-
blockComment = false
|
|
322
|
-
i++
|
|
323
|
-
}
|
|
324
|
-
continue
|
|
325
|
-
}
|
|
326
|
-
if (quote !== null) {
|
|
327
|
-
if (char === quote) {
|
|
328
|
-
if (next === quote) i++
|
|
329
|
-
else quote = null
|
|
330
|
-
}
|
|
331
|
-
continue
|
|
332
|
-
}
|
|
333
|
-
if (char === "'" || char === '"') {
|
|
334
|
-
quote = char
|
|
335
|
-
continue
|
|
336
|
-
}
|
|
337
|
-
if (char === "-" && next === "-") {
|
|
338
|
-
lineComment = true
|
|
339
|
-
lineCommentStart = i
|
|
340
|
-
i++
|
|
341
|
-
continue
|
|
342
|
-
}
|
|
343
|
-
if (char === "/" && next === "*") {
|
|
344
|
-
blockComment = true
|
|
345
|
-
i++
|
|
346
|
-
continue
|
|
347
|
-
}
|
|
348
|
-
if (char === ";") return sql.slice(0, i).trim()
|
|
349
|
-
}
|
|
350
|
-
if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
|
|
351
|
-
return sql.trim()
|
|
352
|
-
}
|
|
353
|
-
|
|
354
125
|
function assertResultLimit(value: number, name: string): void {
|
|
355
126
|
if (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER) {
|
|
356
127
|
throw new McpDbConfigError(`${name} must be a finite positive safe integer`)
|
|
357
128
|
}
|
|
358
129
|
}
|
|
359
130
|
|
|
360
|
-
class QueryTimeoutError extends Error {
|
|
361
|
-
constructor() {
|
|
362
|
-
super("query exceeded the time limit")
|
|
363
|
-
this.name = "QueryTimeoutError"
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** One execution lane for `run_query`: `run` resolves rows, or rejects once the deadline passes. */
|
|
368
|
-
interface QuerySession {
|
|
369
|
-
run(sql: string, deadline: number): Promise<unknown[]>
|
|
370
|
-
close(): Promise<void>
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* The isolated lane. The worker reopens the SAME database FILE read-only and answers one statement
|
|
375
|
-
* at a time; it is spawned once and reused for the life of the server. It deliberately does not
|
|
376
|
-
* take a `serialize()` snapshot - that copies the whole database into memory on EVERY call, which
|
|
377
|
-
* is a larger availability problem than the slow query the deadline exists to bound.
|
|
378
|
-
*/
|
|
379
|
-
const SQLITE_WORKER_SOURCE = `
|
|
380
|
-
const { Database } = require("bun:sqlite")
|
|
381
|
-
let db
|
|
382
|
-
self.onmessage = (event) => {
|
|
383
|
-
const { id, filename, sql, type } = event.data
|
|
384
|
-
if (type === "close") {
|
|
385
|
-
let response
|
|
386
|
-
try {
|
|
387
|
-
// prepare().all() leaves a statement alive until it is finalized or collected. Bun's
|
|
388
|
-
// default close(false) preserves those statements and can keep the database file locked;
|
|
389
|
-
// close(true) finalizes every outstanding statement before the worker exits.
|
|
390
|
-
db?.close(true)
|
|
391
|
-
db = undefined
|
|
392
|
-
response = { id, closed: true, ok: true }
|
|
393
|
-
} catch (error) {
|
|
394
|
-
response = { id, closed: true, ok: false, error: String(error) }
|
|
395
|
-
}
|
|
396
|
-
// A forced parent-side terminate can leave native SQLite handles locked on Windows even after
|
|
397
|
-
// close() returns. Let the worker exit its own event turn so the runtime can finish releasing
|
|
398
|
-
// those handles before the parent considers graceful cleanup complete.
|
|
399
|
-
self.postMessage(response)
|
|
400
|
-
self.close()
|
|
401
|
-
return
|
|
402
|
-
}
|
|
403
|
-
try {
|
|
404
|
-
if (db === undefined) {
|
|
405
|
-
db = new Database(filename, { readonly: true })
|
|
406
|
-
db.run("PRAGMA query_only = ON")
|
|
407
|
-
}
|
|
408
|
-
self.postMessage({ id, ok: true, rows: db.prepare(sql).all() })
|
|
409
|
-
} catch (error) {
|
|
410
|
-
self.postMessage({ id, ok: false, error: String(error) })
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
`
|
|
414
|
-
|
|
415
|
-
function isWorkerAvailable(): boolean {
|
|
416
|
-
return typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined"
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Only a file-backed database can be reopened in a worker. `:memory:` and the anonymous temp
|
|
420
|
-
* database live in this process alone, so they take the in-process lane instead. */
|
|
421
|
-
function reopenableFilename(db: SqliteDatabaseLike): string | undefined {
|
|
422
|
-
const filename = db.filename
|
|
423
|
-
if (typeof filename !== "string" || filename === "" || filename === ":memory:") return undefined
|
|
424
|
-
return filename.startsWith("file::memory:") ? undefined : filename
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
interface PendingQuery {
|
|
428
|
-
readonly deadline: number
|
|
429
|
-
resolve(rows: unknown[]): void
|
|
430
|
-
reject(error: Error): void
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
interface PendingClose {
|
|
434
|
-
readonly id: number
|
|
435
|
-
resolve(graceful?: boolean): void
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const WORKER_CLOSE_TIMEOUT_MS = 1_000
|
|
439
|
-
|
|
440
|
-
function workerSession(filename: string): QuerySession {
|
|
441
|
-
let worker: Worker | undefined
|
|
442
|
-
let sourceUrl: string | undefined
|
|
443
|
-
let nextId = 0
|
|
444
|
-
const pending = new Map<number, PendingQuery>()
|
|
445
|
-
let lane: Promise<unknown> = Promise.resolve()
|
|
446
|
-
let closed = false
|
|
447
|
-
let closePromise: Promise<void> | undefined
|
|
448
|
-
let pendingClose: PendingClose | undefined
|
|
449
|
-
|
|
450
|
-
const detachWorker = (active: Worker | undefined = worker): void => {
|
|
451
|
-
if (active === undefined) return
|
|
452
|
-
if (worker !== active) return
|
|
453
|
-
worker = undefined
|
|
454
|
-
if (sourceUrl !== undefined) URL.revokeObjectURL(sourceUrl)
|
|
455
|
-
sourceUrl = undefined
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const releaseWorker = (active: Worker | undefined = worker): void => {
|
|
459
|
-
if (active === undefined) return
|
|
460
|
-
active.terminate()
|
|
461
|
-
detachWorker(active)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
const rejectPending = (error: Error): void => {
|
|
465
|
-
const waiters = [...pending.values()]
|
|
466
|
-
pending.clear()
|
|
467
|
-
for (const waiter of waiters) waiter.reject(error)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** Drop the worker and fail everything riding on it. The only bound this side can enforce over a
|
|
471
|
-
* statement already running inside native SQLite is to stop owning the connection it runs on. */
|
|
472
|
-
const discard = (error: Error): void => {
|
|
473
|
-
const closing = pendingClose
|
|
474
|
-
pendingClose = undefined
|
|
475
|
-
releaseWorker()
|
|
476
|
-
rejectPending(error)
|
|
477
|
-
closing?.resolve()
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const ensureWorker = (): Worker => {
|
|
481
|
-
if (worker !== undefined) return worker
|
|
482
|
-
sourceUrl = URL.createObjectURL(new Blob([SQLITE_WORKER_SOURCE], { type: "text/javascript" }))
|
|
483
|
-
const spawned = new Worker(sourceUrl)
|
|
484
|
-
spawned.onmessage = (
|
|
485
|
-
event: MessageEvent<{
|
|
486
|
-
id?: number
|
|
487
|
-
ok?: boolean
|
|
488
|
-
rows?: unknown[]
|
|
489
|
-
error?: string
|
|
490
|
-
closed?: boolean
|
|
491
|
-
}>,
|
|
492
|
-
) => {
|
|
493
|
-
const { id, ok, rows, error } = event.data
|
|
494
|
-
if (typeof id !== "number") return
|
|
495
|
-
if (event.data.closed === true) {
|
|
496
|
-
const closing = pendingClose
|
|
497
|
-
if (closing?.id !== id) return
|
|
498
|
-
pendingClose = undefined
|
|
499
|
-
closing.resolve(event.data.ok === true)
|
|
500
|
-
return
|
|
501
|
-
}
|
|
502
|
-
const waiter = pending.get(id)
|
|
503
|
-
if (waiter === undefined) return
|
|
504
|
-
if (Date.now() >= waiter.deadline) {
|
|
505
|
-
discard(new QueryTimeoutError())
|
|
506
|
-
return
|
|
507
|
-
}
|
|
508
|
-
pending.delete(id)
|
|
509
|
-
if (ok === true) waiter.resolve(rows ?? [])
|
|
510
|
-
else waiter.reject(new Error(error ?? "query failed in worker"))
|
|
511
|
-
}
|
|
512
|
-
spawned.onerror = () => discard(new Error("query failed in worker"))
|
|
513
|
-
// An in-flight query holds its own deadline timer on the loop, so an IDLE worker must not be
|
|
514
|
-
// what keeps the process alive.
|
|
515
|
-
;(spawned as { unref?: () => void }).unref?.()
|
|
516
|
-
worker = spawned
|
|
517
|
-
return spawned
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const dispatch = (sql: string, deadline: number): Promise<unknown[]> => {
|
|
521
|
-
if (closed) return Promise.reject(new Error("query session is closed"))
|
|
522
|
-
const active = ensureWorker()
|
|
523
|
-
const remaining = deadline - Date.now()
|
|
524
|
-
if (remaining <= 0) {
|
|
525
|
-
discard(new QueryTimeoutError())
|
|
526
|
-
return Promise.reject(new QueryTimeoutError())
|
|
527
|
-
}
|
|
528
|
-
const id = ++nextId
|
|
529
|
-
return new Promise<unknown[]>((resolve, reject) => {
|
|
530
|
-
const timer = setTimeout(() => discard(new QueryTimeoutError()), remaining)
|
|
531
|
-
pending.set(id, {
|
|
532
|
-
deadline,
|
|
533
|
-
resolve: (rows) => {
|
|
534
|
-
clearTimeout(timer)
|
|
535
|
-
resolve(rows)
|
|
536
|
-
},
|
|
537
|
-
reject: (error) => {
|
|
538
|
-
clearTimeout(timer)
|
|
539
|
-
reject(error)
|
|
540
|
-
},
|
|
541
|
-
})
|
|
542
|
-
try {
|
|
543
|
-
active.postMessage({ id, filename, sql })
|
|
544
|
-
} catch (error) {
|
|
545
|
-
pending.delete(id)
|
|
546
|
-
clearTimeout(timer)
|
|
547
|
-
reject(error instanceof Error ? error : new Error(String(error)))
|
|
548
|
-
}
|
|
549
|
-
})
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
return {
|
|
553
|
-
run(sql, deadline) {
|
|
554
|
-
// One worker owns the connection, so overlapping calls queue behind each other rather than
|
|
555
|
-
// interleaving statements on it.
|
|
556
|
-
const result = lane.then(() => dispatch(sql, deadline))
|
|
557
|
-
lane = result.catch(() => undefined)
|
|
558
|
-
return result
|
|
559
|
-
},
|
|
560
|
-
close() {
|
|
561
|
-
if (closePromise !== undefined) return closePromise
|
|
562
|
-
closed = true
|
|
563
|
-
closePromise = (async () => {
|
|
564
|
-
const active = worker
|
|
565
|
-
if (active !== undefined) {
|
|
566
|
-
await new Promise<void>((resolve) => {
|
|
567
|
-
const id = ++nextId
|
|
568
|
-
let settled = false
|
|
569
|
-
const finish = (error?: Error, graceful = false): void => {
|
|
570
|
-
if (settled) return
|
|
571
|
-
settled = true
|
|
572
|
-
clearTimeout(timer)
|
|
573
|
-
if (pendingClose?.id === id) pendingClose = undefined
|
|
574
|
-
if (error !== undefined) rejectPending(error)
|
|
575
|
-
if (graceful) detachWorker(active)
|
|
576
|
-
else releaseWorker(active)
|
|
577
|
-
resolve()
|
|
578
|
-
}
|
|
579
|
-
const timer = setTimeout(() => {
|
|
580
|
-
finish(new Error("query session is closed"))
|
|
581
|
-
}, WORKER_CLOSE_TIMEOUT_MS)
|
|
582
|
-
pendingClose = { id, resolve: (graceful) => finish(undefined, graceful) }
|
|
583
|
-
try {
|
|
584
|
-
active.postMessage({ id, type: "close" })
|
|
585
|
-
} catch {
|
|
586
|
-
finish(new Error("query session is closed"))
|
|
587
|
-
}
|
|
588
|
-
})
|
|
589
|
-
}
|
|
590
|
-
await lane
|
|
591
|
-
})()
|
|
592
|
-
return closePromise
|
|
593
|
-
},
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* The in-process lane, used when the database cannot be reopened elsewhere. The deadline is checked
|
|
599
|
-
* around the statement and nothing more: `prepare().all()` is synchronous, so it holds the only
|
|
600
|
-
* thread there is and no timer of ours can fire while it runs. That makes this lane BEST EFFORT -
|
|
601
|
-
* it reports an overrun, it does not cut one short.
|
|
602
|
-
*/
|
|
603
|
-
function inProcessSession(db: SqliteDatabaseLike): QuerySession {
|
|
604
|
-
return {
|
|
605
|
-
async run(sql, deadline) {
|
|
606
|
-
if (Date.now() >= deadline) throw new QueryTimeoutError()
|
|
607
|
-
const rows = db.prepare(sql).all()
|
|
608
|
-
if (Date.now() > deadline) throw new QueryTimeoutError()
|
|
609
|
-
return rows
|
|
610
|
-
},
|
|
611
|
-
async close() {},
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Pick the lane once, at construction. A file-backed database is isolated in a reusable read-only
|
|
617
|
-
* worker, which is the only arrangement that can actually stop a running statement. Anything else
|
|
618
|
-
* runs in process - every database keeps working, none is refused.
|
|
619
|
-
*/
|
|
620
|
-
function openQuerySession(db: SqliteDatabaseLike): QuerySession {
|
|
621
|
-
const filename = reopenableFilename(db)
|
|
622
|
-
if (filename !== undefined && isWorkerAvailable()) return workerSession(filename)
|
|
623
|
-
return inProcessSession(db)
|
|
624
|
-
}
|
|
625
|
-
|
|
626
131
|
/**
|
|
627
132
|
* Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
|
|
628
133
|
* security model. Throws {@link McpDbConfigError} on any unsafe configuration - always at
|
|
@@ -728,33 +233,16 @@ export function serveDatabaseAsMcp(
|
|
|
728
233
|
}
|
|
729
234
|
activeQueries += 1
|
|
730
235
|
try {
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
if (!isReadStatement(sql)) {
|
|
737
|
-
return { isError: true, text: "only SELECT (or WITH…SELECT) statements are allowed" }
|
|
738
|
-
}
|
|
236
|
+
const gate = gateSqliteStatement(String(args.sql ?? ""), (relation) =>
|
|
237
|
+
allowlist.has(relation),
|
|
238
|
+
)
|
|
239
|
+
if (!gate.ok) return { isError: true, text: gate.text }
|
|
739
240
|
|
|
740
241
|
const deadline = Date.now() + queryTimeoutMs
|
|
741
242
|
|
|
742
|
-
// Verify SQL relation tokens before the plan check. SQLite's plan output uses aliases as
|
|
743
|
-
// scan targets (e.g. `users AS habits` becomes `SCAN habits`), so plan-only validation can
|
|
744
|
-
// mistake an attacker-controlled alias for an allowlisted table.
|
|
745
|
-
const executable = executableSql(sql)
|
|
746
|
-
for (const relation of relationNames(executable)) {
|
|
747
|
-
if (!allowlist.has(relation)) {
|
|
748
|
-
return {
|
|
749
|
-
isError: true,
|
|
750
|
-
text: `query touches ${JSON.stringify(relation)}, which is not exposed`,
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
243
|
// Verify via the query plan that only allowlisted tables are touched. SQLite names every
|
|
756
244
|
// scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
|
|
757
|
-
const query =
|
|
245
|
+
const query = gate.query
|
|
758
246
|
let planRows: Array<{ detail?: unknown }>
|
|
759
247
|
try {
|
|
760
248
|
planRows = (await querySession.run(`EXPLAIN QUERY PLAN ${query}`, deadline)) as Array<{
|
|
@@ -764,31 +252,11 @@ export function serveDatabaseAsMcp(
|
|
|
764
252
|
if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
|
|
765
253
|
return { isError: true, text: `query failed to plan: ${String(error)}` }
|
|
766
254
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (
|
|
773
|
-
/^SCAN\s+CONSTANT\s+ROW$/i.test(detail.trim()) ||
|
|
774
|
-
/^SCAN\s+SUBQUERY\s+\d+$/i.test(detail.trim())
|
|
775
|
-
) {
|
|
776
|
-
continue
|
|
777
|
-
}
|
|
778
|
-
// A schema-qualified reference plans as `SCAN main.habits`. The table is the last dotted
|
|
779
|
-
// segment; reading the first would reject `main.habits` as a table literally named "main".
|
|
780
|
-
const match =
|
|
781
|
-
/(?:SCAN|SEARCH)\s+(?:TABLE\s+)?(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(
|
|
782
|
-
detail,
|
|
783
|
-
)
|
|
784
|
-
if (match !== null) {
|
|
785
|
-
const relation = match[1]?.toLowerCase() ?? ""
|
|
786
|
-
if (!allowlist.has(relation)) {
|
|
787
|
-
return {
|
|
788
|
-
isError: true,
|
|
789
|
-
text: `query touches ${JSON.stringify(match[1])}, which is not exposed`,
|
|
790
|
-
}
|
|
791
|
-
}
|
|
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`,
|
|
792
260
|
}
|
|
793
261
|
}
|
|
794
262
|
|
|
@@ -796,11 +264,7 @@ export function serveDatabaseAsMcp(
|
|
|
796
264
|
try {
|
|
797
265
|
// Fetch at most one row beyond the advertised cap. This keeps the database driver's
|
|
798
266
|
// materialization bounded even when the caller submits `SELECT * FROM a_huge_table`.
|
|
799
|
-
|
|
800
|
-
limitedRows = await querySession.run(
|
|
801
|
-
`SELECT * FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`,
|
|
802
|
-
deadline,
|
|
803
|
-
)
|
|
267
|
+
limitedRows = await querySession.run(boundedSqliteQuery(query, maxRows), deadline)
|
|
804
268
|
} catch (error) {
|
|
805
269
|
if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
|
|
806
270
|
return { isError: true, text: `query failed: ${String(error)}` }
|
|
@@ -810,9 +274,8 @@ export function serveDatabaseAsMcp(
|
|
|
810
274
|
let total: number | null = limitedRows.length
|
|
811
275
|
if (wasLimited && options.runQuery?.exactTotal === true) {
|
|
812
276
|
try {
|
|
813
|
-
// 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
|
|
814
277
|
const countRows = (await querySession.run(
|
|
815
|
-
|
|
278
|
+
countSqliteQuery(query),
|
|
816
279
|
deadline,
|
|
817
280
|
)) as Array<{ __nifra_total?: unknown }>
|
|
818
281
|
const count = countRows[0]?.__nifra_total
|
|
@@ -827,27 +290,18 @@ export function serveDatabaseAsMcp(
|
|
|
827
290
|
} else if (wasLimited) {
|
|
828
291
|
total = null
|
|
829
292
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
|
|
835
|
-
}
|
|
836
|
-
let serialized = JSON.stringify(result)
|
|
837
|
-
const encoded = new TextEncoder()
|
|
838
|
-
while (encoded.encode(serialized).byteLength > maxResultBytes && shown > 0) {
|
|
839
|
-
shown = Math.max(0, Math.floor(shown / 2))
|
|
840
|
-
payload = limitedRows.slice(0, shown)
|
|
841
|
-
result = {
|
|
842
|
-
rows: payload,
|
|
293
|
+
const fit = fitToBytes(
|
|
294
|
+
Math.min(limitedRows.length, maxRows),
|
|
295
|
+
(shown): Record<string, unknown> => ({
|
|
296
|
+
rows: limitedRows.slice(0, shown),
|
|
843
297
|
...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (
|
|
298
|
+
}),
|
|
299
|
+
maxResultBytes,
|
|
300
|
+
)
|
|
301
|
+
if (fit === undefined) {
|
|
848
302
|
return { isError: true, text: "query result exceeds maxResultBytes" }
|
|
849
303
|
}
|
|
850
|
-
return { text: serialized, structuredContent:
|
|
304
|
+
return { text: fit.serialized, structuredContent: fit.value }
|
|
851
305
|
} finally {
|
|
852
306
|
activeQueries -= 1
|
|
853
307
|
}
|
|
@@ -860,6 +314,7 @@ export function serveDatabaseAsMcp(
|
|
|
860
314
|
name: options.name ?? "nifra-db",
|
|
861
315
|
version: options.version ?? "1.0.0",
|
|
862
316
|
tools,
|
|
317
|
+
...(options.allowedHosts !== undefined ? { allowedHosts: options.allowedHosts } : {}),
|
|
863
318
|
})
|
|
864
319
|
|
|
865
320
|
const runQueryOptions = options.runQuery
|