@contextflo/postgres-mcp 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 +267 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/context/context-file.d.ts +53 -0
- package/dist/context/context-file.js +248 -0
- package/dist/context/context-file.js.map +1 -0
- package/dist/context/init.d.ts +14 -0
- package/dist/context/init.js +71 -0
- package/dist/context/init.js.map +1 -0
- package/dist/context/store.d.ts +34 -0
- package/dist/context/store.js +87 -0
- package/dist/context/store.js.map +1 -0
- package/dist/db/errors.d.ts +9 -0
- package/dist/db/errors.js +58 -0
- package/dist/db/errors.js.map +1 -0
- package/dist/db/introspection.d.ts +55 -0
- package/dist/db/introspection.js +178 -0
- package/dist/db/introspection.js.map +1 -0
- package/dist/db/pool.d.ts +37 -0
- package/dist/db/pool.js +213 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.js +137 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +46 -0
- package/dist/log.js +100 -0
- package/dist/log.js.map +1 -0
- package/dist/safety/errors.d.ts +11 -0
- package/dist/safety/errors.js +65 -0
- package/dist/safety/errors.js.map +1 -0
- package/dist/safety/validate.d.ts +12 -0
- package/dist/safety/validate.js +145 -0
- package/dist/safety/validate.js.map +1 -0
- package/dist/safety/walk.d.ts +26 -0
- package/dist/safety/walk.js +61 -0
- package/dist/safety/walk.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +132 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/add-table-context.d.ts +28 -0
- package/dist/tools/add-table-context.js +105 -0
- package/dist/tools/add-table-context.js.map +1 -0
- package/dist/tools/context.d.ts +12 -0
- package/dist/tools/context.js +2 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/get-table-context.d.ts +20 -0
- package/dist/tools/get-table-context.js +102 -0
- package/dist/tools/get-table-context.js.map +1 -0
- package/dist/tools/list-tables.d.ts +25 -0
- package/dist/tools/list-tables.js +78 -0
- package/dist/tools/list-tables.js.map +1 -0
- package/dist/tools/query.d.ts +22 -0
- package/dist/tools/query.js +138 -0
- package/dist/tools/query.js.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { ConfigError, HelpRequested, parseArgs } from './config.js';
|
|
5
|
+
import { parse as parseConnectionString } from 'pg-connection-string';
|
|
6
|
+
import { ContextFileExists, readOnlyRoleSnippet, runInit } from './context/init.js';
|
|
7
|
+
import { ContextStore } from './context/store.js';
|
|
8
|
+
import { Database } from './db/pool.js';
|
|
9
|
+
import { startHttpServer } from './http.js';
|
|
10
|
+
import { QueryLog } from './log.js';
|
|
11
|
+
import { ensureParserReady } from './safety/validate.js';
|
|
12
|
+
import { createServer } from './server.js';
|
|
13
|
+
// stdout carries MCP protocol frames on a stdio transport, so every diagnostic in this
|
|
14
|
+
// package goes to stderr. A stray console.log corrupts the stream. The exceptions are
|
|
15
|
+
// --help and `init`, neither of which starts a transport.
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { version } = require('../package.json');
|
|
18
|
+
async function main() {
|
|
19
|
+
const config = readConfig();
|
|
20
|
+
// The WASM parser must be loaded before any query is validated.
|
|
21
|
+
await ensureParserReady();
|
|
22
|
+
const database = await Database.connect({
|
|
23
|
+
connectionString: config.connectionString,
|
|
24
|
+
statementTimeoutMs: config.statementTimeoutMs,
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
if (config.command === 'init') {
|
|
28
|
+
await initialiseContextFile(database, config);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
await serve(database, config);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await database.close().catch(() => { });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function readConfig() {
|
|
39
|
+
try {
|
|
40
|
+
return parseArgs(process.argv.slice(2));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof HelpRequested) {
|
|
44
|
+
// Safe on stdout: no transport is running yet, and this process exits immediately.
|
|
45
|
+
console.log(error.message);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (error instanceof ConfigError) {
|
|
49
|
+
console.error(error.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function initialiseContextFile(database, config) {
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = await runInit(database, config.contextFile);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof ContextFileExists) {
|
|
62
|
+
console.error(error.message);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const databaseName = parseConnectionString(config.connectionString).database || 'postgres';
|
|
68
|
+
console.log(`Wrote ${result.path}`);
|
|
69
|
+
console.log(` ${result.tableCount} tables across ${result.schemas.length} schema(s); ` +
|
|
70
|
+
`${result.seededColumnCount} column descriptions seeded from COMMENT ON.`);
|
|
71
|
+
if (result.truncated) {
|
|
72
|
+
console.log(' Note: only the first 1000 tables were included.');
|
|
73
|
+
}
|
|
74
|
+
console.log('\nEdit it — the business definitions section is where the value is.\n');
|
|
75
|
+
console.log('Recommended: connect as a role that cannot write, so read-only holds even if this');
|
|
76
|
+
console.log('server has a bug.\n');
|
|
77
|
+
console.log(readOnlyRoleSnippet(databaseName, result.schemas));
|
|
78
|
+
await database.close();
|
|
79
|
+
}
|
|
80
|
+
async function serve(database, config) {
|
|
81
|
+
await database.warnOnWeakSetup();
|
|
82
|
+
const contextFile = await ContextStore.open(config.contextFile, { writable: config.contextWrites });
|
|
83
|
+
const contextDocument = contextFile.current;
|
|
84
|
+
if (contextDocument.tables.size === 0 && !contextDocument.preamble) {
|
|
85
|
+
console.error(`[postgres-mcp] no context file at ${config.contextFile}. Run \`postgres-mcp init <url>\` to ` +
|
|
86
|
+
'generate one, or pass --context-file with an absolute path — the model does better when it ' +
|
|
87
|
+
'knows what your tables mean.');
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.error(`[postgres-mcp] context from ${config.contextFile}`);
|
|
91
|
+
}
|
|
92
|
+
const log = await QueryLog.resolve({
|
|
93
|
+
explicitPath: config.logFile,
|
|
94
|
+
disabled: config.logDisabled,
|
|
95
|
+
contextDirectory: config.contextDirectory,
|
|
96
|
+
});
|
|
97
|
+
if (log.isEnabled) {
|
|
98
|
+
console.error(`[postgres-mcp] logging queries to ${log.filePath}`);
|
|
99
|
+
}
|
|
100
|
+
const toolContext = {
|
|
101
|
+
database,
|
|
102
|
+
contextFile,
|
|
103
|
+
log,
|
|
104
|
+
maxRows: config.maxRows,
|
|
105
|
+
maxOutputChars: config.maxOutputChars,
|
|
106
|
+
};
|
|
107
|
+
const buildServer = () => createServer({ context: toolContext, version, connectionString: config.connectionString });
|
|
108
|
+
const shutdown = async () => {
|
|
109
|
+
await log.flush().catch(() => { });
|
|
110
|
+
await database.close().catch(() => { });
|
|
111
|
+
process.exit(0);
|
|
112
|
+
};
|
|
113
|
+
process.on('SIGINT', () => void shutdown());
|
|
114
|
+
process.on('SIGTERM', () => void shutdown());
|
|
115
|
+
if (config.http) {
|
|
116
|
+
await startHttpServer({ config: config.http, createMcpServer: buildServer });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await buildServer().connect(new StdioServerTransport());
|
|
120
|
+
}
|
|
121
|
+
main().catch((error) => {
|
|
122
|
+
console.error(`[postgres-mcp] ${error instanceof Error ? error.message : String(error)}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
125
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.contextflo/log.md` — an audit trail of every statement the agent ran, in a file a
|
|
3
|
+
* human reads. Nothing here is served back to the model.
|
|
4
|
+
*
|
|
5
|
+
* Off unless asked for: enabled by `--log-file`, or automatically once `.contextflo/`
|
|
6
|
+
* exists, since creating that directory with `init` is the opt-in. A database tool that
|
|
7
|
+
* writes files into your project uninvited is a bad neighbour.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_LOG_FILE = ".contextflo/log.md";
|
|
10
|
+
export type QueryOutcome = 'ok' | 'rejected' | 'error';
|
|
11
|
+
export interface QueryLogEntry {
|
|
12
|
+
sql: string;
|
|
13
|
+
outcome: QueryOutcome;
|
|
14
|
+
rowCount?: number | undefined;
|
|
15
|
+
durationMs?: number | undefined;
|
|
16
|
+
/** Rejection reason or database error, for the non-ok outcomes. */
|
|
17
|
+
message?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare class QueryLog {
|
|
20
|
+
private readonly path;
|
|
21
|
+
/** Appends are chained so concurrent tool calls cannot interleave mid-entry. */
|
|
22
|
+
private pending;
|
|
23
|
+
private warned;
|
|
24
|
+
private constructor();
|
|
25
|
+
static disabled(): QueryLog;
|
|
26
|
+
static enabled(path: string): QueryLog;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves whether to log at all: an explicit path wins, otherwise log only if the
|
|
29
|
+
* context directory is already there.
|
|
30
|
+
*/
|
|
31
|
+
static resolve(options: {
|
|
32
|
+
explicitPath?: string | undefined;
|
|
33
|
+
disabled: boolean;
|
|
34
|
+
contextDirectory: string;
|
|
35
|
+
}): Promise<QueryLog>;
|
|
36
|
+
get isEnabled(): boolean;
|
|
37
|
+
get filePath(): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Never rejects. A failed write must not turn a successful query into a failed tool
|
|
40
|
+
* call, so problems are reported once to stderr and then swallowed.
|
|
41
|
+
*/
|
|
42
|
+
record(entry: QueryLogEntry): void;
|
|
43
|
+
/** Waits for queued writes — used on shutdown so the last entry is not lost. */
|
|
44
|
+
flush(): Promise<void>;
|
|
45
|
+
private write;
|
|
46
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { appendFile, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { DEFAULT_CONTEXT_DIRECTORY } from './context/context-file.js';
|
|
4
|
+
/**
|
|
5
|
+
* `.contextflo/log.md` — an audit trail of every statement the agent ran, in a file a
|
|
6
|
+
* human reads. Nothing here is served back to the model.
|
|
7
|
+
*
|
|
8
|
+
* Off unless asked for: enabled by `--log-file`, or automatically once `.contextflo/`
|
|
9
|
+
* exists, since creating that directory with `init` is the opt-in. A database tool that
|
|
10
|
+
* writes files into your project uninvited is a bad neighbour.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_LOG_FILE = `${DEFAULT_CONTEXT_DIRECTORY}/log.md`;
|
|
13
|
+
const FILE_HEADER = `# postgres-mcp query log
|
|
14
|
+
|
|
15
|
+
Every statement this server ran, newest last. Written for you, not for the model.
|
|
16
|
+
`;
|
|
17
|
+
export class QueryLog {
|
|
18
|
+
path;
|
|
19
|
+
/** Appends are chained so concurrent tool calls cannot interleave mid-entry. */
|
|
20
|
+
pending = Promise.resolve();
|
|
21
|
+
warned = false;
|
|
22
|
+
constructor(path) {
|
|
23
|
+
this.path = path;
|
|
24
|
+
}
|
|
25
|
+
static disabled() {
|
|
26
|
+
return new QueryLog(null);
|
|
27
|
+
}
|
|
28
|
+
static enabled(path) {
|
|
29
|
+
return new QueryLog(path);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolves whether to log at all: an explicit path wins, otherwise log only if the
|
|
33
|
+
* context directory is already there.
|
|
34
|
+
*/
|
|
35
|
+
static async resolve(options) {
|
|
36
|
+
if (options.disabled)
|
|
37
|
+
return QueryLog.disabled();
|
|
38
|
+
if (options.explicitPath)
|
|
39
|
+
return QueryLog.enabled(options.explicitPath);
|
|
40
|
+
try {
|
|
41
|
+
const info = await stat(options.contextDirectory);
|
|
42
|
+
if (info.isDirectory())
|
|
43
|
+
return QueryLog.enabled(`${options.contextDirectory}/log.md`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// No context directory — nothing was opted into.
|
|
47
|
+
}
|
|
48
|
+
return QueryLog.disabled();
|
|
49
|
+
}
|
|
50
|
+
get isEnabled() {
|
|
51
|
+
return this.path !== null;
|
|
52
|
+
}
|
|
53
|
+
get filePath() {
|
|
54
|
+
return this.path;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Never rejects. A failed write must not turn a successful query into a failed tool
|
|
58
|
+
* call, so problems are reported once to stderr and then swallowed.
|
|
59
|
+
*/
|
|
60
|
+
record(entry) {
|
|
61
|
+
if (this.path === null)
|
|
62
|
+
return;
|
|
63
|
+
this.pending = this.pending
|
|
64
|
+
.then(() => this.write(this.path, entry))
|
|
65
|
+
.catch((error) => {
|
|
66
|
+
if (this.warned)
|
|
67
|
+
return;
|
|
68
|
+
this.warned = true;
|
|
69
|
+
console.error(`[postgres-mcp] could not write the query log at ${this.path}: ` +
|
|
70
|
+
`${error instanceof Error ? error.message : String(error)}. Continuing without it.`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Waits for queued writes — used on shutdown so the last entry is not lost. */
|
|
74
|
+
async flush() {
|
|
75
|
+
await this.pending;
|
|
76
|
+
}
|
|
77
|
+
async write(path, entry) {
|
|
78
|
+
let isNew = false;
|
|
79
|
+
try {
|
|
80
|
+
await stat(path);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
isNew = true;
|
|
84
|
+
await mkdir(dirname(path), { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
await appendFile(path, (isNew ? FILE_HEADER : '') + formatEntry(entry), 'utf8');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function formatEntry(entry) {
|
|
90
|
+
const parts = [new Date().toISOString(), entry.outcome];
|
|
91
|
+
if (entry.rowCount !== undefined)
|
|
92
|
+
parts.push(`${entry.rowCount} rows`);
|
|
93
|
+
if (entry.durationMs !== undefined)
|
|
94
|
+
parts.push(`${entry.durationMs}ms`);
|
|
95
|
+
const heading = `\n## ${parts.join(' · ')}\n\n`;
|
|
96
|
+
const sql = '```sql\n' + entry.sql.trim() + '\n```\n';
|
|
97
|
+
const message = entry.message ? `\n> ${entry.message.replace(/\n/g, '\n> ')}\n` : '';
|
|
98
|
+
return heading + sql + message;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=log.js.map
|
package/dist/log.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable rejection codes. These are asserted on by the adversarial test suite and
|
|
3
|
+
* surfaced to the model in tool errors, so treat them as part of the public contract.
|
|
4
|
+
*/
|
|
5
|
+
export type SafetyCode = 'PARSE_ERROR' | 'EMPTY_STATEMENT' | 'MULTIPLE_STATEMENTS' | 'STATEMENT_NOT_ALLOWED' | 'SELECT_INTO' | 'LOCKING_CLAUSE' | 'FUNCTION_NOT_ALLOWED';
|
|
6
|
+
export declare class SafetyError extends Error {
|
|
7
|
+
readonly code: SafetyCode;
|
|
8
|
+
constructor(code: SafetyCode, message: string);
|
|
9
|
+
}
|
|
10
|
+
/** Falls back to de-camel-casing the node name so unknown types still read sensibly. */
|
|
11
|
+
export declare function describeStatement(nodeName: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class SafetyError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'SafetyError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse-tree node names are not what a user typed. Map the common ones back to SQL
|
|
11
|
+
* keywords so the rejection message is actionable rather than an implementation detail.
|
|
12
|
+
*/
|
|
13
|
+
const STATEMENT_KEYWORDS = {
|
|
14
|
+
InsertStmt: 'INSERT',
|
|
15
|
+
UpdateStmt: 'UPDATE',
|
|
16
|
+
DeleteStmt: 'DELETE',
|
|
17
|
+
MergeStmt: 'MERGE',
|
|
18
|
+
TruncateStmt: 'TRUNCATE',
|
|
19
|
+
CopyStmt: 'COPY',
|
|
20
|
+
DropStmt: 'DROP',
|
|
21
|
+
CreateStmt: 'CREATE TABLE',
|
|
22
|
+
CreateTableAsStmt: 'CREATE TABLE AS',
|
|
23
|
+
CreateFunctionStmt: 'CREATE FUNCTION',
|
|
24
|
+
CreateRoleStmt: 'CREATE ROLE',
|
|
25
|
+
AlterTableStmt: 'ALTER TABLE',
|
|
26
|
+
AlterRoleStmt: 'ALTER ROLE',
|
|
27
|
+
GrantStmt: 'GRANT/REVOKE',
|
|
28
|
+
GrantRoleStmt: 'GRANT/REVOKE ROLE',
|
|
29
|
+
DoStmt: 'DO',
|
|
30
|
+
CallStmt: 'CALL',
|
|
31
|
+
TransactionStmt: 'BEGIN/COMMIT/ROLLBACK',
|
|
32
|
+
VariableSetStmt: 'SET/RESET',
|
|
33
|
+
DeclareCursorStmt: 'DECLARE CURSOR',
|
|
34
|
+
FetchStmt: 'FETCH/MOVE',
|
|
35
|
+
ClosePortalStmt: 'CLOSE',
|
|
36
|
+
PrepareStmt: 'PREPARE',
|
|
37
|
+
ExecuteStmt: 'EXECUTE',
|
|
38
|
+
DeallocateStmt: 'DEALLOCATE',
|
|
39
|
+
LockStmt: 'LOCK',
|
|
40
|
+
NotifyStmt: 'NOTIFY',
|
|
41
|
+
ListenStmt: 'LISTEN',
|
|
42
|
+
UnlistenStmt: 'UNLISTEN',
|
|
43
|
+
VacuumStmt: 'VACUUM/ANALYZE',
|
|
44
|
+
ReindexStmt: 'REINDEX',
|
|
45
|
+
ClusterStmt: 'CLUSTER',
|
|
46
|
+
RefreshMatViewStmt: 'REFRESH MATERIALIZED VIEW',
|
|
47
|
+
IndexStmt: 'CREATE INDEX',
|
|
48
|
+
ViewStmt: 'CREATE VIEW',
|
|
49
|
+
RuleStmt: 'CREATE RULE',
|
|
50
|
+
CreatedbStmt: 'CREATE DATABASE',
|
|
51
|
+
DropdbStmt: 'DROP DATABASE',
|
|
52
|
+
CreateSchemaStmt: 'CREATE SCHEMA',
|
|
53
|
+
CreateTrigStmt: 'CREATE TRIGGER',
|
|
54
|
+
CreateExtensionStmt: 'CREATE EXTENSION',
|
|
55
|
+
AlterSystemStmt: 'ALTER SYSTEM',
|
|
56
|
+
CheckPointStmt: 'CHECKPOINT',
|
|
57
|
+
};
|
|
58
|
+
/** Falls back to de-camel-casing the node name so unknown types still read sensibly. */
|
|
59
|
+
export function describeStatement(nodeName) {
|
|
60
|
+
const known = STATEMENT_KEYWORDS[nodeName];
|
|
61
|
+
if (known)
|
|
62
|
+
return known;
|
|
63
|
+
return nodeName.replace(/Stmt$/, '').replace(/([a-z])([A-Z])/g, '$1 $2').toUpperCase();
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/safety/errors.ts"],"names":[],"mappings":"AAaA,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC3B,IAAI,CAAY;IAEzB,YAAY,IAAgB,EAAE,OAAe;QAC3C,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,aAAa,CAAA;QACzB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;IAClB,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,kBAAkB,GAA2B;IACjD,UAAU,EAAE,QAAQ;IACpB,UAAU,EAAE,QAAQ;IACpB,UAAU,EAAE,QAAQ;IACpB,SAAS,EAAE,OAAO;IAClB,YAAY,EAAE,UAAU;IACxB,QAAQ,EAAE,MAAM;IAChB,QAAQ,EAAE,MAAM;IAChB,UAAU,EAAE,cAAc;IAC1B,iBAAiB,EAAE,iBAAiB;IACpC,kBAAkB,EAAE,iBAAiB;IACrC,cAAc,EAAE,aAAa;IAC7B,cAAc,EAAE,aAAa;IAC7B,aAAa,EAAE,YAAY;IAC3B,SAAS,EAAE,cAAc;IACzB,aAAa,EAAE,mBAAmB;IAClC,MAAM,EAAE,IAAI;IACZ,QAAQ,EAAE,MAAM;IAChB,eAAe,EAAE,uBAAuB;IACxC,eAAe,EAAE,WAAW;IAC5B,iBAAiB,EAAE,gBAAgB;IACnC,SAAS,EAAE,YAAY;IACvB,eAAe,EAAE,OAAO;IACxB,WAAW,EAAE,SAAS;IACtB,WAAW,EAAE,SAAS;IACtB,cAAc,EAAE,YAAY;IAC5B,QAAQ,EAAE,MAAM;IAChB,UAAU,EAAE,QAAQ;IACpB,UAAU,EAAE,QAAQ;IACpB,YAAY,EAAE,UAAU;IACxB,UAAU,EAAE,gBAAgB;IAC5B,WAAW,EAAE,SAAS;IACtB,WAAW,EAAE,SAAS;IACtB,kBAAkB,EAAE,2BAA2B;IAC/C,SAAS,EAAE,cAAc;IACzB,QAAQ,EAAE,aAAa;IACvB,QAAQ,EAAE,aAAa;IACvB,YAAY,EAAE,iBAAiB;IAC/B,UAAU,EAAE,eAAe;IAC3B,gBAAgB,EAAE,eAAe;IACjC,cAAc,EAAE,gBAAgB;IAChC,mBAAmB,EAAE,kBAAkB;IACvC,eAAe,EAAE,cAAc;IAC/B,cAAc,EAAE,YAAY;CAC7B,CAAA;AAED,wFAAwF;AACxF,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,MAAM,KAAK,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAA;IAC1C,IAAI,KAAK;QAAE,OAAO,KAAK,CAAA;IACvB,OAAO,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,iBAAiB,EAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAA;AACxF,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the WASM parser. Must be awaited once before {@link validateReadOnlySql}, which
|
|
3
|
+
* is synchronous so it can be used freely from anywhere.
|
|
4
|
+
*/
|
|
5
|
+
export declare function ensureParserReady(): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Throws {@link SafetyError} unless `sql` is a single read-only statement.
|
|
8
|
+
*
|
|
9
|
+
* Accepts: SELECT (including `WITH ... SELECT`, set operations, and subqueries), EXPLAIN
|
|
10
|
+
* over an otherwise-accepted statement, and SHOW.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateReadOnlySql(sql: string): void;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { loadModule, parseSync } from 'libpg-query';
|
|
2
|
+
import { SafetyError, describeStatement } from './errors.js';
|
|
3
|
+
import { findFunctionNames, findStatementNodes } from './walk.js';
|
|
4
|
+
/**
|
|
5
|
+
* Layer 3 of four: the statement allowlist, enforced with the real Postgres C parser
|
|
6
|
+
* (libpg-query, compiled to WASM) rather than a JavaScript SQL dialect approximation.
|
|
7
|
+
*
|
|
8
|
+
* The other three layers live outside this file and each stop the archived server's CVE
|
|
9
|
+
* on their own: the extended query protocol (src/db/pool.ts), the connection's
|
|
10
|
+
* `default_transaction_read_only` (src/db/pool.ts), and a read-only database role (the
|
|
11
|
+
* documented setup). This layer exists to turn "the database refused it" into a clear,
|
|
12
|
+
* early, actionable error — and to catch the cases the transaction alone would not,
|
|
13
|
+
* like `SELECT ... INTO`.
|
|
14
|
+
*/
|
|
15
|
+
/** Anything not in this set is rejected. Allowlist, so new node types fail closed. */
|
|
16
|
+
const ALLOWED_STATEMENTS = new Set(['SelectStmt', 'ExplainStmt', 'VariableShowStmt']);
|
|
17
|
+
let parserReady;
|
|
18
|
+
/**
|
|
19
|
+
* Loads the WASM parser. Must be awaited once before {@link validateReadOnlySql}, which
|
|
20
|
+
* is synchronous so it can be used freely from anywhere.
|
|
21
|
+
*/
|
|
22
|
+
export function ensureParserReady() {
|
|
23
|
+
parserReady ??= loadModule();
|
|
24
|
+
return parserReady;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Throws {@link SafetyError} unless `sql` is a single read-only statement.
|
|
28
|
+
*
|
|
29
|
+
* Accepts: SELECT (including `WITH ... SELECT`, set operations, and subqueries), EXPLAIN
|
|
30
|
+
* over an otherwise-accepted statement, and SHOW.
|
|
31
|
+
*/
|
|
32
|
+
export function validateReadOnlySql(sql) {
|
|
33
|
+
if (sql.trim() === '') {
|
|
34
|
+
// libpg-query reports this as a parse error; EMPTY_STATEMENT is the clearer contract.
|
|
35
|
+
throw new SafetyError('EMPTY_STATEMENT', 'No SQL statement found. Provide a single SELECT, EXPLAIN, or SHOW.');
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseSync(sql);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new SafetyError('PARSE_ERROR', formatParseError(error));
|
|
43
|
+
}
|
|
44
|
+
const statements = parsed.stmts ?? [];
|
|
45
|
+
if (statements.length === 0 || !statements[0]?.stmt) {
|
|
46
|
+
throw new SafetyError('EMPTY_STATEMENT', 'No SQL statement found. Provide a single SELECT, EXPLAIN, or SHOW.');
|
|
47
|
+
}
|
|
48
|
+
if (statements.length > 1) {
|
|
49
|
+
// The wire protocol rejects this too (see src/db/pool.ts) — this is the friendly version.
|
|
50
|
+
throw new SafetyError('MULTIPLE_STATEMENTS', `Only one statement per call is allowed; received ${statements.length}. ` +
|
|
51
|
+
'Send each statement as a separate query call.');
|
|
52
|
+
}
|
|
53
|
+
for (const node of findStatementNodes(statements[0].stmt)) {
|
|
54
|
+
if (!ALLOWED_STATEMENTS.has(node.name)) {
|
|
55
|
+
throw new SafetyError('STATEMENT_NOT_ALLOWED', `This server is read-only; ${describeStatement(node.name)} is not allowed. ` +
|
|
56
|
+
'Only SELECT, EXPLAIN, and SHOW statements can run.');
|
|
57
|
+
}
|
|
58
|
+
if (node.name === 'SelectStmt') {
|
|
59
|
+
checkSelect(node.fields);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const name of findFunctionNames(statements[0].stmt)) {
|
|
63
|
+
if (isDeniedFunction(name)) {
|
|
64
|
+
throw new SafetyError('FUNCTION_NOT_ALLOWED', `This server is read-only; ${name}() is not allowed because it can act outside the ` +
|
|
65
|
+
'read-only transaction (another connection, the filesystem, other sessions, or a lock ' +
|
|
66
|
+
'that outlives the query).');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Functions a read-only SELECT can call that escape the read-only transaction. This is
|
|
72
|
+
* defence in depth, not a boundary: user-defined and SECURITY DEFINER functions can do
|
|
73
|
+
* the same things, and only a read-only role stops those. What it does is make the common
|
|
74
|
+
* built-in escapes fail with a clear message instead of depending on the role.
|
|
75
|
+
*/
|
|
76
|
+
const DENIED_FUNCTIONS = new Set([
|
|
77
|
+
// Run a SQL string the parser never sees.
|
|
78
|
+
'query_to_xml',
|
|
79
|
+
'query_to_xmlschema',
|
|
80
|
+
'query_to_xml_and_xmlschema',
|
|
81
|
+
'cursor_to_xml',
|
|
82
|
+
'cursor_to_xmlschema',
|
|
83
|
+
'ts_stat',
|
|
84
|
+
// Change settings; SET is rejected, so its function form is too.
|
|
85
|
+
'set_config',
|
|
86
|
+
// Act on other sessions or the server.
|
|
87
|
+
'pg_terminate_backend',
|
|
88
|
+
'pg_cancel_backend',
|
|
89
|
+
'pg_reload_conf',
|
|
90
|
+
'pg_rotate_logfile',
|
|
91
|
+
'pg_switch_wal',
|
|
92
|
+
'pg_promote',
|
|
93
|
+
'pg_create_restore_point',
|
|
94
|
+
'pg_notify',
|
|
95
|
+
// Session-level advisory locks survive the ROLLBACK and stay on the pooled connection.
|
|
96
|
+
'pg_advisory_lock',
|
|
97
|
+
'pg_advisory_lock_shared',
|
|
98
|
+
'pg_try_advisory_lock',
|
|
99
|
+
'pg_try_advisory_lock_shared',
|
|
100
|
+
// Server filesystem.
|
|
101
|
+
'pg_read_file',
|
|
102
|
+
'pg_read_binary_file',
|
|
103
|
+
'pg_stat_file',
|
|
104
|
+
'pg_file_write',
|
|
105
|
+
'pg_file_unlink',
|
|
106
|
+
'pg_file_rename',
|
|
107
|
+
'lo_import',
|
|
108
|
+
'lo_export',
|
|
109
|
+
]);
|
|
110
|
+
/** Families matched by prefix. */
|
|
111
|
+
const DENIED_FUNCTION_PREFIXES = [
|
|
112
|
+
// dblink opens a new connection, which is not read-only, and runs SQL the parser never sees.
|
|
113
|
+
'dblink',
|
|
114
|
+
// Directory listings of the server's filesystem.
|
|
115
|
+
'pg_ls_',
|
|
116
|
+
// Replication slots and origins: creating, dropping, or consuming them changes server state.
|
|
117
|
+
'pg_create_logical_replication_slot',
|
|
118
|
+
'pg_create_physical_replication_slot',
|
|
119
|
+
'pg_drop_replication_slot',
|
|
120
|
+
'pg_logical_slot_',
|
|
121
|
+
'pg_replication_origin_',
|
|
122
|
+
];
|
|
123
|
+
function isDeniedFunction(name) {
|
|
124
|
+
return DENIED_FUNCTIONS.has(name) || DENIED_FUNCTION_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
125
|
+
}
|
|
126
|
+
function checkSelect(fields) {
|
|
127
|
+
if (fields.intoClause) {
|
|
128
|
+
// `SELECT ... INTO new_table` is a SelectStmt but creates a table.
|
|
129
|
+
throw new SafetyError('SELECT_INTO', 'SELECT ... INTO creates a table, which this read-only server does not allow. ' +
|
|
130
|
+
'Drop the INTO clause to return the rows instead.');
|
|
131
|
+
}
|
|
132
|
+
if (fields.lockingClause) {
|
|
133
|
+
// FOR UPDATE/SHARE takes row locks; the read-only transaction would reject it with a
|
|
134
|
+
// less obvious error, so name it here.
|
|
135
|
+
throw new SafetyError('LOCKING_CLAUSE', 'Row locking (FOR UPDATE / FOR SHARE) is not available on a read-only connection. ' +
|
|
136
|
+
'Remove the locking clause.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function formatParseError(error) {
|
|
140
|
+
const details = error;
|
|
141
|
+
const message = details?.message ?? 'Could not parse SQL';
|
|
142
|
+
const position = details?.sqlDetails?.cursorPosition;
|
|
143
|
+
return position && position > 0 ? `${message} (at position ${position})` : message;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/safety/validate.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking the whole parse tree — not just the top-level node — is the point of this file.
|
|
3
|
+
*
|
|
4
|
+
* `WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x` parses to a
|
|
5
|
+
* SelectStmt at the top, with the InsertStmt buried at
|
|
6
|
+
* `SelectStmt.withClause.ctes[].CommonTableExpr.ctequery.InsertStmt`. A validator that
|
|
7
|
+
* only inspects the top-level statement type waves that through, and it writes.
|
|
8
|
+
*/
|
|
9
|
+
export interface StatementNode {
|
|
10
|
+
/** Parse-tree node name, e.g. `SelectStmt`. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** The node's fields. */
|
|
13
|
+
fields: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Yields every statement node anywhere in the tree, in document order, including the
|
|
17
|
+
* root. Callers check membership against an allowlist so unfamiliar node types — from a
|
|
18
|
+
* future Postgres, say — fail closed instead of slipping through a denylist.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findStatementNodes(node: unknown): Generator<StatementNode>;
|
|
21
|
+
/**
|
|
22
|
+
* Yields the lower-cased, unqualified name of every function call anywhere in the tree —
|
|
23
|
+
* in the select list, FROM (`dblink(...) AS t`), WHERE, subqueries, CTEs. The schema
|
|
24
|
+
* qualifier is dropped so `pg_catalog.pg_read_file` and `pg_read_file` look the same.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findFunctionNames(node: unknown): Generator<string>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking the whole parse tree — not just the top-level node — is the point of this file.
|
|
3
|
+
*
|
|
4
|
+
* `WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x` parses to a
|
|
5
|
+
* SelectStmt at the top, with the InsertStmt buried at
|
|
6
|
+
* `SelectStmt.withClause.ctes[].CommonTableExpr.ctequery.InsertStmt`. A validator that
|
|
7
|
+
* only inspects the top-level statement type waves that through, and it writes.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Postgres parse trees wrap every node as `{ PascalCaseTypeName: { ...fields } }`, while
|
|
11
|
+
* plain fields are camelCase (`stmt`, `ctequery`, `withClause`). Requiring a leading
|
|
12
|
+
* capital keeps this from matching a field that merely ends in "Stmt".
|
|
13
|
+
*/
|
|
14
|
+
const STATEMENT_NODE_KEY = /^[A-Z][A-Za-z0-9]*Stmt$/;
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Yields every statement node anywhere in the tree, in document order, including the
|
|
20
|
+
* root. Callers check membership against an allowlist so unfamiliar node types — from a
|
|
21
|
+
* future Postgres, say — fail closed instead of slipping through a denylist.
|
|
22
|
+
*/
|
|
23
|
+
export function* findStatementNodes(node) {
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
for (const item of node)
|
|
26
|
+
yield* findStatementNodes(item);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!isRecord(node))
|
|
30
|
+
return;
|
|
31
|
+
for (const [key, value] of Object.entries(node)) {
|
|
32
|
+
if (STATEMENT_NODE_KEY.test(key) && isRecord(value)) {
|
|
33
|
+
yield { name: key, fields: value };
|
|
34
|
+
}
|
|
35
|
+
yield* findStatementNodes(value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Yields the lower-cased, unqualified name of every function call anywhere in the tree —
|
|
40
|
+
* in the select list, FROM (`dblink(...) AS t`), WHERE, subqueries, CTEs. The schema
|
|
41
|
+
* qualifier is dropped so `pg_catalog.pg_read_file` and `pg_read_file` look the same.
|
|
42
|
+
*/
|
|
43
|
+
export function* findFunctionNames(node) {
|
|
44
|
+
if (Array.isArray(node)) {
|
|
45
|
+
for (const item of node)
|
|
46
|
+
yield* findFunctionNames(item);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(node))
|
|
50
|
+
return;
|
|
51
|
+
for (const [key, value] of Object.entries(node)) {
|
|
52
|
+
if (key === 'FuncCall' && isRecord(value) && Array.isArray(value.funcname)) {
|
|
53
|
+
const last = value.funcname[value.funcname.length - 1];
|
|
54
|
+
const name = last?.String?.sval;
|
|
55
|
+
if (typeof name === 'string')
|
|
56
|
+
yield name.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
yield* findFunctionNames(value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=walk.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"walk.js","sourceRoot":"","sources":["../../src/safety/walk.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,MAAM,kBAAkB,GAAG,yBAAyB,CAAA;AASpD,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;AAC7E,CAAC;AAED;;;;GAIG;AACH,MAAM,SAAS,CAAC,CAAC,kBAAkB,CAAC,IAAa;IAC/C,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,KAAK,CAAC,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAA;QACxD,OAAM;IACR,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAM;IAE3B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,IAAI,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACpD,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;QACpC,CAAC;QACD,KAAK,CAAC,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAA;IAClC,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,SAAS,CAAC,CAAC,iBAAiB,CAAC,IAAa;IAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,KAAK,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAA;QACvD,OAAM;IACR,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAM;IAE3B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,IAAI,GAAG,KAAK,UAAU,IAAI,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3E,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAgD,CAAA;YACrG,MAAM,IAAI,GAAG,IAAI,EAAE,MAAM,EAAE,IAAI,CAAA;YAC/B,IAAI,OAAO,IAAI,KAAK,QAAQ;gBAAE,MAAM,IAAI,CAAC,WAAW,EAAE,CAAA;QACxD,CAAC;QACD,KAAK,CAAC,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAA;IACjC,CAAC;AACH,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import type { ToolContext } from './tools/context.js';
|
|
3
|
+
export interface ServerDeps {
|
|
4
|
+
context: ToolContext;
|
|
5
|
+
version: string;
|
|
6
|
+
/** Connection string, used only to build resource URIs. The password is stripped. */
|
|
7
|
+
connectionString: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function createServer({ context, version, connectionString }: ServerDeps): Server;
|