@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,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** A handful of real rows, documents, hits, or the start of a Redis value. */
|
|
5
|
+
export class GetTableSampleTool extends DatabaseScopedTool {
|
|
6
|
+
name = "get_table_sample";
|
|
7
|
+
description = "Get sample rows from a table, documents from a collection, hits from an index, or the first entries of a Redis key";
|
|
8
|
+
static MIN_LIMIT = 1;
|
|
9
|
+
static MAX_LIMIT = 50;
|
|
10
|
+
annotations = {
|
|
11
|
+
title: "Get Table Sample",
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
destructiveHint: false,
|
|
14
|
+
idempotentHint: true,
|
|
15
|
+
openWorldHint: true,
|
|
16
|
+
};
|
|
17
|
+
inputSchema = {
|
|
18
|
+
table: z.string().describe("Table, collection, index, or Redis key"),
|
|
19
|
+
limit: z
|
|
20
|
+
.number()
|
|
21
|
+
.min(GetTableSampleTool.MIN_LIMIT)
|
|
22
|
+
.max(GetTableSampleTool.MAX_LIMIT)
|
|
23
|
+
.default(5)
|
|
24
|
+
.describe("Number of rows to return (1-50, default 5)"),
|
|
25
|
+
database: DatabaseScopedTool.databaseParam,
|
|
26
|
+
};
|
|
27
|
+
constructor(drivers, names) {
|
|
28
|
+
super(drivers, names);
|
|
29
|
+
}
|
|
30
|
+
async read(args, target) {
|
|
31
|
+
const rejection = this.validateObjectName(target, args.table, "table name");
|
|
32
|
+
if (rejection) {
|
|
33
|
+
return rejection;
|
|
34
|
+
}
|
|
35
|
+
const driver = await this.drivers.acquire(target);
|
|
36
|
+
return ToolResponse.json(await driver.sample(args.table, this.clampLimit(args.limit)));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Some drivers interpolate the limit, because not every engine accepts a
|
|
40
|
+
* placeholder in LIMIT.
|
|
41
|
+
*
|
|
42
|
+
* Clamping here duplicates the Zod constraint on purpose. The schema is
|
|
43
|
+
* enforced by the client before dispatch; clamping in the handler means a
|
|
44
|
+
* value reaching a query string cannot be anything but an integer in range,
|
|
45
|
+
* however it got here.
|
|
46
|
+
*/
|
|
47
|
+
clampLimit(limit) {
|
|
48
|
+
return Math.min(Math.max(Math.trunc(limit), GetTableSampleTool.MIN_LIMIT), GetTableSampleTool.MAX_LIMIT);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/** Lists tables, collections, keys or indices, whichever the engine has. */
|
|
6
|
+
export class ListTablesTool extends DatabaseScopedTool {
|
|
7
|
+
name = "list_tables";
|
|
8
|
+
description = "List the tables in the active database: collections on MongoDB, keys on Redis, indices on Elasticsearch";
|
|
9
|
+
/**
|
|
10
|
+
* Enough to see what is there on any real schema. The cap matters most on
|
|
11
|
+
* Redis, where "tables" are keys and a production instance holds millions.
|
|
12
|
+
*/
|
|
13
|
+
static MAX_NAMES = 1000;
|
|
14
|
+
annotations = {
|
|
15
|
+
title: "List Tables",
|
|
16
|
+
readOnlyHint: true,
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
idempotentHint: true,
|
|
19
|
+
openWorldHint: true,
|
|
20
|
+
};
|
|
21
|
+
inputSchema = {
|
|
22
|
+
pattern: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Optional glob filter, * for any characters and ? for one, e.g. user* or logs-2026-*"),
|
|
26
|
+
database: DatabaseScopedTool.databaseParam,
|
|
27
|
+
};
|
|
28
|
+
constructor(drivers, names) {
|
|
29
|
+
super(drivers, names);
|
|
30
|
+
}
|
|
31
|
+
async read(args, target) {
|
|
32
|
+
if (args.pattern) {
|
|
33
|
+
const rejection = this.reject(this.names.for(target.engine).validatePattern(args.pattern));
|
|
34
|
+
if (rejection) {
|
|
35
|
+
return rejection;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const driver = await this.drivers.acquire(target);
|
|
39
|
+
const listing = await driver.listObjects(args.pattern, ListTablesTool.MAX_NAMES);
|
|
40
|
+
const noun = EngineCatalog.describe(target.engine).objectNoun;
|
|
41
|
+
const heading = `${this.plural(noun)} (${listing.names.length}${listing.truncated ? "+" : ""})`;
|
|
42
|
+
const note = listing.truncated
|
|
43
|
+
? `\n\n--- Showing the first ${listing.names.length}. Pass a pattern to narrow the list. ---`
|
|
44
|
+
: "";
|
|
45
|
+
return ToolResponse.text(`${heading}:\n${listing.names.join("\n")}${note}`);
|
|
46
|
+
}
|
|
47
|
+
plural(noun) {
|
|
48
|
+
const capitalised = `${noun[0].toUpperCase()}${noun.slice(1)}`;
|
|
49
|
+
return noun === "index" ? "Indices" : `${capitalised}s`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BaseTool } from "../BaseTool.js";
|
|
3
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/**
|
|
6
|
+
* Opens an arbitrary server, on any engine, at runtime.
|
|
7
|
+
*
|
|
8
|
+
* This is the tool that makes a restart unnecessary in every case.
|
|
9
|
+
* DB_PROFILES is a convenience; this is the guarantee.
|
|
10
|
+
*/
|
|
11
|
+
export class ConnectTool extends BaseTool {
|
|
12
|
+
connections;
|
|
13
|
+
targetFactory;
|
|
14
|
+
names;
|
|
15
|
+
name = "connect";
|
|
16
|
+
description = "Connect to a database at runtime with a connection URL (mysql, mariadb, postgres, sqlite, mssql, clickhouse, mongodb, redis, elasticsearch, opensearch). Not persisted to disk, but kept for the rest of the session under an alias";
|
|
17
|
+
/**
|
|
18
|
+
* Not read-only: it opens a server and stores the alias for the session.
|
|
19
|
+
* Not destructive either, because nothing in any database changes, and
|
|
20
|
+
* re-running the same call lands on the same connection.
|
|
21
|
+
*/
|
|
22
|
+
annotations = {
|
|
23
|
+
title: "Connect to Database",
|
|
24
|
+
readOnlyHint: false,
|
|
25
|
+
destructiveHint: false,
|
|
26
|
+
idempotentHint: true,
|
|
27
|
+
openWorldHint: true,
|
|
28
|
+
};
|
|
29
|
+
/** Used when no alias is given, so repeated ad-hoc connections overwrite. */
|
|
30
|
+
static DEFAULT_ALIAS = "custom";
|
|
31
|
+
inputSchema = {
|
|
32
|
+
url: z
|
|
33
|
+
.string()
|
|
34
|
+
// The two most common setup mistakes, spelled out because a model
|
|
35
|
+
// reading this description usually gets them right unprompted:
|
|
36
|
+
// localhost inside a container, and a SQLite path without three slashes.
|
|
37
|
+
.describe("Connection URL, e.g. postgres://user@host:5432/db, mongodb://user@host/db, redis://host:6379/0, sqlite:///path/to/file.db. Use host.docker.internal for a database on this machine when running in Docker"),
|
|
38
|
+
password: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Password, if not in the URL. Avoids percent-encoding special characters"),
|
|
42
|
+
alias: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Name to remember this connection under for use_connection later"),
|
|
46
|
+
};
|
|
47
|
+
constructor(connections, targetFactory, names) {
|
|
48
|
+
super();
|
|
49
|
+
this.connections = connections;
|
|
50
|
+
this.targetFactory = targetFactory;
|
|
51
|
+
this.names = names;
|
|
52
|
+
}
|
|
53
|
+
async execute(args) {
|
|
54
|
+
const target = this.targetFactory.fromUrl(args.url, args.password);
|
|
55
|
+
// A SQLite target's database is its file path, which no name policy
|
|
56
|
+
// describes; everything else is checked like any other database name.
|
|
57
|
+
if (target.database && EngineCatalog.describe(target.engine).switchesDatabases) {
|
|
58
|
+
const check = this.names.for(target.engine).validateDatabase(target.database);
|
|
59
|
+
if (!check.valid) {
|
|
60
|
+
return ToolResponse.failure(check.error ?? "Invalid database name.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const alias = args.alias ?? ConnectTool.DEFAULT_ALIAS;
|
|
64
|
+
await this.connections.connect(target, alias);
|
|
65
|
+
return ToolResponse.text(`Connected as "${alias}" (${EngineCatalog.label(target.engine)}) -> ${target.describe()}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BaseTool } from "../BaseTool.js";
|
|
2
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** Reports the active connection, its engine and its database. */
|
|
5
|
+
export class CurrentConnectionTool extends BaseTool {
|
|
6
|
+
connections;
|
|
7
|
+
name = "current_connection";
|
|
8
|
+
description = "Show which database server, engine and database the read-only tools are currently pointed at";
|
|
9
|
+
annotations = {
|
|
10
|
+
title: "Show Current Connection",
|
|
11
|
+
readOnlyHint: true,
|
|
12
|
+
destructiveHint: false,
|
|
13
|
+
idempotentHint: true,
|
|
14
|
+
openWorldHint: true,
|
|
15
|
+
};
|
|
16
|
+
inputSchema = {};
|
|
17
|
+
constructor(connections) {
|
|
18
|
+
super();
|
|
19
|
+
this.connections = connections;
|
|
20
|
+
}
|
|
21
|
+
async execute() {
|
|
22
|
+
const target = this.connections.getActiveTarget();
|
|
23
|
+
// Reads the nullable accessor rather than the asserting one, so an
|
|
24
|
+
// unconfigured server reports its state as ordinary output instead of an
|
|
25
|
+
// error. Nothing has gone wrong; nothing has been chosen yet.
|
|
26
|
+
if (!target) {
|
|
27
|
+
return ToolResponse.text("No active connection. Call connect or use_connection to set one.");
|
|
28
|
+
}
|
|
29
|
+
// Rendering through describe() is what keeps the password out of output.
|
|
30
|
+
return ToolResponse.text([
|
|
31
|
+
`Active profile: ${this.connections.getActiveName()}`,
|
|
32
|
+
`Engine: ${EngineCatalog.label(target.engine)}`,
|
|
33
|
+
`Target: ${target.describe()}`,
|
|
34
|
+
].join("\n"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BaseTool } from "../BaseTool.js";
|
|
2
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
3
|
+
/**
|
|
4
|
+
* Lists the profiles available to switch to.
|
|
5
|
+
*
|
|
6
|
+
* Shows each profile's origin and engine: `(env)` versus `(session)` tells the
|
|
7
|
+
* reader whether the connection will still be there after a restart, and the
|
|
8
|
+
* engine says which query tools apply to it.
|
|
9
|
+
*/
|
|
10
|
+
export class ListConnectionsTool extends BaseTool {
|
|
11
|
+
connections;
|
|
12
|
+
name = "list_connections";
|
|
13
|
+
description = "List the connection profiles available to switch to, with each one's engine, including any added during this session";
|
|
14
|
+
annotations = {
|
|
15
|
+
title: "List Connection Profiles",
|
|
16
|
+
readOnlyHint: true,
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
idempotentHint: true,
|
|
19
|
+
openWorldHint: true,
|
|
20
|
+
};
|
|
21
|
+
inputSchema = {};
|
|
22
|
+
constructor(connections) {
|
|
23
|
+
super();
|
|
24
|
+
this.connections = connections;
|
|
25
|
+
}
|
|
26
|
+
async execute() {
|
|
27
|
+
const profiles = this.connections.listProfiles();
|
|
28
|
+
if (profiles.length === 0) {
|
|
29
|
+
return ToolResponse.text("No profiles configured. Use connect to open one directly.");
|
|
30
|
+
}
|
|
31
|
+
const activeName = this.connections.getActiveName();
|
|
32
|
+
// The profile renders its own line, including the "* " active marker, so
|
|
33
|
+
// the display rule lives with the entity rather than being duplicated by
|
|
34
|
+
// every caller that wants to show a profile.
|
|
35
|
+
const lines = profiles.map((profile) => profile.describe(profile.name === activeName));
|
|
36
|
+
return ToolResponse.text(`Connection profiles:\n${lines.join("\n")}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BaseTool } from "../BaseTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** Lists databases on the connected server, marking the active one. */
|
|
5
|
+
export class ListDatabasesTool extends BaseTool {
|
|
6
|
+
drivers;
|
|
7
|
+
name = "list_databases";
|
|
8
|
+
description = "List databases on the currently connected server (schemas on MySQL, numbered databases on Redis)";
|
|
9
|
+
annotations = {
|
|
10
|
+
title: "List Databases",
|
|
11
|
+
readOnlyHint: true,
|
|
12
|
+
destructiveHint: false,
|
|
13
|
+
idempotentHint: true,
|
|
14
|
+
openWorldHint: true,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* System databases are noise in almost every session and make it harder to
|
|
18
|
+
* spot the one you want. `include_system` exists because inspecting them is
|
|
19
|
+
* occasionally the real task.
|
|
20
|
+
*/
|
|
21
|
+
inputSchema = {
|
|
22
|
+
include_system: z
|
|
23
|
+
.boolean()
|
|
24
|
+
.default(false)
|
|
25
|
+
.describe("Include system databases such as information_schema, pg templates, or MongoDB admin and local"),
|
|
26
|
+
};
|
|
27
|
+
constructor(drivers) {
|
|
28
|
+
super();
|
|
29
|
+
this.drivers = drivers;
|
|
30
|
+
}
|
|
31
|
+
async execute(args) {
|
|
32
|
+
const target = this.drivers.requireActiveTarget();
|
|
33
|
+
const driver = await this.drivers.acquire(target);
|
|
34
|
+
const entries = await driver.listDatabases();
|
|
35
|
+
const names = entries
|
|
36
|
+
.filter((entry) => args.include_system || !entry.system)
|
|
37
|
+
.map((entry) => entry.name);
|
|
38
|
+
const lines = names.map((name) => (name === target.database ? `* ${name}` : ` ${name}`));
|
|
39
|
+
return ToolResponse.text(`Databases (${names.length}):\n${lines.join("\n")}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BaseTool } from "../BaseTool.js";
|
|
3
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/**
|
|
6
|
+
* Switches to a named profile, optionally overriding its database.
|
|
7
|
+
*
|
|
8
|
+
* The override composes two ideas that would otherwise take two calls: "go to
|
|
9
|
+
* staging, but the analytics database".
|
|
10
|
+
*/
|
|
11
|
+
export class UseConnectionTool extends BaseTool {
|
|
12
|
+
connections;
|
|
13
|
+
names;
|
|
14
|
+
name = "use_connection";
|
|
15
|
+
description = "Switch to a named connection profile, on any engine. Takes effect immediately, no restart needed";
|
|
16
|
+
inputSchema = {
|
|
17
|
+
profile: z.string().describe("Profile name from list_connections"),
|
|
18
|
+
database: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Optional database to use instead of the profile's own database"),
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Repoints the reading tools at another server, so not read-only. No data is
|
|
25
|
+
* altered and repeating the call is a no-op, hence not destructive and
|
|
26
|
+
* idempotent.
|
|
27
|
+
*/
|
|
28
|
+
annotations = {
|
|
29
|
+
title: "Switch Connection Profile",
|
|
30
|
+
readOnlyHint: false,
|
|
31
|
+
destructiveHint: false,
|
|
32
|
+
idempotentHint: true,
|
|
33
|
+
openWorldHint: true,
|
|
34
|
+
};
|
|
35
|
+
constructor(connections, names) {
|
|
36
|
+
super();
|
|
37
|
+
this.connections = connections;
|
|
38
|
+
this.names = names;
|
|
39
|
+
}
|
|
40
|
+
async execute(args) {
|
|
41
|
+
// The override is checked against the profile's own engine, which may not
|
|
42
|
+
// be the active one: switching from MySQL to a Redis profile with
|
|
43
|
+
// database "3" is valid, and MySQL's rules would refuse nothing useful.
|
|
44
|
+
const profile = this.connections.findProfile(args.profile);
|
|
45
|
+
if (args.database && profile) {
|
|
46
|
+
const check = this.names.for(profile.target.engine).validateDatabase(args.database);
|
|
47
|
+
if (!check.valid) {
|
|
48
|
+
return ToolResponse.failure(check.error ?? "Invalid database name.");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// An unknown profile throws UnknownProfileError, which carries the known
|
|
52
|
+
// names; BaseTool turns it into a tool error. When someone mistypes a
|
|
53
|
+
// profile, seeing the real list is the fastest route to the fix.
|
|
54
|
+
const target = await this.connections.useProfile(args.profile, args.database);
|
|
55
|
+
return ToolResponse.text(`Switched to profile "${args.profile}" (${EngineCatalog.label(target.engine)}) -> ${target.describe()}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BaseTool } from "../BaseTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* Switches database on the current server.
|
|
6
|
+
*
|
|
7
|
+
* The verify-then-commit ordering lives in ConnectionManager, so this tool
|
|
8
|
+
* cannot get it wrong: a nonexistent database fails the call and leaves the
|
|
9
|
+
* previous connection working.
|
|
10
|
+
*/
|
|
11
|
+
export class UseDatabaseTool extends BaseTool {
|
|
12
|
+
connections;
|
|
13
|
+
names;
|
|
14
|
+
name = "use_database";
|
|
15
|
+
description = "Switch the active database on the current server. Takes effect immediately, no restart needed";
|
|
16
|
+
inputSchema = {
|
|
17
|
+
database: z.string().describe("Database name to switch to, or the database number on Redis"),
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Changes which database the reading tools see, so not read-only. Nothing
|
|
21
|
+
* in the database is touched, and switching twice to the same name leaves
|
|
22
|
+
* the same state, hence not destructive and idempotent.
|
|
23
|
+
*/
|
|
24
|
+
annotations = {
|
|
25
|
+
title: "Switch Database",
|
|
26
|
+
readOnlyHint: false,
|
|
27
|
+
destructiveHint: false,
|
|
28
|
+
idempotentHint: true,
|
|
29
|
+
openWorldHint: true,
|
|
30
|
+
};
|
|
31
|
+
constructor(connections, names) {
|
|
32
|
+
super();
|
|
33
|
+
this.connections = connections;
|
|
34
|
+
this.names = names;
|
|
35
|
+
}
|
|
36
|
+
async execute(args) {
|
|
37
|
+
const active = this.connections.requireActiveTarget();
|
|
38
|
+
const check = this.names.for(active.engine).validateDatabase(args.database);
|
|
39
|
+
if (!check.valid) {
|
|
40
|
+
return ToolResponse.failure(check.error ?? "Invalid database name.");
|
|
41
|
+
}
|
|
42
|
+
const target = await this.connections.useDatabase(args.database);
|
|
43
|
+
return ToolResponse.text(`Switched to ${target.describe()}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { DocumentTool } from "./DocumentTool.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/**
|
|
6
|
+
* A MongoDB aggregation pipeline: MongoDB's equivalent of run_query.
|
|
7
|
+
*
|
|
8
|
+
* Checked twice, by two different mechanisms: MongoOperatorGuard here refuses
|
|
9
|
+
* `$out`, `$merge` and server-side JavaScript anywhere in the pipeline, and
|
|
10
|
+
* the driver's MongoStageAllowlist refuses any stage it does not recognise as
|
|
11
|
+
* a read.
|
|
12
|
+
*/
|
|
13
|
+
export class AggregateTool extends DocumentTool {
|
|
14
|
+
rows;
|
|
15
|
+
name = "aggregate";
|
|
16
|
+
description = "Run a read-only MongoDB aggregation pipeline on a collection ($out and $merge are not allowed)";
|
|
17
|
+
annotations = {
|
|
18
|
+
title: "Run Aggregation Pipeline",
|
|
19
|
+
readOnlyHint: true,
|
|
20
|
+
destructiveHint: false,
|
|
21
|
+
idempotentHint: true,
|
|
22
|
+
openWorldHint: true,
|
|
23
|
+
};
|
|
24
|
+
inputSchema = {
|
|
25
|
+
collection: DocumentTool.collectionParam,
|
|
26
|
+
pipeline: z
|
|
27
|
+
.array(z.record(z.unknown()))
|
|
28
|
+
.describe('Pipeline stages as Extended JSON, e.g. [{"$match": {"status": "active"}}, {"$group": {"_id": "$type", "n": {"$sum": 1}}}]'),
|
|
29
|
+
database: DatabaseScopedTool.databaseParam,
|
|
30
|
+
};
|
|
31
|
+
constructor(drivers, names, guard, rows) {
|
|
32
|
+
super(drivers, names, guard);
|
|
33
|
+
this.rows = rows;
|
|
34
|
+
}
|
|
35
|
+
async read(args, target) {
|
|
36
|
+
const rejection = this.checkInputs(target, args.collection, { pipeline: args.pipeline });
|
|
37
|
+
if (rejection) {
|
|
38
|
+
return rejection;
|
|
39
|
+
}
|
|
40
|
+
const driver = await this.drivers.acquireDocument(target, this.name);
|
|
41
|
+
const result = await driver.aggregate(args.collection, args.pipeline, this.rows.limit);
|
|
42
|
+
return ToolResponse.text(this.rows.format(result.rows, "Add a $limit stage or a narrower $match", result.truncated));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
2
|
+
import { DocumentTool } from "./DocumentTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/** An exact count of matching documents. */
|
|
5
|
+
export class CountDocumentsTool extends DocumentTool {
|
|
6
|
+
name = "count_documents";
|
|
7
|
+
description = "Count documents in a MongoDB collection that match a filter";
|
|
8
|
+
annotations = {
|
|
9
|
+
title: "Count Documents",
|
|
10
|
+
readOnlyHint: true,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: true,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
};
|
|
15
|
+
inputSchema = {
|
|
16
|
+
collection: DocumentTool.collectionParam,
|
|
17
|
+
filter: DocumentTool.filterParam,
|
|
18
|
+
database: DatabaseScopedTool.databaseParam,
|
|
19
|
+
};
|
|
20
|
+
constructor(drivers, names, guard) {
|
|
21
|
+
super(drivers, names, guard);
|
|
22
|
+
}
|
|
23
|
+
async read(args, target) {
|
|
24
|
+
const rejection = this.checkInputs(target, args.collection, { filter: args.filter });
|
|
25
|
+
if (rejection) {
|
|
26
|
+
return rejection;
|
|
27
|
+
}
|
|
28
|
+
const driver = await this.drivers.acquireDocument(target, this.name);
|
|
29
|
+
const count = await driver.count(args.collection, args.filter);
|
|
30
|
+
return ToolResponse.text(`${count} matching document${count === 1 ? "" : "s"} in "${args.collection}".`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { DocumentTool } from "./DocumentTool.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/** The distinct values of one field: how people find out what a status column holds. */
|
|
6
|
+
export class DistinctValuesTool extends DocumentTool {
|
|
7
|
+
rows;
|
|
8
|
+
name = "distinct_values";
|
|
9
|
+
description = "List the distinct values of a field in a MongoDB collection, optionally within a filter";
|
|
10
|
+
annotations = {
|
|
11
|
+
title: "List Distinct Values",
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
destructiveHint: false,
|
|
14
|
+
idempotentHint: true,
|
|
15
|
+
openWorldHint: true,
|
|
16
|
+
};
|
|
17
|
+
inputSchema = {
|
|
18
|
+
collection: DocumentTool.collectionParam,
|
|
19
|
+
field: z.string().min(1).describe("Field path, e.g. status or address.city"),
|
|
20
|
+
filter: DocumentTool.filterParam,
|
|
21
|
+
database: DatabaseScopedTool.databaseParam,
|
|
22
|
+
};
|
|
23
|
+
constructor(drivers, names, guard, rows) {
|
|
24
|
+
super(drivers, names, guard);
|
|
25
|
+
this.rows = rows;
|
|
26
|
+
}
|
|
27
|
+
async read(args, target) {
|
|
28
|
+
const rejection = this.checkInputs(target, args.collection, { filter: args.filter });
|
|
29
|
+
if (rejection) {
|
|
30
|
+
return rejection;
|
|
31
|
+
}
|
|
32
|
+
const driver = await this.drivers.acquireDocument(target, this.name);
|
|
33
|
+
const values = await driver.distinct(args.collection, args.field, args.filter);
|
|
34
|
+
return ToolResponse.text(this.rows.format(values, "Narrow the filter"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
/**
|
|
4
|
+
* Base for the MongoDB query tools.
|
|
5
|
+
*
|
|
6
|
+
* Holds what all four share: the collection argument, the family check, and
|
|
7
|
+
* the operator guard every filter and pipeline passes through before the
|
|
8
|
+
* driver sees it.
|
|
9
|
+
*/
|
|
10
|
+
export class DocumentTool extends DatabaseScopedTool {
|
|
11
|
+
guard;
|
|
12
|
+
family = "document";
|
|
13
|
+
static collectionParam = z.string().describe("Collection name");
|
|
14
|
+
static filterParam = z
|
|
15
|
+
.record(z.unknown())
|
|
16
|
+
.default({})
|
|
17
|
+
.describe('Query filter as MongoDB Extended JSON, e.g. {"status": "active", "_id": {"$oid": "..."}}');
|
|
18
|
+
constructor(drivers, names, guard) {
|
|
19
|
+
super(drivers, names);
|
|
20
|
+
this.guard = guard;
|
|
21
|
+
}
|
|
22
|
+
/** The collection name and every filter or pipeline, checked in one place. */
|
|
23
|
+
checkInputs(target, collection, inputs) {
|
|
24
|
+
const rejection = this.validateObjectName(target, collection, "collection name");
|
|
25
|
+
if (rejection) {
|
|
26
|
+
return rejection;
|
|
27
|
+
}
|
|
28
|
+
for (const [label, value] of Object.entries(inputs)) {
|
|
29
|
+
if (value !== undefined) {
|
|
30
|
+
const objection = this.reject(this.guard.validate(value, label));
|
|
31
|
+
if (objection) {
|
|
32
|
+
return objection;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { DocumentTool } from "./DocumentTool.js";
|
|
4
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
5
|
+
/** MongoDB's `find`: the everyday query. */
|
|
6
|
+
export class FindDocumentsTool extends DocumentTool {
|
|
7
|
+
rows;
|
|
8
|
+
name = "find_documents";
|
|
9
|
+
description = "Find documents in a MongoDB collection with a filter, optional projection and sort";
|
|
10
|
+
static MAX_LIMIT = 100;
|
|
11
|
+
annotations = {
|
|
12
|
+
title: "Find Documents",
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
destructiveHint: false,
|
|
15
|
+
idempotentHint: true,
|
|
16
|
+
openWorldHint: true,
|
|
17
|
+
};
|
|
18
|
+
inputSchema = {
|
|
19
|
+
collection: DocumentTool.collectionParam,
|
|
20
|
+
filter: DocumentTool.filterParam,
|
|
21
|
+
projection: z.record(z.unknown()).optional().describe('Fields to include or exclude, e.g. {"name": 1, "_id": 0}'),
|
|
22
|
+
sort: z.record(z.unknown()).optional().describe('Sort order, e.g. {"createdAt": -1}'),
|
|
23
|
+
limit: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(FindDocumentsTool.MAX_LIMIT)
|
|
28
|
+
.default(20)
|
|
29
|
+
.describe("Maximum documents to return (1-100, default 20)"),
|
|
30
|
+
skip: z.number().int().min(0).default(0).describe("Documents to skip, for paging"),
|
|
31
|
+
database: DatabaseScopedTool.databaseParam,
|
|
32
|
+
};
|
|
33
|
+
constructor(drivers, names, guard, rows) {
|
|
34
|
+
super(drivers, names, guard);
|
|
35
|
+
this.rows = rows;
|
|
36
|
+
}
|
|
37
|
+
async read(args, target) {
|
|
38
|
+
const rejection = this.checkInputs(target, args.collection, {
|
|
39
|
+
filter: args.filter,
|
|
40
|
+
projection: args.projection,
|
|
41
|
+
sort: args.sort,
|
|
42
|
+
});
|
|
43
|
+
if (rejection) {
|
|
44
|
+
return rejection;
|
|
45
|
+
}
|
|
46
|
+
const driver = await this.drivers.acquireDocument(target, this.name);
|
|
47
|
+
const documents = await driver.find(args.collection, {
|
|
48
|
+
filter: args.filter,
|
|
49
|
+
projection: args.projection,
|
|
50
|
+
sort: args.sort,
|
|
51
|
+
limit: Math.min(args.limit, FindDocumentsTool.MAX_LIMIT),
|
|
52
|
+
skip: args.skip,
|
|
53
|
+
});
|
|
54
|
+
return ToolResponse.text(this.rows.format(documents, "Narrow the filter or lower the limit"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* One read-only Redis command: Redis's equivalent of run_query.
|
|
6
|
+
*
|
|
7
|
+
* The command name and its arguments are separate fields rather than one
|
|
8
|
+
* string, so an argument containing a space or a quote is never re-split,
|
|
9
|
+
* and there is no command-line syntax to get wrong.
|
|
10
|
+
*/
|
|
11
|
+
export class RedisCommandTool extends DatabaseScopedTool {
|
|
12
|
+
validator;
|
|
13
|
+
name = "redis_command";
|
|
14
|
+
description = "Run one read-only Redis command such as GET, HGETALL, LRANGE, ZRANGE, SCAN, TTL or INFO. Write commands and KEYS are refused";
|
|
15
|
+
family = "keyvalue";
|
|
16
|
+
annotations = {
|
|
17
|
+
title: "Run Read-Only Redis Command",
|
|
18
|
+
readOnlyHint: true,
|
|
19
|
+
destructiveHint: false,
|
|
20
|
+
idempotentHint: true,
|
|
21
|
+
openWorldHint: true,
|
|
22
|
+
};
|
|
23
|
+
inputSchema = {
|
|
24
|
+
command: z.string().describe("Command name, e.g. HGETALL"),
|
|
25
|
+
args: z.array(z.string()).default([]).describe('Arguments, e.g. ["user:42"]'),
|
|
26
|
+
database: DatabaseScopedTool.databaseParam,
|
|
27
|
+
};
|
|
28
|
+
constructor(drivers, names, validator) {
|
|
29
|
+
super(drivers, names);
|
|
30
|
+
this.validator = validator;
|
|
31
|
+
}
|
|
32
|
+
async read(args, target) {
|
|
33
|
+
const check = this.validator.validate(args.command, args.args);
|
|
34
|
+
if (!check.valid || !check.name) {
|
|
35
|
+
return ToolResponse.failure(check.error ?? "Command rejected.");
|
|
36
|
+
}
|
|
37
|
+
const driver = await this.drivers.acquireKeyValue(target, this.name);
|
|
38
|
+
return ToolResponse.json(await driver.command(check.name, args.args));
|
|
39
|
+
}
|
|
40
|
+
}
|