@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 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**: You MUST rewrite the query to include a `WHERE` clause that uses an indexed column (e.g., a primary key or foreign key).
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
- [![npm version](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp.svg)](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp)
3
+ [![npm version](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp.svg?icon=si%3Anpm)](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. **Business Intelligence Injection**: Bridges the gap between raw data and company logic. Automatically attaches semantic dictionaries (`metadata.json`) to database schema exploration, and exposes a Semantic Template Search tool (`templates.json`) so the LLM uses pre-approved analytical queries instead of hallucinating them.
14
- 3. **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".
15
- 4. **CSV Token Compression**: Database results are efficiently transformed into tabular CSV markdown, saving up to 60% of Output Tokens compared to verbose JSON.
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, precise benchmark suite using official `cl100k_base` tokenization (matching models like Claude 3.5 Sonnet and GPT-4) to measure efficiency improvements.
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
- Traditional 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).
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
- * **Generic MCP Schema Dump:** 611 tokens
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
- | Rows Returned | Generic MCP JSON (Tokens) | TokenLite CSV (Tokens) | 📉 Output Savings (%) |
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,153 | 590 | **48.8%** |
155
- | **50 rows** | 5,764 | 2,861 | **50.3%** |
156
- | **100 rows** | 11,527 | 5,699 | **50.5%** |
157
- | **500 rows** | 57,635 | 28,407 | **50.7%** |
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*: The LLM will automatically see this error and try to 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.
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: 0,
15
- connectTimeout: 10000 // 10 seconds
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
- // Pre-flight Analysis
27
- await analyzeQueryPlan(astOptimizedSql, pool);
28
- const [rows] = await pool.query({
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 pool.query('SELECT 1');
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
+ }
@@ -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
- console.error(`[tokenlite-mysql-mcp] Loaded metadata dictionary from ${metadataPath}`);
15
+ log("info", `Loaded metadata dictionary from ${metadataPath}`, "metadata");
15
16
  }
16
17
  catch (err) {
17
- console.error(`[tokenlite-mysql-mcp] Error loading metadata.json:`, err);
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
- console.error(`[tokenlite-mysql-mcp] Loaded ${templatesCache.length} SQL templates from ${templatesPath}`);
31
+ log("info", `Loaded ${templatesCache.length} SQL templates from ${templatesPath}`, "metadata");
31
32
  }
32
33
  catch (err) {
33
- console.error(`[tokenlite-mysql-mcp] Error loading templates.json:`, err);
34
+ log("error", `Error loading templates.json: ${err}`, "metadata");
34
35
  }
35
36
  }
36
37
  }
@@ -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
- if (ast.type !== 'select') {
36
- throw new OptimizerError("Security Error: Only SELECT or SHOW statements are allowed.");
37
- }
38
- if (!ast.limit) {
39
- ast.limit = {
40
- seperator: "",
41
- value: [
42
- { type: 'number', value: maxLimit }
43
- ]
44
- };
45
- }
46
- else {
47
- // Check if existing limit exceeds maxLimit
48
- // @ts-ignore
49
- const limitValue = ast.limit.value[0]?.value;
50
- if (typeof limitValue === 'number' && limitValue > maxLimit) {
51
- // @ts-ignore
52
- ast.limit.value[0].value = maxLimit;
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 parser.sqlify(ast, astOpt);
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
- console.error(`[tokenlite-mysql-mcp] Schema Graph built successfully. Indexed ${schemaGraph.size} tables.`);
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 { registerGetTemplatesTool } from "./tools/getTemplates.js";
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
- // Register MCP Tools
22
- registerSearchSchemaTool(server);
23
- registerExecuteQueryTool(server);
24
- registerRefreshSchemaTool(server);
25
- registerGetTemplatesTool(server);
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 rows = await executeSafeQuery(sql);
13
- const csvData = jsonToCsv(rows);
14
- return {
15
- content: [{ type: "text", text: csvData }]
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
- server.tool("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
- sql: z.string().describe("SQL SELECT statement to execute."),
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("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.", {}, async () => {
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 { pool } from "../db/index.js";
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 `get_query_templates` tool first to fetch the official template. */";
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("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
- 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": "1.0.0",
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
- }