@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,224 @@
|
|
|
1
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
2
|
+
import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
|
|
3
|
+
import { BaseDriver } from "../BaseDriver.js";
|
|
4
|
+
import { LazyResource } from "../LazyResource.js";
|
|
5
|
+
import { RedisCommandFlagsGuard } from "./RedisCommandFlagsGuard.js";
|
|
6
|
+
/**
|
|
7
|
+
* Redis, and anything speaking its protocol (Valkey, KeyDB, Dragonfly),
|
|
8
|
+
* through ioredis.
|
|
9
|
+
*
|
|
10
|
+
* Keys stand in for tables: list_tables scans keys, describe_table reports a
|
|
11
|
+
* key's type, TTL and size, and get_table_sample reads a bounded slice of its
|
|
12
|
+
* value. Every one of those uses a fixed read command chosen here.
|
|
13
|
+
*
|
|
14
|
+
* Read-only layer two applies to redis_command, the only path where the
|
|
15
|
+
* caller chooses the command: RedisCommandFlagsGuard asks the server to
|
|
16
|
+
* confirm the command is flagged read-only before it is sent.
|
|
17
|
+
*/
|
|
18
|
+
export class RedisDriver extends BaseDriver {
|
|
19
|
+
tuning;
|
|
20
|
+
logger;
|
|
21
|
+
guard;
|
|
22
|
+
family = "keyvalue";
|
|
23
|
+
/** How many keys one SCAN step asks for. A hint to Redis, not a limit. */
|
|
24
|
+
static SCAN_BATCH = 500;
|
|
25
|
+
/** Long strings are cut in samples; the length is reported so nothing is hidden silently. */
|
|
26
|
+
static MAX_STRING_PREVIEW = 4096;
|
|
27
|
+
client;
|
|
28
|
+
constructor(target, tuning, logger, guard = new RedisCommandFlagsGuard()) {
|
|
29
|
+
super(target);
|
|
30
|
+
this.tuning = tuning;
|
|
31
|
+
this.logger = logger;
|
|
32
|
+
this.guard = guard;
|
|
33
|
+
this.client = new LazyResource(() => this.open(), async (client) => {
|
|
34
|
+
client.disconnect();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async verify() {
|
|
38
|
+
await this.call("PING");
|
|
39
|
+
}
|
|
40
|
+
close() {
|
|
41
|
+
return this.client.close();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* INFO keyspace lists only databases holding keys, which is what someone
|
|
45
|
+
* browsing wants; the active one is always included even when empty.
|
|
46
|
+
*/
|
|
47
|
+
async listDatabases() {
|
|
48
|
+
const info = String(await this.call("INFO", "keyspace"));
|
|
49
|
+
const names = new Set();
|
|
50
|
+
for (const match of info.matchAll(/^db(\d+):/gm)) {
|
|
51
|
+
names.add(match[1]);
|
|
52
|
+
}
|
|
53
|
+
names.add(this.databaseIndex().toString());
|
|
54
|
+
return Array.from(names)
|
|
55
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
56
|
+
.map((name) => ({ name, system: false }));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* SCAN, never KEYS: KEYS blocks the whole server while it walks every key,
|
|
60
|
+
* which on a production instance is an outage.
|
|
61
|
+
*/
|
|
62
|
+
async listObjects(pattern, limit) {
|
|
63
|
+
const found = [];
|
|
64
|
+
let cursor = "0";
|
|
65
|
+
do {
|
|
66
|
+
const [next, keys] = (await this.call("SCAN", cursor, "MATCH", pattern || "*", "COUNT", String(RedisDriver.SCAN_BATCH)));
|
|
67
|
+
cursor = next;
|
|
68
|
+
found.push(...keys);
|
|
69
|
+
} while (cursor !== "0" && found.length <= limit);
|
|
70
|
+
const unique = Array.from(new Set(found)).sort();
|
|
71
|
+
return { names: unique.slice(0, limit), truncated: unique.length > limit || cursor !== "0" };
|
|
72
|
+
}
|
|
73
|
+
async describeObject(name) {
|
|
74
|
+
const type = await this.typeOf(name);
|
|
75
|
+
const [ttl, length, encoding, memory] = await Promise.all([
|
|
76
|
+
this.call("TTL", name),
|
|
77
|
+
this.lengthOf(name, type),
|
|
78
|
+
this.optional(() => this.call("OBJECT", "ENCODING", name)),
|
|
79
|
+
this.optional(() => this.call("MEMORY", "USAGE", name)),
|
|
80
|
+
]);
|
|
81
|
+
return {
|
|
82
|
+
key: name,
|
|
83
|
+
type,
|
|
84
|
+
// -1 means the key never expires, which reads better said than shown.
|
|
85
|
+
ttl_seconds: ttl === -1 ? "no expiry" : ttl,
|
|
86
|
+
length,
|
|
87
|
+
encoding,
|
|
88
|
+
memory_bytes: memory,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** A bounded slice of the value, read with the command that fits its type. */
|
|
92
|
+
async sample(name, limit) {
|
|
93
|
+
const type = await this.typeOf(name);
|
|
94
|
+
const last = String(limit - 1);
|
|
95
|
+
switch (type) {
|
|
96
|
+
case "string":
|
|
97
|
+
return this.previewString(String(await this.call("GET", name)));
|
|
98
|
+
case "hash":
|
|
99
|
+
return this.pairs((await this.call("HSCAN", name, "0", "COUNT", String(limit))), limit);
|
|
100
|
+
case "list":
|
|
101
|
+
return this.call("LRANGE", name, "0", last);
|
|
102
|
+
case "set":
|
|
103
|
+
return (await this.call("SSCAN", name, "0", "COUNT", String(limit)))[1].slice(0, limit);
|
|
104
|
+
case "zset":
|
|
105
|
+
return this.scored((await this.call("ZRANGE", name, "0", last, "WITHSCORES")));
|
|
106
|
+
case "stream":
|
|
107
|
+
return this.call("XRANGE", name, "-", "+", "COUNT", String(limit));
|
|
108
|
+
case "ReJSON-RL":
|
|
109
|
+
return JSON.parse(String(await this.call("JSON.GET", name)));
|
|
110
|
+
default:
|
|
111
|
+
return { key: name, type, note: "No sample reader for this type; use redis_command." };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async command(name, args) {
|
|
115
|
+
const client = await this.client.get();
|
|
116
|
+
const call = (command, ...rest) => client.call(command, ...rest);
|
|
117
|
+
const subcommand = this.isContainer(name) ? args[0]?.toUpperCase() : undefined;
|
|
118
|
+
await this.guard.assertReadOnly(call, name, subcommand);
|
|
119
|
+
return call(name, ...args);
|
|
120
|
+
}
|
|
121
|
+
async typeOf(name) {
|
|
122
|
+
const type = String(await this.call("TYPE", name));
|
|
123
|
+
if (type === "none") {
|
|
124
|
+
throw new ObjectNotFoundError(this.objectNoun, name);
|
|
125
|
+
}
|
|
126
|
+
return type;
|
|
127
|
+
}
|
|
128
|
+
async lengthOf(name, type) {
|
|
129
|
+
const command = {
|
|
130
|
+
string: "STRLEN",
|
|
131
|
+
hash: "HLEN",
|
|
132
|
+
list: "LLEN",
|
|
133
|
+
set: "SCARD",
|
|
134
|
+
zset: "ZCARD",
|
|
135
|
+
stream: "XLEN",
|
|
136
|
+
};
|
|
137
|
+
return command[type] ? this.call(command[type], name) : null;
|
|
138
|
+
}
|
|
139
|
+
/** OBJECT and MEMORY are often denied by ACLs or missing on compatible servers. */
|
|
140
|
+
async optional(read) {
|
|
141
|
+
try {
|
|
142
|
+
return await read();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
previewString(value) {
|
|
149
|
+
if (value.length <= RedisDriver.MAX_STRING_PREVIEW) {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
preview: value.slice(0, RedisDriver.MAX_STRING_PREVIEW),
|
|
154
|
+
total_length: value.length,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
pairs(reply, limit) {
|
|
158
|
+
const flat = reply[1];
|
|
159
|
+
const out = {};
|
|
160
|
+
for (let index = 0; index + 1 < flat.length && Object.keys(out).length < limit; index += 2) {
|
|
161
|
+
out[flat[index]] = flat[index + 1];
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
scored(flat) {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (let index = 0; index + 1 < flat.length; index += 2) {
|
|
168
|
+
out.push({ member: flat[index], score: Number(flat[index + 1]) });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
isContainer(name) {
|
|
173
|
+
return ["OBJECT", "MEMORY", "XINFO"].includes(name);
|
|
174
|
+
}
|
|
175
|
+
async call(command, ...args) {
|
|
176
|
+
const client = await this.client.get();
|
|
177
|
+
return client.call(command, ...args);
|
|
178
|
+
}
|
|
179
|
+
databaseIndex() {
|
|
180
|
+
const parsed = Number.parseInt(this.target.database, 10);
|
|
181
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
182
|
+
}
|
|
183
|
+
/** Imported on first use; see MySqlDriver.createPool. */
|
|
184
|
+
async open() {
|
|
185
|
+
const { Redis: RedisClient } = await import("ioredis");
|
|
186
|
+
const secure = EngineCatalog.scheme(this.target.scheme).secure;
|
|
187
|
+
// Reconnect only once a connection has worked. With lazyConnect, ioredis
|
|
188
|
+
// retries a refused first connection forever and connect() never settles,
|
|
189
|
+
// so connecting to a Redis that is down hung the tool call instead of
|
|
190
|
+
// failing it. Found when the integration probe hung on a missing server.
|
|
191
|
+
let everConnected = false;
|
|
192
|
+
const client = new RedisClient({
|
|
193
|
+
host: this.target.host,
|
|
194
|
+
port: this.target.port,
|
|
195
|
+
username: this.target.user || undefined,
|
|
196
|
+
password: this.target.password || undefined,
|
|
197
|
+
db: this.databaseIndex(),
|
|
198
|
+
tls: secure ? {} : undefined,
|
|
199
|
+
connectTimeout: this.tuning.connectTimeoutMs,
|
|
200
|
+
commandTimeout: this.tuning.queryTimeoutMs,
|
|
201
|
+
lazyConnect: true,
|
|
202
|
+
// Fail a command promptly while disconnected rather than queueing it
|
|
203
|
+
// until the server returns, which from a conversation looks like a hang.
|
|
204
|
+
maxRetriesPerRequest: 1,
|
|
205
|
+
retryStrategy: (attempt) => (everConnected ? Math.min(attempt * 200, 2000) : null),
|
|
206
|
+
connectionName: "mcp-db-read-only",
|
|
207
|
+
});
|
|
208
|
+
client.once("ready", () => {
|
|
209
|
+
everConnected = true;
|
|
210
|
+
});
|
|
211
|
+
// ioredis emits connection errors as events; unlistened, they are printed
|
|
212
|
+
// by the library itself. Routed through the logger they stay on stderr
|
|
213
|
+
// and carry this server's prefix.
|
|
214
|
+
client.on("error", (error) => this.logger(`redis: ${error.message}`));
|
|
215
|
+
try {
|
|
216
|
+
await client.connect();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
client.disconnect();
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
return client;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
2
|
+
import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
|
|
3
|
+
import { UnsupportedOperationError } from "../../errors/UnsupportedOperationError.js";
|
|
4
|
+
import { BaseDriver } from "../BaseDriver.js";
|
|
5
|
+
/**
|
|
6
|
+
* Elasticsearch and OpenSearch, over their REST API with Node's own fetch.
|
|
7
|
+
*
|
|
8
|
+
* No client library, for two reasons. The official Elasticsearch client
|
|
9
|
+
* refuses to talk to OpenSearch, and the OpenSearch client to Elasticsearch,
|
|
10
|
+
* so supporting both would take two dependencies. And the five read calls
|
|
11
|
+
* needed here are simple enough that a client would add weight without
|
|
12
|
+
* adding safety; the closed ReadRequest union is the safety.
|
|
13
|
+
*
|
|
14
|
+
* Authentication is basic auth from the URL's user and password, or an API
|
|
15
|
+
* key from `?api_key=`, which is kept out of every displayed string.
|
|
16
|
+
*/
|
|
17
|
+
export class ElasticsearchDriver extends BaseDriver {
|
|
18
|
+
tuning;
|
|
19
|
+
fetcher;
|
|
20
|
+
family = "search";
|
|
21
|
+
constructor(target, tuning,
|
|
22
|
+
// Wrapped so fetch is never invoked with this driver as its receiver.
|
|
23
|
+
fetcher = (input, init) => fetch(input, init)) {
|
|
24
|
+
super(target);
|
|
25
|
+
this.tuning = tuning;
|
|
26
|
+
this.fetcher = fetcher;
|
|
27
|
+
}
|
|
28
|
+
async verify() {
|
|
29
|
+
const cluster = (await this.send({ kind: "cluster" }, this.tuning.connectTimeoutMs));
|
|
30
|
+
if (!cluster.version?.number) {
|
|
31
|
+
throw new Error("The server answered, but not as Elasticsearch or OpenSearch.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Nothing to release: fetch holds no connection between calls that we own. */
|
|
35
|
+
async close() {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
async listDatabases() {
|
|
39
|
+
throw new UnsupportedOperationError(this.label, "databases", "Its indices are listed by list_tables.");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Indices whose names start with `.` are internal (security, Kibana,
|
|
43
|
+
* ingest state) and are hidden unless the pattern asks for them.
|
|
44
|
+
*/
|
|
45
|
+
async listObjects(pattern, limit) {
|
|
46
|
+
const rows = (await this.send({ kind: "indices", pattern: pattern || "*" }));
|
|
47
|
+
const showHidden = (pattern ?? "").startsWith(".");
|
|
48
|
+
const names = rows
|
|
49
|
+
.map((row) => row.index)
|
|
50
|
+
.filter((name) => showHidden || !name.startsWith("."))
|
|
51
|
+
.sort();
|
|
52
|
+
return { names: names.slice(0, limit), truncated: names.length > limit };
|
|
53
|
+
}
|
|
54
|
+
async describeObject(name) {
|
|
55
|
+
return this.send({ kind: "mapping", index: name });
|
|
56
|
+
}
|
|
57
|
+
async sample(name, limit) {
|
|
58
|
+
const result = (await this.search(name, { size: limit }));
|
|
59
|
+
return result.hits;
|
|
60
|
+
}
|
|
61
|
+
/** Hits trimmed to what a reader needs, with the total reported alongside. */
|
|
62
|
+
async search(index, body) {
|
|
63
|
+
const result = (await this.send({ kind: "search", index, body }));
|
|
64
|
+
return {
|
|
65
|
+
took_ms: result.took,
|
|
66
|
+
timed_out: result.timed_out,
|
|
67
|
+
total: result.hits?.total,
|
|
68
|
+
hits: (result.hits?.hits ?? []).map((hit) => ({
|
|
69
|
+
_index: hit._index,
|
|
70
|
+
_id: hit._id,
|
|
71
|
+
_score: hit._score,
|
|
72
|
+
_source: hit._source,
|
|
73
|
+
fields: hit.fields,
|
|
74
|
+
highlight: hit.highlight,
|
|
75
|
+
sort: hit.sort,
|
|
76
|
+
})),
|
|
77
|
+
aggregations: result.aggregations,
|
|
78
|
+
suggest: result.suggest,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async send(request, timeoutMs = this.tuning.queryTimeoutMs) {
|
|
82
|
+
const { method, path, body } = this.route(request);
|
|
83
|
+
const response = await this.fetcher(`${this.baseUrl()}${path}`, {
|
|
84
|
+
method,
|
|
85
|
+
headers: this.headers(),
|
|
86
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
87
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
88
|
+
});
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
const parsed = text ? this.parse(text) : null;
|
|
91
|
+
if (response.status === 404 && request.kind !== "cluster" && request.kind !== "indices") {
|
|
92
|
+
throw new ObjectNotFoundError(this.objectNoun, request.index);
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`${this.label} answered ${response.status}: ${this.reason(parsed, text)}`);
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The only place a path is built. Index names are percent-encoded on top of
|
|
101
|
+
* having passed the name policy, which already refused `.`, `..` and a
|
|
102
|
+
* leading `_`.
|
|
103
|
+
*/
|
|
104
|
+
route(request) {
|
|
105
|
+
switch (request.kind) {
|
|
106
|
+
case "cluster":
|
|
107
|
+
return { method: "GET", path: "/" };
|
|
108
|
+
case "indices":
|
|
109
|
+
return {
|
|
110
|
+
method: "GET",
|
|
111
|
+
path: `/_cat/indices/${this.segment(request.pattern)}?format=json&h=index&expand_wildcards=open`,
|
|
112
|
+
};
|
|
113
|
+
case "mapping":
|
|
114
|
+
return { method: "GET", path: `/${this.segment(request.index)}/_mapping` };
|
|
115
|
+
case "search":
|
|
116
|
+
return { method: "POST", path: `/${this.segment(request.index)}/_search`, body: request.body };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Commas and wildcards are meaningful in an index expression, so they are kept. */
|
|
120
|
+
segment(value) {
|
|
121
|
+
return encodeURIComponent(value).replace(/%2C/gi, ",").replace(/%2A/gi, "*");
|
|
122
|
+
}
|
|
123
|
+
baseUrl() {
|
|
124
|
+
const secure = EngineCatalog.scheme(this.target.scheme).secure;
|
|
125
|
+
const host = this.target.host.includes(":") ? `[${this.target.host}]` : this.target.host;
|
|
126
|
+
return `${secure ? "https" : "http"}://${host}:${this.target.port}`;
|
|
127
|
+
}
|
|
128
|
+
headers() {
|
|
129
|
+
const headers = { "content-type": "application/json", accept: "application/json" };
|
|
130
|
+
const apiKey = this.target.secretOption("api_key") ?? this.target.secretOption("apikey");
|
|
131
|
+
if (apiKey) {
|
|
132
|
+
headers.authorization = `ApiKey ${apiKey}`;
|
|
133
|
+
}
|
|
134
|
+
else if (this.target.user) {
|
|
135
|
+
const credentials = Buffer.from(`${this.target.user}:${this.target.password}`).toString("base64");
|
|
136
|
+
headers.authorization = `Basic ${credentials}`;
|
|
137
|
+
}
|
|
138
|
+
return headers;
|
|
139
|
+
}
|
|
140
|
+
parse(text) {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Elasticsearch errors carry `error.root_cause[0].reason`; the first useful one wins. */
|
|
149
|
+
reason(parsed, text) {
|
|
150
|
+
const error = parsed?.error;
|
|
151
|
+
if (typeof error === "string") {
|
|
152
|
+
return error;
|
|
153
|
+
}
|
|
154
|
+
if (error?.reason) {
|
|
155
|
+
return error.type ? `${error.type}: ${error.reason}` : error.reason;
|
|
156
|
+
}
|
|
157
|
+
return text.slice(0, 500);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
2
|
+
import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
|
|
3
|
+
import { BaseDriver } from "../BaseDriver.js";
|
|
4
|
+
import { GlobPattern } from "../GlobPattern.js";
|
|
5
|
+
import { LazyResource } from "../LazyResource.js";
|
|
6
|
+
import { SqlIdentifier } from "./SqlIdentifier.js";
|
|
7
|
+
/**
|
|
8
|
+
* ClickHouse, through the official client over the HTTP interface.
|
|
9
|
+
*
|
|
10
|
+
* Read-only layer two is ClickHouse's own `readonly` setting, sent with every
|
|
11
|
+
* query. `readonly=2` refuses every write and DDL statement while still
|
|
12
|
+
* allowing the timeout and result caps below to be set, and it cannot itself
|
|
13
|
+
* be lowered from inside a query.
|
|
14
|
+
*
|
|
15
|
+
* An account that is already read-only on the server (a user profile with
|
|
16
|
+
* `readonly=1`) refuses any setting at all, the read-only one included. So the
|
|
17
|
+
* driver asks first and only sends what the account permits: a server that
|
|
18
|
+
* already enforces read-only needs nothing from us to do so.
|
|
19
|
+
*/
|
|
20
|
+
export class ClickHouseDriver extends BaseDriver {
|
|
21
|
+
tuning;
|
|
22
|
+
family = "sql";
|
|
23
|
+
dialect = "clickhouse";
|
|
24
|
+
static SYSTEM_DATABASES = new Set(["system", "INFORMATION_SCHEMA", "information_schema"]);
|
|
25
|
+
/**
|
|
26
|
+
* An analytics table can hold billions of rows, and the formatter shows only
|
|
27
|
+
* a hundred. Capping on the server stops a careless SELECT * from pulling a
|
|
28
|
+
* table into this process's memory before any of it is thrown away.
|
|
29
|
+
*/
|
|
30
|
+
static MAX_RESULT_ROWS = 10000;
|
|
31
|
+
client;
|
|
32
|
+
constructor(target, tuning) {
|
|
33
|
+
super(target);
|
|
34
|
+
this.tuning = tuning;
|
|
35
|
+
this.client = new LazyResource(() => this.open(), (opened) => opened.client.close());
|
|
36
|
+
}
|
|
37
|
+
async verify() {
|
|
38
|
+
await this.run("SELECT 1 AS ok");
|
|
39
|
+
}
|
|
40
|
+
close() {
|
|
41
|
+
return this.client.close();
|
|
42
|
+
}
|
|
43
|
+
async listDatabases() {
|
|
44
|
+
const rows = (await this.run("SELECT name FROM system.databases ORDER BY name"));
|
|
45
|
+
return rows.map((row) => ({ name: row.name, system: ClickHouseDriver.SYSTEM_DATABASES.has(row.name) }));
|
|
46
|
+
}
|
|
47
|
+
async listObjects(pattern, limit) {
|
|
48
|
+
const rows = (await this.run("SELECT name FROM system.tables WHERE database = {database:String} ORDER BY name", { database: this.databaseName() }));
|
|
49
|
+
return new GlobPattern(pattern).apply(rows.map((row) => row.name), limit);
|
|
50
|
+
}
|
|
51
|
+
/** Identifier parameters: ClickHouse binds the table name itself, so nothing is interpolated. */
|
|
52
|
+
async describeObject(name) {
|
|
53
|
+
const { database, table } = this.locate(name);
|
|
54
|
+
await this.assertExists(database, table);
|
|
55
|
+
return this.run("DESCRIBE TABLE {database:Identifier}.{table:Identifier}", { database, table });
|
|
56
|
+
}
|
|
57
|
+
/** ClickHouse has sorting and primary keys plus skipping indices, rather than B-tree indexes. */
|
|
58
|
+
async listIndexes(name) {
|
|
59
|
+
const { database, table } = this.locate(name);
|
|
60
|
+
await this.assertExists(database, table);
|
|
61
|
+
const keys = await this.run(`SELECT partition_key, sorting_key, primary_key, sampling_key
|
|
62
|
+
FROM system.tables WHERE database = {database:String} AND name = {table:String}`, { database, table });
|
|
63
|
+
const skipping = await this.run(`SELECT name, type, expr, granularity
|
|
64
|
+
FROM system.data_skipping_indices WHERE database = {database:String} AND table = {table:String}`, { database, table });
|
|
65
|
+
return { keys: keys[0] ?? {}, data_skipping_indices: skipping };
|
|
66
|
+
}
|
|
67
|
+
async sample(name, limit) {
|
|
68
|
+
const { database, table } = this.locate(name);
|
|
69
|
+
return this.run("SELECT * FROM {database:Identifier}.{table:Identifier} LIMIT {limit:UInt32}", {
|
|
70
|
+
database,
|
|
71
|
+
table,
|
|
72
|
+
limit,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
query(sql) {
|
|
76
|
+
return this.run(sql);
|
|
77
|
+
}
|
|
78
|
+
async run(sql, params) {
|
|
79
|
+
const { client, settings } = await this.client.get();
|
|
80
|
+
const result = await client.query({
|
|
81
|
+
query: sql,
|
|
82
|
+
format: "JSONEachRow",
|
|
83
|
+
query_params: params,
|
|
84
|
+
clickhouse_settings: settings,
|
|
85
|
+
});
|
|
86
|
+
return (await result.json());
|
|
87
|
+
}
|
|
88
|
+
async assertExists(database, table) {
|
|
89
|
+
const rows = await this.run("SELECT 1 AS found FROM system.tables WHERE database = {database:String} AND name = {table:String}", { database, table });
|
|
90
|
+
if (rows.length === 0) {
|
|
91
|
+
throw new ObjectNotFoundError(this.objectNoun, table);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** `db.table` names a table in another database; a bare name means the connection's own. */
|
|
95
|
+
locate(name) {
|
|
96
|
+
const qualified = SqlIdentifier.parse(name);
|
|
97
|
+
return { database: qualified.schema ?? this.databaseName(), table: qualified.name };
|
|
98
|
+
}
|
|
99
|
+
databaseName() {
|
|
100
|
+
return this.target.database || EngineCatalog.describe("clickhouse").defaultDatabase;
|
|
101
|
+
}
|
|
102
|
+
/** Imported on first use; see MySqlDriver.createPool. */
|
|
103
|
+
async open() {
|
|
104
|
+
const { createClient, ClickHouseLogLevel } = await import("@clickhouse/client");
|
|
105
|
+
const secure = EngineCatalog.scheme(this.target.scheme).secure;
|
|
106
|
+
const client = createClient({
|
|
107
|
+
url: `${secure ? "https" : "http"}://${this.target.host}:${this.target.port}`,
|
|
108
|
+
username: this.target.user || "default",
|
|
109
|
+
password: this.target.password,
|
|
110
|
+
database: this.databaseName(),
|
|
111
|
+
application: "mcp-db-read-only",
|
|
112
|
+
max_open_connections: this.tuning.connectionLimit,
|
|
113
|
+
// Client-side, a little beyond the server-side limit, so the server's
|
|
114
|
+
// own timeout error is what the user sees.
|
|
115
|
+
request_timeout: this.tuning.queryTimeoutMs + 5000,
|
|
116
|
+
// The client's own logger writes debug and info through console.debug
|
|
117
|
+
// and console.info, which go to stdout, the JSON-RPC stream; one level
|
|
118
|
+
// change would corrupt the protocol. Its error lines also carry query
|
|
119
|
+
// context, which PRIVACY.md says is never logged. Every failure already
|
|
120
|
+
// reaches the caller as a thrown error, so nothing is lost.
|
|
121
|
+
log: { level: ClickHouseLogLevel.OFF },
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
return { client, settings: await this.negotiateSettings(client) };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
await client.close();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads the account's own `readonly` level with no settings attached, since
|
|
133
|
+
* attaching any is exactly what a read-only account refuses.
|
|
134
|
+
*/
|
|
135
|
+
async negotiateSettings(client) {
|
|
136
|
+
const result = await client.query({
|
|
137
|
+
query: "SELECT toUInt8(getSetting('readonly')) AS readonly",
|
|
138
|
+
format: "JSONEachRow",
|
|
139
|
+
});
|
|
140
|
+
const rows = (await result.json());
|
|
141
|
+
const level = Number(rows[0]?.readonly ?? 0);
|
|
142
|
+
const limits = {
|
|
143
|
+
max_execution_time: Math.max(1, Math.ceil(this.tuning.queryTimeoutMs / 1000)),
|
|
144
|
+
max_result_rows: String(ClickHouseDriver.MAX_RESULT_ROWS),
|
|
145
|
+
result_overflow_mode: "break",
|
|
146
|
+
};
|
|
147
|
+
if (level === 0) {
|
|
148
|
+
return { ...limits, readonly: "2" };
|
|
149
|
+
}
|
|
150
|
+
if (level === 2) {
|
|
151
|
+
return limits;
|
|
152
|
+
}
|
|
153
|
+
// readonly=1: the server already refuses writes, and refuses any setting.
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
|
|
2
|
+
import { BaseDriver } from "../BaseDriver.js";
|
|
3
|
+
import { GlobPattern } from "../GlobPattern.js";
|
|
4
|
+
import { LazyResource } from "../LazyResource.js";
|
|
5
|
+
import { SqlIdentifier } from "./SqlIdentifier.js";
|
|
6
|
+
/**
|
|
7
|
+
* SQL Server and Azure SQL, through the `mssql` package (tedious underneath).
|
|
8
|
+
*
|
|
9
|
+
* Read-only layer two: SQL Server has no read-only session mode, so **every
|
|
10
|
+
* batch runs inside a transaction that is always rolled back**. A write that
|
|
11
|
+
* got past the validator is undone before the connection is reused, and
|
|
12
|
+
* statements that refuse to run inside a transaction (BACKUP, ALTER DATABASE)
|
|
13
|
+
* fail outright. The validator separately refuses COMMIT, so the batch cannot
|
|
14
|
+
* end the transaction early.
|
|
15
|
+
*
|
|
16
|
+
* `?applicationIntent=ReadOnly` routes to a readable secondary in an
|
|
17
|
+
* availability group. It is opt-in: against a primary that disallows
|
|
18
|
+
* read-intent connections, forcing it would make every connection fail.
|
|
19
|
+
*/
|
|
20
|
+
export class MsSqlDriver extends BaseDriver {
|
|
21
|
+
tuning;
|
|
22
|
+
family = "sql";
|
|
23
|
+
dialect = "mssql";
|
|
24
|
+
static SYSTEM_DATABASES = new Set(["master", "tempdb", "model", "msdb"]);
|
|
25
|
+
pool;
|
|
26
|
+
constructor(target, tuning) {
|
|
27
|
+
super(target);
|
|
28
|
+
this.tuning = tuning;
|
|
29
|
+
this.pool = new LazyResource(() => this.createPool(), (opened) => opened.pool.close());
|
|
30
|
+
}
|
|
31
|
+
async verify() {
|
|
32
|
+
await this.run("SELECT 1 AS ok");
|
|
33
|
+
}
|
|
34
|
+
close() {
|
|
35
|
+
return this.pool.close();
|
|
36
|
+
}
|
|
37
|
+
async listDatabases() {
|
|
38
|
+
const rows = (await this.run("SELECT name FROM sys.databases WHERE state = 0 ORDER BY name"));
|
|
39
|
+
return rows.map((row) => ({ name: row.name, system: MsSqlDriver.SYSTEM_DATABASES.has(row.name) }));
|
|
40
|
+
}
|
|
41
|
+
/** `dbo` tables are listed bare and everything else schema-qualified. */
|
|
42
|
+
async listObjects(pattern, limit) {
|
|
43
|
+
const rows = (await this.run("SELECT TABLE_SCHEMA AS table_schema, TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA, TABLE_NAME"));
|
|
44
|
+
const names = rows.map((row) => row.table_schema === "dbo" ? row.table_name : `${row.table_schema}.${row.table_name}`);
|
|
45
|
+
return new GlobPattern(pattern).apply(names, limit);
|
|
46
|
+
}
|
|
47
|
+
async describeObject(name) {
|
|
48
|
+
const qualified = SqlIdentifier.parse(name);
|
|
49
|
+
const rows = await this.run(`SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, CHARACTER_MAXIMUM_LENGTH
|
|
50
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
51
|
+
WHERE TABLE_SCHEMA = COALESCE(@schema, SCHEMA_NAME()) AND TABLE_NAME = @table
|
|
52
|
+
ORDER BY ORDINAL_POSITION`, { schema: qualified.schema, table: qualified.name });
|
|
53
|
+
if (rows.length === 0) {
|
|
54
|
+
throw new ObjectNotFoundError(this.objectNoun, name);
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
async listIndexes(name) {
|
|
59
|
+
await this.assertExists(name);
|
|
60
|
+
return this.run(`SELECT i.name AS index_name, i.type_desc, i.is_unique, i.is_primary_key, c.name AS column_name,
|
|
61
|
+
ic.key_ordinal, ic.is_included_column
|
|
62
|
+
FROM sys.indexes i
|
|
63
|
+
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
64
|
+
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
65
|
+
WHERE i.object_id = OBJECT_ID(@object)
|
|
66
|
+
ORDER BY i.name, ic.key_ordinal`, { object: this.quote(name) });
|
|
67
|
+
}
|
|
68
|
+
async listForeignKeys(name) {
|
|
69
|
+
await this.assertExists(name);
|
|
70
|
+
return this.run(`SELECT fk.name AS constraint_name, pc.name AS column_name,
|
|
71
|
+
OBJECT_SCHEMA_NAME(fk.referenced_object_id) AS referenced_schema,
|
|
72
|
+
OBJECT_NAME(fk.referenced_object_id) AS referenced_table,
|
|
73
|
+
rc.name AS referenced_column
|
|
74
|
+
FROM sys.foreign_keys fk
|
|
75
|
+
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
|
76
|
+
JOIN sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
|
77
|
+
JOIN sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
|
78
|
+
WHERE fk.parent_object_id = OBJECT_ID(@object)
|
|
79
|
+
ORDER BY fk.name`, { object: this.quote(name) });
|
|
80
|
+
}
|
|
81
|
+
async sample(name, limit) {
|
|
82
|
+
return this.run(`SELECT TOP (@limit) * FROM ${this.quote(name)}`, { limit });
|
|
83
|
+
}
|
|
84
|
+
query(sql) {
|
|
85
|
+
return this.run(sql);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One batch inside a transaction that is always rolled back.
|
|
89
|
+
*
|
|
90
|
+
* If the batch itself fails, SQL Server may already have aborted the
|
|
91
|
+
* transaction, and the rollback then fails too. That second failure is
|
|
92
|
+
* swallowed: the batch's own error is the one worth reporting, and an
|
|
93
|
+
* aborted transaction has nothing left to undo.
|
|
94
|
+
*/
|
|
95
|
+
async run(text, inputs = {}) {
|
|
96
|
+
const { pool, sql } = await this.pool.get();
|
|
97
|
+
const transaction = new sql.Transaction(pool);
|
|
98
|
+
await transaction.begin();
|
|
99
|
+
try {
|
|
100
|
+
const request = new sql.Request(transaction);
|
|
101
|
+
for (const [name, value] of Object.entries(inputs)) {
|
|
102
|
+
request.input(name, value);
|
|
103
|
+
}
|
|
104
|
+
const result = await request.query(text);
|
|
105
|
+
return (result.recordset ?? []);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await transaction.rollback().catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async assertExists(name) {
|
|
112
|
+
const rows = await this.run("SELECT OBJECT_ID(@object) AS id", { object: this.quote(name) });
|
|
113
|
+
if (rows[0]?.id == null) {
|
|
114
|
+
throw new ObjectNotFoundError(this.objectNoun, name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
quote(name) {
|
|
118
|
+
return SqlIdentifier.quoteQualified(SqlIdentifier.parse(name), SqlIdentifier.bracket);
|
|
119
|
+
}
|
|
120
|
+
/** Imported on first use; see MySqlDriver.createPool. */
|
|
121
|
+
async createPool() {
|
|
122
|
+
const { default: sql } = (await import("mssql"));
|
|
123
|
+
const pool = new sql.ConnectionPool({
|
|
124
|
+
server: this.target.host,
|
|
125
|
+
port: this.target.port,
|
|
126
|
+
user: this.target.user,
|
|
127
|
+
password: this.target.password,
|
|
128
|
+
database: this.target.database || undefined,
|
|
129
|
+
connectionTimeout: this.tuning.connectTimeoutMs,
|
|
130
|
+
requestTimeout: this.tuning.queryTimeoutMs,
|
|
131
|
+
pool: { max: this.tuning.connectionLimit, min: 0 },
|
|
132
|
+
options: {
|
|
133
|
+
// Encrypted unless the URL says otherwise, which is also tedious's
|
|
134
|
+
// default. A self-signed development server needs
|
|
135
|
+
// trustServerCertificate=true.
|
|
136
|
+
encrypt: this.target.flag("encrypt") ?? true,
|
|
137
|
+
trustServerCertificate: this.target.flag("trustServerCertificate") ?? false,
|
|
138
|
+
readOnlyIntent: (this.target.option("applicationIntent") ?? "").toLowerCase() === "readonly",
|
|
139
|
+
appName: "mcp-db-read-only",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// Without a listener a dropped idle connection is an unhandled error.
|
|
143
|
+
pool.on("error", () => undefined);
|
|
144
|
+
await pool.connect();
|
|
145
|
+
return { pool, sql };
|
|
146
|
+
}
|
|
147
|
+
}
|