Skip to content

Commit 9c119a5

Browse files
committed
feat: add enable_extension tool
Phase 8, Task 8.3: mcpg/extensions.py — enable_extension runs CREATE EXTENSION IF NOT EXISTS for names on a curated allowlist. Since an extension name is a SQL identifier and cannot be parameterised, the allowlist is the injection guard; unknown names are rejected before any SQL is built. Exposed as a DDL-gated MCP tool (unrestricted + MCPG_ALLOW_DDL). 265 tests, 100% coverage of authored code. https://claude.ai/code/session_0122yLZLJ8t4W43sdN6BmTZc
1 parent 43a683a commit 9c119a5

7 files changed

Lines changed: 184 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/).
1010

1111
- `list_available_extensions` tool — lists every extension available to the
1212
database with its installed-vs-available status.
13+
- `enable_extension` tool — enables an allowlisted PostgreSQL extension;
14+
requires unrestricted mode and `MCPG_ALLOW_DDL`.
1315

1416
### Changed
1517

docs/PROGRESS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
## Next action
1414

15-
> Phase 8, Task 8.3 — TDD an `enable_extension` tool: `CREATE EXTENSION` gated
16-
> to unrestricted mode + `MCPG_ALLOW_DDL`, restricted to a known-extension
17-
> allowlist; extension name validated (no SQL injection).
15+
> Phase 8, Task 8.4 — TDD index-type-aware `recommend_indexes`: suggest GIN
16+
> for `jsonb`/array columns and trigram GIN for text columns frequently
17+
> filtered by `LIKE` (using `describe_table` column types).
1818
1919
## Phase 0 — Spike & foundation ✅ COMPLETE
2020

@@ -112,7 +112,7 @@
112112

113113
- [x] 8.1 `list_indexes` reports the index access method (btree/gin/gist/...)
114114
- [x] 8.2 `list_available_extensions` tool — installed vs available
115-
- [ ] 8.3 `enable_extension` tool — gated DDL, known-extension allowlist
115+
- [x] 8.3 `enable_extension` tool — gated DDL, known-extension allowlist
116116
- [ ] 8.4 Index-type-aware `recommend_indexes` — GIN for `jsonb`/arrays,
117117
trigram GIN for `LIKE`, BRIN for append-only (HNSW/IVFFlat in Phase 10)
118118

@@ -275,3 +275,7 @@
275275
- 2026-05-21 — Task 8.2: added `list_available_extensions` (`pg_available_extensions`)
276276
reporting every available extension with installed-vs-not status, exposed
277277
as an MCP tool. 258 tests, 100% coverage.
278+
- 2026-05-21 — Task 8.3: added `mcpg/extensions.py``enable_extension`
279+
runs `CREATE EXTENSION IF NOT EXISTS` for names on a curated allowlist
280+
(the injection guard, since the name is an identifier). Exposed as a
281+
DDL-gated MCP tool. 265 tests, 100% coverage.

docs/tools.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ Executes a single DDL statement (`CREATE`/`ALTER`/`DROP` and related).
7878
Requires `unrestricted` mode **and** `MCPG_ALLOW_DDL=true`. Parameter: `sql`
7979
(string).
8080

81+
### `enable_extension`
82+
Enables a known PostgreSQL extension (`CREATE EXTENSION IF NOT EXISTS`). Only
83+
allowlisted extensions (`pg_trgm`, `vector`, `citext`, `postgis`, ...) may be
84+
enabled. Requires `unrestricted` mode **and** `MCPG_ALLOW_DDL=true`.
85+
Parameter: `name` (string).
86+
8187
## Errors
8288

8389
Tools reject unsafe or invalid input before it reaches the database. Rejected

src/mcpg/extensions.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""PostgreSQL extension management.
2+
3+
``enable_extension`` runs ``CREATE EXTENSION``. Because an extension name is
4+
a SQL identifier (not a bindable value), it cannot be parameterised — so only
5+
names on a curated allowlist may be enabled. That allowlist is the injection
6+
guard.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
13+
from mcpg._vendor.sql import SqlDriver
14+
15+
# Extensions MCPg will enable on request: well-known, widely-used extensions.
16+
# CREATE EXTENSION takes an identifier, so this allowlist guards against
17+
# injection — names outside it are rejected before any SQL is built.
18+
ENABLEABLE_EXTENSIONS = frozenset(
19+
{
20+
"pg_trgm",
21+
"vector",
22+
"unaccent",
23+
"fuzzystrmatch",
24+
"citext",
25+
"hstore",
26+
"pgcrypto",
27+
"uuid-ossp",
28+
"ltree",
29+
"btree_gin",
30+
"btree_gist",
31+
"pg_stat_statements",
32+
"pgstattuple",
33+
"tablefunc",
34+
"intarray",
35+
"cube",
36+
"earthdistance",
37+
"postgis",
38+
}
39+
)
40+
41+
42+
class ExtensionError(Exception):
43+
"""Raised when an extension cannot be enabled."""
44+
45+
46+
@dataclass(frozen=True, slots=True)
47+
class EnableExtensionResult:
48+
"""The outcome of an enable_extension call."""
49+
50+
name: str
51+
enabled: bool
52+
53+
54+
async def enable_extension(driver: SqlDriver, name: str) -> EnableExtensionResult:
55+
"""Enable a known PostgreSQL extension (``CREATE EXTENSION IF NOT EXISTS``).
56+
57+
Only extensions on :data:`ENABLEABLE_EXTENSIONS` may be enabled. The call
58+
is idempotent.
59+
60+
Raises:
61+
ExtensionError: If the extension is not on the allowlist, or creation
62+
fails (e.g. the extension's files are not present on the server).
63+
"""
64+
if name not in ENABLEABLE_EXTENSIONS:
65+
raise ExtensionError(f"extension {name!r} is not on the allowlist of enableable extensions")
66+
try:
67+
await driver.execute_query(f'CREATE EXTENSION IF NOT EXISTS "{name}"', force_readonly=False)
68+
except Exception as exc:
69+
raise ExtensionError(str(exc)) from exc
70+
return EnableExtensionResult(name=name, enabled=True)

src/mcpg/tools.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from mcp.server.fastmcp import Context, FastMCP
1414
from mcp.server.session import ServerSession
1515

16-
from mcpg import __version__, health, indexing, introspection, query, workload, write
16+
from mcpg import __version__, extensions, health, indexing, introspection, query, workload, write
1717
from mcpg._vendor.sql import SqlDriver
1818
from mcpg.config import Settings
1919
from mcpg.context import AppContext
@@ -191,6 +191,18 @@ async def run_ddl(ctx: _Ctx, sql: str) -> dict[str, Any]:
191191
result = await write.run_ddl(_driver(ctx), sql)
192192
return asdict(result)
193193

194+
@server.tool(
195+
name="enable_extension",
196+
description=(
197+
"Enable a known PostgreSQL extension (CREATE EXTENSION IF NOT "
198+
"EXISTS). Only allowlisted extensions may be enabled. Available "
199+
"only in unrestricted access mode with MCPG_ALLOW_DDL enabled."
200+
),
201+
)
202+
async def enable_extension(ctx: _Ctx, name: str) -> dict[str, Any]:
203+
result = await extensions.enable_extension(_driver(ctx), name)
204+
return asdict(result)
205+
194206

195207
def register_tools(server: FastMCP[AppContext], settings: Settings) -> None:
196208
"""Register the MCP tools permitted by the configured access mode.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Integration tests for extension management against a live PostgreSQL."""
2+
3+
import pytest
4+
5+
from mcpg.database import Database
6+
from mcpg.extensions import ExtensionError, enable_extension
7+
from mcpg.introspection import list_available_extensions, list_extensions
8+
9+
10+
async def test_enable_extension_installs_pg_trgm(connected_database: Database) -> None:
11+
driver = connected_database.driver()
12+
available = {extension.name for extension in await list_available_extensions(driver)}
13+
if "pg_trgm" not in available:
14+
pytest.skip("pg_trgm is not available on this PostgreSQL server")
15+
16+
await enable_extension(driver, "pg_trgm")
17+
18+
installed = {extension.name for extension in await list_extensions(driver)}
19+
assert "pg_trgm" in installed
20+
21+
22+
async def test_enable_extension_rejects_an_unknown_extension(connected_database: Database) -> None:
23+
with pytest.raises(ExtensionError, match="allowlist"):
24+
await enable_extension(connected_database.driver(), "definitely_not_a_real_extension")

tests/unit/test_extensions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tests for extension management and the enable_extension tool."""
2+
3+
import pytest
4+
from _fakes import FakeDatabase, FakeDriver
5+
from mcp.shared.memory import create_connected_server_and_client_session
6+
7+
from mcpg.config import load_settings
8+
from mcpg.extensions import EnableExtensionResult, ExtensionError, enable_extension
9+
from mcpg.server import create_server
10+
11+
_UNRESTRICTED_DDL = load_settings(
12+
{
13+
"MCPG_DATABASE_URL": "postgresql://u:p@localhost/db",
14+
"MCPG_ACCESS_MODE": "unrestricted",
15+
"MCPG_ALLOW_DDL": "true",
16+
}
17+
)
18+
_READ_ONLY = load_settings({"MCPG_DATABASE_URL": "postgresql://u:p@localhost/db"})
19+
20+
21+
async def test_enable_extension_runs_create_extension_for_an_allowlisted_name() -> None:
22+
driver = FakeDriver()
23+
24+
result = await enable_extension(driver, "pg_trgm")
25+
26+
assert result == EnableExtensionResult(name="pg_trgm", enabled=True)
27+
query, _params, force_readonly = driver.calls[0]
28+
assert query == 'CREATE EXTENSION IF NOT EXISTS "pg_trgm"'
29+
assert force_readonly is False
30+
31+
32+
async def test_enable_extension_rejects_a_name_not_on_the_allowlist() -> None:
33+
driver = FakeDriver()
34+
35+
with pytest.raises(ExtensionError, match="allowlist"):
36+
await enable_extension(driver, "evil; DROP DATABASE postgres")
37+
# Rejection happens before any SQL is built.
38+
assert driver.calls == []
39+
40+
41+
async def test_enable_extension_wraps_execution_failures() -> None:
42+
with pytest.raises(ExtensionError, match="execution failed"):
43+
await enable_extension(FakeDriver(fail=True), "pg_trgm")
44+
45+
46+
async def test_enable_extension_tool_is_callable_when_ddl_is_allowed() -> None:
47+
server = create_server(_UNRESTRICTED_DDL, database=FakeDatabase(FakeDriver())) # type: ignore[arg-type]
48+
49+
async with create_connected_server_and_client_session(server) as client:
50+
result = await client.call_tool("enable_extension", {"name": "pg_trgm"})
51+
52+
assert result.isError is False
53+
54+
55+
async def test_enable_extension_tool_is_absent_without_ddl_opt_in() -> None:
56+
server = create_server(_READ_ONLY, database=FakeDatabase(FakeDriver())) # type: ignore[arg-type]
57+
58+
async with create_connected_server_and_client_session(server) as client:
59+
names = {tool.name for tool in (await client.list_tools()).tools}
60+
61+
assert "enable_extension" not in names

0 commit comments

Comments
 (0)