easy-pg-mcp 1.0.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.
@@ -0,0 +1,186 @@
1
+ import { createRequire } from 'node:module';
2
+ import { config, isReadOnlyMode, normalizeTableName } from './config.js';
3
+ const require = createRequire(import.meta.url);
4
+ const { Parser } = require('node-sql-parser/build/postgresql.js');
5
+ const parser = new Parser();
6
+ const parserOptions = { database: 'Postgresql' };
7
+ const readQueryTypes = new Set(['select', 'show', 'explain']);
8
+ const executeTypes = new Set(['insert', 'update', 'delete']);
9
+ export class SqlPolicyError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'SqlPolicyError';
13
+ }
14
+ }
15
+ export function assertReadQueryAllowed(sql) {
16
+ const parsed = parseSingleStatement(sql);
17
+ if (!readQueryTypes.has(parsed.type)) {
18
+ throw new SqlPolicyError(`SQL rejected: pg_query only allows SELECT, SHOW, and EXPLAIN statements. Received ${parsed.type.toUpperCase()}.`);
19
+ }
20
+ assertNoUnsafeReadOptions(parsed.ast);
21
+ assertTablePolicy(parsed.tables);
22
+ }
23
+ export function analyzeSql(sql) {
24
+ const parsed = parseSingleStatement(sql);
25
+ return {
26
+ statementType: parsed.type,
27
+ tableNames: parsed.tables,
28
+ };
29
+ }
30
+ export function assertExplainQueryAllowed(sql) {
31
+ const parsed = parseSingleStatement(sql);
32
+ if (parsed.type !== 'select') {
33
+ throw new SqlPolicyError(`SQL rejected: explain_query only accepts a SELECT statement. Received ${parsed.type.toUpperCase()}.`);
34
+ }
35
+ assertNoUnsafeReadOptions(parsed.ast);
36
+ assertTablePolicy(parsed.tables);
37
+ }
38
+ export function assertExecuteAllowed(sql) {
39
+ if (isReadOnlyMode()) {
40
+ throw new SqlPolicyError('SQL rejected: write execution is disabled because PG_READ_ONLY=true or PG_MCP_MODE=readonly.');
41
+ }
42
+ const parsed = parseSingleStatement(sql);
43
+ if (!executeTypes.has(parsed.type)) {
44
+ throw new SqlPolicyError(`SQL rejected: pg_execute only allows INSERT, UPDATE, and DELETE statements. Received ${parsed.type.toUpperCase()}.`);
45
+ }
46
+ assertTablePolicy(parsed.tables);
47
+ }
48
+ export function assertTablesAllowed(tables) {
49
+ assertTablePolicy(tables.map((table) => normalizeTableName(table)));
50
+ }
51
+ export function isTableAllowed(table) {
52
+ try {
53
+ assertTablesAllowed([table]);
54
+ return true;
55
+ }
56
+ catch (error) {
57
+ if (error instanceof SqlPolicyError) {
58
+ return false;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ function parseSingleStatement(sql) {
64
+ let parsed;
65
+ try {
66
+ parsed = parser.parse(sql, parserOptions);
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ throw new SqlPolicyError(`SQL rejected: unable to parse statement as PostgreSQL SQL. ${message}`);
71
+ }
72
+ const ast = parsed.ast;
73
+ if (Array.isArray(ast)) {
74
+ if (ast.length !== 1) {
75
+ throw new SqlPolicyError('SQL rejected: multiple statements are not allowed.');
76
+ }
77
+ return {
78
+ ast: ast[0],
79
+ type: getStatementType(ast[0]),
80
+ tables: getVisitedTables(parsed, ast[0]),
81
+ };
82
+ }
83
+ return {
84
+ ast,
85
+ type: getStatementType(ast),
86
+ tables: getVisitedTables(parsed, ast),
87
+ };
88
+ }
89
+ function getStatementType(ast) {
90
+ const type = typeof ast?.type === 'string' ? ast.type.toLowerCase() : 'unknown';
91
+ return type === 'desc' ? 'describe' : type;
92
+ }
93
+ function assertNoUnsafeReadOptions(ast) {
94
+ if (Array.isArray(ast)) {
95
+ for (const statement of ast) {
96
+ assertNoUnsafeReadOptions(statement);
97
+ }
98
+ return;
99
+ }
100
+ if (ast?.type === 'select' && ast.into?.keyword) {
101
+ throw new SqlPolicyError('SQL rejected: SELECT ... INTO is not allowed.');
102
+ }
103
+ if (ast?.locking_read) {
104
+ throw new SqlPolicyError('SQL rejected: locking reads are not allowed in pg_query.');
105
+ }
106
+ if (Array.isArray(ast?.with)) {
107
+ for (const cte of ast.with) {
108
+ assertNoUnsafeReadOptions(cte?.stmt?.ast);
109
+ }
110
+ }
111
+ if (ast?.type === 'explain') {
112
+ assertNoUnsafeReadOptions(ast.expr);
113
+ }
114
+ }
115
+ function assertTablePolicy(tables) {
116
+ for (const table of tables) {
117
+ if (matchesTableList(table, config.denyTables)) {
118
+ throw new SqlPolicyError(`SQL rejected: table "${table}" is denied by PG_MCP_DENY_TABLES.`);
119
+ }
120
+ if (config.allowTables.length > 0 && !matchesTableList(table, config.allowTables)) {
121
+ throw new SqlPolicyError(`SQL rejected: table "${table}" is not included in PG_MCP_ALLOW_TABLES.`);
122
+ }
123
+ }
124
+ }
125
+ function matchesTableList(table, configuredTables) {
126
+ if (configuredTables.includes(table)) {
127
+ return true;
128
+ }
129
+ const tableOnly = table.split('.').at(-1);
130
+ return tableOnly ? configuredTables.includes(tableOnly) : false;
131
+ }
132
+ function getVisitedTables(parsed, ast) {
133
+ const cteNames = collectCteNames(ast);
134
+ const tables = new Set();
135
+ for (const tableRef of parsed.tableList) {
136
+ const table = normalizeTableRef(tableRef);
137
+ if (table && !cteNames.has(table)) {
138
+ tables.add(table);
139
+ }
140
+ }
141
+ for (const table of collectTablesFromAst(ast)) {
142
+ const normalized = normalizeTableName(table);
143
+ if (normalized && !cteNames.has(normalized)) {
144
+ tables.add(normalized);
145
+ }
146
+ }
147
+ return [...tables];
148
+ }
149
+ function normalizeTableRef(tableRef) {
150
+ const [, dbName, tableName] = tableRef.split('::');
151
+ if (!tableName || tableName === 'null') {
152
+ return null;
153
+ }
154
+ const normalizedTable = normalizeTableName(tableName);
155
+ if (dbName && dbName !== 'null') {
156
+ return `${normalizeTableName(dbName)}.${normalizedTable}`;
157
+ }
158
+ return normalizedTable;
159
+ }
160
+ function collectCteNames(ast) {
161
+ const names = new Set();
162
+ if (!Array.isArray(ast?.with)) {
163
+ return names;
164
+ }
165
+ for (const cte of ast.with) {
166
+ const value = cte?.name?.value;
167
+ if (typeof value === 'string') {
168
+ names.add(normalizeTableName(value));
169
+ }
170
+ }
171
+ return names;
172
+ }
173
+ function collectTablesFromAst(ast) {
174
+ if (!ast) {
175
+ return [];
176
+ }
177
+ switch (ast.type) {
178
+ case 'desc':
179
+ case 'describe':
180
+ return typeof ast.table === 'string' ? [ast.table] : [];
181
+ case 'explain':
182
+ return collectTablesFromAst(ast.expr);
183
+ default:
184
+ return [];
185
+ }
186
+ }
@@ -0,0 +1,249 @@
1
+ import { config } from './config.js';
2
+ import { exportCsv, importCsv } from './csvTools.js';
3
+ import * as db from './db.js';
4
+ import { writeBatchExecuteLog } from './logs.js';
5
+ import { runWithPolicy } from './policyHook.js';
6
+ import { analyzeSql, assertExecuteAllowed, assertExplainQueryAllowed, assertReadQueryAllowed, assertTablesAllowed, isTableAllowed, } from './sqlPolicy.js';
7
+ export async function pgQuery(sql) {
8
+ assertReadQueryAllowed(sql);
9
+ const analysis = analyzeSql(sql);
10
+ return runWithPolicy({
11
+ functionName: 'pg_query',
12
+ sql,
13
+ statementType: analysis.statementType,
14
+ tableNames: analysis.tableNames,
15
+ }, () => db.query(sql));
16
+ }
17
+ export async function pgExecute(sql, params) {
18
+ assertExecuteAllowed(sql);
19
+ const analysis = analyzeSql(sql);
20
+ return runWithPolicy({
21
+ functionName: 'pg_execute',
22
+ sql,
23
+ statementType: analysis.statementType,
24
+ tableNames: analysis.tableNames,
25
+ paramsPreview: params ?? null,
26
+ summary: { sql, paramsPreview: params ?? null },
27
+ }, () => db.execute(sql, params));
28
+ }
29
+ export async function pgBatchExecute(sql, paramsList, transaction) {
30
+ assertExecuteAllowed(sql);
31
+ const analysis = analyzeSql(sql);
32
+ return runWithPolicy({
33
+ functionName: 'pg_batch_execute',
34
+ sql,
35
+ statementType: analysis.statementType,
36
+ tableNames: analysis.tableNames,
37
+ paramsPreview: { rows: paramsList.length, firstParams: paramsList[0] ?? null },
38
+ summary: { sql, rows: paramsList.length, transaction },
39
+ }, async () => {
40
+ const summary = await db.batchExecute(sql, paramsList, {
41
+ batchSize: config.batchMaxSize,
42
+ transaction,
43
+ });
44
+ const logPath = await writeBatchExecuteLog({
45
+ tool: 'pg_batch_execute',
46
+ success: true,
47
+ sql,
48
+ summary,
49
+ });
50
+ return {
51
+ totalRows: summary.totalRows,
52
+ batches: summary.batches,
53
+ batchSize: summary.batchSize,
54
+ transaction: summary.transaction,
55
+ affectedRows: summary.affectedRows,
56
+ changedRows: summary.changedRows,
57
+ logPath,
58
+ };
59
+ }).catch(async (error) => {
60
+ if (!(error instanceof db.BatchExecuteError)) {
61
+ throw error;
62
+ }
63
+ const logPath = await writeBatchExecuteLog({
64
+ tool: 'pg_batch_execute',
65
+ success: false,
66
+ sql,
67
+ summary: error.summary,
68
+ error: error.message,
69
+ });
70
+ throw new Error(`Batch execution failed: ${error.message}. Detailed log: ${logPath}`);
71
+ });
72
+ }
73
+ export async function pgImportCsv(tableName, filePath, transaction) {
74
+ assertTablesAllowed([tableName]);
75
+ return runWithPolicy({
76
+ functionName: 'pg_import_csv',
77
+ sql: null,
78
+ statementType: 'insert',
79
+ tableNames: [tableName],
80
+ paramsPreview: { filePath, transaction },
81
+ summary: { tableName, filePath, transaction },
82
+ }, () => importCsv(tableName, filePath, transaction));
83
+ }
84
+ export async function pgExportCsv(tableName, filePath) {
85
+ assertTablesAllowed([tableName]);
86
+ return runWithPolicy({
87
+ functionName: 'pg_export_csv',
88
+ sql: null,
89
+ statementType: 'export',
90
+ tableNames: [tableName],
91
+ paramsPreview: { filePath },
92
+ summary: { tableName, filePath },
93
+ }, () => exportCsv(tableName, filePath));
94
+ }
95
+ export async function explainQuery(sql) {
96
+ assertExplainQueryAllowed(sql);
97
+ const analysis = analyzeSql(sql);
98
+ return runWithPolicy({
99
+ functionName: 'explain_query',
100
+ sql,
101
+ statementType: 'explain',
102
+ tableNames: analysis.tableNames,
103
+ }, () => db.query(`EXPLAIN ${sql}`));
104
+ }
105
+ export async function listTables() {
106
+ return runWithPolicy({
107
+ functionName: 'list_tables',
108
+ sql: null,
109
+ statementType: 'schema',
110
+ tableNames: [],
111
+ }, async () => {
112
+ const results = await db.query(`
113
+ SELECT
114
+ table_name,
115
+ COALESCE(pg_class.reltuples::bigint, 0) AS table_rows,
116
+ obj_description(pg_class.oid, 'pg_class') AS table_comment
117
+ FROM information_schema.tables
118
+ LEFT JOIN pg_namespace
119
+ ON pg_namespace.nspname = information_schema.tables.table_schema
120
+ LEFT JOIN pg_class
121
+ ON pg_class.relname = information_schema.tables.table_name
122
+ AND pg_class.relnamespace = pg_namespace.oid
123
+ WHERE table_schema = current_schema()
124
+ AND table_type = 'BASE TABLE'
125
+ ORDER BY table_name
126
+ `);
127
+ return Array.isArray(results)
128
+ ? results.filter((row) => typeof row.table_name === 'string' && isTableAllowed(row.table_name))
129
+ : results;
130
+ });
131
+ }
132
+ export async function listViews() {
133
+ return runWithPolicy({
134
+ functionName: 'list_views',
135
+ sql: null,
136
+ statementType: 'schema',
137
+ tableNames: [],
138
+ }, async () => {
139
+ const results = await db.query(`
140
+ SELECT table_name, view_definition
141
+ FROM information_schema.views
142
+ WHERE table_schema = current_schema()
143
+ ORDER BY table_name
144
+ `);
145
+ return Array.isArray(results)
146
+ ? results.filter((row) => typeof row.table_name === 'string' && isTableAllowed(row.table_name))
147
+ : results;
148
+ });
149
+ }
150
+ export async function describeTable(tables) {
151
+ assertTablesAllowed(tables);
152
+ return runWithPolicy({
153
+ functionName: 'describe_table',
154
+ sql: null,
155
+ statementType: 'schema',
156
+ tableNames: tables,
157
+ summary: { tables },
158
+ }, async () => {
159
+ const results = {};
160
+ for (const table of tables) {
161
+ const { schema, name } = splitTableName(table);
162
+ results[table] = await db.query(`
163
+ SELECT
164
+ column_name,
165
+ data_type,
166
+ is_nullable,
167
+ column_default,
168
+ character_maximum_length,
169
+ numeric_precision,
170
+ numeric_scale
171
+ FROM information_schema.columns
172
+ WHERE table_schema = COALESCE($1, current_schema())
173
+ AND table_name = $2
174
+ ORDER BY ordinal_position
175
+ `, [schema, name]);
176
+ }
177
+ return results;
178
+ });
179
+ }
180
+ export async function describeIndex(table) {
181
+ assertTablesAllowed([table]);
182
+ return runWithPolicy({
183
+ functionName: 'describe_index',
184
+ sql: null,
185
+ statementType: 'schema',
186
+ tableNames: [table],
187
+ summary: { table },
188
+ }, () => {
189
+ const { schema, name } = splitTableName(table);
190
+ return db.query(`
191
+ SELECT
192
+ schemaname AS schema_name,
193
+ tablename AS table_name,
194
+ indexname AS index_name,
195
+ indexdef AS index_definition
196
+ FROM pg_indexes
197
+ WHERE schemaname = COALESCE($1, current_schema())
198
+ AND tablename = $2
199
+ ORDER BY indexname
200
+ `, [schema, name]);
201
+ });
202
+ }
203
+ export async function listTriggers() {
204
+ return runWithPolicy({
205
+ functionName: 'list_triggers',
206
+ sql: null,
207
+ statementType: 'schema',
208
+ tableNames: [],
209
+ }, async () => {
210
+ const results = await db.query(`
211
+ SELECT
212
+ trigger_name,
213
+ event_object_table AS table_name,
214
+ event_manipulation,
215
+ action_timing,
216
+ action_statement
217
+ FROM information_schema.triggers
218
+ WHERE trigger_schema = current_schema()
219
+ ORDER BY trigger_name, event_manipulation
220
+ `);
221
+ return Array.isArray(results)
222
+ ? results.filter((row) => typeof row.table_name === 'string' && isTableAllowed(row.table_name))
223
+ : results;
224
+ });
225
+ }
226
+ export async function getCurrentPrivileges() {
227
+ return runWithPolicy({
228
+ functionName: 'get_current_privileges',
229
+ sql: null,
230
+ statementType: 'privileges',
231
+ tableNames: [],
232
+ }, async () => {
233
+ const user = await db.query('SELECT current_user AS user');
234
+ const grants = await db.query(`
235
+ SELECT grantee, table_schema, table_name, privilege_type, is_grantable
236
+ FROM information_schema.role_table_grants
237
+ WHERE grantee = current_user
238
+ ORDER BY table_schema, table_name, privilege_type
239
+ `);
240
+ return { currentUser: user, grants };
241
+ });
242
+ }
243
+ function splitTableName(table) {
244
+ const parts = table.replace(/"/g, '').split('.');
245
+ if (parts.length > 1) {
246
+ return { schema: parts[0], name: parts.at(-1) ?? table };
247
+ }
248
+ return { schema: null, name: table.replace(/"/g, '') };
249
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "easy-pg-mcp",
3
+ "version": "1.0.0",
4
+ "description": "High performance PostgreSQL MCP Server using node-postgres",
5
+ "main": "build/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "easy-pg-mcp": "build/index.js"
9
+ },
10
+ "files": [
11
+ "build",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "postgresql",
21
+ "postgres",
22
+ "pg",
23
+ "node-postgres",
24
+ "claude"
25
+ ],
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "test": "npm run build && node --test test/*.test.mjs",
33
+ "start": "node build/index.js",
34
+ "dev": "tsc --watch",
35
+ "prepack": "npm run build"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "node-sql-parser": "^5.4.0",
40
+ "pg": "^8.21.0",
41
+ "zod": "^4.4.3"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^25.6.0",
45
+ "@types/pg": "^8.15.6",
46
+ "typescript": "^6.0.3"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/chenkumi/easy-pg-mcp.git"
51
+ }
52
+ }