@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,179 @@
|
|
|
1
|
+
import { ConnectionProfile } from "../domain/ConnectionProfile.js";
|
|
2
|
+
import { ConnectionTargetFactory } from "../connections/ConnectionTargetFactory.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads configuration from environment variables.
|
|
5
|
+
*
|
|
6
|
+
* Implements ConfigurationLoader so tests, and any future file or remote
|
|
7
|
+
* source, can be substituted without touching anything downstream.
|
|
8
|
+
*
|
|
9
|
+
* Two families of variables are read, in this order of precedence:
|
|
10
|
+
*
|
|
11
|
+
* 1. `DB_PROFILES` and `DB_URL`, the native form: connection URLs for any
|
|
12
|
+
* engine.
|
|
13
|
+
* 2. `MYSQL_PROFILES` and `MYSQL_HOST`/`USER`/`DATABASE`, the configuration
|
|
14
|
+
* of the MySQL-only server this one generalises. Read so that an existing
|
|
15
|
+
* setup keeps working unchanged when the image is swapped. A profile name
|
|
16
|
+
* already defined by the native form wins.
|
|
17
|
+
*
|
|
18
|
+
* Two properties are deliberate:
|
|
19
|
+
*
|
|
20
|
+
* - **Nothing is logged here.** Problems are returned as `warnings` and the
|
|
21
|
+
* composition root decides where they go. That keeps the loader pure, and
|
|
22
|
+
* lets tests assert on warnings instead of intercepting console output.
|
|
23
|
+
* - **Nothing is fatal.** Malformed JSON, a broken profile, or no
|
|
24
|
+
* configuration at all all produce a usable result. A server that starts and
|
|
25
|
+
* explains the problem can be fixed with the connect tool; one that exits
|
|
26
|
+
* during handshake is reported by the client as a broken install.
|
|
27
|
+
*/
|
|
28
|
+
export class EnvironmentConfigLoader {
|
|
29
|
+
env;
|
|
30
|
+
targetFactory;
|
|
31
|
+
static DEFAULT_QUERY_TIMEOUT_MS = 30000;
|
|
32
|
+
static DEFAULT_CONNECT_TIMEOUT_MS = 10000;
|
|
33
|
+
constructor(env = process.env, targetFactory = new ConnectionTargetFactory()) {
|
|
34
|
+
this.env = env;
|
|
35
|
+
this.targetFactory = targetFactory;
|
|
36
|
+
}
|
|
37
|
+
load() {
|
|
38
|
+
const warnings = [];
|
|
39
|
+
const profiles = new Map();
|
|
40
|
+
this.loadUrlProfiles(profiles, warnings);
|
|
41
|
+
this.loadSingleUrl(profiles, warnings);
|
|
42
|
+
this.loadLegacyMySqlProfiles(profiles, warnings);
|
|
43
|
+
this.loadLegacyMySqlConnection(profiles);
|
|
44
|
+
return {
|
|
45
|
+
profiles: Array.from(profiles.values()),
|
|
46
|
+
defaultProfileName: this.env.DB_DEFAULT_PROFILE || this.env.MYSQL_DEFAULT_PROFILE || null,
|
|
47
|
+
queryTimeoutMs: this.readTimeout(this.env.DB_QUERY_TIMEOUT_MS ?? this.env.MYSQL_QUERY_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_QUERY_TIMEOUT_MS),
|
|
48
|
+
connectTimeoutMs: this.readTimeout(this.env.DB_CONNECT_TIMEOUT_MS ?? this.env.MYSQL_CONNECT_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_CONNECT_TIMEOUT_MS),
|
|
49
|
+
warnings,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* DB_PROFILES: `{"name": "url"}` or `{"name": {"url": "...", "password": "..."}}`.
|
|
54
|
+
*
|
|
55
|
+
* The object form exists for passwords. A password inside a URL must be
|
|
56
|
+
* percent-encoded, and one containing `@`, `/` or `#` silently splits the URL
|
|
57
|
+
* in the wrong place when it is not. Giving it its own field removes that
|
|
58
|
+
* whole class of mistake.
|
|
59
|
+
*/
|
|
60
|
+
loadUrlProfiles(target, warnings) {
|
|
61
|
+
const parsed = this.readJsonObject("DB_PROFILES", warnings);
|
|
62
|
+
if (!parsed) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const [name, definition] of Object.entries(parsed)) {
|
|
66
|
+
const entry = this.readUrlDefinition(definition);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
warnings.push(`Skipping profile "${name}": expected a URL string or an object with "url".`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const connection = this.targetFactory.fromUrl(entry.url, entry.password);
|
|
73
|
+
target.set(name, ConnectionProfile.fromEnvironment(name, connection));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
warnings.push(`Skipping profile "${name}": ${this.reason(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** DB_URL, with DB_PASSWORD as its optional separate password, as `default`. */
|
|
81
|
+
loadSingleUrl(target, warnings) {
|
|
82
|
+
const url = this.env.DB_URL;
|
|
83
|
+
if (!url || target.has("default")) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const connection = this.targetFactory.fromUrl(url, this.env.DB_PASSWORD);
|
|
88
|
+
target.set("default", ConnectionProfile.fromEnvironment("default", connection));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
warnings.push(`Ignoring DB_URL: ${this.reason(error)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The legacy MYSQL_PROFILES object form, exactly as the MySQL-only server read it. */
|
|
95
|
+
loadLegacyMySqlProfiles(target, warnings) {
|
|
96
|
+
const parsed = this.readJsonObject("MYSQL_PROFILES", warnings);
|
|
97
|
+
if (!parsed) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const [name, definition] of Object.entries(parsed)) {
|
|
101
|
+
if (target.has(name)) {
|
|
102
|
+
warnings.push(`Skipping MYSQL_PROFILES "${name}": DB_PROFILES already defines it.`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
|
|
106
|
+
warnings.push(`Skipping profile "${name}": not an object.`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const connection = this.targetFactory.fromMySqlDefinition(definition, name);
|
|
111
|
+
target.set(name, ConnectionProfile.fromEnvironment(name, connection));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
warnings.push(`Skipping profile "${name}": ${this.reason(error)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The MYSQL_HOST/USER/DATABASE form, registered as a profile named `default`
|
|
120
|
+
* unless something above already claimed that name.
|
|
121
|
+
*/
|
|
122
|
+
loadLegacyMySqlConnection(target) {
|
|
123
|
+
const user = this.env.MYSQL_USER;
|
|
124
|
+
const database = this.env.MYSQL_DATABASE;
|
|
125
|
+
if (!user || !database || target.has("default")) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const connection = this.targetFactory.fromMySqlValues({
|
|
129
|
+
host: this.env.MYSQL_HOST,
|
|
130
|
+
port: this.env.MYSQL_PORT ? Number.parseInt(this.env.MYSQL_PORT, 10) : undefined,
|
|
131
|
+
user,
|
|
132
|
+
password: this.env.MYSQL_PASSWORD ?? "",
|
|
133
|
+
database,
|
|
134
|
+
});
|
|
135
|
+
target.set("default", ConnectionProfile.fromEnvironment("default", connection));
|
|
136
|
+
}
|
|
137
|
+
readJsonObject(variable, warnings) {
|
|
138
|
+
const raw = this.env[variable];
|
|
139
|
+
if (!raw) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(raw);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
warnings.push(`${variable} is not valid JSON, ignoring it: ${this.reason(error)}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
151
|
+
warnings.push(`${variable} must be a JSON object of named profiles, ignoring it.`);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
readUrlDefinition(definition) {
|
|
157
|
+
if (typeof definition === "string" && definition) {
|
|
158
|
+
return { url: definition };
|
|
159
|
+
}
|
|
160
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const raw = definition;
|
|
164
|
+
if (typeof raw.url !== "string" || !raw.url) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
url: raw.url,
|
|
169
|
+
password: typeof raw.password === "string" ? raw.password : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
readTimeout(value, fallback) {
|
|
173
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
174
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
175
|
+
}
|
|
176
|
+
reason(error) {
|
|
177
|
+
return error instanceof Error ? error.message : String(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Reads the version clients see in the MCP handshake from `package.json`.
|
|
4
|
+
*
|
|
5
|
+
* A literal in the server class would mean a release has to remember to
|
|
6
|
+
* change the same number in two files. Nothing enforces that, and the tag
|
|
7
|
+
* check in the publish workflows never looks at a literal, so a missed bump
|
|
8
|
+
* would ship a server that misreports itself to every client. The MySQL-only
|
|
9
|
+
* predecessor of this server shipped exactly that bug before this class
|
|
10
|
+
* existed.
|
|
11
|
+
*
|
|
12
|
+
* `package.json` is present in all three ways this server is distributed: the
|
|
13
|
+
* npm tarball always includes it, the runtime image copies it in before the
|
|
14
|
+
* build output, and a local checkout has it by definition.
|
|
15
|
+
*/
|
|
16
|
+
export class PackageVersionLoader {
|
|
17
|
+
packageJsonUrl;
|
|
18
|
+
/**
|
|
19
|
+
* Used when `package.json` cannot be read. A server that starts and reports
|
|
20
|
+
* an obviously wrong version is easier to diagnose than one that refuses to
|
|
21
|
+
* start over metadata it does not need in order to answer queries.
|
|
22
|
+
*/
|
|
23
|
+
static UNKNOWN_VERSION = "0.0.0";
|
|
24
|
+
/**
|
|
25
|
+
* Resolved from this module rather than from `process.cwd()`, so the answer
|
|
26
|
+
* does not depend on the directory the client happened to launch us from.
|
|
27
|
+
*/
|
|
28
|
+
constructor(packageJsonUrl = new URL("../../package.json", import.meta.url)) {
|
|
29
|
+
this.packageJsonUrl = packageJsonUrl;
|
|
30
|
+
}
|
|
31
|
+
load() {
|
|
32
|
+
try {
|
|
33
|
+
const contents = readFileSync(this.packageJsonUrl, "utf8");
|
|
34
|
+
const version = JSON.parse(contents).version;
|
|
35
|
+
return typeof version === "string" && version.length > 0
|
|
36
|
+
? version
|
|
37
|
+
: PackageVersionLoader.UNKNOWN_VERSION;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return PackageVersionLoader.UNKNOWN_VERSION;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ConnectionProfile } from "../domain/ConnectionProfile.js";
|
|
2
|
+
import { EngineCatalog } from "../domain/Engine.js";
|
|
3
|
+
import { UnknownProfileError } from "../errors/UnknownProfileError.js";
|
|
4
|
+
import { UnsupportedOperationError } from "../errors/UnsupportedOperationError.js";
|
|
5
|
+
/**
|
|
6
|
+
* Orchestrates changing the active connection.
|
|
7
|
+
*
|
|
8
|
+
* Sits between the tools and the registry so that every switch follows the
|
|
9
|
+
* same rule: **verify, then commit**. The registry cannot enforce that itself
|
|
10
|
+
* without taking a dependency on the drivers, and the tools should not each be
|
|
11
|
+
* trusted to remember it.
|
|
12
|
+
*
|
|
13
|
+
* Committing only after a successful verification is what makes a failed
|
|
14
|
+
* switch harmless: the previous connection stays active and the session
|
|
15
|
+
* remains usable.
|
|
16
|
+
*/
|
|
17
|
+
export class ConnectionManager {
|
|
18
|
+
registry;
|
|
19
|
+
drivers;
|
|
20
|
+
constructor(registry, drivers) {
|
|
21
|
+
this.registry = registry;
|
|
22
|
+
this.drivers = drivers;
|
|
23
|
+
}
|
|
24
|
+
getActiveTarget() {
|
|
25
|
+
return this.registry.getActiveTarget();
|
|
26
|
+
}
|
|
27
|
+
getActiveName() {
|
|
28
|
+
return this.registry.getActiveName();
|
|
29
|
+
}
|
|
30
|
+
requireActiveTarget() {
|
|
31
|
+
return this.registry.requireActiveTarget();
|
|
32
|
+
}
|
|
33
|
+
listProfiles() {
|
|
34
|
+
return this.registry.list();
|
|
35
|
+
}
|
|
36
|
+
findProfile(profileName) {
|
|
37
|
+
return this.registry.find(profileName);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Move to a different database on the current server.
|
|
41
|
+
*
|
|
42
|
+
* Keeps the current profile label: the connection is still "staging", just
|
|
43
|
+
* pointed elsewhere, and renaming it would throw away that context.
|
|
44
|
+
*
|
|
45
|
+
* @throws UnsupportedOperationError for engines with nothing to switch to,
|
|
46
|
+
* before any network work.
|
|
47
|
+
*/
|
|
48
|
+
async useDatabase(database) {
|
|
49
|
+
const active = this.registry.requireActiveTarget();
|
|
50
|
+
ConnectionManager.assertSwitchable(active);
|
|
51
|
+
const candidate = active.withDatabase(database);
|
|
52
|
+
await this.drivers.verify(candidate);
|
|
53
|
+
return this.registry.activateTarget(candidate, this.registry.getActiveName() ?? "custom");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Move to a named profile, optionally overriding its database.
|
|
57
|
+
*
|
|
58
|
+
* @throws UnknownProfileError carrying the known names, checked before any
|
|
59
|
+
* network work so a typo fails instantly.
|
|
60
|
+
*/
|
|
61
|
+
async useProfile(profileName, database) {
|
|
62
|
+
const profile = this.registry.find(profileName);
|
|
63
|
+
if (!profile) {
|
|
64
|
+
throw new UnknownProfileError(profileName, this.registry.names());
|
|
65
|
+
}
|
|
66
|
+
let candidate = profile.target;
|
|
67
|
+
if (database) {
|
|
68
|
+
ConnectionManager.assertSwitchable(profile.target);
|
|
69
|
+
candidate = profile.target.withDatabase(database);
|
|
70
|
+
}
|
|
71
|
+
await this.drivers.verify(candidate);
|
|
72
|
+
return this.registry.activateTarget(candidate, profileName);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Open an arbitrary connection and keep it under an alias for the session.
|
|
76
|
+
*
|
|
77
|
+
* This is what makes a restart unnecessary in every case: DB_PROFILES is a
|
|
78
|
+
* convenience, this is the guarantee. Credentials are held in memory only
|
|
79
|
+
* and vanish on exit, which list_connections communicates via the `session`
|
|
80
|
+
* origin.
|
|
81
|
+
*/
|
|
82
|
+
async connect(target, alias) {
|
|
83
|
+
await this.drivers.verify(target);
|
|
84
|
+
this.registry.register(ConnectionProfile.fromSession(alias, target));
|
|
85
|
+
return this.registry.activateTarget(target, alias);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Shared with DriverProvider so use_database and the per-call `database`
|
|
89
|
+
* argument refuse in exactly the same words.
|
|
90
|
+
*/
|
|
91
|
+
static assertSwitchable(target) {
|
|
92
|
+
const engine = EngineCatalog.describe(target.engine);
|
|
93
|
+
if (engine.switchesDatabases) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const alternative = target.engine === "sqlite"
|
|
97
|
+
? "The file is the database; call connect with another sqlite:// URL to open a different one."
|
|
98
|
+
: "Its indices are listed by list_tables.";
|
|
99
|
+
throw new UnsupportedOperationError(engine.label, "separate databases to switch between", alternative);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { NoActiveConnectionError } from "../errors/NoActiveConnectionError.js";
|
|
2
|
+
import { UnknownProfileError } from "../errors/UnknownProfileError.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Registry: every known connection, and which one is active.
|
|
5
|
+
*
|
|
6
|
+
* This is the only mutable state that makes runtime switching possible. As an
|
|
7
|
+
* injected instance rather than module-level state it is ordinary to
|
|
8
|
+
* construct, and several can exist side by side in a test file.
|
|
9
|
+
*
|
|
10
|
+
* It holds no database machinery on purpose. Verifying that a connection
|
|
11
|
+
* actually works belongs to ConnectionManager, which keeps this class free of
|
|
12
|
+
* I/O and trivially testable.
|
|
13
|
+
*/
|
|
14
|
+
export class ConnectionRegistry {
|
|
15
|
+
profiles = new Map();
|
|
16
|
+
activeName = null;
|
|
17
|
+
activeTarget = null;
|
|
18
|
+
constructor(profiles = []) {
|
|
19
|
+
for (const profile of profiles) {
|
|
20
|
+
this.profiles.set(profile.name, profile);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Choose the starting connection, in order of preference:
|
|
25
|
+
* the requested name, a profile called `default`, then the first defined.
|
|
26
|
+
*
|
|
27
|
+
* @returns a warning when the requested name does not exist, else null.
|
|
28
|
+
* A typo should not stop the server; saying so and carrying on is more
|
|
29
|
+
* useful than refusing to start.
|
|
30
|
+
*/
|
|
31
|
+
selectInitial(preferredName) {
|
|
32
|
+
if (preferredName && this.profiles.has(preferredName)) {
|
|
33
|
+
this.activateProfile(preferredName);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const warning = preferredName
|
|
37
|
+
? `The default profile "${preferredName}" does not match any profile.`
|
|
38
|
+
: null;
|
|
39
|
+
if (this.profiles.has("default")) {
|
|
40
|
+
this.activateProfile("default");
|
|
41
|
+
return warning;
|
|
42
|
+
}
|
|
43
|
+
const first = this.profiles.keys().next();
|
|
44
|
+
if (!first.done) {
|
|
45
|
+
this.activateProfile(first.value);
|
|
46
|
+
}
|
|
47
|
+
return warning;
|
|
48
|
+
}
|
|
49
|
+
/** Insertion order, which is what makes "the first profile" meaningful. */
|
|
50
|
+
list() {
|
|
51
|
+
return Array.from(this.profiles.values());
|
|
52
|
+
}
|
|
53
|
+
names() {
|
|
54
|
+
return Array.from(this.profiles.keys());
|
|
55
|
+
}
|
|
56
|
+
find(profileName) {
|
|
57
|
+
return this.profiles.get(profileName);
|
|
58
|
+
}
|
|
59
|
+
has(profileName) {
|
|
60
|
+
return this.profiles.has(profileName);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Register an alias opened at runtime. An existing name is replaced, so
|
|
64
|
+
* reconnecting with corrected credentials fixes the entry rather than
|
|
65
|
+
* failing.
|
|
66
|
+
*/
|
|
67
|
+
register(profile) {
|
|
68
|
+
this.profiles.set(profile.name, profile);
|
|
69
|
+
}
|
|
70
|
+
getActiveTarget() {
|
|
71
|
+
return this.activeTarget;
|
|
72
|
+
}
|
|
73
|
+
getActiveName() {
|
|
74
|
+
return this.activeName;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* @throws NoActiveConnectionError whose message names both ways out. Tool
|
|
78
|
+
* handlers convert it to a tool result, so that message is what the user
|
|
79
|
+
* reads.
|
|
80
|
+
*/
|
|
81
|
+
requireActiveTarget() {
|
|
82
|
+
if (!this.activeTarget) {
|
|
83
|
+
throw new NoActiveConnectionError();
|
|
84
|
+
}
|
|
85
|
+
return this.activeTarget;
|
|
86
|
+
}
|
|
87
|
+
/** @throws UnknownProfileError listing the names that do exist. */
|
|
88
|
+
activateProfile(profileName) {
|
|
89
|
+
const profile = this.profiles.get(profileName);
|
|
90
|
+
if (!profile) {
|
|
91
|
+
throw new UnknownProfileError(profileName, this.names());
|
|
92
|
+
}
|
|
93
|
+
this.activeName = profile.name;
|
|
94
|
+
this.activeTarget = profile.target;
|
|
95
|
+
return this.activeTarget;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Point at an explicit target under a label.
|
|
99
|
+
*
|
|
100
|
+
* No defensive copy is needed because ConnectionTarget is immutable: nothing
|
|
101
|
+
* downstream can mutate the active target into corrupting the profile it
|
|
102
|
+
* came from.
|
|
103
|
+
*/
|
|
104
|
+
activateTarget(target, label) {
|
|
105
|
+
this.activeName = label;
|
|
106
|
+
this.activeTarget = target;
|
|
107
|
+
return target;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { ConnectionTarget } from "../domain/ConnectionTarget.js";
|
|
3
|
+
import { InvalidProfileDefinitionError } from "../errors/InvalidProfileDefinitionError.js";
|
|
4
|
+
import { ConnectionUrlParser } from "./ConnectionUrlParser.js";
|
|
5
|
+
/**
|
|
6
|
+
* Builds ConnectionTarget instances from untrusted input.
|
|
7
|
+
*
|
|
8
|
+
* A Factory rather than a constructor because the input arrives in three
|
|
9
|
+
* shapes (a URL, a legacy MYSQL_PROFILES object, and the legacy MYSQL_*
|
|
10
|
+
* variables) and every one has to be checked and defaulted the same way.
|
|
11
|
+
* Keeping all three here means they cannot drift apart in what they accept.
|
|
12
|
+
*/
|
|
13
|
+
export class ConnectionTargetFactory {
|
|
14
|
+
parser;
|
|
15
|
+
resolvePath;
|
|
16
|
+
/**
|
|
17
|
+
* The legacy MYSQL_* form's default host.
|
|
18
|
+
*
|
|
19
|
+
* `localhost` inside a container means the container itself, which is the
|
|
20
|
+
* single most common mistake when the database runs on the host. Kept from
|
|
21
|
+
* the MySQL-only server so an existing configuration behaves identically.
|
|
22
|
+
* URLs always name their host, so this never applies to them.
|
|
23
|
+
*/
|
|
24
|
+
static DEFAULT_MYSQL_HOST = "host.docker.internal";
|
|
25
|
+
static DEFAULT_MYSQL_PORT = 3306;
|
|
26
|
+
/**
|
|
27
|
+
* @param resolvePath turns a SQLite path absolute. Injected so tests do not
|
|
28
|
+
* depend on the directory they run from.
|
|
29
|
+
*/
|
|
30
|
+
constructor(parser = new ConnectionUrlParser(), resolvePath = (path) => resolve(path)) {
|
|
31
|
+
this.parser = parser;
|
|
32
|
+
this.resolvePath = resolvePath;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* @param password used instead of the URL's own password when non-empty, so
|
|
36
|
+
* a password full of `@`, `/` and `#` never needs percent-encoding.
|
|
37
|
+
* @throws InvalidConnectionUrlError describing the problem without the URL.
|
|
38
|
+
*/
|
|
39
|
+
fromUrl(url, password) {
|
|
40
|
+
const props = this.parser.parse(url);
|
|
41
|
+
// A SQLite path is made absolute once, here, so the same file reached as
|
|
42
|
+
// `./app.db` and `/work/app.db` is recognised as one connection, and so a
|
|
43
|
+
// later change of working directory cannot repoint it.
|
|
44
|
+
const database = props.engine === "sqlite" ? this.resolvePath(props.database) : props.database;
|
|
45
|
+
return new ConnectionTarget({
|
|
46
|
+
...props,
|
|
47
|
+
password: password ? password : props.password,
|
|
48
|
+
database,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One entry of the legacy MYSQL_PROFILES object form.
|
|
53
|
+
*
|
|
54
|
+
* @throws InvalidProfileDefinitionError when user or database is missing.
|
|
55
|
+
* The caller turns this into a warning; one broken profile must not stop
|
|
56
|
+
* the server from starting.
|
|
57
|
+
*/
|
|
58
|
+
fromMySqlDefinition(raw, profileName) {
|
|
59
|
+
const user = this.readString(raw.user);
|
|
60
|
+
const database = this.readString(raw.database);
|
|
61
|
+
if (!user) {
|
|
62
|
+
throw new InvalidProfileDefinitionError(profileName, 'is missing "user".');
|
|
63
|
+
}
|
|
64
|
+
if (!database) {
|
|
65
|
+
throw new InvalidProfileDefinitionError(profileName, 'is missing "database".');
|
|
66
|
+
}
|
|
67
|
+
return this.fromMySqlValues({
|
|
68
|
+
host: this.readString(raw.host),
|
|
69
|
+
port: this.readPort(raw.port),
|
|
70
|
+
user,
|
|
71
|
+
password: this.readString(raw.password),
|
|
72
|
+
database,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Builds a MySQL target from already-trusted values, applying the legacy defaults. */
|
|
76
|
+
fromMySqlValues(values) {
|
|
77
|
+
return new ConnectionTarget({
|
|
78
|
+
engine: "mysql",
|
|
79
|
+
scheme: "mysql",
|
|
80
|
+
hosts: [
|
|
81
|
+
{
|
|
82
|
+
host: values.host || ConnectionTargetFactory.DEFAULT_MYSQL_HOST,
|
|
83
|
+
port: values.port ?? ConnectionTargetFactory.DEFAULT_MYSQL_PORT,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
user: values.user,
|
|
87
|
+
password: values.password ?? "",
|
|
88
|
+
database: values.database,
|
|
89
|
+
options: {},
|
|
90
|
+
secretOptions: {},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
readString(value) {
|
|
94
|
+
return typeof value === "string" ? value : "";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Only a real number is accepted. A port given as the string "3306" falls
|
|
98
|
+
* back to the default rather than becoming NaN and failing much later with
|
|
99
|
+
* an unrecognisable error.
|
|
100
|
+
*/
|
|
101
|
+
readPort(value) {
|
|
102
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|