@nifrajs/mcp-db 1.5.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/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +306 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/mcp-db` — serve a SQLite database as an MCP server, fail-closed.
|
|
3
|
+
*
|
|
4
|
+
* Out of the box only SCHEMA tools are exposed (`list_tables`, `describe_table`), and only for
|
|
5
|
+
* tables on the explicit `tables` allowlist. Query execution is OPT-IN and requires BOTH the
|
|
6
|
+
* allowlist and an `authorize` hook — a database is PII by default, and an MCP client is an LLM;
|
|
7
|
+
* this package refuses to be mounted open rather than defaulting open.
|
|
8
|
+
*
|
|
9
|
+
* Read-only is enforced in layers, not promised: `PRAGMA query_only = ON` on the connection
|
|
10
|
+
* (SQLite rejects every write at the engine), a single-statement gate, a SELECT/WITH-only gate,
|
|
11
|
+
* and `EXPLAIN QUERY PLAN` verification that a query touches ONLY allowlisted tables. Results are
|
|
12
|
+
* capped by rows and bytes (an LLM does not need 100k rows; a transport does not want them) with
|
|
13
|
+
* an explicit `truncated` marker.
|
|
14
|
+
*
|
|
15
|
+
* Bun/Node SQLite only (anything `bun:sqlite`-shaped, bound structurally — no driver dependency).
|
|
16
|
+
* There is deliberately NO D1 mode: D1 has no `query_only`, so read-only cannot be guaranteed.
|
|
17
|
+
*
|
|
18
|
+
* import { Database } from "bun:sqlite"
|
|
19
|
+
* const mcp = serveDatabaseAsMcp(new Database("app.db"), {
|
|
20
|
+
* tables: ["habits", "entries"],
|
|
21
|
+
* runQuery: { authorize: (ctx) => ctx.request?.headers.get("x-api-key") === env.MCP_KEY },
|
|
22
|
+
* })
|
|
23
|
+
* app.mount("/mcp", mcp.fetch)
|
|
24
|
+
*/
|
|
25
|
+
import { type McpServer } from "@nifrajs/mcp";
|
|
26
|
+
/** The structural slice of `bun:sqlite`'s `Database` this package needs. */
|
|
27
|
+
export interface SqliteDatabaseLike {
|
|
28
|
+
/** Prepare a statement; `all` runs it and returns row objects. */
|
|
29
|
+
prepare(sql: string): {
|
|
30
|
+
all(...params: unknown[]): unknown[];
|
|
31
|
+
};
|
|
32
|
+
/** Execute a statement for its side effect (used only for `PRAGMA query_only`). */
|
|
33
|
+
run(sql: string): unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Context forwarded to `authorize` — the inbound HTTP Request carrying the `run_query` call. */
|
|
36
|
+
export interface McpDbAuthorizeContext {
|
|
37
|
+
readonly toolName: "run_query";
|
|
38
|
+
readonly request: Request;
|
|
39
|
+
}
|
|
40
|
+
export interface RunQueryOptions {
|
|
41
|
+
/**
|
|
42
|
+
* REQUIRED. Authorize each `run_query` call at the transport boundary (inspect the inbound
|
|
43
|
+
* Request's auth). Return false to reject with a JSON-RPC error. There is no unauthenticated
|
|
44
|
+
* mode — schema tools are the anonymous surface. Note: `run_query` is therefore HTTP-only;
|
|
45
|
+
* direct `handle()` dispatch rejects it (no Request to authorize — fails closed).
|
|
46
|
+
*/
|
|
47
|
+
readonly authorize: (context: McpDbAuthorizeContext) => boolean | Promise<boolean>;
|
|
48
|
+
/** Max rows returned per query (default 100). */
|
|
49
|
+
readonly maxRows?: number;
|
|
50
|
+
/** Max serialized result size in bytes (default 100 KB). */
|
|
51
|
+
readonly maxResultBytes?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface ServeDatabaseAsMcpOptions {
|
|
54
|
+
/**
|
|
55
|
+
* REQUIRED allowlist of tables the MCP surface may see. `list_tables`/`describe_table` are
|
|
56
|
+
* restricted to it and `run_query` plans are verified against it. Empty array → construction
|
|
57
|
+
* throws; expose nothing by accident, ever.
|
|
58
|
+
*/
|
|
59
|
+
readonly tables: readonly string[];
|
|
60
|
+
/** Enable the `run_query` tool. Omitted → schema tools only. */
|
|
61
|
+
readonly runQuery?: RunQueryOptions;
|
|
62
|
+
/** MCP server identity (defaults: name `nifra-db`, version `1.0.0`). */
|
|
63
|
+
readonly name?: string;
|
|
64
|
+
readonly version?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Set `PRAGMA query_only = ON` on the connection at construction (default true). Disable ONLY
|
|
67
|
+
* when the same connection must also serve writes elsewhere in the app — with it disabled,
|
|
68
|
+
* `run_query` still gates on SELECT/WITH + plan verification, but the engine-level guarantee is
|
|
69
|
+
* yours to provide (e.g. open a second, read-only connection for the MCP mount — preferred).
|
|
70
|
+
*/
|
|
71
|
+
readonly enforceQueryOnly?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export declare class McpDbConfigError extends Error {
|
|
74
|
+
constructor(message: string);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
|
|
78
|
+
* security model. Throws {@link McpDbConfigError} on any unsafe configuration — always at
|
|
79
|
+
* construction (boot), never at request time.
|
|
80
|
+
*/
|
|
81
|
+
export declare function serveDatabaseAsMcp(db: SqliteDatabaseLike, options: ServeDatabaseAsMcpOptions): McpServer;
|
|
82
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAwD,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAEnG,4EAA4E;AAC5E,MAAM,WAAW,kBAAkB;IACjC,kEAAkE;IAClE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG;QAAE,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAAA;KAAE,CAAA;IAC9D,mFAAmF;IACnF,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAC1B;AAED,iGAAiG;AACjG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAA;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAClF,iDAAiD;IACjD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CACjC;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAA;IACnC,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAA;CACpC;AAED,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAwBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,yBAAyB,GACjC,SAAS,CAgMX"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/mcp-db` — serve a SQLite database as an MCP server, fail-closed.
|
|
3
|
+
*
|
|
4
|
+
* Out of the box only SCHEMA tools are exposed (`list_tables`, `describe_table`), and only for
|
|
5
|
+
* tables on the explicit `tables` allowlist. Query execution is OPT-IN and requires BOTH the
|
|
6
|
+
* allowlist and an `authorize` hook — a database is PII by default, and an MCP client is an LLM;
|
|
7
|
+
* this package refuses to be mounted open rather than defaulting open.
|
|
8
|
+
*
|
|
9
|
+
* Read-only is enforced in layers, not promised: `PRAGMA query_only = ON` on the connection
|
|
10
|
+
* (SQLite rejects every write at the engine), a single-statement gate, a SELECT/WITH-only gate,
|
|
11
|
+
* and `EXPLAIN QUERY PLAN` verification that a query touches ONLY allowlisted tables. Results are
|
|
12
|
+
* capped by rows and bytes (an LLM does not need 100k rows; a transport does not want them) with
|
|
13
|
+
* an explicit `truncated` marker.
|
|
14
|
+
*
|
|
15
|
+
* Bun/Node SQLite only (anything `bun:sqlite`-shaped, bound structurally — no driver dependency).
|
|
16
|
+
* There is deliberately NO D1 mode: D1 has no `query_only`, so read-only cannot be guaranteed.
|
|
17
|
+
*
|
|
18
|
+
* import { Database } from "bun:sqlite"
|
|
19
|
+
* const mcp = serveDatabaseAsMcp(new Database("app.db"), {
|
|
20
|
+
* tables: ["habits", "entries"],
|
|
21
|
+
* runQuery: { authorize: (ctx) => ctx.request?.headers.get("x-api-key") === env.MCP_KEY },
|
|
22
|
+
* })
|
|
23
|
+
* app.mount("/mcp", mcp.fetch)
|
|
24
|
+
*/
|
|
25
|
+
import { createMcpServer, defineMcpTool } from "@nifrajs/mcp";
|
|
26
|
+
export class McpDbConfigError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(`@nifrajs/mcp-db: ${message}`);
|
|
29
|
+
this.name = "McpDbConfigError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A safe SQL identifier — quoting is not enough for PRAGMA args, so reject instead. */
|
|
33
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
34
|
+
const stripSqlNoise = (sql) => sql
|
|
35
|
+
// Strings first so comment markers inside literals don't count.
|
|
36
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
37
|
+
.replace(/"(?:[^"]|"")*"/g, '""')
|
|
38
|
+
.replace(/--[^\n]*/g, " ")
|
|
39
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
40
|
+
/** Reject multi-statement input: any `;` outside literals/comments except one trailing. */
|
|
41
|
+
const isSingleStatement = (sql) => {
|
|
42
|
+
const noise = stripSqlNoise(sql).trim().replace(/;$/, "");
|
|
43
|
+
return !noise.includes(";");
|
|
44
|
+
};
|
|
45
|
+
const isReadStatement = (sql) => {
|
|
46
|
+
const head = stripSqlNoise(sql).trim().toLowerCase();
|
|
47
|
+
return head.startsWith("select") || head.startsWith("with");
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
|
|
51
|
+
* security model. Throws {@link McpDbConfigError} on any unsafe configuration — always at
|
|
52
|
+
* construction (boot), never at request time.
|
|
53
|
+
*/
|
|
54
|
+
export function serveDatabaseAsMcp(db, options) {
|
|
55
|
+
if (options.tables.length === 0) {
|
|
56
|
+
throw new McpDbConfigError("an explicit non-empty `tables` allowlist is required — this package never defaults to exposing a whole database");
|
|
57
|
+
}
|
|
58
|
+
for (const table of options.tables) {
|
|
59
|
+
if (!SAFE_IDENTIFIER.test(table)) {
|
|
60
|
+
throw new McpDbConfigError(`allowlisted table ${JSON.stringify(table)} is not a plain SQL identifier`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const allowlist = new Set(options.tables.map((table) => table.toLowerCase()));
|
|
64
|
+
if (options.enforceQueryOnly !== false) {
|
|
65
|
+
try {
|
|
66
|
+
db.run("PRAGMA query_only = ON");
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new McpDbConfigError(`could not set PRAGMA query_only on the connection (${String(error)}) — pass a bun:sqlite-shaped database or set enforceQueryOnly: false with a read-only connection`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const maxRows = options.runQuery?.maxRows ?? 100;
|
|
73
|
+
const maxResultBytes = options.runQuery?.maxResultBytes ?? 100 * 1024;
|
|
74
|
+
const listTables = defineMcpTool({
|
|
75
|
+
name: "list_tables",
|
|
76
|
+
description: "List the tables this server exposes, with row counts.",
|
|
77
|
+
handler: () => {
|
|
78
|
+
const rows = options.tables.map((table) => {
|
|
79
|
+
const [count] = db.prepare(`SELECT count(*) AS n FROM "${table}"`).all();
|
|
80
|
+
return { table, rows: count?.n ?? 0 };
|
|
81
|
+
});
|
|
82
|
+
return { text: JSON.stringify(rows), structuredContent: { tables: rows } };
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const describeTable = defineMcpTool({
|
|
86
|
+
name: "describe_table",
|
|
87
|
+
description: "Describe an exposed table: columns, types, nullability, primary key.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: { table: { type: "string", description: "Table name (must be exposed)" } },
|
|
91
|
+
required: ["table"],
|
|
92
|
+
},
|
|
93
|
+
handler: (args) => {
|
|
94
|
+
const table = String(args.table ?? "");
|
|
95
|
+
if (!allowlist.has(table.toLowerCase())) {
|
|
96
|
+
return { isError: true, text: `table ${JSON.stringify(table)} is not exposed` };
|
|
97
|
+
}
|
|
98
|
+
// Identifier is allowlist-verified (and the allowlist is identifier-checked at boot),
|
|
99
|
+
// so interpolation here cannot inject.
|
|
100
|
+
const columns = db.prepare(`PRAGMA table_info("${table}")`).all();
|
|
101
|
+
return { text: JSON.stringify(columns), structuredContent: { table, columns } };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const tools = [listTables, describeTable];
|
|
105
|
+
if (options.runQuery !== undefined) {
|
|
106
|
+
const runQuery = defineMcpTool({
|
|
107
|
+
name: "run_query",
|
|
108
|
+
description: "Run one read-only SELECT against the exposed tables. Results are capped by rows and bytes.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
sql: { type: "string", description: "A single SELECT (or WITH…SELECT) statement" },
|
|
113
|
+
},
|
|
114
|
+
required: ["sql"],
|
|
115
|
+
},
|
|
116
|
+
intent: "table",
|
|
117
|
+
handler: (args) => {
|
|
118
|
+
const sql = String(args.sql ?? "").trim();
|
|
119
|
+
if (sql === "")
|
|
120
|
+
return { isError: true, text: "empty query" };
|
|
121
|
+
if (!isSingleStatement(sql)) {
|
|
122
|
+
return { isError: true, text: "only a single statement is allowed" };
|
|
123
|
+
}
|
|
124
|
+
if (!isReadStatement(sql)) {
|
|
125
|
+
return { isError: true, text: "only SELECT (or WITH…SELECT) statements are allowed" };
|
|
126
|
+
}
|
|
127
|
+
// Verify via the query plan that only allowlisted tables are touched. SQLite names every
|
|
128
|
+
// scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
|
|
129
|
+
let planRows;
|
|
130
|
+
try {
|
|
131
|
+
planRows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { isError: true, text: `query failed to plan: ${String(error)}` };
|
|
135
|
+
}
|
|
136
|
+
for (const row of planRows) {
|
|
137
|
+
const detail = typeof row.detail === "string" ? row.detail : "";
|
|
138
|
+
const match = /(?:SCAN|SEARCH)\s+(?:TABLE\s+)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(detail);
|
|
139
|
+
if (match !== null) {
|
|
140
|
+
const relation = match[1]?.toLowerCase() ?? "";
|
|
141
|
+
if (!allowlist.has(relation)) {
|
|
142
|
+
return {
|
|
143
|
+
isError: true,
|
|
144
|
+
text: `query touches ${JSON.stringify(match[1])}, which is not exposed`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let rows;
|
|
150
|
+
try {
|
|
151
|
+
rows = db.prepare(sql).all();
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { isError: true, text: `query failed: ${String(error)}` };
|
|
155
|
+
}
|
|
156
|
+
const total = rows.length;
|
|
157
|
+
let shown = Math.min(total, maxRows);
|
|
158
|
+
let payload = rows.slice(0, shown);
|
|
159
|
+
let serialized = JSON.stringify(payload);
|
|
160
|
+
while (serialized.length > maxResultBytes && shown > 0) {
|
|
161
|
+
shown = Math.max(0, Math.floor(shown / 2));
|
|
162
|
+
payload = rows.slice(0, shown);
|
|
163
|
+
serialized = JSON.stringify(payload);
|
|
164
|
+
}
|
|
165
|
+
const truncated = shown < total;
|
|
166
|
+
const result = {
|
|
167
|
+
rows: payload,
|
|
168
|
+
...(truncated ? { truncated: { shown, total } } : {}),
|
|
169
|
+
};
|
|
170
|
+
return { text: JSON.stringify(result), structuredContent: result };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
tools.push(runQuery);
|
|
174
|
+
}
|
|
175
|
+
const server = createMcpServer({
|
|
176
|
+
name: options.name ?? "nifra-db",
|
|
177
|
+
version: options.version ?? "1.0.0",
|
|
178
|
+
tools,
|
|
179
|
+
});
|
|
180
|
+
if (options.runQuery === undefined)
|
|
181
|
+
return server;
|
|
182
|
+
// `run_query` is authorized at the TRANSPORT boundary, where the inbound Request's credentials
|
|
183
|
+
// are visible — the handler itself performs no auth, so every path to it must pass this gate:
|
|
184
|
+
// the wrapped fetch authorizes, and direct handle() dispatch fails closed (no Request → no auth).
|
|
185
|
+
const { authorize } = options.runQuery;
|
|
186
|
+
const rpcId = (id) => typeof id === "string" || typeof id === "number" ? id : null;
|
|
187
|
+
const unauthorizedResult = (id) => ({
|
|
188
|
+
jsonrpc: "2.0",
|
|
189
|
+
id: rpcId(id),
|
|
190
|
+
result: {
|
|
191
|
+
content: [{ type: "text", text: "unauthorized" }],
|
|
192
|
+
isError: true,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
const isRunQueryCall = (message) => {
|
|
196
|
+
if (typeof message !== "object" || message === null)
|
|
197
|
+
return false;
|
|
198
|
+
const { method, params } = message;
|
|
199
|
+
return method === "tools/call" && params?.name === "run_query";
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
...server,
|
|
203
|
+
async fetch(request) {
|
|
204
|
+
if (request.method === "POST") {
|
|
205
|
+
let body;
|
|
206
|
+
try {
|
|
207
|
+
body = await request.clone().json();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return server.fetch(request); // malformed JSON → the protocol layer's error handling
|
|
211
|
+
}
|
|
212
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
213
|
+
const queryCall = messages.find(isRunQueryCall);
|
|
214
|
+
if (queryCall !== undefined) {
|
|
215
|
+
const ok = await authorize({ toolName: "run_query", request });
|
|
216
|
+
if (!ok)
|
|
217
|
+
return Response.json(unauthorizedResult(queryCall.id));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return server.fetch(request);
|
|
221
|
+
},
|
|
222
|
+
async handle(message) {
|
|
223
|
+
if (isRunQueryCall(message))
|
|
224
|
+
return unauthorizedResult(message.id);
|
|
225
|
+
return server.handle(message);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/mcp-db",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Serve a SQLite database as a fail-closed MCP server — allowlisted schema tools by default, opt-in read-only queries with plan verification.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"mcp",
|
|
10
|
+
"sqlite",
|
|
11
|
+
"ai",
|
|
12
|
+
"agent"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@nifrajs/mcp": "1.5.0"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
43
|
+
"directory": "packages/mcp-db"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/mcp-db` — serve a SQLite database as an MCP server, fail-closed.
|
|
3
|
+
*
|
|
4
|
+
* Out of the box only SCHEMA tools are exposed (`list_tables`, `describe_table`), and only for
|
|
5
|
+
* tables on the explicit `tables` allowlist. Query execution is OPT-IN and requires BOTH the
|
|
6
|
+
* allowlist and an `authorize` hook — a database is PII by default, and an MCP client is an LLM;
|
|
7
|
+
* this package refuses to be mounted open rather than defaulting open.
|
|
8
|
+
*
|
|
9
|
+
* Read-only is enforced in layers, not promised: `PRAGMA query_only = ON` on the connection
|
|
10
|
+
* (SQLite rejects every write at the engine), a single-statement gate, a SELECT/WITH-only gate,
|
|
11
|
+
* and `EXPLAIN QUERY PLAN` verification that a query touches ONLY allowlisted tables. Results are
|
|
12
|
+
* capped by rows and bytes (an LLM does not need 100k rows; a transport does not want them) with
|
|
13
|
+
* an explicit `truncated` marker.
|
|
14
|
+
*
|
|
15
|
+
* Bun/Node SQLite only (anything `bun:sqlite`-shaped, bound structurally — no driver dependency).
|
|
16
|
+
* There is deliberately NO D1 mode: D1 has no `query_only`, so read-only cannot be guaranteed.
|
|
17
|
+
*
|
|
18
|
+
* import { Database } from "bun:sqlite"
|
|
19
|
+
* const mcp = serveDatabaseAsMcp(new Database("app.db"), {
|
|
20
|
+
* tables: ["habits", "entries"],
|
|
21
|
+
* runQuery: { authorize: (ctx) => ctx.request?.headers.get("x-api-key") === env.MCP_KEY },
|
|
22
|
+
* })
|
|
23
|
+
* app.mount("/mcp", mcp.fetch)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createMcpServer, defineMcpTool, type JsonRpcResponse, type McpServer } from "@nifrajs/mcp"
|
|
27
|
+
|
|
28
|
+
/** The structural slice of `bun:sqlite`'s `Database` this package needs. */
|
|
29
|
+
export interface SqliteDatabaseLike {
|
|
30
|
+
/** Prepare a statement; `all` runs it and returns row objects. */
|
|
31
|
+
prepare(sql: string): { all(...params: unknown[]): unknown[] }
|
|
32
|
+
/** Execute a statement for its side effect (used only for `PRAGMA query_only`). */
|
|
33
|
+
run(sql: string): unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Context forwarded to `authorize` — the inbound HTTP Request carrying the `run_query` call. */
|
|
37
|
+
export interface McpDbAuthorizeContext {
|
|
38
|
+
readonly toolName: "run_query"
|
|
39
|
+
readonly request: Request
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunQueryOptions {
|
|
43
|
+
/**
|
|
44
|
+
* REQUIRED. Authorize each `run_query` call at the transport boundary (inspect the inbound
|
|
45
|
+
* Request's auth). Return false to reject with a JSON-RPC error. There is no unauthenticated
|
|
46
|
+
* mode — schema tools are the anonymous surface. Note: `run_query` is therefore HTTP-only;
|
|
47
|
+
* direct `handle()` dispatch rejects it (no Request to authorize — fails closed).
|
|
48
|
+
*/
|
|
49
|
+
readonly authorize: (context: McpDbAuthorizeContext) => boolean | Promise<boolean>
|
|
50
|
+
/** Max rows returned per query (default 100). */
|
|
51
|
+
readonly maxRows?: number
|
|
52
|
+
/** Max serialized result size in bytes (default 100 KB). */
|
|
53
|
+
readonly maxResultBytes?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServeDatabaseAsMcpOptions {
|
|
57
|
+
/**
|
|
58
|
+
* REQUIRED allowlist of tables the MCP surface may see. `list_tables`/`describe_table` are
|
|
59
|
+
* restricted to it and `run_query` plans are verified against it. Empty array → construction
|
|
60
|
+
* throws; expose nothing by accident, ever.
|
|
61
|
+
*/
|
|
62
|
+
readonly tables: readonly string[]
|
|
63
|
+
/** Enable the `run_query` tool. Omitted → schema tools only. */
|
|
64
|
+
readonly runQuery?: RunQueryOptions
|
|
65
|
+
/** MCP server identity (defaults: name `nifra-db`, version `1.0.0`). */
|
|
66
|
+
readonly name?: string
|
|
67
|
+
readonly version?: string
|
|
68
|
+
/**
|
|
69
|
+
* Set `PRAGMA query_only = ON` on the connection at construction (default true). Disable ONLY
|
|
70
|
+
* when the same connection must also serve writes elsewhere in the app — with it disabled,
|
|
71
|
+
* `run_query` still gates on SELECT/WITH + plan verification, but the engine-level guarantee is
|
|
72
|
+
* yours to provide (e.g. open a second, read-only connection for the MCP mount — preferred).
|
|
73
|
+
*/
|
|
74
|
+
readonly enforceQueryOnly?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class McpDbConfigError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(`@nifrajs/mcp-db: ${message}`)
|
|
80
|
+
this.name = "McpDbConfigError"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A safe SQL identifier — quoting is not enough for PRAGMA args, so reject instead. */
|
|
85
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
86
|
+
|
|
87
|
+
const stripSqlNoise = (sql: string): string =>
|
|
88
|
+
sql
|
|
89
|
+
// Strings first so comment markers inside literals don't count.
|
|
90
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
91
|
+
.replace(/"(?:[^"]|"")*"/g, '""')
|
|
92
|
+
.replace(/--[^\n]*/g, " ")
|
|
93
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
94
|
+
|
|
95
|
+
/** Reject multi-statement input: any `;` outside literals/comments except one trailing. */
|
|
96
|
+
const isSingleStatement = (sql: string): boolean => {
|
|
97
|
+
const noise = stripSqlNoise(sql).trim().replace(/;$/, "")
|
|
98
|
+
return !noise.includes(";")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isReadStatement = (sql: string): boolean => {
|
|
102
|
+
const head = stripSqlNoise(sql).trim().toLowerCase()
|
|
103
|
+
return head.startsWith("select") || head.startsWith("with")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Serve `db` as a mountable MCP server (`mcp.fetch` at `POST /mcp`). See module docs for the
|
|
108
|
+
* security model. Throws {@link McpDbConfigError} on any unsafe configuration — always at
|
|
109
|
+
* construction (boot), never at request time.
|
|
110
|
+
*/
|
|
111
|
+
export function serveDatabaseAsMcp(
|
|
112
|
+
db: SqliteDatabaseLike,
|
|
113
|
+
options: ServeDatabaseAsMcpOptions,
|
|
114
|
+
): McpServer {
|
|
115
|
+
if (options.tables.length === 0) {
|
|
116
|
+
throw new McpDbConfigError(
|
|
117
|
+
"an explicit non-empty `tables` allowlist is required — this package never defaults to exposing a whole database",
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
for (const table of options.tables) {
|
|
121
|
+
if (!SAFE_IDENTIFIER.test(table)) {
|
|
122
|
+
throw new McpDbConfigError(
|
|
123
|
+
`allowlisted table ${JSON.stringify(table)} is not a plain SQL identifier`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const allowlist = new Set(options.tables.map((table) => table.toLowerCase()))
|
|
128
|
+
|
|
129
|
+
if (options.enforceQueryOnly !== false) {
|
|
130
|
+
try {
|
|
131
|
+
db.run("PRAGMA query_only = ON")
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new McpDbConfigError(
|
|
134
|
+
`could not set PRAGMA query_only on the connection (${String(error)}) — pass a bun:sqlite-shaped database or set enforceQueryOnly: false with a read-only connection`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const maxRows = options.runQuery?.maxRows ?? 100
|
|
140
|
+
const maxResultBytes = options.runQuery?.maxResultBytes ?? 100 * 1024
|
|
141
|
+
|
|
142
|
+
const listTables = defineMcpTool({
|
|
143
|
+
name: "list_tables",
|
|
144
|
+
description: "List the tables this server exposes, with row counts.",
|
|
145
|
+
handler: () => {
|
|
146
|
+
const rows = options.tables.map((table) => {
|
|
147
|
+
const [count] = db.prepare(`SELECT count(*) AS n FROM "${table}"`).all() as Array<{
|
|
148
|
+
n: number
|
|
149
|
+
}>
|
|
150
|
+
return { table, rows: count?.n ?? 0 }
|
|
151
|
+
})
|
|
152
|
+
return { text: JSON.stringify(rows), structuredContent: { tables: rows } }
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const describeTable = defineMcpTool({
|
|
157
|
+
name: "describe_table",
|
|
158
|
+
description: "Describe an exposed table: columns, types, nullability, primary key.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: { table: { type: "string", description: "Table name (must be exposed)" } },
|
|
162
|
+
required: ["table"],
|
|
163
|
+
},
|
|
164
|
+
handler: (args) => {
|
|
165
|
+
const table = String(args.table ?? "")
|
|
166
|
+
if (!allowlist.has(table.toLowerCase())) {
|
|
167
|
+
return { isError: true, text: `table ${JSON.stringify(table)} is not exposed` }
|
|
168
|
+
}
|
|
169
|
+
// Identifier is allowlist-verified (and the allowlist is identifier-checked at boot),
|
|
170
|
+
// so interpolation here cannot inject.
|
|
171
|
+
const columns = db.prepare(`PRAGMA table_info("${table}")`).all()
|
|
172
|
+
return { text: JSON.stringify(columns), structuredContent: { table, columns } }
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const tools = [listTables, describeTable]
|
|
177
|
+
|
|
178
|
+
if (options.runQuery !== undefined) {
|
|
179
|
+
const runQuery = defineMcpTool({
|
|
180
|
+
name: "run_query",
|
|
181
|
+
description:
|
|
182
|
+
"Run one read-only SELECT against the exposed tables. Results are capped by rows and bytes.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
sql: { type: "string", description: "A single SELECT (or WITH…SELECT) statement" },
|
|
187
|
+
},
|
|
188
|
+
required: ["sql"],
|
|
189
|
+
},
|
|
190
|
+
intent: "table",
|
|
191
|
+
handler: (args) => {
|
|
192
|
+
const sql = String(args.sql ?? "").trim()
|
|
193
|
+
if (sql === "") return { isError: true, text: "empty query" }
|
|
194
|
+
if (!isSingleStatement(sql)) {
|
|
195
|
+
return { isError: true, text: "only a single statement is allowed" }
|
|
196
|
+
}
|
|
197
|
+
if (!isReadStatement(sql)) {
|
|
198
|
+
return { isError: true, text: "only SELECT (or WITH…SELECT) statements are allowed" }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Verify via the query plan that only allowlisted tables are touched. SQLite names every
|
|
202
|
+
// scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
|
|
203
|
+
let planRows: Array<{ detail?: unknown }>
|
|
204
|
+
try {
|
|
205
|
+
planRows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all() as Array<{ detail?: unknown }>
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { isError: true, text: `query failed to plan: ${String(error)}` }
|
|
208
|
+
}
|
|
209
|
+
for (const row of planRows) {
|
|
210
|
+
const detail = typeof row.detail === "string" ? row.detail : ""
|
|
211
|
+
const match = /(?:SCAN|SEARCH)\s+(?:TABLE\s+)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(detail)
|
|
212
|
+
if (match !== null) {
|
|
213
|
+
const relation = match[1]?.toLowerCase() ?? ""
|
|
214
|
+
if (!allowlist.has(relation)) {
|
|
215
|
+
return {
|
|
216
|
+
isError: true,
|
|
217
|
+
text: `query touches ${JSON.stringify(match[1])}, which is not exposed`,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let rows: unknown[]
|
|
224
|
+
try {
|
|
225
|
+
rows = db.prepare(sql).all()
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return { isError: true, text: `query failed: ${String(error)}` }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const total = rows.length
|
|
231
|
+
let shown = Math.min(total, maxRows)
|
|
232
|
+
let payload = rows.slice(0, shown)
|
|
233
|
+
let serialized = JSON.stringify(payload)
|
|
234
|
+
while (serialized.length > maxResultBytes && shown > 0) {
|
|
235
|
+
shown = Math.max(0, Math.floor(shown / 2))
|
|
236
|
+
payload = rows.slice(0, shown)
|
|
237
|
+
serialized = JSON.stringify(payload)
|
|
238
|
+
}
|
|
239
|
+
const truncated = shown < total
|
|
240
|
+
const result: Record<string, unknown> = {
|
|
241
|
+
rows: payload,
|
|
242
|
+
...(truncated ? { truncated: { shown, total } } : {}),
|
|
243
|
+
}
|
|
244
|
+
return { text: JSON.stringify(result), structuredContent: result }
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
tools.push(runQuery)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const server = createMcpServer({
|
|
251
|
+
name: options.name ?? "nifra-db",
|
|
252
|
+
version: options.version ?? "1.0.0",
|
|
253
|
+
tools,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if (options.runQuery === undefined) return server
|
|
257
|
+
|
|
258
|
+
// `run_query` is authorized at the TRANSPORT boundary, where the inbound Request's credentials
|
|
259
|
+
// are visible — the handler itself performs no auth, so every path to it must pass this gate:
|
|
260
|
+
// the wrapped fetch authorizes, and direct handle() dispatch fails closed (no Request → no auth).
|
|
261
|
+
const { authorize } = options.runQuery
|
|
262
|
+
|
|
263
|
+
const rpcId = (id: unknown): string | number | null =>
|
|
264
|
+
typeof id === "string" || typeof id === "number" ? id : null
|
|
265
|
+
|
|
266
|
+
const unauthorizedResult = (id: unknown): JsonRpcResponse =>
|
|
267
|
+
({
|
|
268
|
+
jsonrpc: "2.0",
|
|
269
|
+
id: rpcId(id),
|
|
270
|
+
result: {
|
|
271
|
+
content: [{ type: "text", text: "unauthorized" }],
|
|
272
|
+
isError: true,
|
|
273
|
+
},
|
|
274
|
+
}) as JsonRpcResponse
|
|
275
|
+
|
|
276
|
+
const isRunQueryCall = (message: unknown): message is { id?: unknown } => {
|
|
277
|
+
if (typeof message !== "object" || message === null) return false
|
|
278
|
+
const { method, params } = message as { method?: unknown; params?: { name?: unknown } }
|
|
279
|
+
return method === "tools/call" && params?.name === "run_query"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
...server,
|
|
284
|
+
async fetch(request: Request): Promise<Response> {
|
|
285
|
+
if (request.method === "POST") {
|
|
286
|
+
let body: unknown
|
|
287
|
+
try {
|
|
288
|
+
body = await request.clone().json()
|
|
289
|
+
} catch {
|
|
290
|
+
return server.fetch(request) // malformed JSON → the protocol layer's error handling
|
|
291
|
+
}
|
|
292
|
+
const messages = Array.isArray(body) ? body : [body]
|
|
293
|
+
const queryCall = messages.find(isRunQueryCall)
|
|
294
|
+
if (queryCall !== undefined) {
|
|
295
|
+
const ok = await authorize({ toolName: "run_query", request })
|
|
296
|
+
if (!ok) return Response.json(unauthorizedResult(queryCall.id))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return server.fetch(request)
|
|
300
|
+
},
|
|
301
|
+
async handle(message) {
|
|
302
|
+
if (isRunQueryCall(message)) return unauthorizedResult(message.id)
|
|
303
|
+
return server.handle(message)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|