@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/dist/index.js CHANGED
@@ -23,6 +23,8 @@
23
23
  * app.mount("/mcp", mcp.fetch)
24
24
  */
25
25
  import { createMcpServer, defineMcpTool } from "@nifrajs/mcp";
26
+ import { boundedSqliteQuery, countSqliteQuery, fitToBytes, gateSqliteStatement, unexposedPlanRelation, } from "./internal/shared.js";
27
+ import { openQuerySession, QueryTimeoutError, } from "./internal/sqlite-session.js";
26
28
  export class McpDbConfigError extends Error {
27
29
  constructor(message) {
28
30
  super(`@nifrajs/mcp-db: ${message}`);
@@ -42,498 +44,11 @@ function sqlIdentifiers(strings, ...identifiers) {
42
44
  }
43
45
  return sql;
44
46
  }
45
- const stripSqlNoise = (sql) => sql
46
- // Strings first so comment markers inside literals don't count.
47
- .replace(/'(?:[^']|'')*'/g, "''")
48
- .replace(/"(?:[^"]|"")*"/g, '""')
49
- .replace(/--[^\n]*/g, " ")
50
- .replace(/\/\*[\s\S]*?\*\//g, " ");
51
- // SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
52
- // an identifier character; leaving it out would end a word early and split one table name into two.
53
- const WORD_START = /[A-Za-z_\u0080-\uffff]/;
54
- const WORD_PART = /[A-Za-z0-9_$\u0080-\uffff]/;
55
- /**
56
- * Tokenize far enough to name every relation. A regex cannot do this job: SQLite needs no separator
57
- * between a keyword and a quoted identifier (`FROM"users"` is legal), and it accepts four identifier
58
- * quotings - `"x"`, `[x]`, `` `x` ``, and bare. A `\s+`-anchored pattern silently reads none of
59
- * those as a table reference, which is exactly how an aliased `FROM"users"AS habits` slipped past
60
- * the allowlist and returned a non-exposed table's rows.
61
- *
62
- * String literals and comments become nothing, so a `FROM` inside attacker-controlled text is inert.
63
- */
64
- function tokenizeSql(sql) {
65
- const tokens = [];
66
- let i = 0;
67
- /** Read a quoted identifier, collapsing the doubled-delimiter escape SQLite uses for `"` and `` ` ``. */
68
- const readQuoted = (close, escapable) => {
69
- let value = "";
70
- i++; // past the opening delimiter
71
- while (i < sql.length) {
72
- if (sql[i] === close) {
73
- if (escapable && sql[i + 1] === close) {
74
- value += close;
75
- i += 2;
76
- continue;
77
- }
78
- i++;
79
- tokens.push({ kind: "word", value, bare: false });
80
- return;
81
- }
82
- value += sql[i];
83
- i++;
84
- }
85
- // Unterminated - emit what we have. The caller's single-statement and plan checks still run.
86
- tokens.push({ kind: "word", value, bare: false });
87
- };
88
- while (i < sql.length) {
89
- const char = sql[i];
90
- if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
91
- i++;
92
- }
93
- else if (char === "-" && sql[i + 1] === "-") {
94
- const end = sql.indexOf("\n", i);
95
- i = end === -1 ? sql.length : end + 1;
96
- }
97
- else if (char === "/" && sql[i + 1] === "*") {
98
- const end = sql.indexOf("*/", i + 2);
99
- i = end === -1 ? sql.length : end + 2;
100
- }
101
- else if (char === "'") {
102
- // Literal: consumed and dropped, so its contents can never be read as SQL.
103
- i++;
104
- while (i < sql.length) {
105
- if (sql[i] === "'") {
106
- if (sql[i + 1] === "'") {
107
- i += 2;
108
- continue;
109
- }
110
- i++;
111
- break;
112
- }
113
- i++;
114
- }
115
- }
116
- else if (char === '"') {
117
- readQuoted('"', true);
118
- }
119
- else if (char === "`") {
120
- readQuoted("`", true);
121
- }
122
- else if (char === "[") {
123
- readQuoted("]", false); // MSSQL-style bracket quoting: no escape form
124
- }
125
- else if (WORD_START.test(char)) {
126
- const start = i;
127
- while (i < sql.length && WORD_PART.test(sql[i]))
128
- i++;
129
- tokens.push({ kind: "word", value: sql.slice(start, i), bare: true });
130
- }
131
- else if (char >= "0" && char <= "9") {
132
- while (i < sql.length && /[0-9A-Za-z._]/.test(sql[i]))
133
- i++;
134
- }
135
- else {
136
- tokens.push({ kind: "punct", value: char, bare: false });
137
- i++;
138
- }
139
- }
140
- return tokens;
141
- }
142
- /** True when `token` is the unquoted keyword `word` (case-insensitive). */
143
- const isKeyword = (token, word) => token?.bare === true && token.value.toLowerCase() === word;
144
- /**
145
- * Every relation the statement names after `FROM`/`JOIN`, lowercased, with CTE names excluded.
146
- * A schema qualifier resolves to its table (`main.habits` -> `habits`); a subquery contributes
147
- * nothing of its own because its inner `FROM` is tokenized alongside everything else.
148
- */
149
- function relationNames(sql) {
150
- const tokens = tokenizeSql(sql);
151
- // `name AS (` only ever introduces a CTE - a column alias cannot be followed by a paren - so this
152
- // needs no `WITH` tracking to be exact.
153
- const ctes = new Set();
154
- for (let i = 1; i < tokens.length - 1; i++) {
155
- const name = tokens[i - 1];
156
- if (isKeyword(tokens[i], "as") && tokens[i + 1]?.value === "(" && name.kind === "word") {
157
- ctes.add(name.value.toLowerCase());
158
- }
159
- }
160
- const names = [];
161
- for (let i = 0; i < tokens.length; i++) {
162
- if (!isKeyword(tokens[i], "from") && !isKeyword(tokens[i], "join"))
163
- continue;
164
- const first = tokens[i + 1];
165
- if (first === undefined || first.kind !== "word")
166
- continue; // `FROM (subquery)` - nothing to name here
167
- // A qualified reference names the table second; an unqualified one names it first.
168
- const qualified = tokens[i + 2]?.value === "." && tokens[i + 3]?.kind === "word";
169
- const table = qualified ? tokens[i + 3].value : first.value;
170
- if (table !== "" && !ctes.has(table.toLowerCase()))
171
- names.push(table.toLowerCase());
172
- }
173
- return names;
174
- }
175
- /** Reject multi-statement input: allow one terminator only when it is the final character. */
176
- const isSingleStatement = (sql) => {
177
- let quote = null;
178
- let lineComment = false;
179
- let blockComment = false;
180
- for (let i = 0; i < sql.length; i++) {
181
- const char = sql[i];
182
- const next = sql[i + 1];
183
- if (lineComment) {
184
- if (char === "\n")
185
- lineComment = false;
186
- continue;
187
- }
188
- if (blockComment) {
189
- if (char === "*" && next === "/") {
190
- blockComment = false;
191
- i++;
192
- }
193
- continue;
194
- }
195
- if (quote !== null) {
196
- if (char === quote) {
197
- if (next === quote)
198
- i++;
199
- else
200
- quote = null;
201
- }
202
- continue;
203
- }
204
- if (char === "'" || char === '"') {
205
- quote = char;
206
- continue;
207
- }
208
- if (char === "-" && next === "-") {
209
- lineComment = true;
210
- i++;
211
- continue;
212
- }
213
- if (char === "/" && next === "*") {
214
- blockComment = true;
215
- i++;
216
- continue;
217
- }
218
- if (char === ";") {
219
- return sql.slice(i + 1).trim() === "";
220
- }
221
- }
222
- return true;
223
- };
224
- const isReadStatement = (sql) => {
225
- const head = stripSqlNoise(sql).trim().toLowerCase();
226
- return head.startsWith("select") || head.startsWith("with");
227
- };
228
- /** Remove one trailing statement terminator (and anything after it) while preserving literals and
229
- * identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
230
- * wrapper's closing parenthesis. */
231
- function executableSql(sql) {
232
- let quote = null;
233
- let lineComment = false;
234
- let lineCommentStart = -1;
235
- let blockComment = false;
236
- for (let i = 0; i < sql.length; i++) {
237
- const char = sql[i];
238
- const next = sql[i + 1];
239
- if (lineComment) {
240
- if (char === "\n") {
241
- lineComment = false;
242
- lineCommentStart = -1;
243
- }
244
- continue;
245
- }
246
- if (blockComment) {
247
- if (char === "*" && next === "/") {
248
- blockComment = false;
249
- i++;
250
- }
251
- continue;
252
- }
253
- if (quote !== null) {
254
- if (char === quote) {
255
- if (next === quote)
256
- i++;
257
- else
258
- quote = null;
259
- }
260
- continue;
261
- }
262
- if (char === "'" || char === '"') {
263
- quote = char;
264
- continue;
265
- }
266
- if (char === "-" && next === "-") {
267
- lineComment = true;
268
- lineCommentStart = i;
269
- i++;
270
- continue;
271
- }
272
- if (char === "/" && next === "*") {
273
- blockComment = true;
274
- i++;
275
- continue;
276
- }
277
- if (char === ";")
278
- return sql.slice(0, i).trim();
279
- }
280
- if (lineComment && lineCommentStart >= 0)
281
- return sql.slice(0, lineCommentStart).trim();
282
- return sql.trim();
283
- }
284
47
  function assertResultLimit(value, name) {
285
48
  if (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER) {
286
49
  throw new McpDbConfigError(`${name} must be a finite positive safe integer`);
287
50
  }
288
51
  }
289
- class QueryTimeoutError extends Error {
290
- constructor() {
291
- super("query exceeded the time limit");
292
- this.name = "QueryTimeoutError";
293
- }
294
- }
295
- /**
296
- * The isolated lane. The worker reopens the SAME database FILE read-only and answers one statement
297
- * at a time; it is spawned once and reused for the life of the server. It deliberately does not
298
- * take a `serialize()` snapshot - that copies the whole database into memory on EVERY call, which
299
- * is a larger availability problem than the slow query the deadline exists to bound.
300
- */
301
- const SQLITE_WORKER_SOURCE = `
302
- const { Database } = require("bun:sqlite")
303
- let db
304
- self.onmessage = (event) => {
305
- const { id, filename, sql, type } = event.data
306
- if (type === "close") {
307
- let response
308
- try {
309
- // prepare().all() leaves a statement alive until it is finalized or collected. Bun's
310
- // default close(false) preserves those statements and can keep the database file locked;
311
- // close(true) finalizes every outstanding statement before the worker exits.
312
- db?.close(true)
313
- db = undefined
314
- response = { id, closed: true, ok: true }
315
- } catch (error) {
316
- response = { id, closed: true, ok: false, error: String(error) }
317
- }
318
- // A forced parent-side terminate can leave native SQLite handles locked on Windows even after
319
- // close() returns. Let the worker exit its own event turn so the runtime can finish releasing
320
- // those handles before the parent considers graceful cleanup complete.
321
- self.postMessage(response)
322
- self.close()
323
- return
324
- }
325
- try {
326
- if (db === undefined) {
327
- db = new Database(filename, { readonly: true })
328
- db.run("PRAGMA query_only = ON")
329
- }
330
- self.postMessage({ id, ok: true, rows: db.prepare(sql).all() })
331
- } catch (error) {
332
- self.postMessage({ id, ok: false, error: String(error) })
333
- }
334
- }
335
- `;
336
- function isWorkerAvailable() {
337
- return typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined";
338
- }
339
- /** Only a file-backed database can be reopened in a worker. `:memory:` and the anonymous temp
340
- * database live in this process alone, so they take the in-process lane instead. */
341
- function reopenableFilename(db) {
342
- const filename = db.filename;
343
- if (typeof filename !== "string" || filename === "" || filename === ":memory:")
344
- return undefined;
345
- return filename.startsWith("file::memory:") ? undefined : filename;
346
- }
347
- const WORKER_CLOSE_TIMEOUT_MS = 1_000;
348
- function workerSession(filename) {
349
- let worker;
350
- let sourceUrl;
351
- let nextId = 0;
352
- const pending = new Map();
353
- let lane = Promise.resolve();
354
- let closed = false;
355
- let closePromise;
356
- let pendingClose;
357
- const detachWorker = (active = worker) => {
358
- if (active === undefined)
359
- return;
360
- if (worker !== active)
361
- return;
362
- worker = undefined;
363
- if (sourceUrl !== undefined)
364
- URL.revokeObjectURL(sourceUrl);
365
- sourceUrl = undefined;
366
- };
367
- const releaseWorker = (active = worker) => {
368
- if (active === undefined)
369
- return;
370
- active.terminate();
371
- detachWorker(active);
372
- };
373
- const rejectPending = (error) => {
374
- const waiters = [...pending.values()];
375
- pending.clear();
376
- for (const waiter of waiters)
377
- waiter.reject(error);
378
- };
379
- /** Drop the worker and fail everything riding on it. The only bound this side can enforce over a
380
- * statement already running inside native SQLite is to stop owning the connection it runs on. */
381
- const discard = (error) => {
382
- const closing = pendingClose;
383
- pendingClose = undefined;
384
- releaseWorker();
385
- rejectPending(error);
386
- closing?.resolve();
387
- };
388
- const ensureWorker = () => {
389
- if (worker !== undefined)
390
- return worker;
391
- sourceUrl = URL.createObjectURL(new Blob([SQLITE_WORKER_SOURCE], { type: "text/javascript" }));
392
- const spawned = new Worker(sourceUrl);
393
- spawned.onmessage = (event) => {
394
- const { id, ok, rows, error } = event.data;
395
- if (typeof id !== "number")
396
- return;
397
- if (event.data.closed === true) {
398
- const closing = pendingClose;
399
- if (closing?.id !== id)
400
- return;
401
- pendingClose = undefined;
402
- closing.resolve(event.data.ok === true);
403
- return;
404
- }
405
- const waiter = pending.get(id);
406
- if (waiter === undefined)
407
- return;
408
- if (Date.now() >= waiter.deadline) {
409
- discard(new QueryTimeoutError());
410
- return;
411
- }
412
- pending.delete(id);
413
- if (ok === true)
414
- waiter.resolve(rows ?? []);
415
- else
416
- waiter.reject(new Error(error ?? "query failed in worker"));
417
- };
418
- spawned.onerror = () => discard(new Error("query failed in worker"));
419
- spawned.unref?.();
420
- worker = spawned;
421
- return spawned;
422
- };
423
- const dispatch = (sql, deadline) => {
424
- if (closed)
425
- return Promise.reject(new Error("query session is closed"));
426
- const active = ensureWorker();
427
- const remaining = deadline - Date.now();
428
- if (remaining <= 0) {
429
- discard(new QueryTimeoutError());
430
- return Promise.reject(new QueryTimeoutError());
431
- }
432
- const id = ++nextId;
433
- return new Promise((resolve, reject) => {
434
- const timer = setTimeout(() => discard(new QueryTimeoutError()), remaining);
435
- pending.set(id, {
436
- deadline,
437
- resolve: (rows) => {
438
- clearTimeout(timer);
439
- resolve(rows);
440
- },
441
- reject: (error) => {
442
- clearTimeout(timer);
443
- reject(error);
444
- },
445
- });
446
- try {
447
- active.postMessage({ id, filename, sql });
448
- }
449
- catch (error) {
450
- pending.delete(id);
451
- clearTimeout(timer);
452
- reject(error instanceof Error ? error : new Error(String(error)));
453
- }
454
- });
455
- };
456
- return {
457
- run(sql, deadline) {
458
- // One worker owns the connection, so overlapping calls queue behind each other rather than
459
- // interleaving statements on it.
460
- const result = lane.then(() => dispatch(sql, deadline));
461
- lane = result.catch(() => undefined);
462
- return result;
463
- },
464
- close() {
465
- if (closePromise !== undefined)
466
- return closePromise;
467
- closed = true;
468
- closePromise = (async () => {
469
- const active = worker;
470
- if (active !== undefined) {
471
- await new Promise((resolve) => {
472
- const id = ++nextId;
473
- let settled = false;
474
- const finish = (error, graceful = false) => {
475
- if (settled)
476
- return;
477
- settled = true;
478
- clearTimeout(timer);
479
- if (pendingClose?.id === id)
480
- pendingClose = undefined;
481
- if (error !== undefined)
482
- rejectPending(error);
483
- if (graceful)
484
- detachWorker(active);
485
- else
486
- releaseWorker(active);
487
- resolve();
488
- };
489
- const timer = setTimeout(() => {
490
- finish(new Error("query session is closed"));
491
- }, WORKER_CLOSE_TIMEOUT_MS);
492
- pendingClose = { id, resolve: (graceful) => finish(undefined, graceful) };
493
- try {
494
- active.postMessage({ id, type: "close" });
495
- }
496
- catch {
497
- finish(new Error("query session is closed"));
498
- }
499
- });
500
- }
501
- await lane;
502
- })();
503
- return closePromise;
504
- },
505
- };
506
- }
507
- /**
508
- * The in-process lane, used when the database cannot be reopened elsewhere. The deadline is checked
509
- * around the statement and nothing more: `prepare().all()` is synchronous, so it holds the only
510
- * thread there is and no timer of ours can fire while it runs. That makes this lane BEST EFFORT -
511
- * it reports an overrun, it does not cut one short.
512
- */
513
- function inProcessSession(db) {
514
- return {
515
- async run(sql, deadline) {
516
- if (Date.now() >= deadline)
517
- throw new QueryTimeoutError();
518
- const rows = db.prepare(sql).all();
519
- if (Date.now() > deadline)
520
- throw new QueryTimeoutError();
521
- return rows;
522
- },
523
- async close() { },
524
- };
525
- }
526
- /**
527
- * Pick the lane once, at construction. A file-backed database is isolated in a reusable read-only
528
- * worker, which is the only arrangement that can actually stop a running statement. Anything else
529
- * runs in process - every database keeps working, none is refused.
530
- */
531
- function openQuerySession(db) {
532
- const filename = reopenableFilename(db);
533
- if (filename !== undefined && isWorkerAvailable())
534
- return workerSession(filename);
535
- return inProcessSession(db);
536
- }
537
52
  /**
538
53
  * Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
539
54
  * security model. Throws {@link McpDbConfigError} on any unsafe configuration - always at
@@ -622,31 +137,13 @@ export function serveDatabaseAsMcp(db, options) {
622
137
  }
623
138
  activeQueries += 1;
624
139
  try {
625
- const sql = String(args.sql ?? "").trim();
626
- if (sql === "")
627
- return { isError: true, text: "empty query" };
628
- if (!isSingleStatement(sql)) {
629
- return { isError: true, text: "only a single statement is allowed" };
630
- }
631
- if (!isReadStatement(sql)) {
632
- return { isError: true, text: "only SELECT (or WITH…SELECT) statements are allowed" };
633
- }
140
+ const gate = gateSqliteStatement(String(args.sql ?? ""), (relation) => allowlist.has(relation));
141
+ if (!gate.ok)
142
+ return { isError: true, text: gate.text };
634
143
  const deadline = Date.now() + queryTimeoutMs;
635
- // Verify SQL relation tokens before the plan check. SQLite's plan output uses aliases as
636
- // scan targets (e.g. `users AS habits` becomes `SCAN habits`), so plan-only validation can
637
- // mistake an attacker-controlled alias for an allowlisted table.
638
- const executable = executableSql(sql);
639
- for (const relation of relationNames(executable)) {
640
- if (!allowlist.has(relation)) {
641
- return {
642
- isError: true,
643
- text: `query touches ${JSON.stringify(relation)}, which is not exposed`,
644
- };
645
- }
646
- }
647
144
  // Verify via the query plan that only allowlisted tables are touched. SQLite names every
648
145
  // scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
649
- const query = executable;
146
+ const query = gate.query;
650
147
  let planRows;
651
148
  try {
652
149
  planRows = (await querySession.run(`EXPLAIN QUERY PLAN ${query}`, deadline));
@@ -656,34 +153,18 @@ export function serveDatabaseAsMcp(db, options) {
656
153
  return { isError: true, text: error.message };
657
154
  return { isError: true, text: `query failed to plan: ${String(error)}` };
658
155
  }
659
- for (const row of planRows) {
660
- const detail = typeof row.detail === "string" ? row.detail : "";
661
- // SQLite also emits non-table nodes such as `SCAN CONSTANT ROW` for a
662
- // constant-only SELECT. Only treat a scan/search target as a relation
663
- // when it is not one of those planner pseudo-nodes.
664
- if (/^SCAN\s+CONSTANT\s+ROW$/i.test(detail.trim()) ||
665
- /^SCAN\s+SUBQUERY\s+\d+$/i.test(detail.trim())) {
666
- continue;
667
- }
668
- // A schema-qualified reference plans as `SCAN main.habits`. The table is the last dotted
669
- // segment; reading the first would reject `main.habits` as a table literally named "main".
670
- const match = /(?:SCAN|SEARCH)\s+(?:TABLE\s+)?(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(detail);
671
- if (match !== null) {
672
- const relation = match[1]?.toLowerCase() ?? "";
673
- if (!allowlist.has(relation)) {
674
- return {
675
- isError: true,
676
- text: `query touches ${JSON.stringify(match[1])}, which is not exposed`,
677
- };
678
- }
679
- }
156
+ const unexposed = unexposedPlanRelation(planRows, (relation) => allowlist.has(relation));
157
+ if (unexposed !== undefined) {
158
+ return {
159
+ isError: true,
160
+ text: `query touches ${JSON.stringify(unexposed)}, which is not exposed`,
161
+ };
680
162
  }
681
163
  let limitedRows;
682
164
  try {
683
165
  // Fetch at most one row beyond the advertised cap. This keeps the database driver's
684
166
  // materialization bounded even when the caller submits `SELECT * FROM a_huge_table`.
685
- // 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
686
- limitedRows = await querySession.run(`SELECT * FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`, deadline);
167
+ limitedRows = await querySession.run(boundedSqliteQuery(query, maxRows), deadline);
687
168
  }
688
169
  catch (error) {
689
170
  if (error instanceof QueryTimeoutError)
@@ -694,8 +175,7 @@ export function serveDatabaseAsMcp(db, options) {
694
175
  let total = limitedRows.length;
695
176
  if (wasLimited && options.runQuery?.exactTotal === true) {
696
177
  try {
697
- // 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
698
- const countRows = (await querySession.run(`SELECT count(*) AS "__nifra_total" FROM (${query}) AS "__nifra_count"`, deadline));
178
+ const countRows = (await querySession.run(countSqliteQuery(query), deadline));
699
179
  const count = countRows[0]?.__nifra_total;
700
180
  if (typeof count !== "number" || !Number.isSafeInteger(count) || count < maxRows) {
701
181
  return { isError: true, text: "query failed: invalid row count" };
@@ -711,27 +191,14 @@ export function serveDatabaseAsMcp(db, options) {
711
191
  else if (wasLimited) {
712
192
  total = null;
713
193
  }
714
- let shown = Math.min(limitedRows.length, maxRows);
715
- let payload = limitedRows.slice(0, shown);
716
- let result = {
717
- rows: payload,
194
+ const fit = fitToBytes(Math.min(limitedRows.length, maxRows), (shown) => ({
195
+ rows: limitedRows.slice(0, shown),
718
196
  ...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
719
- };
720
- let serialized = JSON.stringify(result);
721
- const encoded = new TextEncoder();
722
- while (encoded.encode(serialized).byteLength > maxResultBytes && shown > 0) {
723
- shown = Math.max(0, Math.floor(shown / 2));
724
- payload = limitedRows.slice(0, shown);
725
- result = {
726
- rows: payload,
727
- ...(wasLimited || shown < limitedRows.length ? { truncated: true, total } : {}),
728
- };
729
- serialized = JSON.stringify(result);
730
- }
731
- if (encoded.encode(serialized).byteLength > maxResultBytes) {
197
+ }), maxResultBytes);
198
+ if (fit === undefined) {
732
199
  return { isError: true, text: "query result exceeds maxResultBytes" };
733
200
  }
734
- return { text: serialized, structuredContent: result };
201
+ return { text: fit.serialized, structuredContent: fit.value };
735
202
  }
736
203
  finally {
737
204
  activeQueries -= 1;
@@ -744,6 +211,7 @@ export function serveDatabaseAsMcp(db, options) {
744
211
  name: options.name ?? "nifra-db",
745
212
  version: options.version ?? "1.0.0",
746
213
  tools,
214
+ ...(options.allowedHosts !== undefined ? { allowedHosts: options.allowedHosts } : {}),
747
215
  });
748
216
  const runQueryOptions = options.runQuery;
749
217
  if (session === undefined || runQueryOptions === undefined)