feat: remote MCP server for governed Postgres access#18
Conversation
Tools-first, IAM-mapped MCP server design for exposing pgconsole's governed Postgres access to external AI agents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds a remote Model Context Protocol (MCP) HTTP endpoint to pgconsole so external agents can use the existing governed Postgres access model (IAM + SQL permission detection + audit logging) without requiring raw connection strings.
Changes:
- Introduces
POST /mcpStreamable HTTP MCP server with token → user identity mapping, permission-filtered tool listing, and audited SQL execution. - Extends SQL permission analysis to return per-statement
primaryPermissions, used to enforce “tool permission must match statement kind-permission”. - Adds MCP token config parsing and audit log fields (
source,tool), plus docs and unit tests.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/sql-permissions.test.ts | Adds unit tests for new primaryPermissions output. |
| tests/mcp.test.ts | Adds unit tests for MCP token parsing and per-token tool selection. |
| server/services/query-service.ts | Refactors connection-detail building to use shared helper. |
| server/mcp.ts | Implements MCP router, tool definitions, permission enforcement, and auditing. |
| server/lib/sql-permissions.ts | Adds primaryPermissions to the SQL analysis result. |
| server/lib/db.ts | Adds buildConnectionDetails() shared helper. |
| server/lib/config.ts | Adds [[mcp.tokens]] parsing and token→email resolver. |
| server/lib/audit.ts | Extends SQL audit events with optional MCP origin fields. |
| server/index.ts | Mounts the MCP router on the Express app. |
| package.json | Adds @modelcontextprotocol/sdk dependency. |
| pnpm-lock.yaml | Locks new MCP SDK dependency tree. |
| plans/mcp-server.md | Documents implementation plan and governance rules for MCP server. |
| docs/features/mcp-server.mdx | Adds public documentation for MCP endpoint, config, tools, and enforcement. |
| docs/features/audit-log.mdx | Documents new MCP audit fields. |
| docs/docs.json | Adds MCP Server page to docs nav. |
| docs/configuration/config.mdx | Documents [[mcp.tokens]] configuration reference. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Greptile SummaryThis PR adds a remote MCP server to pgconsole, exposing governed Postgres access to external AI agents (Claude Code, Cursor, etc.) over Streamable HTTP. Bearer tokens map to user emails, inheriting the existing IAM permission model — including per-statement SQL enforcement and audit logging — so agents operate within the same access boundaries as UI users.
Confidence Score: 3/5The core governance wiring is sound, but the multi-statement BEGIN/COMMIT path in The auth, IAM enforcement, and per-statement SQL analysis layers all look correct. The primary concern is
|
| Filename | Overview |
|---|---|
| server/mcp.ts | New 563-line MCP server: token auth, per-token tool filtering, discovery tools, and execution tools with IAM enforcement. Multi-statement BEGIN/COMMIT wrapping has a rowCount correctness issue; async close handlers are fire-and-forget; reqStr returns un-trimmed values. |
| server/lib/config.ts | Adds MCPConfig/MCPTokenConfig types, TOML parsing with duplicate-detection and email validation, and getMcpTokens/resolveMcpTokenEmail accessors. Token comparison in resolveMcpTokenEmail uses non-constant-time ===. |
| server/lib/sql-permissions.ts | Adds primaryPermissions: Permission[] field to SqlAnalysis — tracks each statement's kind-level permission separately from function-derived permissions. Clean, well-tested change. |
| server/lib/db.ts | Extracts buildConnectionDetails helper from query-service.ts into db.ts for reuse by the MCP server. Safe refactor with no behaviour change. |
| server/lib/audit.ts | Extends SQLExecuteEvent with optional source and tool fields; adds an opts parameter to auditSQL so MCP calls are tagged. Backward-compatible change. |
| server/index.ts | Mounts the new mcpRouter before connectRouter. Minimal change; correct placement. |
| tests/mcp.test.ts | New unit tests cover config parsing, token resolution, and selectToolNames filtering. No integration tests for actual tool execution (multi-statement rowCount, BEGIN/COMMIT wrapping) — the correctness of execute under multi-statement input is untested. |
| tests/sql-permissions.test.ts | Adds three focused tests for primaryPermissions: multi-statement order, function-derived permission exclusion, and empty SQL. All correct. |
| server/services/query-service.ts | Replaces the inline connection-detail construction with the new buildConnectionDetails helper. Behaviour is identical; safe mechanical refactor. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Agent as AI Agent
participant MCP as mcpRouter (POST /mcp)
participant Config as resolveMcpTokenEmail
participant IAM as getUserPermissions
participant SQLCheck as detectRequiredPermissions
participant DB as withConnection / postgres.js
participant Audit as auditSQL
Agent->>MCP: POST /mcp Authorization: Bearer token
MCP->>Config: resolveMcpTokenEmail(token)
Config-->>MCP: email (or undefined → 401)
alt tools/list
MCP->>IAM: getUserPermissions(email, connId) x all connections
IAM-->>MCP: union of permissions
MCP-->>Agent: filtered tool list
else tools/call (execution tool)
MCP->>IAM: requirePermission(user, connId, expectedPerm)
IAM-->>MCP: ok or ConnectError
MCP->>SQLCheck: detectRequiredPermissions(sql)
SQLCheck-->>MCP: primaryPermissions + permissions
MCP->>MCP: primaryPermissions check (anti-smuggling)
MCP->>IAM: requirePermissions(user, connId, fullPermSet)
IAM-->>MCP: ok or ConnectError
MCP->>DB: client.unsafe(sql)
DB-->>MCP: result
MCP->>Audit: "auditSQL source=mcp tool=name"
MCP-->>Agent: tool result (rows / rowCount)
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Agent as AI Agent
participant MCP as mcpRouter (POST /mcp)
participant Config as resolveMcpTokenEmail
participant IAM as getUserPermissions
participant SQLCheck as detectRequiredPermissions
participant DB as withConnection / postgres.js
participant Audit as auditSQL
Agent->>MCP: POST /mcp Authorization: Bearer token
MCP->>Config: resolveMcpTokenEmail(token)
Config-->>MCP: email (or undefined → 401)
alt tools/list
MCP->>IAM: getUserPermissions(email, connId) x all connections
IAM-->>MCP: union of permissions
MCP-->>Agent: filtered tool list
else tools/call (execution tool)
MCP->>IAM: requirePermission(user, connId, expectedPerm)
IAM-->>MCP: ok or ConnectError
MCP->>SQLCheck: detectRequiredPermissions(sql)
SQLCheck-->>MCP: primaryPermissions + permissions
MCP->>MCP: primaryPermissions check (anti-smuggling)
MCP->>IAM: requirePermissions(user, connId, fullPermSet)
IAM-->>MCP: ok or ConnectError
MCP->>DB: client.unsafe(sql)
DB-->>MCP: result
MCP->>Audit: "auditSQL source=mcp tool=name"
MCP-->>Agent: tool result (rows / rowCount)
end
Comments Outside Diff (4)
-
server/mcp.ts, line 1287-1296 (link)Incorrect
rowCountfor BEGIN/COMMIT-wrapped multi-statement queriesWhen
statementCount > 1 && transactionSafe,finalSqlis prefixed withBEGIN;and suffixed withCOMMIT;. postgres.js'sclient.unsafe()in simple-query mode processes each statement individually; the.countproperty on the aggregate result reflects the lastCommandCompletemessage — which isCOMMIT(count = 0). Theexecutereturn value would then always reportrowCount: 0for any multi-statement DML or DDL batch, even if 1 000 rows were modified. An agent that trusts this count may decide the operation failed and retry, causing duplicate writes.The safest fix is to not wrap in a synthetic transaction and let the caller decide, or to sum individual statement counts by running each statement separately. If wrapping is kept, the actual affected-row count must be extracted from the intermediate results before the
COMMIT, not from the final result object. -
server/lib/config.ts, line 809-811 (link)Bearer token comparison is not constant-time
Array.findwitht.token === tokenuses JavaScript's===operator, which short-circuits on the first differing byte. An attacker making many requests can distinguish "token has correct prefix" from "token is completely wrong" via response-time measurements. For a bearer-token auth gate on a database API, a constant-time comparison (e.g.crypto.timingSafeEqual) is the standard mitigation. -
server/mcp.ts, line 1440-1445 (link)Async
close()calls are fire-and-forget in theres.on('close')handlertransport.close()andserver.close()are both async but are called withoutawaitinside a synchronous event callback. If either rejects, Node.js surfaces an unhandled rejection. More importantly, ifserver.close()performs async cleanup (flushing pending handler state), it may not finish before the next request re-uses the same infrastructure. Consider wrapping in an IIFE with error logging:void (async () => { try { await transport.close(); await server.close(); } catch (e) { console.error('MCP cleanup error:', e) } })(). -
server/mcp.ts, line 1060-1066 (link)reqStrvalidates a trimmed value but returns the raw (un-trimmed) stringThe guard
!v.trim()correctly rejects blank input, butreturn vreturns the original string including any surrounding whitespace. For theconnectionparameter this meansbuildConnectionDetails(" conn-id ")will receive a padded string that won't match any configured connection ID, producing a confusing "Connection not found" error rather than a validation error. Returningv.trim()would make the behaviour consistent with the guard.
Reviews (1): Last reviewed commit: "feat: add remote MCP server for governed..." | Re-trigger Greptile
Expose pgconsole as a remote MCP server (Streamable HTTP, mounted on the existing Express app) so external AI agents can operate on the managed Postgres connections through the same governance the UI uses. Identity: Authorization: Bearer <token> resolves to a user email via [[mcp.tokens]] in pgconsole.toml; every request runs as that user, so the existing IAM permissions apply unchanged. Read-only by default — write/ddl are opt-in per the token's granted permissions. Tools (filtered per token by IAM permission): - discovery: list_connections, list_objects (paginated/filterable), describe_table - execution: explain_query (explain), query (read), write_data (write), run_ddl (ddl) Enforcement reuses detectRequiredPermissions + IAM: every statement's primary kind-permission must equal the tool's permission (blocks smuggling a DROP through query and mixed-class batches), and the full required set (including function-derived permissions like pg_terminate_backend → admin) must be a subset of the token's grants. Every call is audited with source=mcp and the tool name. Adds @modelcontextprotocol/sdk. Docs: config reference (MCP Tokens) and a feature page; audit-log gains source/tool fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ad165f9 to
279b0a9
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Replace the flat `[[mcp.tokens]]` (token → user email, inheriting that
user's *full* IAM grant) with a first-class `[[agents]]` principal, so the
two identity modes from the "identity for AI agents" delegation model are
both expressible:
- Pure agent (case 3) — a standalone service account. Not a [[users]]
entry (no UI login, no license seat). Authorized only by IAM rules whose
members include `agent:<id>`; `*`/`group:`/`user:` rules never apply, so
no agent silently inherits a broad grant. Audited as `agent:<id>`.
- Delegated agent (case 2, on_behalf_of set) — acts for a user, inheriting
getUserPermissions narrowed by optional `permissions`/`connections` caps.
Can never exceed the user and deprovisions automatically with them
("persona shadowing"). Audited as the user, tagged with the agent.
Closes the prior least-privilege gap: a token previously got the user's
entire IAM grant with no way to scope it down per agent.
Implementation:
- config: `[[agents]]` parsing + `agent:` IAM member type; getAgents/
getAgentById/getAgentByToken (replaces the MCP token getters).
- iam: getAgentPermissions for pure agents (agent:-only matching).
- mcp: a Principal class resolves effective permissions per connection and
carries the audit identity; tool filtering + per-statement enforcement
are unchanged, just principal-aware. The token folds onto the agent.
- audit: sql.execute gains an `agent` field; actor is the user (delegated)
or `agent:<id>` (pure).
- docs + plan updated; tests cover agent parsing, validation, the agent:
IAM member type, pure-agent resolution, and delegated caps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Simplification pass on the agents feature: - iam.ts: extract a private resolvePermissions(connectionId, matches) engine shared by getUserPermissions and getAgentPermissions (removes ~25 lines of duplicated auth/plan shortcuts + rule-iteration scaffolding). - Move delegated-agent resolution (getUserPermissions ∩ caps + connection cap) out of mcp.ts's Principal and into iam.ts getAgentPermissions(agent, conn), so all principal permission resolution has one home. Principal shrinks to identity + audit actor and no longer imports getUserPermissions. - config.ts: hoist the duplicated email regex (EMAIL_RE) and the permission-array parsing (`*` expansion + validation) into a shared parsePermissionList helper used by both the agent-cap and IAM-rule loops; drop the third copy of the permission whitelist. No behavior change — verified live (pure + delegated agents, permission/connection caps still enforced) and 468 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45b74aa to
95613fc
Compare
From Copilot review (PR #18). All four findings were valid: - Transaction wrap dropped the user's last statement into COMMIT when the batch lacked a trailing semicolon: `BEGIN;\n<sql>\nCOMMIT;` → `INSERT b\nCOMMIT` → syntax error → whole batch rolled back. Use `\n;\n` before COMMIT so the last statement is terminated even when it ends in a line comment (a bare `;` would be swallowed by the comment). Fixed in both the MCP path and the original query-service RPC path (same pre-existing bug). Verified live: multi-INSERT without trailing `;` now commits. - reqStr now returns the trimmed value (whitespace-padded connection/ schema/table/sql no longer fail validation yet mismatch lookups). - optStr trims and treats all-whitespace as "not provided". - Agent `connections` cap trims each id before validating/storing, matching how IAM already trims connection references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From Copilot review (PR #18): - getAgentPermissions docstring now notes the shared auth-disabled / IAM-not-licensed full-access short-circuit (the uniform plan-gating posture applied to every principal, same as getUserPermissions), so it no longer reads as if pure agents are always limited to agent: rules. - plans/mcp-server.md: reference detectRequiredPermissions by name instead of a hard-coded sql-permissions.ts line number that had already shifted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From Copilot review (PR #18), both valid: - Parse the Authorization scheme case-insensitively (RFC 6750) so clients sending `bearer <token>` aren't rejected. Verified live. - getUserPermissions resolved the user's groups eagerly even when resolvePermissions short-circuits to ALL_PERMISSIONS (auth off / IAM unlicensed) — a small regression from the resolver refactor. Compute groupIds lazily, only when a group: member is actually evaluated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From Copilot review (PR #18): - execute()/explain_query() now fail closed with "Connection not found or not accessible" when the agent has zero permissions on the connection, matching the discovery tools (was a generic "Permission denied", which was inconsistent). Added requireAccessible() and routed requireAny through it so the message has one source. - Extract dispatchTool() (the CallTool switch) and export it, then add unit tests for the security-critical enforcement paths — all throw before any DB I/O: query rejects DROP and mixed-class batches, write_data rejects a SELECT, a read-capped delegated agent is denied a function-derived admin (pg_terminate_backend), explain_query rejects non-read/multi statements, inaccessible connections fail closed, and unknown tools are rejected. (The 4th review comment — getConnectionById "unused" in query-service.ts — is a false positive; it is still used by auditExport.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From Copilot review (PR #18) — the docs claimed "SELECT/SHOW" but enforcement keyed off the `read` permission class, which is broader. - explain_query: Postgres EXPLAIN only accepts SELECT among read-class statements (verified: EXPLAIN SHOW/SET/VACUUM/BEGIN are syntax errors). The old `primary === read` check let those through to a confusing PG syntax error. Now restricted to a single SELECT (new `kinds` field on SqlAnalysis), with the message/docs updated to match. Gives a clean, fast rejection. - query: keeps the uniform permission-class enforcement (the tool *is* the `read` permission, consistent with write_data/run_ddl and the UI read path — SET/VACUUM etc. run fine and need only `read`). Docs/tool description updated to say "read-only statements (SELECT, SHOW, …)" instead of implying only SELECT/SHOW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Exposes pgconsole as a remote MCP server (Streamable HTTP, mounted on the existing Express app) so external AI agents (Claude Code, Cursor, VS Code Copilot, etc.) can operate on the Postgres connections pgconsole manages — with the same governance the UI enforces: per-connection IAM, per-statement SQL permission detection, and audit logging.
Identity: first-class agents
MCP callers authenticate with a bearer token belonging to an
[[agents]]principal — a non-human identity that is not a user (no UI login, no license seat). Two modes, matching the standard AI-agent delegation model:agent:<id>(*/group:/user:rules never apply to agents). Audited asagent:<id>.on_behalf_of) — acts for a user, inheriting that user's permissions narrowed by optionalpermissions/connectionscaps. Can never exceed the user and deprovisions automatically with them ("persona shadowing"). Audited as the user, tagged with the agent.Tools (filtered per agent)
Discovery:
list_connections,list_objects(paginated/filterable),describe_table.Execution, one per IAM permission:
explain_query(explain),query(read),write_data(write),run_ddl(ddl).Enforcement
Every execution tool runs the SQL through pgconsole's parser-based permission detection first:
DROPthroughqueryand rejects mixed-class batches.pg_terminate_backend→admin) must be a subset of the agent's effective grant.source=mcp+ tool + agent.explain_queryis restricted to a single read statement;analyzeadditionally requires everything running it would.Testing
agent:IAM member type, pure-agent permission resolution, delegated caps, per-token tool selection, andprimaryPermissions.permissions/connectionscaps enforced (tool list filtered,write_datadenied, off-cap connection denied); all enforcement rejections.🤖 Generated with Claude Code