@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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema introspection straight off the live catalog.
|
|
3
|
+
*
|
|
4
|
+
* Everything here filters on `has_table_privilege`, so the picture the model gets is
|
|
5
|
+
* scoped to what the connecting role can actually read. On the read-only role the README
|
|
6
|
+
* recommends, that is a useful property rather than an accident.
|
|
7
|
+
*
|
|
8
|
+
* These are deliberately thin. The catalog is queryable SQL through the `query` tool, so
|
|
9
|
+
* anything more specific than "list" and "describe" belongs there rather than in a tool
|
|
10
|
+
* signature the model has to learn.
|
|
11
|
+
*/
|
|
12
|
+
/** Ordinary tables, views, materialized views, partitioned and foreign tables. */
|
|
13
|
+
const RELATION_KINDS = "('r', 'v', 'm', 'p', 'f')";
|
|
14
|
+
const VISIBLE_SCHEMAS = `
|
|
15
|
+
n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
16
|
+
AND n.nspname NOT LIKE 'pg_toast%'
|
|
17
|
+
AND n.nspname NOT LIKE 'pg_temp%'
|
|
18
|
+
`;
|
|
19
|
+
/**
|
|
20
|
+
* Lists tables, optionally narrowed by a case-insensitive substring.
|
|
21
|
+
*
|
|
22
|
+
* The pattern matches anywhere in the table name, the qualified name, or the table's
|
|
23
|
+
* comment — the comment included because a table named `fct_orders` may be the one
|
|
24
|
+
* someone means by "revenue", and a name-only match would report nothing and send the
|
|
25
|
+
* model away empty-handed.
|
|
26
|
+
*/
|
|
27
|
+
export async function listTables(database, options) {
|
|
28
|
+
const pattern = options.pattern ?? null;
|
|
29
|
+
const schema = options.schema ?? null;
|
|
30
|
+
const rows = await database.internalQuery(`
|
|
31
|
+
SELECT n.nspname AS schema,
|
|
32
|
+
c.relname AS name,
|
|
33
|
+
CASE c.relkind
|
|
34
|
+
WHEN 'r' THEN 'table'
|
|
35
|
+
WHEN 'p' THEN 'partitioned table'
|
|
36
|
+
WHEN 'v' THEN 'view'
|
|
37
|
+
WHEN 'm' THEN 'materialized view'
|
|
38
|
+
WHEN 'f' THEN 'foreign table'
|
|
39
|
+
END AS kind,
|
|
40
|
+
obj_description(c.oid, 'pg_class') AS description,
|
|
41
|
+
count(*) OVER () AS total_matches
|
|
42
|
+
FROM pg_class c
|
|
43
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
44
|
+
WHERE c.relkind IN ${RELATION_KINDS}
|
|
45
|
+
AND ${VISIBLE_SCHEMAS}
|
|
46
|
+
AND has_table_privilege(c.oid, 'SELECT')
|
|
47
|
+
-- A table partitioned by day has hundreds of children; the parent is the one to query.
|
|
48
|
+
AND NOT c.relispartition
|
|
49
|
+
AND ($2::text IS NULL OR n.nspname = $2)
|
|
50
|
+
AND (
|
|
51
|
+
$1::text IS NULL
|
|
52
|
+
OR c.relname ILIKE '%' || $4::text || '%'
|
|
53
|
+
OR (n.nspname || '.' || c.relname) ILIKE '%' || $4::text || '%'
|
|
54
|
+
OR obj_description(c.oid, 'pg_class') ILIKE '%' || $4::text || '%'
|
|
55
|
+
)
|
|
56
|
+
ORDER BY
|
|
57
|
+
CASE
|
|
58
|
+
WHEN $1::text IS NULL THEN 0
|
|
59
|
+
WHEN lower(n.nspname || '.' || c.relname) = lower($1) THEN 0
|
|
60
|
+
WHEN lower(c.relname) = lower($1) THEN 1
|
|
61
|
+
WHEN c.relname ILIKE $4::text || '%' THEN 2
|
|
62
|
+
WHEN c.relname ILIKE '%' || $4::text || '%' THEN 3
|
|
63
|
+
ELSE 4
|
|
64
|
+
END,
|
|
65
|
+
n.nspname,
|
|
66
|
+
c.relname
|
|
67
|
+
LIMIT $3
|
|
68
|
+
`, [pattern, schema, options.limit, pattern === null ? null : escapeLike(pattern)]);
|
|
69
|
+
return {
|
|
70
|
+
tables: rows.map((row) => ({
|
|
71
|
+
schema: row.schema,
|
|
72
|
+
name: row.name,
|
|
73
|
+
fullyQualifiedName: `${row.schema}.${row.name}`,
|
|
74
|
+
kind: row.kind,
|
|
75
|
+
description: row.description,
|
|
76
|
+
})),
|
|
77
|
+
totalMatches: rows.length > 0 ? Number(rows[0].total_matches) : 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** `order_items` should match that name, not every table with "order" + any char + "items". */
|
|
81
|
+
function escapeLike(pattern) {
|
|
82
|
+
return pattern.replace(/[\\%_]/g, (character) => `\\${character}`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Describes several tables in one round trip — the model usually has two or three
|
|
86
|
+
* candidates after a search and should not need a call each to choose between them.
|
|
87
|
+
*
|
|
88
|
+
* Names are matched case-insensitively, and an unqualified name resolves against any
|
|
89
|
+
* visible schema, because a model that read `orders` in a list will ask for `orders`.
|
|
90
|
+
*/
|
|
91
|
+
export async function getTableContext(database, fullyQualifiedNames) {
|
|
92
|
+
if (fullyQualifiedNames.length === 0)
|
|
93
|
+
return [];
|
|
94
|
+
const wanted = fullyQualifiedNames.map((name) => name.toLowerCase());
|
|
95
|
+
const rows = await database.internalQuery(`
|
|
96
|
+
SELECT n.nspname AS schema,
|
|
97
|
+
c.relname AS table,
|
|
98
|
+
CASE c.relkind
|
|
99
|
+
WHEN 'r' THEN 'table'
|
|
100
|
+
WHEN 'p' THEN 'partitioned table'
|
|
101
|
+
WHEN 'v' THEN 'view'
|
|
102
|
+
WHEN 'm' THEN 'materialized view'
|
|
103
|
+
WHEN 'f' THEN 'foreign table'
|
|
104
|
+
END AS kind,
|
|
105
|
+
obj_description(c.oid, 'pg_class') AS table_description,
|
|
106
|
+
NULLIF(c.reltuples, -1) AS approximate_rows,
|
|
107
|
+
a.attname AS column_name,
|
|
108
|
+
format_type(a.atttypid, a.atttypmod) AS data_type,
|
|
109
|
+
NOT a.attnotnull AS is_nullable,
|
|
110
|
+
pg_get_expr(d.adbin, d.adrelid) AS default_value,
|
|
111
|
+
col_description(c.oid, a.attnum) AS column_description,
|
|
112
|
+
EXISTS (
|
|
113
|
+
SELECT 1 FROM pg_index i
|
|
114
|
+
WHERE i.indrelid = c.oid AND i.indisprimary AND a.attnum = ANY (i.indkey)
|
|
115
|
+
) AS is_primary_key,
|
|
116
|
+
(
|
|
117
|
+
SELECT tn.nspname || '.' || tc.relname || '.' || ta.attname
|
|
118
|
+
FROM pg_constraint con
|
|
119
|
+
JOIN pg_class tc ON tc.oid = con.confrelid
|
|
120
|
+
JOIN pg_namespace tn ON tn.oid = tc.relnamespace
|
|
121
|
+
JOIN pg_attribute ta
|
|
122
|
+
ON ta.attrelid = con.confrelid
|
|
123
|
+
AND ta.attnum = con.confkey[array_position(con.conkey, a.attnum)]
|
|
124
|
+
WHERE con.conrelid = c.oid
|
|
125
|
+
AND con.contype = 'f'
|
|
126
|
+
AND a.attnum = ANY (con.conkey)
|
|
127
|
+
LIMIT 1
|
|
128
|
+
) AS references,
|
|
129
|
+
(
|
|
130
|
+
SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
|
|
131
|
+
FROM pg_enum e
|
|
132
|
+
WHERE e.enumtypid = a.atttypid
|
|
133
|
+
) AS enum_values,
|
|
134
|
+
a.attnum AS ordinal
|
|
135
|
+
FROM pg_class c
|
|
136
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
137
|
+
LEFT JOIN pg_attribute a
|
|
138
|
+
ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
139
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
|
|
140
|
+
WHERE c.relkind IN ${RELATION_KINDS}
|
|
141
|
+
AND ${VISIBLE_SCHEMAS}
|
|
142
|
+
AND has_table_privilege(c.oid, 'SELECT')
|
|
143
|
+
AND (
|
|
144
|
+
lower(n.nspname || '.' || c.relname) = ANY ($1::text[])
|
|
145
|
+
OR lower(c.relname) = ANY ($1::text[])
|
|
146
|
+
)
|
|
147
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
148
|
+
`, [wanted]);
|
|
149
|
+
const byTable = new Map();
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const fullyQualifiedName = `${row.schema}.${row.table}`;
|
|
152
|
+
let table = byTable.get(fullyQualifiedName);
|
|
153
|
+
if (!table) {
|
|
154
|
+
table = {
|
|
155
|
+
fullyQualifiedName,
|
|
156
|
+
kind: row.kind,
|
|
157
|
+
description: row.table_description,
|
|
158
|
+
approximateRows: row.approximate_rows === null ? null : Math.max(0, Math.round(Number(row.approximate_rows))),
|
|
159
|
+
columns: [],
|
|
160
|
+
};
|
|
161
|
+
byTable.set(fullyQualifiedName, table);
|
|
162
|
+
}
|
|
163
|
+
if (row.column_name !== null) {
|
|
164
|
+
table.columns.push({
|
|
165
|
+
name: row.column_name,
|
|
166
|
+
dataType: row.data_type ?? 'unknown',
|
|
167
|
+
isNullable: row.is_nullable ?? true,
|
|
168
|
+
defaultValue: row.default_value,
|
|
169
|
+
description: row.column_description,
|
|
170
|
+
isPrimaryKey: row.is_primary_key ?? false,
|
|
171
|
+
references: row.references,
|
|
172
|
+
enumValues: row.enum_values,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...byTable.values()];
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=introspection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"introspection.js","sourceRoot":"","sources":["../../src/db/introspection.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
export interface DatabaseOptions {
|
|
3
|
+
connectionString: string;
|
|
4
|
+
statementTimeoutMs: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ReadOnlyResult {
|
|
7
|
+
rows: Record<string, unknown>[];
|
|
8
|
+
/** True when the query produced more rows than the cap and the extras were dropped. */
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class Database {
|
|
12
|
+
private readonly pool;
|
|
13
|
+
private readonly statementTimeoutMs;
|
|
14
|
+
/** True when startup options were refused and we connected without them. */
|
|
15
|
+
readonly behindPooler: boolean;
|
|
16
|
+
private constructor();
|
|
17
|
+
static connect(options: DatabaseOptions): Promise<Database>;
|
|
18
|
+
private static open;
|
|
19
|
+
/**
|
|
20
|
+
* Runs a single already-validated read-only statement, reading at most `maxRows`.
|
|
21
|
+
*
|
|
22
|
+
* Reads `maxRows + 1` so truncation is detectable rather than silent.
|
|
23
|
+
*/
|
|
24
|
+
runReadOnly(sql: string, maxRows: number): Promise<ReadOnlyResult>;
|
|
25
|
+
/**
|
|
26
|
+
* For this server's own introspection queries — never for user input. `queryMode` is
|
|
27
|
+
* mandatory here for the reason described at the top of this file.
|
|
28
|
+
*/
|
|
29
|
+
internalQuery<T extends pg.QueryResultRow>(text: string, values?: unknown[]): Promise<T[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Layer 4 is a database role, not code — but connecting as a superuser silently
|
|
32
|
+
* defeats it, so say so. Also confirms the startup options actually landed, which a
|
|
33
|
+
* connection pooler in front of Postgres may prevent.
|
|
34
|
+
*/
|
|
35
|
+
warnOnWeakSetup(): Promise<void>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
package/dist/db/pool.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import Cursor from 'pg-cursor';
|
|
3
|
+
import { parse as parseConnectionString } from 'pg-connection-string';
|
|
4
|
+
import { describeConnectionError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* The single place user-supplied SQL reaches the database, and the home of safety layers
|
|
7
|
+
* 1 and 2.
|
|
8
|
+
*
|
|
9
|
+
* ## Layer 1 — extended query protocol
|
|
10
|
+
*
|
|
11
|
+
* The archived `@modelcontextprotocol/server-postgres` ran `client.query(sql)` with a
|
|
12
|
+
* bare string. node-postgres only prepares a statement when
|
|
13
|
+
* `Query.requiresPreparation()` is true, and with no values that is false — so the driver
|
|
14
|
+
* sends a *simple* Query message, which permits multiple statements. That is what let
|
|
15
|
+
* `COMMIT; DROP TABLE x; BEGIN;` escape their read-only transaction.
|
|
16
|
+
*
|
|
17
|
+
* So: user SQL goes through `pg-cursor`, which always issues Parse/Bind/Describe/Execute,
|
|
18
|
+
* and Postgres itself rejects multi-statement input at Parse time. Internal queries pass
|
|
19
|
+
* `queryMode: 'extended'` explicitly.
|
|
20
|
+
*
|
|
21
|
+
* Note that `values: []` does NOT force the extended protocol — `requiresPreparation()`
|
|
22
|
+
* checks `values.length > 0`. Anything added here must use a cursor or `queryMode`.
|
|
23
|
+
*
|
|
24
|
+
* ## Layer 2 — connection-level read-only
|
|
25
|
+
*
|
|
26
|
+
* `default_transaction_read_only=on` is set in the startup packet, and every statement
|
|
27
|
+
* additionally runs inside an explicit `BEGIN READ ONLY`. The explicit transaction is
|
|
28
|
+
* what makes this layer hold behind a pooler: PgBouncer refuses unknown startup
|
|
29
|
+
* parameters outright, so on that error we reconnect without them rather than fail. The
|
|
30
|
+
* statement timeout is set with `SET LOCAL` inside each transaction for the same reason.
|
|
31
|
+
* `SET`/`RESET` are rejected by the parser layer, so a session cannot turn either back off.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = 60_000;
|
|
34
|
+
/** Client-side backstop in case the server never answers; the server-side timeout fires first. */
|
|
35
|
+
const CLIENT_TIMEOUT_GRACE_MS = 5_000;
|
|
36
|
+
// node-postgres turns date and timestamp values into JS Dates in the *MCP process's*
|
|
37
|
+
// timezone, and JSON renders those in UTC — so `2024-01-15` came out as
|
|
38
|
+
// `2024-01-14T18:30:00.000Z` for anyone east of Greenwich. Hand the model exactly what
|
|
39
|
+
// Postgres sent instead. Intervals too, which otherwise become `{ "days": 1 }` objects.
|
|
40
|
+
const DATE_LIKE_TYPES = [1082 /* date */, 1114 /* timestamp */, 1184 /* timestamptz */, 1186 /* interval */];
|
|
41
|
+
const DATE_LIKE_ARRAY_TYPES = [1182, 1115, 1185, 1187];
|
|
42
|
+
const TEXT_ARRAY_TYPE = 1009;
|
|
43
|
+
for (const oid of DATE_LIKE_TYPES)
|
|
44
|
+
pg.types.setTypeParser(oid, (value) => value);
|
|
45
|
+
for (const oid of DATE_LIKE_ARRAY_TYPES)
|
|
46
|
+
pg.types.setTypeParser(oid, pg.types.getTypeParser(TEXT_ARRAY_TYPE));
|
|
47
|
+
export class Database {
|
|
48
|
+
pool;
|
|
49
|
+
statementTimeoutMs;
|
|
50
|
+
/** True when startup options were refused and we connected without them. */
|
|
51
|
+
behindPooler;
|
|
52
|
+
constructor(pool, statementTimeoutMs, behindPooler) {
|
|
53
|
+
this.pool = pool;
|
|
54
|
+
this.statementTimeoutMs = statementTimeoutMs;
|
|
55
|
+
this.behindPooler = behindPooler;
|
|
56
|
+
}
|
|
57
|
+
static async connect(options) {
|
|
58
|
+
try {
|
|
59
|
+
return await Database.open(options, true);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!isUnsupportedStartupParameter(error))
|
|
63
|
+
throw new Error(describeConnectionError(error));
|
|
64
|
+
}
|
|
65
|
+
console.error('[postgres-mcp] the server refused startup parameters, which usually means PgBouncer or another ' +
|
|
66
|
+
'pooler. Reconnecting without them: every statement still runs in BEGIN READ ONLY with its ' +
|
|
67
|
+
'own statement timeout.');
|
|
68
|
+
try {
|
|
69
|
+
return await Database.open(options, false);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new Error(describeConnectionError(error));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
static async open(options, withStartupOptions) {
|
|
76
|
+
const pool = new pg.Pool(buildPoolConfig(options, withStartupOptions));
|
|
77
|
+
// pg emits 'error' asynchronously when an idle connection is reaped server-side.
|
|
78
|
+
// Without a listener node re-throws it as an uncaught exception and kills the server.
|
|
79
|
+
pool.on('error', (error) => {
|
|
80
|
+
console.error(`[postgres-mcp] pool error: ${describeConnectionError(error)}`);
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const client = await pool.connect();
|
|
84
|
+
client.release();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await pool.end().catch(() => { });
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return new Database(pool, options.statementTimeoutMs, !withStartupOptions);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Runs a single already-validated read-only statement, reading at most `maxRows`.
|
|
94
|
+
*
|
|
95
|
+
* Reads `maxRows + 1` so truncation is detectable rather than silent.
|
|
96
|
+
*/
|
|
97
|
+
async runReadOnly(sql, maxRows) {
|
|
98
|
+
const client = await this.pool.connect();
|
|
99
|
+
try {
|
|
100
|
+
await client.query({ text: 'BEGIN READ ONLY', queryMode: 'extended' });
|
|
101
|
+
// SET LOCAL rather than a startup option, so the timeout holds behind a pooler too.
|
|
102
|
+
await client.query({
|
|
103
|
+
text: "SELECT set_config('statement_timeout', $1, true)",
|
|
104
|
+
values: [String(this.statementTimeoutMs)],
|
|
105
|
+
queryMode: 'extended',
|
|
106
|
+
});
|
|
107
|
+
const cursor = client.query(new Cursor(sql));
|
|
108
|
+
try {
|
|
109
|
+
const rows = (await cursor.read(maxRows + 1));
|
|
110
|
+
const truncated = rows.length > maxRows;
|
|
111
|
+
return { rows: truncated ? rows.slice(0, maxRows) : rows, truncated };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await cursor.close().catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
// Always ROLLBACK, never COMMIT. Beyond discarding the transaction, this undoes any
|
|
119
|
+
// GUC change made inside it (SET is transactional), so a statement cannot leave a
|
|
120
|
+
// pooled connection in a weakened state for whoever gets it next.
|
|
121
|
+
await client.query({ text: 'ROLLBACK', queryMode: 'extended' }).catch((error) => {
|
|
122
|
+
console.error(`[postgres-mcp] could not roll back: ${describeConnectionError(error)}`);
|
|
123
|
+
});
|
|
124
|
+
client.release();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* For this server's own introspection queries — never for user input. `queryMode` is
|
|
129
|
+
* mandatory here for the reason described at the top of this file.
|
|
130
|
+
*/
|
|
131
|
+
async internalQuery(text, values = []) {
|
|
132
|
+
const result = await this.pool.query({ text, values, queryMode: 'extended' });
|
|
133
|
+
return result.rows;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Layer 4 is a database role, not code — but connecting as a superuser silently
|
|
137
|
+
* defeats it, so say so. Also confirms the startup options actually landed, which a
|
|
138
|
+
* connection pooler in front of Postgres may prevent.
|
|
139
|
+
*/
|
|
140
|
+
async warnOnWeakSetup() {
|
|
141
|
+
try {
|
|
142
|
+
const rows = await this.internalQuery(`SELECT rolname AS role,
|
|
143
|
+
rolsuper AS is_superuser,
|
|
144
|
+
rolbypassrls AS bypasses_rls,
|
|
145
|
+
current_setting('default_transaction_read_only') AS default_read_only
|
|
146
|
+
FROM pg_roles
|
|
147
|
+
WHERE rolname = current_user`);
|
|
148
|
+
const info = rows[0];
|
|
149
|
+
if (!info)
|
|
150
|
+
return;
|
|
151
|
+
if (info.is_superuser || info.bypasses_rls) {
|
|
152
|
+
console.error(`[postgres-mcp] connected as "${info.role}", which is a superuser or bypasses RLS. ` +
|
|
153
|
+
'Queries are still read-only, but the recommended setup is a dedicated read-only role ' +
|
|
154
|
+
'so the database enforces it independently of this server. See the README.');
|
|
155
|
+
}
|
|
156
|
+
if (info.default_read_only !== 'on' && !this.behindPooler) {
|
|
157
|
+
console.error('[postgres-mcp] default_transaction_read_only did not take effect on this connection ' +
|
|
158
|
+
'(a connection pooler may be dropping startup options). Statements still run inside an ' +
|
|
159
|
+
'explicit READ ONLY transaction, so writes remain blocked.');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
// A restricted role may not be able to read pg_roles. That is fine — it is a
|
|
164
|
+
// diagnostic, not a safety layer.
|
|
165
|
+
console.error(`[postgres-mcp] skipped setup check: ${describeConnectionError(error)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async close() {
|
|
169
|
+
await this.pool.end();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** PgBouncer: `unsupported startup parameter: options` (or `...in options: ...`). */
|
|
173
|
+
function isUnsupportedStartupParameter(error) {
|
|
174
|
+
return /unsupported startup parameter/i.test(error?.message ?? '');
|
|
175
|
+
}
|
|
176
|
+
function buildPoolConfig(options, withStartupOptions) {
|
|
177
|
+
// Parse here rather than handing `connectionString` to pg: pg re-parses it and
|
|
178
|
+
// Object.assigns the result over the rest of the config, which would drop our options.
|
|
179
|
+
const parsed = parseConnectionString(options.connectionString);
|
|
180
|
+
const hardening = [
|
|
181
|
+
'-c default_transaction_read_only=on',
|
|
182
|
+
`-c statement_timeout=${options.statementTimeoutMs}`,
|
|
183
|
+
`-c idle_in_transaction_session_timeout=${DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS}`,
|
|
184
|
+
].join(' ');
|
|
185
|
+
const config = {
|
|
186
|
+
max: 5,
|
|
187
|
+
idleTimeoutMillis: 30_000,
|
|
188
|
+
// Client-side only — never sent as a startup parameter, so safe behind a pooler.
|
|
189
|
+
query_timeout: options.statementTimeoutMs + CLIENT_TIMEOUT_GRACE_MS,
|
|
190
|
+
};
|
|
191
|
+
if (withStartupOptions) {
|
|
192
|
+
// Keep anything the user set (including their own `options`) and append ours last so
|
|
193
|
+
// the read-only settings win.
|
|
194
|
+
config.options = parsed.options ? `${parsed.options} ${hardening}` : hardening;
|
|
195
|
+
}
|
|
196
|
+
else if (parsed.options) {
|
|
197
|
+
config.options = parsed.options;
|
|
198
|
+
}
|
|
199
|
+
if (parsed.host)
|
|
200
|
+
config.host = parsed.host;
|
|
201
|
+
if (parsed.port)
|
|
202
|
+
config.port = Number(parsed.port);
|
|
203
|
+
if (parsed.database)
|
|
204
|
+
config.database = parsed.database;
|
|
205
|
+
if (parsed.user)
|
|
206
|
+
config.user = parsed.user;
|
|
207
|
+
if (parsed.password)
|
|
208
|
+
config.password = parsed.password;
|
|
209
|
+
if (parsed.ssl !== undefined)
|
|
210
|
+
config.ssl = parsed.ssl;
|
|
211
|
+
return config;
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=pool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pool.js","sourceRoot":"","sources":["../../src/db/pool.ts"],"names":[],"mappings":"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"}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import type { HttpConfig } from './config.js';
|
|
4
|
+
export interface HttpServerDeps {
|
|
5
|
+
config: HttpConfig;
|
|
6
|
+
createMcpServer: () => Server;
|
|
7
|
+
}
|
|
8
|
+
export declare function startHttpServer({ config, createMcpServer }: HttpServerDeps): Promise<HttpServer>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { StreamableHTTPServerTransport, } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
/**
|
|
5
|
+
* Streamable HTTP transport, for running this on a box and pointing a remote client at it.
|
|
6
|
+
*
|
|
7
|
+
* Stateless: a fresh Server and transport per request, all sharing one connection pool.
|
|
8
|
+
* There is no session state worth keeping between calls, and no session map to leak.
|
|
9
|
+
*
|
|
10
|
+
* On authentication — an MCP endpoint on a reachable port is a live database connection
|
|
11
|
+
* for anyone who can open a socket to it. The read-only layers hold, so the exposure is
|
|
12
|
+
* reading everything rather than breaking anything, which is still the whole database.
|
|
13
|
+
* Hence: loopback unless a flag says otherwise, an optional bearer token, and a warning
|
|
14
|
+
* loud enough to be uncomfortable when it is exposed without one.
|
|
15
|
+
*/
|
|
16
|
+
const MCP_PATH = '/mcp';
|
|
17
|
+
export async function startHttpServer({ config, createMcpServer }) {
|
|
18
|
+
warnAboutExposure(config);
|
|
19
|
+
const server = createHttpServer((request, response) => {
|
|
20
|
+
void handleRequest(request, response, config, createMcpServer);
|
|
21
|
+
});
|
|
22
|
+
await new Promise((resolve, reject) => {
|
|
23
|
+
server.once('error', reject);
|
|
24
|
+
server.listen(config.port, config.host, () => {
|
|
25
|
+
server.removeListener('error', reject);
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
// Report the port actually bound, which differs from the requested one when it was 0.
|
|
30
|
+
const address = server.address();
|
|
31
|
+
const port = typeof address === 'object' && address !== null ? address.port : config.port;
|
|
32
|
+
console.error(`[postgres-mcp] listening on http://${config.host}:${port}${MCP_PATH}`);
|
|
33
|
+
return server;
|
|
34
|
+
}
|
|
35
|
+
async function handleRequest(request, response, config, createMcpServer) {
|
|
36
|
+
const path = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`).pathname;
|
|
37
|
+
if (path === '/health') {
|
|
38
|
+
respondJson(response, 200, { status: 'ok' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (path !== MCP_PATH) {
|
|
42
|
+
respondJson(response, 404, { error: `Not found. The MCP endpoint is ${MCP_PATH}.` });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!isLoopbackRequestAllowed(request, config.host)) {
|
|
46
|
+
respondJson(response, 403, {
|
|
47
|
+
error: 'Host or Origin is not local. This server is bound to loopback and only answers local requests.',
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!isAuthorized(request, config.authToken)) {
|
|
52
|
+
response.setHeader('WWW-Authenticate', 'Bearer');
|
|
53
|
+
respondJson(response, 401, { error: 'Missing or invalid bearer token.' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const mcpServer = createMcpServer();
|
|
57
|
+
// `sessionIdGenerator: undefined` is the SDK's documented way to ask for stateless mode,
|
|
58
|
+
// but its own types are not written for exactOptionalPropertyTypes. The two casts here
|
|
59
|
+
// are types-only; keeping the flag on is worth more in the safety layer than it costs
|
|
60
|
+
// at this boundary.
|
|
61
|
+
const transport = new StreamableHTTPServerTransport({
|
|
62
|
+
sessionIdGenerator: undefined,
|
|
63
|
+
});
|
|
64
|
+
response.on('close', () => {
|
|
65
|
+
void transport.close();
|
|
66
|
+
void mcpServer.close();
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await mcpServer.connect(transport);
|
|
70
|
+
await transport.handleRequest(request, response);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
console.error(`[postgres-mcp] request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
if (!response.headersSent) {
|
|
75
|
+
respondJson(response, 500, { error: 'Internal server error.' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isAuthorized(request, authToken) {
|
|
80
|
+
if (!authToken)
|
|
81
|
+
return true;
|
|
82
|
+
const header = request.headers.authorization;
|
|
83
|
+
if (!header?.startsWith('Bearer '))
|
|
84
|
+
return false;
|
|
85
|
+
const presented = Buffer.from(header.slice('Bearer '.length));
|
|
86
|
+
const expected = Buffer.from(authToken);
|
|
87
|
+
// timingSafeEqual throws on length mismatch, and the length itself is not a secret.
|
|
88
|
+
return presented.length === expected.length && timingSafeEqual(presented, expected);
|
|
89
|
+
}
|
|
90
|
+
function respondJson(response, status, body) {
|
|
91
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
92
|
+
response.end(JSON.stringify(body));
|
|
93
|
+
}
|
|
94
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
95
|
+
/** As `URL.hostname` spells them, which brackets IPv6. */
|
|
96
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '[::1]', 'localhost']);
|
|
97
|
+
/**
|
|
98
|
+
* DNS rebinding: a web page on evil.example re-points its own hostname at 127.0.0.1, and
|
|
99
|
+
* the browser, seeing the same origin, lets its script POST to this server. Loopback plus
|
|
100
|
+
* no token is the default setup, so that script could read the database. The Host header
|
|
101
|
+
* still says evil.example, which is how it is caught. The MCP spec requires this check.
|
|
102
|
+
*
|
|
103
|
+
* Only for loopback binds: behind 0.0.0.0 the Host is whatever the proxy in front sends,
|
|
104
|
+
* and the bearer token is the control.
|
|
105
|
+
*/
|
|
106
|
+
function isLoopbackRequestAllowed(request, boundHost) {
|
|
107
|
+
if (!LOOPBACK.has(boundHost))
|
|
108
|
+
return true;
|
|
109
|
+
const host = request.headers.host;
|
|
110
|
+
if (!host || !LOOPBACK_HOSTNAMES.has(hostnameOf(`http://${host}`)))
|
|
111
|
+
return false;
|
|
112
|
+
// Browsers send Origin; local tools such as the MCP Inspector are served from localhost.
|
|
113
|
+
const origin = request.headers.origin;
|
|
114
|
+
return origin === undefined || LOOPBACK_HOSTNAMES.has(hostnameOf(origin));
|
|
115
|
+
}
|
|
116
|
+
function hostnameOf(url) {
|
|
117
|
+
try {
|
|
118
|
+
return new URL(url).hostname;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function warnAboutExposure(config) {
|
|
125
|
+
if (LOOPBACK.has(config.host))
|
|
126
|
+
return;
|
|
127
|
+
if (config.authToken) {
|
|
128
|
+
console.error(`[postgres-mcp] bound to ${config.host} with bearer-token auth. Terminate TLS in front of it — ` +
|
|
129
|
+
'the token crosses the wire in plaintext otherwise.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.error(`[postgres-mcp] WARNING: bound to ${config.host} with NO AUTHENTICATION. Anyone who can reach ` +
|
|
133
|
+
`${config.host}:${config.port} can read every table this connection can see. Queries stay ` +
|
|
134
|
+
'read-only, so this is data exposure rather than damage — but it is the whole database. ' +
|
|
135
|
+
'Set AUTH_TOKEN, keep it inside a private network, or bind 127.0.0.1.');
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=http.js.map
|
package/dist/http.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED