@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 { ConnectionManager } from "../connections/ConnectionManager.js";
|
|
2
|
+
import { EngineCatalog } from "../domain/Engine.js";
|
|
3
|
+
import { EngineMismatchError } from "../errors/EngineMismatchError.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves which connection a tool call belongs to, and hands back its driver.
|
|
6
|
+
*
|
|
7
|
+
* A Facade over the registry and the driver cache, so tools never learn how a
|
|
8
|
+
* target becomes a driver. It is also the single place a call decides which
|
|
9
|
+
* connection it belongs to, which is why the per-call `database` override
|
|
10
|
+
* costs one argument in each tool rather than a branch.
|
|
11
|
+
*/
|
|
12
|
+
export class DriverProvider {
|
|
13
|
+
registry;
|
|
14
|
+
cache;
|
|
15
|
+
queryTools;
|
|
16
|
+
constructor(registry, cache, queryTools) {
|
|
17
|
+
this.registry = registry;
|
|
18
|
+
this.cache = cache;
|
|
19
|
+
this.queryTools = queryTools;
|
|
20
|
+
}
|
|
21
|
+
/** @throws NoActiveConnectionError when nothing is configured yet. */
|
|
22
|
+
requireActiveTarget() {
|
|
23
|
+
return this.registry.requireActiveTarget();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* No argument means the active connection; a database name means the active
|
|
27
|
+
* connection pointed at that database for this call only.
|
|
28
|
+
*
|
|
29
|
+
* @throws UnsupportedOperationError on an engine with nothing to switch to.
|
|
30
|
+
*/
|
|
31
|
+
resolveTarget(database) {
|
|
32
|
+
const active = this.registry.requireActiveTarget();
|
|
33
|
+
if (!database) {
|
|
34
|
+
return active;
|
|
35
|
+
}
|
|
36
|
+
ConnectionManager.assertSwitchable(active);
|
|
37
|
+
return active.withDatabase(database);
|
|
38
|
+
}
|
|
39
|
+
acquire(target) {
|
|
40
|
+
return this.cache.acquire(target);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Checked against the engine catalog before the driver is acquired, so the
|
|
44
|
+
* mismatch is reported without constructing anything.
|
|
45
|
+
*
|
|
46
|
+
* @throws EngineMismatchError naming the tools that do fit the active engine.
|
|
47
|
+
*/
|
|
48
|
+
requireFamily(target, family, toolName) {
|
|
49
|
+
const engine = EngineCatalog.describe(target.engine);
|
|
50
|
+
if (engine.family === family) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
throw new EngineMismatchError(toolName, EngineCatalog.labelsFor(family), engine.label, this.queryTools[engine.family]);
|
|
54
|
+
}
|
|
55
|
+
async acquireSql(target, toolName) {
|
|
56
|
+
this.requireFamily(target, "sql", toolName);
|
|
57
|
+
return (await this.acquire(target));
|
|
58
|
+
}
|
|
59
|
+
async acquireDocument(target, toolName) {
|
|
60
|
+
this.requireFamily(target, "document", toolName);
|
|
61
|
+
return (await this.acquire(target));
|
|
62
|
+
}
|
|
63
|
+
async acquireKeyValue(target, toolName) {
|
|
64
|
+
this.requireFamily(target, "keyvalue", toolName);
|
|
65
|
+
return (await this.acquire(target));
|
|
66
|
+
}
|
|
67
|
+
async acquireSearch(target, toolName) {
|
|
68
|
+
this.requireFamily(target, "search", toolName);
|
|
69
|
+
return (await this.acquire(target));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which driver serves which engine: an Abstract Factory keyed by engine.
|
|
3
|
+
*
|
|
4
|
+
* The composition root registers one factory per engine. Nothing else in the
|
|
5
|
+
* server names a concrete driver class, which is what lets the connection and
|
|
6
|
+
* tool layers stay engine-agnostic, and lets tests register a fake.
|
|
7
|
+
*/
|
|
8
|
+
export class DriverRegistry {
|
|
9
|
+
factories = new Map();
|
|
10
|
+
register(engine, factory) {
|
|
11
|
+
this.factories.set(engine, factory);
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
create(target) {
|
|
15
|
+
const factory = this.factories.get(target.engine);
|
|
16
|
+
if (!factory) {
|
|
17
|
+
throw new Error(`No driver is registered for ${target.engine}.`);
|
|
18
|
+
}
|
|
19
|
+
return factory(target);
|
|
20
|
+
}
|
|
21
|
+
engines() {
|
|
22
|
+
return Array.from(this.factories.keys());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `pattern` argument of list_tables: `*` for any run of characters, `?`
|
|
3
|
+
* for one.
|
|
4
|
+
*
|
|
5
|
+
* Glob rather than SQL LIKE or regular expressions because it is the one
|
|
6
|
+
* syntax that means the same thing on every engine here. Redis SCAN MATCH and
|
|
7
|
+
* Elasticsearch index patterns take it natively; the SQL and MongoDB drivers
|
|
8
|
+
* apply it with this class after listing.
|
|
9
|
+
*/
|
|
10
|
+
export class GlobPattern {
|
|
11
|
+
expression;
|
|
12
|
+
constructor(pattern) {
|
|
13
|
+
this.expression = pattern ? GlobPattern.compile(pattern) : null;
|
|
14
|
+
}
|
|
15
|
+
matches(name) {
|
|
16
|
+
return this.expression ? this.expression.test(name) : true;
|
|
17
|
+
}
|
|
18
|
+
/** Filter, then cap, reporting whether the cap cut anything off. */
|
|
19
|
+
apply(names, limit) {
|
|
20
|
+
const matching = names.filter((name) => this.matches(name));
|
|
21
|
+
return {
|
|
22
|
+
names: matching.slice(0, limit),
|
|
23
|
+
truncated: matching.length > limit,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Case-insensitive, since table name case is rarely what someone means to filter on. */
|
|
27
|
+
static compile(pattern) {
|
|
28
|
+
const source = Array.from(pattern)
|
|
29
|
+
.map((char) => {
|
|
30
|
+
if (char === "*") {
|
|
31
|
+
return ".*";
|
|
32
|
+
}
|
|
33
|
+
if (char === "?") {
|
|
34
|
+
return ".";
|
|
35
|
+
}
|
|
36
|
+
return char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
})
|
|
38
|
+
.join("");
|
|
39
|
+
return new RegExp(`^${source}$`, "i");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Something expensive to open, opened once, on first use.
|
|
3
|
+
*
|
|
4
|
+
* Every driver holds its client in one of these. Two properties matter:
|
|
5
|
+
*
|
|
6
|
+
* - **A failed open is forgotten.** Without that, a server that was down when
|
|
7
|
+
* first asked would stay "down" in this process forever, because every later
|
|
8
|
+
* call would await the same rejected promise.
|
|
9
|
+
* - **Concurrent first calls share one open.** Two tool calls arriving
|
|
10
|
+
* together must not each open a pool and leak one of them.
|
|
11
|
+
*/
|
|
12
|
+
export class LazyResource {
|
|
13
|
+
open;
|
|
14
|
+
dispose;
|
|
15
|
+
pending = null;
|
|
16
|
+
constructor(open, dispose) {
|
|
17
|
+
this.open = open;
|
|
18
|
+
this.dispose = dispose;
|
|
19
|
+
}
|
|
20
|
+
get() {
|
|
21
|
+
if (!this.pending) {
|
|
22
|
+
const opening = this.open();
|
|
23
|
+
this.pending = opening;
|
|
24
|
+
opening.catch(() => {
|
|
25
|
+
// Compared rather than cleared unconditionally, so a failure of an
|
|
26
|
+
// old open cannot wipe out a newer one started after a reset.
|
|
27
|
+
if (this.pending === opening) {
|
|
28
|
+
this.pending = null;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return this.pending;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Drop the resource so the next `get` opens a fresh one, for when a client
|
|
36
|
+
* has died in a way it cannot recover from.
|
|
37
|
+
*/
|
|
38
|
+
reset() {
|
|
39
|
+
return this.close();
|
|
40
|
+
}
|
|
41
|
+
/** Never throws: shutdown must not stall on one broken connection. */
|
|
42
|
+
async close() {
|
|
43
|
+
const pending = this.pending;
|
|
44
|
+
this.pending = null;
|
|
45
|
+
if (!pending) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await this.dispose(await pending);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Already failed to open, or failed to close: either way nothing is
|
|
53
|
+
// left to release.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
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 { MongoSchemaSampler } from "./MongoSchemaSampler.js";
|
|
6
|
+
import { MongoStageAllowlist } from "./MongoStageAllowlist.js";
|
|
7
|
+
/**
|
|
8
|
+
* MongoDB, through the official driver.
|
|
9
|
+
*
|
|
10
|
+
* Read-only layer two is structural. This class calls only read operations
|
|
11
|
+
* (`find`, `aggregate`, `countDocuments`, `distinct`, the list commands and
|
|
12
|
+
* `ping`) and never a generic command, so there is no method here through
|
|
13
|
+
* which a write could be expressed. The one read operation that can write,
|
|
14
|
+
* `aggregate` with `$out` or `$merge`, passes MongoStageAllowlist first.
|
|
15
|
+
*
|
|
16
|
+
* Filters and pipelines arrive as Extended JSON, so `{"$oid": "..."}` and
|
|
17
|
+
* `{"$date": "..."}` work, and results go back the same way, so an ObjectId
|
|
18
|
+
* survives the round trip into a follow-up filter.
|
|
19
|
+
*/
|
|
20
|
+
export class MongoDriver extends BaseDriver {
|
|
21
|
+
tuning;
|
|
22
|
+
stages;
|
|
23
|
+
sampler;
|
|
24
|
+
family = "document";
|
|
25
|
+
static SYSTEM_DATABASES = new Set(["admin", "local", "config"]);
|
|
26
|
+
static SCHEMA_SAMPLE_SIZE = 100;
|
|
27
|
+
client;
|
|
28
|
+
constructor(target, tuning, stages = new MongoStageAllowlist(), sampler = new MongoSchemaSampler()) {
|
|
29
|
+
super(target);
|
|
30
|
+
this.tuning = tuning;
|
|
31
|
+
this.stages = stages;
|
|
32
|
+
this.sampler = sampler;
|
|
33
|
+
this.client = new LazyResource(() => this.open(), (opened) => opened.client.close());
|
|
34
|
+
}
|
|
35
|
+
async verify() {
|
|
36
|
+
const { client } = await this.client.get();
|
|
37
|
+
await client.db(this.target.database || "admin").command({ ping: 1 });
|
|
38
|
+
}
|
|
39
|
+
close() {
|
|
40
|
+
return this.client.close();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `authorizedDatabases` so a user with access to a few databases sees those,
|
|
44
|
+
* rather than an authorisation error for not being allowed to see them all.
|
|
45
|
+
*/
|
|
46
|
+
async listDatabases() {
|
|
47
|
+
const { client } = await this.client.get();
|
|
48
|
+
const result = await client.db("admin").admin().listDatabases({ nameOnly: true, authorizedDatabases: true });
|
|
49
|
+
return result.databases.map((entry) => ({
|
|
50
|
+
name: entry.name,
|
|
51
|
+
system: MongoDriver.SYSTEM_DATABASES.has(entry.name),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
async listObjects(pattern, limit) {
|
|
55
|
+
const database = await this.database();
|
|
56
|
+
const collections = await database
|
|
57
|
+
.listCollections({}, { nameOnly: true, authorizedCollections: true })
|
|
58
|
+
.toArray();
|
|
59
|
+
return new GlobPattern(pattern).apply(collections.map((entry) => entry.name).sort(), limit);
|
|
60
|
+
}
|
|
61
|
+
/** The inferred shape of a sample, plus any JSON Schema validator the collection declares. */
|
|
62
|
+
async describeObject(name) {
|
|
63
|
+
const database = await this.database();
|
|
64
|
+
const [info] = await database.listCollections({ name }).toArray();
|
|
65
|
+
if (!info) {
|
|
66
|
+
throw new ObjectNotFoundError(this.objectNoun, name);
|
|
67
|
+
}
|
|
68
|
+
const documents = await database
|
|
69
|
+
.collection(name)
|
|
70
|
+
.aggregate([{ $sample: { size: MongoDriver.SCHEMA_SAMPLE_SIZE } }], { maxTimeMS: this.tuning.queryTimeoutMs })
|
|
71
|
+
.toArray();
|
|
72
|
+
return {
|
|
73
|
+
collection: name,
|
|
74
|
+
sampled_documents: documents.length,
|
|
75
|
+
fields: this.sampler.infer(documents),
|
|
76
|
+
validator: info.options?.validator ?? null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async listIndexes(name) {
|
|
80
|
+
await this.assertCollection(name);
|
|
81
|
+
const database = await this.database();
|
|
82
|
+
return database.collection(name).indexes();
|
|
83
|
+
}
|
|
84
|
+
async sample(name, limit) {
|
|
85
|
+
await this.assertCollection(name);
|
|
86
|
+
return this.find(name, { filter: {}, limit, skip: 0 });
|
|
87
|
+
}
|
|
88
|
+
async find(collection, request) {
|
|
89
|
+
const { bson } = await this.client.get();
|
|
90
|
+
const database = await this.database();
|
|
91
|
+
const cursor = database.collection(collection).find(this.fromJson(bson, request.filter), {
|
|
92
|
+
projection: request.projection ? this.fromJson(bson, request.projection) : undefined,
|
|
93
|
+
sort: request.sort ? this.fromJson(bson, request.sort) : undefined,
|
|
94
|
+
limit: request.limit,
|
|
95
|
+
skip: request.skip,
|
|
96
|
+
maxTimeMS: this.tuning.queryTimeoutMs,
|
|
97
|
+
});
|
|
98
|
+
return this.toJson(bson, await cursor.toArray());
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reads at most `limit + 1` documents and closes the cursor, so a pipeline
|
|
102
|
+
* matching millions of documents costs one batch, and the extra document
|
|
103
|
+
* says whether there were more.
|
|
104
|
+
*/
|
|
105
|
+
async aggregate(collection, pipeline, limit) {
|
|
106
|
+
this.stages.assertAllowed(pipeline);
|
|
107
|
+
const { bson } = await this.client.get();
|
|
108
|
+
const database = await this.database();
|
|
109
|
+
const cursor = database.collection(collection).aggregate(pipeline.map((stage) => this.fromJson(bson, stage)), {
|
|
110
|
+
maxTimeMS: this.tuning.queryTimeoutMs,
|
|
111
|
+
allowDiskUse: false,
|
|
112
|
+
batchSize: limit + 1,
|
|
113
|
+
});
|
|
114
|
+
const rows = [];
|
|
115
|
+
try {
|
|
116
|
+
for await (const document of cursor) {
|
|
117
|
+
rows.push(document);
|
|
118
|
+
if (rows.length > limit) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await cursor.close();
|
|
125
|
+
}
|
|
126
|
+
return { rows: this.toJson(bson, rows.slice(0, limit)), truncated: rows.length > limit };
|
|
127
|
+
}
|
|
128
|
+
async count(collection, filter) {
|
|
129
|
+
const { bson } = await this.client.get();
|
|
130
|
+
const database = await this.database();
|
|
131
|
+
return database
|
|
132
|
+
.collection(collection)
|
|
133
|
+
.countDocuments(this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs });
|
|
134
|
+
}
|
|
135
|
+
async distinct(collection, field, filter) {
|
|
136
|
+
const { bson } = await this.client.get();
|
|
137
|
+
const database = await this.database();
|
|
138
|
+
const values = await database
|
|
139
|
+
.collection(collection)
|
|
140
|
+
.distinct(field, this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs });
|
|
141
|
+
return this.toJson(bson, values);
|
|
142
|
+
}
|
|
143
|
+
async database() {
|
|
144
|
+
const { client } = await this.client.get();
|
|
145
|
+
return client.db(this.requireDatabase());
|
|
146
|
+
}
|
|
147
|
+
async assertCollection(name) {
|
|
148
|
+
const database = await this.database();
|
|
149
|
+
const found = await database.listCollections({ name }, { nameOnly: true }).toArray();
|
|
150
|
+
if (found.length === 0) {
|
|
151
|
+
throw new ObjectNotFoundError(this.objectNoun, name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Extended JSON in: `{"$oid": ...}` becomes an ObjectId the server can match. */
|
|
155
|
+
fromJson(bson, value) {
|
|
156
|
+
return bson.EJSON.deserialize(value, { relaxed: true });
|
|
157
|
+
}
|
|
158
|
+
/** Relaxed Extended JSON out: numbers stay numbers, and ObjectIds stay recognisable. */
|
|
159
|
+
toJson(bson, values) {
|
|
160
|
+
return values.map((value) => bson.EJSON.serialize(value, { relaxed: true }));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The URL is rebuilt without credentials, which go through `auth` instead,
|
|
164
|
+
* so a password containing URL syntax never has to survive a round trip
|
|
165
|
+
* through encoding. Imported on first use; see MySqlDriver.createPool.
|
|
166
|
+
*/
|
|
167
|
+
async open() {
|
|
168
|
+
const mongodb = (await import("mongodb"));
|
|
169
|
+
const hosts = this.target.hosts
|
|
170
|
+
.map((entry) => (entry.port > 0 ? `${entry.host}:${entry.port}` : entry.host))
|
|
171
|
+
.join(",");
|
|
172
|
+
const query = new URLSearchParams({ ...this.target.options, ...this.target.secretOptions }).toString();
|
|
173
|
+
const url = `${this.target.scheme}://${hosts}/${query ? `?${query}` : ""}`;
|
|
174
|
+
const client = new mongodb.MongoClient(url, {
|
|
175
|
+
auth: this.target.user ? { username: this.target.user, password: this.target.password } : undefined,
|
|
176
|
+
// The database named in the URL is also where credentials are checked,
|
|
177
|
+
// unless authSource says otherwise, which is what a MongoDB URL means.
|
|
178
|
+
authSource: this.target.option("authSource") ?? (this.target.database || undefined),
|
|
179
|
+
appName: "mcp-db-read-only",
|
|
180
|
+
maxPoolSize: this.tuning.connectionLimit,
|
|
181
|
+
connectTimeoutMS: this.tuning.connectTimeoutMs,
|
|
182
|
+
serverSelectionTimeoutMS: this.tuning.connectTimeoutMs,
|
|
183
|
+
});
|
|
184
|
+
await client.connect();
|
|
185
|
+
return { client, bson: mongodb.BSON };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infers a collection's shape from a sample of its documents.
|
|
3
|
+
*
|
|
4
|
+
* MongoDB has no schema to read, so describe_table answers the question
|
|
5
|
+
* people actually ask of a table description ("what fields are there, and of
|
|
6
|
+
* what type") by looking at documents. The presence percentage matters as much
|
|
7
|
+
* as the types: a field present in 3% of documents is a very different thing
|
|
8
|
+
* to query on than one present in all of them.
|
|
9
|
+
*/
|
|
10
|
+
export class MongoSchemaSampler {
|
|
11
|
+
/** Deep enough for real documents without turning one describe into a dump. */
|
|
12
|
+
static MAX_DEPTH = 4;
|
|
13
|
+
infer(documents) {
|
|
14
|
+
const types = new Map();
|
|
15
|
+
const counts = new Map();
|
|
16
|
+
for (const document of documents) {
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
this.walk(document, "", 0, types, seen);
|
|
19
|
+
for (const path of seen) {
|
|
20
|
+
counts.set(path, (counts.get(path) ?? 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const total = Math.max(documents.length, 1);
|
|
24
|
+
return Array.from(types.entries())
|
|
25
|
+
.map(([path, found]) => ({
|
|
26
|
+
path,
|
|
27
|
+
types: Array.from(found).sort(),
|
|
28
|
+
presentPercent: Math.round(((counts.get(path) ?? 0) / total) * 100),
|
|
29
|
+
}))
|
|
30
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
31
|
+
}
|
|
32
|
+
walk(value, prefix, depth, types, seen) {
|
|
33
|
+
for (const [key, child] of Object.entries(value)) {
|
|
34
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
35
|
+
seen.add(path);
|
|
36
|
+
const found = types.get(path) ?? new Set();
|
|
37
|
+
found.add(this.typeOf(child));
|
|
38
|
+
types.set(path, found);
|
|
39
|
+
if (this.isPlainObject(child) && depth < MongoSchemaSampler.MAX_DEPTH) {
|
|
40
|
+
this.walk(child, path, depth + 1, types, seen);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* BSON types by their driver class name (ObjectId, Decimal128, Long), which
|
|
46
|
+
* is what a reader needs to write a correct filter against the field.
|
|
47
|
+
*/
|
|
48
|
+
typeOf(value) {
|
|
49
|
+
if (value === null) {
|
|
50
|
+
return "null";
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return "array";
|
|
54
|
+
}
|
|
55
|
+
if (value instanceof Date) {
|
|
56
|
+
return "date";
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "object") {
|
|
59
|
+
const bsonType = value._bsontype;
|
|
60
|
+
if (typeof bsonType === "string") {
|
|
61
|
+
return bsonType;
|
|
62
|
+
}
|
|
63
|
+
return "object";
|
|
64
|
+
}
|
|
65
|
+
return typeof value;
|
|
66
|
+
}
|
|
67
|
+
isPlainObject(value) {
|
|
68
|
+
return (value !== null &&
|
|
69
|
+
typeof value === "object" &&
|
|
70
|
+
!Array.isArray(value) &&
|
|
71
|
+
!(value instanceof Date) &&
|
|
72
|
+
typeof value._bsontype !== "string");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aggregation stages the MongoDB driver will send, and no others.
|
|
3
|
+
*
|
|
4
|
+
* Layer two for MongoDB, checked inside the driver immediately before
|
|
5
|
+
* `aggregate` is called. MongoDB has no read-only session mode, so the server
|
|
6
|
+
* cannot be asked to refuse a write; this list is the structural substitute.
|
|
7
|
+
* It is an allowlist of stages, deliberately a different mechanism from the
|
|
8
|
+
* tool-level MongoOperatorGuard (a denylist of operators), so the two do not
|
|
9
|
+
* share a blind spot.
|
|
10
|
+
*
|
|
11
|
+
* Nested pipelines are walked too: `$facet` holds several, and `$lookup` and
|
|
12
|
+
* `$unionWith` each hold one, any of which could otherwise carry a `$merge`.
|
|
13
|
+
*
|
|
14
|
+
* The honest limit of this layer: it constrains what this server sends. It is
|
|
15
|
+
* not a server-side guarantee, which is why the README recommends connecting
|
|
16
|
+
* with a user granted only the `read` role.
|
|
17
|
+
*/
|
|
18
|
+
export class MongoStageAllowlist {
|
|
19
|
+
static STAGES = new Set([
|
|
20
|
+
"$match",
|
|
21
|
+
"$project",
|
|
22
|
+
"$addFields",
|
|
23
|
+
"$set",
|
|
24
|
+
"$unset",
|
|
25
|
+
"$group",
|
|
26
|
+
"$sort",
|
|
27
|
+
"$limit",
|
|
28
|
+
"$skip",
|
|
29
|
+
"$unwind",
|
|
30
|
+
"$lookup",
|
|
31
|
+
"$graphLookup",
|
|
32
|
+
"$unionWith",
|
|
33
|
+
"$facet",
|
|
34
|
+
"$count",
|
|
35
|
+
"$sortByCount",
|
|
36
|
+
"$bucket",
|
|
37
|
+
"$bucketAuto",
|
|
38
|
+
"$replaceRoot",
|
|
39
|
+
"$replaceWith",
|
|
40
|
+
"$sample",
|
|
41
|
+
"$redact",
|
|
42
|
+
"$geoNear",
|
|
43
|
+
"$setWindowFields",
|
|
44
|
+
"$densify",
|
|
45
|
+
"$fill",
|
|
46
|
+
"$documents",
|
|
47
|
+
"$collStats",
|
|
48
|
+
"$indexStats",
|
|
49
|
+
"$search",
|
|
50
|
+
"$searchMeta",
|
|
51
|
+
"$vectorSearch",
|
|
52
|
+
]);
|
|
53
|
+
/** @throws Error naming the first stage that is not allowed. */
|
|
54
|
+
assertAllowed(pipeline) {
|
|
55
|
+
for (const stage of pipeline) {
|
|
56
|
+
if (!stage || typeof stage !== "object" || Array.isArray(stage)) {
|
|
57
|
+
throw new Error("Every pipeline stage must be an object such as {\"$match\": {...}}.");
|
|
58
|
+
}
|
|
59
|
+
const keys = Object.keys(stage);
|
|
60
|
+
if (keys.length !== 1) {
|
|
61
|
+
throw new Error("Every pipeline stage must have exactly one stage operator.");
|
|
62
|
+
}
|
|
63
|
+
const name = keys[0];
|
|
64
|
+
if (!MongoStageAllowlist.STAGES.has(name)) {
|
|
65
|
+
throw new Error(`The aggregation stage ${name} is not allowed on a read-only connection.`);
|
|
66
|
+
}
|
|
67
|
+
this.assertNested(name, stage[name]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
assertNested(name, body) {
|
|
71
|
+
if (!body || typeof body !== "object") {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (name === "$facet") {
|
|
75
|
+
for (const nested of Object.values(body)) {
|
|
76
|
+
this.assertAllowed(Array.isArray(nested) ? nested : [nested]);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (name === "$lookup" || name === "$unionWith") {
|
|
81
|
+
const nested = body.pipeline;
|
|
82
|
+
if (nested !== undefined) {
|
|
83
|
+
this.assertAllowed(Array.isArray(nested) ? nested : [nested]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks the Redis server whether a command is read-only before it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Layer two for Redis. The tool-level allowlist is a list written in this
|
|
5
|
+
* repository; this is the server's own classification of its commands, as
|
|
6
|
+
* reported by COMMAND INFO. A command is sent only if the server flags it
|
|
7
|
+
* `readonly` and does not flag it `write`. The two layers are independent: a
|
|
8
|
+
* command wrongly added to the allowlist is still refused here, and a server
|
|
9
|
+
* whose flags differ from what the allowlist assumed is caught too.
|
|
10
|
+
*
|
|
11
|
+
* It fails closed. When COMMAND itself is unavailable, renamed or denied by an
|
|
12
|
+
* ACL, the command is refused with a message saying why, rather than sent on
|
|
13
|
+
* the strength of the allowlist alone.
|
|
14
|
+
*/
|
|
15
|
+
export class RedisCommandFlagsGuard {
|
|
16
|
+
cache = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* @param name the canonical upper-case command
|
|
19
|
+
* @param subcommand for container commands such as OBJECT ENCODING
|
|
20
|
+
* @throws Error when the server does not confirm the command is read-only
|
|
21
|
+
*/
|
|
22
|
+
async assertReadOnly(call, name, subcommand) {
|
|
23
|
+
const flags = await this.flagsFor(call, name, subcommand);
|
|
24
|
+
const readOnly = flags.includes("readonly") && !flags.includes("write");
|
|
25
|
+
if (!readOnly) {
|
|
26
|
+
const label = subcommand ? `${name} ${subcommand}` : name;
|
|
27
|
+
throw new Error(`The server does not flag ${label} as read-only (flags: ${flags.join(", ") || "none"}), so it was not sent.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async flagsFor(call, name, subcommand) {
|
|
31
|
+
const key = subcommand ? `${name}|${subcommand}` : name;
|
|
32
|
+
const cached = this.cache.get(key);
|
|
33
|
+
if (cached) {
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
let reply;
|
|
37
|
+
try {
|
|
38
|
+
// Redis 7 answers `container|subcommand` directly; older servers only
|
|
39
|
+
// know the container, whose flags then stand for all its subcommands.
|
|
40
|
+
reply = await call("COMMAND", "INFO", subcommand ? `${name.toLowerCase()}|${subcommand.toLowerCase()}` : name);
|
|
41
|
+
if (subcommand && this.isEmpty(reply)) {
|
|
42
|
+
reply = await call("COMMAND", "INFO", name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new Error(`Could not confirm ${name} is read-only because COMMAND INFO failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
const flags = this.parseFlags(reply);
|
|
49
|
+
if (!flags) {
|
|
50
|
+
throw new Error(`The server does not know the command ${name}.`);
|
|
51
|
+
}
|
|
52
|
+
this.cache.set(key, flags);
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
/** COMMAND INFO replies `[[name, arity, [flags...], ...]]`, or `[null]` for an unknown command. */
|
|
56
|
+
parseFlags(reply) {
|
|
57
|
+
if (!Array.isArray(reply) || !Array.isArray(reply[0])) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const flags = reply[0][2];
|
|
61
|
+
if (!Array.isArray(flags)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return flags.map((flag) => String(flag).toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
isEmpty(reply) {
|
|
67
|
+
return !Array.isArray(reply) || reply[0] === null || reply[0] === undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|