@dbx-app/node-core 0.4.3 → 0.4.5

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.
@@ -17,6 +17,14 @@ export interface ConnectionConfig {
17
17
  proxy_username?: string;
18
18
  proxy_password?: string;
19
19
  ssl: boolean;
20
+ ca_cert_path?: string;
21
+ oracle_connection_type?: "service_name" | "sid";
22
+ redis_connection_mode?: "standalone" | "sentinel";
23
+ redis_sentinel_master?: string;
24
+ redis_sentinel_nodes?: string;
25
+ redis_sentinel_username?: string;
26
+ redis_sentinel_password?: string;
27
+ redis_sentinel_tls?: boolean;
20
28
  }
21
29
  export interface ConnectionStoreOptions {
22
30
  path?: string;
@@ -53,6 +53,9 @@ export async function loadConnections(options = {}) {
53
53
  config.password = getSecret(db, row.id, "password");
54
54
  if (!config.proxy_password)
55
55
  config.proxy_password = getSecret(db, row.id, "proxy_password");
56
+ if (!config.redis_sentinel_password) {
57
+ config.redis_sentinel_password = getSecret(db, row.id, "redis_sentinel_password");
58
+ }
56
59
  configs.push(config);
57
60
  }
58
61
  return configs;
@@ -148,7 +151,14 @@ export async function addConnection(config) {
148
151
  proxy_password: "",
149
152
  ssl: normalized.ssl ?? false,
150
153
  sysdba: false,
154
+ oracle_connection_type: normalized.oracle_connection_type ?? null,
151
155
  connection_string: null,
156
+ redis_connection_mode: normalized.redis_connection_mode ?? "standalone",
157
+ redis_sentinel_master: normalized.redis_sentinel_master ?? "",
158
+ redis_sentinel_nodes: normalized.redis_sentinel_nodes ?? "",
159
+ redis_sentinel_username: normalized.redis_sentinel_username ?? "",
160
+ redis_sentinel_password: "",
161
+ redis_sentinel_tls: normalized.redis_sentinel_tls ?? false,
152
162
  };
153
163
  const configJson = JSON.stringify(full);
154
164
  const insert = db.transaction(() => {
@@ -159,6 +169,9 @@ export async function addConnection(config) {
159
169
  if (normalized.proxy_password) {
160
170
  db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "proxy_password", normalized.proxy_password);
161
171
  }
172
+ if (normalized.redis_sentinel_password) {
173
+ db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "redis_sentinel_password", normalized.redis_sentinel_password);
174
+ }
162
175
  });
163
176
  insert();
164
177
  db.close();
@@ -23,3 +23,56 @@ export interface QueryOptions {
23
23
  export declare function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult>;
24
24
  export declare function listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
25
25
  export declare function describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
26
+ export declare function mongoDocumentsToQueryResult(documents: unknown[], total: number): QueryResult;
27
+ export declare function inferMongoColumns(documents: unknown[]): ColumnInfo[];
28
+ interface MongoFindCommand {
29
+ collection: string;
30
+ filter: string;
31
+ skip: number;
32
+ limit: number;
33
+ sort?: string;
34
+ }
35
+ interface MongoCountDocumentsCommand {
36
+ collection: string;
37
+ filter: string;
38
+ }
39
+ interface MongoAggregateCommand {
40
+ collection: string;
41
+ pipeline: string;
42
+ }
43
+ export type MongoWriteCommand = {
44
+ kind: "insert";
45
+ collection: string;
46
+ docsJson: string;
47
+ } | {
48
+ kind: "update";
49
+ collection: string;
50
+ filter: string;
51
+ update: string;
52
+ many: boolean;
53
+ } | {
54
+ kind: "delete";
55
+ collection: string;
56
+ filter: string;
57
+ many: boolean;
58
+ };
59
+ export declare function parseMongoFindCommand(input: string): MongoFindCommand | null;
60
+ export declare function parseMongoCountDocumentsCommand(input: string): MongoCountDocumentsCommand | null;
61
+ export declare function parseMongoAggregateCommand(input: string): MongoAggregateCommand | null;
62
+ export declare function mongoAggregateWriteStage(pipelineJson: string): "$out" | "$merge" | null;
63
+ export declare function parseMongoWriteCommand(input: string): MongoWriteCommand | null;
64
+ export declare function evaluateMongoWriteSafety(command: MongoWriteCommand, options: {
65
+ allowWrites?: boolean;
66
+ allowDangerous?: boolean;
67
+ }): {
68
+ allowed: boolean;
69
+ reason?: string;
70
+ };
71
+ export declare function evaluateMongoAggregateSafety(command: MongoAggregateCommand, options: {
72
+ allowWrites?: boolean;
73
+ allowDangerous?: boolean;
74
+ }): {
75
+ allowed: boolean;
76
+ reason?: string;
77
+ };
78
+ export {};
package/dist/database.js CHANGED
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { homedir, platform } from "node:os";
5
5
  import Database from "better-sqlite3";
6
6
  import { sqlSafetyFromEnv } from "./sql-safety.js";
7
+ import { isDirectQueryType } from "./diagnostics.js";
7
8
  const MAX_ROWS = 100;
8
9
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
9
10
  const QUERY_TIMEOUT_MS = 30_000;
@@ -194,19 +195,6 @@ function portBytes(port) {
194
195
  function isMysqlType(dbType) {
195
196
  return dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
196
197
  }
197
- function isDirectType(dbType) {
198
- switch (dbType) {
199
- case "postgres":
200
- case "redshift":
201
- case "mysql":
202
- case "doris":
203
- case "starrocks":
204
- case "sqlite":
205
- return true;
206
- default:
207
- return false;
208
- }
209
- }
210
198
  function bridgeAppDataDir() {
211
199
  const home = homedir();
212
200
  switch (platform()) {
@@ -337,7 +325,36 @@ function sqliteQuery(config, sql, options) {
337
325
  }
338
326
  }
339
327
  export async function executeQuery(config, sql, options) {
340
- if (isDirectType(config.db_type)) {
328
+ if (config.db_type === "mongodb") {
329
+ const find = parseMongoFindCommand(sql);
330
+ if (find) {
331
+ const result = await withTimeout(mongoFindDocuments(config, find.collection, find.skip, find.limit, find.filter, find.sort), resolveTimeoutMs(options));
332
+ return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total);
333
+ }
334
+ const count = parseMongoCountDocumentsCommand(sql);
335
+ if (count) {
336
+ const result = await withTimeout(mongoFindDocuments(config, count.collection, 0, 1, count.filter), resolveTimeoutMs(options));
337
+ return { columns: ["count"], rows: [{ count: result.total }], row_count: 1 };
338
+ }
339
+ const aggregate = parseMongoAggregateCommand(sql);
340
+ if (aggregate) {
341
+ const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv());
342
+ if (!safety.allowed)
343
+ throw new Error(safety.reason);
344
+ const result = await withTimeout(mongoAggregateDocuments(config, aggregate.collection, aggregate.pipeline, resolveMaxRows(options)), resolveTimeoutMs(options));
345
+ return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total);
346
+ }
347
+ const write = parseMongoWriteCommand(sql);
348
+ if (write) {
349
+ const safety = evaluateMongoWriteSafety(write, sqlSafetyFromEnv());
350
+ if (!safety.allowed)
351
+ throw new Error(safety.reason);
352
+ const affected = await withTimeout(executeMongoWrite(config, write), resolveTimeoutMs(options));
353
+ return { columns: [], rows: [], row_count: affected };
354
+ }
355
+ throw new Error("Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.projects.countDocuments({}), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})");
356
+ }
357
+ if (isDirectQueryType(config.db_type)) {
341
358
  return query(config, sql, undefined, options);
342
359
  }
343
360
  const result = await withTimeout(bridgeDataRequest("/data/execute-query", {
@@ -348,11 +365,19 @@ export async function executeQuery(config, sql, options) {
348
365
  return convertBridgeQueryResult(result, options);
349
366
  }
350
367
  export async function listTables(config, schema) {
368
+ if (config.db_type === "mongodb") {
369
+ const collections = await bridgeDataRequest("/data/mongo/list-collections", {
370
+ connection_name: config.name,
371
+ database: config.database || "",
372
+ schema: schema || "",
373
+ });
374
+ return collections.map((name) => ({ name, type: "COLLECTION" }));
375
+ }
351
376
  if (config.db_type === "sqlite") {
352
377
  const result = await query(config, `SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`);
353
378
  return result.rows.map((r) => ({ name: String(r.name || ""), type: String(r.type || "table") }));
354
379
  }
355
- if (!isDirectType(config.db_type)) {
380
+ if (!isDirectQueryType(config.db_type)) {
356
381
  const tables = await bridgeDataRequest("/data/list-tables", {
357
382
  connection_name: config.name,
358
383
  database: config.database || "",
@@ -370,6 +395,10 @@ export async function listTables(config, schema) {
370
395
  return result.rows.map((r) => ({ name: String(r.name || r.NAME), type: String(r.type || r.TYPE || "TABLE") }));
371
396
  }
372
397
  export async function describeTable(config, table, schema) {
398
+ if (config.db_type === "mongodb") {
399
+ const result = await mongoFindDocuments(config, table, 0, 20, "{}");
400
+ return inferMongoColumns(result.documents);
401
+ }
373
402
  if (config.db_type === "sqlite") {
374
403
  const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`);
375
404
  return result.rows.map((r) => ({
@@ -381,7 +410,7 @@ export async function describeTable(config, table, schema) {
381
410
  comment: null,
382
411
  }));
383
412
  }
384
- if (!isDirectType(config.db_type)) {
413
+ if (!isDirectQueryType(config.db_type)) {
385
414
  const columns = await bridgeDataRequest("/data/describe-table", {
386
415
  connection_name: config.name,
387
416
  database: config.database || "",
@@ -413,3 +442,379 @@ export async function describeTable(config, table, schema) {
413
442
  comment: r.comment != null ? String(r.comment) : null,
414
443
  }));
415
444
  }
445
+ async function mongoFindDocuments(config, collection, skip, limit, filter, sort) {
446
+ return bridgeDataRequest("/data/mongo/find-documents", {
447
+ connection_name: config.name,
448
+ database: config.database || "",
449
+ collection,
450
+ skip,
451
+ limit,
452
+ filter,
453
+ sort,
454
+ });
455
+ }
456
+ async function executeMongoWrite(config, command) {
457
+ if (command.kind === "insert") {
458
+ const result = await bridgeDataRequest("/data/mongo/insert-documents", {
459
+ connection_name: config.name,
460
+ database: config.database || "",
461
+ collection: command.collection,
462
+ docs_json: command.docsJson,
463
+ });
464
+ return result.affected_rows;
465
+ }
466
+ if (command.kind === "update") {
467
+ const result = await bridgeDataRequest("/data/mongo/update-documents", {
468
+ connection_name: config.name,
469
+ database: config.database || "",
470
+ collection: command.collection,
471
+ filter_json: command.filter,
472
+ update_json: command.update,
473
+ many: command.many,
474
+ });
475
+ return result.affected_rows;
476
+ }
477
+ const result = await bridgeDataRequest("/data/mongo/delete-documents", {
478
+ connection_name: config.name,
479
+ database: config.database || "",
480
+ collection: command.collection,
481
+ filter_json: command.filter,
482
+ many: command.many,
483
+ });
484
+ return result.affected_rows;
485
+ }
486
+ async function mongoAggregateDocuments(config, collection, pipelineJson, maxRows) {
487
+ return bridgeDataRequest("/data/mongo/aggregate-documents", {
488
+ connection_name: config.name,
489
+ database: config.database || "",
490
+ collection,
491
+ pipeline_json: pipelineJson,
492
+ max_rows: maxRows,
493
+ });
494
+ }
495
+ export function mongoDocumentsToQueryResult(documents, total) {
496
+ const columns = [];
497
+ for (const doc of documents) {
498
+ if (isRecord(doc)) {
499
+ for (const key of Object.keys(doc)) {
500
+ if (!columns.includes(key))
501
+ columns.push(key);
502
+ }
503
+ }
504
+ else if (!columns.includes("value")) {
505
+ columns.push("value");
506
+ }
507
+ }
508
+ const rows = documents.map((doc) => {
509
+ const row = {};
510
+ for (const column of columns) {
511
+ row[column] = isRecord(doc) ? toCellValue(doc[column]) : column === "value" ? toCellValue(doc) : null;
512
+ }
513
+ return row;
514
+ });
515
+ return { columns, rows, row_count: rows.length };
516
+ }
517
+ export function inferMongoColumns(documents) {
518
+ const columns = new Map();
519
+ for (const doc of documents) {
520
+ if (!isRecord(doc)) {
521
+ const entry = columns.get("value") ?? { types: new Set(), nullable: false };
522
+ entry.types.add(mongoTypeName(doc));
523
+ columns.set("value", entry);
524
+ continue;
525
+ }
526
+ for (const [name, value] of Object.entries(doc)) {
527
+ const entry = columns.get(name) ?? { types: new Set(), nullable: false };
528
+ entry.types.add(mongoTypeName(value));
529
+ if (value === null || value === undefined)
530
+ entry.nullable = true;
531
+ columns.set(name, entry);
532
+ }
533
+ }
534
+ return Array.from(columns.entries()).map(([name, entry]) => ({
535
+ name,
536
+ data_type: Array.from(entry.types).sort().join(" | ") || "unknown",
537
+ is_nullable: entry.nullable,
538
+ column_default: null,
539
+ is_primary_key: name === "_id",
540
+ comment: null,
541
+ }));
542
+ }
543
+ export function parseMongoFindCommand(input) {
544
+ const source = input.trim().replace(/;$/, "").trim();
545
+ const target = parseCollectionMethodTarget(source, "find");
546
+ if (!target)
547
+ return null;
548
+ const findOpenIndex = source.indexOf("(", target.methodCallIndex);
549
+ const findCloseIndex = findMatchingParen(source, findOpenIndex);
550
+ if (findCloseIndex < 0)
551
+ return null;
552
+ const findArgs = splitTopLevel(source.slice(findOpenIndex + 1, findCloseIndex));
553
+ const filter = normalizeJsonArgument(findArgs[0] || "{}");
554
+ if (!filter)
555
+ return null;
556
+ const chain = source.slice(findCloseIndex + 1).trim();
557
+ if (chain && !chain.startsWith("."))
558
+ return null;
559
+ const sortArg = readChainedCallArgument(chain, "sort");
560
+ let sort;
561
+ if (sortArg !== undefined) {
562
+ const parsedSort = normalizeJsonArgument(sortArg);
563
+ if (!parsedSort)
564
+ return null;
565
+ sort = parsedSort;
566
+ }
567
+ const skip = readChainedIntegerArgument(chain, "skip", 0);
568
+ const limit = readChainedIntegerArgument(chain, "limit", MAX_ROWS);
569
+ if (skip === null || limit === null)
570
+ return null;
571
+ return { collection: target.collection, filter, skip, limit, sort };
572
+ }
573
+ export function parseMongoCountDocumentsCommand(input) {
574
+ const source = input.trim().replace(/;$/, "").trim();
575
+ const target = parseCollectionMethodTarget(source, "countDocuments");
576
+ if (!target)
577
+ return null;
578
+ const openIndex = source.indexOf("(", target.methodCallIndex);
579
+ const closeIndex = findMatchingParen(source, openIndex);
580
+ if (closeIndex < 0 || source.slice(closeIndex + 1).trim())
581
+ return null;
582
+ const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
583
+ if (args.length > 1 && args.slice(1).some((arg) => arg.trim()))
584
+ return null;
585
+ const filter = normalizeJsonArgument(args[0] || "{}");
586
+ return filter ? { collection: target.collection, filter } : null;
587
+ }
588
+ export function parseMongoAggregateCommand(input) {
589
+ const source = input.trim().replace(/;$/, "").trim();
590
+ const target = parseCollectionMethodTarget(source, "aggregate");
591
+ if (!target)
592
+ return null;
593
+ const args = parseMethodArgs(source, target.methodCallIndex);
594
+ if (!args || args.length !== 1)
595
+ return null;
596
+ const pipeline = normalizeJsonArgument(args[0]);
597
+ if (!pipeline)
598
+ return null;
599
+ return Array.isArray(JSON.parse(pipeline)) ? { collection: target.collection, pipeline } : null;
600
+ }
601
+ export function mongoAggregateWriteStage(pipelineJson) {
602
+ try {
603
+ const pipeline = JSON.parse(pipelineJson);
604
+ if (!Array.isArray(pipeline))
605
+ return null;
606
+ for (const stage of pipeline) {
607
+ if (!isRecord(stage))
608
+ continue;
609
+ if (Object.prototype.hasOwnProperty.call(stage, "$out"))
610
+ return "$out";
611
+ if (Object.prototype.hasOwnProperty.call(stage, "$merge"))
612
+ return "$merge";
613
+ }
614
+ return null;
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ }
620
+ export function parseMongoWriteCommand(input) {
621
+ const source = input.trim().replace(/;$/, "").trim();
622
+ const insertOne = parseCollectionMethodTarget(source, "insertOne");
623
+ if (insertOne) {
624
+ const args = parseMethodArgs(source, insertOne.methodCallIndex);
625
+ if (!args || args.length !== 1)
626
+ return null;
627
+ const doc = normalizeJsonArgument(args[0]);
628
+ return doc ? { kind: "insert", collection: insertOne.collection, docsJson: doc } : null;
629
+ }
630
+ const insertMany = parseCollectionMethodTarget(source, "insertMany");
631
+ if (insertMany) {
632
+ const args = parseMethodArgs(source, insertMany.methodCallIndex);
633
+ if (!args || args.length !== 1)
634
+ return null;
635
+ const docs = normalizeJsonArgument(args[0]);
636
+ if (!docs)
637
+ return null;
638
+ return Array.isArray(JSON.parse(docs)) ? { kind: "insert", collection: insertMany.collection, docsJson: docs } : null;
639
+ }
640
+ for (const method of ["updateOne", "updateMany"]) {
641
+ const target = parseCollectionMethodTarget(source, method);
642
+ if (!target)
643
+ continue;
644
+ const args = parseMethodArgs(source, target.methodCallIndex);
645
+ if (!args || args.length !== 2)
646
+ return null;
647
+ const filter = normalizeJsonArgument(args[0]);
648
+ const update = normalizeJsonArgument(args[1]);
649
+ if (!filter || !update)
650
+ return null;
651
+ return { kind: "update", collection: target.collection, filter, update, many: method === "updateMany" };
652
+ }
653
+ for (const method of ["deleteOne", "deleteMany"]) {
654
+ const target = parseCollectionMethodTarget(source, method);
655
+ if (!target)
656
+ continue;
657
+ const args = parseMethodArgs(source, target.methodCallIndex);
658
+ if (!args || args.length !== 1)
659
+ return null;
660
+ const filter = normalizeJsonArgument(args[0]);
661
+ if (!filter)
662
+ return null;
663
+ return { kind: "delete", collection: target.collection, filter, many: method === "deleteMany" };
664
+ }
665
+ return null;
666
+ }
667
+ export function evaluateMongoWriteSafety(command, options) {
668
+ if (!options.allowWrites) {
669
+ return {
670
+ allowed: false,
671
+ reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
672
+ };
673
+ }
674
+ if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) {
675
+ return {
676
+ allowed: false,
677
+ reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
678
+ };
679
+ }
680
+ return { allowed: true };
681
+ }
682
+ export function evaluateMongoAggregateSafety(command, options) {
683
+ const writeStage = mongoAggregateWriteStage(command.pipeline);
684
+ if (!writeStage)
685
+ return { allowed: true };
686
+ if (!options.allowWrites) {
687
+ return {
688
+ allowed: false,
689
+ reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`,
690
+ };
691
+ }
692
+ if (!options.allowDangerous) {
693
+ return {
694
+ allowed: false,
695
+ reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
696
+ };
697
+ }
698
+ return { allowed: true };
699
+ }
700
+ function parseCollectionMethodTarget(source, method) {
701
+ const direct = new RegExp(`^db\\.([A-Za-z_$][\\w$]*)\\.${method}\\s*\\(`).exec(source);
702
+ if (direct)
703
+ return { collection: direct[1], methodCallIndex: source.indexOf(`.${method}`) };
704
+ const quoted = new RegExp(`^db\\.getCollection\\(\\s*(['"])([^'"]+)\\1\\s*\\)\\.${method}\\s*\\(`).exec(source);
705
+ if (quoted)
706
+ return { collection: quoted[2], methodCallIndex: source.indexOf(`.${method}`) };
707
+ return null;
708
+ }
709
+ function parseMethodArgs(source, methodCallIndex) {
710
+ const openIndex = source.indexOf("(", methodCallIndex);
711
+ const closeIndex = findMatchingParen(source, openIndex);
712
+ if (closeIndex < 0 || source.slice(closeIndex + 1).trim())
713
+ return null;
714
+ return splitTopLevel(source.slice(openIndex + 1, closeIndex));
715
+ }
716
+ function readChainedCallArgument(chain, method) {
717
+ const pattern = new RegExp(`\\.${method}\\s*\\(`, "g");
718
+ const match = pattern.exec(chain);
719
+ if (!match)
720
+ return undefined;
721
+ const openIndex = match.index + match[0].lastIndexOf("(");
722
+ const closeIndex = findMatchingParen(chain, openIndex);
723
+ return closeIndex < 0 ? undefined : chain.slice(openIndex + 1, closeIndex);
724
+ }
725
+ function readChainedIntegerArgument(chain, method, fallback) {
726
+ const arg = readChainedCallArgument(chain, method);
727
+ if (arg === undefined)
728
+ return fallback;
729
+ if (!/^\d+$/.test(arg.trim()))
730
+ return null;
731
+ return Number(arg.trim());
732
+ }
733
+ function normalizeJsonArgument(arg) {
734
+ const value = (arg.trim() || "{}").replace(/ObjectId\s*\(\s*["']([^"']+)["']\s*\)/g, '{"$oid":"$1"}');
735
+ try {
736
+ JSON.parse(value);
737
+ return value;
738
+ }
739
+ catch {
740
+ return null;
741
+ }
742
+ }
743
+ function isEmptyJsonObject(json) {
744
+ try {
745
+ const parsed = JSON.parse(json);
746
+ return isRecord(parsed) && Object.keys(parsed).length === 0;
747
+ }
748
+ catch {
749
+ return false;
750
+ }
751
+ }
752
+ function splitTopLevel(source) {
753
+ const parts = [];
754
+ let depth = 0;
755
+ let start = 0;
756
+ let quote = null;
757
+ for (let i = 0; i < source.length; i += 1) {
758
+ const ch = source[i];
759
+ if (quote) {
760
+ if (ch === "\\" && i + 1 < source.length)
761
+ i += 1;
762
+ else if (ch === quote)
763
+ quote = null;
764
+ continue;
765
+ }
766
+ if (ch === "'" || ch === '"')
767
+ quote = ch;
768
+ else if (ch === "{" || ch === "[" || ch === "(")
769
+ depth += 1;
770
+ else if (ch === "}" || ch === "]" || ch === ")")
771
+ depth -= 1;
772
+ else if (ch === "," && depth === 0) {
773
+ parts.push(source.slice(start, i));
774
+ start = i + 1;
775
+ }
776
+ }
777
+ parts.push(source.slice(start));
778
+ return parts;
779
+ }
780
+ function findMatchingParen(source, openIndex) {
781
+ if (openIndex < 0 || source[openIndex] !== "(")
782
+ return -1;
783
+ let depth = 0;
784
+ let quote = null;
785
+ for (let i = openIndex; i < source.length; i += 1) {
786
+ const ch = source[i];
787
+ if (quote) {
788
+ if (ch === "\\" && i + 1 < source.length)
789
+ i += 1;
790
+ else if (ch === quote)
791
+ quote = null;
792
+ continue;
793
+ }
794
+ if (ch === "'" || ch === '"')
795
+ quote = ch;
796
+ else if (ch === "(")
797
+ depth += 1;
798
+ else if (ch === ")") {
799
+ depth -= 1;
800
+ if (depth === 0)
801
+ return i;
802
+ }
803
+ }
804
+ return -1;
805
+ }
806
+ function isRecord(value) {
807
+ return typeof value === "object" && value !== null && !Array.isArray(value);
808
+ }
809
+ function mongoTypeName(value) {
810
+ if (value === null || value === undefined)
811
+ return "null";
812
+ if (Array.isArray(value))
813
+ return "array";
814
+ if (isRecord(value))
815
+ return "object";
816
+ return typeof value;
817
+ }
818
+ function toCellValue(value) {
819
+ return typeof value === "object" && value !== null ? JSON.stringify(value) : value;
820
+ }
@@ -1,5 +1,7 @@
1
- export declare const DIRECT_QUERY_TYPES: readonly ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite"];
2
- export declare const BRIDGE_REQUIRED_TYPES: readonly ["redis", "mongodb", "duckdb", "clickhouse", "sqlserver", "oracle", "elasticsearch", "dameng", "kingbase", "highgo", "vastbase", "goldendb", "gaussdb", "tdengine", "h2", "snowflake", "trino", "hive", "db2", "informix", "neo4j", "cassandra", "bigquery", "kylin", "sundb", "jdbc", "access"];
1
+ export declare const DIRECT_QUERY_TYPES: readonly ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite", "gaussdb", "opengauss"];
2
+ export type DirectQueryType = (typeof DIRECT_QUERY_TYPES)[number];
3
+ export declare function isDirectQueryType(dbType: string): dbType is DirectQueryType;
4
+ export declare const BRIDGE_REQUIRED_TYPES: readonly ["redis", "mongodb", "duckdb", "clickhouse", "sqlserver", "oracle", "elasticsearch", "dameng", "kingbase", "highgo", "vastbase", "goldendb", "yashandb", "databricks", "saphana", "teradata", "vertica", "firebird", "exasol", "oceanbase-oracle", "gbase", "tdengine", "h2", "snowflake", "trino", "hive", "db2", "informix", "neo4j", "cassandra", "bigquery", "kylin", "sundb", "xugu", "jdbc", "access"];
3
5
  export interface DbxDiagnostics {
4
6
  appDataDir: string;
5
7
  dbPath: string;
@@ -1,7 +1,11 @@
1
1
  import { access, readFile } from "node:fs/promises";
2
2
  import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js";
3
3
  import { inspectConnectionStore } from "./connections.js";
4
- export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite"];
4
+ export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite", "gaussdb", "opengauss"];
5
+ const DIRECT_QUERY_TYPE_SET = new Set(DIRECT_QUERY_TYPES);
6
+ export function isDirectQueryType(dbType) {
7
+ return DIRECT_QUERY_TYPE_SET.has(dbType);
8
+ }
5
9
  export const BRIDGE_REQUIRED_TYPES = [
6
10
  "redis",
7
11
  "mongodb",
@@ -15,7 +19,15 @@ export const BRIDGE_REQUIRED_TYPES = [
15
19
  "highgo",
16
20
  "vastbase",
17
21
  "goldendb",
18
- "gaussdb",
22
+ "yashandb",
23
+ "databricks",
24
+ "saphana",
25
+ "teradata",
26
+ "vertica",
27
+ "firebird",
28
+ "exasol",
29
+ "oceanbase-oracle",
30
+ "gbase",
19
31
  "tdengine",
20
32
  "h2",
21
33
  "snowflake",
@@ -28,6 +40,7 @@ export const BRIDGE_REQUIRED_TYPES = [
28
40
  "bigquery",
29
41
  "kylin",
30
42
  "sundb",
43
+ "xugu",
31
44
  "jdbc",
32
45
  "access",
33
46
  ];
@@ -0,0 +1 @@
1
+ export declare function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean;
@@ -0,0 +1,20 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { normalize, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function isMainModule(moduleUrl, argvPath) {
5
+ if (!argvPath)
6
+ return false;
7
+ return normalizeEntryPath(fileURLToPath(moduleUrl)) === normalizeEntryPath(argvPath);
8
+ }
9
+ function normalizeEntryPath(path) {
10
+ const normalized = normalize(realpathIfPossible(resolve(path)));
11
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
12
+ }
13
+ function realpathIfPossible(path) {
14
+ try {
15
+ return realpathSync.native(path);
16
+ }
17
+ catch {
18
+ return path;
19
+ }
20
+ }
@@ -0,0 +1,2 @@
1
+ export declare function mdTable(headers: string[], rows: string[][]): string;
2
+ export declare function formatCell(value: unknown): string;
package/dist/format.js ADDED
@@ -0,0 +1,14 @@
1
+ export function mdTable(headers, rows) {
2
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
3
+ const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
4
+ const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
5
+ const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
6
+ return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`;
7
+ }
8
+ export function formatCell(value) {
9
+ if (value === null || value === undefined)
10
+ return "NULL";
11
+ if (typeof value === "object")
12
+ return JSON.stringify(value);
13
+ return String(value);
14
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export * from "./bridge.js";
3
3
  export * from "./connections.js";
4
4
  export * from "./database.js";
5
5
  export * from "./diagnostics.js";
6
+ export * from "./entrypoint.js";
7
+ export * from "./format.js";
6
8
  export * from "./paths.js";
7
9
  export * from "./schema-context.js";
8
10
  export * from "./sql-safety.js";
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export * from "./bridge.js";
3
3
  export * from "./connections.js";
4
4
  export * from "./database.js";
5
5
  export * from "./diagnostics.js";
6
+ export * from "./entrypoint.js";
7
+ export * from "./format.js";
6
8
  export * from "./paths.js";
7
9
  export * from "./schema-context.js";
8
10
  export * from "./sql-safety.js";
@@ -1,6 +1,7 @@
1
1
  export interface SqlSafetyOptions {
2
2
  allowWrites?: boolean;
3
3
  allowDangerous?: boolean;
4
+ allowMultipleStatements?: boolean;
4
5
  }
5
6
  export interface SqlSafetyDecision {
6
7
  allowed: boolean;
@@ -8,3 +9,4 @@ export interface SqlSafetyDecision {
8
9
  }
9
10
  export declare function evaluateSqlSafety(sql: string, options?: SqlSafetyOptions): SqlSafetyDecision;
10
11
  export declare function sqlSafetyFromEnv(env?: NodeJS.ProcessEnv): SqlSafetyOptions;
12
+ export declare function splitSqlStatements(sql: string): string[];
@@ -4,9 +4,24 @@ export function evaluateSqlSafety(sql, options = {}) {
4
4
  const statements = splitSqlStatements(sql);
5
5
  if (statements.length === 0)
6
6
  return { allowed: false, reason: "SQL is empty." };
7
- if (statements.length > 1)
8
- return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." };
9
- const normalized = stripSqlCommentsAndStrings(statements[0]).trim();
7
+ if (statements.length > 1 && !options.allowMultipleStatements) {
8
+ return { allowed: false, reason: "Only one SQL statement is allowed per query." };
9
+ }
10
+ for (let i = 0; i < statements.length; i++) {
11
+ const decision = evaluateSingleSqlStatementSafety(statements[i], options);
12
+ if (!decision.allowed && statements.length > 1) {
13
+ return {
14
+ allowed: false,
15
+ reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`,
16
+ };
17
+ }
18
+ if (!decision.allowed)
19
+ return decision;
20
+ }
21
+ return { allowed: true };
22
+ }
23
+ function evaluateSingleSqlStatementSafety(sql, options = {}) {
24
+ const normalized = stripSqlCommentsAndStrings(sql).trim();
10
25
  const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
11
26
  if (!firstKeyword)
12
27
  return { allowed: false, reason: "SQL statement is not recognized." };
@@ -37,7 +52,7 @@ export function sqlSafetyFromEnv(env = process.env) {
37
52
  allowDangerous: env.DBX_MCP_ALLOW_DANGEROUS_SQL === "1" || env.DBX_MCP_ALLOW_DANGEROUS_SQL === "true",
38
53
  };
39
54
  }
40
- function splitSqlStatements(sql) {
55
+ export function splitSqlStatements(sql) {
41
56
  const statements = [];
42
57
  let current = "";
43
58
  let quote = null;
@@ -1,3 +1,5 @@
1
+ import { evaluateMongoAggregateSafety, evaluateMongoWriteSafety, inferMongoColumns, mongoDocumentsToQueryResult, parseMongoAggregateCommand, parseMongoCountDocumentsCommand, parseMongoFindCommand, parseMongoWriteCommand, } from "./database.js";
2
+ import { sqlSafetyFromEnv } from "./sql-safety.js";
1
3
  const baseUrl = process.env.DBX_WEB_URL.replace(/\/+$/, "");
2
4
  const password = process.env.DBX_WEB_PASSWORD || "";
3
5
  let sessionCookie = null;
@@ -73,6 +75,14 @@ async function ensureConnected(config) {
73
75
  }
74
76
  export async function listTables(config, schema) {
75
77
  await ensureConnected(config);
78
+ if (config.db_type === "mongodb") {
79
+ const res = await apiFetch("/api/mongo/list-collections", {
80
+ method: "POST",
81
+ body: JSON.stringify({ connectionId: config.id, database: config.database || "" }),
82
+ });
83
+ const collections = (await res.json());
84
+ return collections.map((name) => ({ name, type: "COLLECTION" }));
85
+ }
76
86
  const params = new URLSearchParams({
77
87
  connection_id: config.id,
78
88
  database: config.database || "",
@@ -83,6 +93,14 @@ export async function listTables(config, schema) {
83
93
  }
84
94
  export async function describeTable(config, table, schema) {
85
95
  await ensureConnected(config);
96
+ if (config.db_type === "mongodb") {
97
+ const res = await apiFetch("/api/mongo/find-documents", {
98
+ method: "POST",
99
+ body: JSON.stringify({ connectionId: config.id, database: config.database || "", collection: table, skip: 0, limit: 20, filter: "{}" }),
100
+ });
101
+ const result = (await res.json());
102
+ return inferMongoColumns(result.documents);
103
+ }
86
104
  const params = new URLSearchParams({
87
105
  connection_id: config.id,
88
106
  database: config.database || "",
@@ -94,6 +112,68 @@ export async function describeTable(config, table, schema) {
94
112
  }
95
113
  export async function executeQuery(config, sql, options) {
96
114
  await ensureConnected(config);
115
+ if (config.db_type === "mongodb") {
116
+ const find = parseMongoFindCommand(sql);
117
+ if (find) {
118
+ const res = await apiFetch("/api/mongo/find-documents", {
119
+ method: "POST",
120
+ body: JSON.stringify({
121
+ connectionId: config.id,
122
+ database: config.database || "",
123
+ collection: find.collection,
124
+ skip: find.skip,
125
+ limit: find.limit,
126
+ filter: find.filter,
127
+ sort: find.sort,
128
+ }),
129
+ });
130
+ const result = (await res.json());
131
+ return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
132
+ }
133
+ const count = parseMongoCountDocumentsCommand(sql);
134
+ if (count) {
135
+ const res = await apiFetch("/api/mongo/find-documents", {
136
+ method: "POST",
137
+ body: JSON.stringify({
138
+ connectionId: config.id,
139
+ database: config.database || "",
140
+ collection: count.collection,
141
+ skip: 0,
142
+ limit: 1,
143
+ filter: count.filter,
144
+ }),
145
+ });
146
+ const result = (await res.json());
147
+ return { columns: ["count"], rows: [{ count: result.total }], row_count: 1 };
148
+ }
149
+ const aggregate = parseMongoAggregateCommand(sql);
150
+ if (aggregate) {
151
+ const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv());
152
+ if (!safety.allowed)
153
+ throw new Error(safety.reason);
154
+ const res = await apiFetch("/api/mongo/aggregate-documents", {
155
+ method: "POST",
156
+ body: JSON.stringify({
157
+ connectionId: config.id,
158
+ database: config.database || "",
159
+ collection: aggregate.collection,
160
+ pipelineJson: aggregate.pipeline,
161
+ maxRows: options?.maxRows ?? 100,
162
+ }),
163
+ });
164
+ const result = (await res.json());
165
+ return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
166
+ }
167
+ const write = parseMongoWriteCommand(sql);
168
+ if (write) {
169
+ const safety = evaluateMongoWriteSafety(write, sqlSafetyFromEnv());
170
+ if (!safety.allowed)
171
+ throw new Error(safety.reason);
172
+ const affected = await executeMongoWrite(config, write);
173
+ return { columns: [], rows: [], row_count: affected };
174
+ }
175
+ throw new Error("Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.projects.countDocuments({}), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})");
176
+ }
97
177
  const res = await apiFetch("/api/query/execute", {
98
178
  method: "POST",
99
179
  body: JSON.stringify({
@@ -113,3 +193,45 @@ export async function executeQuery(config, sql, options) {
113
193
  const limitedRows = rows.slice(0, options?.maxRows ?? rows.length);
114
194
  return { columns: data.columns, rows: limitedRows, row_count: limitedRows.length };
115
195
  }
196
+ async function executeMongoWrite(config, command) {
197
+ if (command.kind === "insert") {
198
+ const res = await apiFetch("/api/mongo/insert-documents", {
199
+ method: "POST",
200
+ body: JSON.stringify({
201
+ connectionId: config.id,
202
+ database: config.database || "",
203
+ collection: command.collection,
204
+ docsJson: command.docsJson,
205
+ }),
206
+ });
207
+ const result = (await res.json());
208
+ return result.affected_rows;
209
+ }
210
+ if (command.kind === "update") {
211
+ const res = await apiFetch("/api/mongo/update-documents", {
212
+ method: "POST",
213
+ body: JSON.stringify({
214
+ connectionId: config.id,
215
+ database: config.database || "",
216
+ collection: command.collection,
217
+ filterJson: command.filter,
218
+ updateJson: command.update,
219
+ many: command.many,
220
+ }),
221
+ });
222
+ const result = (await res.json());
223
+ return result.affected_rows;
224
+ }
225
+ const res = await apiFetch("/api/mongo/delete-documents", {
226
+ method: "POST",
227
+ body: JSON.stringify({
228
+ connectionId: config.id,
229
+ database: config.database || "",
230
+ collection: command.collection,
231
+ filterJson: command.filter,
232
+ many: command.many,
233
+ }),
234
+ });
235
+ const result = (await res.json());
236
+ return result.affected_rows;
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbx-app/node-core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Shared Node.js database and DBX connection utilities for DBX CLI and MCP server",
5
5
  "type": "module",
6
6
  "engines": {
@@ -16,6 +16,8 @@
16
16
  "./connections": "./dist/connections.js",
17
17
  "./database": "./dist/database.js",
18
18
  "./diagnostics": "./dist/diagnostics.js",
19
+ "./entrypoint": "./dist/entrypoint.js",
20
+ "./format": "./dist/format.js",
19
21
  "./paths": "./dist/paths.js",
20
22
  "./schema-context": "./dist/schema-context.js",
21
23
  "./sql-safety": "./dist/sql-safety.js"