ajan-sql 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/config.js +18 -0
- package/dist/db/pool.js +14 -0
- package/dist/db/schema.js +74 -0
- package/dist/guard/index.js +85 -0
- package/dist/index.js +28 -0
- package/dist/query-runner/index.js +65 -0
- package/dist/resources/schema-resources.js +65 -0
- package/dist/server.js +15 -0
- package/dist/tools/schema-tools.js +74 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bora Kilicoglu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# ajan-sql
|
|
2
|
+
|
|
3
|
+
AI-safe MCP server for schema-aware, read-only SQL access.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`ajan-sql` is an npm package for running an MCP server over stdio with a PostgreSQL backend.
|
|
8
|
+
|
|
9
|
+
The project is designed as:
|
|
10
|
+
|
|
11
|
+
> psql + schema awareness + AI-safe guard layer
|
|
12
|
+
|
|
13
|
+
## Goals
|
|
14
|
+
|
|
15
|
+
- Safe, read-only database access for AI agents
|
|
16
|
+
- Schema inspection and table discovery
|
|
17
|
+
- Reliable PostgreSQL query execution with strict guardrails
|
|
18
|
+
- Simple, maintainable implementation
|
|
19
|
+
|
|
20
|
+
## Tech Stack
|
|
21
|
+
|
|
22
|
+
- Node.js
|
|
23
|
+
- TypeScript
|
|
24
|
+
- MCP TypeScript SDK v1.x
|
|
25
|
+
- PostgreSQL via `pg`
|
|
26
|
+
|
|
27
|
+
## Security Model
|
|
28
|
+
|
|
29
|
+
All executed queries must follow these rules:
|
|
30
|
+
|
|
31
|
+
- `SELECT` only
|
|
32
|
+
- Reject `INSERT`
|
|
33
|
+
- Reject `UPDATE`
|
|
34
|
+
- Reject `DELETE`
|
|
35
|
+
- Reject `DROP`
|
|
36
|
+
- Reject `ALTER`
|
|
37
|
+
- Reject `TRUNCATE`
|
|
38
|
+
- Enforce `LIMIT` with a default of `100`
|
|
39
|
+
- Enforce query timeout with a maximum of `5` seconds
|
|
40
|
+
- Enforce maximum result size
|
|
41
|
+
- Reject multi-statement SQL
|
|
42
|
+
- Reject SQL comments
|
|
43
|
+
|
|
44
|
+
These rules should never be bypassed.
|
|
45
|
+
|
|
46
|
+
## Available MCP Tools
|
|
47
|
+
|
|
48
|
+
- `list_tables`
|
|
49
|
+
- `describe_table`
|
|
50
|
+
- `list_relationships`
|
|
51
|
+
- `run_readonly_query`
|
|
52
|
+
- `explain_query`
|
|
53
|
+
- `sample_rows`
|
|
54
|
+
|
|
55
|
+
## Available MCP Resources
|
|
56
|
+
|
|
57
|
+
- `schema://snapshot`
|
|
58
|
+
- `schema://table/{name}`
|
|
59
|
+
|
|
60
|
+
## Local Usage
|
|
61
|
+
|
|
62
|
+
Start the server with a PostgreSQL connection string:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/DB npm run dev
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or build and run the compiled server:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm run build
|
|
72
|
+
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/DB npm start
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Client Configuration
|
|
76
|
+
|
|
77
|
+
For MCP clients that launch local stdio servers, point the command to the built CLI and provide `DATABASE_URL`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"ajan-sql": {
|
|
83
|
+
"command": "node",
|
|
84
|
+
"args": ["/absolute/path/to/ajan-sql/dist/index.js"],
|
|
85
|
+
"env": {
|
|
86
|
+
"DATABASE_URL": "postgres://USER:PASSWORD@HOST:PORT/DB"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Integration Testing
|
|
94
|
+
|
|
95
|
+
The repository supports local PostgreSQL integration testing during development, but any Docker compose files or seeded local test databases can remain untracked and machine-local.
|
|
96
|
+
|
|
97
|
+
## Development Principles
|
|
98
|
+
|
|
99
|
+
- Keep functions small and composable
|
|
100
|
+
- Avoid side effects
|
|
101
|
+
- Route all DB logic through `db/`
|
|
102
|
+
- Route all query execution through `query-runner`
|
|
103
|
+
- Route all validation through `guard`
|
|
104
|
+
- Prefer simple working code over abstraction
|
|
105
|
+
- Prioritize correctness, safety, and clarity
|
|
106
|
+
|
|
107
|
+
## CLI Behavior
|
|
108
|
+
|
|
109
|
+
The CLI will:
|
|
110
|
+
|
|
111
|
+
- Start the MCP server over stdio
|
|
112
|
+
- Read `DATABASE_URL` from the environment
|
|
113
|
+
- Fail fast if `DATABASE_URL` is missing
|
|
114
|
+
|
|
115
|
+
## Status
|
|
116
|
+
|
|
117
|
+
Early development. Schema inspection, readonly query execution, query explain, and sample row tools are implemented. The CLI is publish-ready for npm packaging, and current package version is `0.1.0`.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getRequiredEnv = getRequiredEnv;
|
|
4
|
+
exports.getAppConfig = getAppConfig;
|
|
5
|
+
const DEFAULT_DB_POOL_MAX = 10;
|
|
6
|
+
function getRequiredEnv(name) {
|
|
7
|
+
const value = process.env[name]?.trim();
|
|
8
|
+
if (!value) {
|
|
9
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function getAppConfig() {
|
|
14
|
+
return {
|
|
15
|
+
databaseUrl: getRequiredEnv("DATABASE_URL"),
|
|
16
|
+
dbPoolMax: DEFAULT_DB_POOL_MAX,
|
|
17
|
+
};
|
|
18
|
+
}
|
package/dist/db/pool.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDbPool = createDbPool;
|
|
4
|
+
exports.closeDbPool = closeDbPool;
|
|
5
|
+
const pg_1 = require("pg");
|
|
6
|
+
function createDbPool(options) {
|
|
7
|
+
return new pg_1.Pool({
|
|
8
|
+
connectionString: options.connectionString,
|
|
9
|
+
max: options.max,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
async function closeDbPool(pool) {
|
|
13
|
+
await pool.end();
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listTables = listTables;
|
|
4
|
+
exports.describeTable = describeTable;
|
|
5
|
+
exports.listRelationships = listRelationships;
|
|
6
|
+
async function listTables(pool) {
|
|
7
|
+
const result = await pool.query(`
|
|
8
|
+
select table_schema, table_name
|
|
9
|
+
from information_schema.tables
|
|
10
|
+
where table_type = 'BASE TABLE'
|
|
11
|
+
and table_schema not in ('information_schema', 'pg_catalog')
|
|
12
|
+
order by table_schema, table_name
|
|
13
|
+
`);
|
|
14
|
+
return result.rows.map((row) => ({
|
|
15
|
+
schema: row.table_schema,
|
|
16
|
+
name: row.table_name,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
async function describeTable(pool, tableName, schemaName = "public") {
|
|
20
|
+
const result = await pool.query(`
|
|
21
|
+
select column_name, data_type, is_nullable, column_default
|
|
22
|
+
from information_schema.columns
|
|
23
|
+
where table_schema = $1
|
|
24
|
+
and table_name = $2
|
|
25
|
+
order by ordinal_position
|
|
26
|
+
`, [schemaName, tableName]);
|
|
27
|
+
if (result.rows.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
schema: schemaName,
|
|
32
|
+
name: tableName,
|
|
33
|
+
columns: result.rows.map((row) => ({
|
|
34
|
+
name: row.column_name,
|
|
35
|
+
dataType: row.data_type,
|
|
36
|
+
isNullable: row.is_nullable === "YES",
|
|
37
|
+
defaultValue: row.column_default,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function listRelationships(pool) {
|
|
42
|
+
const result = await pool.query(`
|
|
43
|
+
select
|
|
44
|
+
tc.constraint_name,
|
|
45
|
+
tc.table_schema as source_schema,
|
|
46
|
+
tc.table_name as source_table,
|
|
47
|
+
kcu.column_name as source_column,
|
|
48
|
+
ccu.table_schema as target_schema,
|
|
49
|
+
ccu.table_name as target_table,
|
|
50
|
+
ccu.column_name as target_column
|
|
51
|
+
from information_schema.table_constraints tc
|
|
52
|
+
join information_schema.key_column_usage kcu
|
|
53
|
+
on tc.constraint_name = kcu.constraint_name
|
|
54
|
+
and tc.table_schema = kcu.table_schema
|
|
55
|
+
join information_schema.constraint_column_usage ccu
|
|
56
|
+
on ccu.constraint_name = tc.constraint_name
|
|
57
|
+
and ccu.table_schema = tc.table_schema
|
|
58
|
+
where tc.constraint_type = 'FOREIGN KEY'
|
|
59
|
+
order by
|
|
60
|
+
tc.table_schema,
|
|
61
|
+
tc.table_name,
|
|
62
|
+
tc.constraint_name,
|
|
63
|
+
kcu.ordinal_position
|
|
64
|
+
`);
|
|
65
|
+
return result.rows.map((row) => ({
|
|
66
|
+
constraintName: row.constraint_name,
|
|
67
|
+
sourceSchema: row.source_schema,
|
|
68
|
+
sourceTable: row.source_table,
|
|
69
|
+
sourceColumn: row.source_column,
|
|
70
|
+
targetSchema: row.target_schema,
|
|
71
|
+
targetTable: row.target_table,
|
|
72
|
+
targetColumn: row.target_column,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getReadonlyDefaults = getReadonlyDefaults;
|
|
4
|
+
exports.guardReadonlyQuery = guardReadonlyQuery;
|
|
5
|
+
exports.quoteIdentifier = quoteIdentifier;
|
|
6
|
+
const DEFAULT_LIMIT = 100;
|
|
7
|
+
const MAX_LIMIT = 100;
|
|
8
|
+
const MAX_TIMEOUT_MS = 5_000;
|
|
9
|
+
const MAX_RESULT_BYTES = 1_000_000;
|
|
10
|
+
const BLOCKED_KEYWORDS = [
|
|
11
|
+
"insert",
|
|
12
|
+
"update",
|
|
13
|
+
"delete",
|
|
14
|
+
"drop",
|
|
15
|
+
"alter",
|
|
16
|
+
"truncate",
|
|
17
|
+
];
|
|
18
|
+
function getReadonlyDefaults() {
|
|
19
|
+
return {
|
|
20
|
+
defaultLimit: DEFAULT_LIMIT,
|
|
21
|
+
maxLimit: MAX_LIMIT,
|
|
22
|
+
timeoutMs: MAX_TIMEOUT_MS,
|
|
23
|
+
maxResultBytes: MAX_RESULT_BYTES,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function guardReadonlyQuery(sql, options = {}) {
|
|
27
|
+
const defaults = getReadonlyDefaults();
|
|
28
|
+
const defaultLimit = options.defaultLimit ?? defaults.defaultLimit;
|
|
29
|
+
const maxLimit = options.maxLimit ?? defaults.maxLimit;
|
|
30
|
+
const timeoutMs = Math.min(options.timeoutMs ?? defaults.timeoutMs, defaults.timeoutMs);
|
|
31
|
+
const maxResultBytes = options.maxResultBytes ?? defaults.maxResultBytes;
|
|
32
|
+
const normalizedSql = normalizeSql(sql);
|
|
33
|
+
assertSingleStatement(normalizedSql);
|
|
34
|
+
assertNoSqlComments(normalizedSql);
|
|
35
|
+
assertSelectOnly(normalizedSql);
|
|
36
|
+
assertNoBlockedKeywords(normalizedSql);
|
|
37
|
+
const limitMatch = normalizedSql.match(/\blimit\s+(\d+)\b/i);
|
|
38
|
+
const parsedLimit = limitMatch ? Number.parseInt(limitMatch[1], 10) : null;
|
|
39
|
+
if (parsedLimit !== null && parsedLimit > maxLimit) {
|
|
40
|
+
throw new Error(`Query LIMIT exceeds maximum allowed value of ${maxLimit}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
sql: parsedLimit === null ? `${normalizedSql} LIMIT ${defaultLimit}` : normalizedSql,
|
|
44
|
+
limit: parsedLimit ?? defaultLimit,
|
|
45
|
+
timeoutMs,
|
|
46
|
+
maxResultBytes,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function quoteIdentifier(identifier) {
|
|
50
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
|
51
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
52
|
+
}
|
|
53
|
+
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
|
54
|
+
}
|
|
55
|
+
function normalizeSql(sql) {
|
|
56
|
+
const trimmed = sql.trim().replace(/;+$/, "").trim();
|
|
57
|
+
if (!trimmed) {
|
|
58
|
+
throw new Error("SQL query is required");
|
|
59
|
+
}
|
|
60
|
+
return trimmed;
|
|
61
|
+
}
|
|
62
|
+
function assertSingleStatement(sql) {
|
|
63
|
+
if (sql.includes(";")) {
|
|
64
|
+
throw new Error("Only a single SQL statement is allowed");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function assertNoSqlComments(sql) {
|
|
68
|
+
if (sql.includes("--") || sql.includes("/*") || sql.includes("*/")) {
|
|
69
|
+
throw new Error("SQL comments are not allowed");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function assertSelectOnly(sql) {
|
|
73
|
+
if (!/^select\b/i.test(sql)) {
|
|
74
|
+
throw new Error("Only SELECT queries are allowed");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function assertNoBlockedKeywords(sql) {
|
|
78
|
+
const scrubbedSql = sql.replace(/'(?:''|[^'])*'/g, "''");
|
|
79
|
+
for (const keyword of BLOCKED_KEYWORDS) {
|
|
80
|
+
const pattern = new RegExp(`\\b${keyword}\\b`, "i");
|
|
81
|
+
if (pattern.test(scrubbedSql)) {
|
|
82
|
+
throw new Error(`Blocked SQL keyword detected: ${keyword.toUpperCase()}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
|
+
const config_1 = require("./config");
|
|
6
|
+
const pool_1 = require("./db/pool");
|
|
7
|
+
const server_1 = require("./server");
|
|
8
|
+
async function main() {
|
|
9
|
+
const config = (0, config_1.getAppConfig)();
|
|
10
|
+
const pool = (0, pool_1.createDbPool)({
|
|
11
|
+
connectionString: config.databaseUrl,
|
|
12
|
+
max: config.dbPoolMax,
|
|
13
|
+
});
|
|
14
|
+
const server = (0, server_1.createAjanServer)({ pool });
|
|
15
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
16
|
+
const shutdown = async () => {
|
|
17
|
+
await (0, pool_1.closeDbPool)(pool);
|
|
18
|
+
process.exit(0);
|
|
19
|
+
};
|
|
20
|
+
process.on("SIGINT", shutdown);
|
|
21
|
+
process.on("SIGTERM", shutdown);
|
|
22
|
+
await server.connect(transport);
|
|
23
|
+
}
|
|
24
|
+
main().catch((error) => {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
console.error(`[ajan-sql] ${message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runReadonlyQuery = runReadonlyQuery;
|
|
4
|
+
exports.explainReadonlyQuery = explainReadonlyQuery;
|
|
5
|
+
exports.sampleRows = sampleRows;
|
|
6
|
+
const guard_1 = require("../guard");
|
|
7
|
+
async function runReadonlyQuery(pool, sql, options = {}) {
|
|
8
|
+
const guarded = (0, guard_1.guardReadonlyQuery)(sql, options);
|
|
9
|
+
const result = await executeWithReadonlySettings(pool, guarded.sql, guarded.timeoutMs);
|
|
10
|
+
assertRowCount(result.rows.length, guarded.limit);
|
|
11
|
+
assertResultSize(result.rows, guarded.maxResultBytes);
|
|
12
|
+
return {
|
|
13
|
+
sql: guarded.sql,
|
|
14
|
+
rowCount: result.rows.length,
|
|
15
|
+
rows: result.rows,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async function explainReadonlyQuery(pool, sql) {
|
|
19
|
+
const guarded = (0, guard_1.guardReadonlyQuery)(sql);
|
|
20
|
+
const explainSql = `EXPLAIN (FORMAT JSON) ${guarded.sql}`;
|
|
21
|
+
const result = await executeWithReadonlySettings(pool, explainSql, guarded.timeoutMs);
|
|
22
|
+
const plan = result.rows[0]?.["QUERY PLAN"];
|
|
23
|
+
return {
|
|
24
|
+
sql: guarded.sql,
|
|
25
|
+
plan,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function sampleRows(pool, tableName, schemaName = "public", limit = 10) {
|
|
29
|
+
const defaults = (0, guard_1.getReadonlyDefaults)();
|
|
30
|
+
const safeLimit = Math.min(limit, defaults.maxLimit);
|
|
31
|
+
const sql = [
|
|
32
|
+
"SELECT *",
|
|
33
|
+
`FROM ${(0, guard_1.quoteIdentifier)(schemaName)}.${(0, guard_1.quoteIdentifier)(tableName)}`,
|
|
34
|
+
`LIMIT ${safeLimit}`,
|
|
35
|
+
].join(" ");
|
|
36
|
+
return runReadonlyQuery(pool, sql, { defaultLimit: safeLimit });
|
|
37
|
+
}
|
|
38
|
+
async function executeWithReadonlySettings(pool, sql, timeoutMs) {
|
|
39
|
+
const client = await pool.connect();
|
|
40
|
+
try {
|
|
41
|
+
await client.query("BEGIN");
|
|
42
|
+
await client.query(`SET LOCAL statement_timeout = '${timeoutMs}ms'`);
|
|
43
|
+
const result = await client.query(sql);
|
|
44
|
+
await client.query("ROLLBACK");
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
client.release();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function assertRowCount(rowCount, limit) {
|
|
56
|
+
if (rowCount > limit) {
|
|
57
|
+
throw new Error(`Query returned more rows than allowed limit of ${limit}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function assertResultSize(rows, maxResultBytes) {
|
|
61
|
+
const resultBytes = Buffer.byteLength(JSON.stringify(rows), "utf8");
|
|
62
|
+
if (resultBytes > maxResultBytes) {
|
|
63
|
+
throw new Error(`Query result exceeds maximum allowed size of ${maxResultBytes} bytes`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSchemaResources = registerSchemaResources;
|
|
4
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const schema_1 = require("../db/schema");
|
|
6
|
+
function registerSchemaResources(server, deps) {
|
|
7
|
+
server.registerResource("schema-snapshot", "schema://snapshot", {
|
|
8
|
+
description: "Snapshot of tables and foreign key relationships.",
|
|
9
|
+
mimeType: "application/json",
|
|
10
|
+
}, async (uri) => {
|
|
11
|
+
const [tables, relationships] = await Promise.all([
|
|
12
|
+
(0, schema_1.listTables)(deps.pool),
|
|
13
|
+
(0, schema_1.listRelationships)(deps.pool),
|
|
14
|
+
]);
|
|
15
|
+
return {
|
|
16
|
+
contents: [
|
|
17
|
+
{
|
|
18
|
+
uri: uri.href,
|
|
19
|
+
text: JSON.stringify({ tables, relationships }, null, 2),
|
|
20
|
+
mimeType: "application/json",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
server.registerResource("schema-table", new mcp_js_1.ResourceTemplate("schema://table/{name}", {
|
|
26
|
+
list: async () => {
|
|
27
|
+
const tables = await (0, schema_1.listTables)(deps.pool);
|
|
28
|
+
return {
|
|
29
|
+
resources: tables.map((table) => ({
|
|
30
|
+
uri: `schema://table/${table.name}`,
|
|
31
|
+
name: `${table.schema}.${table.name}`,
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
complete: {
|
|
36
|
+
name: async (value) => {
|
|
37
|
+
const tables = await (0, schema_1.listTables)(deps.pool);
|
|
38
|
+
return tables
|
|
39
|
+
.map((table) => table.name)
|
|
40
|
+
.filter((tableName) => tableName.startsWith(value));
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}), {
|
|
44
|
+
description: "Schema details for a single table.",
|
|
45
|
+
mimeType: "application/json",
|
|
46
|
+
}, async (uri, params) => {
|
|
47
|
+
const tableName = Array.isArray(params.name) ? params.name[0] : params.name;
|
|
48
|
+
if (!tableName) {
|
|
49
|
+
throw new Error("Table name is required");
|
|
50
|
+
}
|
|
51
|
+
const description = await (0, schema_1.describeTable)(deps.pool, tableName);
|
|
52
|
+
if (!description) {
|
|
53
|
+
throw new Error(`Table not found: ${tableName}`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
contents: [
|
|
57
|
+
{
|
|
58
|
+
uri: uri.href,
|
|
59
|
+
text: JSON.stringify(description, null, 2),
|
|
60
|
+
mimeType: "application/json",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAjanServer = createAjanServer;
|
|
4
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const schema_resources_1 = require("./resources/schema-resources");
|
|
6
|
+
const schema_tools_1 = require("./tools/schema-tools");
|
|
7
|
+
function createAjanServer(options) {
|
|
8
|
+
const server = new mcp_js_1.McpServer({
|
|
9
|
+
name: "ajan-sql",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
});
|
|
12
|
+
(0, schema_tools_1.registerSchemaTools)(server, { pool: options.pool });
|
|
13
|
+
(0, schema_resources_1.registerSchemaResources)(server, { pool: options.pool });
|
|
14
|
+
return server;
|
|
15
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSchemaTools = registerSchemaTools;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const schema_1 = require("../db/schema");
|
|
6
|
+
const query_runner_1 = require("../query-runner");
|
|
7
|
+
function asTextResult(data) {
|
|
8
|
+
return {
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: "text",
|
|
12
|
+
text: JSON.stringify(data, null, 2),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function registerSchemaTools(server, deps) {
|
|
18
|
+
const registerTool = server.registerTool.bind(server);
|
|
19
|
+
registerTool("list_tables", {
|
|
20
|
+
description: "Return all tables in the database.",
|
|
21
|
+
}, async () => {
|
|
22
|
+
const tables = await (0, schema_1.listTables)(deps.pool);
|
|
23
|
+
return asTextResult(tables);
|
|
24
|
+
});
|
|
25
|
+
registerTool("describe_table", {
|
|
26
|
+
description: "Return columns and types for a given table.",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
name: zod_1.z.string().min(1),
|
|
29
|
+
schema: zod_1.z.string().min(1).optional(),
|
|
30
|
+
},
|
|
31
|
+
}, async ({ name, schema }) => {
|
|
32
|
+
const resolvedSchema = schema ?? "public";
|
|
33
|
+
const description = await (0, schema_1.describeTable)(deps.pool, name, resolvedSchema);
|
|
34
|
+
if (!description) {
|
|
35
|
+
throw new Error(`Table not found: ${resolvedSchema}.${name}`);
|
|
36
|
+
}
|
|
37
|
+
return asTextResult(description);
|
|
38
|
+
});
|
|
39
|
+
registerTool("list_relationships", {
|
|
40
|
+
description: "Return foreign key relationships.",
|
|
41
|
+
}, async () => {
|
|
42
|
+
const relationships = await (0, schema_1.listRelationships)(deps.pool);
|
|
43
|
+
return asTextResult(relationships);
|
|
44
|
+
});
|
|
45
|
+
registerTool("run_readonly_query", {
|
|
46
|
+
description: "Execute a safe SELECT query.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
sql: zod_1.z.string().min(1),
|
|
49
|
+
},
|
|
50
|
+
}, async ({ sql }) => {
|
|
51
|
+
const result = await (0, query_runner_1.runReadonlyQuery)(deps.pool, sql);
|
|
52
|
+
return asTextResult(result);
|
|
53
|
+
});
|
|
54
|
+
registerTool("explain_query", {
|
|
55
|
+
description: "Return query execution plan.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
sql: zod_1.z.string().min(1),
|
|
58
|
+
},
|
|
59
|
+
}, async ({ sql }) => {
|
|
60
|
+
const result = await (0, query_runner_1.explainReadonlyQuery)(deps.pool, sql);
|
|
61
|
+
return asTextResult(result);
|
|
62
|
+
});
|
|
63
|
+
registerTool("sample_rows", {
|
|
64
|
+
description: "Return example rows from a table.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
name: zod_1.z.string().min(1),
|
|
67
|
+
schema: zod_1.z.string().min(1).optional(),
|
|
68
|
+
limit: zod_1.z.number().int().positive().max(100).optional(),
|
|
69
|
+
},
|
|
70
|
+
}, async ({ name, schema, limit }) => {
|
|
71
|
+
const result = await (0, query_runner_1.sampleRows)(deps.pool, name, schema ?? "public", limit ?? 10);
|
|
72
|
+
return asTextResult(result);
|
|
73
|
+
});
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ajan-sql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-safe MCP server for schema-aware, read-only SQL access.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ajan-sql": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"docs:build": "vitepress build docs",
|
|
18
|
+
"docs:dev": "vitepress dev docs",
|
|
19
|
+
"docs:preview": "vitepress preview docs",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test",
|
|
21
|
+
"prepare": "husky",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mcp",
|
|
28
|
+
"model-context-protocol",
|
|
29
|
+
"postgres",
|
|
30
|
+
"postgresql",
|
|
31
|
+
"sql",
|
|
32
|
+
"ai",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"author": "Bora Kilicoglu",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"type": "commonjs",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
40
|
+
"pg": "^8.16.3",
|
|
41
|
+
"zod": "^3.25.76"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"@types/pg": "^8.15.6",
|
|
46
|
+
"husky": "^9.1.7",
|
|
47
|
+
"tsx": "^4.20.6",
|
|
48
|
+
"typescript": "^5.9.0",
|
|
49
|
+
"vitepress": "^1.6.4",
|
|
50
|
+
"vitest": "^3.2.0"
|
|
51
|
+
}
|
|
52
|
+
}
|