@andezdev/tokenlite-mysql-mcp 1.0.0 → 3.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/AGENTS.md +13 -1
- package/README.md +81 -15
- package/dist/db/index.js +108 -10
- package/dist/db/metadata.js +5 -4
- package/dist/db/optimizer.js +23 -21
- package/dist/db/schema.js +29 -1
- package/dist/index.js +33 -6
- package/dist/prompts/templates.js +39 -0
- package/dist/resources/tables.js +50 -0
- package/dist/tools/executeQuery.js +33 -14
- package/dist/tools/explainQuery.js +26 -0
- package/dist/tools/ping.js +37 -0
- package/dist/tools/refreshSchema.js +2 -2
- package/dist/tools/searchSchema.js +6 -19
- package/dist/utils/logger.js +39 -0
- package/package.json +5 -1
- package/dist/tools/getTemplates.js +0 -24
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
5. **Fixing Optimizer Blocks (Full Table Scans).**
|
|
25
25
|
- **Rule**: If the `execute_safe_query` tool throws an `OptimizerError: Full table scan detected`, it means your query is scanning too many rows without an index.
|
|
26
|
-
- **Action**:
|
|
26
|
+
- **Action**: Use `explain_query` to see the full EXPLAIN output, then rewrite the query to include a `WHERE` clause that uses an indexed column (e.g., a primary key or foreign key).
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
@@ -49,3 +49,15 @@
|
|
|
49
49
|
**Use for:** Forcing the server to rescan the database and update its internal graph.
|
|
50
50
|
**Arguments:** None.
|
|
51
51
|
**Use when:** You receive an error that a table or column doesn't exist, implying the schema changed.
|
|
52
|
+
|
|
53
|
+
### `explain_query`
|
|
54
|
+
**Use for:** Analyzing the execution plan of a SELECT query before running it.
|
|
55
|
+
**Arguments:** `sql` (string) - The SELECT query to analyze.
|
|
56
|
+
**Returns:** The MySQL EXPLAIN output as CSV showing join types, index usage, and estimated row counts.
|
|
57
|
+
**Use when:** Your query was blocked by the optimizer (`OptimizerError`) or you want to verify it uses indexes before executing.
|
|
58
|
+
|
|
59
|
+
### `ping`
|
|
60
|
+
**Use for:** Checking if the database connection is alive and healthy.
|
|
61
|
+
**Arguments:** None.
|
|
62
|
+
**Returns:** JSON with `status` (`"ok"` or `"error"`), `server_version`, and `pool` stats (`active`, `idle`, `queue` connection counts).
|
|
63
|
+
**Use when:** You suspect a connection issue or want to verify the server is operational before running queries.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TokenLite MySQL MCP
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp)
|
|
3
|
+
[](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp)
|
|
4
4
|
|
|
5
5
|
A robust and secure MySQL database server implemented under Anthropic's **Model Context Protocol (MCP)**.
|
|
6
6
|
Designed specifically to solve the shortcomings of current generic MCP servers through **Graceful Degradation, Active Performance Protection, and Aggressive Token Optimization**.
|
|
@@ -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,19 @@ 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
|
+
| `MYSQL_RETRY_ATTEMPTS` | Max retries on transient connection errors (`ECONNREFUSED`, `PROTOCOL_CONNECTION_LOST`, etc.). | `3` | No |
|
|
108
|
+
| `MYSQL_RETRY_DELAY_MS` | Base delay (ms) for exponential backoff between retries (1s, 2s, 4s...). | `1000` | No |
|
|
109
|
+
| `MYSQL_QUEUE_LIMIT` | Max queued requests when all pool connections are busy. Prevents unbounded growth if MySQL is down. | `50` | No |
|
|
110
|
+
| `MCP_DDL_CACHE_TTL` | Time-to-live (in seconds) for cached DDL statements. Reduces latency on repeated `search_schema` calls. Invalidated by `refresh_schema`. | `60` | No |
|
|
111
|
+
| `MCP_LOG_LEVEL` | Minimum severity for MCP log notifications: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. | `info` | No |
|
|
112
|
+
| `ALLOW_INSERT_OPERATION` | Enable `INSERT` and `REPLACE` queries. | `false` | No |
|
|
113
|
+
| `ALLOW_UPDATE_OPERATION` | Enable `UPDATE` queries. | `false` | No |
|
|
114
|
+
| `ALLOW_DELETE_OPERATION` | Enable `DELETE` and `TRUNCATE` queries. | `false` | No |
|
|
115
|
+
| `ALLOW_DDL_OPERATION` | Enable Data Definition Language (`CREATE`, `ALTER`, `DROP`, `RENAME`). | `false` | No |
|
|
101
116
|
|
|
102
117
|
---
|
|
103
118
|
|
|
@@ -132,40 +147,91 @@ Stop the LLM from hallucinating complex metrics by providing vetted templates.
|
|
|
132
147
|
|
|
133
148
|
## 📈 Benchmarks & Token Savings
|
|
134
149
|
|
|
135
|
-
TokenLite includes an automated
|
|
150
|
+
TokenLite includes an automated benchmark suite using `o200k_base` tokenization (GPT-4o/GPT-5 standard) to measure efficiency improvements. Token counts are approximate — Claude 4.x uses a proprietary tokenizer; actual counts may vary slightly.
|
|
136
151
|
|
|
137
152
|
To run the benchmark in your own environment:
|
|
138
153
|
```bash
|
|
139
154
|
npm run benchmark
|
|
140
155
|
```
|
|
141
156
|
|
|
157
|
+
### Baseline: Standard MCP Pattern
|
|
158
|
+
The benchmark compares against the standard pattern used by generic MySQL MCP servers: full schema exposed as `information_schema.columns` in pretty-printed JSON, and query results returned as `JSON.stringify(rows, null, 2)` with execution time metadata.
|
|
159
|
+
|
|
142
160
|
### 1. Schema Discovery (Input Tokens)
|
|
143
|
-
|
|
161
|
+
Standard MCP servers dump the entire schema to the LLM. For large databases, this consumes thousands of input tokens on every turn. TokenLite's relational graph serves a localized **Auto-Join Context** (target table + direct parent tables + direct child tables).
|
|
162
|
+
|
|
163
|
+
| Scenario | Standard MCP Pattern | TokenLite | 📉 Savings |
|
|
164
|
+
| :--- | :--- | :--- | :--- |
|
|
165
|
+
| **Mock (50 tables, Enterprise CRM)** | 15,566 tokens | 883 tokens | **94.3%** |
|
|
166
|
+
| **Live (7 tables, Test DB)** | 1,892 tokens | 257 tokens | **86.4%** |
|
|
144
167
|
|
|
145
|
-
|
|
146
|
-
* **TokenLite Relational Graph:** 252 tokens
|
|
147
|
-
* **📉 Schema Input Savings:** **58.7%** (up to **90%** on larger enterprise schemas)
|
|
168
|
+
Savings scale with the number of tables: the more tables in the database, the higher the savings because the standard pattern dumps all of them while TokenLite only fetches the target + 1-hop relationships.
|
|
148
169
|
|
|
149
170
|
### 2. Query Result Payloads (Output Tokens)
|
|
150
171
|
TokenLite converts raw database rows to a dense, structured CSV layout. This avoids JSON syntax overhead (brackets, braces, repeated keys) and compresses the output payload returned to the LLM.
|
|
151
172
|
|
|
152
|
-
|
|
173
|
+
**Mock data** (varied: NULLs, long descriptions, mixed lengths):
|
|
174
|
+
|
|
175
|
+
| Rows Returned | Standard MCP Pattern (Tokens) | TokenLite CSV (Tokens) | 📉 Output Savings (%) |
|
|
176
|
+
| :--- | :--- | :--- | :--- |
|
|
177
|
+
| **10 rows** | 1,167 | 592 | **49.3%** |
|
|
178
|
+
| **50 rows** | 5,805 | 2,875 | **50.5%** |
|
|
179
|
+
| **100 rows** | 11,607 | 5,734 | **50.6%** |
|
|
180
|
+
| **500 rows** | 57,998 | 28,578 | **50.7%** |
|
|
181
|
+
|
|
182
|
+
**Live data** (real MySQL test database with NULLs, ENUMs, variable-length text):
|
|
183
|
+
|
|
184
|
+
| Rows Returned | Standard MCP Pattern (Tokens) | TokenLite CSV (Tokens) | 📉 Output Savings (%) |
|
|
153
185
|
| :--- | :--- | :--- | :--- |
|
|
154
|
-
| **10 rows** | 1,
|
|
155
|
-
| **50 rows** | 5,
|
|
156
|
-
| **100 rows** |
|
|
157
|
-
| **500 rows** |
|
|
186
|
+
| **10 rows** | 1,007 | 575 | **42.9%** |
|
|
187
|
+
| **50 rows** | 5,029 | 2,845 | **43.4%** |
|
|
188
|
+
| **100 rows** | 10,071 | 5,699 | **43.4%** |
|
|
189
|
+
| **500 rows** | 50,327 | 28,441 | **43.5%** |
|
|
158
190
|
|
|
159
191
|
---
|
|
160
192
|
|
|
193
|
+
## 📊 Logging & Observability
|
|
194
|
+
|
|
195
|
+
TokenLite uses MCP-native logging via `notifications/message` instead of raw stderr output. Clients that support MCP logging (e.g., MCP Inspector) will receive structured log messages with severity levels, logger names, and JSON data.
|
|
196
|
+
|
|
197
|
+
**Severity levels** (from least to most severe): `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`.
|
|
198
|
+
|
|
199
|
+
The server emits logs at `info` level and above by default. Control the minimum level via `MCP_LOG_LEVEL` or dynamically at runtime through the MCP `logging/setLevel` request.
|
|
200
|
+
|
|
201
|
+
Before the MCP session is established (e.g., during pool initialization), logs fall back to stderr.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 🌐 Advanced Networking & Remote Connections
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
If you need to connect to remote databases or expose this server over the network, here are the recommended, enterprise-grade alternatives:
|
|
210
|
+
|
|
211
|
+
### 1. Connecting to Remote Databases (SSH Tunnels)
|
|
212
|
+
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).
|
|
213
|
+
|
|
214
|
+
Simply open a terminal and run:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
ssh -N -L 3306:127.0.0.1:3306 user@your-remote-server.com
|
|
218
|
+
```
|
|
219
|
+
Then, point `tokenlite-mysql-mcp` to `localhost` and port `3306`.
|
|
220
|
+
|
|
221
|
+
### 2. Exposing the MCP Server over HTTP/Network
|
|
222
|
+
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.
|
|
223
|
+
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.
|
|
224
|
+
|
|
161
225
|
## 🐛 Troubleshooting
|
|
162
226
|
|
|
163
227
|
**Error: `OptimizerError: Full table scan detected...`**
|
|
164
228
|
The LLM attempted to execute a query that requires scanning thousands of rows without using an index.
|
|
165
|
-
*Solution*:
|
|
229
|
+
*Solution*: Use `explain_query` to see the full EXPLAIN output and understand why the query was blocked. Rewrite the query with an indexed `WHERE` clause. If you truly need to scan the whole table, increase `MCP_SAFE_QUERY_MAX_ROWS` in your config.
|
|
166
230
|
|
|
167
231
|
**Error: `calling "initialize": invalid character...`**
|
|
168
232
|
This means the MCP JSON-RPC protocol crashed. Ensure you are passing the correct DB credentials and that the database is running and accessible from the machine where the MCP server runs.
|
|
169
233
|
|
|
170
234
|
---
|
|
171
235
|
*Built for the AI Engineering era.*
|
|
236
|
+
|
|
237
|
+
---
|
package/dist/db/index.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise';
|
|
2
2
|
import dotenv from 'dotenv';
|
|
3
3
|
import { injectLimitAst, analyzeQueryPlan } from './optimizer.js';
|
|
4
|
+
import { log } from '../utils/logger.js';
|
|
4
5
|
// Supress dotenv logs so they don't corrupt the MCP JSON-RPC stdout stream
|
|
5
6
|
dotenv.config({ quiet: true });
|
|
7
|
+
const MAX_RETRY_ATTEMPTS = parseInt(process.env.MYSQL_RETRY_ATTEMPTS || '3', 10);
|
|
8
|
+
const RETRY_BASE_DELAY_MS = parseInt(process.env.MYSQL_RETRY_DELAY_MS || '1000', 10);
|
|
9
|
+
const QUEUE_LIMIT = parseInt(process.env.MYSQL_QUEUE_LIMIT || '50', 10);
|
|
10
|
+
const RETRYABLE_ERRORS = new Set([
|
|
11
|
+
'ECONNREFUSED',
|
|
12
|
+
'PROTOCOL_CONNECTION_LOST',
|
|
13
|
+
'ECONNRESET',
|
|
14
|
+
'ETIMEDOUT',
|
|
15
|
+
'ER_CON_COUNT_ERROR',
|
|
16
|
+
]);
|
|
17
|
+
export function isRetryableError(error) {
|
|
18
|
+
if (RETRYABLE_ERRORS.has(error.code))
|
|
19
|
+
return true;
|
|
20
|
+
if (error.message?.includes('Connection lost'))
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
6
27
|
export const pool = mysql.createPool({
|
|
7
28
|
host: process.env.DB_HOST || 'localhost',
|
|
8
29
|
port: parseInt(process.env.DB_PORT || '3306', 10),
|
|
@@ -10,33 +31,110 @@ export const pool = mysql.createPool({
|
|
|
10
31
|
password: process.env.DB_PASSWORD || '',
|
|
11
32
|
database: process.env.DB_NAME || 'test',
|
|
12
33
|
waitForConnections: true,
|
|
13
|
-
connectionLimit: 10,
|
|
14
|
-
queueLimit:
|
|
15
|
-
connectTimeout: 10000
|
|
34
|
+
connectionLimit: parseInt(process.env.MYSQL_CONNECTION_LIMIT || '10', 10),
|
|
35
|
+
queueLimit: QUEUE_LIMIT,
|
|
36
|
+
connectTimeout: parseInt(process.env.MYSQL_CONNECT_TIMEOUT || '10000', 10)
|
|
37
|
+
});
|
|
38
|
+
pool.pool.on('enqueue', () => {
|
|
39
|
+
log("warning", `All connections busy, request queued (limit: ${QUEUE_LIMIT}).`, "pool");
|
|
16
40
|
});
|
|
41
|
+
// Session-Level Defense in Depth
|
|
42
|
+
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
43
|
+
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
44
|
+
const allowDelete = process.env.ALLOW_DELETE_OPERATION === 'true';
|
|
45
|
+
const allowDdl = process.env.ALLOW_DDL_OPERATION === 'true';
|
|
46
|
+
const isStrictReadOnlyMode = !(allowInsert || allowUpdate || allowDelete || allowDdl);
|
|
47
|
+
if (isStrictReadOnlyMode) {
|
|
48
|
+
pool.on('connection', (connection) => {
|
|
49
|
+
// At runtime this is a callback connection, despite what TS types say
|
|
50
|
+
connection.query('SET SESSION TRANSACTION READ ONLY', (err) => {
|
|
51
|
+
if (err) {
|
|
52
|
+
log("warning", `Failed to set connection to READ ONLY: ${err.message}`, "pool");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
17
57
|
export function getDbName() {
|
|
18
58
|
return process.env.DB_NAME || 'test';
|
|
19
59
|
}
|
|
20
60
|
/**
|
|
21
|
-
* Executes a safe query with a Timeout.
|
|
61
|
+
* Executes a safe query with a Timeout and Granular Permissions.
|
|
22
62
|
*/
|
|
23
63
|
export async function executeSafeQuery(sql) {
|
|
24
64
|
// AST Validation and Limit Injection
|
|
25
|
-
const astOptimizedSql = injectLimitAst(sql);
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
65
|
+
const { sql: astOptimizedSql, astType } = injectLimitAst(sql);
|
|
66
|
+
// Permission Enforcement
|
|
67
|
+
if (astType !== 'select' && astType !== 'show') {
|
|
68
|
+
const blockedTypes = ['call', 'grant', 'revoke', 'set', 'use'];
|
|
69
|
+
if (blockedTypes.includes(astType)) {
|
|
70
|
+
throw new Error(`Security Error: Dangerous operation '${astType}' is strictly prohibited.`);
|
|
71
|
+
}
|
|
72
|
+
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
73
|
+
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
74
|
+
const allowDelete = process.env.ALLOW_DELETE_OPERATION === 'true';
|
|
75
|
+
const allowDdl = process.env.ALLOW_DDL_OPERATION === 'true';
|
|
76
|
+
let isAllowed = false;
|
|
77
|
+
if ((astType === 'insert' || astType === 'replace') && allowInsert)
|
|
78
|
+
isAllowed = true;
|
|
79
|
+
else if (astType === 'update' && allowUpdate)
|
|
80
|
+
isAllowed = true;
|
|
81
|
+
else if ((astType === 'delete' || astType === 'truncate') && allowDelete)
|
|
82
|
+
isAllowed = true;
|
|
83
|
+
else if (['create', 'alter', 'drop', 'rename'].includes(astType) && allowDdl)
|
|
84
|
+
isAllowed = true;
|
|
85
|
+
if (!isAllowed) {
|
|
86
|
+
throw new Error(`Security Error: Operation '${astType}' is disabled in the server configuration.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Pre-flight Analysis (Only blocks full table scans on SELECT)
|
|
90
|
+
if (astType === 'select') {
|
|
91
|
+
await analyzeQueryPlan(astOptimizedSql, pool);
|
|
92
|
+
}
|
|
93
|
+
const rows = await queryWithRetry({
|
|
29
94
|
sql: astOptimizedSql,
|
|
30
|
-
timeout: 15000
|
|
95
|
+
timeout: parseInt(process.env.MYSQL_QUERY_TIMEOUT || '15000', 10)
|
|
31
96
|
});
|
|
32
97
|
return rows;
|
|
33
98
|
}
|
|
99
|
+
async function queryWithRetry(opts) {
|
|
100
|
+
let lastError;
|
|
101
|
+
for (let attempt = 0; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
const [rows] = await pool.query(opts);
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
lastError = error;
|
|
108
|
+
if (!isRetryableError(error)) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (attempt < MAX_RETRY_ATTEMPTS) {
|
|
112
|
+
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
113
|
+
log("warning", `Connection error (${error.code}), retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS})...`, "pool");
|
|
114
|
+
await sleep(delay);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw lastError;
|
|
119
|
+
}
|
|
34
120
|
export async function pingDb() {
|
|
35
121
|
try {
|
|
36
|
-
await
|
|
122
|
+
await queryWithRetry({ sql: 'SELECT 1' });
|
|
37
123
|
return true;
|
|
38
124
|
}
|
|
39
125
|
catch (e) {
|
|
40
126
|
return false;
|
|
41
127
|
}
|
|
42
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Gracefully shuts down the MySQL connection pool.
|
|
131
|
+
*/
|
|
132
|
+
export async function closePool() {
|
|
133
|
+
try {
|
|
134
|
+
await pool.end();
|
|
135
|
+
log("info", "Database connection pool closed gracefully.", "pool");
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
log("error", `Error closing database connection pool: ${error.message}`, "pool");
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/db/metadata.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import Fuse from 'fuse.js';
|
|
3
3
|
import dotenv from 'dotenv';
|
|
4
|
+
import { log } from '../utils/logger.js';
|
|
4
5
|
dotenv.config({ quiet: true });
|
|
5
6
|
let metadataCache = {};
|
|
6
7
|
let templatesCache = [];
|
|
@@ -11,10 +12,10 @@ export function initMetadata() {
|
|
|
11
12
|
try {
|
|
12
13
|
const raw = fs.readFileSync(metadataPath, 'utf8');
|
|
13
14
|
metadataCache = JSON.parse(raw);
|
|
14
|
-
|
|
15
|
+
log("info", `Loaded metadata dictionary from ${metadataPath}`, "metadata");
|
|
15
16
|
}
|
|
16
17
|
catch (err) {
|
|
17
|
-
|
|
18
|
+
log("error", `Error loading metadata.json: ${err}`, "metadata");
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
const templatesPath = process.env.MCP_TEMPLATES_PATH;
|
|
@@ -27,10 +28,10 @@ export function initMetadata() {
|
|
|
27
28
|
threshold: 0.5,
|
|
28
29
|
ignoreLocation: true
|
|
29
30
|
});
|
|
30
|
-
|
|
31
|
+
log("info", `Loaded ${templatesCache.length} SQL templates from ${templatesPath}`, "metadata");
|
|
31
32
|
}
|
|
32
33
|
catch (err) {
|
|
33
|
-
|
|
34
|
+
log("error", `Error loading templates.json: ${err}`, "metadata");
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
}
|
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,32 @@
|
|
|
1
1
|
import { pool, getDbName } from './index.js';
|
|
2
|
+
import { log } from '../utils/logger.js';
|
|
2
3
|
export let schemaGraph = new Map();
|
|
4
|
+
const DDL_CACHE_TTL_MS = parseInt(process.env.MCP_DDL_CACHE_TTL || '60', 10) * 1000;
|
|
5
|
+
const ddlCache = new Map();
|
|
6
|
+
export function invalidateDdlCache() {
|
|
7
|
+
ddlCache.clear();
|
|
8
|
+
}
|
|
9
|
+
const SAFE_TABLE_NAME = /^[a-zA-Z0-9_]+$/;
|
|
10
|
+
export async function getTableDDL(tableName) {
|
|
11
|
+
if (!SAFE_TABLE_NAME.test(tableName)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const cached = ddlCache.get(tableName);
|
|
15
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
16
|
+
return cached.ddl;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const [rows] = await pool.query(`SHOW CREATE TABLE \`${tableName}\``);
|
|
20
|
+
const ddl = (rows && rows.length > 0)
|
|
21
|
+
? (rows[0]['Create Table'] || rows[0]['Create View'])
|
|
22
|
+
: null;
|
|
23
|
+
ddlCache.set(tableName, { ddl, expiresAt: Date.now() + DDL_CACHE_TTL_MS });
|
|
24
|
+
return ddl;
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* Connects to the database and builds the relational graph in-memory.
|
|
5
32
|
* Optimized for low RAM usage by only extracting node names and edges (no DDL/Columns cached).
|
|
@@ -79,6 +106,7 @@ export async function buildSchemaGraph() {
|
|
|
79
106
|
}
|
|
80
107
|
}
|
|
81
108
|
}
|
|
109
|
+
invalidateDdlCache();
|
|
82
110
|
schemaGraph = newGraph;
|
|
83
|
-
|
|
111
|
+
log("info", `Schema Graph built successfully. Indexed ${schemaGraph.size} tables.`, "schema");
|
|
84
112
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,26 +4,53 @@ 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 { registerPingTool } from "./tools/ping.js";
|
|
8
|
+
import { registerExplainQueryTool } from "./tools/explainQuery.js";
|
|
9
|
+
import { registerTemplatesPrompt } from "./prompts/templates.js";
|
|
10
|
+
import { registerTableResources } from "./resources/tables.js";
|
|
8
11
|
import { buildSchemaGraph } from "./db/schema.js";
|
|
9
12
|
import { initMetadata } from "./db/metadata.js";
|
|
13
|
+
import { closePool } from "./db/index.js";
|
|
14
|
+
import { initLogger } from "./utils/logger.js";
|
|
10
15
|
import dotenv from "dotenv";
|
|
11
16
|
dotenv.config({ quiet: true });
|
|
12
17
|
async function main() {
|
|
13
18
|
const server = new McpServer({
|
|
14
19
|
name: "tokenlite-mysql-mcp",
|
|
15
20
|
version: "1.0.0",
|
|
21
|
+
}, {
|
|
22
|
+
capabilities: {
|
|
23
|
+
logging: {},
|
|
24
|
+
},
|
|
16
25
|
});
|
|
26
|
+
initLogger(server);
|
|
17
27
|
// Build Semantic Graph on startup
|
|
18
28
|
await buildSchemaGraph();
|
|
19
29
|
// Load Metadata and Templates
|
|
20
30
|
initMetadata();
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// Generate or use provided tool prefix (dev_, prod_ ...)
|
|
32
|
+
let prefix = process.env.TOOL_PREFIX;
|
|
33
|
+
if (!prefix) {
|
|
34
|
+
const randomStr = Math.random().toString(36).substring(2, 6);
|
|
35
|
+
prefix = `db_${randomStr}_`;
|
|
36
|
+
}
|
|
37
|
+
// Register MCP tools & prompts & resources
|
|
38
|
+
registerSearchSchemaTool(server, prefix);
|
|
39
|
+
registerExecuteQueryTool(server, prefix);
|
|
40
|
+
registerRefreshSchemaTool(server, prefix);
|
|
41
|
+
registerPingTool(server, prefix);
|
|
42
|
+
registerExplainQueryTool(server, prefix);
|
|
43
|
+
registerTemplatesPrompt(server, prefix);
|
|
44
|
+
registerTableResources(server);
|
|
26
45
|
const transport = new StdioServerTransport();
|
|
27
46
|
await server.connect(transport);
|
|
47
|
+
const cleanup = async () => {
|
|
48
|
+
console.error('\n[tokenlite-mysql-mcp] Shutting down server...');
|
|
49
|
+
await server.close();
|
|
50
|
+
await closePool();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
};
|
|
53
|
+
process.on('SIGINT', cleanup);
|
|
54
|
+
process.on('SIGTERM', cleanup);
|
|
28
55
|
}
|
|
29
56
|
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,51 @@ 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
|
-
|
|
31
|
-
|
|
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.", {
|
|
47
|
+
sql: z.string().max(10000).describe("SQL SELECT statement to execute."),
|
|
48
|
+
}, {
|
|
49
|
+
readOnlyHint: isReadOnly,
|
|
50
|
+
destructiveHint: allowDelete || allowDdl,
|
|
32
51
|
}, handleExecuteQuery);
|
|
33
52
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { pool } from "../db/index.js";
|
|
3
|
+
import { jsonToCsv } from "../utils/csvFormatter.js";
|
|
4
|
+
export async function handleExplainQuery({ sql }) {
|
|
5
|
+
try {
|
|
6
|
+
const [rows] = await pool.query(`EXPLAIN ${sql}`);
|
|
7
|
+
const csv = jsonToCsv(rows);
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: "text", text: csv }],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text", text: `EXPLAIN Error: ${error.message}` }],
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function registerExplainQueryTool(server, prefix = "") {
|
|
20
|
+
server.tool(`${prefix}explain_query`, "Returns the MySQL EXPLAIN output for a SELECT query. Use this to understand index usage, join types, and row estimates before rewriting a blocked or slow query.", {
|
|
21
|
+
sql: z.string().max(10000).describe("The SELECT query to analyze."),
|
|
22
|
+
}, {
|
|
23
|
+
readOnlyHint: true,
|
|
24
|
+
idempotentHint: true,
|
|
25
|
+
}, handleExplainQuery);
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { pool } from "../db/index.js";
|
|
2
|
+
export async function handlePing() {
|
|
3
|
+
const rawPool = pool.pool;
|
|
4
|
+
const poolInfo = {
|
|
5
|
+
active: rawPool._allConnections?.length ?? 0,
|
|
6
|
+
idle: rawPool._freeConnections?.length ?? 0,
|
|
7
|
+
queue: rawPool._connectionQueue?.length ?? 0,
|
|
8
|
+
};
|
|
9
|
+
try {
|
|
10
|
+
const [rows] = await pool.query("SELECT VERSION() AS version");
|
|
11
|
+
const response = {
|
|
12
|
+
status: "ok",
|
|
13
|
+
server_version: rows[0]?.version,
|
|
14
|
+
pool: poolInfo,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const response = {
|
|
22
|
+
status: "error",
|
|
23
|
+
pool: poolInfo,
|
|
24
|
+
error: error.message,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
28
|
+
isError: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function registerPingTool(server, prefix = "") {
|
|
33
|
+
server.tool(`${prefix}ping`, "Health check: verifies the database connection is alive and returns pool stats and server version.", {}, {
|
|
34
|
+
readOnlyHint: true,
|
|
35
|
+
openWorldHint: false,
|
|
36
|
+
}, handlePing);
|
|
37
|
+
}
|
|
@@ -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.", {}, { readOnlyHint: true, 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(
|
|
91
|
-
query: z.string().describe("The name of the table or entity to search for (e.g. 'users', 'invoices')."),
|
|
92
|
-
}, handleSearchSchema);
|
|
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.", {
|
|
78
|
+
query: z.string().max(200).describe("The name of the table or entity to search for (e.g. 'users', 'invoices')."),
|
|
79
|
+
}, { readOnlyHint: true, openWorldHint: false }, handleSearchSchema);
|
|
93
80
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SetLevelRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
const SEVERITY_ORDER = [
|
|
3
|
+
"debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"
|
|
4
|
+
];
|
|
5
|
+
let mcpServer = null;
|
|
6
|
+
let minLevel = process.env.MCP_LOG_LEVEL || "info";
|
|
7
|
+
function severityIndex(level) {
|
|
8
|
+
return SEVERITY_ORDER.indexOf(level);
|
|
9
|
+
}
|
|
10
|
+
export function initLogger(server) {
|
|
11
|
+
mcpServer = server;
|
|
12
|
+
server.server.setRequestHandler(SetLevelRequestSchema, async (request) => {
|
|
13
|
+
const requested = request.params?.level;
|
|
14
|
+
if (requested && SEVERITY_ORDER.includes(requested)) {
|
|
15
|
+
minLevel = requested;
|
|
16
|
+
}
|
|
17
|
+
return {};
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function getMinLevel() {
|
|
21
|
+
return minLevel;
|
|
22
|
+
}
|
|
23
|
+
export async function log(level, data, logger) {
|
|
24
|
+
if (severityIndex(level) < severityIndex(minLevel)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (mcpServer) {
|
|
28
|
+
try {
|
|
29
|
+
await mcpServer.sendLoggingMessage({ level, data, logger });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// MCP session not ready yet, fall through to stderr
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const prefix = logger ? `[${logger}]` : "[tokenlite-mysql-mcp]";
|
|
37
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
38
|
+
console.error(`${prefix} [${level}] ${msg}`);
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andezdev/tokenlite-mysql-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"mcpName": "io.github.andezdev/tokenlite-mysql-mcp",
|
|
4
5
|
"description": "A secure, efficient, and intelligent MySQL server for the Model Context Protocol",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"type": "module",
|
|
@@ -39,8 +40,10 @@
|
|
|
39
40
|
"homepage": "https://github.com/andezdev/tokenlite-mysql-mcp#readme",
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "tsc",
|
|
43
|
+
"prepublishOnly": "npm run build",
|
|
42
44
|
"start": "node dist/index.js",
|
|
43
45
|
"test": "vitest run",
|
|
46
|
+
"test:coverage": "vitest run --coverage",
|
|
44
47
|
"test:watch": "vitest",
|
|
45
48
|
"inspect-graph": "tsx scripts/inspect-graph.ts",
|
|
46
49
|
"benchmark": "tsx scripts/benchmark.ts",
|
|
@@ -59,6 +62,7 @@
|
|
|
59
62
|
"@commitlint/config-conventional": "^21.0.2",
|
|
60
63
|
"@types/node": "^22.10.1",
|
|
61
64
|
"@types/node-sql-parser": "^1.0.0",
|
|
65
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
62
66
|
"husky": "^9.1.7",
|
|
63
67
|
"js-tiktoken": "^1.0.21",
|
|
64
68
|
"ts-node": "^10.9.2",
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { searchTemplates } from "../db/metadata.js";
|
|
3
|
-
export function handleGetTemplates({ query }) {
|
|
4
|
-
const results = searchTemplates(query || "");
|
|
5
|
-
if (results.length === 0) {
|
|
6
|
-
return {
|
|
7
|
-
content: [{ type: "text", text: "No SQL templates found matching your query." }]
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
let output = "--- PRE-APPROVED SQL TEMPLATES ---\n\n";
|
|
11
|
-
for (const t of results) {
|
|
12
|
-
output += `### ${t.name}\n`;
|
|
13
|
-
output += `Description: ${t.description}\n`;
|
|
14
|
-
output += `SQL:\n\`\`\`sql\n${t.sql}\n\`\`\`\n\n`;
|
|
15
|
-
}
|
|
16
|
-
return {
|
|
17
|
-
content: [{ type: "text", text: output }]
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
export function registerGetTemplatesTool(server) {
|
|
21
|
-
server.tool("get_query_templates", "NEVER write SQL for business metrics (like LTV, Revenue, Performance) manually. YOU MUST ALWAYS use this tool first to retrieve the official company SQL template. Pass a keyword to search, or leave empty to list all templates.", {
|
|
22
|
-
query: z.string().optional().describe("Keyword to search for in templates (e.g., 'revenue', 'ltv')."),
|
|
23
|
-
}, handleGetTemplates);
|
|
24
|
-
}
|