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 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
+ ```
@@ -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
@@ -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
+ }