@ecubelabs/mysql-mcp 0.0.0-stage → 0.0.1
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/LICENSE.md +21 -0
- package/README.md +848 -2
- package/dist/index.js +684 -0
- package/dist/src/catalog/collect.js +220 -0
- package/dist/src/catalog/docs.js +328 -0
- package/dist/src/catalog/index.js +685 -0
- package/dist/src/catalog/render.js +177 -0
- package/dist/src/catalog/store.js +617 -0
- package/dist/src/catalog/types.js +59 -0
- package/dist/src/catalog/usage.js +164 -0
- package/dist/src/config/index.js +244 -0
- package/dist/src/db/diagnose.js +189 -0
- package/dist/src/db/index.js +461 -0
- package/dist/src/db/permissions.js +26 -0
- package/dist/src/db/utils.js +264 -0
- package/dist/src/ssh/config.js +137 -0
- package/dist/src/ssh/tunnel.js +334 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/index.js +11 -0
- package/package.json +50 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { realpathSync } from 'fs';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { log, tableResourceUri } from './src/utils/index.js';
|
|
8
|
+
import { ALLOW_DELETE_OPERATION, ALLOW_DDL_OPERATION, ALLOW_INSERT_OPERATION, ALLOW_UPDATE_OPERATION, SCHEMA_DELETE_PERMISSIONS, SCHEMA_DDL_PERMISSIONS, SCHEMA_INSERT_PERMISSIONS, SCHEMA_UPDATE_PERMISSIONS, isMultiDbMode, mcpConfig as config, MYSQL_PROFILE, PROFILE_LABEL, IS_WRITE_FORBIDDEN_PROFILE, APP_SCHEMAS, CODE_BRANCH, MYSQL_CATALOG_ENABLED, MYSQL_CATALOG_PATH, MYSQL_CATALOG_TTL_HOURS, MYSQL_DOCS_REPO, MYSQL_DEFAULT_TIMEOUT_SECONDS, MYSQL_MAX_TIMEOUT_SECONDS, MAX_RESPONSE_ROWS, MYSQL_SSL_ENABLED, MYSQL_SSL_CA_PATH, MYSQL_SSL_CERT_PATH, MYSQL_SSL_KEY_PATH, PACKAGE_VERSION, SSH_ENABLED, } from './src/config/index.js';
|
|
9
|
+
import { catalogIdentity, SchemaCatalog } from './src/catalog/index.js';
|
|
10
|
+
import { describeConnection, getPool, executeQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, poolPromise, profileBanner, } from './src/db/index.js';
|
|
11
|
+
import { describeTunnel, ensureTunnel, resolveTunnelConfig, stopTunnel } from './src/ssh/tunnel.js';
|
|
12
|
+
log('info', `Starting MySQL MCP server v${PACKAGE_VERSION}...`);
|
|
13
|
+
const toolVersion = `MySQL MCP Server [v${PACKAGE_VERSION}]`;
|
|
14
|
+
const TOOL_DESCRIPTION_LIMIT = 2048;
|
|
15
|
+
let baseToolDescription = `[${toolVersion}] Run SQL queries against the ${PROFILE_LABEL} MySQL database`;
|
|
16
|
+
if (MYSQL_PROFILE) {
|
|
17
|
+
baseToolDescription += IS_WRITE_FORBIDDEN_PROFILE
|
|
18
|
+
? ` — ENVIRONMENT: ${PROFILE_LABEL}, STRICTLY READ-ONLY (writes are refused by policy)`
|
|
19
|
+
: ` — ENVIRONMENT: ${PROFILE_LABEL}`;
|
|
20
|
+
}
|
|
21
|
+
if (isMultiDbMode) {
|
|
22
|
+
baseToolDescription += ' (Multi-DB mode enabled)';
|
|
23
|
+
}
|
|
24
|
+
if (ALLOW_INSERT_OPERATION || ALLOW_UPDATE_OPERATION || ALLOW_DELETE_OPERATION || ALLOW_DDL_OPERATION) {
|
|
25
|
+
baseToolDescription += ' with support for:';
|
|
26
|
+
if (ALLOW_INSERT_OPERATION) {
|
|
27
|
+
baseToolDescription += ' INSERT,';
|
|
28
|
+
}
|
|
29
|
+
if (ALLOW_UPDATE_OPERATION) {
|
|
30
|
+
baseToolDescription += ' UPDATE,';
|
|
31
|
+
}
|
|
32
|
+
if (ALLOW_DELETE_OPERATION) {
|
|
33
|
+
baseToolDescription += ' DELETE,';
|
|
34
|
+
}
|
|
35
|
+
if (ALLOW_DDL_OPERATION) {
|
|
36
|
+
baseToolDescription += ' DDL,';
|
|
37
|
+
}
|
|
38
|
+
baseToolDescription = baseToolDescription.replace(/,$/, '') + ' and READ operations';
|
|
39
|
+
if (Object.keys(SCHEMA_INSERT_PERMISSIONS).length > 0 ||
|
|
40
|
+
Object.keys(SCHEMA_UPDATE_PERMISSIONS).length > 0 ||
|
|
41
|
+
Object.keys(SCHEMA_DELETE_PERMISSIONS).length > 0 ||
|
|
42
|
+
Object.keys(SCHEMA_DDL_PERMISSIONS).length > 0) {
|
|
43
|
+
baseToolDescription += ' (Schema-specific permissions enabled)';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
baseToolDescription += ' (READ-ONLY)';
|
|
48
|
+
}
|
|
49
|
+
baseToolDescription +=
|
|
50
|
+
`\n\nLIMITS: every read is cancelled after ${MYSQL_DEFAULT_TIMEOUT_SECONDS}s ` +
|
|
51
|
+
`(raise per call with timeout_seconds, up to ${MYSQL_MAX_TIMEOUT_SECONDS}) and returns at most ` +
|
|
52
|
+
`${MAX_RESPONSE_ROWS.toLocaleString('en-US')} rows. Aggregate in SQL rather than pulling rows to count them. ` +
|
|
53
|
+
`USE and SET are rejected; write schema.table instead of USE.`;
|
|
54
|
+
if (CODE_BRANCH) {
|
|
55
|
+
baseToolDescription +=
|
|
56
|
+
`\n\nCODE REVISION: this database matches the \`${CODE_BRANCH}\` branch. ` +
|
|
57
|
+
`Read entities, migrations, and queries from that branch when correlating code with data.`;
|
|
58
|
+
}
|
|
59
|
+
if (APP_SCHEMAS.length > 0) {
|
|
60
|
+
baseToolDescription +=
|
|
61
|
+
'\n\nAPP -> SCHEMA (authoritative — use these directly; do not run SHOW DATABASES ' +
|
|
62
|
+
'or search information_schema to find a schema):';
|
|
63
|
+
for (const entry of APP_SCHEMAS) {
|
|
64
|
+
baseToolDescription +=
|
|
65
|
+
`\n - ${entry.app} -> ${entry.schema}` + (entry.description ? ` — ${entry.description}` : '');
|
|
66
|
+
}
|
|
67
|
+
baseToolDescription +=
|
|
68
|
+
'\nQualify every table with its schema (schema.table). Any schema not listed here is ' +
|
|
69
|
+
'either absent from this environment or not owned by an application.';
|
|
70
|
+
const needsQuoting = APP_SCHEMAS.find((entry) => !/^[A-Za-z0-9_$]+$/.test(entry.schema));
|
|
71
|
+
if (needsQuoting) {
|
|
72
|
+
baseToolDescription +=
|
|
73
|
+
`\nA schema name that is not a bare identifier must be backtick-quoted, ` +
|
|
74
|
+
`e.g. SELECT ... FROM \`${needsQuoting.schema}\`.some_table.`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const isReadOnly = !(ALLOW_INSERT_OPERATION || ALLOW_UPDATE_OPERATION || ALLOW_DELETE_OPERATION || ALLOW_DDL_OPERATION);
|
|
78
|
+
const mysqlCatalogTool = {
|
|
79
|
+
name: 'mysql_catalog',
|
|
80
|
+
description: 'Read the local schema catalog without rediscovering database metadata. ' +
|
|
81
|
+
'Use map for the app/schema overview, search to find tables or known columns, ' +
|
|
82
|
+
'describe before writing SQL, and docs_list/docs_read to inspect domain documents. ' +
|
|
83
|
+
"Use link or unlink to record the model's document decision. " +
|
|
84
|
+
'Use note to save a table memo or alias, and forget to clear one catalog scope. ' +
|
|
85
|
+
'Use refresh when cached metadata contradicts what a query actually returned, ' +
|
|
86
|
+
'or right after a migration: added columns and indexes raise no error, so ' +
|
|
87
|
+
'nothing else invalidates them.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
action: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
enum: [
|
|
94
|
+
'map',
|
|
95
|
+
'search',
|
|
96
|
+
'describe',
|
|
97
|
+
'docs_list',
|
|
98
|
+
'docs_read',
|
|
99
|
+
'link',
|
|
100
|
+
'unlink',
|
|
101
|
+
'note',
|
|
102
|
+
'forget',
|
|
103
|
+
'refresh',
|
|
104
|
+
],
|
|
105
|
+
description: 'Catalog operation to perform',
|
|
106
|
+
},
|
|
107
|
+
q: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Keyword for search',
|
|
110
|
+
},
|
|
111
|
+
limit: {
|
|
112
|
+
type: 'integer',
|
|
113
|
+
minimum: 1,
|
|
114
|
+
maximum: 100,
|
|
115
|
+
description: 'Maximum search results (default 20)',
|
|
116
|
+
},
|
|
117
|
+
table: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Qualified schema.table name for describe, or for unlink. note and ' +
|
|
120
|
+
'forget take the same name as target instead.',
|
|
121
|
+
},
|
|
122
|
+
schema: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Declared schema name for docs_list. Results are narrowed to the ' +
|
|
125
|
+
'documents under the app directory declared for that schema; the ' +
|
|
126
|
+
'response says so and reports how many were left out.',
|
|
127
|
+
},
|
|
128
|
+
path: {
|
|
129
|
+
type: 'string',
|
|
130
|
+
description: 'Cataloged model.md path for docs_read',
|
|
131
|
+
},
|
|
132
|
+
target: {
|
|
133
|
+
type: 'string',
|
|
134
|
+
description: 'Qualified schema.table for note or forget. For refresh: "schema.table" for one table\'s ' +
|
|
135
|
+
'columns, indexes and foreign keys; a declared schema name for the ' +
|
|
136
|
+
'table inventory; "docs" to re-read the document ref after a git ' +
|
|
137
|
+
'fetch. Omit it to refresh the inventory and the documents together.',
|
|
138
|
+
},
|
|
139
|
+
text: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
maxLength: 1000,
|
|
142
|
+
description: 'Table memo to add with note; use either text or alias',
|
|
143
|
+
},
|
|
144
|
+
alias: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
maxLength: 200,
|
|
147
|
+
description: 'Alternative table name to add with note; use either alias or text',
|
|
148
|
+
},
|
|
149
|
+
scope: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
enum: ['notes', 'aliases', 'usage', 'joins', 'metadata'],
|
|
152
|
+
description: 'What forget clears for one table. metadata is lazily recollected; ' +
|
|
153
|
+
'document decisions use unlink instead.',
|
|
154
|
+
},
|
|
155
|
+
links: {
|
|
156
|
+
type: 'array',
|
|
157
|
+
description: 'Table/document decisions to record in one call',
|
|
158
|
+
items: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
table: {
|
|
162
|
+
type: 'string',
|
|
163
|
+
description: 'Qualified schema.table name',
|
|
164
|
+
},
|
|
165
|
+
doc: {
|
|
166
|
+
type: 'string',
|
|
167
|
+
description: 'Cataloged model.md path',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
required: ['table', 'doc'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
required: ['action'],
|
|
175
|
+
},
|
|
176
|
+
annotations: {
|
|
177
|
+
readOnlyHint: false,
|
|
178
|
+
idempotentHint: false,
|
|
179
|
+
destructiveHint: false,
|
|
180
|
+
openWorldHint: false,
|
|
181
|
+
title: 'MySQL Schema Catalog',
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const mysqlQueryInputSchema = {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
sql: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
description: 'The SQL query to execute',
|
|
190
|
+
},
|
|
191
|
+
timeout_seconds: {
|
|
192
|
+
type: 'integer',
|
|
193
|
+
minimum: 1,
|
|
194
|
+
maximum: MYSQL_MAX_TIMEOUT_SECONDS,
|
|
195
|
+
description: `Server-side execution limit in seconds (default ${MYSQL_DEFAULT_TIMEOUT_SECONDS}, ` +
|
|
196
|
+
`maximum ${MYSQL_MAX_TIMEOUT_SECONDS}; out-of-range values are clamped). ` +
|
|
197
|
+
`Raise it only when a cancelled query's plan looked sound and the query was merely slow. ` +
|
|
198
|
+
`A query the plan shows scanning a whole table does not finish sooner with more time.`,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
required: ['sql'],
|
|
202
|
+
};
|
|
203
|
+
log('info', 'MySQL Configuration:', JSON.stringify({
|
|
204
|
+
connection: describeConnection(),
|
|
205
|
+
user: config.mysql.user,
|
|
206
|
+
password: config.mysql.password ? '******' : 'not set',
|
|
207
|
+
database: config.mysql.database || 'MULTI_DB_MODE',
|
|
208
|
+
ssl: MYSQL_SSL_ENABLED ? 'enabled' : 'disabled',
|
|
209
|
+
sslCA: MYSQL_SSL_CA_PATH ?? 'not set',
|
|
210
|
+
sslCert: MYSQL_SSL_CERT_PATH ?? 'not set',
|
|
211
|
+
sslKey: MYSQL_SSL_KEY_PATH ?? 'not set',
|
|
212
|
+
multiDbMode: isMultiDbMode ? 'enabled' : 'disabled',
|
|
213
|
+
profile: PROFILE_LABEL,
|
|
214
|
+
writePolicy: IS_WRITE_FORBIDDEN_PROFILE
|
|
215
|
+
? 'read-only (enforced by profile)'
|
|
216
|
+
: isReadOnly
|
|
217
|
+
? 'read-only'
|
|
218
|
+
: 'writes enabled',
|
|
219
|
+
sshTunnel: SSH_ENABLED ? describeTunnel() : 'disabled',
|
|
220
|
+
}, null, 2));
|
|
221
|
+
export default function createMcpServer() {
|
|
222
|
+
const mysqlSettings = config.mysql;
|
|
223
|
+
const catalogTarget = SSH_ENABLED
|
|
224
|
+
? (() => {
|
|
225
|
+
const tunnel = resolveTunnelConfig();
|
|
226
|
+
return JSON.stringify(['tcp', tunnel.remoteHost, tunnel.remotePort]);
|
|
227
|
+
})()
|
|
228
|
+
: typeof mysqlSettings.socketPath === 'string'
|
|
229
|
+
? JSON.stringify(['unix', mysqlSettings.socketPath])
|
|
230
|
+
: JSON.stringify(['tcp', String(mysqlSettings.host ?? '127.0.0.1'), Number(mysqlSettings.port ?? 3306)]);
|
|
231
|
+
const identity = catalogIdentity({
|
|
232
|
+
profile: MYSQL_PROFILE,
|
|
233
|
+
target: catalogTarget,
|
|
234
|
+
user: String(mysqlSettings.user ?? ''),
|
|
235
|
+
customPath: MYSQL_CATALOG_PATH,
|
|
236
|
+
});
|
|
237
|
+
const noDeclaredSchemas = APP_SCHEMAS.length === 0;
|
|
238
|
+
if (noDeclaredSchemas && MYSQL_CATALOG_ENABLED) {
|
|
239
|
+
console.error('[catalog] disabled: MYSQL_APP_SCHEMAS declares no schema, so there is ' +
|
|
240
|
+
'nothing to catalog. Declare the app -> schema map to enable it.');
|
|
241
|
+
}
|
|
242
|
+
const catalog = new SchemaCatalog({
|
|
243
|
+
enabled: MYSQL_CATALOG_ENABLED && !noDeclaredSchemas,
|
|
244
|
+
profile: MYSQL_PROFILE || 'default',
|
|
245
|
+
fingerprint: identity.fingerprint,
|
|
246
|
+
filePath: identity.filePath,
|
|
247
|
+
ttlHours: MYSQL_CATALOG_TTL_HOURS,
|
|
248
|
+
appSchemas: APP_SCHEMAS,
|
|
249
|
+
docsRepo: MYSQL_DOCS_REPO ?? null,
|
|
250
|
+
docsRef: CODE_BRANCH ? `origin/${CODE_BRANCH}` : null,
|
|
251
|
+
defaultSchema: typeof mysqlSettings.database === 'string' ? mysqlSettings.database : null,
|
|
252
|
+
});
|
|
253
|
+
setQueryDiagnosticsSource({
|
|
254
|
+
indexFacts: (reference) => catalog.indexFacts(reference),
|
|
255
|
+
});
|
|
256
|
+
if (baseToolDescription.length > TOOL_DESCRIPTION_LIMIT) {
|
|
257
|
+
console.error(`[warn] tool description is ${baseToolDescription.length} characters before the ` +
|
|
258
|
+
`catalog tail; clients keep ${TOOL_DESCRIPTION_LIMIT} and silently drop the rest. ` +
|
|
259
|
+
`Shorten MYSQL_APP_SCHEMAS descriptions, or the text in index.ts.`);
|
|
260
|
+
}
|
|
261
|
+
const mysqlQueryDescription = () => baseToolDescription + catalog.toolDescriptionSuffix(TOOL_DESCRIPTION_LIMIT - baseToolDescription.length);
|
|
262
|
+
const loadResourceTables = async () => {
|
|
263
|
+
if (catalog.isEnabled()) {
|
|
264
|
+
try {
|
|
265
|
+
const cached = await catalog.listTables();
|
|
266
|
+
if (cached.length > 0) {
|
|
267
|
+
return cached;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
console.error(`[catalog] table resource fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return executeQuery(`
|
|
275
|
+
SELECT
|
|
276
|
+
table_name as name,
|
|
277
|
+
table_schema as \`database\`,
|
|
278
|
+
table_comment as description,
|
|
279
|
+
table_rows as rowCount,
|
|
280
|
+
data_length as dataSize,
|
|
281
|
+
index_length as indexSize,
|
|
282
|
+
create_time as createTime,
|
|
283
|
+
update_time as updateTime
|
|
284
|
+
FROM
|
|
285
|
+
information_schema.tables
|
|
286
|
+
WHERE
|
|
287
|
+
table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
|
|
288
|
+
ORDER BY
|
|
289
|
+
table_schema, table_name
|
|
290
|
+
`);
|
|
291
|
+
};
|
|
292
|
+
const server = new Server({
|
|
293
|
+
name: 'MySQL MCP Server',
|
|
294
|
+
version: PACKAGE_VERSION,
|
|
295
|
+
}, {
|
|
296
|
+
capabilities: {
|
|
297
|
+
resources: {},
|
|
298
|
+
tools: catalog.isEnabled() ? { listChanged: true } : {},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
302
|
+
try {
|
|
303
|
+
log('info', 'Handling ListResourcesRequest');
|
|
304
|
+
log('info', `Connection info: ${describeConnection()}`);
|
|
305
|
+
const tables = await loadResourceTables();
|
|
306
|
+
log('info', `Found ${tables.length} tables`);
|
|
307
|
+
const resources = tables.map((table) => ({
|
|
308
|
+
uri: tableResourceUri(table.database, table.name),
|
|
309
|
+
name: table.name,
|
|
310
|
+
title: `${table.database}.${table.name}`,
|
|
311
|
+
description: table.description || `Table ${table.name} in database ${table.database}`,
|
|
312
|
+
mimeType: 'application/json',
|
|
313
|
+
}));
|
|
314
|
+
resources.push({
|
|
315
|
+
uri: 'mysql://tables',
|
|
316
|
+
name: 'Tables',
|
|
317
|
+
title: 'MySQL Tables',
|
|
318
|
+
description: 'List of all MySQL tables',
|
|
319
|
+
mimeType: 'application/json',
|
|
320
|
+
});
|
|
321
|
+
if (APP_SCHEMAS.length > 0) {
|
|
322
|
+
resources.push({
|
|
323
|
+
uri: 'mysql://schemas',
|
|
324
|
+
name: 'App schemas',
|
|
325
|
+
title: 'Application to schema map',
|
|
326
|
+
description: `Which schema each application owns in ${PROFILE_LABEL}`,
|
|
327
|
+
mimeType: 'application/json',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return { resources };
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
log('error', 'Error in ListResourcesRequest handler:', error);
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
338
|
+
try {
|
|
339
|
+
log('info', 'Handling ReadResourceRequest:', request.params.uri);
|
|
340
|
+
if (request.params.uri === 'mysql://schemas') {
|
|
341
|
+
return {
|
|
342
|
+
contents: [
|
|
343
|
+
{
|
|
344
|
+
uri: request.params.uri,
|
|
345
|
+
mimeType: 'application/json',
|
|
346
|
+
text: JSON.stringify({
|
|
347
|
+
profile: PROFILE_LABEL,
|
|
348
|
+
...(CODE_BRANCH ? { codeBranch: CODE_BRANCH } : {}),
|
|
349
|
+
apps: APP_SCHEMAS,
|
|
350
|
+
}, null, 2),
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (request.params.uri === 'mysql://tables') {
|
|
356
|
+
return {
|
|
357
|
+
contents: [
|
|
358
|
+
{
|
|
359
|
+
uri: request.params.uri,
|
|
360
|
+
mimeType: 'application/json',
|
|
361
|
+
text: JSON.stringify(await loadResourceTables(), null, 2),
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
let dbName = null;
|
|
367
|
+
let tableName;
|
|
368
|
+
if (request.params.uri.startsWith('mysql://tables/')) {
|
|
369
|
+
const parts = request.params.uri.slice('mysql://tables/'.length).split('/').map(decodeURIComponent);
|
|
370
|
+
dbName = parts.length >= 2 ? parts[0] : null;
|
|
371
|
+
tableName = parts.length >= 2 ? parts[1] : parts[0];
|
|
372
|
+
if (catalog.isEnabled()) {
|
|
373
|
+
try {
|
|
374
|
+
return {
|
|
375
|
+
contents: [
|
|
376
|
+
{
|
|
377
|
+
uri: request.params.uri,
|
|
378
|
+
mimeType: 'application/json',
|
|
379
|
+
text: await catalog.describe(dbName ? `${dbName}.${tableName}` : tableName),
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
log('info', `[catalog] describe fallback for ${request.params.uri}: ${error instanceof Error ? error.message : String(error)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const uriParts = request.params.uri.split('/');
|
|
391
|
+
tableName = uriParts.pop();
|
|
392
|
+
dbName = uriParts.length > 0 ? (uriParts.pop() ?? null) : null;
|
|
393
|
+
}
|
|
394
|
+
if (!tableName) {
|
|
395
|
+
throw new Error(`Invalid resource URI: ${request.params.uri}`);
|
|
396
|
+
}
|
|
397
|
+
let columnsQuery = 'SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?';
|
|
398
|
+
let queryParams = [tableName];
|
|
399
|
+
if (dbName) {
|
|
400
|
+
columnsQuery += ' AND table_schema = ?';
|
|
401
|
+
queryParams.push(dbName);
|
|
402
|
+
}
|
|
403
|
+
const results = (await executeQuery(columnsQuery, queryParams));
|
|
404
|
+
return {
|
|
405
|
+
contents: [
|
|
406
|
+
{
|
|
407
|
+
uri: request.params.uri,
|
|
408
|
+
mimeType: 'application/json',
|
|
409
|
+
text: JSON.stringify(results, null, 2),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
log('error', 'Error in ReadResourceRequest handler:', error);
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
420
|
+
try {
|
|
421
|
+
log('info', 'Handling CallToolRequest:', request.params.name);
|
|
422
|
+
if (request.params.name === 'mysql_catalog') {
|
|
423
|
+
if (!catalog.isEnabled()) {
|
|
424
|
+
throw new Error('The schema catalog is disabled.');
|
|
425
|
+
}
|
|
426
|
+
const args = request.params.arguments ?? {};
|
|
427
|
+
const action = args.action;
|
|
428
|
+
let text;
|
|
429
|
+
if (action === 'map') {
|
|
430
|
+
text = await catalog.map();
|
|
431
|
+
}
|
|
432
|
+
else if (action === 'search') {
|
|
433
|
+
if (typeof args.q !== 'string' || !args.q.trim()) {
|
|
434
|
+
throw new Error('mysql_catalog search requires a non-empty "q".');
|
|
435
|
+
}
|
|
436
|
+
text = await catalog.search(args.q, typeof args.limit === 'number' ? args.limit : undefined);
|
|
437
|
+
}
|
|
438
|
+
else if (action === 'describe') {
|
|
439
|
+
if (typeof args.table !== 'string' || !args.table.trim()) {
|
|
440
|
+
throw new Error('mysql_catalog describe requires "table" as schema.table.');
|
|
441
|
+
}
|
|
442
|
+
text = await catalog.describe(args.table);
|
|
443
|
+
}
|
|
444
|
+
else if (action === 'docs_list') {
|
|
445
|
+
if (typeof args.schema !== 'string' || !args.schema.trim()) {
|
|
446
|
+
throw new Error('mysql_catalog docs_list requires "schema".');
|
|
447
|
+
}
|
|
448
|
+
text = await catalog.docsList(args.schema);
|
|
449
|
+
}
|
|
450
|
+
else if (action === 'docs_read') {
|
|
451
|
+
if (typeof args.path !== 'string' || !args.path.trim()) {
|
|
452
|
+
throw new Error('mysql_catalog docs_read requires "path".');
|
|
453
|
+
}
|
|
454
|
+
text = await catalog.docsRead(args.path);
|
|
455
|
+
}
|
|
456
|
+
else if (action === 'link') {
|
|
457
|
+
if (!Array.isArray(args.links) ||
|
|
458
|
+
!args.links.every((link) => link &&
|
|
459
|
+
typeof link === 'object' &&
|
|
460
|
+
typeof link.table === 'string' &&
|
|
461
|
+
typeof link.doc === 'string')) {
|
|
462
|
+
throw new Error('mysql_catalog link requires "links" as [{table, doc}, ...].');
|
|
463
|
+
}
|
|
464
|
+
text = await catalog.link(args.links);
|
|
465
|
+
}
|
|
466
|
+
else if (action === 'unlink') {
|
|
467
|
+
if (typeof args.table !== 'string' || !args.table.trim()) {
|
|
468
|
+
throw new Error('mysql_catalog unlink requires "table" as schema.table.');
|
|
469
|
+
}
|
|
470
|
+
text = await catalog.unlink(args.table);
|
|
471
|
+
}
|
|
472
|
+
else if (action === 'note') {
|
|
473
|
+
if (typeof args.target !== 'string' || !args.target.trim()) {
|
|
474
|
+
throw new Error('mysql_catalog note requires "target" as schema.table.');
|
|
475
|
+
}
|
|
476
|
+
const hasText = typeof args.text === 'string' && args.text.trim().length > 0;
|
|
477
|
+
const hasAlias = typeof args.alias === 'string' && args.alias.trim().length > 0;
|
|
478
|
+
if (hasText === hasAlias) {
|
|
479
|
+
throw new Error('mysql_catalog note requires exactly one non-empty "text" or "alias".');
|
|
480
|
+
}
|
|
481
|
+
text = await catalog.note(args.target, {
|
|
482
|
+
...(hasText ? { text: args.text } : {}),
|
|
483
|
+
...(hasAlias ? { alias: args.alias } : {}),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
else if (action === 'forget') {
|
|
487
|
+
if (typeof args.target !== 'string' || !args.target.trim()) {
|
|
488
|
+
throw new Error('mysql_catalog forget requires "target" as schema.table.');
|
|
489
|
+
}
|
|
490
|
+
const scopes = new Set(['notes', 'aliases', 'usage', 'joins', 'metadata']);
|
|
491
|
+
if (typeof args.scope !== 'string' || !scopes.has(args.scope)) {
|
|
492
|
+
throw new Error('mysql_catalog forget requires "scope" as notes, aliases, usage, joins, or metadata.');
|
|
493
|
+
}
|
|
494
|
+
text = await catalog.forget(args.target, args.scope);
|
|
495
|
+
}
|
|
496
|
+
else if (action === 'refresh') {
|
|
497
|
+
if (args.target !== undefined && typeof args.target !== 'string') {
|
|
498
|
+
throw new Error('mysql_catalog refresh takes "target" as a string.');
|
|
499
|
+
}
|
|
500
|
+
text = await catalog.refresh(args.target);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
throw new Error(`Unknown mysql_catalog action: ${String(action)}`);
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
content: [
|
|
507
|
+
{ type: 'text', text: profileBanner() },
|
|
508
|
+
{ type: 'text', text },
|
|
509
|
+
],
|
|
510
|
+
isError: false,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (request.params.name !== 'mysql_query') {
|
|
514
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
515
|
+
}
|
|
516
|
+
const sql = request.params.arguments?.sql;
|
|
517
|
+
const timeoutSeconds = clampTimeoutSeconds(request.params.arguments?.timeout_seconds);
|
|
518
|
+
const references = catalog.prepareQuery(sql);
|
|
519
|
+
let result;
|
|
520
|
+
try {
|
|
521
|
+
result = await executeReadOnlyQuery(sql, { timeoutSeconds });
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
catalog.afterQuery(references, { content: [], isError: true }, error);
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
const documentGuidance = result.isError ? null : catalog.queryDocumentGuidance(references);
|
|
528
|
+
catalog.afterQuery(references, result);
|
|
529
|
+
return {
|
|
530
|
+
...result,
|
|
531
|
+
content: [
|
|
532
|
+
{ type: 'text', text: profileBanner() },
|
|
533
|
+
...(result.content ?? []),
|
|
534
|
+
...(documentGuidance ? [{ type: 'text', text: documentGuidance }] : []),
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const error = err;
|
|
540
|
+
log('error', 'Error in CallToolRequest handler:', error);
|
|
541
|
+
return {
|
|
542
|
+
content: [
|
|
543
|
+
{
|
|
544
|
+
type: 'text',
|
|
545
|
+
text: `${profileBanner()} Error: ${error.message}`,
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
isError: true,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
553
|
+
log('info', 'Handling ListToolsRequest');
|
|
554
|
+
const toolsResponse = {
|
|
555
|
+
tools: [
|
|
556
|
+
{
|
|
557
|
+
name: 'mysql_query',
|
|
558
|
+
description: mysqlQueryDescription(),
|
|
559
|
+
inputSchema: mysqlQueryInputSchema,
|
|
560
|
+
annotations: {
|
|
561
|
+
readOnlyHint: isReadOnly,
|
|
562
|
+
idempotentHint: isReadOnly,
|
|
563
|
+
destructiveHint: !isReadOnly,
|
|
564
|
+
openWorldHint: false,
|
|
565
|
+
title: 'MySQL Query',
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
...(catalog.isEnabled() ? [mysqlCatalogTool] : []),
|
|
569
|
+
],
|
|
570
|
+
};
|
|
571
|
+
log('info', 'ListToolsRequest response:', JSON.stringify(toolsResponse, null, 2));
|
|
572
|
+
return toolsResponse;
|
|
573
|
+
});
|
|
574
|
+
catalog.onToolDescriptionFilled(async () => {
|
|
575
|
+
await server.sendToolListChanged().catch(() => undefined);
|
|
576
|
+
});
|
|
577
|
+
(async () => {
|
|
578
|
+
try {
|
|
579
|
+
if (SSH_ENABLED) {
|
|
580
|
+
log('info', 'Opening SSH tunnel before connecting to MySQL...');
|
|
581
|
+
const endpoint = await ensureTunnel();
|
|
582
|
+
log('info', `SSH tunnel ready on ${endpoint?.host}:${endpoint?.port}` +
|
|
583
|
+
(endpoint?.reused ? ' (reused an existing forward)' : ''));
|
|
584
|
+
}
|
|
585
|
+
log('info', 'Attempting to test database connection...');
|
|
586
|
+
const pool = await getPool();
|
|
587
|
+
const connection = await pool.getConnection();
|
|
588
|
+
log('info', 'Database connection test successful');
|
|
589
|
+
connection.release();
|
|
590
|
+
catalog.startInventory();
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
console.error(`[startup] fatal error for profile ${PROFILE_LABEL}: ` +
|
|
594
|
+
(error instanceof Error ? error.message : String(error)));
|
|
595
|
+
log('error', 'Fatal error during server startup:', error);
|
|
596
|
+
await stopTunnel();
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
const shutdown = async (signal) => {
|
|
601
|
+
log('error', `Received ${signal}. Shutting down...`);
|
|
602
|
+
try {
|
|
603
|
+
await catalog.close();
|
|
604
|
+
if (poolPromise) {
|
|
605
|
+
const pool = await poolPromise;
|
|
606
|
+
await pool.end();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
log('error', 'Error closing pool:', err);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
await stopTunnel();
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
const shutdownAndExit = async (reason) => {
|
|
617
|
+
try {
|
|
618
|
+
await shutdown(reason);
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
log('error', `Error during ${reason} shutdown:`, err);
|
|
622
|
+
}
|
|
623
|
+
process.exit(0);
|
|
624
|
+
};
|
|
625
|
+
process.stdin.once('end', () => void shutdownAndExit('stdin end'));
|
|
626
|
+
process.stdin.once('close', () => void shutdownAndExit('stdin close'));
|
|
627
|
+
process.on('SIGINT', async () => {
|
|
628
|
+
try {
|
|
629
|
+
await shutdown('SIGINT');
|
|
630
|
+
process.exit(0);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
log('error', 'Error during SIGINT shutdown:', err);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
process.on('SIGTERM', async () => {
|
|
638
|
+
try {
|
|
639
|
+
await shutdown('SIGTERM');
|
|
640
|
+
process.exit(0);
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
log('error', 'Error during SIGTERM shutdown:', err);
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
process.on('uncaughtException', (error) => {
|
|
648
|
+
log('error', 'Uncaught exception:', error);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
});
|
|
651
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
652
|
+
log('error', 'Unhandled rejection at:', promise, 'reason:', reason);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
});
|
|
655
|
+
return server;
|
|
656
|
+
}
|
|
657
|
+
const isMainModule = () => {
|
|
658
|
+
if (typeof require !== 'undefined' && require.main === module) {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
if (typeof import.meta !== 'undefined' && import.meta.url && process.argv[1]) {
|
|
662
|
+
const currentModulePath = fileURLToPath(import.meta.url);
|
|
663
|
+
const mainScriptPath = realpathSync(process.argv[1]);
|
|
664
|
+
return currentModulePath === mainScriptPath;
|
|
665
|
+
}
|
|
666
|
+
return false;
|
|
667
|
+
};
|
|
668
|
+
if (isMainModule()) {
|
|
669
|
+
log('info', 'Running in standalone mode');
|
|
670
|
+
(async () => {
|
|
671
|
+
try {
|
|
672
|
+
const mcpServer = createMcpServer();
|
|
673
|
+
const transport = new StdioServerTransport();
|
|
674
|
+
await mcpServer.connect(transport);
|
|
675
|
+
log('info', 'Server started and listening on stdio');
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
console.error(`[startup] fatal error for profile ${PROFILE_LABEL}: ` +
|
|
679
|
+
(error instanceof Error ? error.message : String(error)));
|
|
680
|
+
log('error', 'Server error:', error);
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
})();
|
|
684
|
+
}
|