@mrfentmen/sql-format-mcp 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/dist/api.d.ts +17 -0
- package/dist/api.js +261 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +43 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SQL Formatter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Format, minify and split SQL statements offline - strings, quotes and comments are respected, keyless.
|
|
4
|
+
|
|
5
|
+
Keyless — no signup, no API key, no environment variables.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y @mrfentmen/sql-format-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your MCP client:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"sql-format-mcp": { "command": "npx", "args": ["-y", "@mrfentmen/sql-format-mcp"] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Tool | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `format_sql` | Lay out a SQL statement with clauses on their own lines, SELECT lists one item per line and subqueries indented. |
|
|
28
|
+
| `minify_sql` | Collapse a SQL statement onto one line without touching strings or comments. |
|
|
29
|
+
| `split_statements` | Split a SQL script into statements on top-level semicolons, keeping strings and comments intact. |
|
|
30
|
+
|
|
31
|
+
## Data source
|
|
32
|
+
|
|
33
|
+
Runs fully offline — all work happens inside the server with no API calls and no keys.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Formatter — keyless public API client.
|
|
3
|
+
*/
|
|
4
|
+
export declare class SourceError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export declare function formatSql(a: {
|
|
7
|
+
sql: string;
|
|
8
|
+
indent?: number;
|
|
9
|
+
uppercaseKeywords?: boolean;
|
|
10
|
+
}): Promise<string>;
|
|
11
|
+
export declare function minifySql(a: {
|
|
12
|
+
sql: string;
|
|
13
|
+
stripComments?: boolean;
|
|
14
|
+
}): Promise<string>;
|
|
15
|
+
export declare function splitStatements(a: {
|
|
16
|
+
sql: string;
|
|
17
|
+
}): Promise<string>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Formatter — keyless public API client.
|
|
3
|
+
*/
|
|
4
|
+
export class SourceError extends Error {
|
|
5
|
+
}
|
|
6
|
+
function tokenize(sql) {
|
|
7
|
+
const toks = [];
|
|
8
|
+
let i = 0;
|
|
9
|
+
while (i < sql.length) {
|
|
10
|
+
const ch = sql[i];
|
|
11
|
+
if (/\s/.test(ch)) {
|
|
12
|
+
i++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
16
|
+
const nl = sql.indexOf("\n", i);
|
|
17
|
+
const end = nl < 0 ? sql.length : nl;
|
|
18
|
+
toks.push({ t: "comment", v: sql.slice(i, end), pos: i });
|
|
19
|
+
i = end;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
23
|
+
const close = sql.indexOf("*/", i + 2);
|
|
24
|
+
const end = close < 0 ? sql.length : close + 2;
|
|
25
|
+
toks.push({ t: "comment", v: sql.slice(i, end), pos: i });
|
|
26
|
+
i = end;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
30
|
+
let j = i + 1;
|
|
31
|
+
while (j < sql.length) {
|
|
32
|
+
if (sql[j] === ch) {
|
|
33
|
+
if (sql[j + 1] === ch && ch !== "`") {
|
|
34
|
+
j += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
j++;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
j++;
|
|
41
|
+
}
|
|
42
|
+
toks.push({ t: ch === "'" ? "string" : "word", v: sql.slice(i, j), pos: i });
|
|
43
|
+
i = j;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(sql[i + 1] ?? ""))) {
|
|
47
|
+
const m = /^[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(sql.slice(i));
|
|
48
|
+
const v = m ? m[0] : ch;
|
|
49
|
+
toks.push({ t: "num", v, pos: i });
|
|
50
|
+
i += v.length;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
54
|
+
const m = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(sql.slice(i));
|
|
55
|
+
const v = m ? m[0] : ch;
|
|
56
|
+
toks.push({ t: "word", v, pos: i });
|
|
57
|
+
i += v.length;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if ("(),;".includes(ch)) {
|
|
61
|
+
toks.push({ t: "punct", v: ch, pos: i });
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (".+-*/%=<>!|&:".includes(ch)) {
|
|
66
|
+
const m = /^(<=|>=|<>|!=|\|\||::|[.+\-*/%=<>!|&])/.exec(sql.slice(i));
|
|
67
|
+
const v = m ? m[0] : ch;
|
|
68
|
+
toks.push({ t: "op", v, pos: i });
|
|
69
|
+
i += v.length;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
throw new SourceError(`unexpected character "${ch}" at position ${i}`);
|
|
73
|
+
}
|
|
74
|
+
return toks;
|
|
75
|
+
}
|
|
76
|
+
const KEYWORDS = new Set("select from where group by order having limit offset union all distinct as join left right inner outer full cross on insert into values update set delete create table index view drop alter add column primary key foreign references not null default unique check and or in like between is exists case when then else end asc desc with returning using natural cast count sum avg min max between any some".split(" "));
|
|
77
|
+
const JOIN_START = new Set(["join", "left", "right", "inner", "outer", "full", "cross"]);
|
|
78
|
+
// Keywords that read naturally with a space before a bracket; function names
|
|
79
|
+
// (count(, sum(, anything else) stay tight.
|
|
80
|
+
const SPACED_PAREN = new Set(["from", "in", "values", "exists", "on", "and", "or", "not", "where", "select", "join", "union", "distinct"]);
|
|
81
|
+
function phraseAt(tokens, i) {
|
|
82
|
+
const word = (k) => (tokens[i + k] && tokens[i + k].t === "word" ? tokens[i + k].v.toLowerCase() : null);
|
|
83
|
+
const two = [word(0), word(1)];
|
|
84
|
+
const three = [word(0), word(1), word(2)];
|
|
85
|
+
const phrases = [
|
|
86
|
+
[["left", "outer", "join"], " "],
|
|
87
|
+
[["group", "by"], " "],
|
|
88
|
+
[["order", "by"], " "],
|
|
89
|
+
[["left", "join"], " "],
|
|
90
|
+
[["right", "join"], " "],
|
|
91
|
+
[["inner", "join"], " "],
|
|
92
|
+
[["full", "join"], " "],
|
|
93
|
+
[["cross", "join"], " "],
|
|
94
|
+
[["insert", "into"], " "],
|
|
95
|
+
[["delete", "from"], " "],
|
|
96
|
+
[["create", "table"], " "],
|
|
97
|
+
[["alter", "table"], " "],
|
|
98
|
+
[["drop", "table"], " "],
|
|
99
|
+
[["union", "all"], " "],
|
|
100
|
+
];
|
|
101
|
+
for (const [words] of phrases) {
|
|
102
|
+
const candidate = words.length === 2 ? two : three;
|
|
103
|
+
if (words.every((w, k) => candidate[k] === w))
|
|
104
|
+
return words;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const BREAK_BEFORE = new Set(["select", "from", "where", "group by", "order by", "having", "limit", "offset", "union", "union all", "insert into", "values", "set", "update", "delete from"]);
|
|
109
|
+
function upper(token) {
|
|
110
|
+
return token.t === "word" && KEYWORDS.has(token.v.toLowerCase()) ? token.v.toUpperCase() : token.v;
|
|
111
|
+
}
|
|
112
|
+
export async function formatSql(a) {
|
|
113
|
+
const indentSize = Math.floor(a.indent ?? 2);
|
|
114
|
+
if (![2, 4].includes(indentSize))
|
|
115
|
+
throw new SourceError("indent must be 2 or 4");
|
|
116
|
+
const upperCase = a.uppercaseKeywords !== false;
|
|
117
|
+
const tokens = tokenize(a.sql);
|
|
118
|
+
if (!tokens.length)
|
|
119
|
+
throw new SourceError("sql is empty");
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let inSelectList = false;
|
|
122
|
+
let selectDepth = 0;
|
|
123
|
+
const multilineParen = [];
|
|
124
|
+
const lines = [];
|
|
125
|
+
let current = "";
|
|
126
|
+
const flush = () => {
|
|
127
|
+
if (current.trim())
|
|
128
|
+
lines.push(current.trimEnd());
|
|
129
|
+
current = "";
|
|
130
|
+
};
|
|
131
|
+
const startNew = (extra = 0) => {
|
|
132
|
+
flush();
|
|
133
|
+
current = " ".repeat((depth + extra) * indentSize);
|
|
134
|
+
};
|
|
135
|
+
const render = upperCase ? upper : (t) => t.v;
|
|
136
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
137
|
+
const token = tokens[i];
|
|
138
|
+
if (token.t === "comment") {
|
|
139
|
+
current += (current.trim() ? " " : "") + token.v;
|
|
140
|
+
if (i === tokens.length - 1)
|
|
141
|
+
flush();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const phrase = phraseAt(tokens, i);
|
|
145
|
+
const phraseKey = phrase ? phrase.join(" ") : token.v.toLowerCase();
|
|
146
|
+
const breakKey = phrase ? phrase[0] : token.v.toLowerCase();
|
|
147
|
+
const isBreak = token.t === "word" && (BREAK_BEFORE.has(phraseKey) || JOIN_START.has(breakKey));
|
|
148
|
+
if (isBreak) {
|
|
149
|
+
startNew();
|
|
150
|
+
if (phraseKey === "select") {
|
|
151
|
+
inSelectList = true;
|
|
152
|
+
selectDepth = depth;
|
|
153
|
+
}
|
|
154
|
+
else if (depth === selectDepth && phraseKey !== "union" && phraseKey !== "union all") {
|
|
155
|
+
inSelectList = false;
|
|
156
|
+
}
|
|
157
|
+
if (phrase) {
|
|
158
|
+
current += phrase.map((w) => (upperCase ? w.toUpperCase() : w)).join(" ");
|
|
159
|
+
i += phrase.length - 1;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
current += render(token);
|
|
163
|
+
}
|
|
164
|
+
// SELECT items go one per line, indented under the keyword.
|
|
165
|
+
if (phraseKey === "select")
|
|
166
|
+
startNew(1);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (token.v === "(") {
|
|
170
|
+
const next = tokens[i + 1];
|
|
171
|
+
const prev = tokens[i - 1];
|
|
172
|
+
const isSubquery = next && next.t === "word" && next.v.toLowerCase() === "select";
|
|
173
|
+
// "FROM (" and "IN (" keep a space; function calls like count( stay tight.
|
|
174
|
+
const spaced = Boolean(prev && prev.t === "word" && SPACED_PAREN.has(prev.v.toLowerCase()));
|
|
175
|
+
current = current.trimEnd() + (spaced ? " (" : "(");
|
|
176
|
+
depth++;
|
|
177
|
+
multilineParen.push(Boolean(isSubquery));
|
|
178
|
+
if (isSubquery)
|
|
179
|
+
startNew();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (token.v === ")") {
|
|
183
|
+
const wasMultiline = multilineParen.pop() ?? false;
|
|
184
|
+
depth = Math.max(0, depth - 1);
|
|
185
|
+
if (wasMultiline)
|
|
186
|
+
startNew();
|
|
187
|
+
current = current.trimEnd() + ")";
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token.v === ",") {
|
|
191
|
+
current = current.trimEnd() + ",";
|
|
192
|
+
if (inSelectList && depth === selectDepth)
|
|
193
|
+
startNew(1);
|
|
194
|
+
else
|
|
195
|
+
current += " ";
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (token.v === ";") {
|
|
199
|
+
current = current.trimEnd() + ";";
|
|
200
|
+
flush();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (token.t === "op") {
|
|
204
|
+
// Qualified names read as one token, and * inside count( stays tight.
|
|
205
|
+
const tight = token.v === "." || (token.v === "*" && /[(]$/.test(current.trimEnd()));
|
|
206
|
+
current = tight ? current.trimEnd() + token.v : current.trimEnd() + ` ${token.v} `;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const text = render(token);
|
|
210
|
+
const glue = current.endsWith(".") || current.endsWith("(");
|
|
211
|
+
current += current.trim() === "" || current.endsWith(" ") || glue ? text : ` ${text}`;
|
|
212
|
+
}
|
|
213
|
+
flush();
|
|
214
|
+
const text = lines.join("\n").replace(/[ \t]+$/gm, "");
|
|
215
|
+
return [`Formatted SQL (${lines.length} line(s)):`, "", text].join("\n");
|
|
216
|
+
}
|
|
217
|
+
export async function minifySql(a) {
|
|
218
|
+
const strip = a.stripComments === true;
|
|
219
|
+
const tokens = tokenize(a.sql).filter((t) => !(strip && t.t === "comment"));
|
|
220
|
+
if (!tokens.length)
|
|
221
|
+
throw new SourceError("sql is empty");
|
|
222
|
+
const glued = new Set(["(", ")", ",", ";", "."]);
|
|
223
|
+
let out = "";
|
|
224
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
225
|
+
const token = tokens[i];
|
|
226
|
+
const prev = tokens[i - 1];
|
|
227
|
+
if (!prev) {
|
|
228
|
+
out = token.v;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const needsSpace = !glued.has(token.v) && prev.v !== "(" && prev.v !== ".";
|
|
232
|
+
out += needsSpace ? ` ${token.v}` : token.v;
|
|
233
|
+
}
|
|
234
|
+
return [`Minified SQL (${a.sql.length} -> ${out.length} character(s)):`, "", out].join("\n");
|
|
235
|
+
}
|
|
236
|
+
export async function splitStatements(a) {
|
|
237
|
+
const tokens = tokenize(a.sql);
|
|
238
|
+
if (!tokens.length)
|
|
239
|
+
throw new SourceError("sql is empty");
|
|
240
|
+
const statements = [];
|
|
241
|
+
let start = tokens[0].pos;
|
|
242
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
243
|
+
if (tokens[i].v === ";") {
|
|
244
|
+
const text = a.sql.slice(start, tokens[i].pos).trim();
|
|
245
|
+
if (text)
|
|
246
|
+
statements.push(text);
|
|
247
|
+
const next = tokens[i + 1];
|
|
248
|
+
start = next ? next.pos : a.sql.length;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const tail = a.sql.slice(start).trim();
|
|
252
|
+
if (tail)
|
|
253
|
+
statements.push(tail);
|
|
254
|
+
if (!statements.length)
|
|
255
|
+
throw new SourceError("no statements found");
|
|
256
|
+
return [
|
|
257
|
+
`${statements.length} statement(s):`,
|
|
258
|
+
"",
|
|
259
|
+
...statements.map((s, i) => `${i + 1}. ${s.length > 160 ? s.slice(0, 157) + "..." : s}`),
|
|
260
|
+
].join("\n");
|
|
261
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer } from "./server.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
|
8
|
+
console.error("sql-format-mcp running on stdio");
|
|
9
|
+
}
|
|
10
|
+
main().catch((err) => {
|
|
11
|
+
console.error("Fatal error:", err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { formatSql, minifySql, splitStatements, SourceError } from "./api.js";
|
|
4
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
|
+
function errorMessage(e) {
|
|
6
|
+
if (e instanceof SourceError || e instanceof Error)
|
|
7
|
+
return `Error: ${e.message}`;
|
|
8
|
+
return `Error: ${String(e)}`;
|
|
9
|
+
}
|
|
10
|
+
export function createServer() {
|
|
11
|
+
const server = new McpServer({ name: "sql-format-mcp", version: "1.0.0" });
|
|
12
|
+
server.tool("format_sql", "Lay out a SQL statement with clauses on their own lines, SELECT lists one item per line and subqueries indented.", {
|
|
13
|
+
sql: z.string().describe('SQL text'), indent: z.number().int().min(2).max(4).optional().describe('Spaces per indent level, 2 or 4, default 2'), uppercaseKeywords: z.boolean().optional().describe('Upper-case keywords, default true')
|
|
14
|
+
}, async (a) => {
|
|
15
|
+
try {
|
|
16
|
+
return text(await formatSql(a));
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.tool("minify_sql", "Collapse a SQL statement onto one line without touching strings or comments.", {
|
|
23
|
+
sql: z.string().describe('SQL text'), stripComments: z.boolean().optional().describe('Drop comments as well, default false')
|
|
24
|
+
}, async (a) => {
|
|
25
|
+
try {
|
|
26
|
+
return text(await minifySql(a));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.tool("split_statements", "Split a SQL script into statements on top-level semicolons, keeping strings and comments intact.", {
|
|
33
|
+
sql: z.string().describe('SQL script that may hold several statements')
|
|
34
|
+
}, async (a) => {
|
|
35
|
+
try {
|
|
36
|
+
return text(await splitStatements(a));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return server;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/sql-format-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Format, minify and split SQL statements offline - strings, quotes and comments are respected, keyless.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/sql-format-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrfentmen/awesome-servers.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"sql-format-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"start": "node dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"sql",
|
|
25
|
+
"format",
|
|
26
|
+
"beautify",
|
|
27
|
+
"minify",
|
|
28
|
+
"offline",
|
|
29
|
+
"keyless"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|