@andezdev/tokenlite-mysql-mcp 1.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 ADDED
@@ -0,0 +1,51 @@
1
+ # 🤖 AI Agents Guide: TokenLite MySQL MCP
2
+
3
+ > **Notice to LLMs, Agents, and Coding Assistants:**
4
+ > If you are reading this file, you have been connected to the TokenLite MySQL MCP Server. This server is heavily protected and optimized to prevent hallucinations, reduce context window bloat, and block dangerous operations.
5
+ >
6
+ > **You MUST follow the rules below strictly.**
7
+
8
+ ## 🚨 Golden Rules
9
+
10
+ 1. **NEVER use `SHOW TABLES` or `DESCRIBE` manually.**
11
+ - **Rule**: You MUST use the `search_schema` tool instead.
12
+ - **Why**: `search_schema` provides a compressed, heuristic-based Graph (Auto-Join Context) that gives you the DDL of the requested table *and* its implicitly related tables. It also injects business semantics from `metadata.json`.
13
+
14
+ 2. **NEVER manually query `information_schema`.**
15
+ - **Rule**: If a query fails because of a missing column or table (e.g., `ER_BAD_FIELD_ERROR`), you MUST use the `refresh_schema` tool to rebuild the internal graph, and then use `search_schema` again. Do not attempt to query `information_schema` directly.
16
+
17
+ 3. **NEVER write business metrics SQL manually.**
18
+ - **Rule**: Before writing analytical SQL (e.g., LTV, Revenue, Active Users, Performance), you MUST query the `get_query_templates` tool.
19
+ - **Why**: The company has predefined, vetted SQL templates. Hallucinating metrics leads to incorrect dashboards.
20
+
21
+ 4. **DO NOT add `LIMIT` to your exploratory queries.**
22
+ - **Rule**: When using `execute_safe_query`, the server will automatically inject a `LIMIT` (default 500) at the AST level. Do not manually append `LIMIT` unless you need a very specific offset pagination.
23
+
24
+ 5. **Fixing Optimizer Blocks (Full Table Scans).**
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).
27
+
28
+ ---
29
+
30
+ ## 🛠 Available MCP Tools
31
+
32
+ ### `search_schema`
33
+ **Use for:** Understanding the database structure.
34
+ **Arguments:** `query` (string) - The name of the table you want to inspect.
35
+ **Returns:** The SQL DDL of the matched table, the DDL of its Parent/Child tables, and Business Semantics.
36
+
37
+ ### `execute_safe_query`
38
+ **Use for:** Running `SELECT` statements against the database.
39
+ **Arguments:** `sql` (string) - The SQL query to execute.
40
+ **Returns:** A compressed Markdown CSV table containing the results.
41
+ **Note:** This tool runs your SQL through an AST parser to inject limits, and an `EXPLAIN` planner to block unindexed heavy scans.
42
+
43
+ ### `get_query_templates`
44
+ **Use for:** Retrieving pre-approved SQL for complex calculations.
45
+ **Arguments:** `query` (string) - A keyword like 'revenue', 'ltv', or leave empty.
46
+ **Returns:** Vetted SQL templates that you can execute via `execute_safe_query`.
47
+
48
+ ### `refresh_schema`
49
+ **Use for:** Forcing the server to rescan the database and update its internal graph.
50
+ **Arguments:** None.
51
+ **Use when:** You receive an error that a table or column doesn't exist, implying the schema changed.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antonio Hernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # TokenLite MySQL MCP
2
+
3
+ [![npm version](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp.svg)](https://badge.fury.io/js/@andezdev%2Ftokenlite-mysql-mcp)
4
+
5
+ A robust and secure MySQL database server implemented under Anthropic's **Model Context Protocol (MCP)**.
6
+ Designed specifically to solve the shortcomings of current generic MCP servers through **Graceful Degradation, Active Performance Protection, and Aggressive Token Optimization**.
7
+
8
+ ---
9
+
10
+ ## 🌟 Core Pillars
11
+
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.
16
+
17
+ ---
18
+
19
+ ## 📋 Requirements
20
+
21
+ - Node.js v20 or higher
22
+ - MySQL 5.7 or higher (MySQL 8.0+ recommended)
23
+ - A MySQL user with `SELECT` and `SHOW VIEW` privileges.
24
+
25
+ ---
26
+
27
+ ## 🚀 Installation & Usage
28
+
29
+ You can use this MCP server with any compatible client. Below are the configurations for the most popular ones.
30
+
31
+ ### 1. Claude Desktop
32
+
33
+ Edit your `claude_desktop_config.json` (usually located at `%APPDATA%\Claude\claude_desktop_config.json` on Windows or `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS) and add the following:
34
+
35
+ **Using NPX (Recommended)**
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "tokenlite-mysql": {
40
+ "command": "npx",
41
+ "args": [
42
+ "-y",
43
+ "@andezdev/tokenlite-mysql-mcp"
44
+ ],
45
+ "env": {
46
+ "DB_HOST": "localhost",
47
+ "DB_PORT": "3306",
48
+ "DB_USER": "your_db_user",
49
+ "DB_PASSWORD": "your_password",
50
+ "DB_NAME": "your_database",
51
+ "MCP_SAFE_QUERY_MAX_ROWS": "1000",
52
+ "MCP_SAFE_QUERY_ENABLE_BLOCKING": "true"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ ### 2. Claude Code (CLI)
60
+
61
+ You can easily integrate this server globally into Claude Code:
62
+
63
+ ```bash
64
+ claude mcp add tokenlite_mysql \
65
+ -e DB_HOST="127.0.0.1" \
66
+ -e DB_PORT="3306" \
67
+ -e DB_USER="root" \
68
+ -e DB_PASSWORD="your_password" \
69
+ -e DB_NAME="your_database" \
70
+ -- npx -y @andezdev/tokenlite-mysql-mcp
71
+ ```
72
+
73
+ ### 3. Cursor IDE
74
+
75
+ To use within Cursor IDE:
76
+ 1. Open Cursor Settings > Features > MCP.
77
+ 2. Click **+ Add New MCP Server**.
78
+ 3. Set the Type to `command`.
79
+ 4. Name it `tokenlite-mysql`.
80
+ 5. Set the command to:
81
+ ```bash
82
+ npx -y @andezdev/tokenlite-mysql-mcp
83
+ ```
84
+ *(Note: Cursor handles environment variables directly in the IDE UI, make sure to add your DB credentials there).*
85
+
86
+ ---
87
+
88
+ ## ⚙️ Environment Variables Reference
89
+
90
+ | Variable | Description | Default | Required |
91
+ |----------|-------------|---------|----------|
92
+ | `DB_HOST` | MySQL Host address | `localhost` | No |
93
+ | `DB_PORT` | MySQL Port | `3306` | No |
94
+ | `DB_USER` | MySQL Username | `root` | No |
95
+ | `DB_PASSWORD` | MySQL Password | `''` | No |
96
+ | `DB_NAME` | MySQL Database name | `test` | Yes |
97
+ | `MCP_SAFE_QUERY_MAX_ROWS` | Threshold for EXPLAIN to block unindexed Full Table Scans. | `1000` | No |
98
+ | `MCP_SAFE_QUERY_ENABLE_BLOCKING`| Enable or disable the EXPLAIN guardrail. | `true` | No |
99
+ | `MCP_METADATA_PATH` | Absolute path to your custom `metadata.json` dictionary. | (Disabled) | No |
100
+ | `MCP_TEMPLATES_PATH` | Absolute path to your custom `templates.json` queries. | (Disabled) | No |
101
+
102
+ ---
103
+
104
+ ## 🛡️ Business Intelligence Features (Opt-in)
105
+
106
+ TokenLite can teach the LLM about your company's business rules. To enable this, map the absolute paths of two JSON files via `.env` or your MCP client config:
107
+
108
+ ### `metadata.json` (Semantic Dictionary)
109
+ Translate integer statuses or internal jargon so the LLM understands the data.
110
+ ```json
111
+ {
112
+ "orders.status": {
113
+ "pending": "The order is waiting for payment validation",
114
+ "shipped": "The order has left the warehouse"
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### `templates.json` (Pre-approved SQL)
120
+ Stop the LLM from hallucinating complex metrics by providing vetted templates.
121
+ ```json
122
+ [
123
+ {
124
+ "name": "Customer Lifetime Value (LTV)",
125
+ "description": "Calculates total revenue generated by delivered orders per customer.",
126
+ "sql": "SELECT c.id, SUM(oi.price) FROM customers c JOIN orders o... WHERE o.status='delivered'"
127
+ }
128
+ ]
129
+ ```
130
+
131
+ ---
132
+
133
+ ## 📈 Benchmarks & Token Savings
134
+
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.
136
+
137
+ To run the benchmark in your own environment:
138
+ ```bash
139
+ npm run benchmark
140
+ ```
141
+
142
+ ### 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).
144
+
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)
148
+
149
+ ### 2. Query Result Payloads (Output Tokens)
150
+ 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
+
152
+ | Rows Returned | Generic MCP JSON (Tokens) | TokenLite CSV (Tokens) | 📉 Output Savings (%) |
153
+ | :--- | :--- | :--- | :--- |
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%** |
158
+
159
+ ---
160
+
161
+ ## 🐛 Troubleshooting
162
+
163
+ **Error: `OptimizerError: Full table scan detected...`**
164
+ 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.
166
+
167
+ **Error: `calling "initialize": invalid character...`**
168
+ 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
+
170
+ ---
171
+ *Built for the AI Engineering era.*
@@ -0,0 +1,42 @@
1
+ import mysql from 'mysql2/promise';
2
+ import dotenv from 'dotenv';
3
+ import { injectLimitAst, analyzeQueryPlan } from './optimizer.js';
4
+ // Supress dotenv logs so they don't corrupt the MCP JSON-RPC stdout stream
5
+ dotenv.config({ quiet: true });
6
+ export const pool = mysql.createPool({
7
+ host: process.env.DB_HOST || 'localhost',
8
+ port: parseInt(process.env.DB_PORT || '3306', 10),
9
+ user: process.env.DB_USER || 'root',
10
+ password: process.env.DB_PASSWORD || '',
11
+ database: process.env.DB_NAME || 'test',
12
+ waitForConnections: true,
13
+ connectionLimit: 10,
14
+ queueLimit: 0,
15
+ connectTimeout: 10000 // 10 seconds
16
+ });
17
+ export function getDbName() {
18
+ return process.env.DB_NAME || 'test';
19
+ }
20
+ /**
21
+ * Executes a safe query with a Timeout.
22
+ */
23
+ export async function executeSafeQuery(sql) {
24
+ // 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({
29
+ sql: astOptimizedSql,
30
+ timeout: 15000
31
+ });
32
+ return rows;
33
+ }
34
+ export async function pingDb() {
35
+ try {
36
+ await pool.query('SELECT 1');
37
+ return true;
38
+ }
39
+ catch (e) {
40
+ return false;
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import Fuse from 'fuse.js';
3
+ import dotenv from 'dotenv';
4
+ dotenv.config({ quiet: true });
5
+ let metadataCache = {};
6
+ let templatesCache = [];
7
+ let templateSearcher = null;
8
+ export function initMetadata() {
9
+ const metadataPath = process.env.MCP_METADATA_PATH;
10
+ if (metadataPath && fs.existsSync(metadataPath)) {
11
+ try {
12
+ const raw = fs.readFileSync(metadataPath, 'utf8');
13
+ metadataCache = JSON.parse(raw);
14
+ console.error(`[tokenlite-mysql-mcp] Loaded metadata dictionary from ${metadataPath}`);
15
+ }
16
+ catch (err) {
17
+ console.error(`[tokenlite-mysql-mcp] Error loading metadata.json:`, err);
18
+ }
19
+ }
20
+ const templatesPath = process.env.MCP_TEMPLATES_PATH;
21
+ if (templatesPath && fs.existsSync(templatesPath)) {
22
+ try {
23
+ const raw = fs.readFileSync(templatesPath, 'utf8');
24
+ templatesCache = JSON.parse(raw);
25
+ templateSearcher = new Fuse(templatesCache, {
26
+ keys: ['name', 'description'],
27
+ threshold: 0.5,
28
+ ignoreLocation: true
29
+ });
30
+ console.error(`[tokenlite-mysql-mcp] Loaded ${templatesCache.length} SQL templates from ${templatesPath}`);
31
+ }
32
+ catch (err) {
33
+ console.error(`[tokenlite-mysql-mcp] Error loading templates.json:`, err);
34
+ }
35
+ }
36
+ }
37
+ /**
38
+ * Extracts all semantic definitions relevant to a specific table.
39
+ * Example: if metadata has "orders.status", and tableName is "orders", it returns that chunk.
40
+ */
41
+ export function getTableSemantics(tableName) {
42
+ const semantics = {};
43
+ const prefix = `${tableName}.`;
44
+ for (const key of Object.keys(metadataCache)) {
45
+ if (key.startsWith(prefix) || key === tableName) {
46
+ semantics[key] = metadataCache[key];
47
+ }
48
+ }
49
+ return semantics;
50
+ }
51
+ /**
52
+ * Performs a fuzzy search on the loaded templates.
53
+ */
54
+ export function searchTemplates(query) {
55
+ if (!templateSearcher)
56
+ return [];
57
+ // If query is empty, return all (capped to a safe limit, e.g., 10)
58
+ if (!query.trim()) {
59
+ return templatesCache.slice(0, 10);
60
+ }
61
+ const results = templateSearcher.search(query);
62
+ return results.map(r => r.item);
63
+ }
@@ -0,0 +1,92 @@
1
+ import pkg from 'node-sql-parser';
2
+ const { Parser } = pkg;
3
+ export class OptimizerError extends Error {
4
+ code;
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.name = 'OptimizerError';
8
+ this.code = code;
9
+ }
10
+ }
11
+ const parser = new Parser();
12
+ function getMaxRows() {
13
+ return process.env.MCP_SAFE_QUERY_MAX_ROWS ? parseInt(process.env.MCP_SAFE_QUERY_MAX_ROWS, 10) : 1000;
14
+ }
15
+ function isBlockingEnabled() {
16
+ return process.env.MCP_SAFE_QUERY_ENABLE_BLOCKING !== 'false';
17
+ }
18
+ /**
19
+ * Parses the SQL query to AST, injects a LIMIT if missing, and returns the modified SQL.
20
+ */
21
+ export function injectLimitAst(sql, maxLimit = 500) {
22
+ if (sql.trim().toUpperCase().startsWith('SHOW')) {
23
+ return sql;
24
+ }
25
+ try {
26
+ const astOpt = { database: 'MySQL' };
27
+ let ast = parser.astify(sql, astOpt);
28
+ // AST can be an array if multiple statements are provided.
29
+ if (Array.isArray(ast)) {
30
+ if (ast.length > 1) {
31
+ throw new OptimizerError("Security Error: Multiple statements are not allowed.");
32
+ }
33
+ ast = ast[0];
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;
53
+ }
54
+ }
55
+ return parser.sqlify(ast, astOpt);
56
+ }
57
+ catch (e) {
58
+ if (e instanceof OptimizerError) {
59
+ throw e;
60
+ }
61
+ throw new OptimizerError(`SQL Syntax Error or Unsupported Feature: ${e.message}`);
62
+ }
63
+ }
64
+ /**
65
+ * Analyzes the query using EXPLAIN. If a Full Table Scan (type: ALL) is detected
66
+ * on a table with more rows than MAX_ROWS, it blocks the query.
67
+ */
68
+ export async function analyzeQueryPlan(sql, pool) {
69
+ if (!isBlockingEnabled())
70
+ return;
71
+ if (sql.trim().toUpperCase().startsWith('SHOW'))
72
+ return;
73
+ try {
74
+ const [planRows] = await pool.query(`EXPLAIN ${sql}`);
75
+ const maxRows = getMaxRows();
76
+ for (const row of planRows) {
77
+ // In standard EXPLAIN, row.type is the join type. 'ALL' means full table scan.
78
+ if (row.type && row.type.toUpperCase() === 'ALL') {
79
+ const estimatedRows = parseInt(row.rows, 10);
80
+ if (!isNaN(estimatedRows) && estimatedRows > maxRows) {
81
+ throw new OptimizerError(`Full table scan detected on table '${row.table}'. Estimated rows: ${estimatedRows}. Please add an indexed filter (e.g., a specific ID) to your WHERE clause.`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ catch (e) {
87
+ if (e instanceof OptimizerError) {
88
+ throw e;
89
+ }
90
+ throw new OptimizerError(`Query Analysis Error: ${e.message}`, e.code);
91
+ }
92
+ }
@@ -0,0 +1,84 @@
1
+ import { pool, getDbName } from './index.js';
2
+ export let schemaGraph = new Map();
3
+ /**
4
+ * Connects to the database and builds the relational graph in-memory.
5
+ * Optimized for low RAM usage by only extracting node names and edges (no DDL/Columns cached).
6
+ */
7
+ export async function buildSchemaGraph() {
8
+ const dbName = getDbName();
9
+ const newGraph = new Map();
10
+ // Fetch all tables
11
+ const [tables] = await pool.query(`SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'`, [dbName]);
12
+ const tableNames = new Set();
13
+ for (const row of tables) {
14
+ tableNames.add(row.TABLE_NAME);
15
+ newGraph.set(row.TABLE_NAME, {
16
+ name: row.TABLE_NAME,
17
+ foreignKeys: []
18
+ });
19
+ }
20
+ // Fetch Explicit Foreign Keys
21
+ const [fks] = await pool.query(`SELECT
22
+ TABLE_NAME,
23
+ COLUMN_NAME,
24
+ REFERENCED_TABLE_NAME,
25
+ REFERENCED_COLUMN_NAME
26
+ FROM information_schema.key_column_usage
27
+ WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL`, [dbName]);
28
+ const explicitFkSignatures = new Set();
29
+ for (const row of fks) {
30
+ const tableNode = newGraph.get(row.TABLE_NAME);
31
+ if (tableNode) {
32
+ tableNode.foreignKeys.push({
33
+ columnName: row.COLUMN_NAME,
34
+ referencedTable: row.REFERENCED_TABLE_NAME,
35
+ referencedColumn: row.REFERENCED_COLUMN_NAME,
36
+ isHeuristic: false
37
+ });
38
+ // Keep a signature to avoid duplicating with heuristics
39
+ explicitFkSignatures.add(`${row.TABLE_NAME}.${row.COLUMN_NAME}`);
40
+ }
41
+ }
42
+ // The Heuristic Engine: Fetch columns that end with '_id'
43
+ // This is extremely lightweight because we filter at the DB engine level.
44
+ const [idColumns] = await pool.query(`SELECT TABLE_NAME, COLUMN_NAME
45
+ FROM information_schema.columns
46
+ WHERE TABLE_SCHEMA = ? AND COLUMN_NAME LIKE '%\\_id'`, [dbName]);
47
+ for (const row of idColumns) {
48
+ const tableName = row.TABLE_NAME;
49
+ const columnName = row.COLUMN_NAME;
50
+ const signature = `${tableName}.${columnName}`;
51
+ if (explicitFkSignatures.has(signature)) {
52
+ continue; // Already an explicit FK, skip heuristic
53
+ }
54
+ // Try to guess the target table name. e.g. 'company_id' -> 'company' or 'companies'
55
+ const baseName = columnName.slice(0, -3); // remove '_id'
56
+ let targetTable = null;
57
+ if (tableNames.has(baseName)) {
58
+ targetTable = baseName;
59
+ }
60
+ else if (tableNames.has(baseName + 's')) {
61
+ targetTable = baseName + 's';
62
+ }
63
+ else if (tableNames.has(baseName + 'es')) {
64
+ targetTable = baseName + 'es';
65
+ }
66
+ else if (baseName.endsWith('y') && tableNames.has(baseName.slice(0, -1) + 'ies')) {
67
+ // company -> companies
68
+ targetTable = baseName.slice(0, -1) + 'ies';
69
+ }
70
+ if (targetTable) {
71
+ const tableNode = newGraph.get(tableName);
72
+ if (tableNode) {
73
+ tableNode.foreignKeys.push({
74
+ columnName: columnName,
75
+ referencedTable: targetTable,
76
+ referencedColumn: 'id', // assumption
77
+ isHeuristic: true
78
+ });
79
+ }
80
+ }
81
+ }
82
+ schemaGraph = newGraph;
83
+ console.error(`[tokenlite-mysql-mcp] Schema Graph built successfully. Indexed ${schemaGraph.size} tables.`);
84
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerExecuteQueryTool } from "./tools/executeQuery.js";
5
+ import { registerSearchSchemaTool } from "./tools/searchSchema.js";
6
+ import { registerRefreshSchemaTool } from "./tools/refreshSchema.js";
7
+ import { registerGetTemplatesTool } from "./tools/getTemplates.js";
8
+ import { buildSchemaGraph } from "./db/schema.js";
9
+ import { initMetadata } from "./db/metadata.js";
10
+ import dotenv from "dotenv";
11
+ dotenv.config({ quiet: true });
12
+ async function main() {
13
+ const server = new McpServer({
14
+ name: "tokenlite-mysql-mcp",
15
+ version: "1.0.0",
16
+ });
17
+ // Build Semantic Graph on startup
18
+ await buildSchemaGraph();
19
+ // Load Metadata and Templates
20
+ initMetadata();
21
+ // Register MCP Tools
22
+ registerSearchSchemaTool(server);
23
+ registerExecuteQueryTool(server);
24
+ registerRefreshSchemaTool(server);
25
+ registerGetTemplatesTool(server);
26
+ const transport = new StdioServerTransport();
27
+ await server.connect(transport);
28
+ }
29
+ main().catch(console.error);
package/dist/server.js ADDED
@@ -0,0 +1,11 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerExecuteQueryTool } from "./tools/executeQuery.js";
3
+ import { registerSearchSchemaTool } from "./tools/searchSchema.js";
4
+ import { registerRefreshSchemaTool } from "./tools/refreshSchema.js";
5
+ export const server = new McpServer({
6
+ name: "tokenlite-mysql-server",
7
+ version: "1.0.0",
8
+ });
9
+ registerExecuteQueryTool(server);
10
+ registerSearchSchemaTool(server);
11
+ registerRefreshSchemaTool(server);
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { executeSafeQuery } from "../db/index.js";
3
+ import { jsonToCsv } from "../utils/csvFormatter.js";
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
+ try {
12
+ const rows = await executeSafeQuery(sql);
13
+ const csvData = jsonToCsv(rows);
14
+ return {
15
+ content: [{ type: "text", text: csvData }]
16
+ };
17
+ }
18
+ catch (error) {
19
+ let errorMessage = error.name === 'OptimizerError' ? error.message : `Database Error: ${error.message}`;
20
+ if (error.code === 'ER_BAD_FIELD_ERROR' || error.message?.includes('Unknown column')) {
21
+ 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
+ }
23
+ return {
24
+ content: [{ type: "text", text: errorMessage }],
25
+ isError: true
26
+ };
27
+ }
28
+ }
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."),
32
+ }, handleExecuteQuery);
33
+ }
@@ -0,0 +1,24 @@
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
+ }
@@ -0,0 +1,17 @@
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 () => {
4
+ try {
5
+ await buildSchemaGraph();
6
+ return {
7
+ content: [{ type: "text", text: "Schema Graph rebuilt successfully. You can now use search_schema to explore the updated relationships." }]
8
+ };
9
+ }
10
+ catch (error) {
11
+ return {
12
+ content: [{ type: "text", text: `Failed to rebuild schema graph: ${error.message}` }],
13
+ isError: true
14
+ };
15
+ }
16
+ });
17
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import Fuse from "fuse.js";
3
+ import { pool } from "../db/index.js";
4
+ import { schemaGraph } from "../db/schema.js";
5
+ 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
+ export async function handleSearchSchema({ query }) {
19
+ if (schemaGraph.size === 0) {
20
+ return {
21
+ content: [{ type: "text", text: "Schema Graph is empty. Make sure the database is connected." }],
22
+ isError: true
23
+ };
24
+ }
25
+ // Search for the table
26
+ const tableNodes = Array.from(schemaGraph.values());
27
+ const fuse = new Fuse(tableNodes, {
28
+ keys: ["name"],
29
+ threshold: 0.4 // somewhat fuzzy
30
+ });
31
+ const results = fuse.search(query);
32
+ if (results.length === 0) {
33
+ return {
34
+ content: [{ type: "text", text: `No table found matching '${query}'. Use refresh_schema() if you believe it was recently added.` }],
35
+ isError: true
36
+ };
37
+ }
38
+ const targetTable = results[0].item;
39
+ // Traversal: Find Parent tables (the tables targetTable points to)
40
+ const parentTableNames = new Set();
41
+ const inferredHints = [];
42
+ for (const fk of targetTable.foreignKeys) {
43
+ parentTableNames.add(fk.referencedTable);
44
+ if (fk.isHeuristic) {
45
+ inferredHints.push(`/* INFERRED PARENT: \`${targetTable.name}\`.\`${fk.columnName}\` -> \`${fk.referencedTable}\`.\`${fk.referencedColumn}\` */`);
46
+ }
47
+ }
48
+ // Traversal: Find Child tables (tables that point to targetTable)
49
+ const childTableNames = new Set();
50
+ for (const node of tableNodes) {
51
+ for (const fk of node.foreignKeys) {
52
+ if (fk.referencedTable === targetTable.name) {
53
+ childTableNames.add(node.name);
54
+ if (fk.isHeuristic) {
55
+ inferredHints.push(`/* INFERRED CHILD: \`${node.name}\`.\`${fk.columnName}\` -> \`${targetTable.name}\`.\`${fk.referencedColumn}\` */`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ // Fetch DDLs dynamically
61
+ const tablesToFetch = [targetTable.name, ...parentTableNames, ...childTableNames];
62
+ const ddls = [];
63
+ for (const tableName of tablesToFetch) {
64
+ const ddl = await getTableDDL(tableName);
65
+ if (ddl) {
66
+ let header = tableName === targetTable.name
67
+ ? `-- === MATCHED TABLE ===\n`
68
+ : `-- === RELATED TABLE ===\n`;
69
+ let tableStr = header + ddl + ";\n";
70
+ // Append Semantics
71
+ const semantics = getTableSemantics(tableName);
72
+ if (Object.keys(semantics).length > 0) {
73
+ tableStr += `/* SEMANTIC DICTIONARY:\n`;
74
+ tableStr += JSON.stringify(semantics, null, 2);
75
+ tableStr += `\n*/\n`;
76
+ }
77
+ ddls.push(tableStr);
78
+ }
79
+ }
80
+ let output = ddls.join("\n");
81
+ if (inferredHints.length > 0) {
82
+ output += "\n-- === HEURISTIC GRAPH HINTS ===\n" + inferredHints.join("\n");
83
+ }
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. */";
85
+ return {
86
+ content: [{ type: "text", text: output }]
87
+ };
88
+ }
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);
93
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Converts an array of JSON objects (typically returned by MySQL)
3
+ * to a tabular CSV format to save LLM tokens.
4
+ */
5
+ export function jsonToCsv(data) {
6
+ if (!data || data.length === 0) {
7
+ return "No data returned.";
8
+ }
9
+ const headers = Object.keys(data[0]);
10
+ const csvRows = [];
11
+ // Add headers
12
+ csvRows.push(headers.join(","));
13
+ // Add rows
14
+ for (const row of data) {
15
+ const values = headers.map(header => {
16
+ const val = row[header];
17
+ if (val === null || val === undefined) {
18
+ return "";
19
+ }
20
+ // If the value contains commas, quotes, or newlines, it must be escaped
21
+ const strVal = String(val);
22
+ if (strVal.includes(",") || strVal.includes("\"") || strVal.includes("\n")) {
23
+ return `"${strVal.replace(/"/g, "\"\"")}"`;
24
+ }
25
+ return strVal;
26
+ });
27
+ csvRows.push(values.join(","));
28
+ }
29
+ return csvRows.join("\n");
30
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@andezdev/tokenlite-mysql-mcp",
3
+ "version": "1.0.0",
4
+ "description": "A secure, efficient, and intelligent MySQL server for the Model Context Protocol",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "tokenlite-mysql-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "package.json",
13
+ "README.md",
14
+ "AGENTS.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "author": "Antonio Hernandez",
20
+ "license": "MIT",
21
+ "keywords": [
22
+ "mcp",
23
+ "mysql",
24
+ "model-context-protocol",
25
+ "claude",
26
+ "ai",
27
+ "llm",
28
+ "database",
29
+ "sql",
30
+ "agent"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/andezdev/tokenlite-mysql-mcp.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/andezdev/tokenlite-mysql-mcp/issues"
38
+ },
39
+ "homepage": "https://github.com/andezdev/tokenlite-mysql-mcp#readme",
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "start": "node dist/index.js",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "inspect-graph": "tsx scripts/inspect-graph.ts",
46
+ "benchmark": "tsx scripts/benchmark.ts",
47
+ "prepare": "husky"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.6.0",
51
+ "dotenv": "^16.4.7",
52
+ "fuse.js": "^7.4.0",
53
+ "mysql2": "^3.12.0",
54
+ "node-sql-parser": "^5.4.0",
55
+ "zod": "^4.4.3"
56
+ },
57
+ "devDependencies": {
58
+ "@commitlint/cli": "^21.0.2",
59
+ "@commitlint/config-conventional": "^21.0.2",
60
+ "@types/node": "^22.10.1",
61
+ "@types/node-sql-parser": "^1.0.0",
62
+ "husky": "^9.1.7",
63
+ "js-tiktoken": "^1.0.21",
64
+ "ts-node": "^10.9.2",
65
+ "tsx": "^4.19.2",
66
+ "typescript": "^5.7.2",
67
+ "vitest": "^4.1.7"
68
+ }
69
+ }