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,97 @@
1
+ import { config } from './config.js';
2
+ import { readCsvFile, writeCsvFile } from './csv.js';
3
+ import * as db from './db.js';
4
+ import { writeBatchExecuteLog } from './logs.js';
5
+ import { assertExecuteAllowed, assertTablesAllowed } from './sqlPolicy.js';
6
+ export async function importCsv(tableName, filePath, transaction) {
7
+ assertTablesAllowed([tableName]);
8
+ const csv = await readCsvFile(filePath);
9
+ const escapedColumns = csv.headers.map((header) => db.escapeIdentifier(header)).join(', ');
10
+ const placeholders = csv.headers.map((_, index) => `$${index + 1}`).join(', ');
11
+ const sql = `INSERT INTO ${db.escapeIdentifier(tableName)} (${escapedColumns}) VALUES (${placeholders})`;
12
+ assertExecuteAllowed(sql);
13
+ try {
14
+ const summary = await db.batchExecute(sql, csv.rows, {
15
+ batchSize: config.batchMaxSize,
16
+ transaction,
17
+ });
18
+ const logPath = await writeBatchExecuteLog({
19
+ tool: 'pg_import_csv',
20
+ success: true,
21
+ tableName,
22
+ filePath,
23
+ headers: csv.headers,
24
+ sql,
25
+ summary,
26
+ });
27
+ return {
28
+ tableName,
29
+ filePath,
30
+ importedRows: summary.totalRows,
31
+ batches: summary.batches,
32
+ batchSize: summary.batchSize,
33
+ transaction: summary.transaction,
34
+ affectedRows: summary.affectedRows,
35
+ changedRows: summary.changedRows,
36
+ logPath,
37
+ };
38
+ }
39
+ catch (error) {
40
+ if (error instanceof db.BatchExecuteError) {
41
+ const logPath = await writeBatchExecuteLog({
42
+ tool: 'pg_import_csv',
43
+ success: false,
44
+ tableName,
45
+ filePath,
46
+ headers: csv.headers,
47
+ sql,
48
+ summary: error.summary,
49
+ error: error.message,
50
+ });
51
+ throw new Error(`CSV import failed: ${error.message}. Detailed log: ${logPath}`);
52
+ }
53
+ throw error;
54
+ }
55
+ }
56
+ export async function exportCsv(tableName, filePath) {
57
+ assertTablesAllowed([tableName]);
58
+ const { schema, name } = splitTableName(tableName);
59
+ const columns = await db.query(`
60
+ SELECT column_name
61
+ FROM information_schema.columns
62
+ WHERE table_schema = COALESCE($1, current_schema())
63
+ AND table_name = $2
64
+ ORDER BY ordinal_position
65
+ `, [schema, name]);
66
+ const headers = Array.isArray(columns)
67
+ ? columns
68
+ .map((column) => column.column_name)
69
+ .filter((field) => typeof field === 'string')
70
+ : [];
71
+ const rows = await db.query(`SELECT * FROM ${db.escapeIdentifier(tableName)}`);
72
+ const rowObjects = Array.isArray(rows) ? rows : [];
73
+ const exportHeaders = headers.length > 0 ? headers : collectExportHeaders(rowObjects);
74
+ await writeCsvFile(filePath, exportHeaders, rowObjects);
75
+ return {
76
+ tableName,
77
+ filePath,
78
+ exportedRows: rowObjects.length,
79
+ columns: exportHeaders.length,
80
+ };
81
+ }
82
+ function collectExportHeaders(rows) {
83
+ const headers = new Set();
84
+ for (const row of rows) {
85
+ for (const key of Object.keys(row)) {
86
+ headers.add(key);
87
+ }
88
+ }
89
+ return [...headers];
90
+ }
91
+ function splitTableName(table) {
92
+ const parts = table.replace(/"/g, '').split('.');
93
+ if (parts.length > 1) {
94
+ return { schema: parts[0], name: parts.at(-1) ?? table };
95
+ }
96
+ return { schema: null, name: table.replace(/"/g, '') };
97
+ }
package/build/db.js ADDED
@@ -0,0 +1,206 @@
1
+ import { Pool } from 'pg';
2
+ export class BatchExecuteError extends Error {
3
+ summary;
4
+ constructor(message, summary) {
5
+ super(message);
6
+ this.summary = summary;
7
+ this.name = 'BatchExecuteError';
8
+ }
9
+ }
10
+ const { PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DATABASE, PG_CONNECTION_LIMIT, PG_IDLE_TIMEOUT, PG_ENABLE_KEEP_ALIVE, PG_KEEP_ALIVE_INITIAL_DELAY, PG_SSL, PG_CONNECTION_STRING, } = process.env;
11
+ if (!PG_CONNECTION_STRING && (!PG_HOST || !PG_USER || !PG_DATABASE)) {
12
+ console.error('Missing required environment variables for PostgreSQL connection.');
13
+ process.exit(1);
14
+ }
15
+ export const pool = new Pool({
16
+ connectionString: PG_CONNECTION_STRING,
17
+ host: PG_CONNECTION_STRING ? undefined : PG_HOST,
18
+ port: PG_CONNECTION_STRING ? undefined : (PG_PORT ? parseInt(PG_PORT, 10) : 5432),
19
+ user: PG_CONNECTION_STRING ? undefined : PG_USER,
20
+ password: PG_CONNECTION_STRING ? undefined : PG_PASSWORD,
21
+ database: PG_CONNECTION_STRING ? undefined : PG_DATABASE,
22
+ max: PG_CONNECTION_LIMIT ? parseInt(PG_CONNECTION_LIMIT, 10) : 10,
23
+ idleTimeoutMillis: PG_IDLE_TIMEOUT ? parseInt(PG_IDLE_TIMEOUT, 10) : 30000,
24
+ keepAlive: PG_ENABLE_KEEP_ALIVE !== 'false',
25
+ keepAliveInitialDelayMillis: PG_KEEP_ALIVE_INITIAL_DELAY ? parseInt(PG_KEEP_ALIVE_INITIAL_DELAY, 10) : 0,
26
+ ssl: parseSslConfig(PG_SSL),
27
+ });
28
+ export async function query(sql, params) {
29
+ const result = await pool.query(sql, params);
30
+ return result.rows;
31
+ }
32
+ export async function execute(sql, params) {
33
+ const result = await pool.query(sql, params);
34
+ return toExecutionResult(result);
35
+ }
36
+ export async function batchExecute(sql, paramsList, options) {
37
+ const batches = chunk(paramsList, options.batchSize);
38
+ const summary = {
39
+ totalRows: paramsList.length,
40
+ batchSize: options.batchSize,
41
+ batches: batches.length,
42
+ transaction: options.transaction,
43
+ affectedRows: 0,
44
+ changedRows: 0,
45
+ results: [],
46
+ };
47
+ const client = await pool.connect();
48
+ let currentScope = createBatchScope();
49
+ let failedRow = null;
50
+ try {
51
+ if (options.transaction === 'all') {
52
+ await client.query('BEGIN');
53
+ }
54
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
55
+ const batch = batches[batchIndex];
56
+ if (options.transaction === 'batch') {
57
+ await client.query('BEGIN');
58
+ currentScope = createBatchScope();
59
+ }
60
+ for (let rowIndex = 0; rowIndex < batch.length; rowIndex += 1) {
61
+ if (options.transaction === 'each') {
62
+ await client.query('BEGIN');
63
+ currentScope = createBatchScope();
64
+ }
65
+ const globalRow = batchIndex * options.batchSize + rowIndex;
66
+ const rowResult = await executeBatchRow(client, sql, batch[rowIndex], batchIndex, globalRow);
67
+ if (options.transaction === 'none') {
68
+ pushCommittedResults(summary, {
69
+ results: [{ ...rowResult.rowResult, committed: true }],
70
+ affectedRows: rowResult.executionResult.affectedRows,
71
+ changedRows: rowResult.executionResult.changedRows,
72
+ });
73
+ continue;
74
+ }
75
+ currentScope.results.push(rowResult.rowResult);
76
+ currentScope.affectedRows += rowResult.executionResult.affectedRows;
77
+ currentScope.changedRows += rowResult.executionResult.changedRows;
78
+ if (options.transaction === 'each') {
79
+ await client.query('COMMIT');
80
+ pushCommittedResults(summary, currentScope);
81
+ currentScope = createBatchScope();
82
+ }
83
+ }
84
+ if (options.transaction === 'batch') {
85
+ await client.query('COMMIT');
86
+ pushCommittedResults(summary, currentScope);
87
+ currentScope = createBatchScope();
88
+ }
89
+ }
90
+ if (options.transaction === 'all') {
91
+ await client.query('COMMIT');
92
+ pushCommittedResults(summary, currentScope);
93
+ }
94
+ return summary;
95
+ }
96
+ catch (error) {
97
+ if (error instanceof BatchRowError) {
98
+ failedRow = error.rowResult;
99
+ error = error.cause;
100
+ }
101
+ if (options.transaction !== 'none') {
102
+ try {
103
+ await client.query('ROLLBACK');
104
+ }
105
+ catch {
106
+ // Preserve the original batch execution error.
107
+ }
108
+ }
109
+ if (currentScope.results.length > 0) {
110
+ pushRolledBackResults(summary, currentScope);
111
+ }
112
+ if (failedRow) {
113
+ summary.results.push(failedRow);
114
+ }
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ throw new BatchExecuteError(message, summary);
117
+ }
118
+ finally {
119
+ client.release();
120
+ }
121
+ }
122
+ export function escapeIdentifier(identifier) {
123
+ return identifier
124
+ .split('.')
125
+ .map((part) => `"${part.replace(/"/g, '""')}"`)
126
+ .join('.');
127
+ }
128
+ function parseSslConfig(value) {
129
+ if (!value || value.toLowerCase() === 'false') {
130
+ return undefined;
131
+ }
132
+ if (value.toLowerCase() === 'true') {
133
+ return true;
134
+ }
135
+ if (value.toLowerCase() === 'no-verify') {
136
+ return { rejectUnauthorized: false };
137
+ }
138
+ return undefined;
139
+ }
140
+ class BatchRowError extends Error {
141
+ rowResult;
142
+ constructor(rowResult, cause) {
143
+ super(rowResult.error ?? 'Batch row execution failed.');
144
+ this.rowResult = rowResult;
145
+ this.name = 'BatchRowError';
146
+ this.cause = cause;
147
+ }
148
+ }
149
+ async function executeBatchRow(client, sql, params, batchIndex, globalRow) {
150
+ try {
151
+ const result = await client.query(sql, params);
152
+ const executionResult = toExecutionResult(result);
153
+ return {
154
+ rowResult: {
155
+ batch: batchIndex + 1,
156
+ row: globalRow + 1,
157
+ success: true,
158
+ params,
159
+ result: executionResult,
160
+ },
161
+ executionResult,
162
+ };
163
+ }
164
+ catch (error) {
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ throw new BatchRowError({
167
+ batch: batchIndex + 1,
168
+ row: globalRow + 1,
169
+ success: false,
170
+ params,
171
+ committed: false,
172
+ error: message,
173
+ }, error);
174
+ }
175
+ }
176
+ function toExecutionResult(result) {
177
+ return {
178
+ command: result.command,
179
+ affectedRows: result.rowCount ?? 0,
180
+ changedRows: result.command === 'UPDATE' ? result.rowCount ?? 0 : 0,
181
+ rowCount: result.rowCount ?? 0,
182
+ rows: result.rows,
183
+ };
184
+ }
185
+ function chunk(items, size) {
186
+ const chunks = [];
187
+ for (let index = 0; index < items.length; index += size) {
188
+ chunks.push(items.slice(index, index + size));
189
+ }
190
+ return chunks;
191
+ }
192
+ function createBatchScope() {
193
+ return {
194
+ results: [],
195
+ affectedRows: 0,
196
+ changedRows: 0,
197
+ };
198
+ }
199
+ function pushCommittedResults(summary, scope) {
200
+ summary.results.push(...scope.results.map((row) => ({ ...row, committed: true })));
201
+ summary.affectedRows += scope.affectedRows;
202
+ summary.changedRows += scope.changedRows;
203
+ }
204
+ function pushRolledBackResults(summary, scope) {
205
+ summary.results.push(...scope.results.map((row) => ({ ...row, committed: false })));
206
+ }
package/build/index.js ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { isPolicyHookEnabled, isReadOnlyMode } from './config.js';
6
+ import { cleanupOldLogs } from './logs.js';
7
+ import { cancelApproval, listPendingApprovals, runApprovedCommand } from './approvalStore.js';
8
+ import { describeIndex, describeTable, explainQuery, getCurrentPrivileges, listTables, listTriggers, listViews, pgBatchExecute, pgExecute, pgExportCsv, pgImportCsv, pgQuery, } from './toolHandlers.js';
9
+ const { PG_HOST, PG_PORT, PG_DATABASE, } = process.env;
10
+ // Initialize MCP Server/mcp
11
+ const server = new McpServer({
12
+ name: 'easy-pg-mcp',
13
+ version: '1.0.0',
14
+ description: `PostgreSQL Database: ${PG_HOST}:${PG_PORT ?? 5432}/${PG_DATABASE}`,
15
+ });
16
+ // --- Register Tools ---
17
+ const transactionModeSchema = z.enum(['all', 'batch', 'each', 'none']);
18
+ server.registerTool('pg_query', {
19
+ description: 'Execute a read-only SQL query (e.g., SELECT). Use this for data retrieval.',
20
+ inputSchema: z.object({
21
+ sql: z.string().describe('The PostgreSQL query to execute.'),
22
+ }),
23
+ }, async ({ sql }) => {
24
+ const results = await pgQuery(sql);
25
+ return {
26
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
27
+ };
28
+ });
29
+ if (!isReadOnlyMode()) {
30
+ server.registerTool('pg_execute', {
31
+ description: 'Execute a data modification SQL statement (e.g., INSERT, UPDATE, DELETE). Use node-postgres placeholders: $1, $2, ... for params.',
32
+ inputSchema: z.object({
33
+ sql: z.string().describe('The PostgreSQL statement to execute. Use $1, $2, ... placeholders instead of ?.'),
34
+ params: z.array(z.any()).optional().describe('Optional parameters for the statement, bound to $1, $2, ... in order.'),
35
+ }),
36
+ }, async ({ sql, params }) => {
37
+ const result = await pgExecute(sql, params);
38
+ return {
39
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
40
+ };
41
+ });
42
+ server.registerTool('pg_batch_execute', {
43
+ description: 'Execute one data modification SQL statement repeatedly with multiple parameter sets. Use node-postgres placeholders: $1, $2, ... for params.',
44
+ inputSchema: z.object({
45
+ sql: z.string().describe('The parameterized PostgreSQL statement to execute for each params entry. Use $1, $2, ... placeholders instead of ?.'),
46
+ paramsList: z.array(z.array(z.any())).min(1).describe('A list of parameter arrays. Each item is bound to $1, $2, ... for the same SQL statement.'),
47
+ transaction: transactionModeSchema.optional().default('all').describe('Transaction scope: all, batch, each, or none.'),
48
+ }),
49
+ }, async ({ sql, paramsList, transaction }) => {
50
+ const result = await pgBatchExecute(sql, paramsList, transaction);
51
+ return {
52
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
53
+ };
54
+ });
55
+ server.registerTool('pg_import_csv', {
56
+ description: 'Import a UTF-8 CSV file into a table using the header row as column names.',
57
+ inputSchema: z.object({
58
+ tableName: z.string().min(1).describe('The target table name.'),
59
+ filePath: z.string().min(1).describe('Path to a UTF-8 CSV file. The first row must contain column names.'),
60
+ transaction: transactionModeSchema.optional().default('all').describe('Transaction scope: all, batch, each, or none.'),
61
+ }),
62
+ }, async ({ tableName, filePath, transaction }) => {
63
+ const result = await pgImportCsv(tableName, filePath, transaction);
64
+ return {
65
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
66
+ };
67
+ });
68
+ }
69
+ if (isPolicyHookEnabled()) {
70
+ server.registerTool('pg_run_approved_command', {
71
+ description: 'Run a pending command after the host has obtained user approval.',
72
+ inputSchema: z.object({
73
+ approvalId: z.string().min(1).describe('The approval id returned by an approval_required response.'),
74
+ }),
75
+ }, async ({ approvalId }) => {
76
+ const result = await runApprovedCommand(approvalId);
77
+ return {
78
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
79
+ };
80
+ });
81
+ server.registerTool('pg_list_pending_approvals', {
82
+ description: 'List pending commands waiting for approval.',
83
+ inputSchema: z.object({}),
84
+ }, async () => {
85
+ const result = listPendingApprovals();
86
+ return {
87
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
88
+ };
89
+ });
90
+ server.registerTool('pg_cancel_approval', {
91
+ description: 'Cancel a pending approval request.',
92
+ inputSchema: z.object({
93
+ approvalId: z.string().min(1).describe('The approval id to cancel.'),
94
+ }),
95
+ }, async ({ approvalId }) => {
96
+ const result = cancelApproval(approvalId);
97
+ return {
98
+ content: [{ type: 'text', text: JSON.stringify({ cancelled: true, approval: result }, null, 2) }],
99
+ };
100
+ });
101
+ }
102
+ server.registerTool('pg_export_csv', {
103
+ description: 'Export all rows from a table to a UTF-8 CSV file.',
104
+ inputSchema: z.object({
105
+ tableName: z.string().min(1).describe('The source table name.'),
106
+ filePath: z.string().min(1).describe('Path where the UTF-8 CSV file should be written.'),
107
+ }),
108
+ }, async ({ tableName, filePath }) => {
109
+ const result = await pgExportCsv(tableName, filePath);
110
+ return {
111
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
112
+ };
113
+ });
114
+ server.registerTool('explain_query', {
115
+ description: 'Run EXPLAIN on a SQL query to analyze its execution plan and performance.',
116
+ inputSchema: z.object({
117
+ sql: z.string().describe('The SQL query to explain.'),
118
+ }),
119
+ }, async ({ sql }) => {
120
+ const results = await explainQuery(sql);
121
+ return {
122
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
123
+ };
124
+ });
125
+ server.registerTool('list_tables', {
126
+ description: 'List all base tables in the current database with row counts and comments.',
127
+ inputSchema: z.object({}),
128
+ }, async () => {
129
+ const filteredResults = await listTables();
130
+ return {
131
+ content: [{ type: 'text', text: JSON.stringify(filteredResults, null, 2) }],
132
+ };
133
+ });
134
+ server.registerTool('list_views', {
135
+ description: 'List all views in the current database.',
136
+ inputSchema: z.object({}),
137
+ }, async () => {
138
+ const filteredResults = await listViews();
139
+ return {
140
+ content: [{ type: 'text', text: JSON.stringify(filteredResults, null, 2) }],
141
+ };
142
+ });
143
+ server.registerTool('describe_table', {
144
+ description: 'Show the schema/structure of one or more specific tables.',
145
+ inputSchema: z.object({
146
+ tables: z.array(z.string()).describe('The names of the tables to describe.'),
147
+ }),
148
+ }, async ({ tables }) => {
149
+ const results = await describeTable(tables);
150
+ return {
151
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
152
+ };
153
+ });
154
+ server.registerTool('describe_index', {
155
+ description: 'Show indexes for a specific table.',
156
+ inputSchema: z.object({
157
+ table: z.string().describe('The name of the table to show indexes for.'),
158
+ }),
159
+ }, async ({ table }) => {
160
+ const results = await describeIndex(table);
161
+ return {
162
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
163
+ };
164
+ });
165
+ server.registerTool('list_triggers', {
166
+ description: 'List all triggers in the current database.',
167
+ inputSchema: z.object({}),
168
+ }, async () => {
169
+ const filteredResults = await listTriggers();
170
+ return {
171
+ content: [{ type: 'text', text: JSON.stringify(filteredResults, null, 2) }],
172
+ };
173
+ });
174
+ server.registerTool('get_current_privileges', {
175
+ description: 'Check the permissions and grants of the current database user. Useful for debugging access issues.',
176
+ inputSchema: z.object({}),
177
+ }, async () => {
178
+ const result = await getCurrentPrivileges();
179
+ return {
180
+ content: [{
181
+ type: 'text',
182
+ text: JSON.stringify(result, null, 2)
183
+ }],
184
+ };
185
+ });
186
+ // Start server
187
+ async function main() {
188
+ await cleanupOldLogs();
189
+ const transport = new StdioServerTransport();
190
+ await server.connect(transport);
191
+ console.error('PostgreSQL MCP Server running on stdio');
192
+ }
193
+ main().catch((error) => {
194
+ console.error('Fatal error in main():', error);
195
+ process.exit(1);
196
+ });
package/build/logs.js ADDED
@@ -0,0 +1,28 @@
1
+ import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { config } from './config.js';
4
+ const LOG_RETENTION_DAYS = 7;
5
+ const LOG_RETENTION_MS = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
6
+ const logsDirectory = path.resolve(config.logPath);
7
+ export async function cleanupOldLogs(now = Date.now()) {
8
+ await mkdir(logsDirectory, { recursive: true });
9
+ const entries = await readdir(logsDirectory);
10
+ await Promise.all(entries.map(async (entry) => {
11
+ if (!entry.endsWith('.log')) {
12
+ return;
13
+ }
14
+ const filePath = path.join(logsDirectory, entry);
15
+ const fileStat = await stat(filePath);
16
+ if (now - fileStat.mtimeMs > LOG_RETENTION_MS) {
17
+ await unlink(filePath);
18
+ }
19
+ }));
20
+ }
21
+ export async function writeBatchExecuteLog(payload) {
22
+ await mkdir(logsDirectory, { recursive: true });
23
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
24
+ const randomSuffix = Math.random().toString(36).slice(2, 10);
25
+ const filePath = path.join(logsDirectory, `pg_batch_execute-${timestamp}-${randomSuffix}.log`);
26
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
27
+ return filePath;
28
+ }
@@ -0,0 +1,66 @@
1
+ import { config } from './config.js';
2
+ import { createPendingApproval } from './approvalStore.js';
3
+ export function isApprovalRequiredResponse(value) {
4
+ return typeof value === 'object'
5
+ && value !== null
6
+ && value.status === 'approval_required';
7
+ }
8
+ export async function runWithPolicy(context, command) {
9
+ if (!config.policyHookUrl) {
10
+ return command();
11
+ }
12
+ const decision = await callPolicyHook(context);
13
+ if (decision.status === 'accept') {
14
+ return command();
15
+ }
16
+ if (decision.status === 'reject') {
17
+ throw new Error(decision.message ?? 'Command rejected by PG_POLICY_HOOK.');
18
+ }
19
+ const pendingApproval = createPendingApproval({
20
+ functionName: context.functionName,
21
+ statementType: context.statementType,
22
+ tableNames: context.tableNames,
23
+ message: decision.message,
24
+ summary: context.summary,
25
+ command,
26
+ });
27
+ return {
28
+ status: 'approval_required',
29
+ approvalId: pendingApproval.approvalId,
30
+ message: pendingApproval.message,
31
+ expiresAt: pendingApproval.expiresAt,
32
+ functionName: context.functionName,
33
+ statementType: context.statementType,
34
+ tableNames: context.tableNames,
35
+ summary: pendingApproval.summary,
36
+ };
37
+ }
38
+ async function callPolicyHook(context) {
39
+ if (!config.policyHookUrl) {
40
+ return { status: 'accept' };
41
+ }
42
+ const response = await fetch(config.policyHookUrl, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({
46
+ functionName: context.functionName,
47
+ sql: context.sql ?? null,
48
+ statementType: context.statementType,
49
+ tableNames: context.tableNames,
50
+ paramsPreview: context.paramsPreview ?? null,
51
+ metadata: {
52
+ database: process.env.PG_DATABASE,
53
+ mode: config.mode,
54
+ timestamp: new Date().toISOString(),
55
+ },
56
+ }),
57
+ });
58
+ if (!response.ok) {
59
+ throw new Error(`PG_POLICY_HOOK returned HTTP ${response.status}.`);
60
+ }
61
+ const body = await response.json();
62
+ if (body.status !== 'accept' && body.status !== 'reject' && body.status !== 'approval_required') {
63
+ throw new Error('PG_POLICY_HOOK returned an invalid status.');
64
+ }
65
+ return body;
66
+ }