@shibbirweb/mcp-db-read-only 0.1.0
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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.dockerhub.md +354 -0
- package/README.md +387 -0
- package/dist/ApplicationFactory.js +144 -0
- package/dist/config/EnvironmentConfigLoader.js +179 -0
- package/dist/config/PackageVersionLoader.js +43 -0
- package/dist/connections/ConnectionManager.js +101 -0
- package/dist/connections/ConnectionRegistry.js +109 -0
- package/dist/connections/ConnectionTargetFactory.js +104 -0
- package/dist/connections/ConnectionUrlParser.js +195 -0
- package/dist/domain/ConnectionProfile.js +39 -0
- package/dist/domain/ConnectionTarget.js +139 -0
- package/dist/domain/Engine.js +159 -0
- package/dist/drivers/BaseDriver.js +35 -0
- package/dist/drivers/DatabaseDriver.js +1 -0
- package/dist/drivers/DriverCache.js +107 -0
- package/dist/drivers/DriverProvider.js +71 -0
- package/dist/drivers/DriverRegistry.js +24 -0
- package/dist/drivers/GlobPattern.js +41 -0
- package/dist/drivers/LazyResource.js +56 -0
- package/dist/drivers/document/MongoDriver.js +187 -0
- package/dist/drivers/document/MongoSchemaSampler.js +74 -0
- package/dist/drivers/document/MongoStageAllowlist.js +87 -0
- package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
- package/dist/drivers/keyvalue/RedisDriver.js +224 -0
- package/dist/drivers/search/ElasticsearchDriver.js +159 -0
- package/dist/drivers/sql/ClickHouseDriver.js +156 -0
- package/dist/drivers/sql/MsSqlDriver.js +147 -0
- package/dist/drivers/sql/MySqlDriver.js +144 -0
- package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
- package/dist/drivers/sql/PostgresDriver.js +176 -0
- package/dist/drivers/sql/SqlIdentifier.js +36 -0
- package/dist/drivers/sql/SqliteDriver.js +202 -0
- package/dist/drivers/sql/SqliteProtocol.js +7 -0
- package/dist/drivers/sql/SqliteWorker.js +71 -0
- package/dist/errors/ApplicationError.js +15 -0
- package/dist/errors/EngineMismatchError.js +15 -0
- package/dist/errors/InvalidConnectionUrlError.js +14 -0
- package/dist/errors/InvalidProfileDefinitionError.js +15 -0
- package/dist/errors/NoActiveConnectionError.js +13 -0
- package/dist/errors/NoDatabaseSelectedError.js +13 -0
- package/dist/errors/ObjectNotFoundError.js +14 -0
- package/dist/errors/UnknownProfileError.js +16 -0
- package/dist/errors/UnsupportedOperationError.js +14 -0
- package/dist/errors/index.js +9 -0
- package/dist/formatting/JsonSerializer.js +49 -0
- package/dist/formatting/RowFormatter.js +43 -0
- package/dist/formatting/ToolResponse.js +25 -0
- package/dist/index.js +15 -0
- package/dist/server/McpDbServer.js +69 -0
- package/dist/tools/BaseTool.js +42 -0
- package/dist/tools/DatabaseScopedTool.js +61 -0
- package/dist/tools/QueryTools.js +13 -0
- package/dist/tools/browse/DescribeTableTool.js +37 -0
- package/dist/tools/browse/GetForeignKeysTool.js +36 -0
- package/dist/tools/browse/GetTableIndexesTool.js +30 -0
- package/dist/tools/browse/GetTableSampleTool.js +50 -0
- package/dist/tools/browse/ListTablesTool.js +51 -0
- package/dist/tools/connection/ConnectTool.js +67 -0
- package/dist/tools/connection/CurrentConnectionTool.js +36 -0
- package/dist/tools/connection/ListConnectionsTool.js +38 -0
- package/dist/tools/connection/ListDatabasesTool.js +41 -0
- package/dist/tools/connection/UseConnectionTool.js +57 -0
- package/dist/tools/connection/UseDatabaseTool.js +45 -0
- package/dist/tools/document/AggregateTool.js +44 -0
- package/dist/tools/document/CountDocumentsTool.js +32 -0
- package/dist/tools/document/DistinctValuesTool.js +36 -0
- package/dist/tools/document/DocumentTool.js +38 -0
- package/dist/tools/document/FindDocumentsTool.js +56 -0
- package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
- package/dist/tools/search/SearchTool.js +54 -0
- package/dist/tools/sql/RunQueryTool.js +46 -0
- package/dist/types/config.types.js +1 -0
- package/dist/types/connection.types.js +1 -0
- package/dist/types/driver.types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/tool.types.js +1 -0
- package/dist/types/validation.types.js +1 -0
- package/dist/validation/document/MongoOperatorGuard.js +72 -0
- package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
- package/dist/validation/names/NamePolicy.js +122 -0
- package/dist/validation/names/NamePolicyRegistry.js +33 -0
- package/dist/validation/search/SearchBodyValidator.js +56 -0
- package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
- package/dist/validation/sql/SqlDialect.js +196 -0
- package/dist/validation/sql/SqlSkeletonizer.js +197 -0
- package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
- package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
- package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
- package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
- package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
- package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
- package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
- package/dist/validation/sql/rules/index.js +6 -0
- package/package.json +76 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
/**
|
|
3
|
+
* Owns one read-only SQLite database, in a child process.
|
|
4
|
+
*
|
|
5
|
+
* node:sqlite is synchronous: a query runs on the calling thread until it
|
|
6
|
+
* finishes, and there is no way to interrupt it from JavaScript. On the main
|
|
7
|
+
* thread, one runaway query would freeze the whole MCP server.
|
|
8
|
+
*
|
|
9
|
+
* A child process rather than a worker thread, which was tried first and
|
|
10
|
+
* failed: `worker.terminate()` stops JavaScript, but a thread inside
|
|
11
|
+
* SQLite's native `sqlite3_step` never returns to JavaScript, so the
|
|
12
|
+
* terminate waited forever and took the server with it. A process can always
|
|
13
|
+
* be killed, whatever it is doing.
|
|
14
|
+
*
|
|
15
|
+
* The database is opened read-only at the file level, which is SQLite's own
|
|
16
|
+
* guarantee and the second layer behind the SQL validator. `query_only` is set
|
|
17
|
+
* as well, though it can be switched off from SQL and so is not relied on.
|
|
18
|
+
*
|
|
19
|
+
* stdout is never written: the parent does not even connect it, because the
|
|
20
|
+
* parent's own stdout is the JSON-RPC stream.
|
|
21
|
+
*/
|
|
22
|
+
function send(message) {
|
|
23
|
+
process.send?.(message);
|
|
24
|
+
}
|
|
25
|
+
function describe(error) {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Integers arrive as bigint so none is rounded on the way out. Those that fit
|
|
30
|
+
* a double exactly go back to plain numbers here, which is nearly all of them.
|
|
31
|
+
*/
|
|
32
|
+
function normalise(row) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [column, value] of Object.entries(row)) {
|
|
35
|
+
out[column] =
|
|
36
|
+
typeof value === "bigint" && Number.isSafeInteger(Number(value)) ? Number(value) : value;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
// The parent going away closes the IPC channel. Exiting then means a killed
|
|
41
|
+
// or crashed server never leaves an orphan holding the database file open.
|
|
42
|
+
process.on("disconnect", () => process.exit(0));
|
|
43
|
+
const options = JSON.parse(process.argv[2] ?? "{}");
|
|
44
|
+
let database;
|
|
45
|
+
try {
|
|
46
|
+
database = new DatabaseSync(options.path, {
|
|
47
|
+
readOnly: true,
|
|
48
|
+
// Extensions are native code; loading one is the classic route out of
|
|
49
|
+
// SQLite's sandbox.
|
|
50
|
+
allowExtension: false,
|
|
51
|
+
timeout: options.busyTimeoutMs,
|
|
52
|
+
readBigInts: true,
|
|
53
|
+
});
|
|
54
|
+
database.exec("PRAGMA query_only = ON");
|
|
55
|
+
send({ type: "ready" });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
send({ type: "failed", error: describe(error) });
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
process.on("message", (request) => {
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
const rows = database.prepare(request.sql).all(...request.params);
|
|
65
|
+
response = { id: request.id, rows: rows.map(normalise) };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
response = { id: request.id, error: describe(error) };
|
|
69
|
+
}
|
|
70
|
+
send(response);
|
|
71
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base for errors this server raises deliberately.
|
|
3
|
+
*
|
|
4
|
+
* Tool handlers turn any thrown error into a tool result, so the distinction
|
|
5
|
+
* that matters is not the class but the message: it is read by a person or a
|
|
6
|
+
* model deciding what to do next. Every subclass therefore writes a message
|
|
7
|
+
* that names the fix, not just the fault.
|
|
8
|
+
*/
|
|
9
|
+
export class ApplicationError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
Error.captureStackTrace?.(this, new.target);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a query tool is called against an engine it does not speak.
|
|
4
|
+
*
|
|
5
|
+
* Every tool is advertised all the time, because MCP fixes the tool list at
|
|
6
|
+
* handshake while the active engine changes mid-conversation. So `run_query`
|
|
7
|
+
* can be called against MongoDB, and when it is, the most useful answer names
|
|
8
|
+
* the tools that do work there.
|
|
9
|
+
*/
|
|
10
|
+
export class EngineMismatchError extends ApplicationError {
|
|
11
|
+
constructor(toolName, supportedEngines, activeEngine, alternatives) {
|
|
12
|
+
const instead = alternatives.length > 0 ? ` Use ${alternatives.join(", ")} for ${activeEngine}.` : "";
|
|
13
|
+
super(`${toolName} works on ${supportedEngines} connections, but the active connection is ${activeEngine}.${instead}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a connection URL cannot be understood.
|
|
4
|
+
*
|
|
5
|
+
* The message never contains the URL itself. A URL is where the password
|
|
6
|
+
* usually lives, and this message is shown to the user, logged to stderr, and
|
|
7
|
+
* handed to the model. Callers describe the problem ("port is not a number")
|
|
8
|
+
* without echoing the input that caused it.
|
|
9
|
+
*/
|
|
10
|
+
export class InvalidConnectionUrlError extends ApplicationError {
|
|
11
|
+
constructor(reason) {
|
|
12
|
+
super(`Invalid connection URL: ${reason}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised while parsing one entry of DB_PROFILES or MYSQL_PROFILES.
|
|
4
|
+
*
|
|
5
|
+
* Caught by the configuration loader and turned into a warning: one malformed
|
|
6
|
+
* profile must not stop the server starting, because a running server can be
|
|
7
|
+
* repaired with the connect tool while a dead one cannot be diagnosed at all.
|
|
8
|
+
*/
|
|
9
|
+
export class InvalidProfileDefinitionError extends ApplicationError {
|
|
10
|
+
profileName;
|
|
11
|
+
constructor(profileName, reason) {
|
|
12
|
+
super(`Profile "${profileName}" ${reason}`);
|
|
13
|
+
this.profileName = profileName;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a read is attempted before any connection has been chosen.
|
|
4
|
+
*
|
|
5
|
+
* Reaching this is normal rather than exceptional: the server starts happily
|
|
6
|
+
* with no configuration, so the message has to double as the instructions for
|
|
7
|
+
* getting out of that state.
|
|
8
|
+
*/
|
|
9
|
+
export class NoActiveConnectionError extends ApplicationError {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("No active connection. Call connect with a connection URL, or use_connection with a profile name.");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a connection URL named no database and the operation needs one.
|
|
4
|
+
*
|
|
5
|
+
* A URL without a database is legitimate, and common for MongoDB and SQL
|
|
6
|
+
* Server, so it is accepted at connect time. The error is deferred to the
|
|
7
|
+
* first operation that genuinely needs a database, and says how to pick one.
|
|
8
|
+
*/
|
|
9
|
+
export class NoDatabaseSelectedError extends ApplicationError {
|
|
10
|
+
constructor(engineLabel) {
|
|
11
|
+
super(`No database selected on this ${engineLabel} connection. Call list_databases, then use_database, or pass the database argument.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a table, collection, key or index does not exist.
|
|
4
|
+
*
|
|
5
|
+
* Several engines answer a question about a missing table with an empty
|
|
6
|
+
* result rather than an error, and an empty result reads as "it has no
|
|
7
|
+
* columns". Saying plainly that it does not exist, and how to see what does,
|
|
8
|
+
* gets the caller unstuck in one step.
|
|
9
|
+
*/
|
|
10
|
+
export class ObjectNotFoundError extends ApplicationError {
|
|
11
|
+
constructor(noun, name) {
|
|
12
|
+
super(`No ${noun} named "${name}" was found. Call list_tables to see what exists.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a profile name does not exist.
|
|
4
|
+
*
|
|
5
|
+
* Carries the known names, because a mistyped profile is almost always fixed
|
|
6
|
+
* by seeing the real list rather than by being told the name was wrong.
|
|
7
|
+
*/
|
|
8
|
+
export class UnknownProfileError extends ApplicationError {
|
|
9
|
+
profileName;
|
|
10
|
+
knownProfiles;
|
|
11
|
+
constructor(profileName, knownProfiles) {
|
|
12
|
+
super(`Unknown profile "${profileName}". Known profiles: ${knownProfiles.join(", ") || "none"}`);
|
|
13
|
+
this.profileName = profileName;
|
|
14
|
+
this.knownProfiles = knownProfiles;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when the active engine has no equivalent of what was asked.
|
|
4
|
+
*
|
|
5
|
+
* Redis has no foreign keys and Elasticsearch has no databases. These are not
|
|
6
|
+
* failures of the server, so the message says what the engine does have
|
|
7
|
+
* instead, which is usually what the caller wanted in the first place.
|
|
8
|
+
*/
|
|
9
|
+
export class UnsupportedOperationError extends ApplicationError {
|
|
10
|
+
constructor(engineLabel, operation, alternative) {
|
|
11
|
+
const suffix = alternative ? ` ${alternative}` : "";
|
|
12
|
+
super(`${engineLabel} has no ${operation}.${suffix}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { ApplicationError } from "./ApplicationError.js";
|
|
2
|
+
export { NoActiveConnectionError } from "./NoActiveConnectionError.js";
|
|
3
|
+
export { NoDatabaseSelectedError } from "./NoDatabaseSelectedError.js";
|
|
4
|
+
export { UnknownProfileError } from "./UnknownProfileError.js";
|
|
5
|
+
export { InvalidProfileDefinitionError } from "./InvalidProfileDefinitionError.js";
|
|
6
|
+
export { InvalidConnectionUrlError } from "./InvalidConnectionUrlError.js";
|
|
7
|
+
export { UnsupportedOperationError } from "./UnsupportedOperationError.js";
|
|
8
|
+
export { EngineMismatchError } from "./EngineMismatchError.js";
|
|
9
|
+
export { ObjectNotFoundError } from "./ObjectNotFoundError.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns driver output into JSON a person and a model can read.
|
|
3
|
+
*
|
|
4
|
+
* Plain `JSON.stringify` fails in three ways across these drivers:
|
|
5
|
+
*
|
|
6
|
+
* - It throws on a `bigint`, which SQLite, ClickHouse and MongoDB can return.
|
|
7
|
+
* - It renders a Buffer as `{"type":"Buffer","data":[1,2,3,...]}`, one number
|
|
8
|
+
* per byte, which floods the context window with a binary column.
|
|
9
|
+
* - It renders a Map (Redis replies, some driver metadata) as `{}`.
|
|
10
|
+
*
|
|
11
|
+
* A single serializer used by every tool means a new driver cannot forget one
|
|
12
|
+
* of these.
|
|
13
|
+
*/
|
|
14
|
+
export class JsonSerializer {
|
|
15
|
+
/** Enough bytes to recognise a value; the length says how much was omitted. */
|
|
16
|
+
static BINARY_PREVIEW_BYTES = 32;
|
|
17
|
+
stringify(value) {
|
|
18
|
+
const serializer = this;
|
|
19
|
+
return JSON.stringify(value,
|
|
20
|
+
// A function rather than an arrow, because the replacer receives the
|
|
21
|
+
// value after toJSON has already run, and only `this[key]` still holds
|
|
22
|
+
// the original Buffer.
|
|
23
|
+
function (key, replaced) {
|
|
24
|
+
return serializer.replace(this[key], replaced);
|
|
25
|
+
}, 2) ?? "null";
|
|
26
|
+
}
|
|
27
|
+
replace(original, replaced) {
|
|
28
|
+
if (typeof original === "bigint") {
|
|
29
|
+
// Exact where a number can be, a string where it cannot, so no digit
|
|
30
|
+
// is ever silently rounded away.
|
|
31
|
+
return Number.isSafeInteger(Number(original)) ? Number(original) : original.toString();
|
|
32
|
+
}
|
|
33
|
+
if (original instanceof Uint8Array) {
|
|
34
|
+
return this.describeBinary(original);
|
|
35
|
+
}
|
|
36
|
+
if (original instanceof Map) {
|
|
37
|
+
return Object.fromEntries(original);
|
|
38
|
+
}
|
|
39
|
+
if (original instanceof Set) {
|
|
40
|
+
return Array.from(original);
|
|
41
|
+
}
|
|
42
|
+
return replaced;
|
|
43
|
+
}
|
|
44
|
+
describeBinary(bytes) {
|
|
45
|
+
const shown = Buffer.from(bytes.subarray(0, JsonSerializer.BINARY_PREVIEW_BYTES)).toString("hex");
|
|
46
|
+
const more = bytes.length > JsonSerializer.BINARY_PREVIEW_BYTES ? "..." : "";
|
|
47
|
+
return `<binary ${bytes.length} bytes: 0x${shown}${more}>`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { JsonSerializer } from "./JsonSerializer.js";
|
|
2
|
+
/**
|
|
3
|
+
* Renders query results for a conversation.
|
|
4
|
+
*
|
|
5
|
+
* Truncation protects the context window: `SELECT *` on a large table would
|
|
6
|
+
* otherwise flood the transcript and can exceed the client's message limit
|
|
7
|
+
* outright.
|
|
8
|
+
*
|
|
9
|
+
* This truncates *output*. Bounding the work the database does is the job of
|
|
10
|
+
* each driver's server-side timeout.
|
|
11
|
+
*/
|
|
12
|
+
export class RowFormatter {
|
|
13
|
+
maxRows;
|
|
14
|
+
serializer;
|
|
15
|
+
static DEFAULT_MAX_ROWS = 100;
|
|
16
|
+
constructor(maxRows = RowFormatter.DEFAULT_MAX_ROWS, serializer = new JsonSerializer()) {
|
|
17
|
+
this.maxRows = maxRows;
|
|
18
|
+
this.serializer = serializer;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @param narrowing how to ask for fewer rows on this engine ("Add a LIMIT
|
|
22
|
+
* clause"), so the note names the fix in the caller's own terms.
|
|
23
|
+
* @param moreBeyond set when a driver already stopped reading at the cap,
|
|
24
|
+
* so the true total is unknown but known to be larger.
|
|
25
|
+
*/
|
|
26
|
+
format(rows, narrowing, moreBeyond = false) {
|
|
27
|
+
const truncated = rows.length > this.maxRows;
|
|
28
|
+
const shown = truncated ? rows.slice(0, this.maxRows) : rows;
|
|
29
|
+
const body = this.serializer.stringify(shown);
|
|
30
|
+
if (truncated) {
|
|
31
|
+
// The note states the true total, so the reader knows they are seeing
|
|
32
|
+
// a sample and how large the whole is.
|
|
33
|
+
return `${body}\n\n--- Showing ${this.maxRows} of ${rows.length} rows. ${narrowing} for smaller results. ---`;
|
|
34
|
+
}
|
|
35
|
+
if (moreBeyond) {
|
|
36
|
+
return `${body}\n\n--- Showing the first ${shown.length} results; there are more. ${narrowing} for smaller results. ---`;
|
|
37
|
+
}
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
get limit() {
|
|
41
|
+
return this.maxRows;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { JsonSerializer } from "./JsonSerializer.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds MCP tool results.
|
|
4
|
+
*
|
|
5
|
+
* Static factories rather than an instance, because there is no state and
|
|
6
|
+
* every tool needs them. Having one place that constructs the content envelope
|
|
7
|
+
* means `isError` is set consistently and no handler hand-rolls the shape.
|
|
8
|
+
*/
|
|
9
|
+
export class ToolResponse {
|
|
10
|
+
static serializer = new JsonSerializer();
|
|
11
|
+
static text(body) {
|
|
12
|
+
return { content: [{ type: "text", text: body }] };
|
|
13
|
+
}
|
|
14
|
+
static json(value) {
|
|
15
|
+
return ToolResponse.text(ToolResponse.serializer.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The `Error: ` prefix is load-bearing. An assistant reads tool output to
|
|
19
|
+
* decide what to do next, and an unprefixed message is easily mistaken for
|
|
20
|
+
* data rather than a failure.
|
|
21
|
+
*/
|
|
22
|
+
static failure(body) {
|
|
23
|
+
return { content: [{ type: "text", text: `Error: ${body}` }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ApplicationFactory } from "./ApplicationFactory.js";
|
|
3
|
+
/**
|
|
4
|
+
* Entry point. Nothing but construction and start.
|
|
5
|
+
*
|
|
6
|
+
* There is no configuration check and no exit path here on purpose. A server
|
|
7
|
+
* with no usable connection still starts, still answers tools/list, and
|
|
8
|
+
* reports the problem through tool results. An MCP client cannot show a stderr
|
|
9
|
+
* message from a process that exited during handshake; it reports "server
|
|
10
|
+
* failed to start", which is indistinguishable from a broken image or a wrong
|
|
11
|
+
* path. A running server that says "call connect" is diagnosable, and usually
|
|
12
|
+
* fixable in the same conversation.
|
|
13
|
+
*/
|
|
14
|
+
const application = new ApplicationFactory();
|
|
15
|
+
await application.create().start();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
/**
|
|
4
|
+
* Owns the MCP server's lifetime.
|
|
5
|
+
*
|
|
6
|
+
* Takes tools as an already-constructed list rather than building them, so
|
|
7
|
+
* this class knows nothing about databases or about which tools exist. Wiring
|
|
8
|
+
* is the composition root's job; running is this one's.
|
|
9
|
+
*/
|
|
10
|
+
export class McpDbServer {
|
|
11
|
+
tools;
|
|
12
|
+
drivers;
|
|
13
|
+
logger;
|
|
14
|
+
server;
|
|
15
|
+
shuttingDown = false;
|
|
16
|
+
/**
|
|
17
|
+
* `version` is required and has no default on purpose: a default here is a
|
|
18
|
+
* second place to remember at release time, and the one that silently wins
|
|
19
|
+
* when it is forgotten. The composition root reads it from `package.json`.
|
|
20
|
+
*/
|
|
21
|
+
constructor(tools, drivers, logger, version, name = "db-readonly-switchable") {
|
|
22
|
+
this.tools = tools;
|
|
23
|
+
this.drivers = drivers;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
this.server = new McpServer({ name, version });
|
|
26
|
+
}
|
|
27
|
+
async start() {
|
|
28
|
+
// Registered before the transport connects, so a tools/list arriving
|
|
29
|
+
// immediately after the handshake can be answered.
|
|
30
|
+
for (const tool of this.tools) {
|
|
31
|
+
tool.register(this.server);
|
|
32
|
+
}
|
|
33
|
+
this.installSignalHandlers();
|
|
34
|
+
await this.server.connect(new StdioServerTransport());
|
|
35
|
+
// stderr, always. On stdio transport stdout carries JSON-RPC and a single
|
|
36
|
+
// stray byte corrupts the stream.
|
|
37
|
+
this.logger("MCP database server running (read-only, switchable connection)");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Only signals, deliberately.
|
|
41
|
+
*
|
|
42
|
+
* Once a query has run a driver holds open sockets, and open handles keep
|
|
43
|
+
* the Node event loop alive, so the process cannot exit on its own. That
|
|
44
|
+
* makes it tempting to shut down when stdin closes. It is a trap, found the
|
|
45
|
+
* hard way in the MySQL-only predecessor: stdin `end` fires when no further
|
|
46
|
+
* requests are *buffered*, not when the client has gone. A client that
|
|
47
|
+
* writes several requests and waits reaches EOF while they are still being
|
|
48
|
+
* processed, so connections were torn down mid-flight and every later call
|
|
49
|
+
* failed.
|
|
50
|
+
*
|
|
51
|
+
* SIGTERM is what a client sends when it is genuinely finished.
|
|
52
|
+
*/
|
|
53
|
+
installSignalHandlers() {
|
|
54
|
+
const shutdown = () => {
|
|
55
|
+
void this.shutdown();
|
|
56
|
+
};
|
|
57
|
+
process.on("SIGINT", shutdown);
|
|
58
|
+
process.on("SIGTERM", shutdown);
|
|
59
|
+
}
|
|
60
|
+
/** Idempotent, so a repeated signal cannot tear down drivers twice at once. */
|
|
61
|
+
async shutdown() {
|
|
62
|
+
if (this.shuttingDown) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.shuttingDown = true;
|
|
66
|
+
await this.drivers.closeAll();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ToolResponse } from "../formatting/ToolResponse.js";
|
|
2
|
+
/**
|
|
3
|
+
* Template Method base for every tool.
|
|
4
|
+
*
|
|
5
|
+
* `register` and `invoke` are fixed; subclasses supply only `execute`. That
|
|
6
|
+
* makes the error contract impossible to forget, which matters because an MCP
|
|
7
|
+
* server that throws out of a handler can take the client's whole session with
|
|
8
|
+
* it.
|
|
9
|
+
*/
|
|
10
|
+
export class BaseTool {
|
|
11
|
+
/**
|
|
12
|
+
* The cast is confined to this one line: the SDK derives the callback's
|
|
13
|
+
* argument type from the schema it was given, which it cannot do for a
|
|
14
|
+
* schema held in an abstract property. Every subclass declares its own
|
|
15
|
+
* argument interface, so the type is recovered immediately below.
|
|
16
|
+
*/
|
|
17
|
+
register(server) {
|
|
18
|
+
server.registerTool(this.name, {
|
|
19
|
+
// Sent at the top level as well as inside the annotations: newer
|
|
20
|
+
// clients read the top-level field, older ones only the annotation.
|
|
21
|
+
title: this.annotations.title,
|
|
22
|
+
description: this.description,
|
|
23
|
+
inputSchema: this.inputSchema,
|
|
24
|
+
annotations: this.annotations,
|
|
25
|
+
}, (args) => this.invoke(args));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The one place a thrown error becomes a tool error.
|
|
29
|
+
*
|
|
30
|
+
* A dropped connection, a syntax error or an unconfigured server all
|
|
31
|
+
* arrive here and leave as readable text, so the process stays alive and the
|
|
32
|
+
* user can simply try again.
|
|
33
|
+
*/
|
|
34
|
+
async invoke(args) {
|
|
35
|
+
try {
|
|
36
|
+
return await this.execute(args);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return ToolResponse.failure(error instanceof Error ? error.message : String(error));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BaseTool } from "./BaseTool.js";
|
|
3
|
+
import { ToolResponse } from "../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* Base for every tool that reads through the active connection.
|
|
6
|
+
*
|
|
7
|
+
* Refines the Template Method one step further: `execute` is implemented here
|
|
8
|
+
* to resolve the connection, check the tool fits its engine, and validate the
|
|
9
|
+
* shared `database` argument, and subclasses supply `read`. Without this,
|
|
10
|
+
* every tool would repeat the same three guards, and a new tool could
|
|
11
|
+
* silently omit one and interpolate an unchecked name.
|
|
12
|
+
*
|
|
13
|
+
* The per-call `database` override exists for two reasons: comparing two
|
|
14
|
+
* databases otherwise means switching, reading and switching back, and a call
|
|
15
|
+
* carrying its own database does not depend on shared mutable state, so it
|
|
16
|
+
* cannot be reordered against a switch issued in the same batch.
|
|
17
|
+
*/
|
|
18
|
+
export class DatabaseScopedTool extends BaseTool {
|
|
19
|
+
drivers;
|
|
20
|
+
names;
|
|
21
|
+
/** Reused by every subclass so the argument reads identically everywhere. */
|
|
22
|
+
static databaseParam = z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Optional database to read from for this call only, without changing the active connection");
|
|
26
|
+
/**
|
|
27
|
+
* The engine family this tool speaks, or null for the browse tools that
|
|
28
|
+
* work on every engine. Checked before anything else, so run_query against
|
|
29
|
+
* MongoDB fails with a pointer to the right tool rather than a parse error.
|
|
30
|
+
*/
|
|
31
|
+
family = null;
|
|
32
|
+
constructor(drivers, names) {
|
|
33
|
+
super();
|
|
34
|
+
this.drivers = drivers;
|
|
35
|
+
this.names = names;
|
|
36
|
+
}
|
|
37
|
+
async execute(args) {
|
|
38
|
+
const active = this.drivers.requireActiveTarget();
|
|
39
|
+
if (this.family) {
|
|
40
|
+
this.drivers.requireFamily(active, this.family, this.name);
|
|
41
|
+
}
|
|
42
|
+
if (args.database) {
|
|
43
|
+
const rejection = this.reject(this.names.for(active.engine).validateDatabase(args.database));
|
|
44
|
+
if (rejection) {
|
|
45
|
+
return rejection;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return this.read(args, this.drivers.resolveTarget(args.database));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* @returns a failure result, or null when the name is valid. Returning a
|
|
52
|
+
* value rather than throwing keeps "the caller passed something invalid"
|
|
53
|
+
* distinct from "something failed at runtime".
|
|
54
|
+
*/
|
|
55
|
+
validateObjectName(target, value, label) {
|
|
56
|
+
return this.reject(this.names.for(target.engine).validateObject(value, label));
|
|
57
|
+
}
|
|
58
|
+
reject(result) {
|
|
59
|
+
return result.valid ? null : ToolResponse.failure(result.error ?? "Invalid argument.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query tools for each engine family.
|
|
3
|
+
*
|
|
4
|
+
* Lives beside the tools rather than in the engine catalog, because the
|
|
5
|
+
* domain layer should not know tool names. DriverProvider receives it so that
|
|
6
|
+
* calling a query tool against the wrong engine names the right ones.
|
|
7
|
+
*/
|
|
8
|
+
export const QUERY_TOOLS = {
|
|
9
|
+
sql: ["run_query"],
|
|
10
|
+
document: ["find_documents", "aggregate", "count_documents", "distinct_values"],
|
|
11
|
+
keyvalue: ["redis_command"],
|
|
12
|
+
search: ["search"],
|
|
13
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* The shape of one table, in each engine's terms.
|
|
6
|
+
*
|
|
7
|
+
* Columns on SQL engines, fields inferred from sampled documents on MongoDB,
|
|
8
|
+
* the mapping on Elasticsearch, and type, TTL and size for a Redis key.
|
|
9
|
+
*/
|
|
10
|
+
export class DescribeTableTool extends DatabaseScopedTool {
|
|
11
|
+
name = "describe_table";
|
|
12
|
+
description = "Show the structure of a table: columns on SQL engines, inferred fields for a MongoDB collection, the mapping of an Elasticsearch index, or the type and TTL of a Redis key";
|
|
13
|
+
annotations = {
|
|
14
|
+
title: "Describe Table",
|
|
15
|
+
readOnlyHint: true,
|
|
16
|
+
destructiveHint: false,
|
|
17
|
+
idempotentHint: true,
|
|
18
|
+
openWorldHint: true,
|
|
19
|
+
};
|
|
20
|
+
inputSchema = {
|
|
21
|
+
table: z
|
|
22
|
+
.string()
|
|
23
|
+
.describe("Table name (schema.table where the engine has schemas), collection, Redis key, or index"),
|
|
24
|
+
database: DatabaseScopedTool.databaseParam,
|
|
25
|
+
};
|
|
26
|
+
constructor(drivers, names) {
|
|
27
|
+
super(drivers, names);
|
|
28
|
+
}
|
|
29
|
+
async read(args, target) {
|
|
30
|
+
const rejection = this.validateObjectName(target, args.table, "table name");
|
|
31
|
+
if (rejection) {
|
|
32
|
+
return rejection;
|
|
33
|
+
}
|
|
34
|
+
const driver = await this.drivers.acquire(target);
|
|
35
|
+
return ToolResponse.json(await driver.describeObject(args.table));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** Foreign keys declared on one table, on the relational engines that have them. */
|
|
5
|
+
export class GetForeignKeysTool extends DatabaseScopedTool {
|
|
6
|
+
name = "get_foreign_keys";
|
|
7
|
+
description = "Show foreign key relationships for a table on MySQL, PostgreSQL, SQLite or SQL Server";
|
|
8
|
+
annotations = {
|
|
9
|
+
title: "Get Foreign Keys",
|
|
10
|
+
readOnlyHint: true,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: true,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
};
|
|
15
|
+
inputSchema = {
|
|
16
|
+
table: z.string().describe("Table name"),
|
|
17
|
+
database: DatabaseScopedTool.databaseParam,
|
|
18
|
+
};
|
|
19
|
+
constructor(drivers, names) {
|
|
20
|
+
super(drivers, names);
|
|
21
|
+
}
|
|
22
|
+
async read(args, target) {
|
|
23
|
+
const rejection = this.validateObjectName(target, args.table, "table name");
|
|
24
|
+
if (rejection) {
|
|
25
|
+
return rejection;
|
|
26
|
+
}
|
|
27
|
+
const driver = await this.drivers.acquire(target);
|
|
28
|
+
const rows = await driver.listForeignKeys(args.table);
|
|
29
|
+
// A sentence rather than []: an empty array reads as "the query failed",
|
|
30
|
+
// a sentence reads as an answer.
|
|
31
|
+
if (rows.length === 0) {
|
|
32
|
+
return ToolResponse.text(`No foreign keys found for table "${args.table}".`);
|
|
33
|
+
}
|
|
34
|
+
return ToolResponse.json(rows);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** Indexes on one table or collection. */
|
|
5
|
+
export class GetTableIndexesTool extends DatabaseScopedTool {
|
|
6
|
+
name = "get_table_indexes";
|
|
7
|
+
description = "Show indexes on a table or MongoDB collection, or the sorting key and skipping indices of a ClickHouse table";
|
|
8
|
+
annotations = {
|
|
9
|
+
title: "Get Table Indexes",
|
|
10
|
+
readOnlyHint: true,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: true,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
};
|
|
15
|
+
inputSchema = {
|
|
16
|
+
table: z.string().describe("Table or collection name"),
|
|
17
|
+
database: DatabaseScopedTool.databaseParam,
|
|
18
|
+
};
|
|
19
|
+
constructor(drivers, names) {
|
|
20
|
+
super(drivers, names);
|
|
21
|
+
}
|
|
22
|
+
async read(args, target) {
|
|
23
|
+
const rejection = this.validateObjectName(target, args.table, "table name");
|
|
24
|
+
if (rejection) {
|
|
25
|
+
return rejection;
|
|
26
|
+
}
|
|
27
|
+
const driver = await this.drivers.acquire(target);
|
|
28
|
+
return ToolResponse.json(await driver.listIndexes(args.table));
|
|
29
|
+
}
|
|
30
|
+
}
|