postgres-aiops 0.5.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (112) hide show
  1. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/CHANGELOG.md +19 -0
  2. postgres_aiops-0.7.0/Dockerfile +9 -0
  3. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/PKG-INFO +30 -42
  4. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/README.md +28 -40
  5. postgres_aiops-0.7.0/RELEASE_NOTES.md +33 -0
  6. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/SECURITY.md +6 -3
  7. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/docs/VERIFICATION.md +10 -7
  8. postgres_aiops-0.7.0/glama.json +6 -0
  9. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/_shared.py +11 -7
  10. postgres_aiops-0.7.0/mcp_server/server.py +51 -0
  11. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/remediation.py +1 -1
  12. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/undo.py +40 -12
  13. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/_common.py +10 -2
  14. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/init.py +0 -38
  15. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/query.py +7 -4
  16. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/remediate.py +31 -18
  17. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/undo.py +10 -4
  18. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/config.py +14 -2
  19. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/__init__.py +2 -6
  20. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/budget.py +19 -22
  21. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/decorators.py +61 -101
  22. postgres_aiops-0.7.0/postgres_aiops/governance/policy.py +67 -0
  23. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/queries.py +43 -7
  24. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/pyproject.toml +2 -2
  25. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/server.json +2 -2
  26. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/skills/postgres-aiops/SKILL.md +13 -7
  27. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/skills/postgres-aiops/references/agent-guardrails.md +15 -7
  28. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/skills/postgres-aiops/references/capabilities.md +1 -1
  29. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/skills/postgres-aiops/references/cli-reference.md +8 -5
  30. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/skills/postgres-aiops/references/setup-guide.md +3 -2
  31. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/conftest.py +6 -4
  32. postgres_aiops-0.7.0/tests/test_cli_remediate.py +220 -0
  33. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_cli_writes.py +36 -3
  34. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_gov_decorators.py +11 -28
  35. postgres_aiops-0.7.0/tests/test_gov_policy.py +56 -0
  36. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_governance_persistence.py +23 -35
  37. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_init.py +7 -18
  38. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_reads.py +33 -0
  39. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_smoke.py +59 -3
  40. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_undo_executor.py +75 -2
  41. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/uv.lock +54 -51
  42. postgres_aiops-0.5.0/RELEASE_NOTES.md +0 -50
  43. postgres_aiops-0.5.0/mcp_server/server.py +0 -76
  44. postgres_aiops-0.5.0/postgres_aiops/governance/policy.py +0 -430
  45. postgres_aiops-0.5.0/postgres_aiops/governance/readonly.py +0 -49
  46. postgres_aiops-0.5.0/tests/test_cli_remediate.py +0 -134
  47. postgres_aiops-0.5.0/tests/test_gov_policy.py +0 -415
  48. postgres_aiops-0.5.0/tests/test_readonly.py +0 -145
  49. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/.github/workflows/mcp-publish.yml +0 -0
  50. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/.github/workflows/publish.yml +0 -0
  51. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/.gitignore +0 -0
  52. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/LICENSE +0 -0
  53. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/__init__.py +0 -0
  54. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/__init__.py +0 -0
  55. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/activity.py +0 -0
  56. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/analysis.py +0 -0
  57. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/indexes.py +0 -0
  58. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/queries.py +0 -0
  59. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/replication.py +0 -0
  60. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/server.py +0 -0
  61. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/mcp_server/tools/tables.py +0 -0
  62. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/__init__.py +0 -0
  63. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/__init__.py +0 -0
  64. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/_root.py +0 -0
  65. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/activity.py +0 -0
  66. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/analyze.py +0 -0
  67. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/doctor.py +0 -0
  68. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/index.py +0 -0
  69. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/overview.py +0 -0
  70. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/replication.py +0 -0
  71. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/secret.py +0 -0
  72. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/server.py +0 -0
  73. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/cli/table.py +0 -0
  74. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/connection.py +0 -0
  75. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/doctor.py +0 -0
  76. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/audit.py +0 -0
  77. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/outcome.py +0 -0
  78. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/paths.py +0 -0
  79. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/patterns.py +0 -0
  80. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/sanitize.py +0 -0
  81. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/governance/undo.py +0 -0
  82. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/__init__.py +0 -0
  83. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/_util.py +0 -0
  84. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/activity.py +0 -0
  85. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/analysis.py +0 -0
  86. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/indexes.py +0 -0
  87. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/overview.py +0 -0
  88. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/remediation.py +0 -0
  89. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/replication.py +0 -0
  90. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/server.py +0 -0
  91. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/ops/tables.py +0 -0
  92. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/postgres_aiops/secretstore.py +0 -0
  93. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/smithery.yaml +0 -0
  94. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_analysis.py +0 -0
  95. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_cli_reads.py +0 -0
  96. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_cli_secret.py +0 -0
  97. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_config.py +0 -0
  98. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_connection.py +0 -0
  99. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_connection_lost.py +0 -0
  100. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_connection_more.py +0 -0
  101. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_doctor.py +0 -0
  102. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_gov_audit.py +0 -0
  103. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_gov_patterns.py +0 -0
  104. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_mcp_tools.py +0 -0
  105. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_ops_more.py +0 -0
  106. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_optional_fields.py +0 -0
  107. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_secretstore.py +0 -0
  108. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_secretstore_more.py +0 -0
  109. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_self_lockout.py +0 -0
  110. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_truncation.py +0 -0
  111. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_undo_replay.py +0 -0
  112. {postgres_aiops-0.5.0 → postgres_aiops-0.7.0}/tests/test_writes.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.0 — 2026-08-02
4
+
5
+ ### Changed (BREAKING)
6
+ - **Requires MCP SDK 2.0** (`mcp[cli]>=2.0,<3.0`). `mcp.server.fastmcp` no longer exists in 2.0; the server is now built with `MCPServer` and reports its package version in the stdio handshake.
7
+
8
+ ### Fixed
9
+ - **`undo apply` works from the CLI.** Every write tool is imported lazily inside its own CLI command, so a CLI-driven undo ran in a process where the inverse tool was never registered and failed with "inverse tool is not registered" — for every write tool. Only the MCP entry point, which imports the whole server, worked. Found while live-verifying against a real cluster.
10
+ - **An undetermined outcome is audited `unknown`, not `ok`.** The harness only classified a result as undetermined when the payload *also* carried an `error` key, so a write that looked successful but had not been confirmed was recorded as a success.
11
+
12
+
13
+ ## v0.6.0 — 2026-07-21
14
+
15
+ ### Changed (BREAKING)
16
+ - **Removed the authorization layer** — read-only mode, the approver gate, and rules.yaml deny are gone. The skill no longer decides read vs write; that is the agent's judgement or the connecting account's permissions. `<PREFIX>_READ_ONLY` now has no effect (a startup warning is logged); `<PREFIX>_AUDIT_APPROVED_BY`/`_RATIONALE` are optional audit annotations.
17
+ - The retained guarantee is **unbypassable audit over MCP and CLI alike** — no unaudited entry point. Harness = audit + runaway safety guard + undo + sanitize; `risk_level` is a descriptive audit label, not a gate.
18
+
19
+ See RELEASE_NOTES.md for tool-specific changes.
20
+
21
+
3
22
  ## v0.5.0 — 2026-07-20
4
23
 
5
24
  ### Fixed
@@ -0,0 +1,9 @@
1
+ # syntax=docker/dockerfile:1
2
+ # Minimal image for Glama introspection: starts the MCP server over stdio.
3
+ # The tools/list introspection handshake needs no live PostgreSQL credentials.
4
+ FROM python:3.12-slim
5
+
6
+ RUN pip install --no-cache-dir postgres-aiops
7
+
8
+ # MCP server speaks JSON-RPC over stdio.
9
+ ENTRYPOINT ["postgres-aiops-mcp"]
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgres-aiops
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)
5
5
  Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
9
  Requires-Dist: cryptography>=42.0
10
- Requires-Dist: mcp[cli]<2.0,>=1.10
10
+ Requires-Dist: mcp[cli]<3.0,>=2.0
11
11
  Requires-Dist: psycopg[binary]<4.0,>=3.1
12
12
  Requires-Dist: pyyaml<7.0,>=6.0
13
13
  Requires-Dist: rich<16.0,>=13.0
@@ -22,8 +22,8 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
24
24
  **psycopg 3** and reading the system catalogs and `pg_stat_*` views — with a
25
- **built-in governance harness**: unified audit log, policy engine, token/runaway
26
- budget guard, undo-token recording, and graduated-autonomy risk tiers.
25
+ **built-in governance harness**: unified audit log, token/runaway
26
+ budget guard, undo-token recording, and descriptive risk-tier labels.
27
27
  Beyond the mock test suite, the reads, a governed write, and its undo have been
28
28
  exercised against a live PostgreSQL 16.14 instance — see [`docs/VERIFICATION.md`](docs/VERIFICATION.md).
29
29
 
@@ -70,40 +70,25 @@ The flagship analyses accept injected records for pure/offline analysis, or pull
70
70
  live from a configured target. `top_queries`/`slow_query_rca` require the
71
71
  `pg_stat_statements` extension; the read role should have `pg_monitor`.
72
72
 
73
- ## Security: read-only mode
73
+ ## What this tool does, and does not, decide
74
74
 
75
- This tool is meant to be handed to an AI agent, so its safety story is enforced
76
- by the server rather than requested in a prompt:
75
+ It delivers PostgreSQL DBA operations — reads and writes — accurately and
76
+ efficiently, and records every one of them. It does **not** decide whether a
77
+ write is allowed to happen. That is the agent's judgement, or the permission of
78
+ the account you connect it with: connect with a PostgreSQL role that has no
79
+ write privileges (a read-only role, or one without INSERT/UPDATE/DELETE/DDL),
80
+ and the writes fail at the server — the place that actually owns the
81
+ permission.
77
82
 
78
- ```bash
79
- export POSTGRES_READ_ONLY=1
80
- ```
81
-
82
- With that set, the **10 write tools are never registered**. An MCP client
83
- lists **25 tools instead of 35** — the writes are not hidden, not
84
- gated behind a flag, and not merely refused when called. They are absent from
85
- the session. A model cannot invoke a tool it was never offered, and cannot be
86
- argued into one.
87
-
88
- That distinction is the whole point. A tool that exists but refuses still invites
89
- retry loops and "I'll describe the call instead" behaviour from smaller models,
90
- and it leaves a reviewer trusting a promise. An absent tool is a fact you can
91
- check: connect, list the tools, and see that the writes are not there.
92
-
93
- Enforcement is two layers deep, so the switch cannot be sidestepped by changing
94
- entry point:
95
-
96
- | Layer | What it does | Covers |
97
- |---|---|---|
98
- | `@governed_tool` harness | refuses every non-read operation outright | MCP, CLI, and in-process callers |
99
- | MCP registration | write tools are removed from `list_tools()` | anything speaking MCP |
100
-
101
- Read operations are unaffected, and every call is still audited to
102
- `~/.postgres-aiops/audit.db`.
83
+ So there is no read-only switch, no policy file, no approval gate to configure.
84
+ The one thing the tool guarantees is that nothing is silent: **every call, over
85
+ MCP and over the CLI alike, lands an audit row** in `~/.postgres-aiops/audit.db`,
86
+ and destructive writes still capture their before-state and record an inverse
87
+ where one exists.
103
88
 
104
- > The read/write split is derived from each tool's declared `risk_level`, and a
105
- > test asserts that this never disagrees with the `[READ]`/`[WRITE]` tag in the
106
- > tool's own documentation — so a write can't quietly present itself as a read.
89
+ > Each tool declares a `risk_level`, carried into the audit row as a descriptive
90
+ > tier (none/confirm/review) — so a reviewer can see at a glance that a row was
91
+ > a high-risk delete. It is a label, not a gate.
107
92
 
108
93
  Running a smaller / local model? See
109
94
  [agent-guardrails.md](skills/postgres-aiops/references/agent-guardrails.md) — it lists
@@ -132,15 +117,18 @@ postgres-aiops-mcp
132
117
 
133
118
  Every MCP tool passes through the bundled `@governed_tool` harness:
134
119
 
135
- - **Audit** — every call (params, result, status, duration, risk tier, approver,
136
- rationale) is logged to `~/.postgres-aiops/audit.db` (relocatable via
137
- `POSTGRES_AIOPS_HOME`).
138
- - **Budget / runaway guard** — token and call budgets trip a circuit breaker.
139
- - **Risk tiers** — graduated autonomy; high-risk ops can require a named approver
140
- (`POSTGRES_AUDIT_APPROVED_BY` / `POSTGRES_AUDIT_RATIONALE` — the env-var names
141
- the bundled harness reads).
120
+ - **Audit** — every call (params, result, status, duration, risk tier, and any
121
+ operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
122
+ (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
123
+ path does — there is no unaudited entry point.
124
+ - **Runaway guard** — a safety backstop, not an authorization gate: the same call
125
+ hammered in a tight loop trips a circuit breaker. Disable with
126
+ `POSTGRES_RUNAWAY_MAX=0`; optional hard ceilings via `POSTGRES_MAX_TOOL_CALLS` /
127
+ `POSTGRES_MAX_TOOL_SECONDS`.
142
128
  - **Undo recording** — reversible writes record an inverse descriptor built from
143
129
  the fetched before-state.
130
+ - **Risk tier** — a descriptive label on the audit row derived from `risk_level`;
131
+ it gates nothing.
144
132
 
145
133
  ## Scope
146
134
 
@@ -6,8 +6,8 @@
6
6
 
7
7
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
8
8
  **psycopg 3** and reading the system catalogs and `pg_stat_*` views — with a
9
- **built-in governance harness**: unified audit log, policy engine, token/runaway
10
- budget guard, undo-token recording, and graduated-autonomy risk tiers.
9
+ **built-in governance harness**: unified audit log, token/runaway
10
+ budget guard, undo-token recording, and descriptive risk-tier labels.
11
11
  Beyond the mock test suite, the reads, a governed write, and its undo have been
12
12
  exercised against a live PostgreSQL 16.14 instance — see [`docs/VERIFICATION.md`](docs/VERIFICATION.md).
13
13
 
@@ -54,40 +54,25 @@ The flagship analyses accept injected records for pure/offline analysis, or pull
54
54
  live from a configured target. `top_queries`/`slow_query_rca` require the
55
55
  `pg_stat_statements` extension; the read role should have `pg_monitor`.
56
56
 
57
- ## Security: read-only mode
57
+ ## What this tool does, and does not, decide
58
58
 
59
- This tool is meant to be handed to an AI agent, so its safety story is enforced
60
- by the server rather than requested in a prompt:
59
+ It delivers PostgreSQL DBA operations — reads and writes — accurately and
60
+ efficiently, and records every one of them. It does **not** decide whether a
61
+ write is allowed to happen. That is the agent's judgement, or the permission of
62
+ the account you connect it with: connect with a PostgreSQL role that has no
63
+ write privileges (a read-only role, or one without INSERT/UPDATE/DELETE/DDL),
64
+ and the writes fail at the server — the place that actually owns the
65
+ permission.
61
66
 
62
- ```bash
63
- export POSTGRES_READ_ONLY=1
64
- ```
65
-
66
- With that set, the **10 write tools are never registered**. An MCP client
67
- lists **25 tools instead of 35** — the writes are not hidden, not
68
- gated behind a flag, and not merely refused when called. They are absent from
69
- the session. A model cannot invoke a tool it was never offered, and cannot be
70
- argued into one.
71
-
72
- That distinction is the whole point. A tool that exists but refuses still invites
73
- retry loops and "I'll describe the call instead" behaviour from smaller models,
74
- and it leaves a reviewer trusting a promise. An absent tool is a fact you can
75
- check: connect, list the tools, and see that the writes are not there.
76
-
77
- Enforcement is two layers deep, so the switch cannot be sidestepped by changing
78
- entry point:
79
-
80
- | Layer | What it does | Covers |
81
- |---|---|---|
82
- | `@governed_tool` harness | refuses every non-read operation outright | MCP, CLI, and in-process callers |
83
- | MCP registration | write tools are removed from `list_tools()` | anything speaking MCP |
84
-
85
- Read operations are unaffected, and every call is still audited to
86
- `~/.postgres-aiops/audit.db`.
67
+ So there is no read-only switch, no policy file, no approval gate to configure.
68
+ The one thing the tool guarantees is that nothing is silent: **every call, over
69
+ MCP and over the CLI alike, lands an audit row** in `~/.postgres-aiops/audit.db`,
70
+ and destructive writes still capture their before-state and record an inverse
71
+ where one exists.
87
72
 
88
- > The read/write split is derived from each tool's declared `risk_level`, and a
89
- > test asserts that this never disagrees with the `[READ]`/`[WRITE]` tag in the
90
- > tool's own documentation — so a write can't quietly present itself as a read.
73
+ > Each tool declares a `risk_level`, carried into the audit row as a descriptive
74
+ > tier (none/confirm/review) — so a reviewer can see at a glance that a row was
75
+ > a high-risk delete. It is a label, not a gate.
91
76
 
92
77
  Running a smaller / local model? See
93
78
  [agent-guardrails.md](skills/postgres-aiops/references/agent-guardrails.md) — it lists
@@ -116,15 +101,18 @@ postgres-aiops-mcp
116
101
 
117
102
  Every MCP tool passes through the bundled `@governed_tool` harness:
118
103
 
119
- - **Audit** — every call (params, result, status, duration, risk tier, approver,
120
- rationale) is logged to `~/.postgres-aiops/audit.db` (relocatable via
121
- `POSTGRES_AIOPS_HOME`).
122
- - **Budget / runaway guard** — token and call budgets trip a circuit breaker.
123
- - **Risk tiers** — graduated autonomy; high-risk ops can require a named approver
124
- (`POSTGRES_AUDIT_APPROVED_BY` / `POSTGRES_AUDIT_RATIONALE` — the env-var names
125
- the bundled harness reads).
104
+ - **Audit** — every call (params, result, status, duration, risk tier, and any
105
+ operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
106
+ (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
107
+ path does — there is no unaudited entry point.
108
+ - **Runaway guard** — a safety backstop, not an authorization gate: the same call
109
+ hammered in a tight loop trips a circuit breaker. Disable with
110
+ `POSTGRES_RUNAWAY_MAX=0`; optional hard ceilings via `POSTGRES_MAX_TOOL_CALLS` /
111
+ `POSTGRES_MAX_TOOL_SECONDS`.
126
112
  - **Undo recording** — reversible writes record an inverse descriptor built from
127
113
  the fetched before-state.
114
+ - **Risk tier** — a descriptive label on the audit row derived from `risk_level`;
115
+ it gates nothing.
128
116
 
129
117
  ## Scope
130
118
 
@@ -0,0 +1,33 @@
1
+ # Release notes — postgres-aiops 0.6.0
2
+
3
+ Previous release: 0.5.0.
4
+
5
+ ## BREAKING — the authorization layer is removed
6
+
7
+ This tool no longer decides whether a write is permitted. Read-only mode
8
+ (`<PREFIX>_READ_ONLY`), the graduated-approval / approver gate, and the
9
+ `rules.yaml` deny engine are **all gone**. Whether an operation runs is the
10
+ agent's judgement, or the permission of the account you connect it with — point
11
+ it at a read-only credential and the write fails at the server, the place that
12
+ actually owns the permission.
13
+
14
+ What the tool guarantees instead is that **nothing is silent**: every operation,
15
+ over MCP **and** the CLI alike, lands a row in the audit log — there is no
16
+ unaudited entry point. Destructive writes still capture their before-state and
17
+ record an undo token where a clean inverse exists.
18
+
19
+ - If you set `<PREFIX>_READ_ONLY=1`, it now has **no effect** and the MCP server
20
+ logs a warning at startup. Restrict writes via the connecting account instead.
21
+ - `<PREFIX>_AUDIT_APPROVED_BY` / `<PREFIX>_AUDIT_RATIONALE` still work, but are now
22
+ **optional audit annotations** — recorded on the row when set, never required.
23
+ - The declared `risk_level` is carried into the audit row as a descriptive tier
24
+ (a label, not a gate).
25
+
26
+ The governance harness is now: **audit (MCP+CLI, unbypassable) · runaway/budget
27
+ safety guard · undo recording · output sanitize**. `policy.py` is a small
28
+ risk-tier classifier; `governance/readonly.py` is deleted.
29
+
30
+
31
+ ## Also in this tool
32
+
33
+ - **`explain --analyze` is now genuinely read-only.** ANALYZE executes the statement, so a `SELECT ... INTO` could create a table; it now runs inside a rolled-back transaction, so no side effect can persist.
@@ -46,9 +46,12 @@ Every MCP tool runs through the bundled `@governed_tool` harness
46
46
  - **Token/runaway budget** — hard ceilings (`POSTGRES_MAX_TOOL_CALLS` /
47
47
  `POSTGRES_MAX_TOOL_SECONDS` — the env-var names the bundled harness reads) plus
48
48
  an on-by-default guard that trips a tight poll/retry loop.
49
- - **Graduated risk tiers** — `~/.postgres-aiops/rules.yaml` `risk_tiers` gate
50
- writes by environment/tag; the highest tiers require a recorded approver
51
- (`POSTGRES_AUDIT_APPROVED_BY` / `POSTGRES_AUDIT_RATIONALE`).
49
+ - **Risk tiers** — a descriptive label (`none`/`confirm`/`review`) carried into
50
+ the audit row from each tool's declared `risk_level`; it gates nothing. The
51
+ harness does not decide whether a write is permitted — that is the agent's
52
+ judgement or the connecting account's permissions. `POSTGRES_AUDIT_APPROVED_BY`
53
+ / `POSTGRES_AUDIT_RATIONALE` are optional annotations recorded on the audit
54
+ row, never required, never blocking.
52
55
  - **Undo-token recording** — reversible writes fetch the **real before-state
53
56
  first** and record a faithful inverse (`create_index`↔`drop_index`, where drop
54
57
  captures `pg_get_indexdef`; `update_setting` restores the prior value).
@@ -28,9 +28,10 @@ suite predicts. Sections left open below are **not** claimed.
28
28
  (with `drop_index` capturing `pg_get_indexdef` **before** dropping), and
29
29
  `update_setting` capturing the prior value. Irreversible ops (terminate,
30
30
  cancel, vacuum, analyze, reindex, reset stats) declare no undo.
31
- - Governance persistence: audited rows land in the SQLite audit DB, and the
32
- secure-by-default approver gate refuses high-risk writes with no `rules.yaml`
33
- and no `POSTGRES_AUDIT_APPROVED_BY`.
31
+ - Governance persistence: audited rows land in the SQLite audit DB. The harness
32
+ authorizes nothing — there is no read-only, deny-rule, or approver gate to
33
+ test; an approver, when supplied, is recorded on the audit row as an optional
34
+ annotation.
34
35
 
35
36
  ## Prerequisites for a live run
36
37
 
@@ -113,10 +114,12 @@ are open — record them as gaps rather than silently passing.
113
114
  long-running backend (**open gap** — not exercised live).
114
115
  - [ ] `remediate reindex` on a real index (**open gap**).
115
116
 
116
- ### 6. Governance actually gates
117
- - [x] ✅ With no `~/.postgres-aiops/rules.yaml`, a high-risk write was refused
118
- until `POSTGRES_AUDIT_APPROVED_BY` named an approver (secure-by-default);
119
- the approver and `POSTGRES_AUDIT_RATIONALE` appear in the audit row.
117
+ ### 6. Governance records, it does not gate
118
+ - [x] ✅ The harness authorizes nothing — there is no read-only, deny-rule, or
119
+ approver gate to test. A high-risk write ran with no approver set and
120
+ landed an `ok` audit row; when `POSTGRES_AUDIT_APPROVED_BY` and
121
+ `POSTGRES_AUDIT_RATIONALE` were set, they appeared in the audit row as
122
+ optional annotations.
120
123
  - [x] ✅ Relocation: with `POSTGRES_AIOPS_HOME` set, `audit.db`, the undo store,
121
124
  and `secrets.enc` all land under that directory.
122
125
  - [ ] A tight poll loop trips the runaway budget guard rather than hammering the
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": [
4
+ "zw008"
5
+ ]
6
+ }
@@ -1,13 +1,15 @@
1
- """Shared MCP server primitives: the FastMCP instance, connection helper,
1
+ """Shared MCP server primitives: the MCPServer instance, connection helper,
2
2
  error sanitisation, and the ``@tool_errors`` decorator.
3
3
 
4
4
  Tool modules under ``mcp_server/tools/`` import ``mcp`` from here and register
5
5
  their ``@mcp.tool()`` functions onto it. ``mcp_server/server.py`` then imports
6
6
  those modules and runs the server.
7
7
 
8
- Keep ``Optional[X]`` (never PEP 604 ``X | None``) in any FastMCP-reflected
9
- tool signature — on older mcp/pydantic the union eval'd to ``types.UnionType``
10
- crashes FastMCP's ``issubclass`` check.
8
+ Keep ``Optional[X]`` (never PEP 604 ``X | None``) in any reflected tool
9
+ signature. mcp>=2.0 accepts the union, but the legacy form is retained for
10
+ consistency and to stay safe under any pydantic reflection path (踩坑 #33 —
11
+ older mcp/pydantic eval'd the union to ``types.UnionType`` and crashed the
12
+ ``issubclass`` check).
11
13
  """
12
14
 
13
15
  import functools
@@ -17,8 +19,9 @@ from collections.abc import Callable
17
19
  from pathlib import Path
18
20
  from typing import Any, Optional
19
21
 
20
- from mcp.server.fastmcp import FastMCP
22
+ from mcp.server import MCPServer
21
23
 
24
+ from postgres_aiops import __version__
22
25
  from postgres_aiops.config import load_config
23
26
  from postgres_aiops.connection import ConnectionManager, PgConnectionLostError, PgError
24
27
  from postgres_aiops.governance import mark_unknown, sanitize
@@ -65,7 +68,7 @@ def tool_errors(shape: str = "dict") -> Callable:
65
68
  """Wrap a tool body in the canonical try/except → ``_safe_error`` pattern.
66
69
 
67
70
  Place this *between* ``@governed_tool`` and the function so the audit
68
- decorator and FastMCP still see the original signature.
71
+ decorator and MCPServer still see the original signature.
69
72
  """
70
73
 
71
74
  def decorator(func: Callable) -> Callable:
@@ -94,8 +97,9 @@ def tool_errors(shape: str = "dict") -> Callable:
94
97
  return decorator
95
98
 
96
99
 
97
- mcp = FastMCP(
100
+ mcp = MCPServer(
98
101
  "postgres-aiops",
102
+ version=__version__,
99
103
  instructions=(
100
104
  "Governed PostgreSQL DBA operations: a one-shot cluster "
101
105
  "'overview'; server reads (version/settings/extensions/databases/roles); "
@@ -0,0 +1,51 @@
1
+ """MCP server wrapping postgres-aiops operations (stdio transport).
2
+
3
+ Thin adapter layer: each ``@mcp.tool()`` function (in ``mcp_server/tools/``)
4
+ delegates to the ``postgres_aiops`` ops package and is wrapped with the
5
+ postgres-aiops ``@governed_tool`` harness (audit / budget / undo / risk-tier).
6
+
7
+ Standalone, self-governed PostgreSQL DBA operations (preview).
8
+ For PostgreSQL servers/clusters via psycopg 3.
9
+
10
+ Source: https://github.com/AIops-tools/Postgres-AIops
11
+ License: MIT
12
+ """
13
+
14
+ import logging
15
+ import os
16
+
17
+ from mcp_server._shared import _safe_error, mcp, tool_errors
18
+
19
+ # Importing the tool modules registers every @mcp.tool() onto the shared
20
+ # `mcp` instance. Order does not matter; each module is self-contained.
21
+ from mcp_server.tools import ( # noqa: F401 — side effects
22
+ activity,
23
+ analysis,
24
+ indexes,
25
+ queries,
26
+ remediation,
27
+ replication,
28
+ server,
29
+ tables,
30
+ undo,
31
+ )
32
+
33
+ __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+
39
+ def main() -> None:
40
+ """Run the MCP server over stdio."""
41
+ logging.basicConfig(level=logging.INFO)
42
+ # Read-only mode was removed. Warn a deployment that still exports the old
43
+ # switch so it gets one audible signal instead of silently gaining writes.
44
+ if os.environ.get("POSTGRES_READ_ONLY"):
45
+ logger.warning(
46
+ "POSTGRES_READ_ONLY is set but no longer has any effect — "
47
+ "read-only mode was removed. Writes ARE enabled. Restrict them via "
48
+ "the connecting account's permissions instead (a read-only socket / "
49
+ "scope-limited token)."
50
+ )
51
+ mcp.run(transport="stdio")
@@ -1,7 +1,7 @@
1
1
  """PostgreSQL maintenance MCP tools (guarded writes).
2
2
 
3
3
  The state-changing tools. Every one is wrapped with the governance harness
4
- (audit + graduated approval tier) and takes a ``dry_run`` preview. Reversible
4
+ (audit + risk-tier tagging) and takes a ``dry_run`` preview. Reversible
5
5
  writes pass an ``undo=`` callback that turns the fetched before-state into an
6
6
  inverse descriptor the harness records; irreversible ones record none.
7
7
 
@@ -3,10 +3,9 @@
3
3
  Every governed write records a replayable inverse descriptor (tool + params) to
4
4
  ``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
5
5
  descriptor and dispatches it to the named governed tool, so the inverse runs on
6
- the SAME governance path as any other call (audited, budget-checked, and — if
7
- the inverse itself is destructive — re-gated by its own risk tier / approver
8
- requirement). ``undo_apply`` is itself governed; the real risk is enforced by
9
- the inner tool it calls.
6
+ the SAME governance path as any other call (audited, budget-checked, and
7
+ tagged with its own risk tier). ``undo_apply`` is itself governed; audit and
8
+ risk-tier labeling for the inverse are handled by the inner tool it calls.
10
9
 
11
10
  Note: recorded ``undo_params`` are the redacted safe-params captured at record
12
11
  time, so an inverse that would need a secret value cannot be replayed here — by
@@ -22,8 +21,22 @@ from postgres_aiops.governance.undo import get_undo_store
22
21
 
23
22
 
24
23
  def _resolve_tool(name: str) -> Any:
25
- """Return the governed callable registered under ``name`` (or None)."""
24
+ """Return the governed callable registered under ``name`` (or None).
25
+
26
+ The CLI reaches this through ``undo apply``, whose process only imports
27
+ ``mcp_server.tools.undo`` — every other tool module is imported lazily inside
28
+ its own CLI command and so is absent here. Without forcing a full load,
29
+ ``undo_apply`` would fail with "inverse tool not registered" for every write
30
+ tool when driven from the CLI (the MCP entry point loads all modules, so it
31
+ never hit this). If the first lookup misses, import ``mcp_server.server`` —
32
+ which registers every ``@mcp.tool()`` as an import side effect — and retry.
33
+ Imported lazily to avoid the undo↔server cycle.
34
+ """
26
35
  tool = mcp._tool_manager._tools.get(name)
36
+ if tool is None:
37
+ import mcp_server.server # noqa: F401 — registers all tools as a side effect
38
+
39
+ tool = mcp._tool_manager._tools.get(name)
27
40
  return getattr(tool, "fn", None) if tool else None
28
41
 
29
42
 
@@ -73,9 +86,9 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
73
86
  def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
74
87
  """[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
75
88
 
76
- The inverse runs through its own governed tool, so its real risk tier and
77
- any approver requirement are enforced there. Pass dry_run=True to preview
78
- the inverse call without executing it. A token can only be applied once.
89
+ The inverse runs through its own governed tool, so its risk tier and audit
90
+ row are recorded there. Pass dry_run=True to preview the inverse call
91
+ without executing it. A token can only be applied once.
79
92
 
80
93
  Args:
81
94
  undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
@@ -92,12 +105,27 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
92
105
  )
93
106
 
94
107
  inverse_tool = rec["undo_tool"]
108
+ # Unreadable parameters must stop the replay, not be replaced with {}.
109
+ # Defaulting dispatched the inverse tool with NO ARGUMENTS: for a tool whose
110
+ # parameters all have defaults that runs a real, unintended operation, and
111
+ # dry_run previewed it as a legitimate no-arg inverse. Refusing is the only
112
+ # honest answer — the recorded intent is gone, so there is nothing to apply.
113
+ raw = rec["undo_params"]
95
114
  try:
96
- params = json.loads(rec["undo_params"]) if rec["undo_params"] else {}
97
- except (ValueError, TypeError):
98
- params = {}
115
+ params = json.loads(raw) if raw else {}
116
+ except (ValueError, TypeError) as exc:
117
+ raise ValueError(
118
+ f"Undo '{undo_id}' has unreadable recorded parameters, so its inverse "
119
+ f"cannot be replayed. Inspect the row in undo.db and re-run the "
120
+ f"inverse tool '{inverse_tool}' by hand if the change still needs "
121
+ f"reversing."
122
+ ) from exc
99
123
  if not isinstance(params, dict):
100
- params = {}
124
+ raise ValueError(
125
+ f"Undo '{undo_id}' recorded parameters of type "
126
+ f"'{type(params).__name__}' rather than an object, so its inverse "
127
+ f"cannot be replayed. Re-run the inverse tool '{inverse_tool}' by hand."
128
+ )
101
129
 
102
130
  fn = _resolve_tool(inverse_tool)
103
131
  if fn is None:
@@ -27,10 +27,18 @@ DryRunOption = Annotated[
27
27
 
28
28
 
29
29
  def _cli_error_types() -> tuple[type[BaseException], ...]:
30
- """Exceptions translated to a one-line teaching error instead of a traceback."""
30
+ """Exceptions translated to a one-line teaching error instead of a traceback.
31
+
32
+ ``PolicyDenied``/``BudgetExceeded`` are raised by ``@governed_tool`` OUTSIDE
33
+ the tool body, so ``tool_errors`` never sees them and they never arrive as
34
+ an ``{"error": ...}`` dict. Their message is the teaching text (which
35
+ approver to set, which budget was hit) — without them here a refusal
36
+ reaches the CLI as a traceback instead.
37
+ """
31
38
  from postgres_aiops.connection import PgError
39
+ from postgres_aiops.governance import BudgetExceeded, PolicyDenied
32
40
 
33
- return (PgError, KeyError, OSError, ValueError)
41
+ return (PgError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
34
42
 
35
43
 
36
44
  def cli_errors(fn: Callable) -> Callable:
@@ -22,45 +22,8 @@ from postgres_aiops.config import (
22
22
  DEFAULT_SSLMODE,
23
23
  DEFAULT_USER,
24
24
  )
25
- from postgres_aiops.governance.paths import ops_path
26
25
  from postgres_aiops.secretstore import SecretStore, resolve_master_password
27
26
 
28
- # Starter policy: keeps the secure-by-default gate (high/critical writes need a
29
- # named approver) explicit and editable, and shows the other rule kinds.
30
- DEFAULT_RULES_YAML = """\
31
- # postgres-aiops policy rules — hot-reloaded on change (no restart needed).
32
- # Kinds: deny rules, maintenance_window, risk_tiers (graduated autonomy).
33
-
34
- risk_tiers:
35
- - name: high-risk-requires-approver
36
- tier: dual
37
- min_risk_level: high
38
- reason: >-
39
- High/critical writes need a named human approver — set
40
- POSTGRES_AUDIT_APPROVED_BY (and POSTGRES_AUDIT_RATIONALE) before the call.
41
-
42
- # deny:
43
- # - name: no-prod-drops
44
- # operations: ["drop_*"]
45
- # environments: ["production"]
46
- # reason: "Drops in production go through change management."
47
-
48
- # maintenance_window:
49
- # start: "22:00"
50
- # end: "06:00"
51
- """
52
-
53
-
54
- def _write_default_rules() -> None:
55
- """Seed a starter rules.yaml (only when none exists) so the policy layer
56
- is explicit from day one; never overwrites an operator-authored file."""
57
- rules_path = ops_path("rules.yaml")
58
- if rules_path.exists():
59
- return
60
- rules_path.parent.mkdir(parents=True, exist_ok=True)
61
- rules_path.write_text(DEFAULT_RULES_YAML, "utf-8")
62
- console.print(f"[green]✓ Wrote default policy rules:[/] {rules_path}")
63
-
64
27
 
65
28
  def _load_existing_targets() -> list[dict]:
66
29
  if not CONFIG_FILE.exists():
@@ -138,7 +101,6 @@ def init_cmd() -> None:
138
101
  if not typer.confirm("\nAdd another target?", default=False):
139
102
  break
140
103
 
141
- _write_default_rules()
142
104
  console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
143
105
  console.print(
144
106
  "[dim]Tip: export POSTGRES_AIOPS_MASTER_PASSWORD=... in your shell profile "
@@ -13,7 +13,7 @@ from postgres_aiops.cli._common import (
13
13
  cli_errors,
14
14
  console,
15
15
  double_confirm,
16
- dry_run_print,
16
+ dry_run_preview,
17
17
  get_connection,
18
18
  print_result,
19
19
  )
@@ -62,13 +62,16 @@ def query_explain(
62
62
  def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> None:
63
63
  """Reset pg_stat_statements accumulators (irreversible; dry-run + confirm).
64
64
 
65
- Real execution is delegated to the ``@governed_tool``-wrapped MCP function
66
- so the reset is audited on the same governance path as MCP calls.
65
+ Both the preview and the real execution are delegated to the
66
+ ``@governed_tool``-wrapped MCP function, so each is audited on the same
67
+ governance path as MCP calls.
67
68
  """
68
69
  from mcp_server.tools import queries as gov
69
70
 
70
71
  if dry_run:
71
- dry_run_print(operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
72
+ dry_run_preview(
73
+ gov.reset_query_stats(dry_run=True, target=target),
74
+ operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
72
75
  return
73
76
  double_confirm("reset pg_stat_statements on", "this target")
74
77
  console.print_json(json.dumps(gov.reset_query_stats(target=target)))