fix(packages): polish cli and mcp package contracts

This commit is contained in:
t8y2 2026-05-30 17:20:38 +08:00
parent 2a696be49a
commit c467372fc8
9 changed files with 154 additions and 45 deletions

View File

@ -1,4 +1,6 @@
import type { ConnectionConfig } from "@dbx-app/node-core";
import { formatCell, mdTable, type ConnectionConfig } from "@dbx-app/node-core";
export { formatCell, mdTable };
export interface ConnectionSummary {
name: string;
@ -43,20 +45,6 @@ function errorHint(code: string, message: string): string | undefined {
return undefined;
}
export function mdTable(headers: string[], rows: string[][]): string {
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`;
}
export function formatCell(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
export function csvTable<T extends object>(headers: string[], rows: T[]): string {
const lines = [headers.map(csvCell).join(",")];
for (const row of rows) {

View File

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ConnectionConfig } from "@dbx-app/node-core";
import { connectionSummary, csvTable, errorPayload, formatErrorMessage, mdTable } from "../src/cli-format.js";
import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "../src/cli-format.js";
const connection: ConnectionConfig = {
id: "1",
@ -37,6 +37,11 @@ test("formats markdown tables", () => {
assert.match(table, /postgres/);
});
test("formats database cell values with shared formatter", () => {
assert.equal(formatCell(null), "NULL");
assert.equal(formatCell({ ok: true }), "{\"ok\":true}");
});
test("builds stable error payloads", () => {
assert.deepEqual(errorPayload("SQL_BLOCKED", "read-only"), {
error: { code: "SQL_BLOCKED", message: "read-only" },

View File

@ -127,7 +127,7 @@ PostgreSQL, MySQL, SQLite, Doris, StarRocks, and Redshift queries run directly f
## Requirements
- [DBX](https://github.com/t8y2/dbx) installed with at least one connection configured
- Node.js 18+
- Node.js 22.13.0 或更高版本
## License
@ -242,4 +242,4 @@ PostgreSQL、MySQL、SQLite、Doris、StarRocks、Redshift 查询可由 MCP Serv
### 系统要求
- 已安装 [DBX](https://github.com/t8y2/dbx) 并配置了至少一个数据库连接
- Node.js 18+
- Node.js 22.13.0 or newer

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { z } from "zod";
import {
@ -8,7 +9,9 @@ import {
createBackend,
evaluateMongoAggregateSafety,
evaluateSqlSafety,
formatCell,
formatSchemaContext,
mdTable,
notifyReload,
parseMongoAggregateCommand,
postBridge,
@ -17,16 +20,16 @@ import {
type ConnectionConfig,
} from "@dbx-app/node-core";
const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as { version?: string };
export const DBX_MCP_PACKAGE_VERSION = packageJson.version ?? "0.0.0";
function text(s: string) {
return { content: [{ type: "text" as const, text: s }] };
}
function mdTable(headers: string[], rows: string[][]): string {
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
return `${header}\n${sep}\n${body}`;
function toolError(code: string, message: string) {
return { ...text(`${code}: ${message}`), isError: true };
}
function withDatabase(config: ConnectionConfig, database?: string): ConnectionConfig {
@ -40,7 +43,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
const server = new McpServer({
name: "dbx",
version: "0.4.2",
version: DBX_MCP_PACKAGE_VERSION,
});
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
@ -60,7 +63,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name, database, schema }) => {
const config = await backend.findConnection(connection_name);
if (!config) return text(`Connection "${connection_name}" not found`);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const tables = await backend.listTables(withDatabase(config, database), schema);
if (tables.length === 0) return text("No tables found.");
const rows = tables.map((t) => [t.name, t.type]);
@ -79,7 +82,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name, table, database, schema }) => {
const config = await backend.findConnection(connection_name);
if (!config) return text(`Connection "${connection_name}" not found`);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const columns = await backend.describeTable(withDatabase(config, database), table, schema);
if (columns.length === 0) return text("No columns found.");
const rows = columns.map((c) => [
@ -103,10 +106,10 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name, database, sql }) => {
const config = await backend.findConnection(connection_name);
if (!config) return text(`Connection "${connection_name}" not found`);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
if (config.db_type !== "mongodb") {
const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
// (node-core executeQuery) applies command-aware read/write gating.
@ -117,7 +120,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
return text(`${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return text(`Query error: ${msg}`);
return toolError("QUERY_ERROR", msg);
}
},
);
@ -134,7 +137,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name, database, schema, tables, max_tables }) => {
const config = await backend.findConnection(connection_name);
if (!config) return text(`Connection "${connection_name}" not found`);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const context = await buildSchemaContext(backend, withDatabase(config, database), {
schema,
tables,
@ -189,7 +192,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name }) => {
const removed = await backend.removeConnection(connection_name);
if (!removed) return text(`Connection "${connection_name}" not found.`);
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
await notifyReload();
return text(`Connection "${connection_name}" removed.`);
},
@ -207,6 +210,8 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
schema: z.string().optional().describe("Schema name"),
},
async ({ connection_name, table, database, schema }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
return bridgeRequest("/open-table", { connection_name, table, database, schema }, `Opened ${table} in DBX`);
},
);
@ -221,16 +226,17 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
async ({ connection_name, sql, database }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const safetyOptions = sqlSafetyFromEnv();
if (config?.db_type === "mongodb") {
const aggregate = parseMongoAggregateCommand(sql);
if (aggregate) {
const safety = evaluateMongoAggregateSafety(aggregate, safetyOptions);
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked.");
}
} else {
const safety = evaluateSqlSafety(sql, safetyOptions);
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
// safety flags to the desktop executor for command-aware gating.
@ -252,16 +258,11 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
return server;
}
function formatCell(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
async function bridgeRequest(path: string, body: Record<string, unknown>, successMsg: string) {
const res = await postBridge(path, body);
if (res.ok) return text(successMsg);
return text(res.text.startsWith("DBX is not running") ? res.text : `Failed: ${res.text}`);
const message = res.text.startsWith("DBX is not running") ? res.text : `Failed: ${res.text}`;
return toolError("DBX_NOT_RUNNING", message);
}
async function main() {

View File

@ -1,7 +1,10 @@
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 "node:test";
import type { Backend, ConnectionConfig } from "@dbx-app/node-core";
import { createDbxMcpServer } from "../src/index.js";
import { createDbxMcpServer, DBX_MCP_PACKAGE_VERSION } from "../src/index.js";
const connection: ConnectionConfig = {
id: "1",
@ -34,6 +37,23 @@ test("creates an MCP server without starting stdio transport", () => {
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 = {
@ -104,7 +124,7 @@ test("mongodb execute query formats shell-style find results", async () => {
const scopedBackend: Backend = {
...backend,
findConnection: async () => mongoConnection,
executeQuery: async () => ({ columns: ["_id", "name"], rows: [{ _id: "1", name: "demo" }], row_count: 1 }),
executeQuery: async () => ({ columns: ["_id", "meta", "missing"], rows: [{ _id: "1", meta: { name: "demo" }, missing: null }], row_count: 1 }),
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
@ -114,10 +134,76 @@ test("mongodb execute query formats shell-style find results", async () => {
sql: "db.projects.find({}).limit(1)",
});
assert.match(result.content[0].text, /demo/);
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("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 oldHome = process.env.HOME;
const dir = await mkdtemp(join(tmpdir(), "dbx-mcp-home-"));
process.env.HOME = dir;
try {
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 {
if (oldHome === undefined) delete process.env.HOME;
else process.env.HOME = oldHome;
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;
@ -137,7 +223,7 @@ test("mongodb execute-and-show blocks aggregate write stages before desktop brid
sql: 'db.projects.aggregate([{"$out":"projects_dump"}])',
});
assert.match(result.content[0].text, /Query blocked:/);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
assert.match(result.content[0].text, /DBX_MCP_ALLOW_WRITES=1/);
} finally {
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;

View File

@ -16,6 +16,7 @@
"./connections": "./dist/connections.js",
"./database": "./dist/database.js",
"./diagnostics": "./dist/diagnostics.js",
"./format": "./dist/format.js",
"./paths": "./dist/paths.js",
"./schema-context": "./dist/schema-context.js",
"./sql-safety": "./dist/sql-safety.js"

View File

@ -0,0 +1,13 @@
export function mdTable(headers: string[], rows: string[][]): string {
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`;
}
export function formatCell(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}

View File

@ -3,6 +3,7 @@ export * from "./bridge.js";
export * from "./connections.js";
export * from "./database.js";
export * from "./diagnostics.js";
export * from "./format.js";
export * from "./paths.js";
export * from "./schema-context.js";
export * from "./sql-safety.js";

View File

@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatCell, mdTable } from "../src/format.js";
test("formats markdown tables", () => {
assert.equal(mdTable(["Name", "Type"], [["local", "postgres"]]), "| Name | Type |\n| ----- | -------- |\n| local | postgres |");
});
test("formats database cell values", () => {
assert.equal(formatCell(null), "NULL");
assert.equal(formatCell(undefined), "NULL");
assert.equal(formatCell({ ok: true }), "{\"ok\":true}");
assert.equal(formatCell("hello"), "hello");
});