ajan-sql 0.1.2 → 0.1.3
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 +0 -2
- package/dist/db/schema.js +62 -4
- package/dist/query-runner/index.js +65 -4
- package/dist/tools/schema-tools.js +21 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,8 +36,6 @@
|
|
|
36
36
|
|
|
37
37
|
`ajan-sql` is an npm package for running an MCP server over stdio with a PostgreSQL backend.
|
|
38
38
|
|
|
39
|
-
`ajan-sql` provides schema-aware, read-only PostgreSQL access for MCP and AI workflows.
|
|
40
|
-
|
|
41
39
|
## Goals
|
|
42
40
|
|
|
43
41
|
- Safe, read-only database access for AI agents
|
package/dist/db/schema.js
CHANGED
|
@@ -18,10 +18,59 @@ async function listTables(pool) {
|
|
|
18
18
|
}
|
|
19
19
|
async function describeTable(pool, tableName, schemaName = "public") {
|
|
20
20
|
const result = await pool.query(`
|
|
21
|
-
select
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
select
|
|
22
|
+
c.column_name,
|
|
23
|
+
c.data_type,
|
|
24
|
+
c.is_nullable,
|
|
25
|
+
c.column_default,
|
|
26
|
+
exists (
|
|
27
|
+
select 1
|
|
28
|
+
from information_schema.table_constraints tc
|
|
29
|
+
join information_schema.key_column_usage kcu
|
|
30
|
+
on tc.constraint_name = kcu.constraint_name
|
|
31
|
+
and tc.table_schema = kcu.table_schema
|
|
32
|
+
where tc.constraint_type = 'PRIMARY KEY'
|
|
33
|
+
and tc.table_schema = c.table_schema
|
|
34
|
+
and tc.table_name = c.table_name
|
|
35
|
+
and kcu.column_name = c.column_name
|
|
36
|
+
) as is_primary_key,
|
|
37
|
+
exists (
|
|
38
|
+
select 1
|
|
39
|
+
from information_schema.table_constraints tc
|
|
40
|
+
join information_schema.key_column_usage kcu
|
|
41
|
+
on tc.constraint_name = kcu.constraint_name
|
|
42
|
+
and tc.table_schema = kcu.table_schema
|
|
43
|
+
where tc.constraint_type in ('PRIMARY KEY', 'UNIQUE')
|
|
44
|
+
and tc.table_schema = c.table_schema
|
|
45
|
+
and tc.table_name = c.table_name
|
|
46
|
+
and kcu.column_name = c.column_name
|
|
47
|
+
) as is_unique,
|
|
48
|
+
fk.target_schema as referenced_schema,
|
|
49
|
+
fk.target_table as referenced_table,
|
|
50
|
+
fk.target_column as referenced_column
|
|
51
|
+
from information_schema.columns c
|
|
52
|
+
left join (
|
|
53
|
+
select
|
|
54
|
+
tc.table_schema as source_schema,
|
|
55
|
+
tc.table_name as source_table,
|
|
56
|
+
kcu.column_name as source_column,
|
|
57
|
+
ccu.table_schema as target_schema,
|
|
58
|
+
ccu.table_name as target_table,
|
|
59
|
+
ccu.column_name as target_column
|
|
60
|
+
from information_schema.table_constraints tc
|
|
61
|
+
join information_schema.key_column_usage kcu
|
|
62
|
+
on tc.constraint_name = kcu.constraint_name
|
|
63
|
+
and tc.table_schema = kcu.table_schema
|
|
64
|
+
join information_schema.constraint_column_usage ccu
|
|
65
|
+
on ccu.constraint_name = tc.constraint_name
|
|
66
|
+
and ccu.table_schema = tc.table_schema
|
|
67
|
+
where tc.constraint_type = 'FOREIGN KEY'
|
|
68
|
+
) fk
|
|
69
|
+
on fk.source_schema = c.table_schema
|
|
70
|
+
and fk.source_table = c.table_name
|
|
71
|
+
and fk.source_column = c.column_name
|
|
72
|
+
where c.table_schema = $1
|
|
73
|
+
and c.table_name = $2
|
|
25
74
|
order by ordinal_position
|
|
26
75
|
`, [schemaName, tableName]);
|
|
27
76
|
if (result.rows.length === 0) {
|
|
@@ -35,6 +84,15 @@ async function describeTable(pool, tableName, schemaName = "public") {
|
|
|
35
84
|
dataType: row.data_type,
|
|
36
85
|
isNullable: row.is_nullable === "YES",
|
|
37
86
|
defaultValue: row.column_default,
|
|
87
|
+
isPrimaryKey: row.is_primary_key,
|
|
88
|
+
isUnique: row.is_unique,
|
|
89
|
+
references: row.referenced_schema && row.referenced_table && row.referenced_column
|
|
90
|
+
? {
|
|
91
|
+
schema: row.referenced_schema,
|
|
92
|
+
table: row.referenced_table,
|
|
93
|
+
column: row.referenced_column,
|
|
94
|
+
}
|
|
95
|
+
: null,
|
|
38
96
|
})),
|
|
39
97
|
};
|
|
40
98
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.runReadonlyQuery = runReadonlyQuery;
|
|
4
4
|
exports.explainReadonlyQuery = explainReadonlyQuery;
|
|
5
5
|
exports.sampleRows = sampleRows;
|
|
6
|
+
const schema_1 = require("../db/schema");
|
|
6
7
|
const guard_1 = require("../guard");
|
|
7
8
|
async function runReadonlyQuery(pool, sql, options = {}) {
|
|
8
9
|
const guarded = (0, guard_1.guardReadonlyQuery)(sql, options);
|
|
@@ -12,6 +13,8 @@ async function runReadonlyQuery(pool, sql, options = {}) {
|
|
|
12
13
|
return {
|
|
13
14
|
sql: guarded.sql,
|
|
14
15
|
rowCount: result.rows.length,
|
|
16
|
+
durationMs: result.durationMs,
|
|
17
|
+
columns: result.fields.map(toQueryColumn),
|
|
15
18
|
rows: result.rows,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -22,27 +25,54 @@ async function explainReadonlyQuery(pool, sql) {
|
|
|
22
25
|
const plan = result.rows[0]?.["QUERY PLAN"];
|
|
23
26
|
return {
|
|
24
27
|
sql: guarded.sql,
|
|
28
|
+
durationMs: result.durationMs,
|
|
29
|
+
summary: summarizeExplainPlan(plan),
|
|
25
30
|
plan,
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
|
-
async function sampleRows(pool, tableName, schemaName = "public", limit = 10) {
|
|
33
|
+
async function sampleRows(pool, tableName, schemaName = "public", limit = 10, columns) {
|
|
29
34
|
const defaults = (0, guard_1.getReadonlyDefaults)();
|
|
30
35
|
const safeLimit = Math.min(limit, defaults.maxLimit);
|
|
36
|
+
const description = await (0, schema_1.describeTable)(pool, tableName, schemaName);
|
|
37
|
+
if (!description) {
|
|
38
|
+
throw new Error(`Table not found: ${schemaName}.${tableName}`);
|
|
39
|
+
}
|
|
40
|
+
const availableColumns = new Set(description.columns.map((column) => column.name));
|
|
41
|
+
const selectedColumns = columns && columns.length > 0
|
|
42
|
+
? columns.map((column) => {
|
|
43
|
+
if (!availableColumns.has(column)) {
|
|
44
|
+
throw new Error(`Unknown column for sample_rows: ${column}`);
|
|
45
|
+
}
|
|
46
|
+
return (0, guard_1.quoteIdentifier)(column);
|
|
47
|
+
})
|
|
48
|
+
: ["*"];
|
|
49
|
+
const primaryKeyColumns = description.columns
|
|
50
|
+
.filter((column) => column.isPrimaryKey)
|
|
51
|
+
.map((column) => (0, guard_1.quoteIdentifier)(column.name));
|
|
31
52
|
const sql = [
|
|
32
|
-
|
|
53
|
+
`SELECT ${selectedColumns.join(", ")}`,
|
|
33
54
|
`FROM ${(0, guard_1.quoteIdentifier)(schemaName)}.${(0, guard_1.quoteIdentifier)(tableName)}`,
|
|
55
|
+
primaryKeyColumns.length > 0
|
|
56
|
+
? `ORDER BY ${primaryKeyColumns.join(", ")}`
|
|
57
|
+
: "",
|
|
34
58
|
`LIMIT ${safeLimit}`,
|
|
35
|
-
]
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" ");
|
|
36
62
|
return runReadonlyQuery(pool, sql, { defaultLimit: safeLimit });
|
|
37
63
|
}
|
|
38
64
|
async function executeWithReadonlySettings(pool, sql, timeoutMs) {
|
|
39
65
|
const client = await pool.connect();
|
|
66
|
+
const startedAt = Date.now();
|
|
40
67
|
try {
|
|
41
68
|
await client.query("BEGIN");
|
|
42
69
|
await client.query(`SET LOCAL statement_timeout = '${timeoutMs}ms'`);
|
|
43
70
|
const result = await client.query(sql);
|
|
44
71
|
await client.query("ROLLBACK");
|
|
45
|
-
return
|
|
72
|
+
return {
|
|
73
|
+
...result,
|
|
74
|
+
durationMs: Date.now() - startedAt,
|
|
75
|
+
};
|
|
46
76
|
}
|
|
47
77
|
catch (error) {
|
|
48
78
|
await client.query("ROLLBACK").catch(() => undefined);
|
|
@@ -52,6 +82,37 @@ async function executeWithReadonlySettings(pool, sql, timeoutMs) {
|
|
|
52
82
|
client.release();
|
|
53
83
|
}
|
|
54
84
|
}
|
|
85
|
+
function toQueryColumn(field) {
|
|
86
|
+
return {
|
|
87
|
+
name: field.name,
|
|
88
|
+
dataTypeId: field.dataTypeID,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function summarizeExplainPlan(plan) {
|
|
92
|
+
if (!Array.isArray(plan) || plan.length === 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const root = plan[0];
|
|
96
|
+
const rootPlan = root?.Plan;
|
|
97
|
+
if (!rootPlan || typeof rootPlan !== "object") {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const childPlans = Array.isArray(rootPlan.Plans) ? rootPlan.Plans : [];
|
|
101
|
+
return {
|
|
102
|
+
nodeType: getStringField(rootPlan, "Node Type"),
|
|
103
|
+
relationName: getStringField(rootPlan, "Relation Name"),
|
|
104
|
+
planRows: getNumberField(rootPlan, "Plan Rows"),
|
|
105
|
+
startupCost: getNumberField(rootPlan, "Startup Cost"),
|
|
106
|
+
totalCost: getNumberField(rootPlan, "Total Cost"),
|
|
107
|
+
childCount: childPlans.length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function getStringField(plan, key) {
|
|
111
|
+
return typeof plan[key] === "string" ? plan[key] : null;
|
|
112
|
+
}
|
|
113
|
+
function getNumberField(plan, key) {
|
|
114
|
+
return typeof plan[key] === "number" ? plan[key] : null;
|
|
115
|
+
}
|
|
55
116
|
function assertRowCount(rowCount, limit) {
|
|
56
117
|
if (rowCount > limit) {
|
|
57
118
|
throw new Error(`Query returned more rows than allowed limit of ${limit}`);
|
|
@@ -14,13 +14,24 @@ function asTextResult(data) {
|
|
|
14
14
|
],
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
function asStructuredResult(summary, data) {
|
|
18
|
+
return {
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: "text",
|
|
22
|
+
text: summary,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
structuredContent: data,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
17
28
|
function registerSchemaTools(server, deps) {
|
|
18
29
|
const registerTool = server.registerTool.bind(server);
|
|
19
30
|
registerTool("list_tables", {
|
|
20
31
|
description: "Return all tables in the database.",
|
|
21
32
|
}, async () => {
|
|
22
33
|
const tables = await (0, schema_1.listTables)(deps.pool);
|
|
23
|
-
return
|
|
34
|
+
return asStructuredResult(`Listed ${tables.length} tables.`, tables);
|
|
24
35
|
});
|
|
25
36
|
registerTool("describe_table", {
|
|
26
37
|
description: "Return columns and types for a given table.",
|
|
@@ -34,13 +45,13 @@ function registerSchemaTools(server, deps) {
|
|
|
34
45
|
if (!description) {
|
|
35
46
|
throw new Error(`Table not found: ${resolvedSchema}.${name}`);
|
|
36
47
|
}
|
|
37
|
-
return
|
|
48
|
+
return asStructuredResult(`Described table ${resolvedSchema}.${name} with ${description.columns.length} columns.`, description);
|
|
38
49
|
});
|
|
39
50
|
registerTool("list_relationships", {
|
|
40
51
|
description: "Return foreign key relationships.",
|
|
41
52
|
}, async () => {
|
|
42
53
|
const relationships = await (0, schema_1.listRelationships)(deps.pool);
|
|
43
|
-
return
|
|
54
|
+
return asStructuredResult(`Listed ${relationships.length} foreign key relationships.`, relationships);
|
|
44
55
|
});
|
|
45
56
|
registerTool("run_readonly_query", {
|
|
46
57
|
description: "Execute a safe SELECT query.",
|
|
@@ -49,7 +60,7 @@ function registerSchemaTools(server, deps) {
|
|
|
49
60
|
},
|
|
50
61
|
}, async ({ sql }) => {
|
|
51
62
|
const result = await (0, query_runner_1.runReadonlyQuery)(deps.pool, sql);
|
|
52
|
-
return
|
|
63
|
+
return asStructuredResult(`Query returned ${result.rowCount} rows in ${result.durationMs}ms.`, result);
|
|
53
64
|
});
|
|
54
65
|
registerTool("explain_query", {
|
|
55
66
|
description: "Return query execution plan.",
|
|
@@ -58,7 +69,8 @@ function registerSchemaTools(server, deps) {
|
|
|
58
69
|
},
|
|
59
70
|
}, async ({ sql }) => {
|
|
60
71
|
const result = await (0, query_runner_1.explainReadonlyQuery)(deps.pool, sql);
|
|
61
|
-
|
|
72
|
+
const rootNode = result.summary?.nodeType ?? "unknown";
|
|
73
|
+
return asStructuredResult(`Explain plan generated in ${result.durationMs}ms. Root node: ${rootNode}.`, result);
|
|
62
74
|
});
|
|
63
75
|
registerTool("sample_rows", {
|
|
64
76
|
description: "Return example rows from a table.",
|
|
@@ -66,9 +78,10 @@ function registerSchemaTools(server, deps) {
|
|
|
66
78
|
name: zod_1.z.string().min(1),
|
|
67
79
|
schema: zod_1.z.string().min(1).optional(),
|
|
68
80
|
limit: zod_1.z.number().int().positive().max(100).optional(),
|
|
81
|
+
columns: zod_1.z.array(zod_1.z.string().min(1)).optional(),
|
|
69
82
|
},
|
|
70
|
-
}, async ({ name, schema, limit }) => {
|
|
71
|
-
const result = await (0, query_runner_1.sampleRows)(deps.pool, name, schema ?? "public", limit ?? 10);
|
|
72
|
-
return
|
|
83
|
+
}, async ({ name, schema, limit, columns }) => {
|
|
84
|
+
const result = await (0, query_runner_1.sampleRows)(deps.pool, name, schema ?? "public", limit ?? 10, columns);
|
|
85
|
+
return asStructuredResult(`Sampled ${result.rowCount} rows from ${(schema ?? "public")}.${name} in ${result.durationMs}ms.`, result);
|
|
73
86
|
});
|
|
74
87
|
}
|