easy-pg-admin-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/README.md +139 -0
- package/README.zh-TW.md +105 -0
- package/build/config.js +39 -0
- package/build/confirmationStore.js +32 -0
- package/build/databaseTools.js +1 -0
- package/build/db.js +38 -0
- package/build/index.js +182 -0
- package/build/roleTools.js +1 -0
- package/build/toolHandlers.js +260 -0
- package/build/validation.js +74 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# easy-pg-admin-mcp
|
|
2
|
+
|
|
3
|
+
High-privilege PostgreSQL admin MCP server for database and role/grant management.
|
|
4
|
+
|
|
5
|
+
This project is a DBA-style tool. It does not provide raw SQL execution and does not manage tables, schemas, views, indexes, triggers, or functions. Use `easy-pg-mcp` for data access and schema/table operations.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- List, create, inspect, and change owners for databases
|
|
10
|
+
- List, create, update, and drop PostgreSQL roles
|
|
11
|
+
- Grant and revoke role memberships
|
|
12
|
+
- Grant and revoke database-level privileges
|
|
13
|
+
- Protect destructive actions with short-lived confirmation tokens
|
|
14
|
+
|
|
15
|
+
## Available Tools
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `pg_list_databases` | List databases in the current PostgreSQL instance |
|
|
20
|
+
| `pg_create_database` | Create a PostgreSQL database |
|
|
21
|
+
| `pg_describe_database` | Inspect a PostgreSQL database |
|
|
22
|
+
| `pg_alter_database_owner` | Change a database owner |
|
|
23
|
+
| `pg_drop_database` | Request database deletion and return a confirmation token |
|
|
24
|
+
| `pg_list_roles` | List PostgreSQL roles |
|
|
25
|
+
| `pg_create_role` | Create a PostgreSQL role without SUPERUSER support |
|
|
26
|
+
| `pg_alter_role_password` | Change a role password |
|
|
27
|
+
| `pg_alter_role_attributes` | Change supported role attributes |
|
|
28
|
+
| `pg_drop_role` | Request role deletion and return a confirmation token |
|
|
29
|
+
| `pg_grant_role` | Grant a role to another role |
|
|
30
|
+
| `pg_revoke_role` | Revoke a role from another role |
|
|
31
|
+
| `pg_show_role_memberships` | Show memberships for a role |
|
|
32
|
+
| `pg_grant_privileges` | Grant database-level privileges to a role |
|
|
33
|
+
| `pg_revoke_privileges` | Revoke database-level privileges from a role |
|
|
34
|
+
| `pg_show_grants` | Show database-level grants for a role |
|
|
35
|
+
| `pg_confirm_task` | Confirm and execute a destructive action token |
|
|
36
|
+
|
|
37
|
+
## Safety
|
|
38
|
+
|
|
39
|
+
- No raw SQL passthrough
|
|
40
|
+
- No schema, table, view, index, trigger, or function management
|
|
41
|
+
- SUPERUSER role creation and modification are not supported
|
|
42
|
+
- `pg_drop_database` and `pg_drop_role` require `pg_confirm_task`
|
|
43
|
+
- `pg_drop_role` does not support `REASSIGN OWNED` or `DROP OWNED`
|
|
44
|
+
- Confirmation tokens are random, single-use, and expire quickly
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
Use environment variables, matching the rest of the `easy-*-mcp` family.
|
|
49
|
+
|
|
50
|
+
| Variable | Required | Default | Description |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| `PG_CONNECTION_STRING` | Conditional | - | PostgreSQL connection string. Takes precedence when provided |
|
|
53
|
+
| `PG_HOST` | Conditional | - | PostgreSQL host when no connection string is provided |
|
|
54
|
+
| `PG_PORT` | No | `5432` | PostgreSQL port |
|
|
55
|
+
| `PG_USER` | Conditional | - | PostgreSQL admin role name when no connection string is provided |
|
|
56
|
+
| `PG_PASSWORD` | No | - | PostgreSQL password |
|
|
57
|
+
| `PG_DATABASE` | Conditional | - | Default database used for the admin connection |
|
|
58
|
+
| `PG_CONNECTION_LIMIT` | No | `10` | Maximum number of active pool connections |
|
|
59
|
+
| `PG_IDLE_TIMEOUT` | No | `30000` | Idle connection timeout in milliseconds |
|
|
60
|
+
| `PG_ENABLE_KEEP_ALIVE` | No | `true` | Whether TCP keep-alive is enabled |
|
|
61
|
+
| `PG_KEEP_ALIVE_INITIAL_DELAY` | No | `0` | Initial TCP keep-alive delay in milliseconds |
|
|
62
|
+
| `PG_SSL` | No | `false` | Use `true`, `false`, or `no-verify` |
|
|
63
|
+
| `PG_ADMIN_TOKEN_TTL_SECONDS` | No | `120` | Confirmation token lifetime in seconds |
|
|
64
|
+
|
|
65
|
+
## Example
|
|
66
|
+
|
|
67
|
+
```env
|
|
68
|
+
PG_HOST=localhost
|
|
69
|
+
PG_PORT=5432
|
|
70
|
+
PG_USER=postgres
|
|
71
|
+
PG_PASSWORD=your_password
|
|
72
|
+
PG_DATABASE=postgres
|
|
73
|
+
PG_SSL=false
|
|
74
|
+
PG_ADMIN_TOKEN_TTL_SECONDS=120
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Claude Desktop Example
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"easy-pg-admin-mcp": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": ["-y", "easy-pg-admin-mcp"],
|
|
85
|
+
"env": {
|
|
86
|
+
"PG_HOST": "localhost",
|
|
87
|
+
"PG_PORT": "5432",
|
|
88
|
+
"PG_USER": "postgres",
|
|
89
|
+
"PG_PASSWORD": "your_password",
|
|
90
|
+
"PG_DATABASE": "postgres",
|
|
91
|
+
"PG_SSL": "false",
|
|
92
|
+
"PG_ADMIN_TOKEN_TTL_SECONDS": "120"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Codex config.toml Example
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
[mcp_servers.easy-pg-admin-mcp]
|
|
103
|
+
args = ["-y", "easy-pg-admin-mcp"]
|
|
104
|
+
command = "npx"
|
|
105
|
+
enabled = true
|
|
106
|
+
|
|
107
|
+
[mcp_servers.easy-pg-admin-mcp.env]
|
|
108
|
+
PG_HOST = "localhost"
|
|
109
|
+
PG_PORT = "5432"
|
|
110
|
+
PG_USER = "postgres"
|
|
111
|
+
PG_PASSWORD = "your_password"
|
|
112
|
+
PG_DATABASE = "postgres"
|
|
113
|
+
PG_SSL = "false"
|
|
114
|
+
PG_ADMIN_TOKEN_TTL_SECONDS = "120"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## OpenCode opencode.jsonc Example
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"$schema": "https://opencode.ai/config.json",
|
|
122
|
+
"mcp": {
|
|
123
|
+
"easy-pg-admin-mcp": {
|
|
124
|
+
"type": "local",
|
|
125
|
+
"command": ["npx", "-y", "easy-pg-admin-mcp"],
|
|
126
|
+
"enabled": true,
|
|
127
|
+
"environment": {
|
|
128
|
+
"PG_HOST": "localhost",
|
|
129
|
+
"PG_PORT": "5432",
|
|
130
|
+
"PG_USER": "postgres",
|
|
131
|
+
"PG_PASSWORD": "your_password",
|
|
132
|
+
"PG_DATABASE": "postgres",
|
|
133
|
+
"PG_SSL": "false",
|
|
134
|
+
"PG_ADMIN_TOKEN_TTL_SECONDS": "120",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
```
|
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# easy-pg-admin-mcp
|
|
2
|
+
|
|
3
|
+
一個高權限的 PostgreSQL 管理型 MCP server,專門處理 database、role 與 database-level grants 管理。
|
|
4
|
+
|
|
5
|
+
這個專案的定位是 DBA 類工具,不提供任意 SQL 執行,也不處理 schema、table、view、index、trigger、function 管理。資料查詢與 schema/table 操作請使用 `easy-pg-mcp`。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 列出、建立、檢視 database,並修改 owner
|
|
10
|
+
- 列出、建立、修改、刪除 PostgreSQL roles
|
|
11
|
+
- 授予與撤銷 role membership
|
|
12
|
+
- 授予與撤銷 database-level privileges
|
|
13
|
+
- 對危險刪除操作提供短效確認 token
|
|
14
|
+
|
|
15
|
+
## 可用工具
|
|
16
|
+
|
|
17
|
+
| 工具 | 說明 |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `pg_list_databases` | 列出目前 PostgreSQL instance 內的 databases |
|
|
20
|
+
| `pg_create_database` | 建立 PostgreSQL database |
|
|
21
|
+
| `pg_describe_database` | 檢視 PostgreSQL database |
|
|
22
|
+
| `pg_alter_database_owner` | 修改 database owner |
|
|
23
|
+
| `pg_drop_database` | 提出刪除 database 的請求並回傳確認 token |
|
|
24
|
+
| `pg_list_roles` | 列出 PostgreSQL roles |
|
|
25
|
+
| `pg_create_role` | 建立不支援 SUPERUSER 的 PostgreSQL role |
|
|
26
|
+
| `pg_alter_role_password` | 修改 role 密碼 |
|
|
27
|
+
| `pg_alter_role_attributes` | 修改支援的 role attributes |
|
|
28
|
+
| `pg_drop_role` | 提出刪除 role 的請求並回傳確認 token |
|
|
29
|
+
| `pg_grant_role` | 將 role 授予另一個 role |
|
|
30
|
+
| `pg_revoke_role` | 從另一個 role 撤銷 role |
|
|
31
|
+
| `pg_show_role_memberships` | 顯示 role membership |
|
|
32
|
+
| `pg_grant_privileges` | 對 role 授予 database-level privileges |
|
|
33
|
+
| `pg_revoke_privileges` | 從 role 撤銷 database-level privileges |
|
|
34
|
+
| `pg_show_grants` | 顯示 role 的 database-level grants |
|
|
35
|
+
| `pg_confirm_task` | 確認並執行先前產生的危險操作 token |
|
|
36
|
+
|
|
37
|
+
## 安全性
|
|
38
|
+
|
|
39
|
+
- 不提供原始 SQL passthrough
|
|
40
|
+
- 不處理 schema、table、view、index、trigger、function 管理
|
|
41
|
+
- 不支援建立或修改 SUPERUSER role
|
|
42
|
+
- `pg_drop_database` 與 `pg_drop_role` 一律要經過 `pg_confirm_task`
|
|
43
|
+
- `pg_drop_role` 不提供 `REASSIGN OWNED` 或 `DROP OWNED`
|
|
44
|
+
- confirmation token 是隨機產生、只能使用一次、且會在短時間後過期
|
|
45
|
+
|
|
46
|
+
## 設定
|
|
47
|
+
|
|
48
|
+
請使用環境變數設定,風格與其他 `easy-*-mcp` 專案一致。
|
|
49
|
+
|
|
50
|
+
| 變數 | 必填 | 預設值 | 說明 |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| `PG_CONNECTION_STRING` | 條件必填 | - | PostgreSQL connection string;提供時優先使用 |
|
|
53
|
+
| `PG_HOST` | 條件必填 | - | 未提供 connection string 時使用的 PostgreSQL host |
|
|
54
|
+
| `PG_PORT` | 否 | `5432` | PostgreSQL port |
|
|
55
|
+
| `PG_USER` | 條件必填 | - | 未提供 connection string 時使用的 PostgreSQL admin role |
|
|
56
|
+
| `PG_PASSWORD` | 否 | - | PostgreSQL 密碼 |
|
|
57
|
+
| `PG_DATABASE` | 條件必填 | - | 管理連線使用的預設 database |
|
|
58
|
+
| `PG_CONNECTION_LIMIT` | 否 | `10` | pool 最大 active connections |
|
|
59
|
+
| `PG_IDLE_TIMEOUT` | 否 | `30000` | idle connection timeout,單位毫秒 |
|
|
60
|
+
| `PG_ENABLE_KEEP_ALIVE` | 否 | `true` | 是否啟用 TCP keep-alive |
|
|
61
|
+
| `PG_KEEP_ALIVE_INITIAL_DELAY` | 否 | `0` | TCP keep-alive 初始延遲,單位毫秒 |
|
|
62
|
+
| `PG_SSL` | 否 | `false` | 可使用 `true`、`false` 或 `no-verify` |
|
|
63
|
+
| `PG_ADMIN_TOKEN_TTL_SECONDS` | 否 | `120` | confirmation token 的有效秒數 |
|
|
64
|
+
|
|
65
|
+
## 範例
|
|
66
|
+
|
|
67
|
+
```env
|
|
68
|
+
PG_HOST=localhost
|
|
69
|
+
PG_PORT=5432
|
|
70
|
+
PG_USER=postgres
|
|
71
|
+
PG_PASSWORD=your_password
|
|
72
|
+
PG_DATABASE=postgres
|
|
73
|
+
PG_SSL=false
|
|
74
|
+
PG_ADMIN_TOKEN_TTL_SECONDS=120
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Claude Desktop 範例
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"easy-pg-admin-mcp": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": ["-y", "easy-pg-admin-mcp"],
|
|
85
|
+
"env": {
|
|
86
|
+
"PG_HOST": "localhost",
|
|
87
|
+
"PG_PORT": "5432",
|
|
88
|
+
"PG_USER": "postgres",
|
|
89
|
+
"PG_PASSWORD": "your_password",
|
|
90
|
+
"PG_DATABASE": "postgres",
|
|
91
|
+
"PG_SSL": "false",
|
|
92
|
+
"PG_ADMIN_TOKEN_TTL_SECONDS": "120"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
更新設定後,請重新啟動 Claude Desktop。
|
|
100
|
+
|
|
101
|
+
## 備註
|
|
102
|
+
|
|
103
|
+
- `pg_drop_database` 與 `pg_drop_role` 不會直接執行
|
|
104
|
+
- 這兩個動作會先產生 token,使用者確認後才會透過 `pg_confirm_task` 真正執行
|
|
105
|
+
- token 是短效且單次使用,不會保留成長期 pending queue
|
package/build/config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function parsePositiveInt(value, defaultValue) {
|
|
2
|
+
const parsed = value ? Number.parseInt(value, 10) : NaN;
|
|
3
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
4
|
+
}
|
|
5
|
+
function parseBoolean(value, defaultValue = true) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return defaultValue;
|
|
8
|
+
return value.toLowerCase() !== 'false';
|
|
9
|
+
}
|
|
10
|
+
function parseSslConfig(value) {
|
|
11
|
+
if (!value || value.toLowerCase() === 'false') {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
if (value.toLowerCase() === 'true') {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (value.toLowerCase() === 'no-verify') {
|
|
18
|
+
return { rejectUnauthorized: false };
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
export function parseAdminConfig(env) {
|
|
23
|
+
return {
|
|
24
|
+
pg: {
|
|
25
|
+
connectionString: env.PG_CONNECTION_STRING?.trim() || undefined,
|
|
26
|
+
host: env.PG_HOST ?? '',
|
|
27
|
+
port: parsePositiveInt(env.PG_PORT, 5432),
|
|
28
|
+
user: env.PG_USER ?? '',
|
|
29
|
+
password: env.PG_PASSWORD,
|
|
30
|
+
database: env.PG_DATABASE ?? '',
|
|
31
|
+
connectionLimit: parsePositiveInt(env.PG_CONNECTION_LIMIT, 10),
|
|
32
|
+
idleTimeout: parsePositiveInt(env.PG_IDLE_TIMEOUT, 30000),
|
|
33
|
+
enableKeepAlive: parseBoolean(env.PG_ENABLE_KEEP_ALIVE, true),
|
|
34
|
+
keepAliveInitialDelay: env.PG_KEEP_ALIVE_INITIAL_DELAY ? Number.parseInt(env.PG_KEEP_ALIVE_INITIAL_DELAY, 10) : 0,
|
|
35
|
+
ssl: parseSslConfig(env.PG_SSL),
|
|
36
|
+
},
|
|
37
|
+
tokenTtlSeconds: parsePositiveInt(env.PG_ADMIN_TOKEN_TTL_SECONDS, 120),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export function createConfirmationStore(options) {
|
|
3
|
+
const tasks = new Map();
|
|
4
|
+
return {
|
|
5
|
+
create(task) {
|
|
6
|
+
const token = randomUUID();
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
tasks.set(token, {
|
|
9
|
+
action: task.action,
|
|
10
|
+
target: task.target,
|
|
11
|
+
options: task.options,
|
|
12
|
+
createdAt: now,
|
|
13
|
+
expiresAt: now + options.defaultTtlMs,
|
|
14
|
+
used: false,
|
|
15
|
+
});
|
|
16
|
+
return token;
|
|
17
|
+
},
|
|
18
|
+
consume(token) {
|
|
19
|
+
const task = tasks.get(token);
|
|
20
|
+
if (!task) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (task.used || task.expiresAt <= Date.now()) {
|
|
24
|
+
tasks.delete(token);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
task.used = true;
|
|
28
|
+
tasks.delete(token);
|
|
29
|
+
return task;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/build/db.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { parseAdminConfig } from './config.js';
|
|
3
|
+
let pool;
|
|
4
|
+
export function getPool() {
|
|
5
|
+
if (pool) {
|
|
6
|
+
return pool;
|
|
7
|
+
}
|
|
8
|
+
const config = parseAdminConfig(process.env);
|
|
9
|
+
if (!config.pg.connectionString && (!config.pg.host || !config.pg.user || !config.pg.database)) {
|
|
10
|
+
throw new Error('Missing required environment variables for PostgreSQL admin connection.');
|
|
11
|
+
}
|
|
12
|
+
pool = new Pool({
|
|
13
|
+
connectionString: config.pg.connectionString,
|
|
14
|
+
host: config.pg.connectionString ? undefined : config.pg.host,
|
|
15
|
+
port: config.pg.connectionString ? undefined : config.pg.port,
|
|
16
|
+
user: config.pg.connectionString ? undefined : config.pg.user,
|
|
17
|
+
password: config.pg.connectionString ? undefined : config.pg.password,
|
|
18
|
+
database: config.pg.connectionString ? undefined : config.pg.database,
|
|
19
|
+
max: config.pg.connectionLimit,
|
|
20
|
+
idleTimeoutMillis: config.pg.idleTimeout,
|
|
21
|
+
keepAlive: config.pg.enableKeepAlive,
|
|
22
|
+
keepAliveInitialDelayMillis: config.pg.keepAliveInitialDelay,
|
|
23
|
+
ssl: config.pg.ssl,
|
|
24
|
+
});
|
|
25
|
+
return pool;
|
|
26
|
+
}
|
|
27
|
+
export function escapeIdentifier(identifier) {
|
|
28
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
29
|
+
}
|
|
30
|
+
export async function withClient(callback) {
|
|
31
|
+
const client = await getPool().connect();
|
|
32
|
+
try {
|
|
33
|
+
return await callback(client);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
client.release();
|
|
37
|
+
}
|
|
38
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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 { parseAdminConfig } from './config.js';
|
|
6
|
+
import { createConfirmationStore } from './confirmationStore.js';
|
|
7
|
+
import { getPool } from './db.js';
|
|
8
|
+
import { createAdminHandlers } from './toolHandlers.js';
|
|
9
|
+
const config = parseAdminConfig(process.env);
|
|
10
|
+
const store = createConfirmationStore({ defaultTtlMs: config.tokenTtlSeconds * 1000 });
|
|
11
|
+
getPool();
|
|
12
|
+
const handlers = createAdminHandlers(store, config.tokenTtlSeconds);
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: 'easy-pg-admin-mcp',
|
|
15
|
+
version: '0.1.0',
|
|
16
|
+
description: `PostgreSQL Admin: ${config.pg.connectionString ? 'connection-string' : `${config.pg.host}:${config.pg.port}/${config.pg.database}`}`,
|
|
17
|
+
});
|
|
18
|
+
const roleAttributesSchema = {
|
|
19
|
+
login: z.boolean().optional(),
|
|
20
|
+
createdb: z.boolean().optional(),
|
|
21
|
+
createrole: z.boolean().optional(),
|
|
22
|
+
inherit: z.boolean().optional(),
|
|
23
|
+
replication: z.boolean().optional(),
|
|
24
|
+
bypassrls: z.boolean().optional(),
|
|
25
|
+
connectionLimit: z.number().int().min(-1).optional(),
|
|
26
|
+
validUntil: z.string().min(1).nullable().optional(),
|
|
27
|
+
};
|
|
28
|
+
function textJson(value) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
server.registerTool('pg_list_databases', {
|
|
34
|
+
description: 'List databases in the current PostgreSQL instance.',
|
|
35
|
+
inputSchema: z.object({}),
|
|
36
|
+
}, async () => textJson(await handlers.listDatabases()));
|
|
37
|
+
server.registerTool('pg_create_database', {
|
|
38
|
+
description: 'Create a PostgreSQL database.',
|
|
39
|
+
inputSchema: z.object({
|
|
40
|
+
database: z.string().min(1),
|
|
41
|
+
owner: z.string().min(1).optional(),
|
|
42
|
+
encoding: z.string().min(1).optional(),
|
|
43
|
+
template: z.string().min(1).optional(),
|
|
44
|
+
locale: z.string().min(1).optional(),
|
|
45
|
+
}),
|
|
46
|
+
}, async ({ database, owner, encoding, template, locale }) => textJson(await handlers.createDatabase({ database, owner, encoding, template, locale })));
|
|
47
|
+
server.registerTool('pg_describe_database', {
|
|
48
|
+
description: 'Inspect a PostgreSQL database definition.',
|
|
49
|
+
inputSchema: z.object({
|
|
50
|
+
database: z.string().min(1),
|
|
51
|
+
}),
|
|
52
|
+
}, async ({ database }) => textJson(await handlers.describeDatabase(database)));
|
|
53
|
+
server.registerTool('pg_alter_database_owner', {
|
|
54
|
+
description: 'Change the owner of a PostgreSQL database.',
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
database: z.string().min(1),
|
|
57
|
+
owner: z.string().min(1),
|
|
58
|
+
}),
|
|
59
|
+
}, async ({ database, owner }) => textJson(await handlers.alterDatabaseOwner(database, owner)));
|
|
60
|
+
server.registerTool('pg_drop_database', {
|
|
61
|
+
description: 'Request database deletion and return a short-lived confirmation token.',
|
|
62
|
+
inputSchema: z.object({
|
|
63
|
+
database: z.string().min(1),
|
|
64
|
+
force: z.boolean().optional(),
|
|
65
|
+
}),
|
|
66
|
+
}, async ({ database, force }) => textJson(await handlers.dropDatabase(database, { force })));
|
|
67
|
+
server.registerTool('pg_list_roles', {
|
|
68
|
+
description: 'List PostgreSQL roles.',
|
|
69
|
+
inputSchema: z.object({
|
|
70
|
+
includeSystem: z.boolean().optional(),
|
|
71
|
+
}),
|
|
72
|
+
}, async ({ includeSystem }) => textJson(await handlers.listRoles({ includeSystem })));
|
|
73
|
+
server.registerTool('pg_create_role', {
|
|
74
|
+
description: 'Create a PostgreSQL role. SUPERUSER is intentionally not supported.',
|
|
75
|
+
inputSchema: z.object({
|
|
76
|
+
role: z.string().min(1),
|
|
77
|
+
password: z.string().min(1).optional(),
|
|
78
|
+
...roleAttributesSchema,
|
|
79
|
+
}),
|
|
80
|
+
}, async ({ role, password, login, createdb, createrole, inherit, replication, bypassrls, connectionLimit, validUntil }) => textJson(await handlers.createRole({
|
|
81
|
+
role,
|
|
82
|
+
password,
|
|
83
|
+
login,
|
|
84
|
+
createdb,
|
|
85
|
+
createrole,
|
|
86
|
+
inherit,
|
|
87
|
+
replication,
|
|
88
|
+
bypassrls,
|
|
89
|
+
connectionLimit,
|
|
90
|
+
validUntil,
|
|
91
|
+
})));
|
|
92
|
+
server.registerTool('pg_alter_role_password', {
|
|
93
|
+
description: 'Change a PostgreSQL role password.',
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
role: z.string().min(1),
|
|
96
|
+
password: z.string().min(1),
|
|
97
|
+
}),
|
|
98
|
+
}, async ({ role, password }) => textJson(await handlers.alterRolePassword(role, password)));
|
|
99
|
+
server.registerTool('pg_alter_role_attributes', {
|
|
100
|
+
description: 'Change PostgreSQL role attributes. SUPERUSER is intentionally not supported.',
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
role: z.string().min(1),
|
|
103
|
+
...roleAttributesSchema,
|
|
104
|
+
}),
|
|
105
|
+
}, async ({ role, login, createdb, createrole, inherit, replication, bypassrls, connectionLimit, validUntil }) => textJson(await handlers.alterRoleAttributes(role, {
|
|
106
|
+
login,
|
|
107
|
+
createdb,
|
|
108
|
+
createrole,
|
|
109
|
+
inherit,
|
|
110
|
+
replication,
|
|
111
|
+
bypassrls,
|
|
112
|
+
connectionLimit,
|
|
113
|
+
validUntil,
|
|
114
|
+
})));
|
|
115
|
+
server.registerTool('pg_drop_role', {
|
|
116
|
+
description: 'Request role deletion and return a short-lived confirmation token.',
|
|
117
|
+
inputSchema: z.object({
|
|
118
|
+
role: z.string().min(1),
|
|
119
|
+
}),
|
|
120
|
+
}, async ({ role }) => textJson(await handlers.dropRole(role)));
|
|
121
|
+
server.registerTool('pg_grant_role', {
|
|
122
|
+
description: 'Grant a PostgreSQL role to another role.',
|
|
123
|
+
inputSchema: z.object({
|
|
124
|
+
role: z.string().min(1),
|
|
125
|
+
member: z.string().min(1),
|
|
126
|
+
adminOption: z.boolean().optional(),
|
|
127
|
+
}),
|
|
128
|
+
}, async ({ role, member, adminOption }) => textJson(await handlers.grantRole({ role, member, adminOption })));
|
|
129
|
+
server.registerTool('pg_revoke_role', {
|
|
130
|
+
description: 'Revoke a PostgreSQL role from another role.',
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
role: z.string().min(1),
|
|
133
|
+
member: z.string().min(1),
|
|
134
|
+
adminOption: z.boolean().optional(),
|
|
135
|
+
}),
|
|
136
|
+
}, async ({ role, member, adminOption }) => textJson(await handlers.revokeRole({ role, member, adminOption })));
|
|
137
|
+
server.registerTool('pg_show_role_memberships', {
|
|
138
|
+
description: 'Show memberships for a PostgreSQL role.',
|
|
139
|
+
inputSchema: z.object({
|
|
140
|
+
role: z.string().min(1),
|
|
141
|
+
}),
|
|
142
|
+
}, async ({ role }) => textJson(await handlers.showRoleMemberships(role)));
|
|
143
|
+
server.registerTool('pg_grant_privileges', {
|
|
144
|
+
description: 'Grant database-level PostgreSQL privileges to a role.',
|
|
145
|
+
inputSchema: z.object({
|
|
146
|
+
role: z.string().min(1),
|
|
147
|
+
database: z.string().min(1),
|
|
148
|
+
privileges: z.array(z.string().min(1)).min(1),
|
|
149
|
+
withGrantOption: z.boolean().optional(),
|
|
150
|
+
}),
|
|
151
|
+
}, async ({ role, database, privileges, withGrantOption }) => textJson(await handlers.grantPrivileges({ role, database, privileges, withGrantOption })));
|
|
152
|
+
server.registerTool('pg_revoke_privileges', {
|
|
153
|
+
description: 'Revoke database-level PostgreSQL privileges from a role.',
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
role: z.string().min(1),
|
|
156
|
+
database: z.string().min(1),
|
|
157
|
+
privileges: z.array(z.string().min(1)).min(1),
|
|
158
|
+
grantOptionFor: z.boolean().optional(),
|
|
159
|
+
}),
|
|
160
|
+
}, async ({ role, database, privileges, grantOptionFor }) => textJson(await handlers.revokePrivileges({ role, database, privileges, grantOptionFor })));
|
|
161
|
+
server.registerTool('pg_show_grants', {
|
|
162
|
+
description: 'Show database-level PostgreSQL grants for a role.',
|
|
163
|
+
inputSchema: z.object({
|
|
164
|
+
role: z.string().min(1),
|
|
165
|
+
database: z.string().min(1).optional(),
|
|
166
|
+
}),
|
|
167
|
+
}, async ({ role, database }) => textJson(await handlers.showGrants({ role, database })));
|
|
168
|
+
server.registerTool('pg_confirm_task', {
|
|
169
|
+
description: 'Confirm and execute a previously issued destructive action token.',
|
|
170
|
+
inputSchema: z.object({
|
|
171
|
+
token: z.string().min(1),
|
|
172
|
+
}),
|
|
173
|
+
}, async ({ token }) => textJson(await handlers.confirmTask(token)));
|
|
174
|
+
async function main() {
|
|
175
|
+
const transport = new StdioServerTransport();
|
|
176
|
+
await server.connect(transport);
|
|
177
|
+
console.error('easy-pg-admin-mcp running on stdio');
|
|
178
|
+
}
|
|
179
|
+
main().catch((error) => {
|
|
180
|
+
console.error('Fatal error in main():', error);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { escapeIdentifier, getPool } from './db.js';
|
|
2
|
+
import { assertNoSuperuser, formatPrivilegeList, parseRoleAttributes, validateDatabaseName, validateExistingRoleName, validateRoleName, } from './validation.js';
|
|
3
|
+
export function formatError(error, code = 'ADMIN_OPERATION_FAILED') {
|
|
4
|
+
return {
|
|
5
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6
|
+
code,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function createAdminHandlers(store, tokenTtlSeconds) {
|
|
10
|
+
const pool = getPool();
|
|
11
|
+
return {
|
|
12
|
+
listDatabases: async () => {
|
|
13
|
+
const result = await pool.query(databaseQuery('ORDER BY d.datname'));
|
|
14
|
+
return result.rows;
|
|
15
|
+
},
|
|
16
|
+
createDatabase: async (input) => {
|
|
17
|
+
const database = validateDatabaseName(input.database);
|
|
18
|
+
const clauses = [escapeIdentifier(database)];
|
|
19
|
+
if (input.owner !== undefined) {
|
|
20
|
+
clauses.push(`OWNER ${escapeIdentifier(validateExistingRoleName(input.owner))}`);
|
|
21
|
+
}
|
|
22
|
+
if (input.encoding !== undefined) {
|
|
23
|
+
clauses.push(`ENCODING ${formatSqlString(validateEncoding(input.encoding))}`);
|
|
24
|
+
}
|
|
25
|
+
if (input.template !== undefined) {
|
|
26
|
+
clauses.push(`TEMPLATE ${escapeIdentifier(validateDatabaseName(input.template))}`);
|
|
27
|
+
}
|
|
28
|
+
if (input.locale !== undefined) {
|
|
29
|
+
clauses.push(`LOCALE ${formatSqlString(validateLocale(input.locale))}`);
|
|
30
|
+
}
|
|
31
|
+
await pool.query(`CREATE DATABASE ${clauses.join(' ')}`);
|
|
32
|
+
return { ok: true, database };
|
|
33
|
+
},
|
|
34
|
+
describeDatabase: async (database) => {
|
|
35
|
+
const safeDatabase = validateDatabaseName(database);
|
|
36
|
+
const result = await pool.query(databaseQuery('WHERE d.datname = $1'), [safeDatabase]);
|
|
37
|
+
return result.rows;
|
|
38
|
+
},
|
|
39
|
+
alterDatabaseOwner: async (database, owner) => {
|
|
40
|
+
const safeDatabase = validateDatabaseName(database);
|
|
41
|
+
const safeOwner = validateExistingRoleName(owner);
|
|
42
|
+
await pool.query(`ALTER DATABASE ${escapeIdentifier(safeDatabase)} OWNER TO ${escapeIdentifier(safeOwner)}`);
|
|
43
|
+
return { ok: true, database: safeDatabase, owner: safeOwner };
|
|
44
|
+
},
|
|
45
|
+
dropDatabase: async (database, options) => {
|
|
46
|
+
const safeDatabase = validateDatabaseName(database);
|
|
47
|
+
await assertDatabaseCanBeDropped(safeDatabase);
|
|
48
|
+
const token = store.create({ action: 'drop_database', target: safeDatabase, options: { force: Boolean(options?.force) } });
|
|
49
|
+
return { status: 'confirmation_required', token, expiresInSeconds: tokenTtlSeconds };
|
|
50
|
+
},
|
|
51
|
+
listRoles: async (input) => {
|
|
52
|
+
const result = await pool.query(`
|
|
53
|
+
SELECT
|
|
54
|
+
rolname AS role,
|
|
55
|
+
rolsuper AS superuser,
|
|
56
|
+
rolinherit AS inherit,
|
|
57
|
+
rolcreaterole AS "createRole",
|
|
58
|
+
rolcreatedb AS "createDb",
|
|
59
|
+
rolcanlogin AS login,
|
|
60
|
+
rolreplication AS replication,
|
|
61
|
+
rolbypassrls AS "bypassRls",
|
|
62
|
+
rolconnlimit AS "connectionLimit",
|
|
63
|
+
rolvaliduntil AS "validUntil"
|
|
64
|
+
FROM pg_catalog.pg_roles
|
|
65
|
+
WHERE ($1::boolean OR rolname !~ '^pg_')
|
|
66
|
+
ORDER BY rolname
|
|
67
|
+
`, [Boolean(input?.includeSystem)]);
|
|
68
|
+
return result.rows;
|
|
69
|
+
},
|
|
70
|
+
createRole: async (input) => {
|
|
71
|
+
assertNoSuperuser(input);
|
|
72
|
+
const role = validateRoleName(input.role);
|
|
73
|
+
const clauses = [escapeIdentifier(role)];
|
|
74
|
+
clauses.push(...roleAttributeClauses(input));
|
|
75
|
+
if (input.password !== undefined) {
|
|
76
|
+
clauses.push(`PASSWORD ${formatSqlString(validateRolePassword(input.password))}`);
|
|
77
|
+
}
|
|
78
|
+
await pool.query(`CREATE ROLE ${clauses.join(' ')}`);
|
|
79
|
+
return { ok: true, role };
|
|
80
|
+
},
|
|
81
|
+
alterRolePassword: async (role, password) => {
|
|
82
|
+
const safeRole = validateRoleName(role);
|
|
83
|
+
await pool.query(`ALTER ROLE ${escapeIdentifier(safeRole)} PASSWORD ${formatSqlString(validateRolePassword(password))}`);
|
|
84
|
+
return { ok: true, role: safeRole };
|
|
85
|
+
},
|
|
86
|
+
alterRoleAttributes: async (role, attributes) => {
|
|
87
|
+
assertNoSuperuser(attributes);
|
|
88
|
+
const safeRole = validateRoleName(role);
|
|
89
|
+
const clauses = roleAttributeClauses(attributes);
|
|
90
|
+
if (clauses.length === 0) {
|
|
91
|
+
throw new Error('at least one role attribute is required');
|
|
92
|
+
}
|
|
93
|
+
await pool.query(`ALTER ROLE ${escapeIdentifier(safeRole)} ${clauses.join(' ')}`);
|
|
94
|
+
return { ok: true, role: safeRole };
|
|
95
|
+
},
|
|
96
|
+
dropRole: async (role) => {
|
|
97
|
+
const safeRole = validateRoleName(role);
|
|
98
|
+
await assertRoleCanBeDropped(safeRole);
|
|
99
|
+
const token = store.create({ action: 'drop_role', target: safeRole });
|
|
100
|
+
return { status: 'confirmation_required', token, expiresInSeconds: tokenTtlSeconds };
|
|
101
|
+
},
|
|
102
|
+
grantRole: async (input) => {
|
|
103
|
+
const role = validateExistingRoleName(input.role);
|
|
104
|
+
const member = validateExistingRoleName(input.member);
|
|
105
|
+
const adminOption = input.adminOption ? ' WITH ADMIN OPTION' : '';
|
|
106
|
+
await pool.query(`GRANT ${escapeIdentifier(role)} TO ${escapeIdentifier(member)}${adminOption}`);
|
|
107
|
+
return { ok: true, role, member, adminOption: Boolean(input.adminOption) };
|
|
108
|
+
},
|
|
109
|
+
revokeRole: async (input) => {
|
|
110
|
+
const role = validateExistingRoleName(input.role);
|
|
111
|
+
const member = validateExistingRoleName(input.member);
|
|
112
|
+
const adminOption = input.adminOption ? 'ADMIN OPTION FOR ' : '';
|
|
113
|
+
await pool.query(`REVOKE ${adminOption}${escapeIdentifier(role)} FROM ${escapeIdentifier(member)}`);
|
|
114
|
+
return { ok: true, role, member, adminOption: Boolean(input.adminOption) };
|
|
115
|
+
},
|
|
116
|
+
showRoleMemberships: async (role) => {
|
|
117
|
+
const safeRole = validateExistingRoleName(role);
|
|
118
|
+
const result = await pool.query(`
|
|
119
|
+
SELECT 'member_of' AS direction, parent.rolname AS role, child.rolname AS member, m.admin_option AS "adminOption"
|
|
120
|
+
FROM pg_catalog.pg_auth_members m
|
|
121
|
+
JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid
|
|
122
|
+
JOIN pg_catalog.pg_roles child ON child.oid = m.member
|
|
123
|
+
WHERE child.rolname = $1
|
|
124
|
+
UNION ALL
|
|
125
|
+
SELECT 'has_member' AS direction, parent.rolname AS role, child.rolname AS member, m.admin_option AS "adminOption"
|
|
126
|
+
FROM pg_catalog.pg_auth_members m
|
|
127
|
+
JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid
|
|
128
|
+
JOIN pg_catalog.pg_roles child ON child.oid = m.member
|
|
129
|
+
WHERE parent.rolname = $1
|
|
130
|
+
ORDER BY direction, role, member
|
|
131
|
+
`, [safeRole]);
|
|
132
|
+
return result.rows;
|
|
133
|
+
},
|
|
134
|
+
grantPrivileges: async (input) => {
|
|
135
|
+
const role = validateExistingRoleName(input.role);
|
|
136
|
+
const database = validateDatabaseName(input.database);
|
|
137
|
+
const privileges = formatPrivilegeList(input.privileges);
|
|
138
|
+
const grantOption = input.withGrantOption ? ' WITH GRANT OPTION' : '';
|
|
139
|
+
await pool.query(`GRANT ${privileges} ON DATABASE ${escapeIdentifier(database)} TO ${escapeIdentifier(role)}${grantOption}`);
|
|
140
|
+
return { ok: true, role, database, privileges: input.privileges, withGrantOption: Boolean(input.withGrantOption) };
|
|
141
|
+
},
|
|
142
|
+
revokePrivileges: async (input) => {
|
|
143
|
+
const role = validateExistingRoleName(input.role);
|
|
144
|
+
const database = validateDatabaseName(input.database);
|
|
145
|
+
const privileges = formatPrivilegeList(input.privileges);
|
|
146
|
+
const grantOption = input.grantOptionFor ? 'GRANT OPTION FOR ' : '';
|
|
147
|
+
await pool.query(`REVOKE ${grantOption}${privileges} ON DATABASE ${escapeIdentifier(database)} FROM ${escapeIdentifier(role)}`);
|
|
148
|
+
return { ok: true, role, database, privileges: input.privileges, grantOptionFor: Boolean(input.grantOptionFor) };
|
|
149
|
+
},
|
|
150
|
+
showGrants: async (input) => {
|
|
151
|
+
const role = validateExistingRoleName(input.role);
|
|
152
|
+
const database = input.database === undefined ? null : validateDatabaseName(input.database);
|
|
153
|
+
const result = await pool.query(`
|
|
154
|
+
SELECT
|
|
155
|
+
d.datname AS database,
|
|
156
|
+
grantee.rolname AS grantee,
|
|
157
|
+
grantor.rolname AS grantor,
|
|
158
|
+
acl.privilege_type AS privilege,
|
|
159
|
+
acl.is_grantable AS "isGrantable"
|
|
160
|
+
FROM pg_catalog.pg_database d
|
|
161
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(d.datacl) AS acl
|
|
162
|
+
JOIN pg_catalog.pg_roles grantee ON grantee.oid = acl.grantee
|
|
163
|
+
JOIN pg_catalog.pg_roles grantor ON grantor.oid = acl.grantor
|
|
164
|
+
WHERE grantee.rolname = $1
|
|
165
|
+
AND ($2::text IS NULL OR d.datname = $2)
|
|
166
|
+
ORDER BY d.datname, acl.privilege_type
|
|
167
|
+
`, [role, database]);
|
|
168
|
+
return result.rows;
|
|
169
|
+
},
|
|
170
|
+
confirmTask: async (token) => {
|
|
171
|
+
const task = store.consume(token);
|
|
172
|
+
if (!task) {
|
|
173
|
+
return { status: 'error', error: 'token not found or expired', code: 'TOKEN_INVALID' };
|
|
174
|
+
}
|
|
175
|
+
if (task.action === 'drop_database') {
|
|
176
|
+
const force = Boolean(task.options?.force);
|
|
177
|
+
await assertDatabaseCanBeDropped(task.target);
|
|
178
|
+
await pool.query(`DROP DATABASE ${escapeIdentifier(task.target)}${force ? ' WITH (FORCE)' : ''}`);
|
|
179
|
+
return { status: 'confirmed', ok: true, action: task.action, target: task.target, force };
|
|
180
|
+
}
|
|
181
|
+
await assertRoleCanBeDropped(task.target);
|
|
182
|
+
await pool.query(`DROP ROLE ${escapeIdentifier(task.target)}`);
|
|
183
|
+
return { status: 'confirmed', ok: true, action: task.action, target: task.target };
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function databaseQuery(suffix) {
|
|
188
|
+
return `
|
|
189
|
+
SELECT
|
|
190
|
+
d.datname AS name,
|
|
191
|
+
pg_catalog.pg_get_userbyid(d.datdba) AS owner,
|
|
192
|
+
pg_catalog.pg_encoding_to_char(d.encoding) AS encoding,
|
|
193
|
+
d.datcollate AS collation,
|
|
194
|
+
d.datctype AS ctype,
|
|
195
|
+
d.datallowconn AS "allowConnections",
|
|
196
|
+
d.datconnlimit AS "connectionLimit",
|
|
197
|
+
d.datistemplate AS "isTemplate"
|
|
198
|
+
FROM pg_catalog.pg_database d
|
|
199
|
+
${suffix}
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
function roleAttributeClauses(input) {
|
|
203
|
+
const rawClauses = parseRoleAttributes(input);
|
|
204
|
+
const clauses = [];
|
|
205
|
+
for (const clause of rawClauses) {
|
|
206
|
+
if (clause === 'VALID UNTIL') {
|
|
207
|
+
clauses.push(`VALID UNTIL ${formatSqlString(validateValidUntil(input.validUntil))}`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
clauses.push(clause);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return clauses;
|
|
214
|
+
}
|
|
215
|
+
async function assertDatabaseCanBeDropped(database) {
|
|
216
|
+
const result = await getPool().query('SELECT current_database() AS current_database');
|
|
217
|
+
if (result.rows[0]?.current_database === database) {
|
|
218
|
+
throw new Error('cannot drop the current connection database');
|
|
219
|
+
}
|
|
220
|
+
const databaseResult = await getPool().query('SELECT datistemplate AS "isTemplate" FROM pg_catalog.pg_database WHERE datname = $1', [database]);
|
|
221
|
+
if (databaseResult.rows.length === 0) {
|
|
222
|
+
throw new Error('database not found');
|
|
223
|
+
}
|
|
224
|
+
if (databaseResult.rows[0].isTemplate) {
|
|
225
|
+
throw new Error('cannot drop a template database');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function assertRoleCanBeDropped(role) {
|
|
229
|
+
const result = await getPool().query('SELECT current_user AS current_user');
|
|
230
|
+
if (result.rows[0]?.current_user === role) {
|
|
231
|
+
throw new Error('cannot drop the current connection role');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validateEncoding(value) {
|
|
235
|
+
if (!value || value.includes('\0')) {
|
|
236
|
+
throw new Error('invalid encoding');
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
function validateLocale(value) {
|
|
241
|
+
if (!value || value.includes('\0')) {
|
|
242
|
+
throw new Error('invalid locale');
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
function validateRolePassword(value) {
|
|
247
|
+
if (value.includes('\0')) {
|
|
248
|
+
throw new Error('invalid role password');
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
function validateValidUntil(value) {
|
|
253
|
+
if (value === undefined || value === null || !value.trim() || value.includes('\0')) {
|
|
254
|
+
throw new Error('invalid validUntil');
|
|
255
|
+
}
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
function formatSqlString(value) {
|
|
259
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
260
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const DATABASE_PRIVILEGES = new Set(['CONNECT', 'CREATE', 'TEMPORARY', 'TEMP', 'ALL']);
|
|
2
|
+
export function validateDatabaseName(name) {
|
|
3
|
+
return validateIdentifierName(name, 'database name');
|
|
4
|
+
}
|
|
5
|
+
export function validateRoleName(name) {
|
|
6
|
+
const role = validateIdentifierName(name, 'role name');
|
|
7
|
+
if (role.toLowerCase().startsWith('pg_')) {
|
|
8
|
+
throw new Error('system role names starting with pg_ are not allowed');
|
|
9
|
+
}
|
|
10
|
+
return role;
|
|
11
|
+
}
|
|
12
|
+
export function validateExistingRoleName(name) {
|
|
13
|
+
return validateIdentifierName(name, 'role name');
|
|
14
|
+
}
|
|
15
|
+
export function parsePrivilegeList(input) {
|
|
16
|
+
const privileges = input.map((privilege) => privilege.trim().toUpperCase());
|
|
17
|
+
for (const privilege of privileges) {
|
|
18
|
+
if (!DATABASE_PRIVILEGES.has(privilege)) {
|
|
19
|
+
throw new Error(`invalid privilege: ${privilege}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (privileges.includes('ALL') && privileges.length > 1) {
|
|
23
|
+
throw new Error('ALL cannot be combined with other privileges');
|
|
24
|
+
}
|
|
25
|
+
return privileges.map((privilege) => privilege === 'TEMP' ? 'TEMPORARY' : privilege);
|
|
26
|
+
}
|
|
27
|
+
export function formatPrivilegeList(input) {
|
|
28
|
+
const privileges = parsePrivilegeList(input);
|
|
29
|
+
return privileges[0] === 'ALL' ? 'ALL PRIVILEGES' : privileges.join(', ');
|
|
30
|
+
}
|
|
31
|
+
export function parseRoleAttributes(input) {
|
|
32
|
+
const clauses = [];
|
|
33
|
+
pushBooleanClause(clauses, input.login, 'LOGIN', 'NOLOGIN');
|
|
34
|
+
pushBooleanClause(clauses, input.createdb, 'CREATEDB', 'NOCREATEDB');
|
|
35
|
+
pushBooleanClause(clauses, input.createrole, 'CREATEROLE', 'NOCREATEROLE');
|
|
36
|
+
pushBooleanClause(clauses, input.inherit, 'INHERIT', 'NOINHERIT');
|
|
37
|
+
pushBooleanClause(clauses, input.replication, 'REPLICATION', 'NOREPLICATION');
|
|
38
|
+
pushBooleanClause(clauses, input.bypassrls, 'BYPASSRLS', 'NOBYPASSRLS');
|
|
39
|
+
if (input.connectionLimit !== undefined) {
|
|
40
|
+
if (!Number.isInteger(input.connectionLimit) || input.connectionLimit < -1) {
|
|
41
|
+
throw new Error('connectionLimit must be an integer greater than or equal to -1');
|
|
42
|
+
}
|
|
43
|
+
clauses.push(`CONNECTION LIMIT ${input.connectionLimit}`);
|
|
44
|
+
}
|
|
45
|
+
if (input.validUntil !== undefined) {
|
|
46
|
+
if (input.validUntil === null) {
|
|
47
|
+
clauses.push("VALID UNTIL 'infinity'");
|
|
48
|
+
}
|
|
49
|
+
else if (!input.validUntil.trim()) {
|
|
50
|
+
throw new Error('validUntil cannot be empty');
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
clauses.push('VALID UNTIL');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return clauses;
|
|
57
|
+
}
|
|
58
|
+
export function assertNoSuperuser(input) {
|
|
59
|
+
if (input.superuser !== undefined) {
|
|
60
|
+
throw new Error('SUPERUSER role management is not supported by this MCP server');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function validateIdentifierName(name, label) {
|
|
64
|
+
if (!name || name.includes('\0')) {
|
|
65
|
+
throw new Error(`invalid ${label}`);
|
|
66
|
+
}
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
function pushBooleanClause(clauses, value, enabled, disabled) {
|
|
70
|
+
if (value === undefined) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
clauses.push(value ? enabled : disabled);
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "easy-pg-admin-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "High privilege PostgreSQL admin MCP server for database and role management",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"postgresql",
|
|
9
|
+
"postgres",
|
|
10
|
+
"database",
|
|
11
|
+
"admin",
|
|
12
|
+
"dba",
|
|
13
|
+
"claude",
|
|
14
|
+
"codex",
|
|
15
|
+
"opencode"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/chenkumi/easy-pg-admin-mcp#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/chenkumi/easy-pg-admin-mcp/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/chenkumi/easy-pg-admin-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Chenkumi",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "build/index.js",
|
|
29
|
+
"bin": {
|
|
30
|
+
"easy-pg-admin-mcp": "build/index.js"
|
|
31
|
+
},
|
|
32
|
+
"directories": {
|
|
33
|
+
"test": "test"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"build",
|
|
37
|
+
"README.md",
|
|
38
|
+
"README.zh-TW.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"dev": "tsc --watch",
|
|
43
|
+
"start": "node build/index.js",
|
|
44
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
45
|
+
"prepack": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
|
+
"pg": "^8.21.0",
|
|
50
|
+
"zod": "^4.4.3"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^25.9.3",
|
|
54
|
+
"@types/pg": "^8.20.0",
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|