@andezdev/tokenlite-mysql-mcp 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -1
- package/README.md +46 -12
- package/dist/db/index.js +51 -6
- package/dist/db/metadata.js +5 -4
- package/dist/db/schema.js +21 -8
- package/dist/index.js +16 -1
- package/dist/tools/executeQuery.js +5 -2
- package/dist/tools/explainQuery.js +26 -0
- package/dist/tools/ping.js +37 -0
- package/dist/tools/refreshSchema.js +1 -1
- package/dist/tools/searchSchema.js +2 -2
- 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**.
|
|
@@ -104,6 +104,11 @@ To use within Cursor IDE:
|
|
|
104
104
|
| `MYSQL_QUERY_TIMEOUT` | Max execution time for a query (in ms). Aborts heavy queries to protect against DoS. | `15000` | No |
|
|
105
105
|
| `MYSQL_CONNECTION_LIMIT` | Max concurrent pool connections. | `10` | No |
|
|
106
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 |
|
|
107
112
|
| `ALLOW_INSERT_OPERATION` | Enable `INSERT` and `REPLACE` queries. | `false` | No |
|
|
108
113
|
| `ALLOW_UPDATE_OPERATION` | Enable `UPDATE` queries. | `false` | No |
|
|
109
114
|
| `ALLOW_DELETE_OPERATION` | Enable `DELETE` and `TRUNCATE` queries. | `false` | No |
|
|
@@ -142,29 +147,58 @@ Stop the LLM from hallucinating complex metrics by providing vetted templates.
|
|
|
142
147
|
|
|
143
148
|
## 📈 Benchmarks & Token Savings
|
|
144
149
|
|
|
145
|
-
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.
|
|
146
151
|
|
|
147
152
|
To run the benchmark in your own environment:
|
|
148
153
|
```bash
|
|
149
154
|
npm run benchmark
|
|
150
155
|
```
|
|
151
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
|
+
|
|
152
160
|
### 1. Schema Discovery (Input Tokens)
|
|
153
|
-
|
|
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%** |
|
|
154
167
|
|
|
155
|
-
|
|
156
|
-
* **TokenLite Relational Graph:** 252 tokens
|
|
157
|
-
* **📉 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.
|
|
158
169
|
|
|
159
170
|
### 2. Query Result Payloads (Output Tokens)
|
|
160
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.
|
|
161
172
|
|
|
162
|
-
|
|
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 (%) |
|
|
163
185
|
| :--- | :--- | :--- | :--- |
|
|
164
|
-
| **10 rows** | 1,
|
|
165
|
-
| **50 rows** | 5,
|
|
166
|
-
| **100 rows** |
|
|
167
|
-
| **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%** |
|
|
190
|
+
|
|
191
|
+
---
|
|
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.
|
|
168
202
|
|
|
169
203
|
---
|
|
170
204
|
|
|
@@ -192,7 +226,7 @@ Instead, wrap the process using standard open-source MCP proxies like [mcp-proxy
|
|
|
192
226
|
|
|
193
227
|
**Error: `OptimizerError: Full table scan detected...`**
|
|
194
228
|
The LLM attempted to execute a query that requires scanning thousands of rows without using an index.
|
|
195
|
-
*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.
|
|
196
230
|
|
|
197
231
|
**Error: `calling "initialize": invalid character...`**
|
|
198
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.
|
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),
|
|
@@ -11,9 +32,12 @@ export const pool = mysql.createPool({
|
|
|
11
32
|
database: process.env.DB_NAME || 'test',
|
|
12
33
|
waitForConnections: true,
|
|
13
34
|
connectionLimit: parseInt(process.env.MYSQL_CONNECTION_LIMIT || '10', 10),
|
|
14
|
-
queueLimit:
|
|
35
|
+
queueLimit: QUEUE_LIMIT,
|
|
15
36
|
connectTimeout: parseInt(process.env.MYSQL_CONNECT_TIMEOUT || '10000', 10)
|
|
16
37
|
});
|
|
38
|
+
pool.pool.on('enqueue', () => {
|
|
39
|
+
log("warning", `All connections busy, request queued (limit: ${QUEUE_LIMIT}).`, "pool");
|
|
40
|
+
});
|
|
17
41
|
// Session-Level Defense in Depth
|
|
18
42
|
const allowInsert = process.env.ALLOW_INSERT_OPERATION === 'true';
|
|
19
43
|
const allowUpdate = process.env.ALLOW_UPDATE_OPERATION === 'true';
|
|
@@ -25,7 +49,7 @@ if (isStrictReadOnlyMode) {
|
|
|
25
49
|
// At runtime this is a callback connection, despite what TS types say
|
|
26
50
|
connection.query('SET SESSION TRANSACTION READ ONLY', (err) => {
|
|
27
51
|
if (err) {
|
|
28
|
-
|
|
52
|
+
log("warning", `Failed to set connection to READ ONLY: ${err.message}`, "pool");
|
|
29
53
|
}
|
|
30
54
|
});
|
|
31
55
|
});
|
|
@@ -66,15 +90,36 @@ export async function executeSafeQuery(sql) {
|
|
|
66
90
|
if (astType === 'select') {
|
|
67
91
|
await analyzeQueryPlan(astOptimizedSql, pool);
|
|
68
92
|
}
|
|
69
|
-
const
|
|
93
|
+
const rows = await queryWithRetry({
|
|
70
94
|
sql: astOptimizedSql,
|
|
71
95
|
timeout: parseInt(process.env.MYSQL_QUERY_TIMEOUT || '15000', 10)
|
|
72
96
|
});
|
|
73
97
|
return rows;
|
|
74
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
|
+
}
|
|
75
120
|
export async function pingDb() {
|
|
76
121
|
try {
|
|
77
|
-
await
|
|
122
|
+
await queryWithRetry({ sql: 'SELECT 1' });
|
|
78
123
|
return true;
|
|
79
124
|
}
|
|
80
125
|
catch (e) {
|
|
@@ -87,9 +132,9 @@ export async function pingDb() {
|
|
|
87
132
|
export async function closePool() {
|
|
88
133
|
try {
|
|
89
134
|
await pool.end();
|
|
90
|
-
|
|
135
|
+
log("info", "Database connection pool closed gracefully.", "pool");
|
|
91
136
|
}
|
|
92
137
|
catch (error) {
|
|
93
|
-
|
|
138
|
+
log("error", `Error closing database connection pool: ${error.message}`, "pool");
|
|
94
139
|
}
|
|
95
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/schema.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { pool, getDbName } from './index.js';
|
|
2
|
+
import { log } from '../utils/logger.js';
|
|
2
3
|
export let schemaGraph = new Map();
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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_]+$/;
|
|
6
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
|
+
}
|
|
7
18
|
try {
|
|
8
19
|
const [rows] = await pool.query(`SHOW CREATE TABLE \`${tableName}\``);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
|
13
25
|
}
|
|
14
26
|
catch (e) {
|
|
15
27
|
return null;
|
|
@@ -94,6 +106,7 @@ export async function buildSchemaGraph() {
|
|
|
94
106
|
}
|
|
95
107
|
}
|
|
96
108
|
}
|
|
109
|
+
invalidateDdlCache();
|
|
97
110
|
schemaGraph = newGraph;
|
|
98
|
-
|
|
111
|
+
log("info", `Schema Graph built successfully. Indexed ${schemaGraph.size} tables.`, "schema");
|
|
99
112
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,20 +4,33 @@ 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 { registerPingTool } from "./tools/ping.js";
|
|
8
|
+
import { registerExplainQueryTool } from "./tools/explainQuery.js";
|
|
7
9
|
import { registerTemplatesPrompt } from "./prompts/templates.js";
|
|
8
10
|
import { registerTableResources } from "./resources/tables.js";
|
|
9
11
|
import { buildSchemaGraph } from "./db/schema.js";
|
|
10
12
|
import { initMetadata } from "./db/metadata.js";
|
|
11
13
|
import { closePool } from "./db/index.js";
|
|
14
|
+
import { initLogger, log } from "./utils/logger.js";
|
|
12
15
|
import dotenv from "dotenv";
|
|
13
16
|
dotenv.config({ quiet: true });
|
|
14
17
|
async function main() {
|
|
15
18
|
const server = new McpServer({
|
|
16
19
|
name: "tokenlite-mysql-mcp",
|
|
17
20
|
version: "1.0.0",
|
|
21
|
+
}, {
|
|
22
|
+
capabilities: {
|
|
23
|
+
logging: {},
|
|
24
|
+
},
|
|
18
25
|
});
|
|
26
|
+
initLogger(server);
|
|
19
27
|
// Build Semantic Graph on startup
|
|
20
|
-
|
|
28
|
+
try {
|
|
29
|
+
await buildSchemaGraph();
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
log("warning", `Failed to build schema graph on startup: ${e instanceof Error ? e.message : String(e)}. The server will start in degraded mode.`);
|
|
33
|
+
}
|
|
21
34
|
// Load Metadata and Templates
|
|
22
35
|
initMetadata();
|
|
23
36
|
// Generate or use provided tool prefix (dev_, prod_ ...)
|
|
@@ -30,6 +43,8 @@ async function main() {
|
|
|
30
43
|
registerSearchSchemaTool(server, prefix);
|
|
31
44
|
registerExecuteQueryTool(server, prefix);
|
|
32
45
|
registerRefreshSchemaTool(server, prefix);
|
|
46
|
+
registerPingTool(server, prefix);
|
|
47
|
+
registerExplainQueryTool(server, prefix);
|
|
33
48
|
registerTemplatesPrompt(server, prefix);
|
|
34
49
|
registerTableResources(server);
|
|
35
50
|
const transport = new StdioServerTransport();
|
|
@@ -44,6 +44,9 @@ export function registerExecuteQueryTool(server, prefix = "") {
|
|
|
44
44
|
// If any write operation is enabled, this tool is no longer read-only
|
|
45
45
|
const isReadOnly = !(allowInsert || allowUpdate || allowDelete || allowDdl);
|
|
46
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().describe("SQL SELECT statement to execute."),
|
|
48
|
-
}, {
|
|
47
|
+
sql: z.string().max(10000).describe("SQL SELECT statement to execute."),
|
|
48
|
+
}, {
|
|
49
|
+
readOnlyHint: isReadOnly,
|
|
50
|
+
destructiveHint: allowDelete || allowDdl,
|
|
51
|
+
}, handleExecuteQuery);
|
|
49
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
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 () => {
|
|
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 {
|
|
@@ -75,6 +75,6 @@ export async function handleSearchSchema({ query }) {
|
|
|
75
75
|
}
|
|
76
76
|
export function registerSearchSchemaTool(server, prefix = "") {
|
|
77
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().describe("The name of the table or entity to search for (e.g. 'users', 'invoices')."),
|
|
79
|
-
}, { readOnlyHint: true }, handleSearchSchema);
|
|
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);
|
|
80
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.1.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
|
-
}
|