fix(packages): polish cli and mcp package contracts
This commit is contained in:
parent
2a696be49a
commit
c467372fc8
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue