@mokoconsulting/mokomysql-mcp 2.5.4 → 2.6.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/LICENSE +696 -696
- package/README.md +122 -112
- package/config.example.json +27 -26
- package/dist/README.md +7 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +57 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +224 -0
- package/dist/migrate.js.map +1 -0
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js +8 -2
- package/dist/secrets.js.map +1 -1
- package/package.json +1 -1
- package/source/client.ts +210 -210
- package/source/config.ts +104 -97
- package/source/index.ts +1105 -1100
- package/source/migrate.ts +263 -0
- package/source/secrets.ts +78 -70
- package/source/state.ts +62 -62
- package/source/types.ts +91 -91
- package/tsconfig.json +19 -19
package/source/index.ts
CHANGED
|
@@ -1,1100 +1,1105 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
3
|
-
*
|
|
4
|
-
* This file is part of a Moko Consulting project.
|
|
5
|
-
*
|
|
6
|
-
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
7
|
-
*
|
|
8
|
-
* FILE INFORMATION
|
|
9
|
-
* DEFGROUP: mokomysql.Server
|
|
10
|
-
* INGROUP: mokomysql
|
|
11
|
-
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
12
|
-
* PATH: /src/index.ts
|
|
13
|
-
* VERSION: 01.00.00
|
|
14
|
-
* BRIEF: MCP server entry point — registers all MySQL management tools
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
20
|
-
import { z } from 'zod';
|
|
21
|
-
import { loadConfig } from './config.js';
|
|
22
|
-
import { MysqlClient } from './client.js';
|
|
23
|
-
import type { MySqlConfig } from './types.js';
|
|
24
|
-
|
|
25
|
-
export let config: MySqlConfig;
|
|
26
|
-
export let client: MysqlClient;
|
|
27
|
-
|
|
28
|
-
function formatRows(rows: Record<string, unknown>[]): { content: Array<{ type: 'text'; text: string }> } {
|
|
29
|
-
const text = rows.length === 0
|
|
30
|
-
? 'No rows returned.'
|
|
31
|
-
: JSON.stringify(rows, null, 2);
|
|
32
|
-
return { content: [{ type: 'text' as const, text: `${rows.length} row(s)\n${text}` }] };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function formatResult(r: { affectedRows: number; insertId: number; changedRows: number }): { content: Array<{ type: 'text'; text: string }> } {
|
|
36
|
-
return { content: [{ type: 'text' as const, text: `Affected rows: ${r.affectedRows} | Insert ID: ${r.insertId} | Changed rows: ${r.changedRows}` }] };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function formatError(err: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
40
|
-
const e = err as { code?: string; message?: string };
|
|
41
|
-
const code = e.code ? ` [${e.code}]` : '';
|
|
42
|
-
const msg = e.message ?? String(err);
|
|
43
|
-
return { content: [{ type: 'text' as const, text: `MySQL Error${code}: ${msg}` }] };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function esc(identifier: string): string {
|
|
47
|
-
return '`' + identifier.replace(/`/g, '``') + '`';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Classify a statement as a read. Only SELECT/SHOW/EXPLAIN/DESCRIBE are treated
|
|
52
|
-
* as reads (they stay unrestricted on Joomla DBs); everything else — including
|
|
53
|
-
* `WITH`, `SET`, and all DML/DDL — is conservatively treated as a write so the
|
|
54
|
-
* double-confirm gate errs toward safety. Leading comments/whitespace are stripped.
|
|
55
|
-
*/
|
|
56
|
-
function isReadStatement(sql: string): boolean {
|
|
57
|
-
const stripped = sql
|
|
58
|
-
.replace(/\/\*[\s\S]*?\*\//g, ' ') // /* block comments */
|
|
59
|
-
.replace(/^\s*(--|#)[^\n]*$/gm, ' ') // -- and # line comments
|
|
60
|
-
.trim();
|
|
61
|
-
return /^(SELECT|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i.test(stripped);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Classify a write statement by its leading keyword, so the gate can tell apart the
|
|
66
|
-
* operations that actually mutate Joomla's structural invariants from those that don't.
|
|
67
|
-
*
|
|
68
|
-
* The asset/nested-set coupling lives in the Joomla Table API, which raw SQL bypasses
|
|
69
|
-
* (verified against joomla-cms 5.2.3): `Table::store()` creates/updates a `#__assets`
|
|
70
|
-
* row and only renumbers the nested set when a node is NEW or MOVED (Table.php:913-916;
|
|
71
|
-
* Nested.php:696,720); `Table::delete()` removes the asset row before the entity row
|
|
72
|
-
* (Table.php:1053-1065). A raw in-place `UPDATE` never enters `store()`, so it cannot
|
|
73
|
-
* create, delete, or renumber an asset / nested-set row. A raw `INSERT`/`DELETE` of an
|
|
74
|
-
* asset-bearing entity (any table with an `asset_id` column — Table.php:208-209), by
|
|
75
|
-
* contrast, desynchronizes `#__assets` (no paired asset row, or an orphaned one). DDL
|
|
76
|
-
* and anything unrecognized are treated as structurally hazardous, conservatively.
|
|
77
|
-
*/
|
|
78
|
-
type WriteOp = 'INSERT' | 'UPDATE' | 'DELETE' | 'DDL' | 'OTHER';
|
|
79
|
-
function classifyWrite(sql: string): WriteOp {
|
|
80
|
-
const s = sql
|
|
81
|
-
.replace(/\/\*[\s\S]*?\*\//g, ' ') // /* block comments */
|
|
82
|
-
.replace(/^\s*(--|#)[^\n]*$/gm, ' ') // -- and # line comments
|
|
83
|
-
.trim();
|
|
84
|
-
if (/^(INSERT|REPLACE)\b/i.test(s)) return 'INSERT';
|
|
85
|
-
if (/^UPDATE\b/i.test(s)) return 'UPDATE';
|
|
86
|
-
if (/^DELETE\b/i.test(s)) return 'DELETE';
|
|
87
|
-
if (/^(CREATE|ALTER|DROP|TRUNCATE|RENAME)\b/i.test(s)) return 'DDL';
|
|
88
|
-
return 'OTHER';
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Known MySQL privilege identifiers accepted by GRANT/REVOKE. Compared
|
|
93
|
-
* case-insensitively against each comma-separated token supplied by the caller
|
|
94
|
-
* so an arbitrary string can never be interpolated raw into a GRANT statement.
|
|
95
|
-
*/
|
|
96
|
-
const ALLOWED_PRIVILEGES = new Set([
|
|
97
|
-
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'RELOAD',
|
|
98
|
-
'SHUTDOWN', 'PROCESS', 'FILE', 'REFERENCES', 'INDEX', 'ALTER',
|
|
99
|
-
'SHOW DATABASES', 'SUPER', 'CREATE TEMPORARY TABLES', 'LOCK TABLES',
|
|
100
|
-
'EXECUTE', 'REPLICATION SLAVE', 'REPLICATION CLIENT', 'CREATE VIEW',
|
|
101
|
-
'SHOW VIEW', 'CREATE ROUTINE', 'ALTER ROUTINE', 'CREATE USER', 'EVENT',
|
|
102
|
-
'TRIGGER', 'GRANT OPTION', 'ALL', 'ALL PRIVILEGES', 'USAGE',
|
|
103
|
-
]);
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Validate a comma-separated `privileges` string against ALLOWED_PRIVILEGES.
|
|
107
|
-
* Rejects parentheses (column-level grants) and any unknown token so nothing
|
|
108
|
-
* unvetted reaches the raw GRANT/REVOKE statement. Returns the normalized,
|
|
109
|
-
* uppercased, re-joined privilege list safe for interpolation.
|
|
110
|
-
*/
|
|
111
|
-
function validatePrivileges(privileges: string): string {
|
|
112
|
-
if (/[()]/.test(privileges)) {
|
|
113
|
-
throw new Error('Column-level privileges (parentheses) are not supported. Supply plain privilege names only.');
|
|
114
|
-
}
|
|
115
|
-
const tokens = privileges.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
116
|
-
if (tokens.length === 0) {
|
|
117
|
-
throw new Error('No privileges supplied.');
|
|
118
|
-
}
|
|
119
|
-
const normalized: string[] = [];
|
|
120
|
-
for (const tok of tokens) {
|
|
121
|
-
const upper = tok.toUpperCase().replace(/\s+/g, ' ');
|
|
122
|
-
if (!ALLOWED_PRIVILEGES.has(upper)) {
|
|
123
|
-
throw new Error(`Rejected unknown or unsafe privilege: "${tok}". Allowed: ${[...ALLOWED_PRIVILEGES].join(', ')}.`);
|
|
124
|
-
}
|
|
125
|
-
normalized.push(upper);
|
|
126
|
-
}
|
|
127
|
-
return normalized.join(', ');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Joomla nested-set tables — raw lft/rgt writes here are the hazardous ones. */
|
|
131
|
-
const NESTED_SET_SUFFIXES = ['assets', 'categories', 'menu'] as const;
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Find Joomla nested-set tables referenced by a statement. With a known prefix
|
|
135
|
-
* the match is precise (`<prefix>assets`); without one it falls back to matching
|
|
136
|
-
* the bare table names as whole identifiers, which may over-warn but never
|
|
137
|
-
* silently misses a hazardous target.
|
|
138
|
-
*/
|
|
139
|
-
function nestedSetTablesHit(sql: string, prefix: string | null): string[] {
|
|
140
|
-
const names = NESTED_SET_SUFFIXES.map((s) => (prefix !== null ? `${prefix}${s}` : s));
|
|
141
|
-
const hits: string[] = [];
|
|
142
|
-
for (const name of names) {
|
|
143
|
-
// Match the identifier with optional backtick quoting, on word boundaries.
|
|
144
|
-
const re = new RegExp('(^|[^a-z0-9_`])`?' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '`?([^a-z0-9_`]|$)', 'i');
|
|
145
|
-
if (re.test(sql)) hits.push(name);
|
|
146
|
-
}
|
|
147
|
-
return hits;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const ConnectionParam = {
|
|
151
|
-
connection: z.string().optional().describe('Named connection from config (uses default if omitted)'),
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const DatabaseParam = {
|
|
155
|
-
database: z.string().describe('Database name'),
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const TableParam = {
|
|
159
|
-
table: z.string().describe('Table name'),
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const ConfirmParam = {
|
|
163
|
-
confirm: z.boolean().describe('Must be true to execute this destructive operation'),
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const JoomlaTokenParam = {
|
|
167
|
-
confirm_token: z.string().optional().describe(
|
|
168
|
-
'Second-step acknowledgement token for a write to a Joomla database. Leave empty on the ' +
|
|
169
|
-
'first call: the write is NOT executed and a token bound to this exact statement+target is ' +
|
|
170
|
-
'returned. Repeat the SAME call with that token to actually run it. Reads are never gated; ' +
|
|
171
|
-
'non-Joomla databases ignore this and run on confirm alone.',
|
|
172
|
-
),
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
// ── Joomla write gate: mandatory two-step challenge/acknowledge ──────────
|
|
176
|
-
//
|
|
177
|
-
// Writes to a Joomla DB must never execute from a single call (an interrupted or
|
|
178
|
-
// careless raw write silently corrupts the nested-set tables — see issue #39). The
|
|
179
|
-
// first call issues a one-time token bound to the exact statement+target and does
|
|
180
|
-
// NOT run; only a second call echoing that token proceeds.
|
|
181
|
-
//
|
|
182
|
-
// The gate is operation-aware (issue #65): the two-step token is required for EVERY
|
|
183
|
-
// Joomla write, but the nested-set/ACL-corruption warning is reserved for the writes
|
|
184
|
-
// that can actually cause it — INSERT/DELETE of asset-bearing rows, DDL, and any write
|
|
185
|
-
// to a nested-set table. A pure in-place UPDATE that avoids the nested-set tables gets
|
|
186
|
-
// a lighter message, because that coupling lives in the Joomla Table API (store()/
|
|
187
|
-
// delete()), which raw SQL bypasses entirely.
|
|
188
|
-
|
|
189
|
-
interface PendingWrite {
|
|
190
|
-
/** Binds the token to a specific normalized op + connection + schema. */
|
|
191
|
-
bindHash: string;
|
|
192
|
-
/** Epoch ms when issued, for TTL expiry. */
|
|
193
|
-
issuedAt: number;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
197
|
-
const pendingWrites = new Map<string, PendingWrite>();
|
|
198
|
-
|
|
199
|
-
function bindHashFor(op: string, connKey: string, schema: string): string {
|
|
200
|
-
const normalized = op.trim().replace(/\s+/g, ' ');
|
|
201
|
-
// JSON framing keeps the three parts unambiguously separated (no delimiter can
|
|
202
|
-
// bleed across fields) while staying printable text.
|
|
203
|
-
return createHash('sha256').update(JSON.stringify([connKey, schema, normalized])).digest('hex');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
type GateResult = { ok: true } | { ok: false; text: string };
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Enforce the two-step gate for a write `op` (raw SQL, or a synthetic descriptor
|
|
210
|
-
* like `DROP TABLE db.table` for the structured tools). Reads and non-Joomla
|
|
211
|
-
* targets pass straight through. On a Joomla write: a valid matching token runs it
|
|
212
|
-
* (and is consumed); anything else returns a fresh challenge and does NOT execute.
|
|
213
|
-
*/
|
|
214
|
-
async function joomlaWriteGate(
|
|
215
|
-
op: string,
|
|
216
|
-
connection: string | undefined,
|
|
217
|
-
database: string | undefined,
|
|
218
|
-
confirmToken: string | undefined,
|
|
219
|
-
): Promise<GateResult> {
|
|
220
|
-
if (isReadStatement(op)) return { ok: true };
|
|
221
|
-
const j = await client.detectJoomla(connection, database);
|
|
222
|
-
if (!j.isJoomla) return { ok: true };
|
|
223
|
-
|
|
224
|
-
const { connKey, schema } = client.resolveIdentity(connection, database);
|
|
225
|
-
const bindHash = bindHashFor(op, connKey, schema);
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
|
|
228
|
-
if (confirmToken) {
|
|
229
|
-
const pending = pendingWrites.get(confirmToken);
|
|
230
|
-
if (pending && pending.bindHash === bindHash && now - pending.issuedAt <= TOKEN_TTL_MS) {
|
|
231
|
-
pendingWrites.delete(confirmToken); // one-time use
|
|
232
|
-
return { ok: true };
|
|
233
|
-
}
|
|
234
|
-
// Mismatched / expired / unknown token → fall through and re-challenge.
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const token = randomBytes(9).toString('hex');
|
|
238
|
-
pendingWrites.set(token, { bindHash, issuedAt: now });
|
|
239
|
-
const nested = nestedSetTablesHit(op, j.prefix);
|
|
240
|
-
const writeOp = classifyWrite(op);
|
|
241
|
-
// A pure in-place UPDATE that does not reference a nested-set table cannot create,
|
|
242
|
-
// delete, or renumber an #__assets / nested-set row (that coupling lives in the Joomla
|
|
243
|
-
// Table API, which raw SQL bypasses — see classifyWrite). Such statements still get the
|
|
244
|
-
// two-step gate (an interrupted or mis-scoped raw write can corrupt row data), but NOT
|
|
245
|
-
// the misleading nested-set/ACL-corruption framing. Everything else — INSERT/DELETE,
|
|
246
|
-
// DDL, anything unrecognized, and any write to a nested-set table — keeps it.
|
|
247
|
-
const inPlaceUpdate = writeOp === 'UPDATE' && nested.length === 0;
|
|
248
|
-
const lines = [
|
|
249
|
-
'⛔ WRITE to a Joomla database — a second confirmation is REQUIRED. This call did NOT execute.',
|
|
250
|
-
'',
|
|
251
|
-
];
|
|
252
|
-
if (inPlaceUpdate) {
|
|
253
|
-
lines.push(
|
|
254
|
-
'This is an in-place UPDATE that does not touch a nested-set table (assets / categories / menu),',
|
|
255
|
-
'so it will NOT create, remove, or renumber ACL asset rows or nested sets — Joomla only mutates',
|
|
256
|
-
'those on row creation/deletion, which raw SQL bypasses. The two-step confirm still applies because',
|
|
257
|
-
'an interrupted or mis-scoped raw write can corrupt row data. To proceed, repeat the SAME call adding:',
|
|
258
|
-
` confirm_token: "${token}"`,
|
|
259
|
-
'(one-time, valid ~5 min, bound to this exact statement + target).',
|
|
260
|
-
);
|
|
261
|
-
} else {
|
|
262
|
-
lines.push(
|
|
263
|
-
'Joomla tables carry structural invariants (nested sets, ACL) that raw SQL can corrupt, and an',
|
|
264
|
-
'interrupted write can leave the database broken. To proceed, repeat the SAME call adding:',
|
|
265
|
-
` confirm_token: "${token}"`,
|
|
266
|
-
'(one-time, valid ~5 min, bound to this exact statement + target).',
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
if (nested.length) {
|
|
270
|
-
lines.push(
|
|
271
|
-
'',
|
|
272
|
-
`⚠ Targets nested-set table(s): ${nested.join(', ')}.`,
|
|
273
|
-
'Strongly prefer the Joomla Web Services API / CLI, which maintains lft/rgt automatically.',
|
|
274
|
-
'If you must write raw, complete the whole operation in one go, then verify integrity:',
|
|
275
|
-
'max(rgt) == 2 × node_count, no duplicate lft/rgt, no lft >= rgt, children within parent bounds.',
|
|
276
|
-
);
|
|
277
|
-
} else if (writeOp === 'INSERT' || writeOp === 'DELETE') {
|
|
278
|
-
lines.push(
|
|
279
|
-
'',
|
|
280
|
-
`⚠ Raw ${writeOp} of a row. If the target is asset-bearing (any table with an asset_id column —`,
|
|
281
|
-
'articles, categories, modules, contacts, banners, newsfeeds, fields, tags, …), Joomla pairs each',
|
|
282
|
-
'row with a #__assets ACL row via Table::store()/delete(). Raw SQL bypasses that: an INSERT leaves',
|
|
283
|
-
'the new row with no asset row (broken ACL), a DELETE orphans the asset row. Strongly prefer the',
|
|
284
|
-
'Joomla Web Services API / CLI, which maintains the asset tree automatically.',
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
return { ok: false, text: lines.join('\n') };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const LimitParam = {
|
|
291
|
-
limit: z.number().optional().describe('Max rows to return (default 500, max 10000)'),
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const server = new McpServer({
|
|
295
|
-
name: '@mokoconsulting/mokomysql-mcp',
|
|
296
|
-
version: '1.0.0',
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// ── Connection & Server Info ────────────────────────────────────────────
|
|
300
|
-
|
|
301
|
-
server.tool(
|
|
302
|
-
'mysql_connections_list',
|
|
303
|
-
'List all configured MySQL connection names with host/port/user (no passwords)',
|
|
304
|
-
{ ...ConnectionParam },
|
|
305
|
-
async () => {
|
|
306
|
-
const entries = Object.entries(config.connections).map(([name, c]) => ({
|
|
307
|
-
name,
|
|
308
|
-
host: c.host,
|
|
309
|
-
port: c.port ?? 3306,
|
|
310
|
-
user: c.user,
|
|
311
|
-
database: c.database ?? '(none)',
|
|
312
|
-
default: name === config.defaultConnection,
|
|
313
|
-
}));
|
|
314
|
-
return formatRows(entries);
|
|
315
|
-
},
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
server.tool(
|
|
319
|
-
'mysql_server_version',
|
|
320
|
-
'Get MySQL server version and uptime',
|
|
321
|
-
{ ...ConnectionParam },
|
|
322
|
-
async ({ connection }) => {
|
|
323
|
-
try {
|
|
324
|
-
const { rows } = await client.query(
|
|
325
|
-
"SELECT VERSION() AS version, @@hostname AS hostname, @@port AS port, " +
|
|
326
|
-
"VARIABLE_VALUE AS uptime FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Uptime'",
|
|
327
|
-
[], connection,
|
|
328
|
-
);
|
|
329
|
-
return formatRows(rows);
|
|
330
|
-
} catch (e) {
|
|
331
|
-
return formatError(e);
|
|
332
|
-
}
|
|
333
|
-
},
|
|
334
|
-
);
|
|
335
|
-
|
|
336
|
-
server.tool(
|
|
337
|
-
'mysql_server_status',
|
|
338
|
-
'Show MySQL server status variables (SHOW GLOBAL STATUS)',
|
|
339
|
-
{ ...ConnectionParam, filter: z.string().optional().describe('LIKE pattern to filter (e.g. "Threads%")') },
|
|
340
|
-
async ({ connection, filter }) => {
|
|
341
|
-
try {
|
|
342
|
-
const sql = filter ? `SHOW GLOBAL STATUS LIKE ?` : 'SHOW GLOBAL STATUS';
|
|
343
|
-
const params = filter ? [filter] : [];
|
|
344
|
-
const { rows } = await client.query(sql, params, connection);
|
|
345
|
-
return formatRows(rows);
|
|
346
|
-
} catch (e) {
|
|
347
|
-
return formatError(e);
|
|
348
|
-
}
|
|
349
|
-
},
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
server.tool(
|
|
353
|
-
'mysql_server_variables',
|
|
354
|
-
'Show MySQL system variables (SHOW GLOBAL VARIABLES)',
|
|
355
|
-
{ ...ConnectionParam, filter: z.string().optional().describe('LIKE pattern to filter (e.g. "innodb%")') },
|
|
356
|
-
async ({ connection, filter }) => {
|
|
357
|
-
try {
|
|
358
|
-
const sql = filter ? `SHOW GLOBAL VARIABLES LIKE ?` : 'SHOW GLOBAL VARIABLES';
|
|
359
|
-
const params = filter ? [filter] : [];
|
|
360
|
-
const { rows } = await client.query(sql, params, connection);
|
|
361
|
-
return formatRows(rows);
|
|
362
|
-
} catch (e) {
|
|
363
|
-
return formatError(e);
|
|
364
|
-
}
|
|
365
|
-
},
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
// ── Database Management ─────────────────────────────────────────────────
|
|
369
|
-
|
|
370
|
-
server.tool(
|
|
371
|
-
'mysql_databases_list',
|
|
372
|
-
'List all databases with sizes',
|
|
373
|
-
{ ...ConnectionParam },
|
|
374
|
-
async ({ connection }) => {
|
|
375
|
-
try {
|
|
376
|
-
const { rows } = await client.query(
|
|
377
|
-
"SELECT s.SCHEMA_NAME AS `database`, " +
|
|
378
|
-
"ROUND(SUM(t.DATA_LENGTH + t.INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb, " +
|
|
379
|
-
"COUNT(t.TABLE_NAME) AS tables_count, " +
|
|
380
|
-
"s.DEFAULT_CHARACTER_SET_NAME AS charset, " +
|
|
381
|
-
"s.DEFAULT_COLLATION_NAME AS collation " +
|
|
382
|
-
"FROM information_schema.SCHEMATA s " +
|
|
383
|
-
"LEFT JOIN information_schema.TABLES t ON s.SCHEMA_NAME = t.TABLE_SCHEMA " +
|
|
384
|
-
"GROUP BY s.SCHEMA_NAME ORDER BY size_mb DESC",
|
|
385
|
-
[], connection,
|
|
386
|
-
);
|
|
387
|
-
return formatRows(rows);
|
|
388
|
-
} catch (e) {
|
|
389
|
-
return formatError(e);
|
|
390
|
-
}
|
|
391
|
-
},
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
server.tool(
|
|
395
|
-
'mysql_database_create',
|
|
396
|
-
'Create a new database',
|
|
397
|
-
{ ...ConnectionParam, ...DatabaseParam, charset: z.string().optional().describe('Character set (default utf8mb4)'), collation: z.string().optional().describe('Collation (default utf8mb4_unicode_ci)') },
|
|
398
|
-
async ({ connection, database, charset, collation }) => {
|
|
399
|
-
try {
|
|
400
|
-
const cs = charset ?? 'utf8mb4';
|
|
401
|
-
const co = collation ?? 'utf8mb4_unicode_ci';
|
|
402
|
-
const r = await client.execute(
|
|
403
|
-
`CREATE DATABASE ${esc(database)} CHARACTER SET ${esc(cs)} COLLATE ${esc(co)}`,
|
|
404
|
-
[], connection,
|
|
405
|
-
);
|
|
406
|
-
return formatResult(r);
|
|
407
|
-
} catch (e) {
|
|
408
|
-
return formatError(e);
|
|
409
|
-
}
|
|
410
|
-
},
|
|
411
|
-
);
|
|
412
|
-
|
|
413
|
-
server.tool(
|
|
414
|
-
'mysql_database_drop',
|
|
415
|
-
'Drop a database — DESTRUCTIVE, requires confirm=true. If the database is Joomla, also requires the two-step confirm_token.',
|
|
416
|
-
{ ...ConnectionParam, ...DatabaseParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
417
|
-
async ({ connection, database, confirm, confirm_token }) => {
|
|
418
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true to drop a database.' }] };
|
|
419
|
-
const op = `DROP DATABASE ${esc(database)}`;
|
|
420
|
-
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
421
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
422
|
-
try {
|
|
423
|
-
const r = await client.execute(op, [], connection);
|
|
424
|
-
return formatResult(r);
|
|
425
|
-
} catch (e) {
|
|
426
|
-
return formatError(e);
|
|
427
|
-
}
|
|
428
|
-
},
|
|
429
|
-
);
|
|
430
|
-
|
|
431
|
-
server.tool(
|
|
432
|
-
'mysql_database_size',
|
|
433
|
-
'Show database size breakdown by table',
|
|
434
|
-
{ ...ConnectionParam, ...DatabaseParam },
|
|
435
|
-
async ({ connection, database }) => {
|
|
436
|
-
try {
|
|
437
|
-
const { rows } = await client.query(
|
|
438
|
-
"SELECT TABLE_NAME AS `table`, TABLE_ROWS AS rows_estimate, " +
|
|
439
|
-
"ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb, " +
|
|
440
|
-
"ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb, " +
|
|
441
|
-
"ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb, " +
|
|
442
|
-
"ENGINE AS engine " +
|
|
443
|
-
"FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY total_mb DESC",
|
|
444
|
-
[database], connection,
|
|
445
|
-
);
|
|
446
|
-
return formatRows(rows);
|
|
447
|
-
} catch (e) {
|
|
448
|
-
return formatError(e);
|
|
449
|
-
}
|
|
450
|
-
},
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
// ── Table Management ────────────────────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
server.tool(
|
|
456
|
-
'mysql_tables_list',
|
|
457
|
-
'List all tables in a database with engine, row count estimate, and size',
|
|
458
|
-
{ ...ConnectionParam, ...DatabaseParam },
|
|
459
|
-
async ({ connection, database }) => {
|
|
460
|
-
try {
|
|
461
|
-
const { rows } = await client.query(
|
|
462
|
-
"SELECT TABLE_NAME AS `table`, ENGINE AS engine, TABLE_ROWS AS rows_estimate, " +
|
|
463
|
-
"ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024, 2) AS size_kb, " +
|
|
464
|
-
"TABLE_COLLATION AS collation, AUTO_INCREMENT AS auto_increment " +
|
|
465
|
-
"FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME",
|
|
466
|
-
[database], connection,
|
|
467
|
-
);
|
|
468
|
-
return formatRows(rows);
|
|
469
|
-
} catch (e) {
|
|
470
|
-
return formatError(e);
|
|
471
|
-
}
|
|
472
|
-
},
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
server.tool(
|
|
476
|
-
'mysql_table_describe',
|
|
477
|
-
'Show columns, types, keys, and indexes for a table',
|
|
478
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
479
|
-
async ({ connection, database, table }) => {
|
|
480
|
-
try {
|
|
481
|
-
const { rows: cols } = await client.query(
|
|
482
|
-
`SHOW FULL COLUMNS FROM ${esc(database)}.${esc(table)}`,
|
|
483
|
-
[], connection,
|
|
484
|
-
);
|
|
485
|
-
const { rows: idxs } = await client.query(
|
|
486
|
-
`SHOW INDEX FROM ${esc(database)}.${esc(table)}`,
|
|
487
|
-
[], connection,
|
|
488
|
-
);
|
|
489
|
-
return { content: [{ type: 'text' as const, text: `Columns:\n${JSON.stringify(cols, null, 2)}\n\nIndexes:\n${JSON.stringify(idxs, null, 2)}` }] };
|
|
490
|
-
} catch (e) {
|
|
491
|
-
return formatError(e);
|
|
492
|
-
}
|
|
493
|
-
},
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
server.tool(
|
|
497
|
-
'mysql_table_create',
|
|
498
|
-
'Execute a CREATE TABLE DDL statement. On a Joomla database this requires the two-step confirm_token.',
|
|
499
|
-
{ ...ConnectionParam, sql: z.string().describe('Full CREATE TABLE SQL statement'), ...JoomlaTokenParam },
|
|
500
|
-
async ({ connection, sql, confirm_token }) => {
|
|
501
|
-
const gate = await joomlaWriteGate(sql, connection, undefined, confirm_token);
|
|
502
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
503
|
-
try {
|
|
504
|
-
const r = await client.execute(sql, [], connection);
|
|
505
|
-
return formatResult(r);
|
|
506
|
-
} catch (e) {
|
|
507
|
-
return formatError(e);
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
server.tool(
|
|
513
|
-
'mysql_table_drop',
|
|
514
|
-
'Drop a table — DESTRUCTIVE, requires confirm=true. On a Joomla database also requires the two-step confirm_token.',
|
|
515
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
516
|
-
async ({ connection, database, table, confirm, confirm_token }) => {
|
|
517
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
518
|
-
const op = `DROP TABLE ${esc(database)}.${esc(table)}`;
|
|
519
|
-
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
520
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
521
|
-
try {
|
|
522
|
-
const r = await client.execute(op, [], connection);
|
|
523
|
-
return formatResult(r);
|
|
524
|
-
} catch (e) {
|
|
525
|
-
return formatError(e);
|
|
526
|
-
}
|
|
527
|
-
},
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
server.tool(
|
|
531
|
-
'mysql_table_truncate',
|
|
532
|
-
'Truncate a table (removes all rows, resets auto-increment) — DESTRUCTIVE, requires confirm=true. On a Joomla database also requires the two-step confirm_token.',
|
|
533
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
534
|
-
async ({ connection, database, table, confirm, confirm_token }) => {
|
|
535
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
536
|
-
const op = `TRUNCATE TABLE ${esc(database)}.${esc(table)}`;
|
|
537
|
-
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
538
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
539
|
-
try {
|
|
540
|
-
const r = await client.execute(op, [], connection);
|
|
541
|
-
return formatResult(r);
|
|
542
|
-
} catch (e) {
|
|
543
|
-
return formatError(e);
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
);
|
|
547
|
-
|
|
548
|
-
server.tool(
|
|
549
|
-
'mysql_table_rename',
|
|
550
|
-
'Rename a table. On a Joomla database this requires the two-step confirm_token.',
|
|
551
|
-
{ ...ConnectionParam, ...DatabaseParam, from: z.string().describe('Current table name'), to: z.string().describe('New table name'), ...JoomlaTokenParam },
|
|
552
|
-
async ({ connection, database, from, to, confirm_token }) => {
|
|
553
|
-
const op = `RENAME TABLE ${esc(database)}.${esc(from)} TO ${esc(database)}.${esc(to)}`;
|
|
554
|
-
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
555
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
556
|
-
try {
|
|
557
|
-
const r = await client.execute(op, [], connection);
|
|
558
|
-
return formatResult(r);
|
|
559
|
-
} catch (e) {
|
|
560
|
-
return formatError(e);
|
|
561
|
-
}
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
// ── Query Execution ─────────────────────────────────────────────────────
|
|
566
|
-
|
|
567
|
-
server.tool(
|
|
568
|
-
'mysql_query_select',
|
|
569
|
-
'Execute a SELECT query and return results as JSON (auto-limited to prevent huge results)',
|
|
570
|
-
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('SELECT query to execute'), ...LimitParam },
|
|
571
|
-
async ({ connection, database, sql, limit }) => {
|
|
572
|
-
try {
|
|
573
|
-
if (!isReadStatement(sql)) {
|
|
574
|
-
return formatError(new Error('mysql_query_select only runs read-only statements (SELECT/SHOW/EXPLAIN/DESCRIBE). Use mysql_query_execute for writes.'));
|
|
575
|
-
}
|
|
576
|
-
const max = Math.min(limit ?? 500, 10000);
|
|
577
|
-
const hasLimit = /\bLIMIT\s+\d/i.test(sql);
|
|
578
|
-
const finalSql = hasLimit ? sql : `${sql} LIMIT ${max}`;
|
|
579
|
-
const { rows } = await client.query(finalSql, [], connection, database);
|
|
580
|
-
return formatRows(rows);
|
|
581
|
-
} catch (e) {
|
|
582
|
-
return formatError(e);
|
|
583
|
-
}
|
|
584
|
-
},
|
|
585
|
-
);
|
|
586
|
-
|
|
587
|
-
server.tool(
|
|
588
|
-
'mysql_query_execute',
|
|
589
|
-
'Execute INSERT, UPDATE, DELETE, or DDL — requires confirm=true. Writes to a Joomla database ' +
|
|
590
|
-
'additionally require the two-step confirm_token (first call returns a token and does NOT run; ' +
|
|
591
|
-
'repeat with the token to execute). Reads stay unrestricted.',
|
|
592
|
-
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('SQL statement to execute'), ...ConfirmParam, ...JoomlaTokenParam },
|
|
593
|
-
async ({ connection, database, sql, confirm, confirm_token }) => {
|
|
594
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true to execute write operations.' }] };
|
|
595
|
-
const gate = await joomlaWriteGate(sql, connection, database, confirm_token);
|
|
596
|
-
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
597
|
-
try {
|
|
598
|
-
const r = await client.execute(sql, [], connection, database);
|
|
599
|
-
const result = formatResult(r);
|
|
600
|
-
// Surface a post-write integrity reminder for nested-set tables.
|
|
601
|
-
if (!isReadStatement(sql)) {
|
|
602
|
-
const j = await client.detectJoomla(connection, database);
|
|
603
|
-
const nested = j.isJoomla ? nestedSetTablesHit(sql, j.prefix) : [];
|
|
604
|
-
if (nested.length) {
|
|
605
|
-
result.content.unshift({
|
|
606
|
-
type: 'text' as const,
|
|
607
|
-
text: `⚠ Wrote to Joomla nested-set table(s): ${nested.join(', ')}. ` +
|
|
608
|
-
'Verify integrity (max(rgt) == 2 × node_count, no dup/overlapping lft/rgt) before relying on the data.\n',
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return result;
|
|
613
|
-
} catch (e) {
|
|
614
|
-
return formatError(e);
|
|
615
|
-
}
|
|
616
|
-
},
|
|
617
|
-
);
|
|
618
|
-
|
|
619
|
-
server.tool(
|
|
620
|
-
'mysql_query_explain',
|
|
621
|
-
'Run EXPLAIN on a query to show execution plan',
|
|
622
|
-
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('Query to explain') },
|
|
623
|
-
async ({ connection, database, sql }) => {
|
|
624
|
-
try {
|
|
625
|
-
if (!isReadStatement(sql)) {
|
|
626
|
-
return formatError(new Error('mysql_query_explain only explains read-only statements (SELECT/SHOW/EXPLAIN/DESCRIBE).'));
|
|
627
|
-
}
|
|
628
|
-
const { rows } = await client.query(`EXPLAIN ${sql}`, [], connection, database);
|
|
629
|
-
return formatRows(rows);
|
|
630
|
-
} catch (e) {
|
|
631
|
-
return formatError(e);
|
|
632
|
-
}
|
|
633
|
-
},
|
|
634
|
-
);
|
|
635
|
-
|
|
636
|
-
// ── Schema Inspection ───────────────────────────────────────────────────
|
|
637
|
-
|
|
638
|
-
server.tool(
|
|
639
|
-
'mysql_schema_show_create_table',
|
|
640
|
-
'Show the CREATE TABLE DDL for a table',
|
|
641
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
642
|
-
async ({ connection, database, table }) => {
|
|
643
|
-
try {
|
|
644
|
-
const { rows } = await client.query(
|
|
645
|
-
`SHOW CREATE TABLE ${esc(database)}.${esc(table)}`,
|
|
646
|
-
[], connection,
|
|
647
|
-
);
|
|
648
|
-
const ddl = (rows[0] as Record<string, unknown>)?.['Create Table'] ?? '';
|
|
649
|
-
return { content: [{ type: 'text' as const, text: String(ddl) }] };
|
|
650
|
-
} catch (e) {
|
|
651
|
-
return formatError(e);
|
|
652
|
-
}
|
|
653
|
-
},
|
|
654
|
-
);
|
|
655
|
-
|
|
656
|
-
server.tool(
|
|
657
|
-
'mysql_schema_indexes',
|
|
658
|
-
'List all indexes on a table',
|
|
659
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
660
|
-
async ({ connection, database, table }) => {
|
|
661
|
-
try {
|
|
662
|
-
const { rows } = await client.query(
|
|
663
|
-
`SHOW INDEX FROM ${esc(database)}.${esc(table)}`,
|
|
664
|
-
[], connection,
|
|
665
|
-
);
|
|
666
|
-
return formatRows(rows);
|
|
667
|
-
} catch (e) {
|
|
668
|
-
return formatError(e);
|
|
669
|
-
}
|
|
670
|
-
},
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
server.tool(
|
|
674
|
-
'mysql_schema_foreign_keys',
|
|
675
|
-
'List all foreign key constraints on a table',
|
|
676
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
677
|
-
async ({ connection, database, table }) => {
|
|
678
|
-
try {
|
|
679
|
-
const { rows } = await client.query(
|
|
680
|
-
"SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, " +
|
|
681
|
-
"REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME " +
|
|
682
|
-
"FROM information_schema.KEY_COLUMN_USAGE " +
|
|
683
|
-
"WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL",
|
|
684
|
-
[database, table], connection,
|
|
685
|
-
);
|
|
686
|
-
return formatRows(rows);
|
|
687
|
-
} catch (e) {
|
|
688
|
-
return formatError(e);
|
|
689
|
-
}
|
|
690
|
-
},
|
|
691
|
-
);
|
|
692
|
-
|
|
693
|
-
server.tool(
|
|
694
|
-
'mysql_schema_full',
|
|
695
|
-
'Export CREATE TABLE DDL for all tables in a database',
|
|
696
|
-
{ ...ConnectionParam, ...DatabaseParam },
|
|
697
|
-
async ({ connection, database }) => {
|
|
698
|
-
try {
|
|
699
|
-
const { rows: tables } = await client.query(
|
|
700
|
-
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME",
|
|
701
|
-
[database], connection,
|
|
702
|
-
);
|
|
703
|
-
const ddls: string[] = [];
|
|
704
|
-
for (const t of tables) {
|
|
705
|
-
const name = String((t as Record<string, unknown>).TABLE_NAME);
|
|
706
|
-
const { rows } = await client.query(`SHOW CREATE TABLE ${esc(database)}.${esc(name)}`, [], connection);
|
|
707
|
-
const ddl = String((rows[0] as Record<string, unknown>)?.['Create Table'] ?? '');
|
|
708
|
-
ddls.push(`-- Table: ${name}\n${ddl};\n`);
|
|
709
|
-
}
|
|
710
|
-
return { content: [{ type: 'text' as const, text: ddls.join('\n') }] };
|
|
711
|
-
} catch (e) {
|
|
712
|
-
return formatError(e);
|
|
713
|
-
}
|
|
714
|
-
},
|
|
715
|
-
);
|
|
716
|
-
|
|
717
|
-
// ── User Management ─────────────────────────────────────────────────────
|
|
718
|
-
|
|
719
|
-
server.tool(
|
|
720
|
-
'mysql_users_list',
|
|
721
|
-
'List all MySQL users with host and authentication plugin',
|
|
722
|
-
{ ...ConnectionParam },
|
|
723
|
-
async ({ connection }) => {
|
|
724
|
-
try {
|
|
725
|
-
const { rows } = await client.query(
|
|
726
|
-
"SELECT User, Host, plugin, account_locked FROM mysql.user ORDER BY User, Host",
|
|
727
|
-
[], connection,
|
|
728
|
-
);
|
|
729
|
-
return formatRows(rows);
|
|
730
|
-
} catch (e) {
|
|
731
|
-
return formatError(e);
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
);
|
|
735
|
-
|
|
736
|
-
server.tool(
|
|
737
|
-
'mysql_user_grants',
|
|
738
|
-
'Show grants for a MySQL user',
|
|
739
|
-
{ ...ConnectionParam, user: z.string().describe('Username'), host: z.string().optional().describe('Host (default %)') },
|
|
740
|
-
async ({ connection, user, host }) => {
|
|
741
|
-
try {
|
|
742
|
-
const h = host ?? '%';
|
|
743
|
-
const { rows } = await client.query(`SHOW GRANTS FOR ?@?`, [user, h], connection);
|
|
744
|
-
return formatRows(rows);
|
|
745
|
-
} catch (e) {
|
|
746
|
-
return formatError(e);
|
|
747
|
-
}
|
|
748
|
-
},
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
server.tool(
|
|
752
|
-
'mysql_user_create',
|
|
753
|
-
'Create a MySQL user',
|
|
754
|
-
{ ...ConnectionParam, user: z.string(), host: z.string().optional().describe('Host (default localhost)'), password: z.string() },
|
|
755
|
-
async ({ connection, user, host, password }) => {
|
|
756
|
-
try {
|
|
757
|
-
const h = host ?? 'localhost';
|
|
758
|
-
const r = await client.execute(
|
|
759
|
-
`CREATE USER ${esc(user)}@${esc(h)} IDENTIFIED BY ?`,
|
|
760
|
-
[password], connection,
|
|
761
|
-
);
|
|
762
|
-
return formatResult(r);
|
|
763
|
-
} catch (e) {
|
|
764
|
-
return formatError(e);
|
|
765
|
-
}
|
|
766
|
-
},
|
|
767
|
-
);
|
|
768
|
-
|
|
769
|
-
server.tool(
|
|
770
|
-
'mysql_user_drop',
|
|
771
|
-
'Drop a MySQL user — DESTRUCTIVE, requires confirm=true',
|
|
772
|
-
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), ...ConfirmParam },
|
|
773
|
-
async ({ connection, user, host, confirm }) => {
|
|
774
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
775
|
-
try {
|
|
776
|
-
const h = host ?? '%';
|
|
777
|
-
const r = await client.execute(`DROP USER ${esc(user)}@${esc(h)}`, [], connection);
|
|
778
|
-
return formatResult(r);
|
|
779
|
-
} catch (e) {
|
|
780
|
-
return formatError(e);
|
|
781
|
-
}
|
|
782
|
-
},
|
|
783
|
-
);
|
|
784
|
-
|
|
785
|
-
server.tool(
|
|
786
|
-
'mysql_user_grant',
|
|
787
|
-
'Grant privileges to a user on a database',
|
|
788
|
-
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), privileges: z.string().describe('e.g. "SELECT, INSERT" or "ALL PRIVILEGES"'), database: z.string(), table: z.string().optional().describe('Table name (default * for all)') },
|
|
789
|
-
async ({ connection, user, host, privileges, database, table }) => {
|
|
790
|
-
try {
|
|
791
|
-
const safePrivileges = validatePrivileges(privileges);
|
|
792
|
-
const h = host ?? '%';
|
|
793
|
-
const t = table ?? '*';
|
|
794
|
-
const r = await client.execute(
|
|
795
|
-
`GRANT ${safePrivileges} ON ${esc(database)}.${t === '*' ? '*' : esc(t)} TO ${esc(user)}@${esc(h)}`,
|
|
796
|
-
[], connection,
|
|
797
|
-
);
|
|
798
|
-
await client.execute('FLUSH PRIVILEGES', [], connection);
|
|
799
|
-
return formatResult(r);
|
|
800
|
-
} catch (e) {
|
|
801
|
-
return formatError(e);
|
|
802
|
-
}
|
|
803
|
-
},
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
server.tool(
|
|
807
|
-
'mysql_user_revoke',
|
|
808
|
-
'Revoke privileges from a user',
|
|
809
|
-
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), privileges: z.string(), database: z.string(), table: z.string().optional() },
|
|
810
|
-
async ({ connection, user, host, privileges, database, table }) => {
|
|
811
|
-
try {
|
|
812
|
-
const safePrivileges = validatePrivileges(privileges);
|
|
813
|
-
const h = host ?? '%';
|
|
814
|
-
const t = table ?? '*';
|
|
815
|
-
const r = await client.execute(
|
|
816
|
-
`REVOKE ${safePrivileges} ON ${esc(database)}.${t === '*' ? '*' : esc(t)} FROM ${esc(user)}@${esc(h)}`,
|
|
817
|
-
[], connection,
|
|
818
|
-
);
|
|
819
|
-
await client.execute('FLUSH PRIVILEGES', [], connection);
|
|
820
|
-
return formatResult(r);
|
|
821
|
-
} catch (e) {
|
|
822
|
-
return formatError(e);
|
|
823
|
-
}
|
|
824
|
-
},
|
|
825
|
-
);
|
|
826
|
-
|
|
827
|
-
// ── Maintenance ─────────────────────────────────────────────────────────
|
|
828
|
-
|
|
829
|
-
server.tool(
|
|
830
|
-
'mysql_maintenance_optimize',
|
|
831
|
-
'Run OPTIMIZE TABLE to reclaim space and defragment',
|
|
832
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
833
|
-
async ({ connection, database, table }) => {
|
|
834
|
-
try {
|
|
835
|
-
const { rows } = await client.query(`OPTIMIZE TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
836
|
-
return formatRows(rows);
|
|
837
|
-
} catch (e) {
|
|
838
|
-
return formatError(e);
|
|
839
|
-
}
|
|
840
|
-
},
|
|
841
|
-
);
|
|
842
|
-
|
|
843
|
-
server.tool(
|
|
844
|
-
'mysql_maintenance_analyze',
|
|
845
|
-
'Run ANALYZE TABLE to update index statistics',
|
|
846
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
847
|
-
async ({ connection, database, table }) => {
|
|
848
|
-
try {
|
|
849
|
-
const { rows } = await client.query(`ANALYZE TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
850
|
-
return formatRows(rows);
|
|
851
|
-
} catch (e) {
|
|
852
|
-
return formatError(e);
|
|
853
|
-
}
|
|
854
|
-
},
|
|
855
|
-
);
|
|
856
|
-
|
|
857
|
-
server.tool(
|
|
858
|
-
'mysql_maintenance_check',
|
|
859
|
-
'Run CHECK TABLE to verify table integrity',
|
|
860
|
-
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
861
|
-
async ({ connection, database, table }) => {
|
|
862
|
-
try {
|
|
863
|
-
const { rows } = await client.query(`CHECK TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
864
|
-
return formatRows(rows);
|
|
865
|
-
} catch (e) {
|
|
866
|
-
return formatError(e);
|
|
867
|
-
}
|
|
868
|
-
},
|
|
869
|
-
);
|
|
870
|
-
|
|
871
|
-
server.tool(
|
|
872
|
-
'mysql_processlist',
|
|
873
|
-
'Show running MySQL processes',
|
|
874
|
-
{ ...ConnectionParam },
|
|
875
|
-
async ({ connection }) => {
|
|
876
|
-
try {
|
|
877
|
-
const { rows } = await client.query('SHOW FULL PROCESSLIST', [], connection);
|
|
878
|
-
return formatRows(rows);
|
|
879
|
-
} catch (e) {
|
|
880
|
-
return formatError(e);
|
|
881
|
-
}
|
|
882
|
-
},
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
server.tool(
|
|
886
|
-
'mysql_process_kill',
|
|
887
|
-
'Kill a MySQL process by ID — requires confirm=true',
|
|
888
|
-
{ ...ConnectionParam, process_id: z.number().describe('Process ID to kill'), ...ConfirmParam },
|
|
889
|
-
async ({ connection, process_id, confirm }) => {
|
|
890
|
-
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
891
|
-
try {
|
|
892
|
-
const r = await client.execute(`KILL ${Number(process_id)}`, [], connection);
|
|
893
|
-
return formatResult(r);
|
|
894
|
-
} catch (e) {
|
|
895
|
-
return formatError(e);
|
|
896
|
-
}
|
|
897
|
-
},
|
|
898
|
-
);
|
|
899
|
-
|
|
900
|
-
// ── Export ───────────────────────────────────────────────────────────────
|
|
901
|
-
|
|
902
|
-
server.tool(
|
|
903
|
-
'mysql_export_table_sql',
|
|
904
|
-
'Export table structure and data as SQL INSERT statements (no shell exec)'
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
);
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
);
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
'
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
);
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
);
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
);
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
process.exit(
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
3
|
+
*
|
|
4
|
+
* This file is part of a Moko Consulting project.
|
|
5
|
+
*
|
|
6
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
7
|
+
*
|
|
8
|
+
* FILE INFORMATION
|
|
9
|
+
* DEFGROUP: mokomysql.Server
|
|
10
|
+
* INGROUP: mokomysql
|
|
11
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
12
|
+
* PATH: /src/index.ts
|
|
13
|
+
* VERSION: 01.00.00
|
|
14
|
+
* BRIEF: MCP server entry point — registers all MySQL management tools
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { loadConfig } from './config.js';
|
|
22
|
+
import { MysqlClient } from './client.js';
|
|
23
|
+
import type { MySqlConfig } from './types.js';
|
|
24
|
+
|
|
25
|
+
export let config: MySqlConfig;
|
|
26
|
+
export let client: MysqlClient;
|
|
27
|
+
|
|
28
|
+
function formatRows(rows: Record<string, unknown>[]): { content: Array<{ type: 'text'; text: string }> } {
|
|
29
|
+
const text = rows.length === 0
|
|
30
|
+
? 'No rows returned.'
|
|
31
|
+
: JSON.stringify(rows, null, 2);
|
|
32
|
+
return { content: [{ type: 'text' as const, text: `${rows.length} row(s)\n${text}` }] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatResult(r: { affectedRows: number; insertId: number; changedRows: number }): { content: Array<{ type: 'text'; text: string }> } {
|
|
36
|
+
return { content: [{ type: 'text' as const, text: `Affected rows: ${r.affectedRows} | Insert ID: ${r.insertId} | Changed rows: ${r.changedRows}` }] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatError(err: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
40
|
+
const e = err as { code?: string; message?: string };
|
|
41
|
+
const code = e.code ? ` [${e.code}]` : '';
|
|
42
|
+
const msg = e.message ?? String(err);
|
|
43
|
+
return { content: [{ type: 'text' as const, text: `MySQL Error${code}: ${msg}` }] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function esc(identifier: string): string {
|
|
47
|
+
return '`' + identifier.replace(/`/g, '``') + '`';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify a statement as a read. Only SELECT/SHOW/EXPLAIN/DESCRIBE are treated
|
|
52
|
+
* as reads (they stay unrestricted on Joomla DBs); everything else — including
|
|
53
|
+
* `WITH`, `SET`, and all DML/DDL — is conservatively treated as a write so the
|
|
54
|
+
* double-confirm gate errs toward safety. Leading comments/whitespace are stripped.
|
|
55
|
+
*/
|
|
56
|
+
function isReadStatement(sql: string): boolean {
|
|
57
|
+
const stripped = sql
|
|
58
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ') // /* block comments */
|
|
59
|
+
.replace(/^\s*(--|#)[^\n]*$/gm, ' ') // -- and # line comments
|
|
60
|
+
.trim();
|
|
61
|
+
return /^(SELECT|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i.test(stripped);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Classify a write statement by its leading keyword, so the gate can tell apart the
|
|
66
|
+
* operations that actually mutate Joomla's structural invariants from those that don't.
|
|
67
|
+
*
|
|
68
|
+
* The asset/nested-set coupling lives in the Joomla Table API, which raw SQL bypasses
|
|
69
|
+
* (verified against joomla-cms 5.2.3): `Table::store()` creates/updates a `#__assets`
|
|
70
|
+
* row and only renumbers the nested set when a node is NEW or MOVED (Table.php:913-916;
|
|
71
|
+
* Nested.php:696,720); `Table::delete()` removes the asset row before the entity row
|
|
72
|
+
* (Table.php:1053-1065). A raw in-place `UPDATE` never enters `store()`, so it cannot
|
|
73
|
+
* create, delete, or renumber an asset / nested-set row. A raw `INSERT`/`DELETE` of an
|
|
74
|
+
* asset-bearing entity (any table with an `asset_id` column — Table.php:208-209), by
|
|
75
|
+
* contrast, desynchronizes `#__assets` (no paired asset row, or an orphaned one). DDL
|
|
76
|
+
* and anything unrecognized are treated as structurally hazardous, conservatively.
|
|
77
|
+
*/
|
|
78
|
+
type WriteOp = 'INSERT' | 'UPDATE' | 'DELETE' | 'DDL' | 'OTHER';
|
|
79
|
+
function classifyWrite(sql: string): WriteOp {
|
|
80
|
+
const s = sql
|
|
81
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ') // /* block comments */
|
|
82
|
+
.replace(/^\s*(--|#)[^\n]*$/gm, ' ') // -- and # line comments
|
|
83
|
+
.trim();
|
|
84
|
+
if (/^(INSERT|REPLACE)\b/i.test(s)) return 'INSERT';
|
|
85
|
+
if (/^UPDATE\b/i.test(s)) return 'UPDATE';
|
|
86
|
+
if (/^DELETE\b/i.test(s)) return 'DELETE';
|
|
87
|
+
if (/^(CREATE|ALTER|DROP|TRUNCATE|RENAME)\b/i.test(s)) return 'DDL';
|
|
88
|
+
return 'OTHER';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Known MySQL privilege identifiers accepted by GRANT/REVOKE. Compared
|
|
93
|
+
* case-insensitively against each comma-separated token supplied by the caller
|
|
94
|
+
* so an arbitrary string can never be interpolated raw into a GRANT statement.
|
|
95
|
+
*/
|
|
96
|
+
const ALLOWED_PRIVILEGES = new Set([
|
|
97
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'RELOAD',
|
|
98
|
+
'SHUTDOWN', 'PROCESS', 'FILE', 'REFERENCES', 'INDEX', 'ALTER',
|
|
99
|
+
'SHOW DATABASES', 'SUPER', 'CREATE TEMPORARY TABLES', 'LOCK TABLES',
|
|
100
|
+
'EXECUTE', 'REPLICATION SLAVE', 'REPLICATION CLIENT', 'CREATE VIEW',
|
|
101
|
+
'SHOW VIEW', 'CREATE ROUTINE', 'ALTER ROUTINE', 'CREATE USER', 'EVENT',
|
|
102
|
+
'TRIGGER', 'GRANT OPTION', 'ALL', 'ALL PRIVILEGES', 'USAGE',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a comma-separated `privileges` string against ALLOWED_PRIVILEGES.
|
|
107
|
+
* Rejects parentheses (column-level grants) and any unknown token so nothing
|
|
108
|
+
* unvetted reaches the raw GRANT/REVOKE statement. Returns the normalized,
|
|
109
|
+
* uppercased, re-joined privilege list safe for interpolation.
|
|
110
|
+
*/
|
|
111
|
+
function validatePrivileges(privileges: string): string {
|
|
112
|
+
if (/[()]/.test(privileges)) {
|
|
113
|
+
throw new Error('Column-level privileges (parentheses) are not supported. Supply plain privilege names only.');
|
|
114
|
+
}
|
|
115
|
+
const tokens = privileges.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
116
|
+
if (tokens.length === 0) {
|
|
117
|
+
throw new Error('No privileges supplied.');
|
|
118
|
+
}
|
|
119
|
+
const normalized: string[] = [];
|
|
120
|
+
for (const tok of tokens) {
|
|
121
|
+
const upper = tok.toUpperCase().replace(/\s+/g, ' ');
|
|
122
|
+
if (!ALLOWED_PRIVILEGES.has(upper)) {
|
|
123
|
+
throw new Error(`Rejected unknown or unsafe privilege: "${tok}". Allowed: ${[...ALLOWED_PRIVILEGES].join(', ')}.`);
|
|
124
|
+
}
|
|
125
|
+
normalized.push(upper);
|
|
126
|
+
}
|
|
127
|
+
return normalized.join(', ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Joomla nested-set tables — raw lft/rgt writes here are the hazardous ones. */
|
|
131
|
+
const NESTED_SET_SUFFIXES = ['assets', 'categories', 'menu'] as const;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Find Joomla nested-set tables referenced by a statement. With a known prefix
|
|
135
|
+
* the match is precise (`<prefix>assets`); without one it falls back to matching
|
|
136
|
+
* the bare table names as whole identifiers, which may over-warn but never
|
|
137
|
+
* silently misses a hazardous target.
|
|
138
|
+
*/
|
|
139
|
+
function nestedSetTablesHit(sql: string, prefix: string | null): string[] {
|
|
140
|
+
const names = NESTED_SET_SUFFIXES.map((s) => (prefix !== null ? `${prefix}${s}` : s));
|
|
141
|
+
const hits: string[] = [];
|
|
142
|
+
for (const name of names) {
|
|
143
|
+
// Match the identifier with optional backtick quoting, on word boundaries.
|
|
144
|
+
const re = new RegExp('(^|[^a-z0-9_`])`?' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '`?([^a-z0-9_`]|$)', 'i');
|
|
145
|
+
if (re.test(sql)) hits.push(name);
|
|
146
|
+
}
|
|
147
|
+
return hits;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ConnectionParam = {
|
|
151
|
+
connection: z.string().optional().describe('Named connection from config (uses default if omitted)'),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const DatabaseParam = {
|
|
155
|
+
database: z.string().describe('Database name'),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const TableParam = {
|
|
159
|
+
table: z.string().describe('Table name'),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const ConfirmParam = {
|
|
163
|
+
confirm: z.boolean().describe('Must be true to execute this destructive operation'),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const JoomlaTokenParam = {
|
|
167
|
+
confirm_token: z.string().optional().describe(
|
|
168
|
+
'Second-step acknowledgement token for a write to a Joomla database. Leave empty on the ' +
|
|
169
|
+
'first call: the write is NOT executed and a token bound to this exact statement+target is ' +
|
|
170
|
+
'returned. Repeat the SAME call with that token to actually run it. Reads are never gated; ' +
|
|
171
|
+
'non-Joomla databases ignore this and run on confirm alone.',
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ── Joomla write gate: mandatory two-step challenge/acknowledge ──────────
|
|
176
|
+
//
|
|
177
|
+
// Writes to a Joomla DB must never execute from a single call (an interrupted or
|
|
178
|
+
// careless raw write silently corrupts the nested-set tables — see issue #39). The
|
|
179
|
+
// first call issues a one-time token bound to the exact statement+target and does
|
|
180
|
+
// NOT run; only a second call echoing that token proceeds.
|
|
181
|
+
//
|
|
182
|
+
// The gate is operation-aware (issue #65): the two-step token is required for EVERY
|
|
183
|
+
// Joomla write, but the nested-set/ACL-corruption warning is reserved for the writes
|
|
184
|
+
// that can actually cause it — INSERT/DELETE of asset-bearing rows, DDL, and any write
|
|
185
|
+
// to a nested-set table. A pure in-place UPDATE that avoids the nested-set tables gets
|
|
186
|
+
// a lighter message, because that coupling lives in the Joomla Table API (store()/
|
|
187
|
+
// delete()), which raw SQL bypasses entirely.
|
|
188
|
+
|
|
189
|
+
interface PendingWrite {
|
|
190
|
+
/** Binds the token to a specific normalized op + connection + schema. */
|
|
191
|
+
bindHash: string;
|
|
192
|
+
/** Epoch ms when issued, for TTL expiry. */
|
|
193
|
+
issuedAt: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
197
|
+
const pendingWrites = new Map<string, PendingWrite>();
|
|
198
|
+
|
|
199
|
+
function bindHashFor(op: string, connKey: string, schema: string): string {
|
|
200
|
+
const normalized = op.trim().replace(/\s+/g, ' ');
|
|
201
|
+
// JSON framing keeps the three parts unambiguously separated (no delimiter can
|
|
202
|
+
// bleed across fields) while staying printable text.
|
|
203
|
+
return createHash('sha256').update(JSON.stringify([connKey, schema, normalized])).digest('hex');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type GateResult = { ok: true } | { ok: false; text: string };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Enforce the two-step gate for a write `op` (raw SQL, or a synthetic descriptor
|
|
210
|
+
* like `DROP TABLE db.table` for the structured tools). Reads and non-Joomla
|
|
211
|
+
* targets pass straight through. On a Joomla write: a valid matching token runs it
|
|
212
|
+
* (and is consumed); anything else returns a fresh challenge and does NOT execute.
|
|
213
|
+
*/
|
|
214
|
+
async function joomlaWriteGate(
|
|
215
|
+
op: string,
|
|
216
|
+
connection: string | undefined,
|
|
217
|
+
database: string | undefined,
|
|
218
|
+
confirmToken: string | undefined,
|
|
219
|
+
): Promise<GateResult> {
|
|
220
|
+
if (isReadStatement(op)) return { ok: true };
|
|
221
|
+
const j = await client.detectJoomla(connection, database);
|
|
222
|
+
if (!j.isJoomla) return { ok: true };
|
|
223
|
+
|
|
224
|
+
const { connKey, schema } = client.resolveIdentity(connection, database);
|
|
225
|
+
const bindHash = bindHashFor(op, connKey, schema);
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
|
|
228
|
+
if (confirmToken) {
|
|
229
|
+
const pending = pendingWrites.get(confirmToken);
|
|
230
|
+
if (pending && pending.bindHash === bindHash && now - pending.issuedAt <= TOKEN_TTL_MS) {
|
|
231
|
+
pendingWrites.delete(confirmToken); // one-time use
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
// Mismatched / expired / unknown token → fall through and re-challenge.
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const token = randomBytes(9).toString('hex');
|
|
238
|
+
pendingWrites.set(token, { bindHash, issuedAt: now });
|
|
239
|
+
const nested = nestedSetTablesHit(op, j.prefix);
|
|
240
|
+
const writeOp = classifyWrite(op);
|
|
241
|
+
// A pure in-place UPDATE that does not reference a nested-set table cannot create,
|
|
242
|
+
// delete, or renumber an #__assets / nested-set row (that coupling lives in the Joomla
|
|
243
|
+
// Table API, which raw SQL bypasses — see classifyWrite). Such statements still get the
|
|
244
|
+
// two-step gate (an interrupted or mis-scoped raw write can corrupt row data), but NOT
|
|
245
|
+
// the misleading nested-set/ACL-corruption framing. Everything else — INSERT/DELETE,
|
|
246
|
+
// DDL, anything unrecognized, and any write to a nested-set table — keeps it.
|
|
247
|
+
const inPlaceUpdate = writeOp === 'UPDATE' && nested.length === 0;
|
|
248
|
+
const lines = [
|
|
249
|
+
'⛔ WRITE to a Joomla database — a second confirmation is REQUIRED. This call did NOT execute.',
|
|
250
|
+
'',
|
|
251
|
+
];
|
|
252
|
+
if (inPlaceUpdate) {
|
|
253
|
+
lines.push(
|
|
254
|
+
'This is an in-place UPDATE that does not touch a nested-set table (assets / categories / menu),',
|
|
255
|
+
'so it will NOT create, remove, or renumber ACL asset rows or nested sets — Joomla only mutates',
|
|
256
|
+
'those on row creation/deletion, which raw SQL bypasses. The two-step confirm still applies because',
|
|
257
|
+
'an interrupted or mis-scoped raw write can corrupt row data. To proceed, repeat the SAME call adding:',
|
|
258
|
+
` confirm_token: "${token}"`,
|
|
259
|
+
'(one-time, valid ~5 min, bound to this exact statement + target).',
|
|
260
|
+
);
|
|
261
|
+
} else {
|
|
262
|
+
lines.push(
|
|
263
|
+
'Joomla tables carry structural invariants (nested sets, ACL) that raw SQL can corrupt, and an',
|
|
264
|
+
'interrupted write can leave the database broken. To proceed, repeat the SAME call adding:',
|
|
265
|
+
` confirm_token: "${token}"`,
|
|
266
|
+
'(one-time, valid ~5 min, bound to this exact statement + target).',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (nested.length) {
|
|
270
|
+
lines.push(
|
|
271
|
+
'',
|
|
272
|
+
`⚠ Targets nested-set table(s): ${nested.join(', ')}.`,
|
|
273
|
+
'Strongly prefer the Joomla Web Services API / CLI, which maintains lft/rgt automatically.',
|
|
274
|
+
'If you must write raw, complete the whole operation in one go, then verify integrity:',
|
|
275
|
+
'max(rgt) == 2 × node_count, no duplicate lft/rgt, no lft >= rgt, children within parent bounds.',
|
|
276
|
+
);
|
|
277
|
+
} else if (writeOp === 'INSERT' || writeOp === 'DELETE') {
|
|
278
|
+
lines.push(
|
|
279
|
+
'',
|
|
280
|
+
`⚠ Raw ${writeOp} of a row. If the target is asset-bearing (any table with an asset_id column —`,
|
|
281
|
+
'articles, categories, modules, contacts, banners, newsfeeds, fields, tags, …), Joomla pairs each',
|
|
282
|
+
'row with a #__assets ACL row via Table::store()/delete(). Raw SQL bypasses that: an INSERT leaves',
|
|
283
|
+
'the new row with no asset row (broken ACL), a DELETE orphans the asset row. Strongly prefer the',
|
|
284
|
+
'Joomla Web Services API / CLI, which maintains the asset tree automatically.',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return { ok: false, text: lines.join('\n') };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const LimitParam = {
|
|
291
|
+
limit: z.number().optional().describe('Max rows to return (default 500, max 10000)'),
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const server = new McpServer({
|
|
295
|
+
name: '@mokoconsulting/mokomysql-mcp',
|
|
296
|
+
version: '1.0.0',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ── Connection & Server Info ────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
server.tool(
|
|
302
|
+
'mysql_connections_list',
|
|
303
|
+
'List all configured MySQL connection names with host/port/user (no passwords)',
|
|
304
|
+
{ ...ConnectionParam },
|
|
305
|
+
async () => {
|
|
306
|
+
const entries = Object.entries(config.connections).map(([name, c]) => ({
|
|
307
|
+
name,
|
|
308
|
+
host: c.host,
|
|
309
|
+
port: c.port ?? 3306,
|
|
310
|
+
user: c.user,
|
|
311
|
+
database: c.database ?? '(none)',
|
|
312
|
+
default: name === config.defaultConnection,
|
|
313
|
+
}));
|
|
314
|
+
return formatRows(entries);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
server.tool(
|
|
319
|
+
'mysql_server_version',
|
|
320
|
+
'Get MySQL server version and uptime',
|
|
321
|
+
{ ...ConnectionParam },
|
|
322
|
+
async ({ connection }) => {
|
|
323
|
+
try {
|
|
324
|
+
const { rows } = await client.query(
|
|
325
|
+
"SELECT VERSION() AS version, @@hostname AS hostname, @@port AS port, " +
|
|
326
|
+
"VARIABLE_VALUE AS uptime FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Uptime'",
|
|
327
|
+
[], connection,
|
|
328
|
+
);
|
|
329
|
+
return formatRows(rows);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
return formatError(e);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.tool(
|
|
337
|
+
'mysql_server_status',
|
|
338
|
+
'Show MySQL server status variables (SHOW GLOBAL STATUS)',
|
|
339
|
+
{ ...ConnectionParam, filter: z.string().optional().describe('LIKE pattern to filter (e.g. "Threads%")') },
|
|
340
|
+
async ({ connection, filter }) => {
|
|
341
|
+
try {
|
|
342
|
+
const sql = filter ? `SHOW GLOBAL STATUS LIKE ?` : 'SHOW GLOBAL STATUS';
|
|
343
|
+
const params = filter ? [filter] : [];
|
|
344
|
+
const { rows } = await client.query(sql, params, connection);
|
|
345
|
+
return formatRows(rows);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return formatError(e);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
server.tool(
|
|
353
|
+
'mysql_server_variables',
|
|
354
|
+
'Show MySQL system variables (SHOW GLOBAL VARIABLES)',
|
|
355
|
+
{ ...ConnectionParam, filter: z.string().optional().describe('LIKE pattern to filter (e.g. "innodb%")') },
|
|
356
|
+
async ({ connection, filter }) => {
|
|
357
|
+
try {
|
|
358
|
+
const sql = filter ? `SHOW GLOBAL VARIABLES LIKE ?` : 'SHOW GLOBAL VARIABLES';
|
|
359
|
+
const params = filter ? [filter] : [];
|
|
360
|
+
const { rows } = await client.query(sql, params, connection);
|
|
361
|
+
return formatRows(rows);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return formatError(e);
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// ── Database Management ─────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
server.tool(
|
|
371
|
+
'mysql_databases_list',
|
|
372
|
+
'List all databases with sizes',
|
|
373
|
+
{ ...ConnectionParam },
|
|
374
|
+
async ({ connection }) => {
|
|
375
|
+
try {
|
|
376
|
+
const { rows } = await client.query(
|
|
377
|
+
"SELECT s.SCHEMA_NAME AS `database`, " +
|
|
378
|
+
"ROUND(SUM(t.DATA_LENGTH + t.INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb, " +
|
|
379
|
+
"COUNT(t.TABLE_NAME) AS tables_count, " +
|
|
380
|
+
"s.DEFAULT_CHARACTER_SET_NAME AS charset, " +
|
|
381
|
+
"s.DEFAULT_COLLATION_NAME AS collation " +
|
|
382
|
+
"FROM information_schema.SCHEMATA s " +
|
|
383
|
+
"LEFT JOIN information_schema.TABLES t ON s.SCHEMA_NAME = t.TABLE_SCHEMA " +
|
|
384
|
+
"GROUP BY s.SCHEMA_NAME ORDER BY size_mb DESC",
|
|
385
|
+
[], connection,
|
|
386
|
+
);
|
|
387
|
+
return formatRows(rows);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
return formatError(e);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
server.tool(
|
|
395
|
+
'mysql_database_create',
|
|
396
|
+
'Create a new database',
|
|
397
|
+
{ ...ConnectionParam, ...DatabaseParam, charset: z.string().optional().describe('Character set (default utf8mb4)'), collation: z.string().optional().describe('Collation (default utf8mb4_unicode_ci)') },
|
|
398
|
+
async ({ connection, database, charset, collation }) => {
|
|
399
|
+
try {
|
|
400
|
+
const cs = charset ?? 'utf8mb4';
|
|
401
|
+
const co = collation ?? 'utf8mb4_unicode_ci';
|
|
402
|
+
const r = await client.execute(
|
|
403
|
+
`CREATE DATABASE ${esc(database)} CHARACTER SET ${esc(cs)} COLLATE ${esc(co)}`,
|
|
404
|
+
[], connection,
|
|
405
|
+
);
|
|
406
|
+
return formatResult(r);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return formatError(e);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
server.tool(
|
|
414
|
+
'mysql_database_drop',
|
|
415
|
+
'Drop a database — DESTRUCTIVE, requires confirm=true. If the database is Joomla, also requires the two-step confirm_token.',
|
|
416
|
+
{ ...ConnectionParam, ...DatabaseParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
417
|
+
async ({ connection, database, confirm, confirm_token }) => {
|
|
418
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true to drop a database.' }] };
|
|
419
|
+
const op = `DROP DATABASE ${esc(database)}`;
|
|
420
|
+
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
421
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
422
|
+
try {
|
|
423
|
+
const r = await client.execute(op, [], connection);
|
|
424
|
+
return formatResult(r);
|
|
425
|
+
} catch (e) {
|
|
426
|
+
return formatError(e);
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
server.tool(
|
|
432
|
+
'mysql_database_size',
|
|
433
|
+
'Show database size breakdown by table',
|
|
434
|
+
{ ...ConnectionParam, ...DatabaseParam },
|
|
435
|
+
async ({ connection, database }) => {
|
|
436
|
+
try {
|
|
437
|
+
const { rows } = await client.query(
|
|
438
|
+
"SELECT TABLE_NAME AS `table`, TABLE_ROWS AS rows_estimate, " +
|
|
439
|
+
"ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb, " +
|
|
440
|
+
"ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb, " +
|
|
441
|
+
"ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb, " +
|
|
442
|
+
"ENGINE AS engine " +
|
|
443
|
+
"FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY total_mb DESC",
|
|
444
|
+
[database], connection,
|
|
445
|
+
);
|
|
446
|
+
return formatRows(rows);
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return formatError(e);
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// ── Table Management ────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
server.tool(
|
|
456
|
+
'mysql_tables_list',
|
|
457
|
+
'List all tables in a database with engine, row count estimate, and size',
|
|
458
|
+
{ ...ConnectionParam, ...DatabaseParam },
|
|
459
|
+
async ({ connection, database }) => {
|
|
460
|
+
try {
|
|
461
|
+
const { rows } = await client.query(
|
|
462
|
+
"SELECT TABLE_NAME AS `table`, ENGINE AS engine, TABLE_ROWS AS rows_estimate, " +
|
|
463
|
+
"ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024, 2) AS size_kb, " +
|
|
464
|
+
"TABLE_COLLATION AS collation, AUTO_INCREMENT AS auto_increment " +
|
|
465
|
+
"FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME",
|
|
466
|
+
[database], connection,
|
|
467
|
+
);
|
|
468
|
+
return formatRows(rows);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
return formatError(e);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
server.tool(
|
|
476
|
+
'mysql_table_describe',
|
|
477
|
+
'Show columns, types, keys, and indexes for a table',
|
|
478
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
479
|
+
async ({ connection, database, table }) => {
|
|
480
|
+
try {
|
|
481
|
+
const { rows: cols } = await client.query(
|
|
482
|
+
`SHOW FULL COLUMNS FROM ${esc(database)}.${esc(table)}`,
|
|
483
|
+
[], connection,
|
|
484
|
+
);
|
|
485
|
+
const { rows: idxs } = await client.query(
|
|
486
|
+
`SHOW INDEX FROM ${esc(database)}.${esc(table)}`,
|
|
487
|
+
[], connection,
|
|
488
|
+
);
|
|
489
|
+
return { content: [{ type: 'text' as const, text: `Columns:\n${JSON.stringify(cols, null, 2)}\n\nIndexes:\n${JSON.stringify(idxs, null, 2)}` }] };
|
|
490
|
+
} catch (e) {
|
|
491
|
+
return formatError(e);
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
server.tool(
|
|
497
|
+
'mysql_table_create',
|
|
498
|
+
'Execute a CREATE TABLE DDL statement. On a Joomla database this requires the two-step confirm_token.',
|
|
499
|
+
{ ...ConnectionParam, sql: z.string().describe('Full CREATE TABLE SQL statement'), ...JoomlaTokenParam },
|
|
500
|
+
async ({ connection, sql, confirm_token }) => {
|
|
501
|
+
const gate = await joomlaWriteGate(sql, connection, undefined, confirm_token);
|
|
502
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
503
|
+
try {
|
|
504
|
+
const r = await client.execute(sql, [], connection);
|
|
505
|
+
return formatResult(r);
|
|
506
|
+
} catch (e) {
|
|
507
|
+
return formatError(e);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
server.tool(
|
|
513
|
+
'mysql_table_drop',
|
|
514
|
+
'Drop a table — DESTRUCTIVE, requires confirm=true. On a Joomla database also requires the two-step confirm_token.',
|
|
515
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
516
|
+
async ({ connection, database, table, confirm, confirm_token }) => {
|
|
517
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
518
|
+
const op = `DROP TABLE ${esc(database)}.${esc(table)}`;
|
|
519
|
+
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
520
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
521
|
+
try {
|
|
522
|
+
const r = await client.execute(op, [], connection);
|
|
523
|
+
return formatResult(r);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
return formatError(e);
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
server.tool(
|
|
531
|
+
'mysql_table_truncate',
|
|
532
|
+
'Truncate a table (removes all rows, resets auto-increment) — DESTRUCTIVE, requires confirm=true. On a Joomla database also requires the two-step confirm_token.',
|
|
533
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam, ...ConfirmParam, ...JoomlaTokenParam },
|
|
534
|
+
async ({ connection, database, table, confirm, confirm_token }) => {
|
|
535
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
536
|
+
const op = `TRUNCATE TABLE ${esc(database)}.${esc(table)}`;
|
|
537
|
+
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
538
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
539
|
+
try {
|
|
540
|
+
const r = await client.execute(op, [], connection);
|
|
541
|
+
return formatResult(r);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
return formatError(e);
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
server.tool(
|
|
549
|
+
'mysql_table_rename',
|
|
550
|
+
'Rename a table. On a Joomla database this requires the two-step confirm_token.',
|
|
551
|
+
{ ...ConnectionParam, ...DatabaseParam, from: z.string().describe('Current table name'), to: z.string().describe('New table name'), ...JoomlaTokenParam },
|
|
552
|
+
async ({ connection, database, from, to, confirm_token }) => {
|
|
553
|
+
const op = `RENAME TABLE ${esc(database)}.${esc(from)} TO ${esc(database)}.${esc(to)}`;
|
|
554
|
+
const gate = await joomlaWriteGate(op, connection, database, confirm_token);
|
|
555
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
556
|
+
try {
|
|
557
|
+
const r = await client.execute(op, [], connection);
|
|
558
|
+
return formatResult(r);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
return formatError(e);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// ── Query Execution ─────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
server.tool(
|
|
568
|
+
'mysql_query_select',
|
|
569
|
+
'Execute a SELECT query and return results as JSON (auto-limited to prevent huge results)',
|
|
570
|
+
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('SELECT query to execute'), ...LimitParam },
|
|
571
|
+
async ({ connection, database, sql, limit }) => {
|
|
572
|
+
try {
|
|
573
|
+
if (!isReadStatement(sql)) {
|
|
574
|
+
return formatError(new Error('mysql_query_select only runs read-only statements (SELECT/SHOW/EXPLAIN/DESCRIBE). Use mysql_query_execute for writes.'));
|
|
575
|
+
}
|
|
576
|
+
const max = Math.min(limit ?? 500, 10000);
|
|
577
|
+
const hasLimit = /\bLIMIT\s+\d/i.test(sql);
|
|
578
|
+
const finalSql = hasLimit ? sql : `${sql} LIMIT ${max}`;
|
|
579
|
+
const { rows } = await client.query(finalSql, [], connection, database);
|
|
580
|
+
return formatRows(rows);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return formatError(e);
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
server.tool(
|
|
588
|
+
'mysql_query_execute',
|
|
589
|
+
'Execute INSERT, UPDATE, DELETE, or DDL — requires confirm=true. Writes to a Joomla database ' +
|
|
590
|
+
'additionally require the two-step confirm_token (first call returns a token and does NOT run; ' +
|
|
591
|
+
'repeat with the token to execute). Reads stay unrestricted.',
|
|
592
|
+
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('SQL statement to execute'), ...ConfirmParam, ...JoomlaTokenParam },
|
|
593
|
+
async ({ connection, database, sql, confirm, confirm_token }) => {
|
|
594
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true to execute write operations.' }] };
|
|
595
|
+
const gate = await joomlaWriteGate(sql, connection, database, confirm_token);
|
|
596
|
+
if (!gate.ok) return { content: [{ type: 'text' as const, text: gate.text }] };
|
|
597
|
+
try {
|
|
598
|
+
const r = await client.execute(sql, [], connection, database);
|
|
599
|
+
const result = formatResult(r);
|
|
600
|
+
// Surface a post-write integrity reminder for nested-set tables.
|
|
601
|
+
if (!isReadStatement(sql)) {
|
|
602
|
+
const j = await client.detectJoomla(connection, database);
|
|
603
|
+
const nested = j.isJoomla ? nestedSetTablesHit(sql, j.prefix) : [];
|
|
604
|
+
if (nested.length) {
|
|
605
|
+
result.content.unshift({
|
|
606
|
+
type: 'text' as const,
|
|
607
|
+
text: `⚠ Wrote to Joomla nested-set table(s): ${nested.join(', ')}. ` +
|
|
608
|
+
'Verify integrity (max(rgt) == 2 × node_count, no dup/overlapping lft/rgt) before relying on the data.\n',
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return result;
|
|
613
|
+
} catch (e) {
|
|
614
|
+
return formatError(e);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
server.tool(
|
|
620
|
+
'mysql_query_explain',
|
|
621
|
+
'Run EXPLAIN on a query to show execution plan',
|
|
622
|
+
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('Query to explain') },
|
|
623
|
+
async ({ connection, database, sql }) => {
|
|
624
|
+
try {
|
|
625
|
+
if (!isReadStatement(sql)) {
|
|
626
|
+
return formatError(new Error('mysql_query_explain only explains read-only statements (SELECT/SHOW/EXPLAIN/DESCRIBE).'));
|
|
627
|
+
}
|
|
628
|
+
const { rows } = await client.query(`EXPLAIN ${sql}`, [], connection, database);
|
|
629
|
+
return formatRows(rows);
|
|
630
|
+
} catch (e) {
|
|
631
|
+
return formatError(e);
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
// ── Schema Inspection ───────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
server.tool(
|
|
639
|
+
'mysql_schema_show_create_table',
|
|
640
|
+
'Show the CREATE TABLE DDL for a table',
|
|
641
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
642
|
+
async ({ connection, database, table }) => {
|
|
643
|
+
try {
|
|
644
|
+
const { rows } = await client.query(
|
|
645
|
+
`SHOW CREATE TABLE ${esc(database)}.${esc(table)}`,
|
|
646
|
+
[], connection,
|
|
647
|
+
);
|
|
648
|
+
const ddl = (rows[0] as Record<string, unknown>)?.['Create Table'] ?? '';
|
|
649
|
+
return { content: [{ type: 'text' as const, text: String(ddl) }] };
|
|
650
|
+
} catch (e) {
|
|
651
|
+
return formatError(e);
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
server.tool(
|
|
657
|
+
'mysql_schema_indexes',
|
|
658
|
+
'List all indexes on a table',
|
|
659
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
660
|
+
async ({ connection, database, table }) => {
|
|
661
|
+
try {
|
|
662
|
+
const { rows } = await client.query(
|
|
663
|
+
`SHOW INDEX FROM ${esc(database)}.${esc(table)}`,
|
|
664
|
+
[], connection,
|
|
665
|
+
);
|
|
666
|
+
return formatRows(rows);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
return formatError(e);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
server.tool(
|
|
674
|
+
'mysql_schema_foreign_keys',
|
|
675
|
+
'List all foreign key constraints on a table',
|
|
676
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
677
|
+
async ({ connection, database, table }) => {
|
|
678
|
+
try {
|
|
679
|
+
const { rows } = await client.query(
|
|
680
|
+
"SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, " +
|
|
681
|
+
"REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME " +
|
|
682
|
+
"FROM information_schema.KEY_COLUMN_USAGE " +
|
|
683
|
+
"WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL",
|
|
684
|
+
[database, table], connection,
|
|
685
|
+
);
|
|
686
|
+
return formatRows(rows);
|
|
687
|
+
} catch (e) {
|
|
688
|
+
return formatError(e);
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
server.tool(
|
|
694
|
+
'mysql_schema_full',
|
|
695
|
+
'Export CREATE TABLE DDL for all tables in a database',
|
|
696
|
+
{ ...ConnectionParam, ...DatabaseParam },
|
|
697
|
+
async ({ connection, database }) => {
|
|
698
|
+
try {
|
|
699
|
+
const { rows: tables } = await client.query(
|
|
700
|
+
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME",
|
|
701
|
+
[database], connection,
|
|
702
|
+
);
|
|
703
|
+
const ddls: string[] = [];
|
|
704
|
+
for (const t of tables) {
|
|
705
|
+
const name = String((t as Record<string, unknown>).TABLE_NAME);
|
|
706
|
+
const { rows } = await client.query(`SHOW CREATE TABLE ${esc(database)}.${esc(name)}`, [], connection);
|
|
707
|
+
const ddl = String((rows[0] as Record<string, unknown>)?.['Create Table'] ?? '');
|
|
708
|
+
ddls.push(`-- Table: ${name}\n${ddl};\n`);
|
|
709
|
+
}
|
|
710
|
+
return { content: [{ type: 'text' as const, text: ddls.join('\n') }] };
|
|
711
|
+
} catch (e) {
|
|
712
|
+
return formatError(e);
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// ── User Management ─────────────────────────────────────────────────────
|
|
718
|
+
|
|
719
|
+
server.tool(
|
|
720
|
+
'mysql_users_list',
|
|
721
|
+
'List all MySQL users with host and authentication plugin',
|
|
722
|
+
{ ...ConnectionParam },
|
|
723
|
+
async ({ connection }) => {
|
|
724
|
+
try {
|
|
725
|
+
const { rows } = await client.query(
|
|
726
|
+
"SELECT User, Host, plugin, account_locked FROM mysql.user ORDER BY User, Host",
|
|
727
|
+
[], connection,
|
|
728
|
+
);
|
|
729
|
+
return formatRows(rows);
|
|
730
|
+
} catch (e) {
|
|
731
|
+
return formatError(e);
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
server.tool(
|
|
737
|
+
'mysql_user_grants',
|
|
738
|
+
'Show grants for a MySQL user',
|
|
739
|
+
{ ...ConnectionParam, user: z.string().describe('Username'), host: z.string().optional().describe('Host (default %)') },
|
|
740
|
+
async ({ connection, user, host }) => {
|
|
741
|
+
try {
|
|
742
|
+
const h = host ?? '%';
|
|
743
|
+
const { rows } = await client.query(`SHOW GRANTS FOR ?@?`, [user, h], connection);
|
|
744
|
+
return formatRows(rows);
|
|
745
|
+
} catch (e) {
|
|
746
|
+
return formatError(e);
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
server.tool(
|
|
752
|
+
'mysql_user_create',
|
|
753
|
+
'Create a MySQL user',
|
|
754
|
+
{ ...ConnectionParam, user: z.string(), host: z.string().optional().describe('Host (default localhost)'), password: z.string() },
|
|
755
|
+
async ({ connection, user, host, password }) => {
|
|
756
|
+
try {
|
|
757
|
+
const h = host ?? 'localhost';
|
|
758
|
+
const r = await client.execute(
|
|
759
|
+
`CREATE USER ${esc(user)}@${esc(h)} IDENTIFIED BY ?`,
|
|
760
|
+
[password], connection,
|
|
761
|
+
);
|
|
762
|
+
return formatResult(r);
|
|
763
|
+
} catch (e) {
|
|
764
|
+
return formatError(e);
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
server.tool(
|
|
770
|
+
'mysql_user_drop',
|
|
771
|
+
'Drop a MySQL user — DESTRUCTIVE, requires confirm=true',
|
|
772
|
+
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), ...ConfirmParam },
|
|
773
|
+
async ({ connection, user, host, confirm }) => {
|
|
774
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
775
|
+
try {
|
|
776
|
+
const h = host ?? '%';
|
|
777
|
+
const r = await client.execute(`DROP USER ${esc(user)}@${esc(h)}`, [], connection);
|
|
778
|
+
return formatResult(r);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
return formatError(e);
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
server.tool(
|
|
786
|
+
'mysql_user_grant',
|
|
787
|
+
'Grant privileges to a user on a database',
|
|
788
|
+
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), privileges: z.string().describe('e.g. "SELECT, INSERT" or "ALL PRIVILEGES"'), database: z.string(), table: z.string().optional().describe('Table name (default * for all)') },
|
|
789
|
+
async ({ connection, user, host, privileges, database, table }) => {
|
|
790
|
+
try {
|
|
791
|
+
const safePrivileges = validatePrivileges(privileges);
|
|
792
|
+
const h = host ?? '%';
|
|
793
|
+
const t = table ?? '*';
|
|
794
|
+
const r = await client.execute(
|
|
795
|
+
`GRANT ${safePrivileges} ON ${esc(database)}.${t === '*' ? '*' : esc(t)} TO ${esc(user)}@${esc(h)}`,
|
|
796
|
+
[], connection,
|
|
797
|
+
);
|
|
798
|
+
await client.execute('FLUSH PRIVILEGES', [], connection);
|
|
799
|
+
return formatResult(r);
|
|
800
|
+
} catch (e) {
|
|
801
|
+
return formatError(e);
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
server.tool(
|
|
807
|
+
'mysql_user_revoke',
|
|
808
|
+
'Revoke privileges from a user',
|
|
809
|
+
{ ...ConnectionParam, user: z.string(), host: z.string().optional(), privileges: z.string(), database: z.string(), table: z.string().optional() },
|
|
810
|
+
async ({ connection, user, host, privileges, database, table }) => {
|
|
811
|
+
try {
|
|
812
|
+
const safePrivileges = validatePrivileges(privileges);
|
|
813
|
+
const h = host ?? '%';
|
|
814
|
+
const t = table ?? '*';
|
|
815
|
+
const r = await client.execute(
|
|
816
|
+
`REVOKE ${safePrivileges} ON ${esc(database)}.${t === '*' ? '*' : esc(t)} FROM ${esc(user)}@${esc(h)}`,
|
|
817
|
+
[], connection,
|
|
818
|
+
);
|
|
819
|
+
await client.execute('FLUSH PRIVILEGES', [], connection);
|
|
820
|
+
return formatResult(r);
|
|
821
|
+
} catch (e) {
|
|
822
|
+
return formatError(e);
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
// ── Maintenance ─────────────────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
server.tool(
|
|
830
|
+
'mysql_maintenance_optimize',
|
|
831
|
+
'Run OPTIMIZE TABLE to reclaim space and defragment',
|
|
832
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
833
|
+
async ({ connection, database, table }) => {
|
|
834
|
+
try {
|
|
835
|
+
const { rows } = await client.query(`OPTIMIZE TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
836
|
+
return formatRows(rows);
|
|
837
|
+
} catch (e) {
|
|
838
|
+
return formatError(e);
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
server.tool(
|
|
844
|
+
'mysql_maintenance_analyze',
|
|
845
|
+
'Run ANALYZE TABLE to update index statistics',
|
|
846
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
847
|
+
async ({ connection, database, table }) => {
|
|
848
|
+
try {
|
|
849
|
+
const { rows } = await client.query(`ANALYZE TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
850
|
+
return formatRows(rows);
|
|
851
|
+
} catch (e) {
|
|
852
|
+
return formatError(e);
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
server.tool(
|
|
858
|
+
'mysql_maintenance_check',
|
|
859
|
+
'Run CHECK TABLE to verify table integrity',
|
|
860
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam },
|
|
861
|
+
async ({ connection, database, table }) => {
|
|
862
|
+
try {
|
|
863
|
+
const { rows } = await client.query(`CHECK TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
864
|
+
return formatRows(rows);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
return formatError(e);
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
server.tool(
|
|
872
|
+
'mysql_processlist',
|
|
873
|
+
'Show running MySQL processes',
|
|
874
|
+
{ ...ConnectionParam },
|
|
875
|
+
async ({ connection }) => {
|
|
876
|
+
try {
|
|
877
|
+
const { rows } = await client.query('SHOW FULL PROCESSLIST', [], connection);
|
|
878
|
+
return formatRows(rows);
|
|
879
|
+
} catch (e) {
|
|
880
|
+
return formatError(e);
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
server.tool(
|
|
886
|
+
'mysql_process_kill',
|
|
887
|
+
'Kill a MySQL process by ID — requires confirm=true',
|
|
888
|
+
{ ...ConnectionParam, process_id: z.number().describe('Process ID to kill'), ...ConfirmParam },
|
|
889
|
+
async ({ connection, process_id, confirm }) => {
|
|
890
|
+
if (!confirm) return { content: [{ type: 'text' as const, text: 'Aborted: confirm must be true.' }] };
|
|
891
|
+
try {
|
|
892
|
+
const r = await client.execute(`KILL ${Number(process_id)}`, [], connection);
|
|
893
|
+
return formatResult(r);
|
|
894
|
+
} catch (e) {
|
|
895
|
+
return formatError(e);
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// ── Export ───────────────────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
server.tool(
|
|
903
|
+
'mysql_export_table_sql',
|
|
904
|
+
'Export table structure and data as SQL INSERT statements (no shell exec). ' +
|
|
905
|
+
'WARNING — replay foot-gun: the output begins with "DROP TABLE IF EXISTS <table>;" and ' +
|
|
906
|
+
'the INSERTs are hand-quoted (not parameterized). Re-running the dump DROPS the existing ' +
|
|
907
|
+
'table (and all its rows) before recreating it, so never replay it against a database where ' +
|
|
908
|
+
'the target table already holds data you intend to keep. Values are escaped only for single ' +
|
|
909
|
+
'quotes, not full binary-safe encoding — prefer mysqldump for authoritative backups.',
|
|
910
|
+
{ ...ConnectionParam, ...DatabaseParam, ...TableParam, include_data: z.boolean().optional().describe('Include INSERT statements (default true)'), row_limit: z.number().optional().describe('Max rows for data export (default 1000)') },
|
|
911
|
+
async ({ connection, database, table, include_data, row_limit }) => {
|
|
912
|
+
try {
|
|
913
|
+
const { rows: ddlRows } = await client.query(`SHOW CREATE TABLE ${esc(database)}.${esc(table)}`, [], connection);
|
|
914
|
+
const ddl = String((ddlRows[0] as Record<string, unknown>)?.['Create Table'] ?? '');
|
|
915
|
+
const parts: string[] = [`-- Export of ${database}.${table}`, `DROP TABLE IF EXISTS ${esc(table)};`, ddl + ';', ''];
|
|
916
|
+
|
|
917
|
+
if (include_data !== false) {
|
|
918
|
+
const max = Math.min(row_limit ?? 1000, 10000);
|
|
919
|
+
const { rows, fields } = await client.query(
|
|
920
|
+
`SELECT * FROM ${esc(database)}.${esc(table)} LIMIT ${max}`,
|
|
921
|
+
[], connection,
|
|
922
|
+
);
|
|
923
|
+
const colNames = fields.map(f => esc(String(f.name))).join(', ');
|
|
924
|
+
for (const row of rows) {
|
|
925
|
+
const vals = fields.map(f => {
|
|
926
|
+
const v = (row as Record<string, unknown>)[String(f.name)];
|
|
927
|
+
if (v === null) return 'NULL';
|
|
928
|
+
if (typeof v === 'number') return String(v);
|
|
929
|
+
return "'" + String(v).replace(/'/g, "''") + "'";
|
|
930
|
+
}).join(', ');
|
|
931
|
+
parts.push(`INSERT INTO ${esc(table)} (${colNames}) VALUES (${vals});`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return { content: [{ type: 'text' as const, text: parts.join('\n') }] };
|
|
935
|
+
} catch (e) {
|
|
936
|
+
return formatError(e);
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
server.tool(
|
|
942
|
+
'mysql_export_query_csv',
|
|
943
|
+
'Execute a SELECT and return results as CSV',
|
|
944
|
+
{ ...ConnectionParam, ...DatabaseParam, sql: z.string().describe('SELECT query'), ...LimitParam },
|
|
945
|
+
async ({ connection, database, sql, limit }) => {
|
|
946
|
+
try {
|
|
947
|
+
const max = Math.min(limit ?? 500, 10000);
|
|
948
|
+
const hasLimit = /\bLIMIT\s+\d/i.test(sql);
|
|
949
|
+
const finalSql = hasLimit ? sql : `${sql} LIMIT ${max}`;
|
|
950
|
+
const { rows, fields } = await client.query(finalSql, [], connection);
|
|
951
|
+
|
|
952
|
+
const header = fields.map(f => String(f.name)).join(',');
|
|
953
|
+
const lines = rows.map(row =>
|
|
954
|
+
fields.map(f => {
|
|
955
|
+
const v = (row as Record<string, unknown>)[String(f.name)];
|
|
956
|
+
if (v === null) return '';
|
|
957
|
+
const s = String(v);
|
|
958
|
+
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
959
|
+
? '"' + s.replace(/"/g, '""') + '"'
|
|
960
|
+
: s;
|
|
961
|
+
}).join(','),
|
|
962
|
+
);
|
|
963
|
+
return { content: [{ type: 'text' as const, text: [header, ...lines].join('\n') }] };
|
|
964
|
+
} catch (e) {
|
|
965
|
+
return formatError(e);
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
// ── Joomla Helpers ──────────────────────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
const JoomlaParams = {
|
|
973
|
+
table_prefix: z.string().optional().describe('Joomla table prefix (default "jos_")'),
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
server.tool(
|
|
977
|
+
'mysql_joomla_extensions',
|
|
978
|
+
'List installed Joomla extensions from #__extensions',
|
|
979
|
+
{ ...ConnectionParam, ...DatabaseParam, ...JoomlaParams, type: z.string().optional().describe('Filter by type: component, module, plugin, template, library, package') },
|
|
980
|
+
async ({ connection, database, table_prefix, type }) => {
|
|
981
|
+
try {
|
|
982
|
+
const pfx = table_prefix ?? 'jos_';
|
|
983
|
+
let sql = `SELECT extension_id, name, type, element, folder, enabled, access, protected, manifest_cache FROM ${esc(database)}.${esc(pfx + 'extensions')}`;
|
|
984
|
+
const params: string[] = [];
|
|
985
|
+
if (type) {
|
|
986
|
+
sql += ' WHERE type = ?';
|
|
987
|
+
params.push(type);
|
|
988
|
+
}
|
|
989
|
+
sql += ' ORDER BY type, name';
|
|
990
|
+
const { rows } = await client.query(sql, params, connection);
|
|
991
|
+
return formatRows(rows);
|
|
992
|
+
} catch (e) {
|
|
993
|
+
return formatError(e);
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
server.tool(
|
|
999
|
+
'mysql_joomla_users',
|
|
1000
|
+
'List Joomla users with username, email, last visit, and block status',
|
|
1001
|
+
{ ...ConnectionParam, ...DatabaseParam, ...JoomlaParams, ...LimitParam },
|
|
1002
|
+
async ({ connection, database, table_prefix, limit }) => {
|
|
1003
|
+
try {
|
|
1004
|
+
const pfx = table_prefix ?? 'jos_';
|
|
1005
|
+
const max = Math.min(limit ?? 100, 1000);
|
|
1006
|
+
const { rows } = await client.query(
|
|
1007
|
+
`SELECT id, name, username, email, block, lastvisitDate, registerDate FROM ${esc(database)}.${esc(pfx + 'users')} ORDER BY id LIMIT ${max}`,
|
|
1008
|
+
[], connection,
|
|
1009
|
+
);
|
|
1010
|
+
return formatRows(rows);
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
return formatError(e);
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
server.tool(
|
|
1018
|
+
'mysql_joomla_articles',
|
|
1019
|
+
'List Joomla articles from #__content',
|
|
1020
|
+
{ ...ConnectionParam, ...DatabaseParam, ...JoomlaParams, state: z.number().optional().describe('Filter: 1=published, 0=unpublished, -1=archived, -2=trashed'), ...LimitParam },
|
|
1021
|
+
async ({ connection, database, table_prefix, state, limit }) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const pfx = table_prefix ?? 'jos_';
|
|
1024
|
+
const max = Math.min(limit ?? 100, 1000);
|
|
1025
|
+
let sql = `SELECT id, title, alias, state, catid, created, modified, publish_up, publish_down, hits FROM ${esc(database)}.${esc(pfx + 'content')}`;
|
|
1026
|
+
const params: unknown[] = [];
|
|
1027
|
+
if (state !== undefined) {
|
|
1028
|
+
sql += ' WHERE state = ?';
|
|
1029
|
+
params.push(state);
|
|
1030
|
+
}
|
|
1031
|
+
sql += ` ORDER BY id DESC LIMIT ${max}`;
|
|
1032
|
+
const { rows } = await client.query(sql, params, connection);
|
|
1033
|
+
return formatRows(rows);
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
return formatError(e);
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
server.tool(
|
|
1041
|
+
'mysql_joomla_sessions',
|
|
1042
|
+
'Count active Joomla sessions and show session stats',
|
|
1043
|
+
{ ...ConnectionParam, ...DatabaseParam, ...JoomlaParams },
|
|
1044
|
+
async ({ connection, database, table_prefix }) => {
|
|
1045
|
+
try {
|
|
1046
|
+
const pfx = table_prefix ?? 'jos_';
|
|
1047
|
+
const { rows } = await client.query(
|
|
1048
|
+
`SELECT COUNT(*) AS total_sessions, ` +
|
|
1049
|
+
`SUM(CASE WHEN guest = 1 THEN 1 ELSE 0 END) AS guest_sessions, ` +
|
|
1050
|
+
`SUM(CASE WHEN guest = 0 THEN 1 ELSE 0 END) AS user_sessions, ` +
|
|
1051
|
+
`MIN(time) AS oldest_session, MAX(time) AS newest_session ` +
|
|
1052
|
+
`FROM ${esc(database)}.${esc(pfx + 'session')}`,
|
|
1053
|
+
[], connection,
|
|
1054
|
+
);
|
|
1055
|
+
return formatRows(rows);
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
return formatError(e);
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
server.tool(
|
|
1063
|
+
'mysql_joomla_config',
|
|
1064
|
+
'Read Joomla global configuration values from the extensions table params',
|
|
1065
|
+
{ ...ConnectionParam, ...DatabaseParam, ...JoomlaParams },
|
|
1066
|
+
async ({ connection, database, table_prefix }) => {
|
|
1067
|
+
try {
|
|
1068
|
+
const pfx = table_prefix ?? 'jos_';
|
|
1069
|
+
const { rows } = await client.query(
|
|
1070
|
+
`SELECT extension_id, name, params FROM ${esc(database)}.${esc(pfx + 'extensions')} WHERE type = 'component' AND element = 'com_config' LIMIT 1`,
|
|
1071
|
+
[], connection,
|
|
1072
|
+
);
|
|
1073
|
+
return formatRows(rows);
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
return formatError(e);
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
// ── Bootstrap ───────────────────────────────────────────────────────────
|
|
1081
|
+
|
|
1082
|
+
async function main() {
|
|
1083
|
+
try {
|
|
1084
|
+
config = await loadConfig();
|
|
1085
|
+
client = new MysqlClient(config);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
process.stderr.write(`${err}\n`);
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const cleanup = async () => {
|
|
1092
|
+
await client.closeAll();
|
|
1093
|
+
process.exit(0);
|
|
1094
|
+
};
|
|
1095
|
+
process.on('SIGINT', cleanup);
|
|
1096
|
+
process.on('SIGTERM', cleanup);
|
|
1097
|
+
|
|
1098
|
+
const transport = new StdioServerTransport();
|
|
1099
|
+
await server.connect(transport);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
main().catch((err) => {
|
|
1103
|
+
process.stderr.write(`Fatal: ${err}\n`);
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
});
|