792 lines
30 KiB
TypeScript
Vendored
792 lines
30 KiB
TypeScript
Vendored
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { test } from "vitest";
|
|
import type { Backend, ConnectionConfig } from "@dbx-app/node-core";
|
|
import { createDbxMcpServer, DBX_MCP_PACKAGE_VERSION } from "../src/index.js";
|
|
|
|
const connection: ConnectionConfig = {
|
|
id: "1",
|
|
name: "local",
|
|
db_type: "postgres",
|
|
host: "127.0.0.1",
|
|
port: 5432,
|
|
username: "app",
|
|
password: "",
|
|
database: "demo",
|
|
ssh_enabled: false,
|
|
ssl: false,
|
|
};
|
|
|
|
const backend: Backend = {
|
|
loadConnections: async () => [connection],
|
|
findConnection: async (name) => (name === "local" ? connection : undefined),
|
|
addConnection: async () => connection,
|
|
removeConnection: async () => true,
|
|
listTables: async () => [{ name: "users", type: "BASE TABLE" }],
|
|
describeTable: async () => [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null }],
|
|
executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }),
|
|
};
|
|
|
|
async function withScopedEnv<T>(env: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
|
|
const oldValues = new Map<string, string | undefined>();
|
|
for (const key of Object.keys(env)) {
|
|
oldValues.set(key, process.env[key]);
|
|
process.env[key] = env[key];
|
|
}
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
for (const [key, value] of oldValues) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
test("creates an MCP server without starting stdio transport", () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
|
|
assert.equal(typeof server.connect, "function");
|
|
});
|
|
|
|
test("MCP server metadata version matches package metadata", () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
|
|
assert.equal((server as any).server._serverInfo.version, DBX_MCP_PACKAGE_VERSION);
|
|
});
|
|
|
|
test("README runtime requirements match package engines", async () => {
|
|
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8")) as {
|
|
engines: { node: string };
|
|
};
|
|
const readme = await readFile(new URL("../README.md", import.meta.url), "utf-8");
|
|
const minimumNodeVersion = packageJson.engines.node.replace(">=", "");
|
|
|
|
assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} or newer`));
|
|
assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} 或更高版本`));
|
|
});
|
|
|
|
test("execute query scopes the connection to the requested database", async () => {
|
|
let usedDatabase = "";
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
executeQuery: async (config) => {
|
|
usedDatabase = config.database || "";
|
|
return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
database: "stores_demo",
|
|
sql: "SELECT FIRST 1 tabname FROM systables",
|
|
});
|
|
|
|
assert.equal(usedDatabase, "stores_demo");
|
|
});
|
|
|
|
test("execute query runs safe multi-statement SQL one statement at a time", async () => {
|
|
const executed: string[] = [];
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
executeQuery: async (_config, sql) => {
|
|
executed.push(sql);
|
|
return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
sql: "select 1; select 2;",
|
|
});
|
|
|
|
assert.deepEqual(executed, ["select 1", "select 2"]);
|
|
assert.match(result.content[0].text, /Statement 1/);
|
|
assert.match(result.content[0].text, /Statement 2/);
|
|
});
|
|
|
|
test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
sql: "select 1; delete from users;",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
|
assert.match(result.content[0].text, /Statement 2/);
|
|
assert.match(result.content[0].text, /WHERE/);
|
|
});
|
|
|
|
test("scoped MCP lists only the active connection", async () => {
|
|
const other: ConnectionConfig = { ...connection, id: "2", name: "other", database: "other_db" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connection, other],
|
|
};
|
|
|
|
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_list_connections.handler({});
|
|
});
|
|
|
|
assert.match(result.content[0].text, /local/);
|
|
assert.doesNotMatch(result.content[0].text, /other/);
|
|
});
|
|
|
|
test("scoped MCP rejects out-of-scope connection tool calls", async () => {
|
|
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "other" });
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /CONNECTION_OUT_OF_SCOPE:/);
|
|
});
|
|
|
|
test("scoped MCP defaults connection-taking tools to the active connection and database", async () => {
|
|
let usedDatabase = "";
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
listTables: async (config) => {
|
|
usedDatabase = config.database || "";
|
|
return [{ name: "users", type: "BASE TABLE" }];
|
|
},
|
|
};
|
|
|
|
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_SCOPE_DATABASE: "scoped_db" }, () => {
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_list_tables.handler({});
|
|
});
|
|
|
|
assert.match(result.content[0].text, /users/);
|
|
assert.equal(usedDatabase, "scoped_db");
|
|
});
|
|
|
|
test("scoped MCP does not register mutation or desktop bridge tools", async () => {
|
|
await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: false });
|
|
const tools = (server as any)._registeredTools;
|
|
|
|
assert.equal(tools.dbx_add_connection, undefined);
|
|
assert.equal(tools.dbx_remove_connection, undefined);
|
|
assert.equal(tools.dbx_open_table, undefined);
|
|
assert.equal(tools.dbx_execute_and_show, undefined);
|
|
});
|
|
});
|
|
|
|
test("scoped MCP with writes disabled blocks write SQL", async () => {
|
|
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_ALLOW_WRITES: "0" }, () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_execute_query.handler({
|
|
sql: "update users set name = 'x' where id = 1",
|
|
});
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
|
});
|
|
|
|
test("redis execute query points callers to the redis command tool", async () => {
|
|
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "0" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [redisConnection],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
sql: "GET session:1",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /REDIS_COMMAND_REQUIRED:/);
|
|
assert.match(result.content[0].text, /dbx_execute_redis_command/);
|
|
});
|
|
|
|
test("redis command tool executes redis commands on the selected database", async () => {
|
|
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "2" };
|
|
let usedDb = -1;
|
|
let usedCommand = "";
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [redisConnection],
|
|
executeRedisCommand: async (_config, db, command) => {
|
|
usedDb = db;
|
|
usedCommand = command;
|
|
return { command: "GET", safety: "allowed", value: "value-1" };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_redis_command.handler({
|
|
connection_name: "local",
|
|
command: "GET session:1",
|
|
});
|
|
|
|
assert.equal(result.isError, undefined);
|
|
assert.equal(usedDb, 2);
|
|
assert.equal(usedCommand, "GET session:1");
|
|
assert.match(result.content[0].text, /Command: GET/);
|
|
assert.match(result.content[0].text, /value-1/);
|
|
});
|
|
|
|
test("redis command tool blocks write commands in read-only MCP sessions", async () => {
|
|
let executed = false;
|
|
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [redisConnection],
|
|
executeRedisCommand: async () => {
|
|
executed = true;
|
|
return { command: "SET", safety: "write", value: "OK" };
|
|
},
|
|
};
|
|
|
|
const result = await withScopedEnv({ DBX_MCP_ALLOW_WRITES: "0" }, () => {
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
|
|
connection_name: "local",
|
|
command: "SET session:1 value",
|
|
});
|
|
});
|
|
|
|
assert.equal(executed, false);
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /REDIS_COMMAND_BLOCKED:/);
|
|
});
|
|
|
|
test("redis command tool allows dangerous redis commands only when explicitly enabled", async () => {
|
|
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" };
|
|
let skipSafetyCheck = false;
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [redisConnection],
|
|
executeRedisCommand: async (_config, _db, _command, options) => {
|
|
skipSafetyCheck = options?.skipSafetyCheck ?? false;
|
|
return { command: "KEYS", safety: "blocked", value: ["session:1"] };
|
|
},
|
|
};
|
|
|
|
const blocked = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "0" }, () => {
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
|
|
connection_name: "local",
|
|
command: "KEYS *",
|
|
});
|
|
});
|
|
const allowed = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "1" }, () => {
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
|
|
connection_name: "local",
|
|
command: "KEYS *",
|
|
});
|
|
});
|
|
|
|
assert.equal(blocked.isError, true);
|
|
assert.match(blocked.content[0].text, /REDIS_COMMAND_BLOCKED:/);
|
|
assert.equal(allowed.isError, undefined);
|
|
assert.equal(skipSafetyCheck, true);
|
|
assert.match(allowed.content[0].text, /session:1/);
|
|
});
|
|
|
|
test("mongodb list tables returns collections from the selected database", async () => {
|
|
let usedDatabase = "";
|
|
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [mongoConnection],
|
|
listTables: async (config) => {
|
|
usedDatabase = config.database || "";
|
|
return [{ name: "projects", type: "COLLECTION" }];
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_name: "local",
|
|
database: "pystrument",
|
|
});
|
|
|
|
assert.equal(usedDatabase, "pystrument");
|
|
assert.match(result.content[0].text, /projects/);
|
|
assert.match(result.content[0].text, /COLLECTION/);
|
|
});
|
|
|
|
test("mongodb describe table returns inferred document fields", async () => {
|
|
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [mongoConnection],
|
|
describeTable: async () => [
|
|
{
|
|
name: "_id",
|
|
data_type: "object",
|
|
is_nullable: false,
|
|
column_default: null,
|
|
is_primary_key: true,
|
|
comment: null,
|
|
},
|
|
{
|
|
name: "name",
|
|
data_type: "string",
|
|
is_nullable: false,
|
|
column_default: null,
|
|
is_primary_key: false,
|
|
comment: null,
|
|
},
|
|
],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_describe_table.handler({
|
|
connection_name: "local",
|
|
database: "pystrument",
|
|
table: "projects",
|
|
});
|
|
|
|
assert.match(result.content[0].text, /_id \(PK\)/);
|
|
assert.match(result.content[0].text, /name/);
|
|
});
|
|
|
|
test("dameng metadata tools default to the login user schema", async () => {
|
|
const damengConnection: ConnectionConfig = {
|
|
...connection,
|
|
db_type: "dameng",
|
|
username: "SYSDBA",
|
|
database: "DAMENG",
|
|
};
|
|
const usedScopes: Array<{ database?: string; schema?: string }> = [];
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [damengConnection],
|
|
listTables: async (config, schema) => {
|
|
usedScopes.push({ database: config.database, schema });
|
|
return [{ name: "ORDERS", type: "TABLE" }];
|
|
},
|
|
describeTable: async (config, _table, schema) => {
|
|
usedScopes.push({ database: config.database, schema });
|
|
return [{ name: "ID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, comment: null }];
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local" });
|
|
await (server as any)._registeredTools.dbx_describe_table.handler({ connection_name: "local", table: "ORDERS" });
|
|
|
|
assert.deepEqual(usedScopes, [
|
|
{ database: "DAMENG", schema: "SYSDBA" },
|
|
{ database: "DAMENG", schema: "SYSDBA" },
|
|
]);
|
|
});
|
|
|
|
test("dameng metadata tools treat database as a schema alias while preferring explicit schema", async () => {
|
|
const damengConnection: ConnectionConfig = { ...connection, db_type: "dameng", username: "SYSDBA", database: "DAMENG" };
|
|
let usedSchema = "";
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [damengConnection],
|
|
listTables: async (_config, schema) => {
|
|
usedSchema = schema || "";
|
|
return [{ name: "ORDERS", type: "TABLE" }];
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC" });
|
|
|
|
assert.equal(usedSchema, "XC");
|
|
|
|
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC", schema: "REPORTING" });
|
|
|
|
assert.equal(usedSchema, "REPORTING");
|
|
});
|
|
|
|
test("mongodb execute query formats shell-style find results", async () => {
|
|
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [mongoConnection],
|
|
executeQuery: async () => ({
|
|
columns: ["_id", "meta", "missing"],
|
|
rows: [{ _id: "1", meta: { name: "demo" }, missing: null }],
|
|
row_count: 1,
|
|
}),
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
database: "pystrument",
|
|
sql: "db.projects.find({}).limit(1)",
|
|
});
|
|
|
|
assert.match(result.content[0].text, /"name":"demo"/);
|
|
assert.match(result.content[0].text, /NULL/);
|
|
assert.match(result.content[0].text, /1 row\(s\)/);
|
|
});
|
|
|
|
test("connection lookup failures include a stable MCP error code", async () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_name: "missing",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /CONNECTION_NOT_FOUND:/);
|
|
assert.match(result.content[0].text, /missing/);
|
|
});
|
|
|
|
test("add connection accepts H2 file paths without a port", async () => {
|
|
let added: Omit<ConnectionConfig, "id"> | undefined;
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
addConnection: async (config) => {
|
|
added = config;
|
|
return { id: "h2-file", ...config };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_add_connection.handler({
|
|
name: "h2-local",
|
|
db_type: "h2",
|
|
host: "/data/app.mv.db",
|
|
username: "sa",
|
|
password: "",
|
|
});
|
|
|
|
assert.equal(result.isError, undefined);
|
|
assert.equal(added?.db_type, "h2");
|
|
assert.equal(added?.host, "/data/app.mv.db");
|
|
assert.equal(added?.port, 0);
|
|
});
|
|
|
|
test("SQL safety failures include a stable MCP error code", async () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
sql: "drop table users",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
|
assert.match(result.content[0].text, /Dangerous SQL/);
|
|
});
|
|
|
|
test("query exceptions include a stable MCP error code", async () => {
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
executeQuery: async () => {
|
|
throw new Error("database timeout");
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "local",
|
|
sql: "select 1",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /QUERY_ERROR: database timeout/);
|
|
});
|
|
|
|
test("desktop bridge failures include a stable MCP error code", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "dbx-mcp-home-"));
|
|
|
|
try {
|
|
// Use DBX_DATA_DIR (honoured cross-platform) to point bridgePortFilePath()
|
|
// at an empty temp directory so no real bridge is reachable.
|
|
await withScopedEnv({ DBX_DATA_DIR: dir }, async () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: false });
|
|
const result = await (server as any)._registeredTools.dbx_open_table.handler({
|
|
connection_name: "local",
|
|
table: "users",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /DBX_NOT_RUNNING:/);
|
|
assert.match(result.content[0].text, /DBX is not running/);
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("mongodb execute-and-show blocks aggregate write stages before desktop bridge", async () => {
|
|
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
|
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
|
delete process.env.DBX_MCP_ALLOW_WRITES;
|
|
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
|
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [mongoConnection],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: false });
|
|
|
|
try {
|
|
const result = await (server as any)._registeredTools.dbx_execute_and_show.handler({
|
|
connection_name: "local",
|
|
database: "pystrument",
|
|
sql: 'db.projects.aggregate([{"$out":"projects_dump"}])',
|
|
});
|
|
|
|
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
|
assert.match(result.content[0].text, /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
|
} finally {
|
|
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
|
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
|
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
|
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
|
|
}
|
|
});
|
|
|
|
test("connection_id parameter resolves correctly", async () => {
|
|
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" };
|
|
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// Resolve by connection_id should return the correct connection
|
|
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_id: "d4e5f6",
|
|
});
|
|
|
|
assert.match(result.content[0].text, /users/);
|
|
});
|
|
|
|
test("duplicate connection names return AMBIGUOUS_CONNECTION error", async () => {
|
|
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres", host: "pg.local", port: 5432 };
|
|
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 };
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// Using connection_name with duplicates should return AMBIGUOUS_CONNECTION
|
|
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_name: "shared-name",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
|
|
assert.match(result.content[0].text, /a1b2c3:/);
|
|
assert.match(result.content[0].text, /d4e5f6:/);
|
|
assert.match(result.content[0].text, /postgres @ pg.local:5432/);
|
|
assert.match(result.content[0].text, /redis @ redis.local:6379/);
|
|
});
|
|
|
|
test("connection_id takes priority over connection_name", async () => {
|
|
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" };
|
|
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "mysql", host: "mysql.local", port: 3306 };
|
|
let usedConfig: ConnectionConfig | undefined;
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
listTables: async (config) => {
|
|
usedConfig = config;
|
|
return [{ name: "users", type: "BASE TABLE" }];
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// Provide both connection_id and connection_name; connection_id should win
|
|
await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_id: "d4e5f6",
|
|
connection_name: "shared-name",
|
|
});
|
|
|
|
assert.equal(usedConfig?.id, "d4e5f6");
|
|
});
|
|
|
|
test("dbx_list_connections includes ID column", async () => {
|
|
const server = createDbxMcpServer(backend, { isWebMode: true });
|
|
const result = await (server as any)._registeredTools.dbx_list_connections.handler({});
|
|
|
|
// The table header should include the ID column
|
|
assert.match(result.content[0].text, /ID.*Name.*Type.*Host.*Port.*Database/);
|
|
// The connection's ID value "1" should appear in the table
|
|
assert.match(result.content[0].text, /1\s+\|\s+local/);
|
|
});
|
|
|
|
test("same name and db_type with different host/port returns AMBIGUOUS_CONNECTION", async () => {
|
|
const connA: ConnectionConfig = {
|
|
...connection,
|
|
id: "pg-prod-us",
|
|
name: "my-db",
|
|
db_type: "postgres",
|
|
host: "10.0.1.100",
|
|
port: 5432,
|
|
database: "app",
|
|
};
|
|
const connB: ConnectionConfig = {
|
|
...connection,
|
|
id: "pg-prod-eu",
|
|
name: "my-db",
|
|
db_type: "postgres",
|
|
host: "10.0.2.200",
|
|
port: 5432,
|
|
database: "app",
|
|
};
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// Using connection_name with duplicates (same db_type) should still return AMBIGUOUS_CONNECTION
|
|
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_name: "my-db",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
|
|
assert.match(result.content[0].text, /pg-prod-us: postgres @ 10\.0\.1\.100:5432/);
|
|
assert.match(result.content[0].text, /pg-prod-eu: postgres @ 10\.0\.2\.200:5432/);
|
|
});
|
|
|
|
test("connection_id routes to correct host among same-name same-type connections", async () => {
|
|
const connA: ConnectionConfig = {
|
|
...connection,
|
|
id: "pg-prod-us",
|
|
name: "my-db",
|
|
db_type: "postgres",
|
|
host: "10.0.1.100",
|
|
port: 5432,
|
|
database: "app",
|
|
};
|
|
const connB: ConnectionConfig = {
|
|
...connection,
|
|
id: "pg-prod-eu",
|
|
name: "my-db",
|
|
db_type: "postgres",
|
|
host: "10.0.2.200",
|
|
port: 5432,
|
|
database: "app",
|
|
};
|
|
const usedConfigs: ConnectionConfig[] = [];
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
listTables: async (config) => {
|
|
usedConfigs.push(config);
|
|
return [{ name: "orders", type: "BASE TABLE" }];
|
|
},
|
|
executeQuery: async (config, _sql) => {
|
|
usedConfigs.push(config);
|
|
return { columns: ["cnt"], rows: [{ cnt: 42 }], row_count: 1 };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// Route to US instance via connection_id
|
|
const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({
|
|
connection_id: "pg-prod-us",
|
|
});
|
|
assert.match(listResult.content[0].text, /orders/);
|
|
assert.equal(usedConfigs[0].id, "pg-prod-us");
|
|
assert.equal(usedConfigs[0].host, "10.0.1.100");
|
|
|
|
// Route to EU instance via connection_id
|
|
const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_id: "pg-prod-eu",
|
|
database: "app",
|
|
sql: "select count(*) as cnt from orders",
|
|
});
|
|
assert.match(queryResult.content[0].text, /42/);
|
|
assert.equal(usedConfigs[1].id, "pg-prod-eu");
|
|
assert.equal(usedConfigs[1].host, "10.0.2.200");
|
|
});
|
|
|
|
test("tool responses are prefixed with connection identity label", async () => {
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [{ ...connection, id: "conn-xyz", name: "orders-db", db_type: "postgres", host: "10.5.5.5", port: 5432 }],
|
|
listTables: async () => [{ name: "orders", type: "BASE TABLE" }],
|
|
executeQuery: async () => ({ columns: ["cnt"], rows: [{ cnt: 7 }], row_count: 1 }),
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({ connection_id: "conn-xyz" });
|
|
assert.match(listResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/);
|
|
|
|
const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({ connection_id: "conn-xyz", sql: "select count(*) as cnt from orders" });
|
|
assert.match(queryResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/);
|
|
});
|
|
|
|
test("dbx_remove_connection with duplicate names returns AMBIGUOUS_CONNECTION", async () => {
|
|
const connA: ConnectionConfig = { ...connection, id: "db-a", name: "staging", db_type: "postgres", host: "pg-a.local" };
|
|
const connB: ConnectionConfig = { ...connection, id: "db-b", name: "staging", db_type: "mysql", host: "mysql-b.local", port: 3306 };
|
|
let removedName: string | undefined;
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connA, connB],
|
|
removeConnection: async (name) => {
|
|
removedName = name;
|
|
return true;
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
const result = await (server as any)._registeredTools.dbx_remove_connection.handler({
|
|
connection_name: "staging",
|
|
});
|
|
|
|
assert.equal(result.isError, true);
|
|
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
|
|
assert.match(result.content[0].text, /db-a: postgres @ pg-a\.local/);
|
|
assert.match(result.content[0].text, /db-b: mysql @ mysql-b\.local/);
|
|
// removeConnection must NOT have been called — no silent deletion
|
|
assert.equal(removedName, undefined);
|
|
});
|
|
|
|
test("dbx_execute_query with connection_id routes correctly on bridge-backed (SSH) connections", async () => {
|
|
const connDirect: ConnectionConfig = { ...connection, id: "pg-direct", name: "shared", db_type: "postgres", host: "direct.local", ssh_enabled: false };
|
|
const connSsh: ConnectionConfig = { ...connection, id: "pg-ssh", name: "shared", db_type: "postgres", host: "private.local", ssh_enabled: true };
|
|
const usedConfigs: ConnectionConfig[] = [];
|
|
const scopedBackend: Backend = {
|
|
...backend,
|
|
loadConnections: async () => [connDirect, connSsh],
|
|
executeQuery: async (config, _sql) => {
|
|
usedConfigs.push(config);
|
|
return { columns: ["result"], rows: [{ result: "ok" }], row_count: 1 };
|
|
},
|
|
};
|
|
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
|
|
|
// connection_name with two same-name connections (one SSH-backed) → AMBIGUOUS_CONNECTION
|
|
const ambigResult = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_name: "shared",
|
|
sql: "select 1",
|
|
});
|
|
assert.equal(ambigResult.isError, true);
|
|
assert.match(ambigResult.content[0].text, /AMBIGUOUS_CONNECTION:/);
|
|
assert.match(ambigResult.content[0].text, /pg-direct/);
|
|
assert.match(ambigResult.content[0].text, /pg-ssh/);
|
|
|
|
// connection_id routes to the SSH-backed instance and passes its config through
|
|
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
|
connection_id: "pg-ssh",
|
|
sql: "select 1",
|
|
});
|
|
assert.equal(result.isError, undefined);
|
|
assert.equal(usedConfigs.length, 1);
|
|
assert.equal(usedConfigs[0].id, "pg-ssh");
|
|
assert.equal(usedConfigs[0].host, "private.local");
|
|
assert.equal(usedConfigs[0].ssh_enabled, true);
|
|
});
|