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 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 column_name, data_type, is_nullable, column_default
22
- from information_schema.columns
23
- where table_schema = $1
24
- and table_name = $2
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
- "SELECT *",
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
- ].join(" ");
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 result;
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 asTextResult(tables);
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 asTextResult(description);
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 asTextResult(relationships);
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 asTextResult(result);
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
- return asTextResult(result);
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 asTextResult(result);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ajan-sql",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "AI-safe MCP server for schema-aware, read-only SQL access.",
5
5
  "bin": {
6
6
  "ajan-sql": "dist/index.js"