diff --git a/packages/cli/src/cli-format.ts b/packages/cli/src/cli-format.ts index bc7f10005..6a26fad36 100644 --- a/packages/cli/src/cli-format.ts +++ b/packages/cli/src/cli-format.ts @@ -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(headers: string[], rows: T[]): string { const lines = [headers.map(csvCell).join(",")]; for (const row of rows) { diff --git a/packages/cli/tests/cli-format.test.ts b/packages/cli/tests/cli-format.test.ts index e27d33395..e5926c74f 100644 --- a/packages/cli/tests/cli-format.test.ts +++ b/packages/cli/tests/cli-format.test.ts @@ -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" }, diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 45b5d73ad..d4beaa809 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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 diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 7da4ec1a7..421b69458 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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, 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() { diff --git a/packages/mcp-server/tests/server.test.ts b/packages/mcp-server/tests/server.test.ts index 10ef0960e..5ddf74ce8 100644 --- a/packages/mcp-server/tests/server.test.ts +++ b/packages/mcp-server/tests/server.test.ts @@ -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; diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 6cd34b63d..590a39fa1 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -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" diff --git a/packages/node-core/src/format.ts b/packages/node-core/src/format.ts new file mode 100644 index 000000000..4acf16c08 --- /dev/null +++ b/packages/node-core/src/format.ts @@ -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); +} diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 7b977f5d3..78899660d 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -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"; diff --git a/packages/node-core/tests/format.test.ts b/packages/node-core/tests/format.test.ts new file mode 100644 index 000000000..57a893823 --- /dev/null +++ b/packages/node-core/tests/format.test.ts @@ -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"); +});