Skip to content

Commit 03c43b5

Browse files
devopamclaude
andauthored
Publish MCP ToolAnnotations on every tool + cut v0.6.7 (#221)
* Publish MCP ToolAnnotations on every tool, derived from the access gates All 252 tools now carry readOnlyHint (185 read-only / 67 write-capable) and openWorldHint on the wire. The hints are derived mechanically in register_tools from the same READ/WRITE/DDL/SHELL/LISTEN capability gates that already enforce access — a snapshot of registered names is taken at the READ-gate boundary and _apply_tool_annotations stamps every tool afterwards — so a tool moved between gates can never ship a stale hint. openWorldHint is false everywhere except translate_nl_to_sql (the one tool that calls an external LLM API). destructiveHint is left unset for write-capable tools: the MCP default (true) is the cautious reading. Also adds wire-visible descriptions to all 7 prompt arguments across the 3 MCP prompts (via Annotated[str, Field(description=...)] — the SDK lifts them into PromptArgument.description). New contract suite pins all of it, including the exhaustive self-consistency check: the hinted read-only set must equal the tool surface actually reachable in read-only access mode. * Cut v0.6.7 Fold [Unreleased] into [0.6.7] - 2026-07-03 (tool annotations, prompt argument descriptions, the mcpg --demo dataset, Python 3.14 runtime, pydantic warning suppression, TestPyPI race fix — the last two were merged without changelog entries, added here). Bump pyproject.toml, mcpg.__version__, and uv.lock to 0.6.7; add docs/release-notes-0.6.7.md and link it from the docs index. * Address review: merge-don't-replace annotations, tighter tests, typo - _apply_tool_annotations now merges instead of overwriting: explicitly registered annotation fields always win; the derived readOnlyHint / openWorldHint only fill fields still None (Gemini). No call site passes annotations= today, but a future per-tool override must not be silently clobbered. New contract test pins the preservation. - Anchor spot-checks assert membership first, so a missing anchor tool reads as a clear failure instead of a KeyError (Sourcery). - Wire-level test also asserts openWorldHint propagation for translate_nl_to_sql and a DB-only tool (Sourcery). - Release-notes grammar fix: "warnings ... are suppressed" (Sourcery). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ff68fe commit 03c43b5

9 files changed

Lines changed: 351 additions & 9 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
### Changed
12+
13+
### Fixed
14+
15+
## [0.6.7] - 2026-07-03
16+
17+
### Added
18+
19+
- **MCP `ToolAnnotations` on every tool** — all 252 tools now publish
20+
`readOnlyHint` (185 read-only / 67 write-capable) and `openWorldHint`
21+
on the wire, derived mechanically from the same READ / WRITE / DDL /
22+
SHELL / LISTEN capability gates that already enforce access, so a
23+
moved tool can never ship a stale hint. Clients like Claude Desktop
24+
use these to decide which calls to auto-approve — MCPg's safety
25+
classification is now visible to them, not just internal.
26+
`openWorldHint` is `false` everywhere except `translate_nl_to_sql`
27+
(the one tool that calls an external LLM API). `destructiveHint` is
28+
deliberately left unset for write-capable tools: the MCP default
29+
(true) is the cautious reading. A contract test pins the derivation
30+
exhaustively: the hinted read-only set must equal the surface
31+
actually reachable in read-only access mode.
32+
- **Prompt argument descriptions** — all 7 arguments across the 3 MCP
33+
prompts (`diagnose_slow_query`, `bisect_slow_migration`,
34+
`review_rls_policy`) now carry wire-visible descriptions.
1135
- **`mcpg --demo` / `mcpg --demo-drop`** (roadmap **17.1**) — one-shot CLI
1236
commands that seed / remove a small, deterministic, curated e-commerce
1337
demo dataset (400 customers, 120 products, 3,000 orders, 900 reviews)
@@ -36,6 +60,22 @@ adheres to [Semantic Versioning](https://semver.org/).
3660

3761
### Fixed
3862

63+
- **Startup warning noise eliminated.** Running MCPg printed a wall of
64+
benign pydantic `Field name "schema" ... shadows an attribute in
65+
parent "BaseModel"` warnings on the first `tools/list` call (the
66+
`mcp` SDK builds a pydantic model from each tool-return dataclass,
67+
and `schema` — the natural name for "which Postgres schema" — collides
68+
with a deprecated pydantic v1 shim). The specific message is now
69+
suppressed at import time; renaming the field would have broken every
70+
affected tool's JSON output shape. A contract test builds the full
71+
tool surface and asserts zero shadow warnings leak.
72+
- **TestPyPI smoke-test race in the publish pipeline.** The
73+
wait-for-indexing step polled TestPyPI's JSON API, which can report a
74+
release "ready" before the PEP 503 simple index (what `pip install`
75+
actually reads) catches up — observed on the v0.6.6 release, which
76+
needed a manual re-run. The step now polls the simple index directly
77+
and retry-wraps the install.
78+
3979
## [0.6.6] - 2026-07-01
4080

4181
### Added

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ graph queries, data movement, live ops, and more.
3939
`[A-Za-z_][A-Za-z0-9_]*` regex — a design constraint that means
4040
user input never reaches the database through string concatenation.
4141
Capabilities like DDL, shell, and `LISTEN/NOTIFY` are off until you
42-
opt in.
42+
opt in. Every tool publishes MCP `ToolAnnotations` (`readOnlyHint`,
43+
`openWorldHint`) derived from those same gates, so clients can
44+
auto-approve reads and gate writes without guessing.
4345
- **One server, broad surface.** Application data access (queries, search,
4446
cursors, NL→SQL) *and* DBA-grade operations (health checks, index tuning,
4547
EXPLAIN analysis, locks, vacuum, dumps, replicas, migrations) in a

‎docs/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ them" — pick the section that matches what you're trying to do.
5151
retrospectives.
5252
- [**Progress log**](PROGRESS.md) — chronological build log.
5353
- Release notes:
54+
[v0.6.7](release-notes-0.6.7.md) ·
5455
[v0.6.6](release-notes-0.6.6.md) ·
5556
[v0.6.5](release-notes-0.6.5.md) ·
5657
[v0.6.4](release-notes-0.6.4.md) ·

‎docs/release-notes-0.6.7.md‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# MCPg v0.6.7 — release notes
2+
3+
**Released:** 2026-07-03
4+
**Tool surface:** **252** tools across 19 capability buckets
5+
**Tests:** 2741 pass (PG 14 / 15 / 16 / 17 / 18 / 19 / WarehousePG)
6+
**Runtime:** Python 3.14
7+
8+
This is a **patch-level bump (0.6.6 → 0.6.7)** focused on the client
9+
experience: the safety classification now reaches the wire, a curated
10+
demo dataset gives new users a rich first five minutes, and the runtime
11+
moves to Python 3.14. No tool signatures changed; everything is
12+
backward-compatible.
13+
14+
## Headline: MCPg's safety model is now on the wire
15+
16+
Every one of the 252 tools now publishes **MCP `ToolAnnotations`**:
17+
18+
- **`readOnlyHint`** — `true` for the 185 read tools, `false` for the
19+
67 write-capable ones. Derived mechanically from the same
20+
READ / WRITE / DDL / SHELL / LISTEN capability gates that enforce
21+
access, so a tool moved between gates can never ship a stale hint.
22+
A contract test pins the derivation exhaustively: the hinted
23+
read-only set must equal the tool surface actually reachable in
24+
read-only access mode.
25+
- **`openWorldHint`** — `false` everywhere except `translate_nl_to_sql`
26+
(the one tool that calls an external LLM API); everything else talks
27+
only to the connected PostgreSQL server.
28+
- `destructiveHint` is deliberately left unset for write-capable tools:
29+
the MCP default (true) is the cautious reading.
30+
31+
Clients like Claude Desktop use these hints to decide which calls to
32+
auto-approve — until now MCPg's "safe by default" story was internal;
33+
now it's visible to every MCP client. The 3 MCP prompts' arguments all
34+
carry wire-visible descriptions too.
35+
36+
## Try MCPg in two minutes: the demo dataset
37+
38+
```bash
39+
MCPG_DATABASE_URL=postgresql://... mcpg --demo # seed
40+
MCPG_DATABASE_URL=postgresql://... mcpg --demo-drop # remove
41+
```
42+
43+
`mcpg --demo` seeds a small, deterministic, **curated** e-commerce
44+
dataset (400 customers, 120 products, 3,000 orders, 900 reviews) into
45+
an `mcpg_demo` schema — engineered so the pivotal tools all have
46+
something real to find on first contact: an un-indexed foreign key for
47+
`analyze_query_plan` / `recommend_indexes`, PII-shaped columns for
48+
`find_sensitive_columns`, a camelCase naming violation, searchable
49+
review prose, and an optional pgvector embedding column when the
50+
extension is installed. The seed is a single transaction, re-seeding
51+
refuses rather than clobbers, and `--demo-drop` only removes a schema
52+
carrying the MCPg ownership marker.
53+
54+
The companion [guided tour](demo.md) is *captured, not written* — every
55+
output block is a real tool run against the seeded dataset, and
56+
integration tests pin the planted findings so the walkthrough can't
57+
silently rot.
58+
59+
## Also in this release
60+
61+
- **Runtime moves to Python 3.14** — Docker image, the full CI matrix,
62+
and the publish pipeline. `requires-python` stays `>=3.12`;
63+
installing on 3.12/3.13 is unchanged.
64+
- **Startup warning noise eliminated** — the benign pydantic
65+
"Field name `schema` shadows an attribute in parent BaseModel"
66+
warnings on first `tools/list` are suppressed (narrowly, by message
67+
pattern), with a contract test guarding the full tool surface.
68+
- **Release-pipeline hardening** — the TestPyPI smoke test now polls
69+
the PEP 503 simple index (what `pip install` actually reads) instead
70+
of the JSON API, eliminating the propagation race that required a
71+
manual re-run during the v0.6.6 release.
72+
73+
## Upgrade
74+
75+
```bash
76+
pip install --upgrade mcpg
77+
docker pull ghcr.io/devopam/mcpg:0.6.7 # or :latest
78+
```
79+
80+
No configuration changes required.
81+
82+
## Full changelog
83+
84+
See [`../CHANGELOG.md`](../CHANGELOG.md) `[0.6.7]` for the complete
85+
itemised list.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcpg"
3-
version = "0.6.6"
3+
version = "0.6.7"
44
description = "A production-grade PostgreSQL Model Context Protocol (MCP) server."
55
readme = "README.md"
66
requires-python = ">=3.12"

‎src/mcpg/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import warnings
44

5-
__version__ = "0.6.6"
5+
__version__ = "0.6.7"
66

77
# ``schema`` is the natural field name for "which Postgres schema" across
88
# ~180 tool-return dataclasses. The ``mcp`` SDK dynamically builds a

‎src/mcpg/tools.py‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from mcp.server.fastmcp import Context, FastMCP
1515
from mcp.server.session import ServerSession
16+
from mcp.types import ToolAnnotations
1617
from pydantic import Field
1718

1819
from mcpg import (
@@ -6401,7 +6402,9 @@ def _register_prompts(server: FastMCP[AppContext]) -> None:
64016402
"structured reporting checklist at the end."
64026403
),
64036404
)
6404-
def diagnose_slow_query_prompt(sql: str) -> str:
6405+
def diagnose_slow_query_prompt(
6406+
sql: Annotated[str, Field(description="The slow SQL statement to investigate, verbatim.")],
6407+
) -> str:
64056408
return mcpg_prompts._build_diagnose_slow_query(sql)
64066409

64076410
# ------------------------------------------------------------------
@@ -6419,9 +6422,15 @@ def diagnose_slow_query_prompt(sql: str) -> str:
64196422
),
64206423
)
64216424
def bisect_slow_migration_prompt(
6422-
migration_id: str,
6423-
baseline_schema: str,
6424-
current_schema: str,
6425+
migration_id: Annotated[
6426+
str, Field(description="Identifier of the suspect migration (as tracked by the migration tool in use).")
6427+
],
6428+
baseline_schema: Annotated[
6429+
str, Field(description="Schema name representing the pre-migration (known-good) state.")
6430+
],
6431+
current_schema: Annotated[
6432+
str, Field(description="Schema name representing the post-migration (regressed) state.")
6433+
],
64256434
) -> str:
64266435
return mcpg_prompts._build_bisect_slow_migration(migration_id, baseline_schema, current_schema)
64276436

@@ -6440,7 +6449,10 @@ def bisect_slow_migration_prompt(
64406449
"not apply them."
64416450
),
64426451
)
6443-
def review_rls_policy_prompt(schema: str, table: str) -> str:
6452+
def review_rls_policy_prompt(
6453+
schema: Annotated[str, Field(description="Schema containing the table to audit.")],
6454+
table: Annotated[str, Field(description="Table whose row-level-security coverage should be reviewed.")],
6455+
) -> str:
64446456
return mcpg_prompts._build_review_rls_policy(schema, table)
64456457

64466458

@@ -6709,6 +6721,52 @@ async def drop_subscription(
67096721
return result
67106722

67116723

6724+
# Tools that reach outside the connected PostgreSQL server. Everything
6725+
# else in MCPg talks to a closed domain (the database), so it gets
6726+
# ``openWorldHint=False``; these are the exceptions (external LLM APIs).
6727+
_OPEN_WORLD_TOOLS = frozenset({"translate_nl_to_sql"})
6728+
6729+
6730+
def _apply_tool_annotations(server: FastMCP[AppContext], read_only_names: set[str]) -> None:
6731+
"""Stamp MCP ``ToolAnnotations`` derived from the capability gates.
6732+
6733+
MCPg already classifies every tool as READ / WRITE / DDL / SHELL /
6734+
LISTEN via the registration gates in :func:`register_tools`; this
6735+
puts that classification on the wire, where clients use it to
6736+
decide which calls to auto-approve (``readOnlyHint``) and whether
6737+
the tool leaves the database's closed world (``openWorldHint``).
6738+
Derived, not hand-written, so a tool moved between gates can never
6739+
ship a stale hint. ``destructiveHint`` is deliberately left unset
6740+
for non-read tools: the MCP default (true) is the cautious reading,
6741+
and MCPg doesn't currently track per-tool destructiveness more
6742+
precisely than the gate does.
6743+
6744+
Uses the sync ``_tool_manager`` accessor for the same reason the
6745+
session-intent filter does: this runs in synchronous startup code.
6746+
6747+
Merge semantics: annotations a registration set explicitly always
6748+
win — the derived values only fill fields that are still ``None``.
6749+
(No call site passes ``annotations=`` today, but a future per-tool
6750+
override — e.g. ``destructiveHint=False`` on a maintenance tool —
6751+
must not be silently clobbered by this sweep.)
6752+
"""
6753+
for tool in server._tool_manager.list_tools():
6754+
existing = tool.annotations
6755+
if existing is None:
6756+
tool.annotations = ToolAnnotations(
6757+
readOnlyHint=tool.name in read_only_names,
6758+
openWorldHint=tool.name in _OPEN_WORLD_TOOLS,
6759+
)
6760+
continue
6761+
derived: dict[str, bool] = {}
6762+
if existing.readOnlyHint is None:
6763+
derived["readOnlyHint"] = tool.name in read_only_names
6764+
if existing.openWorldHint is None:
6765+
derived["openWorldHint"] = tool.name in _OPEN_WORLD_TOOLS
6766+
if derived:
6767+
tool.annotations = existing.model_copy(update=derived)
6768+
6769+
67126770
def register_tools(server: FastMCP[AppContext], settings: Settings) -> None:
67136771
"""Register the MCP tools permitted by the configured access mode.
67146772
@@ -6757,6 +6815,9 @@ def register_tools(server: FastMCP[AppContext], settings: Settings) -> None:
67576815
_register_wait_for_lsn_reads(server)
67586816
_register_wait_for_lsn_writes(server)
67596817
_register_warehousepg_reads(server)
6818+
# Everything registered so far sits behind (at most) the READ gate —
6819+
# this snapshot is what ``readOnlyHint=True`` is derived from below.
6820+
read_only_names = {tool.name for tool in server._tool_manager.list_tools()}
67606821
if is_permitted(settings.access_mode, Capability.WRITE):
67616822
_register_write(server)
67626823
_register_maintenance(server)
@@ -6788,6 +6849,8 @@ def register_tools(server: FastMCP[AppContext], settings: Settings) -> None:
67886849
if is_permitted(settings.access_mode, Capability.LISTEN) and settings.allow_listen:
67896850
_register_listen(server)
67906851

6852+
_apply_tool_annotations(server, read_only_names)
6853+
67916854
# Session-intent surface filter (roadmap 8.8). Runs LAST so every
67926855
# tool that would otherwise be registered has already been added —
67936856
# we then remove whatever the configured intent doesn't allow.

0 commit comments

Comments
 (0)