Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "mcpg",
"version": "0.8.3",
"description": "Production-grade PostgreSQL MCP server: query, tune, and operate Postgres — read-only by default.",
"author": {
"name": "Devopam Mittra",
"email": "devopam@gmail.com"
},
"homepage": "https://github.com/devopam/MCPg",
"repository": "https://github.com/devopam/MCPg",
"license": "MIT",
"keywords": [
"postgresql",
"postgres",
"database",
"sql",
"mcp",
"pgvector"
],
"logo": "docs/assets/logo-400.png",
"mcpServers": "packaging/cursor/mcp.json",
"variables": {
"type": "object",
"properties": {
"MCPG_DATABASE_URL": {
"type": "string",
"title": "PostgreSQL connection URL",
"description": "e.g. postgresql://user:password@localhost:5432/mydb — remote hosts need ?sslmode=require."
},
"MCPG_ACCESS_MODE": {
"type": "string",
"title": "Access mode",
"description": "read-only (default, safe) | restricted (allows write tools) | unrestricted (DBA surface; pair with MCPG_ALLOW_DDL etc.).",
"default": "read-only",
"enum": ["read-only", "restricted", "unrestricted"]
}
},
"required": ["MCPG_DATABASE_URL"]
}
}
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **MCP ServerList tutorial badge** next to the existing listing badge in the README
([mcp-serverlist.com/mcp/mcpg](https://mcp-serverlist.com/mcp/mcpg); GH #365).
- **Cursor Marketplace plugin sources.** `.cursor-plugin/plugin.json` plus
`packaging/cursor/mcp.json` package MCPg as a Cursor plugin (`uvx mcpg@X.Y.Z`, DSN
and access mode as `${VAR}` placeholders). Version is synced by
`packaging/cursor/sync_plugin_version.py` and guarded by `tests/unit/test_cursor_plugin.py`.
Submit the public repo at [cursor.com/marketplace/publish](https://cursor.com/marketplace/publish);
listing is a manual Cursor review, not a publish-workflow step.

### Security

- **`mcp` 2.0.0 → 2.2.0** (GHSA-84m7-p3x7-pcfv, CVE-2026-59951: unbounded session accumulation in the StreamableHTTP
transport). (Dependabot #356)
- **`certifi` ≥ 2026.10.19** (GHSA-6q48-6c7w-9gw2 / PYSEC-2026-4320: bundled emSign roots with compromised
intermediate CA keys). Floor declared next to `httpx`.
- **OpenTelemetry `api`/`sdk`/`exporter-otlp-proto-http` ≥ 1.45.1** (GHSA-xcq7-p3pf-p88h: unbounded tracestate
parsing in `opentelemetry-api`).
- **`virtualenv` ≥ 21.14.9** (dev extra; pre-commit's dependency) to clear CVE-2026-113716–113776
(activation scripts, symlink-following file creation).
- **`filelock` ≥ 4.2.1** (dev; CVE-2026-112940 and Windows ReadWriteLock / oplock GHSAs) and
**`msgpack` ≥ 1.2.3** (dev; CVE-2026-114613).

### Fixed

Expand All @@ -19,6 +38,15 @@ adheres to [Semantic Versioning](https://semver.org/).
audit log records their text; the heavy-diagnostics gate raises `ToolError`. Unexpected exceptions keep the SDK's
generic message. The wrapper reads the `MCPgError` from the exception's `__cause__` or, failing that, its `__context__`
(CodeRabbit on #356).
- **`run_select` keeps the validator's inner verdict** (GH #364). `Error validating query: <sql>` now includes the
cause (`Function pg_read_file is not allowed`, …) so mcp 2.x `ToolError` wrapping does not leave the client with
only the SQL text. Client-session tests cover a disallowed function and an undefined-column error. Disallowed-statement
messages no longer append `str(stmt)` AST dumps (those can render literal passwords).

### Changed

- **SQL allowlist: `jsonb_exists`, `jsonb_exists_any`, `jsonb_exists_all`.** These are the function spellings of the
already-allowed jsonb operators `?` / `?|` / `?&` (GH #364).

## [0.8.3] - 2026-10-14

Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ a read-only HF Spaces demo.
- **Version:** `src/mcpg/__init__.py` `__version__`. `pyproject.toml`
reads it dynamically via hatchling (`dynamic = ["version"]`). The MCPB
bundle pins are synced by `packaging/mcpb/sync_version.py`;
`tests/unit/test_mcpb_bundle.py` enforces the match. A release bumps
exactly one line.
`tests/unit/test_mcpb_bundle.py` enforces the match. The Cursor plugin
version in `.cursor-plugin/plugin.json` and the `uvx mcpg@X.Y.Z` pin
in `packaging/cursor/mcp.json` are synced by
`packaging/cursor/sync_plugin_version.py` (`tests/unit/test_cursor_plugin.py`).
A release bumps exactly one line, then runs the sync scripts.
- **Tool surface:** `tests/contract/tool_surface.snapshot.json` (281
tools) is the contract. `about.py`'s `tool_count = len(tool_names)`.
Two contract snapshots (surface + return shapes) — regenerate with
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ graph queries, data movement, live ops, and more.
[![AllMCPs Verified](https://allmcps.com/api/badge/devopam-mcpg)](https://allmcps.com/mcp/devopam-mcpg?verify=63d9d537-be6b-4e07-a720-77dd0c41411b)
[![MCPVault: claimed](https://mcpvault.io/badge/mcpg.svg?theme=dark)](https://mcpvault.io/servers/mcpg/health?utm_source=external_badge&utm_medium=referral&utm_campaign=mcp_health_report)
[![Listed on MCP ServerList](https://mcp-serverlist.com/api/badge/mcpg)](https://mcp-serverlist.com/mcp/mcpg)
[![MCP Tutorial](https://mcp-serverlist.com/api/badge/mcpg/tutorial)](https://mcp-serverlist.com/mcp/mcpg/tutorial)

> **Try it live:** point an MCP client — or the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) — at the hosted, read-only demo endpoint `https://devopam-mcpg-demo.hf.space/mcp`. It serves read tools against throwaway demo data; for real use, run MCPg next to your own database (see [Quick start](#quick-start)).

Expand Down
16 changes: 16 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ claude mcp add mcpg --env MCPG_DATABASE_URL=postgresql://user:pass@localhost:543
**One-click**: [Add to Cursor](https://cursor.com/install-mcp?name=mcpg&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtY3BnIl0sImVudiI6eyJNQ1BHX0RBVEFCQVNFX1VSTCI6InBvc3RncmVzcWw6Ly91c2VyOnBhc3NAbG9jYWxob3N0OjU0MzIvbXlkYiJ9fQ%3D%3D)
(then edit the placeholder connection URL under Settings → MCP).

**Marketplace plugin** (reviewed listing): this repo is a Cursor Plugin.
The manifest is `.cursor-plugin/plugin.json`; the MCP launch config is
`packaging/cursor/mcp.json` (`uvx mcpg@<release>`). Users set `MCPG_DATABASE_URL`
(and optionally `MCPG_ACCESS_MODE`) under Plugins → Configure — never
commit a real DSN. Local test: copy this repository (or at least
`.cursor-plugin/plugin.json` plus `packaging/cursor/mcp.json` under the
same relative paths) into `~/.cursor/plugins/local/mcpg` and reload the
window — the manifest's `mcpServers` path is repo-relative. Enterprise
workspaces need **Allow Local Plugin Imports** enabled first (off by default). To apply for a
listing, submit the public repository URL at
[cursor.com/marketplace/publish](https://cursor.com/marketplace/publish).
Cursor reviews the plugin and every later update; after a material
change, request a re-index (see `docs/release-process.md` §8b).
The deeplink above remains the supported install until the listing is
approved.

Manual — `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project):

```json
Expand Down
50 changes: 42 additions & 8 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,14 @@ hatchling reads it from that file, so **bump `__init__.py` and nothing
else** — the PyPI metadata, `mcpg --version`, and the MCP `serverInfo`
handshake all derive from that one line and can't drift apart.

Two other checked-in files carry their own copy of the version and
need syncing in the same commit — both are scripted, not hand-edited,
and both are guarded by a unit test that fails CI if you forget:
Three other checked-in files carry their own copy of the version and
need syncing in the same commit — all are scripted, not hand-edited,
and each is guarded by a unit test that fails CI if you forget:

```bash
python3 packaging/mcpb/sync_version.py X.Y.Z # .mcpb bundle — test_mcpb_bundle.py
python3 tools/sync_server_json_version.py X.Y.Z # MCP registry manifest — test_server_json.py
python3 packaging/mcpb/sync_version.py X.Y.Z # .mcpb bundle — test_mcpb_bundle.py
python3 tools/sync_server_json_version.py X.Y.Z # MCP registry manifest — test_server_json.py
python3 packaging/cursor/sync_plugin_version.py X.Y.Z # Cursor plugin — test_cursor_plugin.py
```

(The `publish-mcp-registry` CI job re-runs the second script against
Expand Down Expand Up @@ -394,9 +395,10 @@ when running locally.
/tmp/r.txt` reports no known CVEs.
- [ ] `__version__` in `src/mcpg/__init__.py` is bumped to the release
(this is the single source; `pyproject.toml` derives it dynamically).
- [ ] `.mcpb` bundle + `server.json` re-synced to the release version
(§4.2's two `sync_*.py` commands — `tests/unit/test_mcpb_bundle.py`
and `tests/unit/test_server_json.py` fail loudly if skipped, so
- [ ] `.mcpb` bundle + `server.json` + Cursor plugin re-synced to the
release version (§4.2's three `sync_*.py` commands —
`tests/unit/test_mcpb_bundle.py`, `tests/unit/test_server_json.py`
and `tests/unit/test_cursor_plugin.py` fail loudly if skipped, so
this is covered by the unit-suite gate above, but run it explicitly
rather than relying on CI to notice).
- [ ] `CHANGELOG.md` rolls `[Unreleased]` into the new dated section,
Expand Down Expand Up @@ -727,6 +729,7 @@ every machine-updatable surface.
| **Smithery** (`devopam/mcpg`) | `publish-smithery` job (opt-in) | Automatic *once enabled* — see below |
| **Hosted demo** (HF Space `devopam/mcpg-demo`) | `publish-hf-space` job (opt-in) | Automatic *once enabled* — see below |
| **Claude connectors directory** | Review portal, no API | **Manual** — see below |
| **Cursor Marketplace** | Reviewed plugin, no publish API | **Manual** — see below |

### Copy that lives in `server.json` (drives the registry-fed listings)

Expand Down Expand Up @@ -836,6 +839,37 @@ policy's explicit requirements:
files — only the Postgres database and (for the opt-in
`translate_nl_to_sql` tool) the NL→SQL provider the user configured.

### The reviewed submission: Cursor Marketplace

The [Cursor Marketplace](https://cursor.com/marketplace) is a **reviewed**
plugin catalog with no publish API. Application form:
[cursor.com/marketplace/publish](https://cursor.com/marketplace/publish)
(submit the public GitHub repo URL). Publisher terms:
[cursor.com/marketplace-publisher-terms](https://cursor.com/marketplace-publisher-terms).
Plugins must be open source and free; Cursor reviews identity plus code
on first listing and on every later update.

Sources (do not hand-edit the version — use the sync script in §4.2):

- `.cursor-plugin/plugin.json` — Cursor Plugin manifest, including the
`variables` schema for `MCPG_DATABASE_URL` / `MCPG_ACCESS_MODE`.
- `packaging/cursor/mcp.json` — `uvx mcpg@X.Y.Z` launch config with `${VAR}`
placeholders only (no DSNs). The pin is rewritten by the same sync script.

Until Cursor approves the listing, the supported Cursor install remains
the [Add to Cursor](https://cursor.com/install-mcp) deeplink in
`docs/integrations.md`. After a listing exists, request a re-index when
the plugin manifest, MCP launch config, or user-visible description
changes. A version bump rewrites those files, so every release needs a
re-index once the plugin is listed.

Local test before submitting: copy this repository into
`~/.cursor/plugins/local/mcpg` so `.cursor-plugin/plugin.json` and
`packaging/cursor/mcp.json` keep their repo-relative layout, reload the
window, and confirm it appears under Customize. Enterprise workspaces skip
that folder unless **Allow Local Plugin Imports** is on (Dashboard →
Settings → Security & Identity → Marketplace and Plugins; off by default).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
---

## 9. Recurring chores
Expand Down
12 changes: 12 additions & 0 deletions packaging/cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"mcpg": {
"command": "uvx",
"args": ["mcpg@0.8.3"],
"env": {
"MCPG_DATABASE_URL": "${MCPG_DATABASE_URL}",
"MCPG_ACCESS_MODE": "${MCPG_ACCESS_MODE}"
}
}
}
}
51 changes: 51 additions & 0 deletions packaging/cursor/sync_plugin_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Sync the Cursor plugin version pins to a release version.

Same role ``packaging/mcpb/sync_version.py`` plays for the .mcpb bundle:
the checked-in ``.cursor-plugin/plugin.json`` and ``packaging/cursor/mcp.json``
carry a copy of ``mcpg.__version__``, so a release bump runs this script
(and ``tests/unit/test_cursor_plugin.py`` fails CI if it is skipped).

Named distinctly from the MCPB ``sync_version`` module so pytest
collection cannot bind the wrong ``sync()`` through ``sys.modules``.

Usage::

python3 packaging/cursor/sync_plugin_version.py X.Y.Z
"""

from __future__ import annotations

import json
import sys
from pathlib import Path


def plugin_json_path(repo_root: Path) -> Path:
"""Return the Cursor plugin manifest path under ``repo_root``."""
return repo_root / ".cursor-plugin" / "plugin.json"


def mcp_json_path(repo_root: Path) -> Path:
"""Return the Cursor MCP launch-config path under ``repo_root``."""
return repo_root / "packaging" / "cursor" / "mcp.json"


def sync(repo_root: Path, version: str) -> None:
"""Rewrite the plugin manifest version and the ``uvx mcpg@`` pin."""
manifest_path = plugin_json_path(repo_root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["version"] = version
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")

mcp_path = mcp_json_path(repo_root)
config = json.loads(mcp_path.read_text(encoding="utf-8"))
config["mcpServers"]["mcpg"]["args"] = [f"mcpg@{version}"]
mcp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")


if __name__ == "__main__":
if len(sys.argv) != 2 or not sys.argv[1]:
raise SystemExit("usage: sync_plugin_version.py <version>")
root = Path(__file__).resolve().parents[2]
sync(root, sys.argv[1])
print(f"Cursor plugin pinned to {sys.argv[1]}")
25 changes: 19 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ dependencies = [
# httpx ships transitively via mcp; declare it explicitly so the
# NL→SQL providers in mcpg.nl2sql have a guaranteed surface.
"httpx>=0.27",
# httpx pulls certifi; floor raised to 2026.10.19 to drop the
# bundled emSign roots with compromised intermediate CA keys
# (GHSA-6q48-6c7w-9gw2 / PYSEC-2026-4320).
"certifi>=2026.10.19",
# mcp-types ships transitively via mcp; declare it explicitly so
# mcpg.dynamic_session_intent has a guaranteed surface.
"mcp-types>=2.0.0",
Expand All @@ -77,9 +81,9 @@ dependencies = [
# collector over HTTP/protobuf.
[project.optional-dependencies]
otel = [
"opentelemetry-api>=1.27",
"opentelemetry-sdk>=1.27",
"opentelemetry-exporter-otlp-proto-http>=1.27",
"opentelemetry-api>=1.45.1",
"opentelemetry-sdk>=1.45.1",
"opentelemetry-exporter-otlp-proto-http>=1.45.1",
]
# Cloud secrets backends — selected by ``MCPG_SECRETS_BACKEND=vault|aws|gcp``.
# Each is its own extra so a deployment doesn't pay for every cloud SDK
Expand Down Expand Up @@ -144,6 +148,15 @@ dev = [
"ruff>=0.14.0",
"mypy>=1.15.0",
"pre-commit>=4.0.0",
# pre-commit pulls virtualenv; floor raised to 21.14.9 to clear
# the 2026 activation-script / symlink CVEs (CVE-2026-113716 … 113776).
"virtualenv>=21.14.9",
# virtualenv / python-discovery pull filelock; 4.2.1 clears
# CVE-2026-112940 and the Windows ReadWriteLock / oplock GHSAs.
"filelock>=4.2.1",
# cachecontrol (pip-audit) pulls msgpack; 1.2.3 clears
# CVE-2026-114613 (unbounded leak on strict_map_key failure).
"msgpack>=1.2.3",
"bandit>=1.7.8",
"pip-audit>=2.7",
# PyPI packaging toolchain — build the sdist/wheel, lint the
Expand All @@ -157,9 +170,9 @@ dev = [
# otel-tracing unit tests can use the InMemorySpanExporter and so
# `mcpg[otel]` round-trips through CI. Production deployments
# opt in via the ``mcpg[otel]`` extra rather than dev install.
"opentelemetry-api>=1.27",
"opentelemetry-sdk>=1.27",
"opentelemetry-exporter-otlp-proto-http>=1.27",
"opentelemetry-api>=1.45.1",
"opentelemetry-sdk>=1.45.1",
"opentelemetry-exporter-otlp-proto-http>=1.45.1",
"pip-licenses>=5.5.5",
"pytest-mock>=3.15.1",
"pytest-randomly>=4.1.0",
Expand Down
19 changes: 17 additions & 2 deletions src/mcpg/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ def _friendly_validation_error(tool_name: str, error: ValidationError) -> str:
return f"{tool_name}: {details}"


def _anticipated_tool_failure(exc: BaseException) -> BaseException | None:
"""Return the client-facing ``MCPgError``, or ``None`` for a true crash.

Since mcp 2.2.0 the SDK replaces any non-``ToolError`` message with
``Error executing tool <name>``. ``MCPgError`` (``QueryError``,
``ConfigError``, …) is the anticipated-failure type; ``ValueError`` from
the SQL validator is wrapped as ``QueryError`` before it gets here.
``ValidationError`` is rewritten separately above.
"""
for candidate in (exc, exc.__cause__, exc.__context__):
if isinstance(candidate, MCPgError):
return candidate
return None


SERVER_NAME = "mcpg"
SERVER_INSTRUCTIONS = (
"MCPg: a PostgreSQL MCP server for inspecting, querying, operating, and tuning a Postgres database."
Expand Down Expand Up @@ -211,8 +226,8 @@ async def call_tool(
# MCPgError messages are written for the client ("provider
# 'openai' is not configured ...; set OPENAI_API_KEY"), so they
# go back as a ToolError, and the audit log gets the real text.
domain_error = exc if isinstance(exc, MCPgError) else (exc.__cause__ or exc.__context__)
if isinstance(domain_error, MCPgError):
domain_error = _anticipated_tool_failure(exc)
if domain_error is not None:
tool_exc = ToolError(str(domain_error))
audit.record(audit.AuditEvent(tool=name, arguments=arguments, status="error", error=str(tool_exc)))
metrics.record_call(name, "error", duration, bucket=bucket)
Expand Down
4 changes: 4 additions & 0 deletions src/mcpg/sql/allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,10 @@
"jsonb_extract_path_text",
"json_object_keys",
"jsonb_object_keys",
# Function spellings of the jsonb existence operators ``?`` / ``?|`` / ``?&``.
"jsonb_exists",
"jsonb_exists_any",
"jsonb_exists_all",
"json_array_elements",
"jsonb_array_elements",
"json_array_elements_text",
Expand Down
11 changes: 8 additions & 3 deletions src/mcpg/sql/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,22 @@ def _validate(self, query: str) -> None:
if not isinstance(stmt.stmt, tuple(self.ALLOWED_STMT_TYPES)):
raise ValueError(
"Only SELECT, ANALYZE, VACUUM, EXPLAIN, SHOW and other read-only "
"statements are allowed. Received raw statement: " + str(stmt.stmt)
"statements are allowed."
)
else:
if not isinstance(stmt, tuple(self.ALLOWED_STMT_TYPES)):
raise ValueError(
"Only SELECT, ANALYZE, VACUUM, EXPLAIN, SHOW and other read-only "
"statements are allowed. Received: " + str(stmt)
"statements are allowed."
)
self._validate_node(stmt)
except Exception as e:
raise ValueError(f"Error validating query: {query}") from e
# Keep the original query AND the inner verdict (e.g.
# ``Function jsonb_exists is not allowed``). mcp 2.x only
# forwards ToolError / QueryError text to the client, so
# burying the cause here would leave agents with just
# ``Error validating query: <sql>`` (GH #364).
raise ValueError(f"Error validating query: {query}: {e}") from e
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
except pglast.parser.ParseError as e:
raise ValueError("Failed to parse SQL statement") from e

Expand Down
Loading
Loading