@delta-si/mcp-pg-multidb 0.0.0-stage → 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.
- package/README.md +79 -2
- package/index.mjs +261 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,80 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @delta-si/mcp-pg-multidb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Servidor [MCP](https://modelcontextprotocol.io) para PostgreSQL que acessa **qualquer database do servidor com uma única configuração**. O nome do database é informado em cada chamada de ferramenta, então não é preciso uma entrada no `mcp.json` por banco.
|
|
4
|
+
|
|
5
|
+
Por padrão é **somente leitura**: toda consulta roda em `BEGIN TRANSACTION READ ONLY` e aceita um único comando SQL por chamada.
|
|
6
|
+
|
|
7
|
+
## Ferramentas
|
|
8
|
+
|
|
9
|
+
| Ferramenta | Parâmetros | Descrição |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `list_databases` | — | Lista os databases do servidor |
|
|
12
|
+
| `list_schemas` | `database` | Lista os schemas |
|
|
13
|
+
| `list_tables` | `database`, `schema` (padrão `public`) | Lista tabelas e views |
|
|
14
|
+
| `describe_table` | `database`, `table`, `schema` | Colunas, tipos, defaults, PK/FK/UNIQUE |
|
|
15
|
+
| `query` | `database`, `sql`, `params` | SQL somente leitura, com parâmetros `$1, $2...` |
|
|
16
|
+
| `execute` | `database`, `sql`, `params` | SQL com escrita e `COMMIT`. Só existe com `PG_ALLOW_WRITE=true` |
|
|
17
|
+
|
|
18
|
+
## Configuração
|
|
19
|
+
|
|
20
|
+
Exemplo de `mcp.json` (Kiro, Claude Desktop, VS Code e outros clientes MCP):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"pg-multidb": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@delta-si/mcp-pg-multidb@1.0.0"],
|
|
28
|
+
"env": {
|
|
29
|
+
"PGHOST": "localhost",
|
|
30
|
+
"PGPORT": "5432",
|
|
31
|
+
"PGUSER": "postgres",
|
|
32
|
+
"PG_ALLOW_WRITE": "false"
|
|
33
|
+
},
|
|
34
|
+
"autoApprove": ["list_databases", "list_schemas", "list_tables", "describe_table", "query"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Fixe a versão no `args` para não receber atualizações sem revisar.
|
|
41
|
+
|
|
42
|
+
### Variáveis de ambiente
|
|
43
|
+
|
|
44
|
+
| Variável | Padrão | Descrição |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `PGHOST` | `localhost` | Host do PostgreSQL |
|
|
47
|
+
| `PGPORT` | `5432` | Porta |
|
|
48
|
+
| `PGUSER` | `postgres` | Usuário |
|
|
49
|
+
| `PGPASSWORD` | — | Senha. Prefira o arquivo de senhas (abaixo) |
|
|
50
|
+
| `PG_MAINTENANCE_DB` | `postgres` | Database usado para listar os demais |
|
|
51
|
+
| `PG_ALLOW_WRITE` | `false` | `true` habilita a ferramenta `execute` |
|
|
52
|
+
| `PG_MAX_ROWS` | `500` | Máximo de linhas devolvidas por consulta |
|
|
53
|
+
| `PG_STATEMENT_TIMEOUT` | `30000` | Timeout por comando, em ms |
|
|
54
|
+
|
|
55
|
+
### Senha fora do mcp.json
|
|
56
|
+
|
|
57
|
+
Sem `PGPASSWORD`, o driver lê o arquivo de senhas padrão do PostgreSQL:
|
|
58
|
+
|
|
59
|
+
- Windows: `%APPDATA%\postgresql\pgpass.conf`
|
|
60
|
+
- Linux/macOS: `~/.pgpass` (com `chmod 600`)
|
|
61
|
+
|
|
62
|
+
Formato, uma linha por servidor (`*` vale para qualquer database):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
localhost:5432:*:postgres:suasenha
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Segurança
|
|
69
|
+
|
|
70
|
+
- Use um usuário com permissão somente de leitura sempre que possível. O modo read-only do servidor é uma segunda barreira, não a única.
|
|
71
|
+
- Com `query` no `autoApprove`, o agente executa qualquer `SELECT` em qualquer database do servidor sem pedir confirmação.
|
|
72
|
+
- Não habilite `PG_ALLOW_WRITE` apontando para bancos de produção.
|
|
73
|
+
|
|
74
|
+
## Requisitos
|
|
75
|
+
|
|
76
|
+
Node.js 18 ou superior.
|
|
77
|
+
|
|
78
|
+
## Licença
|
|
79
|
+
|
|
80
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-pg-multidb: MCP server PostgreSQL multi-database.
|
|
3
|
+
// Conecta no host configurado e recebe o nome do database em cada chamada de ferramenta,
|
|
4
|
+
// então não é preciso fixar o database no mcp.json.
|
|
5
|
+
//
|
|
6
|
+
// Variáveis de ambiente:
|
|
7
|
+
// PGHOST (localhost), PGPORT (5432), PGUSER (postgres), PGPASSWORD
|
|
8
|
+
// sem PGPASSWORD, o driver pg usa o pgpass.conf / .pgpass do usuário
|
|
9
|
+
// PG_MAINTENANCE_DB=postgres database usado para listar os demais
|
|
10
|
+
// PG_ALLOW_WRITE=true habilita a ferramenta "execute" (padrão: somente leitura)
|
|
11
|
+
// PG_MAX_ROWS=500 limite de linhas devolvidas por consulta
|
|
12
|
+
// PG_STATEMENT_TIMEOUT=30000 timeout por comando, em ms
|
|
13
|
+
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import pg from "pg";
|
|
19
|
+
|
|
20
|
+
const { name: PKG_NAME, version: PKG_VERSION } = createRequire(import.meta.url)("./package.json");
|
|
21
|
+
const SERVER_NAME = "mcp-pg-multidb";
|
|
22
|
+
|
|
23
|
+
const config = {
|
|
24
|
+
host: process.env.PGHOST || "localhost",
|
|
25
|
+
port: Number(process.env.PGPORT || 5432),
|
|
26
|
+
user: process.env.PGUSER || "postgres",
|
|
27
|
+
password: process.env.PGPASSWORD,
|
|
28
|
+
};
|
|
29
|
+
const ALLOW_WRITE = String(process.env.PG_ALLOW_WRITE).toLowerCase() === "true";
|
|
30
|
+
const MAX_ROWS = Number(process.env.PG_MAX_ROWS || 500);
|
|
31
|
+
const STATEMENT_TIMEOUT = Number(process.env.PG_STATEMENT_TIMEOUT || 30000);
|
|
32
|
+
const MAINTENANCE_DB = process.env.PG_MAINTENANCE_DB || "postgres";
|
|
33
|
+
|
|
34
|
+
// Um pool pequeno por database, criado sob demanda.
|
|
35
|
+
const pools = new Map();
|
|
36
|
+
|
|
37
|
+
function getPool(database) {
|
|
38
|
+
let pool = pools.get(database);
|
|
39
|
+
if (!pool) {
|
|
40
|
+
pool = new pg.Pool({
|
|
41
|
+
...config,
|
|
42
|
+
database,
|
|
43
|
+
max: 3,
|
|
44
|
+
idleTimeoutMillis: 60000,
|
|
45
|
+
statement_timeout: STATEMENT_TIMEOUT,
|
|
46
|
+
application_name: SERVER_NAME,
|
|
47
|
+
});
|
|
48
|
+
// Evita derrubar o processo por erro em conexão ociosa.
|
|
49
|
+
pool.on("error", (err) => console.error(`[pool ${database}]`, err.message));
|
|
50
|
+
pools.set(database, pool);
|
|
51
|
+
}
|
|
52
|
+
return pool;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Lista de databases existentes, usada para validar o nome recebido.
|
|
56
|
+
// Sem pg_database_size: em bancos grandes ele estoura o statement_timeout.
|
|
57
|
+
let dbCache = { at: 0, names: [] };
|
|
58
|
+
|
|
59
|
+
async function listDatabases(force = false) {
|
|
60
|
+
if (force || Date.now() - dbCache.at > 30000) {
|
|
61
|
+
const { rows } = await getPool(MAINTENANCE_DB).query(
|
|
62
|
+
`SELECT datname FROM pg_database
|
|
63
|
+
WHERE datistemplate = false AND datallowconn = true
|
|
64
|
+
ORDER BY datname`
|
|
65
|
+
);
|
|
66
|
+
dbCache = { at: Date.now(), names: rows.map((r) => r.datname) };
|
|
67
|
+
}
|
|
68
|
+
return dbCache.names;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function assertDatabase(database) {
|
|
72
|
+
let dbs = await listDatabases();
|
|
73
|
+
// Banco criado há pouco pode não estar no cache.
|
|
74
|
+
if (!dbs.includes(database)) dbs = await listDatabases(true);
|
|
75
|
+
if (!dbs.includes(database)) {
|
|
76
|
+
throw new Error(`Database "${database}" não encontrado. Disponíveis: ${dbs.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Executa um único comando (protocolo estendido não aceita múltiplos statements,
|
|
81
|
+
// o que impede escapar da transação read-only com "COMMIT; ...").
|
|
82
|
+
async function run(database, sql, params = [], { readOnly = true } = {}) {
|
|
83
|
+
await assertDatabase(database);
|
|
84
|
+
const client = await getPool(database).connect();
|
|
85
|
+
try {
|
|
86
|
+
await client.query(readOnly ? "BEGIN TRANSACTION READ ONLY" : "BEGIN");
|
|
87
|
+
const result = await client.query({ text: sql, values: params, queryMode: "extended" });
|
|
88
|
+
await client.query(readOnly ? "ROLLBACK" : "COMMIT");
|
|
89
|
+
return result;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
92
|
+
throw err;
|
|
93
|
+
} finally {
|
|
94
|
+
client.release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ok(data) {
|
|
99
|
+
const text = JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2);
|
|
100
|
+
return { content: [{ type: "text", text }] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(err) {
|
|
104
|
+
return { content: [{ type: "text", text: `Erro: ${err.message}` }], isError: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatResult(result) {
|
|
108
|
+
const rows = result.rows || [];
|
|
109
|
+
return {
|
|
110
|
+
command: result.command,
|
|
111
|
+
rowCount: result.rowCount,
|
|
112
|
+
truncated: rows.length > MAX_ROWS,
|
|
113
|
+
rows: rows.slice(0, MAX_ROWS),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const databaseArg = z.string().min(1).describe("Nome do database no servidor PostgreSQL");
|
|
118
|
+
const paramsArg = z
|
|
119
|
+
.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Parâmetros posicionais ($1, $2, ...)");
|
|
122
|
+
|
|
123
|
+
const server = new McpServer({ name: SERVER_NAME, version: PKG_VERSION });
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
"list_databases",
|
|
127
|
+
{ description: `Lista os databases disponíveis em ${config.host}:${config.port}` },
|
|
128
|
+
async () => {
|
|
129
|
+
try {
|
|
130
|
+
return ok(await listDatabases(true));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return fail(err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.registerTool(
|
|
138
|
+
"list_schemas",
|
|
139
|
+
{ description: "Lista os schemas de um database", inputSchema: { database: databaseArg } },
|
|
140
|
+
async ({ database }) => {
|
|
141
|
+
try {
|
|
142
|
+
const r = await run(
|
|
143
|
+
database,
|
|
144
|
+
`SELECT schema_name FROM information_schema.schemata
|
|
145
|
+
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
|
|
146
|
+
AND schema_name NOT LIKE 'pg_toast%' AND schema_name NOT LIKE 'pg_temp%'
|
|
147
|
+
ORDER BY schema_name`
|
|
148
|
+
);
|
|
149
|
+
return ok(r.rows.map((x) => x.schema_name));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return fail(err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
server.registerTool(
|
|
157
|
+
"list_tables",
|
|
158
|
+
{
|
|
159
|
+
description: "Lista tabelas e views de um schema",
|
|
160
|
+
inputSchema: { database: databaseArg, schema: z.string().default("public") },
|
|
161
|
+
},
|
|
162
|
+
async ({ database, schema }) => {
|
|
163
|
+
try {
|
|
164
|
+
const r = await run(
|
|
165
|
+
database,
|
|
166
|
+
`SELECT table_name, table_type FROM information_schema.tables
|
|
167
|
+
WHERE table_schema = $1 ORDER BY table_name`,
|
|
168
|
+
[schema]
|
|
169
|
+
);
|
|
170
|
+
return ok(r.rows);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return fail(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
server.registerTool(
|
|
178
|
+
"describe_table",
|
|
179
|
+
{
|
|
180
|
+
description: "Mostra colunas, tipos, nulidade, defaults e chaves de uma tabela",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
database: databaseArg,
|
|
183
|
+
table: z.string().min(1),
|
|
184
|
+
schema: z.string().default("public"),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
async ({ database, table, schema }) => {
|
|
188
|
+
try {
|
|
189
|
+
const cols = await run(
|
|
190
|
+
database,
|
|
191
|
+
`SELECT column_name, data_type, character_maximum_length, is_nullable, column_default
|
|
192
|
+
FROM information_schema.columns
|
|
193
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
194
|
+
ORDER BY ordinal_position`,
|
|
195
|
+
[schema, table]
|
|
196
|
+
);
|
|
197
|
+
const keys = await run(
|
|
198
|
+
database,
|
|
199
|
+
`SELECT tc.constraint_type, tc.constraint_name, kcu.column_name,
|
|
200
|
+
ccu.table_name AS ref_table, ccu.column_name AS ref_column
|
|
201
|
+
FROM information_schema.table_constraints tc
|
|
202
|
+
JOIN information_schema.key_column_usage kcu
|
|
203
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
|
|
204
|
+
LEFT JOIN information_schema.constraint_column_usage ccu
|
|
205
|
+
ON ccu.constraint_name = tc.constraint_name AND tc.constraint_type = 'FOREIGN KEY'
|
|
206
|
+
WHERE tc.table_schema = $1 AND tc.table_name = $2
|
|
207
|
+
AND tc.constraint_type IN ('PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE')
|
|
208
|
+
ORDER BY tc.constraint_type, tc.constraint_name`,
|
|
209
|
+
[schema, table]
|
|
210
|
+
);
|
|
211
|
+
if (cols.rows.length === 0) throw new Error(`Tabela ${schema}.${table} não encontrada em ${database}`);
|
|
212
|
+
return ok({ columns: cols.rows, constraints: keys.rows });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return fail(err);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
server.registerTool(
|
|
220
|
+
"query",
|
|
221
|
+
{
|
|
222
|
+
description: `Executa um único comando SQL somente leitura (transação READ ONLY). Retorna até ${MAX_ROWS} linhas.`,
|
|
223
|
+
inputSchema: { database: databaseArg, sql: z.string().min(1), params: paramsArg },
|
|
224
|
+
},
|
|
225
|
+
async ({ database, sql, params }) => {
|
|
226
|
+
try {
|
|
227
|
+
return ok(formatResult(await run(database, sql, params ?? [])));
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return fail(err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (ALLOW_WRITE) {
|
|
235
|
+
server.registerTool(
|
|
236
|
+
"execute",
|
|
237
|
+
{
|
|
238
|
+
description: "Executa um único comando SQL com escrita (INSERT/UPDATE/DELETE/DDL) e faz COMMIT.",
|
|
239
|
+
inputSchema: { database: databaseArg, sql: z.string().min(1), params: paramsArg },
|
|
240
|
+
},
|
|
241
|
+
async ({ database, sql, params }) => {
|
|
242
|
+
try {
|
|
243
|
+
return ok(formatResult(await run(database, sql, params ?? [], { readOnly: false })));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return fail(err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function shutdown() {
|
|
252
|
+
await Promise.allSettled([...pools.values()].map((p) => p.end()));
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
process.on("SIGINT", shutdown);
|
|
256
|
+
process.on("SIGTERM", shutdown);
|
|
257
|
+
|
|
258
|
+
await server.connect(new StdioServerTransport());
|
|
259
|
+
console.error(
|
|
260
|
+
`${PKG_NAME}@${PKG_VERSION} pronto (${config.user}@${config.host}:${config.port}, escrita ${ALLOW_WRITE ? "ON" : "OFF"})`
|
|
261
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delta-si/mcp-pg-multidb",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server para PostgreSQL que acessa qualquer database do servidor com uma única configuração; o database é escolhido em cada chamada",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-pg-multidb": "index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"postgres",
|
|
21
|
+
"postgresql",
|
|
22
|
+
"multi-database"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
27
|
+
"pg": "8.23.1",
|
|
28
|
+
"zod": "4.6.7"
|
|
29
|
+
}
|
|
30
|
+
}
|