sqlite-hub 1.5.0 → 2.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 +5 -1
- package/bin/sqlite-hub-mcp.js +8 -0
- package/docs/MCP.md +109 -0
- package/docs/changelog.md +5 -0
- package/docs/todo.md +0 -1
- package/frontend/js/api.js +4 -0
- package/frontend/js/app.js +10 -0
- package/frontend/js/store.js +35 -7
- package/frontend/js/views/settings.js +189 -3
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +23 -0
- package/package.json +4 -2
- package/server/mcp/httpRouter.js +95 -0
- package/server/mcp/stdioServer.js +272 -0
- package/server/routes/settings.js +69 -0
- package/server/server.js +15 -0
- package/server/services/databaseCommandService.js +239 -2
- package/server/services/mcpStatusService.js +140 -0
- package/server/services/mcpToolService.js +274 -0
- package/server/services/storage/appStateStore.js +48 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { AppStateStore } = require("../services/storage/appStateStore");
|
|
3
|
+
const { DatabaseCommandService } = require("../services/databaseCommandService");
|
|
4
|
+
const { McpStatusService } = require("../services/mcpStatusService");
|
|
5
|
+
const { MCP_TOOL_DEFINITIONS, McpToolService } = require("../services/mcpToolService");
|
|
6
|
+
const { resolveAppStatePaths } = require("../utils/appPaths");
|
|
7
|
+
|
|
8
|
+
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
9
|
+
|
|
10
|
+
function createAppStateStore() {
|
|
11
|
+
const packageRoot = path.resolve(__dirname, "../..");
|
|
12
|
+
const {
|
|
13
|
+
appStateDbPath,
|
|
14
|
+
legacyStatePath,
|
|
15
|
+
legacyDatabasePaths,
|
|
16
|
+
} = resolveAppStatePaths(packageRoot);
|
|
17
|
+
|
|
18
|
+
return new AppStateStore(appStateDbPath, {
|
|
19
|
+
legacyFilePath: legacyStatePath,
|
|
20
|
+
legacyDatabasePaths,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createMcpServices({ appStateStore = createAppStateStore(), transport = "stdio" } = {}) {
|
|
25
|
+
const databaseService = new DatabaseCommandService({ appStateStore });
|
|
26
|
+
const statusService = new McpStatusService({
|
|
27
|
+
appStateStore,
|
|
28
|
+
exposedTools: MCP_TOOL_DEFINITIONS,
|
|
29
|
+
transport,
|
|
30
|
+
});
|
|
31
|
+
const toolService = new McpToolService({
|
|
32
|
+
databaseService,
|
|
33
|
+
statusService,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
appStateStore,
|
|
38
|
+
databaseService,
|
|
39
|
+
statusService,
|
|
40
|
+
toolService,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createJsonRpcResult(id, result) {
|
|
45
|
+
return {
|
|
46
|
+
jsonrpc: "2.0",
|
|
47
|
+
id,
|
|
48
|
+
result,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createJsonRpcError(id, error) {
|
|
53
|
+
const code = error?.code === "MCP_TOOL_NOT_FOUND" ? -32601 : -32603;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id: id ?? null,
|
|
58
|
+
error: {
|
|
59
|
+
code,
|
|
60
|
+
message: error?.message ?? "MCP request failed.",
|
|
61
|
+
data: {
|
|
62
|
+
code: error?.code ?? error?.name ?? "MCP_ERROR",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleMcpRequest(message, services) {
|
|
69
|
+
const { id, method, params = {} } = message ?? {};
|
|
70
|
+
|
|
71
|
+
if (!method) {
|
|
72
|
+
throw new Error("MCP JSON-RPC method is required.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (method === "initialize") {
|
|
76
|
+
services.statusService.markConnected();
|
|
77
|
+
|
|
78
|
+
return createJsonRpcResult(id, {
|
|
79
|
+
protocolVersion: params.protocolVersion || MCP_PROTOCOL_VERSION,
|
|
80
|
+
capabilities: {
|
|
81
|
+
tools: {},
|
|
82
|
+
},
|
|
83
|
+
serverInfo: {
|
|
84
|
+
name: "sqlite-hub",
|
|
85
|
+
version: require("../../package.json").version,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (method === "notifications/initialized") {
|
|
91
|
+
services.statusService.markConnected();
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (method === "ping") {
|
|
96
|
+
return createJsonRpcResult(id, {});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (method === "tools/list") {
|
|
100
|
+
return createJsonRpcResult(id, {
|
|
101
|
+
tools: services.toolService.listTools(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (method === "tools/call") {
|
|
106
|
+
const result = await services.toolService.callTool(params.name, params.arguments ?? {});
|
|
107
|
+
|
|
108
|
+
return createJsonRpcResult(id, {
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: "text",
|
|
112
|
+
text: JSON.stringify(result, null, 2),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
structuredContent: result,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (method === "shutdown") {
|
|
120
|
+
services.statusService.markStopped();
|
|
121
|
+
return createJsonRpcResult(id, {});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const error = new Error(`Unsupported MCP method: ${method}`);
|
|
125
|
+
error.code = "MCP_METHOD_NOT_FOUND";
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function encodeMessage(message) {
|
|
130
|
+
const json = JSON.stringify(message);
|
|
131
|
+
return `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function tryReadFramedMessage(buffer) {
|
|
135
|
+
const headerSeparator = buffer.indexOf("\r\n\r\n");
|
|
136
|
+
|
|
137
|
+
if (headerSeparator === -1) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const header = buffer.slice(0, headerSeparator).toString("ascii");
|
|
142
|
+
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
|
143
|
+
|
|
144
|
+
if (!lengthMatch) {
|
|
145
|
+
throw new Error("MCP stdio message is missing Content-Length.");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const contentLength = Number(lengthMatch[1]);
|
|
149
|
+
const bodyStart = headerSeparator + 4;
|
|
150
|
+
const bodyEnd = bodyStart + contentLength;
|
|
151
|
+
|
|
152
|
+
if (buffer.length < bodyEnd) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
message: JSON.parse(body),
|
|
160
|
+
rest: buffer.slice(bodyEnd),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tryReadLineMessage(buffer) {
|
|
165
|
+
if (/^Content-Length:/i.test(buffer.slice(0, Math.min(buffer.length, 32)).toString("ascii"))) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
170
|
+
|
|
171
|
+
if (newlineIndex === -1) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const line = buffer.slice(0, newlineIndex).toString("utf8").trim();
|
|
176
|
+
|
|
177
|
+
if (!line) {
|
|
178
|
+
return {
|
|
179
|
+
message: null,
|
|
180
|
+
rest: buffer.slice(newlineIndex + 1),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
message: JSON.parse(line),
|
|
186
|
+
rest: buffer.slice(newlineIndex + 1),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function startMcpStdioServer({ input = process.stdin, output = process.stdout, services = createMcpServices() } = {}) {
|
|
191
|
+
let buffer = Buffer.alloc(0);
|
|
192
|
+
let stopped = false;
|
|
193
|
+
|
|
194
|
+
services.statusService.markServerRunning();
|
|
195
|
+
|
|
196
|
+
async function processMessage(message) {
|
|
197
|
+
if (!message) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const response = await handleMcpRequest(message, services);
|
|
203
|
+
|
|
204
|
+
if (response && message.id !== undefined) {
|
|
205
|
+
output.write(encodeMessage(response));
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
services.statusService.markError(error);
|
|
209
|
+
|
|
210
|
+
if (message.id !== undefined) {
|
|
211
|
+
output.write(encodeMessage(createJsonRpcError(message.id, error)));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function drainBuffer() {
|
|
217
|
+
while (buffer.length) {
|
|
218
|
+
const framed = tryReadFramedMessage(buffer) ?? tryReadLineMessage(buffer);
|
|
219
|
+
|
|
220
|
+
if (!framed) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
buffer = framed.rest;
|
|
225
|
+
await processMessage(framed.message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
input.on("data", (chunk) => {
|
|
230
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
231
|
+
drainBuffer().catch((error) => {
|
|
232
|
+
services.statusService.markError(error);
|
|
233
|
+
process.stderr.write(`SQLite Hub MCP error: ${error.message}\n`);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
function stop() {
|
|
238
|
+
if (stopped) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stopped = true;
|
|
243
|
+
services.statusService.markStopped();
|
|
244
|
+
services.appStateStore?.db?.close?.();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
input.on("end", stop);
|
|
248
|
+
process.once("SIGINT", () => {
|
|
249
|
+
stop();
|
|
250
|
+
process.exit(0);
|
|
251
|
+
});
|
|
252
|
+
process.once("SIGTERM", () => {
|
|
253
|
+
stop();
|
|
254
|
+
process.exit(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
services,
|
|
259
|
+
stop,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
MCP_PROTOCOL_VERSION,
|
|
265
|
+
createAppStateStore,
|
|
266
|
+
createMcpServices,
|
|
267
|
+
createJsonRpcError,
|
|
268
|
+
createJsonRpcResult,
|
|
269
|
+
encodeMessage,
|
|
270
|
+
handleMcpRequest,
|
|
271
|
+
startMcpStdioServer,
|
|
272
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const express = require("express");
|
|
2
|
+
const path = require("node:path");
|
|
2
3
|
const { AppError, DatabaseRequiredError, route, successResponse } = require("../utils/errors");
|
|
3
4
|
const {
|
|
4
5
|
checkLatestAppVersion,
|
|
@@ -7,6 +8,8 @@ const {
|
|
|
7
8
|
readSettingsMetadata,
|
|
8
9
|
readSqliteVersion,
|
|
9
10
|
} = require("../services/appInfoService");
|
|
11
|
+
const { McpStatusService } = require("../services/mcpStatusService");
|
|
12
|
+
const { MCP_TOOL_DEFINITIONS } = require("../services/mcpToolService");
|
|
10
13
|
|
|
11
14
|
function getActiveTokenContext({ connectionManager, tokenService }) {
|
|
12
15
|
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
@@ -34,6 +37,56 @@ function buildSettingsMetadata(context) {
|
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
function buildMcpHttpUrl(req) {
|
|
41
|
+
const host = String(req?.get?.("host") ?? "127.0.0.1:4173").trim() || "127.0.0.1:4173";
|
|
42
|
+
return `http://${host}/mcp`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildMcpCodexConfig({ url = null, commandPath = null } = {}) {
|
|
46
|
+
if (url) {
|
|
47
|
+
return [
|
|
48
|
+
"[mcp_servers.sqlitehub]",
|
|
49
|
+
`url = ${JSON.stringify(url)}`,
|
|
50
|
+
"startup_timeout_sec = 10",
|
|
51
|
+
"tool_timeout_sec = 60",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const serverPath = commandPath ?? path.resolve(__dirname, "../../bin/sqlite-hub-mcp.js");
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
"[mcp_servers.sqlitehub]",
|
|
59
|
+
'command = "node"',
|
|
60
|
+
`args = [${JSON.stringify(serverPath)}]`,
|
|
61
|
+
"startup_timeout_sec = 10",
|
|
62
|
+
"tool_timeout_sec = 60",
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildMcpSettingsStatus(appStateStore, options = {}) {
|
|
67
|
+
const httpUrl = options.httpUrl ?? null;
|
|
68
|
+
const stdioCommandPath = path.resolve(__dirname, "../../bin/sqlite-hub-mcp.js");
|
|
69
|
+
const statusService = new McpStatusService({
|
|
70
|
+
appStateStore,
|
|
71
|
+
exposedTools: MCP_TOOL_DEFINITIONS,
|
|
72
|
+
transport: httpUrl ? "http" : "stdio",
|
|
73
|
+
});
|
|
74
|
+
const status = statusService.getStatus();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...status,
|
|
78
|
+
toolDetails: MCP_TOOL_DEFINITIONS.map((tool) => ({
|
|
79
|
+
name: tool.name,
|
|
80
|
+
description: tool.description,
|
|
81
|
+
})),
|
|
82
|
+
command: httpUrl ?? `node ${stdioCommandPath}`,
|
|
83
|
+
codexConfig: buildMcpCodexConfig({ url: httpUrl, commandPath: stdioCommandPath }),
|
|
84
|
+
httpUrl,
|
|
85
|
+
stdioCommand: `node ${stdioCommandPath}`,
|
|
86
|
+
stdioCodexConfig: buildMcpCodexConfig({ commandPath: stdioCommandPath }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
37
90
|
function createSettingsRouter({ appStateStore, connectionManager, tokenService, versionCheckService }) {
|
|
38
91
|
const router = express.Router();
|
|
39
92
|
const context = { connectionManager, tokenService };
|
|
@@ -89,6 +142,19 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
|
|
|
89
142
|
})
|
|
90
143
|
);
|
|
91
144
|
|
|
145
|
+
router.get(
|
|
146
|
+
"/mcp",
|
|
147
|
+
route((req, res) => {
|
|
148
|
+
res.json(
|
|
149
|
+
successResponse({
|
|
150
|
+
data: buildMcpSettingsStatus(appStateStore, {
|
|
151
|
+
httpUrl: buildMcpHttpUrl(req),
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
|
|
92
158
|
router.post(
|
|
93
159
|
"/api-tokens",
|
|
94
160
|
route((req, res) => {
|
|
@@ -126,6 +192,9 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
|
|
|
126
192
|
|
|
127
193
|
module.exports = {
|
|
128
194
|
createSettingsRouter,
|
|
195
|
+
buildMcpCodexConfig,
|
|
196
|
+
buildMcpHttpUrl,
|
|
197
|
+
buildMcpSettingsStatus,
|
|
129
198
|
buildSettingsMetadata,
|
|
130
199
|
checkLatestAppVersion,
|
|
131
200
|
compareSemver,
|
package/server/server.js
CHANGED
|
@@ -23,6 +23,8 @@ const { TableDesignerService } = require("./services/sqlite/tableDesignerService
|
|
|
23
23
|
const { MediaTaggingService } = require("./services/sqlite/mediaTaggingService");
|
|
24
24
|
const { ApiTokenService } = require("./services/apiTokenService");
|
|
25
25
|
const { DatabaseCommandService } = require("./services/databaseCommandService");
|
|
26
|
+
const { createMcpServices } = require("./mcp/stdioServer");
|
|
27
|
+
const { createMcpHttpRouter } = require("./mcp/httpRouter");
|
|
26
28
|
const { createConnectionsRouter } = require("./routes/connections");
|
|
27
29
|
const { createBackupsRouter } = require("./routes/backups");
|
|
28
30
|
const { createOverviewRouter } = require("./routes/overview");
|
|
@@ -71,6 +73,7 @@ const tableDesignerService = new TableDesignerService({ connectionManager });
|
|
|
71
73
|
const mediaTaggingService = new MediaTaggingService({ connectionManager, appStateStore });
|
|
72
74
|
const apiTokenService = new ApiTokenService({ appStateStore });
|
|
73
75
|
const databaseCommandService = new DatabaseCommandService({ appStateStore });
|
|
76
|
+
const mcpServices = createMcpServices({ appStateStore, transport: "http" });
|
|
74
77
|
|
|
75
78
|
connectionManager.initialize();
|
|
76
79
|
|
|
@@ -91,6 +94,16 @@ app.use(
|
|
|
91
94
|
legacyHeaders: false,
|
|
92
95
|
})
|
|
93
96
|
);
|
|
97
|
+
app.use("/mcp", localRequestSecurity);
|
|
98
|
+
app.use(
|
|
99
|
+
"/mcp",
|
|
100
|
+
rateLimit({
|
|
101
|
+
windowMs: 60 * 1000,
|
|
102
|
+
max: 300,
|
|
103
|
+
standardHeaders: true,
|
|
104
|
+
legacyHeaders: false,
|
|
105
|
+
})
|
|
106
|
+
);
|
|
94
107
|
app.use(express.json({ limit: "10mb" }));
|
|
95
108
|
app.use(express.urlencoded({ extended: false }));
|
|
96
109
|
|
|
@@ -143,6 +156,7 @@ app.use(
|
|
|
143
156
|
appStateStore,
|
|
144
157
|
})
|
|
145
158
|
);
|
|
159
|
+
app.use("/mcp", createMcpHttpRouter({ services: mcpServices }));
|
|
146
160
|
|
|
147
161
|
// auth: public favicon response; it exposes no application data.
|
|
148
162
|
app.get("/favicon.ico", (req, res) => {
|
|
@@ -254,6 +268,7 @@ module.exports = {
|
|
|
254
268
|
apiTokenService,
|
|
255
269
|
connectionManager,
|
|
256
270
|
databaseCommandService,
|
|
271
|
+
mcpServices,
|
|
257
272
|
DEFAULT_HOST,
|
|
258
273
|
DEFAULT_PORT,
|
|
259
274
|
parsePortArgument,
|
|
@@ -4,9 +4,11 @@ const { ConnectionManager } = require("./sqlite/connectionManager");
|
|
|
4
4
|
const { BackupService } = require("./sqlite/backupService");
|
|
5
5
|
const { DataBrowserService } = require("./sqlite/dataBrowserService");
|
|
6
6
|
const { ExportService } = require("./sqlite/exportService");
|
|
7
|
-
const { getTableDetail } = require("./sqlite/introspection");
|
|
8
|
-
const {
|
|
7
|
+
const { getTableDetail, listSchema } = require("./sqlite/introspection");
|
|
8
|
+
const { OverviewService } = require("./sqlite/overviewService");
|
|
9
|
+
const { SqlExecutor, splitSqlStatements } = require("./sqlite/sqlExecutor");
|
|
9
10
|
const { TypeGenerationService } = require("./typeGenerationService");
|
|
11
|
+
const { detectQueryType } = require("./storage/queryHistoryUtils");
|
|
10
12
|
|
|
11
13
|
function normalizeLookupValue(value, label) {
|
|
12
14
|
const normalized = String(value ?? "").trim();
|
|
@@ -65,6 +67,99 @@ function normalizeMarkdownExportFilename(filename, fallback = "document.md") {
|
|
|
65
67
|
return normalizedFilename;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
function stripSqlTerminators(sql) {
|
|
71
|
+
return String(sql ?? "").trim().replace(/;+\s*$/g, "").trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeLeadingSqlComments(sql) {
|
|
75
|
+
let text = String(sql ?? "").trim();
|
|
76
|
+
let changed = true;
|
|
77
|
+
|
|
78
|
+
while (changed) {
|
|
79
|
+
changed = false;
|
|
80
|
+
const nextText = text
|
|
81
|
+
.replace(/^--[^\n]*(?:\n|$)/, "")
|
|
82
|
+
.replace(/^\/\*[\s\S]*?\*\//, "")
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
if (nextText !== text) {
|
|
86
|
+
changed = true;
|
|
87
|
+
text = nextText;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function explainInnerSql(statement) {
|
|
95
|
+
return removeLeadingSqlComments(statement)
|
|
96
|
+
.replace(/^EXPLAIN\s+QUERY\s+PLAN\s+/i, "")
|
|
97
|
+
.replace(/^EXPLAIN\s+/i, "")
|
|
98
|
+
.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isAllowedReadonlyStatement(statement) {
|
|
102
|
+
const text = removeLeadingSqlComments(statement);
|
|
103
|
+
const queryType = detectQueryType(text);
|
|
104
|
+
|
|
105
|
+
if (queryType === "select" || queryType === "pragma") {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (/^EXPLAIN\b/i.test(text)) {
|
|
110
|
+
const innerType = detectQueryType(explainInnerSql(text));
|
|
111
|
+
return innerType === "select" || innerType === "pragma";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertReadOnlySql(db, sql) {
|
|
118
|
+
const statements = splitSqlStatements(sql);
|
|
119
|
+
|
|
120
|
+
if (!statements.length) {
|
|
121
|
+
throw new ValidationError("No executable SQL statements were found.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
statements.forEach((statement, index) => {
|
|
125
|
+
if (!isAllowedReadonlyStatement(statement)) {
|
|
126
|
+
throw new ValidationError(
|
|
127
|
+
`Statement ${index + 1} is not allowed for read-only MCP queries. Only SELECT, PRAGMA, and EXPLAIN statements are allowed.`,
|
|
128
|
+
{ code: "MCP_READONLY_SQL_REQUIRED" }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const prepared = db.prepare(statement);
|
|
133
|
+
|
|
134
|
+
if (!prepared.reader) {
|
|
135
|
+
throw new ValidationError(
|
|
136
|
+
`Statement ${index + 1} does not return rows and is blocked by the read-only query guard.`,
|
|
137
|
+
{ code: "MCP_READONLY_SQL_REQUIRED" }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return statements;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildExplainQueryPlanSql(sql) {
|
|
146
|
+
const stripped = stripSqlTerminators(sql);
|
|
147
|
+
|
|
148
|
+
if (/^EXPLAIN\s+QUERY\s+PLAN\b/i.test(removeLeadingSqlComments(stripped))) {
|
|
149
|
+
return stripped;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return `EXPLAIN QUERY PLAN ${stripped}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildQueryPlanHints(rows = []) {
|
|
156
|
+
return rows
|
|
157
|
+
.map((row) => String(row.detail ?? row["QUERY PLAN"] ?? "").trim())
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.filter((detail) => /\bSCAN\b/i.test(detail) && !/\bUSING\s+(?:COVERING\s+)?INDEX\b/i.test(detail))
|
|
160
|
+
.map((detail) => `Review whether an index would help: ${detail}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
68
163
|
function coerceIdentityValue(column, value) {
|
|
69
164
|
const text = String(value ?? "");
|
|
70
165
|
const affinity = String(column?.affinity ?? "").toUpperCase();
|
|
@@ -216,6 +311,7 @@ class DatabaseCommandService {
|
|
|
216
311
|
connectionManager,
|
|
217
312
|
sqlExecutor,
|
|
218
313
|
}),
|
|
314
|
+
overviewService: new OverviewService({ connectionManager }),
|
|
219
315
|
sqlExecutor,
|
|
220
316
|
close() {
|
|
221
317
|
connectionManager.closeCurrent();
|
|
@@ -257,6 +353,10 @@ class DatabaseCommandService {
|
|
|
257
353
|
return this.withDatabase(databaseReference, ({ runtime }) => runtime.dataBrowserService.listTables());
|
|
258
354
|
}
|
|
259
355
|
|
|
356
|
+
getDatabaseOverview(databaseReference) {
|
|
357
|
+
return this.withDatabase(databaseReference, ({ runtime }) => runtime.overviewService.getOverview());
|
|
358
|
+
}
|
|
359
|
+
|
|
260
360
|
getTable(databaseReference, tableName) {
|
|
261
361
|
const normalizedTableName = normalizeLookupValue(tableName, "Table name");
|
|
262
362
|
return this.withDatabase(databaseReference, ({ runtime }) =>
|
|
@@ -264,6 +364,33 @@ class DatabaseCommandService {
|
|
|
264
364
|
);
|
|
265
365
|
}
|
|
266
366
|
|
|
367
|
+
getSchema(databaseReference) {
|
|
368
|
+
return this.withDatabase(databaseReference, ({ runtime }) => listSchema(runtime.db));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
getIndexes(databaseReference, tableName = null) {
|
|
372
|
+
const normalizedTableName = normalizeOptionalValue(tableName);
|
|
373
|
+
|
|
374
|
+
if (normalizedTableName) {
|
|
375
|
+
return this.getTable(databaseReference, normalizedTableName).indexes ?? [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return this.getSchema(databaseReference).indexes ?? [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
getForeignKeys(databaseReference, tableName = null) {
|
|
382
|
+
const normalizedTableName = normalizeOptionalValue(tableName);
|
|
383
|
+
|
|
384
|
+
if (normalizedTableName) {
|
|
385
|
+
return this.getTable(databaseReference, normalizedTableName).foreignKeys ?? [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return this.getSchema(databaseReference).tables.map((table) => ({
|
|
389
|
+
tableName: table.name,
|
|
390
|
+
foreignKeys: table.foreignKeys ?? [],
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
267
394
|
generateTableTypes(databaseReference, tableName, target, options = {}) {
|
|
268
395
|
const normalizedTableName = normalizeLookupValue(tableName, "Table name");
|
|
269
396
|
return this.withDatabase(databaseReference, ({ runtime }) =>
|
|
@@ -276,6 +403,98 @@ class DatabaseCommandService {
|
|
|
276
403
|
);
|
|
277
404
|
}
|
|
278
405
|
|
|
406
|
+
generateTypes(databaseReference, { tableName = null, allTables = false, target, options = {} } = {}) {
|
|
407
|
+
const normalizedTableName = normalizeOptionalValue(tableName);
|
|
408
|
+
|
|
409
|
+
return this.withDatabase(databaseReference, ({ runtime }) => {
|
|
410
|
+
const tables = allTables || !normalizedTableName
|
|
411
|
+
? runtime.dataBrowserService.listTables().map((table) => table.name)
|
|
412
|
+
: [normalizedTableName];
|
|
413
|
+
const files = tables.map((name) =>
|
|
414
|
+
this.typeGenerationService.generateTypesFromDatabase(runtime.db, name, target, options)
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
target: files[0]?.target ?? target,
|
|
419
|
+
files,
|
|
420
|
+
warnings: files.flatMap((file) =>
|
|
421
|
+
(file.warnings ?? []).map((warning) => `${file.tableName}: ${warning}`)
|
|
422
|
+
),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
executeReadOnlyQuery(databaseReference, sql, options = {}) {
|
|
428
|
+
return this.withDatabase(databaseReference, ({ runtime }) => {
|
|
429
|
+
const statements = assertReadOnlySql(runtime.db, sql);
|
|
430
|
+
const result = runtime.sqlExecutor.execute(sql, {
|
|
431
|
+
executedBy: options.executedBy ?? "mcp",
|
|
432
|
+
maxRows: options.maxRows ?? 500,
|
|
433
|
+
persistHistory: options.persistHistory,
|
|
434
|
+
requireReader: true,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
statementsValidated: statements.length,
|
|
439
|
+
result,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
explainQueryPlan(databaseReference, sql) {
|
|
445
|
+
return this.withDatabase(databaseReference, ({ runtime }) => {
|
|
446
|
+
assertReadOnlySql(runtime.db, sql);
|
|
447
|
+
const explainSql = buildExplainQueryPlanSql(sql);
|
|
448
|
+
const rows = runtime.db.prepare(explainSql).all();
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
sql: explainSql,
|
|
452
|
+
rows,
|
|
453
|
+
hints: buildQueryPlanHints(rows),
|
|
454
|
+
};
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
createChartFromQuery(databaseReference, { sql, name, chartType = "bar", config, resultColumns, tableVisible = true } = {}) {
|
|
459
|
+
const normalizedSql = normalizeLookupValue(sql, "SQL");
|
|
460
|
+
|
|
461
|
+
if (/^EXPLAIN\b/i.test(removeLeadingSqlComments(normalizedSql)) || detectQueryType(normalizedSql) !== "select") {
|
|
462
|
+
throw new ValidationError("Charts can only be created from read-only SELECT queries.", {
|
|
463
|
+
code: "CHART_SELECT_QUERY_REQUIRED",
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return this.withDatabase(databaseReference, ({ connection, runtime }) => {
|
|
468
|
+
assertReadOnlySql(runtime.db, normalizedSql);
|
|
469
|
+
const result = runtime.sqlExecutor.execute(normalizedSql, {
|
|
470
|
+
executedBy: "mcp",
|
|
471
|
+
maxRows: 500,
|
|
472
|
+
requireReader: true,
|
|
473
|
+
});
|
|
474
|
+
const chart = this.appStateStore.createQueryHistoryChart({
|
|
475
|
+
databaseKey: connection.id,
|
|
476
|
+
queryHistoryId: result.historyId,
|
|
477
|
+
name,
|
|
478
|
+
chartType,
|
|
479
|
+
config,
|
|
480
|
+
resultColumns: resultColumns ?? result.columns.map((column) => ({
|
|
481
|
+
name: column,
|
|
482
|
+
type: "unknown",
|
|
483
|
+
})),
|
|
484
|
+
tableVisible,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
chart,
|
|
489
|
+
queryHistoryId: result.historyId,
|
|
490
|
+
export: {
|
|
491
|
+
png: false,
|
|
492
|
+
message: "Charts are saved in SQLite Hub. PNG export is available from the Charts UI.",
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
279
498
|
getTableRow(databaseReference, tableName, exportTarget) {
|
|
280
499
|
const normalizedTableName = normalizeLookupValue(tableName, "Table name");
|
|
281
500
|
normalizeLookupValue(exportTarget, "Row key");
|
|
@@ -440,6 +659,22 @@ class DatabaseCommandService {
|
|
|
440
659
|
return this.appStateStore.listDatabaseDocuments(connection.id);
|
|
441
660
|
}
|
|
442
661
|
|
|
662
|
+
readDocuments(databaseReference, documentName = null) {
|
|
663
|
+
const normalizedDocumentName = normalizeOptionalValue(documentName);
|
|
664
|
+
|
|
665
|
+
if (normalizedDocumentName) {
|
|
666
|
+
return {
|
|
667
|
+
items: [this.getDocument(databaseReference, normalizedDocumentName)],
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
items: this.listDocuments(databaseReference).map((document) =>
|
|
673
|
+
this.appStateStore.getDatabaseDocument(this.getDatabase(databaseReference).id, document.id)
|
|
674
|
+
),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
443
678
|
findDocument(databaseReference, documentName) {
|
|
444
679
|
const connection = this.getDatabase(databaseReference);
|
|
445
680
|
const normalizedDocumentName = normalizeLookupValue(documentName, "Document name").toLowerCase();
|
|
@@ -494,8 +729,10 @@ class DatabaseCommandService {
|
|
|
494
729
|
|
|
495
730
|
module.exports = {
|
|
496
731
|
DatabaseCommandService,
|
|
732
|
+
assertReadOnlySql,
|
|
497
733
|
buildIdentityFromExportTarget,
|
|
498
734
|
buildRowJsonObject,
|
|
735
|
+
isAllowedReadonlyStatement,
|
|
499
736
|
getDocumentTitle,
|
|
500
737
|
getQueryTitle,
|
|
501
738
|
normalizeMarkdownExportFilename,
|