@deepsql/mcp 0.2.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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # DeepSQL MCP
2
+
3
+ DeepSQL MCP V1 is a local stdio server for Claude Desktop, Codex, and similar MCP clients.
4
+
5
+ ## What V1 Supports
6
+
7
+ - Local stdio MCP process
8
+ - Remote DeepSQL backend over HTTPS
9
+ - MCP personal access tokens for authentication
10
+ - Read-only SQL execution and explain through backend-enforced MCP endpoints
11
+
12
+ V1 does **not** provide a shared remote MCP server URL. Each user runs the MCP process locally on their own machine.
13
+
14
+ ## Environment Variables
15
+
16
+ | Variable | Required | Example |
17
+ |----------|----------|---------|
18
+ | `DEEPSQL_API_BASE_URL` | yes | `https://customer-deepsql.example.com/api/` |
19
+ | `DEEPSQL_AUTH_TOKEN` | yes | `dsql_mcp_...` |
20
+ | `DEEPSQL_MCP_TIMEOUT_MS` | no | `120000` |
21
+ | `DEEPSQL_MCP_USER_ID` | no | `codex-mcp` |
22
+ | `DEEPSQL_MCP_PROJECT_ID` | no | `codex-mcp` |
23
+
24
+ ## Claude Desktop
25
+
26
+ Use `claude_desktop_config.customer.example.json` as a template.
27
+
28
+ ## Codex
29
+
30
+ Use `codex_config.customer.example.toml` as a template.
31
+
32
+ ## Run
33
+
34
+ ```bash
35
+ npx -y @deepsql/mcp
36
+ ```
37
+
38
+ For local repo development, you can also run:
39
+
40
+ ```bash
41
+ npm run mcp:phase1
42
+ ```
package/bin/deepsql.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { main } = require("../src/cli");
5
+
6
+ main()
7
+ .then((code) => {
8
+ if (typeof code === "number") process.exit(code);
9
+ })
10
+ .catch((err) => {
11
+ process.stderr.write(`Fatal: ${err.message}\n`);
12
+ process.exit(1);
13
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "mcpServers": {
3
+ "deepsql": {
4
+ "command": "npx",
5
+ "args": [
6
+ "-y",
7
+ "@deepsql/mcp"
8
+ ],
9
+ "env": {
10
+ "DEEPSQL_API_BASE_URL": "https://customer-deepsql.example.com/api/",
11
+ "DEEPSQL_AUTH_TOKEN": "dsql_mcp_replace_me",
12
+ "DEEPSQL_MCP_USER_ID": "claude-desktop",
13
+ "DEEPSQL_MCP_PROJECT_ID": "claude-desktop"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,9 @@
1
+ [mcp_servers.deepsql]
2
+ command = "npx"
3
+ args = ["-y", "@deepsql/mcp"]
4
+
5
+ [mcp_servers.deepsql.env]
6
+ DEEPSQL_API_BASE_URL = "https://customer-deepsql.example.com/api/"
7
+ DEEPSQL_AUTH_TOKEN = "dsql_mcp_replace_me"
8
+ DEEPSQL_MCP_USER_ID = "codex-mcp"
9
+ DEEPSQL_MCP_PROJECT_ID = "codex-mcp"
@@ -0,0 +1,586 @@
1
+ const ALLOWED_READ_ONLY_KEYWORDS = new Set([
2
+ "SELECT",
3
+ "WITH",
4
+ "SHOW",
5
+ "DESCRIBE",
6
+ "DESC",
7
+ "EXPLAIN",
8
+ ]);
9
+
10
+ const FORBIDDEN_SQL_KEYWORDS = [
11
+ "INSERT",
12
+ "UPDATE",
13
+ "DELETE",
14
+ "ALTER",
15
+ "DROP",
16
+ "TRUNCATE",
17
+ "CREATE",
18
+ "MERGE",
19
+ "REPLACE",
20
+ "GRANT",
21
+ "REVOKE",
22
+ "CALL",
23
+ "COPY",
24
+ "VACUUM",
25
+ "COMMENT",
26
+ ];
27
+
28
+ const TOOL_DEFINITIONS = [
29
+ {
30
+ name: "list_connections",
31
+ description: "List DeepSQL database connections available to this user.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {},
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ {
39
+ name: "get_schema",
40
+ description: "Fetch cached schema metadata for a DeepSQL connection.",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ connectionId: {
45
+ type: "string",
46
+ description: "DeepSQL connection ID.",
47
+ },
48
+ },
49
+ required: ["connectionId"],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ {
54
+ name: "get_database_objects",
55
+ description: "Fetch tables, views, functions, and procedures for a DeepSQL connection.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ connectionId: {
60
+ type: "string",
61
+ description: "DeepSQL connection ID.",
62
+ },
63
+ },
64
+ required: ["connectionId"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ {
69
+ name: "answer_question",
70
+ description: "Ask DeepSQL a natural-language database question using the full chat pipeline.",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ connectionId: {
75
+ type: "string",
76
+ description: "DeepSQL connection ID.",
77
+ },
78
+ question: {
79
+ type: "string",
80
+ description: "Natural-language database question.",
81
+ },
82
+ chatId: {
83
+ type: "string",
84
+ description: "Optional DeepSQL chat ID for conversation continuity.",
85
+ },
86
+ userId: {
87
+ type: "string",
88
+ description: "Optional user ID for attribution in feedback/history.",
89
+ },
90
+ },
91
+ required: ["connectionId", "question"],
92
+ additionalProperties: false,
93
+ },
94
+ },
95
+ {
96
+ name: "execute_readonly_sql",
97
+ description: "Execute a read-only SQL query through DeepSQL with client-side and backend safety checks.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ connectionId: {
102
+ type: "string",
103
+ description: "DeepSQL connection ID.",
104
+ },
105
+ query: {
106
+ type: "string",
107
+ description: "Read-only SQL query. Multi-statement SQL is rejected in phase 1.",
108
+ },
109
+ limit: {
110
+ type: "integer",
111
+ minimum: 1,
112
+ maximum: 1000,
113
+ description: "Optional row limit override. Defaults to 100.",
114
+ },
115
+ timeoutSeconds: {
116
+ type: "integer",
117
+ minimum: 1,
118
+ maximum: 60,
119
+ description: "Optional per-query timeout override. Defaults to backend default.",
120
+ },
121
+ },
122
+ required: ["connectionId", "query"],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ {
127
+ name: "explain_readonly_sql",
128
+ description: "Run EXPLAIN (without ANALYZE) for a read-only SQL query through DeepSQL.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ connectionId: {
133
+ type: "string",
134
+ description: "DeepSQL connection ID.",
135
+ },
136
+ query: {
137
+ type: "string",
138
+ description: "Read-only SQL query to explain. Do not include EXPLAIN or EXPLAIN ANALYZE.",
139
+ },
140
+ },
141
+ required: ["connectionId", "query"],
142
+ additionalProperties: false,
143
+ },
144
+ },
145
+ ];
146
+
147
+ class DeepSqlApiError extends Error {
148
+ constructor(message, status, payload) {
149
+ super(message);
150
+ this.name = "DeepSqlApiError";
151
+ this.status = status;
152
+ this.payload = payload;
153
+ }
154
+ }
155
+
156
+ function compactWhitespace(value) {
157
+ return String(value || "")
158
+ .replace(/\s+/g, " ")
159
+ .trim();
160
+ }
161
+
162
+ function stripSqlComments(sql) {
163
+ return String(sql || "")
164
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
165
+ .replace(/--.*$/gm, " ");
166
+ }
167
+
168
+ function stripSqlStringLiterals(sql) {
169
+ return String(sql || "")
170
+ .replace(/'(?:''|[^'])*'/g, "''")
171
+ .replace(/"(?:[""]|[^"])*"/g, '""')
172
+ .replace(/`(?:``|[^`])*`/g, "``");
173
+ }
174
+
175
+ function normalizeSqlForInspection(sql) {
176
+ return compactWhitespace(stripSqlStringLiterals(stripSqlComments(sql)));
177
+ }
178
+
179
+ function firstKeyword(sql) {
180
+ const match = normalizeSqlForInspection(sql).match(/^([A-Za-z]+)/);
181
+ return match ? match[1].toUpperCase() : null;
182
+ }
183
+
184
+ function splitStatements(sql) {
185
+ const normalized = normalizeSqlForInspection(sql);
186
+ return normalized
187
+ .split(";")
188
+ .map((part) => part.trim())
189
+ .filter(Boolean);
190
+ }
191
+
192
+ function stripTrailingSemicolons(sql) {
193
+ return String(sql || "")
194
+ .trim()
195
+ .replace(/;+\s*$/, "");
196
+ }
197
+
198
+ function containsForbiddenKeyword(sql) {
199
+ const normalized = normalizeSqlForInspection(sql);
200
+ return FORBIDDEN_SQL_KEYWORDS.find((keyword) =>
201
+ new RegExp(`\\b${keyword}\\b`, "i").test(normalized),
202
+ );
203
+ }
204
+
205
+ function validateReadOnlySql(sql, { allowExplain = true } = {}) {
206
+ if (!sql || !String(sql).trim()) {
207
+ return {
208
+ ok: false,
209
+ reason: "Query is required.",
210
+ };
211
+ }
212
+
213
+ const statements = splitStatements(sql);
214
+ if (statements.length !== 1) {
215
+ return {
216
+ ok: false,
217
+ reason: "Phase 1 MCP only allows a single SQL statement.",
218
+ };
219
+ }
220
+
221
+ const statement = statements[0];
222
+ const keyword = firstKeyword(statement);
223
+ if (!keyword || !ALLOWED_READ_ONLY_KEYWORDS.has(keyword)) {
224
+ return {
225
+ ok: false,
226
+ reason: "Only read-only SQL is allowed (SELECT, WITH, SHOW, DESCRIBE, DESC, EXPLAIN).",
227
+ };
228
+ }
229
+
230
+ if (!allowExplain && keyword === "EXPLAIN") {
231
+ return {
232
+ ok: false,
233
+ reason: "Pass the underlying SELECT/WITH query, not EXPLAIN itself.",
234
+ };
235
+ }
236
+
237
+ if (keyword === "EXPLAIN" && /\bANALYZ[EA]\b/i.test(statement)) {
238
+ return {
239
+ ok: false,
240
+ reason: "EXPLAIN ANALYZE is blocked in phase 1 MCP because it executes the query.",
241
+ };
242
+ }
243
+
244
+ const forbiddenKeyword = containsForbiddenKeyword(statement);
245
+ if (forbiddenKeyword) {
246
+ return {
247
+ ok: false,
248
+ reason: `Blocked potentially mutating SQL keyword: ${forbiddenKeyword}.`,
249
+ };
250
+ }
251
+
252
+ return {
253
+ ok: true,
254
+ normalizedQuery: stripTrailingSemicolons(sql),
255
+ firstKeyword: keyword,
256
+ };
257
+ }
258
+
259
+ function clampInteger(value, min, max, fallback) {
260
+ if (value == null) {
261
+ return fallback;
262
+ }
263
+
264
+ const parsed = Number.parseInt(value, 10);
265
+ if (!Number.isFinite(parsed)) {
266
+ return fallback;
267
+ }
268
+
269
+ return Math.min(max, Math.max(min, parsed));
270
+ }
271
+
272
+ function buildHeaders(config, extraHeaders = {}) {
273
+ const headers = {
274
+ Accept: "application/json",
275
+ ...extraHeaders,
276
+ };
277
+
278
+ if (config.authToken) {
279
+ headers.Authorization = `Bearer ${config.authToken}`;
280
+ }
281
+
282
+ return headers;
283
+ }
284
+
285
+ function resolveApiUrl(baseUrl, path) {
286
+ const normalizedPath = String(path || "").replace(/^\/+/, "");
287
+ return new URL(normalizedPath, baseUrl).toString();
288
+ }
289
+
290
+ async function callDeepSqlApi(config, path, { method = "GET", json, headers } = {}) {
291
+ const controller = new AbortController();
292
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
293
+ const url = resolveApiUrl(config.baseUrl, path);
294
+
295
+ try {
296
+ const response = await fetch(url, {
297
+ method,
298
+ headers: buildHeaders(
299
+ config,
300
+ json == null
301
+ ? headers
302
+ : {
303
+ "Content-Type": "application/json",
304
+ ...headers,
305
+ },
306
+ ),
307
+ body: json == null ? undefined : JSON.stringify(json),
308
+ signal: controller.signal,
309
+ });
310
+
311
+ const rawBody = await response.text();
312
+ let payload = null;
313
+ if (rawBody) {
314
+ try {
315
+ payload = JSON.parse(rawBody);
316
+ } catch {
317
+ payload = rawBody;
318
+ }
319
+ }
320
+
321
+ if (!response.ok) {
322
+ const message =
323
+ (payload && typeof payload === "object" && payload.message) ||
324
+ response.statusText ||
325
+ "DeepSQL API request failed";
326
+ throw new DeepSqlApiError(message, response.status, payload);
327
+ }
328
+
329
+ return payload;
330
+ } catch (error) {
331
+ if (error.name === "AbortError") {
332
+ throw new DeepSqlApiError(
333
+ `DeepSQL API request timed out after ${config.timeoutMs}ms.`,
334
+ 408,
335
+ );
336
+ }
337
+
338
+ throw error;
339
+ } finally {
340
+ clearTimeout(timeout);
341
+ }
342
+ }
343
+
344
+ function summarizeConnections(connections) {
345
+ const lines = connections.map((connection) => {
346
+ const name = connection.connectionName || connection.name || connection.id;
347
+ const type = connection.dbType || "unknown";
348
+ return `- ${name} (${type}) — ${connection.id}`;
349
+ });
350
+
351
+ return lines.length
352
+ ? `Found ${connections.length} connection(s):\n${lines.join("\n")}`
353
+ : "No connections were returned by DeepSQL.";
354
+ }
355
+
356
+ function summarizeSchema(payload) {
357
+ const schema = payload?.schema || payload;
358
+ const tableCount = schema?.totalTables ?? schema?.tables?.length ?? 0;
359
+ const viewCount = schema?.totalViews ?? 0;
360
+ const databaseName = schema?.databaseName || "unknown";
361
+ const dbType = schema?.dbType || "unknown";
362
+
363
+ return `Schema for ${databaseName} (${dbType}) with ${tableCount} tables and ${viewCount} views.`;
364
+ }
365
+
366
+ function summarizeObjects(payload) {
367
+ const objects = payload?.objects || [];
368
+ const preview = objects
369
+ .slice(0, 10)
370
+ .map((object) => `${object.type}:${object.name}`)
371
+ .join(", ");
372
+ return preview
373
+ ? `Fetched ${objects.length} database object(s). Preview: ${preview}`
374
+ : "No database objects were returned.";
375
+ }
376
+
377
+ function summarizeChat(payload) {
378
+ const lines = [];
379
+ if (payload?.chatId) {
380
+ lines.push(`chatId: ${payload.chatId}`);
381
+ }
382
+ if (payload?.sql) {
383
+ lines.push(`sql: ${compactWhitespace(payload.sql)}`);
384
+ }
385
+ if (payload?.message) {
386
+ lines.push(`answer: ${payload.message}`);
387
+ }
388
+ return lines.join("\n");
389
+ }
390
+
391
+ function summarizeQueryResult(payload) {
392
+ const result = payload?.result || payload?.data || payload;
393
+ const rowCount = result?.rowCount ?? 0;
394
+ const columns = Array.isArray(result?.columns) ? result.columns.join(", ") : "";
395
+ const limited = result?.isLimited ? " (limited)" : "";
396
+ return `Query returned ${rowCount} row(s)${limited}${columns ? ` with columns: ${columns}` : ""}.`;
397
+ }
398
+
399
+ function summarizeExplain(payload) {
400
+ const summaryStats = payload?.summaryStats;
401
+ if (summaryStats) {
402
+ return `EXPLAIN completed. Summary: ${summaryStats}`;
403
+ }
404
+ const planType = payload?.queryType || "query";
405
+ return `EXPLAIN completed for ${planType}.`;
406
+ }
407
+
408
+ function buildToolResult(name, payload) {
409
+ let summary;
410
+
411
+ switch (name) {
412
+ case "list_connections":
413
+ summary = summarizeConnections(payload);
414
+ break;
415
+ case "get_schema":
416
+ summary = summarizeSchema(payload);
417
+ break;
418
+ case "get_database_objects":
419
+ summary = summarizeObjects(payload);
420
+ break;
421
+ case "answer_question":
422
+ summary = summarizeChat(payload);
423
+ break;
424
+ case "execute_readonly_sql":
425
+ summary = summarizeQueryResult(payload);
426
+ break;
427
+ case "explain_readonly_sql":
428
+ summary = summarizeExplain(payload);
429
+ break;
430
+ default:
431
+ summary = JSON.stringify(payload, null, 2);
432
+ break;
433
+ }
434
+
435
+ return {
436
+ content: [
437
+ {
438
+ type: "text",
439
+ text: summary,
440
+ },
441
+ ],
442
+ structuredContent: payload,
443
+ };
444
+ }
445
+
446
+ function buildToolError(message, extra = {}) {
447
+ return {
448
+ content: [
449
+ {
450
+ type: "text",
451
+ text: message,
452
+ },
453
+ ],
454
+ structuredContent: {
455
+ error: message,
456
+ ...extra,
457
+ },
458
+ isError: true,
459
+ };
460
+ }
461
+
462
+ async function handleToolCall(config, name, args = {}) {
463
+ switch (name) {
464
+ case "list_connections": {
465
+ const payload = await callDeepSqlApi(config, "/connections");
466
+ return buildToolResult(name, payload);
467
+ }
468
+
469
+ case "get_schema": {
470
+ const connectionId = String(args.connectionId || "").trim();
471
+ const payload = await callDeepSqlApi(
472
+ config,
473
+ `/connections/${encodeURIComponent(connectionId)}/schema`,
474
+ );
475
+ return buildToolResult(name, payload);
476
+ }
477
+
478
+ case "get_database_objects": {
479
+ const connectionId = String(args.connectionId || "").trim();
480
+ const payload = await callDeepSqlApi(
481
+ config,
482
+ `/connections/${encodeURIComponent(connectionId)}/objects`,
483
+ );
484
+ return buildToolResult(name, payload);
485
+ }
486
+
487
+ case "answer_question": {
488
+ const connectionId = String(args.connectionId || "").trim();
489
+ const question = String(args.question || "").trim();
490
+ const chatId = args.chatId ? String(args.chatId) : null;
491
+ const userId = args.userId ? String(args.userId) : config.defaultUserId;
492
+
493
+ const payload = await callDeepSqlApi(config, "/chat", {
494
+ method: "POST",
495
+ json: {
496
+ connectionId,
497
+ message: question,
498
+ chatId,
499
+ userId,
500
+ projectId: config.defaultProjectId,
501
+ },
502
+ });
503
+
504
+ return buildToolResult(name, payload);
505
+ }
506
+
507
+ case "execute_readonly_sql": {
508
+ const connectionId = String(args.connectionId || "").trim();
509
+ const validation = validateReadOnlySql(args.query, { allowExplain: true });
510
+ if (!validation.ok) {
511
+ return buildToolError(validation.reason);
512
+ }
513
+
514
+ const payload = await callDeepSqlApi(
515
+ config,
516
+ "/mcp/query-readonly",
517
+ {
518
+ method: "POST",
519
+ json: {
520
+ connectionId,
521
+ query: validation.normalizedQuery,
522
+ limit: clampInteger(args.limit, 1, 1000, 100),
523
+ timeoutSeconds: clampInteger(args.timeoutSeconds, 1, 60, null),
524
+ },
525
+ },
526
+ );
527
+
528
+ return buildToolResult(name, payload);
529
+ }
530
+
531
+ case "explain_readonly_sql": {
532
+ const connectionId = String(args.connectionId || "").trim();
533
+ const validation = validateReadOnlySql(args.query, { allowExplain: false });
534
+ if (!validation.ok) {
535
+ return buildToolError(validation.reason);
536
+ }
537
+
538
+ const payload = await callDeepSqlApi(config, "/mcp/explain-readonly", {
539
+ method: "POST",
540
+ json: {
541
+ connectionId,
542
+ query: validation.normalizedQuery,
543
+ },
544
+ });
545
+
546
+ return buildToolResult(name, payload);
547
+ }
548
+
549
+ default:
550
+ return buildToolError(`Unknown tool: ${name}`);
551
+ }
552
+ }
553
+
554
+ function createConfigFromEnv(env = process.env) {
555
+ const rawBaseUrl = env.DEEPSQL_API_BASE_URL || "http://localhost:8080/api/";
556
+ const baseUrl = rawBaseUrl.endsWith("/") ? rawBaseUrl : `${rawBaseUrl}/`;
557
+
558
+ return {
559
+ baseUrl,
560
+ authToken: env.DEEPSQL_AUTH_TOKEN || "",
561
+ timeoutMs: clampInteger(env.DEEPSQL_MCP_TIMEOUT_MS, 1000, 600000, 120000),
562
+ defaultUserId: env.DEEPSQL_MCP_USER_ID || "mcp-phase1",
563
+ defaultProjectId: env.DEEPSQL_MCP_PROJECT_ID || "mcp-phase1",
564
+ };
565
+ }
566
+
567
+ module.exports = {
568
+ DeepSqlApiError,
569
+ TOOL_DEFINITIONS,
570
+ buildToolError,
571
+ buildToolResult,
572
+ callDeepSqlApi,
573
+ clampInteger,
574
+ compactWhitespace,
575
+ containsForbiddenKeyword,
576
+ createConfigFromEnv,
577
+ firstKeyword,
578
+ handleToolCall,
579
+ normalizeSqlForInspection,
580
+ resolveApiUrl,
581
+ splitStatements,
582
+ stripTrailingSemicolons,
583
+ stripSqlComments,
584
+ stripSqlStringLiterals,
585
+ validateReadOnlySql,
586
+ };