@andezdev/tokenlite-mysql-mcp 1.0.0 → 2.0.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/README.md +35 -3
- package/dist/db/index.js +60 -7
- package/dist/db/optimizer.js +23 -21
- package/dist/db/schema.js +15 -0
- package/dist/index.js +23 -6
- package/dist/prompts/templates.js +39 -0
- package/dist/resources/tables.js +50 -0
- package/dist/tools/executeQuery.js +30 -14
- package/dist/tools/refreshSchema.js +2 -2
- package/dist/tools/searchSchema.js +5 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,9 +10,11 @@ Designed specifically to solve the shortcomings of current generic MCP servers t
|
|
|
10
10
|
## 🌟 Core Pillars
|
|
11
11
|
|
|
12
12
|
1. **Safe-Query Optimizer (AST & EXPLAIN)**: Protects production databases by pre-analyzing queries. Blocks unindexed Full Table Scans that exceed configurable thresholds and injects strict `LIMIT` clauses automatically at the AST level.
|
|
13
|
-
2. **
|
|
14
|
-
3. **
|
|
15
|
-
4. **
|
|
13
|
+
2. **Granular AST-Based Write Permissions**: By default, TokenLite is 100% Read-Only. You can surgically enable specific write operations (INSERT, UPDATE, DELETE, DDL) via environment variables. The firewall uses strict AST parsing to prevent SQL injection and comment-bypass attacks, and strictly prohibits privilege escalation commands (like `GRANT` or `CALL`).
|
|
14
|
+
3. **Session-Level Defense in Depth**: If the server is configured in strict Read-Only mode (all write variables disabled), TokenLite injects `SET SESSION TRANSACTION READ ONLY` directly into the connection pool sockets. This guarantees that even if a theoretical bypass exists in the AST parser, the MySQL engine itself will physically reject any data modification.
|
|
15
|
+
4. **Business Intelligence Injection**: Bridges the gap between raw data and company logic. Automatically attaches semantic dictionaries (`metadata.json`) to database schema exploration, and exposes Semantic Templates via the official **MCP Prompts API** (`templates.json`) so the LLM uses pre-approved analytical queries instead of hallucinating them.
|
|
16
|
+
5. **Graph-Based Semantic Schema**: Avoids sending giant schemas to the LLM that saturate the context window. When a table is searched, the engine uses heuristics to deduce implicit relationships and packages the exact "Auto-Join Context".
|
|
17
|
+
6. **CSV Token Compression**: Database results are efficiently transformed into tabular CSV markdown, saving up to 60% of Output Tokens compared to verbose JSON.
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
|
@@ -98,6 +100,14 @@ To use within Cursor IDE:
|
|
|
98
100
|
| `MCP_SAFE_QUERY_ENABLE_BLOCKING`| Enable or disable the EXPLAIN guardrail. | `true` | No |
|
|
99
101
|
| `MCP_METADATA_PATH` | Absolute path to your custom `metadata.json` dictionary. | (Disabled) | No |
|
|
100
102
|
| `MCP_TEMPLATES_PATH` | Absolute path to your custom `templates.json` queries. | (Disabled) | No |
|
|
103
|
+
| `TOOL_PREFIX` | Prefix for tool names (useful when running multiple instances). | Random (e.g., `db_a1b2_`) | No |
|
|
104
|
+
| `MYSQL_QUERY_TIMEOUT` | Max execution time for a query (in ms). Aborts heavy queries to protect against DoS. | `15000` | No |
|
|
105
|
+
| `MYSQL_CONNECTION_LIMIT` | Max concurrent pool connections. | `10` | No |
|
|
106
|
+
| `MYSQL_CONNECT_TIMEOUT` | Max time to wait for a socket to establish (in ms). | `10000` | No |
|
|
107
|
+
| `ALLOW_INSERT_OPERATION` | Enable `INSERT` and `REPLACE` queries. | `false` | No |
|
|
108
|
+
| `ALLOW_UPDATE_OPERATION` | Enable `UPDATE` queries. | `false` | No |
|
|
109
|
+
| `ALLOW_DELETE_OPERATION` | Enable `DELETE` and `TRUNCATE` queries. | `false` | No |
|
|
110
|
+
| `ALLOW_DDL_OPERATION` | Enable Data Definition Language (`CREATE`, `ALTER`, `DROP`, `RENAME`). | `false` | No |
|
|
101
111
|
|
|
102
112
|
---
|
|
103
113
|
|
|
@@ -158,6 +168,26 @@ TokenLite converts raw database rows to a dense, structured CSV layout. This avo
|
|
|
158
168
|
|
|
159
169
|
---
|
|
160
170
|
|
|
171
|
+
## 🌐 Advanced Networking & Remote Connections
|
|
172
|
+
|
|
173
|
+
By design, `tokenlite-mysql-mcp` adheres to the Unix philosophy: it does one thing (AI-driven MySQL interactions) and does it securely via the standard `stdio` transport. It deliberately avoids bloating the codebase with HTTP servers or built-in SSH clients.
|
|
174
|
+
|
|
175
|
+
If you need to connect to remote databases or expose this server over the network, here are the recommended, enterprise-grade alternatives:
|
|
176
|
+
|
|
177
|
+
### 1. Connecting to Remote Databases (SSH Tunnels)
|
|
178
|
+
Instead of embedding SSH libraries, we recommend using native OS tunnels. This is much more secure, respects your `~/.ssh/config`, and supports advanced authentication (2FA, hardware keys).
|
|
179
|
+
|
|
180
|
+
Simply open a terminal and run:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
ssh -N -L 3306:127.0.0.1:3306 user@your-remote-server.com
|
|
184
|
+
```
|
|
185
|
+
Then, point `tokenlite-mysql-mcp` to `localhost` and port `3306`.
|
|
186
|
+
|
|
187
|
+
### 2. Exposing the MCP Server over HTTP/Network
|
|
188
|
+
If you need to host this MCP Server in the cloud (AWS, GCP) and have multiple Claude desktop clients connect to it remotely via HTTP/SSE, do not modify this codebase to add Express/HTTP logic.
|
|
189
|
+
Instead, wrap the process using standard open-source MCP proxies like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy). This cleanly separates the transport layer security from the AI logic.
|
|
190
|
+
|
|
161
191
|
## 🐛 Troubleshooting
|
|
162
192
|
|
|
163
193
|
**Error: `OptimizerError: Full table scan detected...`**
|
|
@@ -169,3 +199,5 @@ This means the MCP JSON-RPC protocol crashed. Ensure you are passing the correct
|
|
|
169
199
|
|
|
170
200
|
---
|
|
171
201
|
*Built for the AI Engineering era.*
|
|
202
|
+
|
|
203
|
+
---
|
package/dist/db/index.js
CHANGED
|
@@ -10,24 +10,65 @@ export const pool = mysql.createPool({
|
|
|
10
10
|
password: process.env.DB_PASSWORD || '',
|
|
11
11
|
database: process.env.DB_NAME || 'test',
|
|
12
12
|
waitForConnections: true,
|
|
13
|
-
connectionLimit: 10,
|
|
13
|
+
connectionLimit: parseInt(process.env.MYSQL_CONNECTION_LIMIT || '10', 10),
|
|
14
14
|
queueLimit: 0,
|
|
15
|
-
connectTimeout: 10000
|
|
15
|
+
connectTimeout: parseInt(process.env.MYSQL_CONNECT_TIMEOUT || '10000', 10)
|
|
16
16
|
});
|
|
17
|
+
// Session-Level Defense in Depth
|
|
18
|
+
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
19
|
+
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
20
|
+
const allowDelete = process.env.ALLOW_DELETE_OPERATION === 'true';
|
|
21
|
+
const allowDdl = process.env.ALLOW_DDL_OPERATION === 'true';
|
|
22
|
+
const isStrictReadOnlyMode = !(allowInsert || allowUpdate || allowDelete || allowDdl);
|
|
23
|
+
if (isStrictReadOnlyMode) {
|
|
24
|
+
pool.on('connection', (connection) => {
|
|
25
|
+
// At runtime this is a callback connection, despite what TS types say
|
|
26
|
+
connection.query('SET SESSION TRANSACTION READ ONLY', (err) => {
|
|
27
|
+
if (err) {
|
|
28
|
+
console.error('[tokenlite-mysql-mcp] Warning: Failed to set connection to READ ONLY:', err.message);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
17
33
|
export function getDbName() {
|
|
18
34
|
return process.env.DB_NAME || 'test';
|
|
19
35
|
}
|
|
20
36
|
/**
|
|
21
|
-
* Executes a safe query with a Timeout.
|
|
37
|
+
* Executes a safe query with a Timeout and Granular Permissions.
|
|
22
38
|
*/
|
|
23
39
|
export async function executeSafeQuery(sql) {
|
|
24
40
|
// AST Validation and Limit Injection
|
|
25
|
-
const astOptimizedSql = injectLimitAst(sql);
|
|
26
|
-
//
|
|
27
|
-
|
|
41
|
+
const { sql: astOptimizedSql, astType } = injectLimitAst(sql);
|
|
42
|
+
// Permission Enforcement
|
|
43
|
+
if (astType !== 'select' && astType !== 'show') {
|
|
44
|
+
const blockedTypes = ['call', 'grant', 'revoke', 'set', 'use'];
|
|
45
|
+
if (blockedTypes.includes(astType)) {
|
|
46
|
+
throw new Error(`Security Error: Dangerous operation '${astType}' is strictly prohibited.`);
|
|
47
|
+
}
|
|
48
|
+
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
49
|
+
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
50
|
+
const allowDelete = process.env.ALLOW_DELETE_OPERATION === 'true';
|
|
51
|
+
const allowDdl = process.env.ALLOW_DDL_OPERATION === 'true';
|
|
52
|
+
let isAllowed = false;
|
|
53
|
+
if ((astType === 'insert' || astType === 'replace') && allowInsert)
|
|
54
|
+
isAllowed = true;
|
|
55
|
+
else if (astType === 'update' && allowUpdate)
|
|
56
|
+
isAllowed = true;
|
|
57
|
+
else if ((astType === 'delete' || astType === 'truncate') && allowDelete)
|
|
58
|
+
isAllowed = true;
|
|
59
|
+
else if (['create', 'alter', 'drop', 'rename'].includes(astType) && allowDdl)
|
|
60
|
+
isAllowed = true;
|
|
61
|
+
if (!isAllowed) {
|
|
62
|
+
throw new Error(`Security Error: Operation '${astType}' is disabled in the server configuration.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Pre-flight Analysis (Only blocks full table scans on SELECT)
|
|
66
|
+
if (astType === 'select') {
|
|
67
|
+
await analyzeQueryPlan(astOptimizedSql, pool);
|
|
68
|
+
}
|
|
28
69
|
const [rows] = await pool.query({
|
|
29
70
|
sql: astOptimizedSql,
|
|
30
|
-
timeout: 15000
|
|
71
|
+
timeout: parseInt(process.env.MYSQL_QUERY_TIMEOUT || '15000', 10)
|
|
31
72
|
});
|
|
32
73
|
return rows;
|
|
33
74
|
}
|
|
@@ -40,3 +81,15 @@ export async function pingDb() {
|
|
|
40
81
|
return false;
|
|
41
82
|
}
|
|
42
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Gracefully shuts down the MySQL connection pool.
|
|
86
|
+
*/
|
|
87
|
+
export async function closePool() {
|
|
88
|
+
try {
|
|
89
|
+
await pool.end();
|
|
90
|
+
console.error('[tokenlite-mysql-mcp] Database connection pool closed gracefully.');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error('[tokenlite-mysql-mcp] Error closing database connection pool:', error.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/db/optimizer.js
CHANGED
|
@@ -16,11 +16,11 @@ function isBlockingEnabled() {
|
|
|
16
16
|
return process.env.MCP_SAFE_QUERY_ENABLE_BLOCKING !== 'false';
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Parses the SQL query to AST, injects a LIMIT if missing, and returns the modified SQL.
|
|
19
|
+
* Parses the SQL query to AST, injects a LIMIT if missing for SELECTs, and returns the modified SQL and AST type.
|
|
20
20
|
*/
|
|
21
21
|
export function injectLimitAst(sql, maxLimit = 500) {
|
|
22
22
|
if (sql.trim().toUpperCase().startsWith('SHOW')) {
|
|
23
|
-
return sql;
|
|
23
|
+
return { sql, astType: 'show' };
|
|
24
24
|
}
|
|
25
25
|
try {
|
|
26
26
|
const astOpt = { database: 'MySQL' };
|
|
@@ -32,27 +32,29 @@ export function injectLimitAst(sql, maxLimit = 500) {
|
|
|
32
32
|
}
|
|
33
33
|
ast = ast[0];
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
35
|
+
// @ts-ignore
|
|
36
|
+
const type = ast.type?.toLowerCase();
|
|
37
|
+
// Only inject LIMIT and sqlify if it's a SELECT query.
|
|
38
|
+
// For DML/DDL, we just return the original SQL to avoid parser mangling.
|
|
39
|
+
if (type === 'select') {
|
|
40
|
+
const selectAst = ast;
|
|
41
|
+
if (!selectAst.limit) {
|
|
42
|
+
selectAst.limit = {
|
|
43
|
+
seperator: "",
|
|
44
|
+
value: [
|
|
45
|
+
{ type: 'number', value: maxLimit }
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const limitValue = selectAst.limit.value[0]?.value;
|
|
51
|
+
if (typeof limitValue === 'number' && limitValue > maxLimit) {
|
|
52
|
+
selectAst.limit.value[0].value = maxLimit;
|
|
53
|
+
}
|
|
53
54
|
}
|
|
55
|
+
return { sql: parser.sqlify(selectAst, astOpt), astType: type };
|
|
54
56
|
}
|
|
55
|
-
return
|
|
57
|
+
return { sql, astType: type };
|
|
56
58
|
}
|
|
57
59
|
catch (e) {
|
|
58
60
|
if (e instanceof OptimizerError) {
|
package/dist/db/schema.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { pool, getDbName } from './index.js';
|
|
2
2
|
export let schemaGraph = new Map();
|
|
3
|
+
/**
|
|
4
|
+
* Retrieves the raw DDL (CREATE TABLE) statement for a given table.
|
|
5
|
+
*/
|
|
6
|
+
export async function getTableDDL(tableName) {
|
|
7
|
+
try {
|
|
8
|
+
const [rows] = await pool.query(`SHOW CREATE TABLE \`${tableName}\``);
|
|
9
|
+
if (rows && rows.length > 0) {
|
|
10
|
+
return rows[0]['Create Table'] || rows[0]['Create View'];
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
3
18
|
/**
|
|
4
19
|
* Connects to the database and builds the relational graph in-memory.
|
|
5
20
|
* Optimized for low RAM usage by only extracting node names and edges (no DDL/Columns cached).
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { registerExecuteQueryTool } from "./tools/executeQuery.js";
|
|
5
5
|
import { registerSearchSchemaTool } from "./tools/searchSchema.js";
|
|
6
6
|
import { registerRefreshSchemaTool } from "./tools/refreshSchema.js";
|
|
7
|
-
import {
|
|
7
|
+
import { registerTemplatesPrompt } from "./prompts/templates.js";
|
|
8
|
+
import { registerTableResources } from "./resources/tables.js";
|
|
8
9
|
import { buildSchemaGraph } from "./db/schema.js";
|
|
9
10
|
import { initMetadata } from "./db/metadata.js";
|
|
11
|
+
import { closePool } from "./db/index.js";
|
|
10
12
|
import dotenv from "dotenv";
|
|
11
13
|
dotenv.config({ quiet: true });
|
|
12
14
|
async function main() {
|
|
@@ -18,12 +20,27 @@ async function main() {
|
|
|
18
20
|
await buildSchemaGraph();
|
|
19
21
|
// Load Metadata and Templates
|
|
20
22
|
initMetadata();
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// Generate or use provided tool prefix (dev_, prod_ ...)
|
|
24
|
+
let prefix = process.env.TOOL_PREFIX;
|
|
25
|
+
if (!prefix) {
|
|
26
|
+
const randomStr = Math.random().toString(36).substring(2, 6);
|
|
27
|
+
prefix = `db_${randomStr}_`;
|
|
28
|
+
}
|
|
29
|
+
// Register MCP tools & prompts & resources
|
|
30
|
+
registerSearchSchemaTool(server, prefix);
|
|
31
|
+
registerExecuteQueryTool(server, prefix);
|
|
32
|
+
registerRefreshSchemaTool(server, prefix);
|
|
33
|
+
registerTemplatesPrompt(server, prefix);
|
|
34
|
+
registerTableResources(server);
|
|
26
35
|
const transport = new StdioServerTransport();
|
|
27
36
|
await server.connect(transport);
|
|
37
|
+
const cleanup = async () => {
|
|
38
|
+
console.error('\n[tokenlite-mysql-mcp] Shutting down server...');
|
|
39
|
+
await server.close();
|
|
40
|
+
await closePool();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
};
|
|
43
|
+
process.on('SIGINT', cleanup);
|
|
44
|
+
process.on('SIGTERM', cleanup);
|
|
28
45
|
}
|
|
29
46
|
main().catch(console.error);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { searchTemplates } from "../db/metadata.js";
|
|
3
|
+
export function registerTemplatesPrompt(server, prefix = "") {
|
|
4
|
+
server.prompt(`${prefix}query_templates`, "Official company SQL templates for business metrics. Use this as a starting point to avoid writing SQL from scratch.", {
|
|
5
|
+
query: z.string().optional().describe("Keyword to search for in templates (e.g., 'revenue', 'ltv').")
|
|
6
|
+
}, ({ query }) => {
|
|
7
|
+
const results = searchTemplates(query || "");
|
|
8
|
+
if (results.length === 0) {
|
|
9
|
+
return {
|
|
10
|
+
messages: [
|
|
11
|
+
{
|
|
12
|
+
role: "user",
|
|
13
|
+
content: {
|
|
14
|
+
type: "text",
|
|
15
|
+
text: "No SQL templates found matching your query."
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
let output = "--- PRE-APPROVED SQL TEMPLATES ---\n\n";
|
|
22
|
+
for (const t of results) {
|
|
23
|
+
output += `### ${t.name}\n`;
|
|
24
|
+
output += `Description: ${t.description}\n`;
|
|
25
|
+
output += `SQL:\n\`\`\`sql\n${t.sql}\n\`\`\`\n\n`;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
messages: [
|
|
29
|
+
{
|
|
30
|
+
role: "user",
|
|
31
|
+
content: {
|
|
32
|
+
type: "text",
|
|
33
|
+
text: output
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { schemaGraph, getTableDDL } from "../db/schema.js";
|
|
3
|
+
import { getTableSemantics } from "../db/metadata.js";
|
|
4
|
+
export function registerTableResources(server) {
|
|
5
|
+
// List all available tables
|
|
6
|
+
server.resource("schema", "mysql://schema", { description: "Lists all available tables in the database schema" }, async (uri) => {
|
|
7
|
+
const tables = Array.from(schemaGraph.keys());
|
|
8
|
+
if (tables.length === 0) {
|
|
9
|
+
return {
|
|
10
|
+
contents: [{
|
|
11
|
+
uri: uri.href,
|
|
12
|
+
text: "Schema Graph is empty. Make sure the database is connected."
|
|
13
|
+
}]
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
contents: [{
|
|
18
|
+
uri: uri.href,
|
|
19
|
+
text: `Available tables in the database:\n\n${tables.map(t => `- ${t}`).join('\n')}`
|
|
20
|
+
}]
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
// DDL for a specific table
|
|
24
|
+
server.resource("table", new ResourceTemplate("mysql://tables/{name}", { list: undefined }), { description: "Exposes the SQL DDL and semantic dictionary of a specific table" }, async (uri, { name }) => {
|
|
25
|
+
const tableName = typeof name === 'string' ? name : String(name);
|
|
26
|
+
const ddl = await getTableDDL(tableName);
|
|
27
|
+
if (!ddl) {
|
|
28
|
+
return {
|
|
29
|
+
contents: [{
|
|
30
|
+
uri: uri.href,
|
|
31
|
+
text: `Table '${tableName}' not found or could not fetch DDL.`
|
|
32
|
+
}]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
let output = `-- === TABLE: ${tableName} ===\n${ddl};\n`;
|
|
36
|
+
// Append Semantics if available
|
|
37
|
+
const semantics = getTableSemantics(tableName);
|
|
38
|
+
if (Object.keys(semantics).length > 0) {
|
|
39
|
+
output += `\n/* SEMANTIC DICTIONARY:\n`;
|
|
40
|
+
output += JSON.stringify(semantics, null, 2);
|
|
41
|
+
output += `\n*/\n`;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
contents: [{
|
|
45
|
+
uri: uri.href,
|
|
46
|
+
text: output
|
|
47
|
+
}]
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -2,32 +2,48 @@ import { z } from "zod";
|
|
|
2
2
|
import { executeSafeQuery } from "../db/index.js";
|
|
3
3
|
import { jsonToCsv } from "../utils/csvFormatter.js";
|
|
4
4
|
export async function handleExecuteQuery({ sql }) {
|
|
5
|
-
if (!sql.trim().toUpperCase().startsWith("SELECT") && !sql.trim().toUpperCase().startsWith("SHOW")) {
|
|
6
|
-
return {
|
|
7
|
-
content: [{ type: "text", text: "Security Error: Only SELECT or SHOW statements are allowed." }],
|
|
8
|
-
isError: true
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
5
|
try {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
const result = await executeSafeQuery(sql);
|
|
7
|
+
// If it's a SELECT, result are rows
|
|
8
|
+
if (Array.isArray(result)) {
|
|
9
|
+
const csvData = jsonToCsv(result);
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: "text", text: csvData }]
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// If it's a DML/DDL operation, result is a ResultSetHeader object
|
|
15
|
+
else {
|
|
16
|
+
const header = result;
|
|
17
|
+
const message = `Operation executed successfully.\nAffected rows: ${header.affectedRows || 0}` +
|
|
18
|
+
(header.insertId ? `\nInsert ID: ${header.insertId}` : '') +
|
|
19
|
+
(header.changedRows ? `\nChanged rows: ${header.changedRows}` : '');
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: message }]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
17
24
|
}
|
|
18
25
|
catch (error) {
|
|
19
26
|
let errorMessage = error.name === 'OptimizerError' ? error.message : `Database Error: ${error.message}`;
|
|
20
27
|
if (error.code === 'ER_BAD_FIELD_ERROR' || error.message?.includes('Unknown column')) {
|
|
21
28
|
errorMessage += `\n\nHint: If you believe this column exists, the DBA might have just added it. Please call the 'refresh_schema' tool and try again.`;
|
|
22
29
|
}
|
|
30
|
+
else if (error.code === 'PROTOCOL_SEQUENCE_TIMEOUT' || error.message?.includes('timeout')) {
|
|
31
|
+
errorMessage += `\n\nHint: The query took too long and was aborted to protect the database (DoS protection). Please optimize your query by using better filters, utilizing indexes, or ask the user to increase the MYSQL_QUERY_TIMEOUT.`;
|
|
32
|
+
}
|
|
23
33
|
return {
|
|
24
34
|
content: [{ type: "text", text: errorMessage }],
|
|
25
35
|
isError: true
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
38
|
}
|
|
29
|
-
export function registerExecuteQueryTool(server) {
|
|
30
|
-
|
|
39
|
+
export function registerExecuteQueryTool(server, prefix = "") {
|
|
40
|
+
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
41
|
+
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
42
|
+
const allowDelete = process.env.ALLOW_DELETE_OPERATION === 'true';
|
|
43
|
+
const allowDdl = process.env.ALLOW_DDL_OPERATION === 'true';
|
|
44
|
+
// If any write operation is enabled, this tool is no longer read-only
|
|
45
|
+
const isReadOnly = !(allowInsert || allowUpdate || allowDelete || allowDdl);
|
|
46
|
+
server.tool(`${prefix}execute_safe_query`, "Executes a safe SELECT query on the database. Large results are automatically truncated. CRITICAL: NEVER use this tool (e.g., SHOW TABLES or querying information_schema) to understand the database structure. You MUST ALWAYS use the 'search_schema' tool first to understand the relationships and tables before writing any JOIN queries.", {
|
|
31
47
|
sql: z.string().describe("SQL SELECT statement to execute."),
|
|
32
|
-
}, handleExecuteQuery);
|
|
48
|
+
}, { readOnlyHint: isReadOnly }, handleExecuteQuery);
|
|
33
49
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildSchemaGraph } from "../db/schema.js";
|
|
2
|
-
export function registerRefreshSchemaTool(server) {
|
|
3
|
-
server.tool(
|
|
2
|
+
export function registerRefreshSchemaTool(server, prefix = "") {
|
|
3
|
+
server.tool(`${prefix}refresh_schema`, "Forces the MCP server to rebuild the internal Schema Graph. Use this if you suspect a DBA recently added a table, column, or foreign key and the search_schema or execute queries are failing.", {}, { idempotentHint: true }, async () => {
|
|
4
4
|
try {
|
|
5
5
|
await buildSchemaGraph();
|
|
6
6
|
return {
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import Fuse from "fuse.js";
|
|
3
|
-
import {
|
|
4
|
-
import { schemaGraph } from "../db/schema.js";
|
|
3
|
+
import { schemaGraph, getTableDDL } from "../db/schema.js";
|
|
5
4
|
import { getTableSemantics } from "../db/metadata.js";
|
|
6
|
-
async function getTableDDL(tableName) {
|
|
7
|
-
try {
|
|
8
|
-
const [rows] = await pool.query(`SHOW CREATE TABLE \`${tableName}\``);
|
|
9
|
-
if (rows && rows.length > 0) {
|
|
10
|
-
return rows[0]['Create Table'] || rows[0]['Create View'];
|
|
11
|
-
}
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
catch (e) {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
5
|
export async function handleSearchSchema({ query }) {
|
|
19
6
|
if (schemaGraph.size === 0) {
|
|
20
7
|
return {
|
|
@@ -81,13 +68,13 @@ export async function handleSearchSchema({ query }) {
|
|
|
81
68
|
if (inferredHints.length > 0) {
|
|
82
69
|
output += "\n-- === HEURISTIC GRAPH HINTS ===\n" + inferredHints.join("\n");
|
|
83
70
|
}
|
|
84
|
-
output += "\n\n/* ⚠️ CRITICAL REMINDER: If you are asked to calculate business metrics (LTV, revenue, etc.), DO NOT write the SQL manually. You MUST use the `
|
|
71
|
+
output += "\n\n/* ⚠️ CRITICAL REMINDER: If you are asked to calculate business metrics (LTV, revenue, etc.), DO NOT write the SQL manually. You MUST use the `query_templates` prompt first to fetch the official template. */";
|
|
85
72
|
return {
|
|
86
73
|
content: [{ type: "text", text: output }]
|
|
87
74
|
};
|
|
88
75
|
}
|
|
89
|
-
export function registerSearchSchemaTool(server) {
|
|
90
|
-
server.tool(
|
|
76
|
+
export function registerSearchSchemaTool(server, prefix = "") {
|
|
77
|
+
server.tool(`${prefix}search_schema`, "CRITICAL TOOL FOR SCHEMA EXPLORATION: Use this tool FIRST to understand the database structure. Searches for a table and returns its exact SQL DDL, along with the DDL of its direct parent and child tables (Auto-Join Context). Do NOT use execute_safe_query for schema exploration.", {
|
|
91
78
|
query: z.string().describe("The name of the table or entity to search for (e.g. 'users', 'invoices')."),
|
|
92
|
-
}, handleSearchSchema);
|
|
79
|
+
}, { readOnlyHint: true }, handleSearchSchema);
|
|
93
80
|
}
|