postgres-aiops 0.2.1__tar.gz → 0.3.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 (86) hide show
  1. postgres_aiops-0.3.0/.github/workflows/mcp-publish.yml +55 -0
  2. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/.gitignore +1 -0
  3. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/CHANGELOG.md +5 -0
  4. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/PKG-INFO +1 -1
  5. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/server.py +1 -0
  6. postgres_aiops-0.3.0/mcp_server/tools/undo.py +121 -0
  7. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/_root.py +2 -0
  8. postgres_aiops-0.3.0/postgres_aiops/cli/undo.py +62 -0
  9. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/pyproject.toml +1 -1
  10. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/server.json +2 -2
  11. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_smoke.py +3 -0
  12. postgres_aiops-0.3.0/tests/test_undo_executor.py +138 -0
  13. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/uv.lock +1 -1
  14. postgres_aiops-0.2.1/.coverage +0 -0
  15. postgres_aiops-0.2.1/.github/workflows/mcp-publish.yml +0 -23
  16. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
  17. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/LICENSE +0 -0
  18. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/README.md +0 -0
  19. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/RELEASE_NOTES.md +0 -0
  20. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/SECURITY.md +0 -0
  21. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/__init__.py +0 -0
  22. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/_shared.py +0 -0
  23. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
  24. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/activity.py +0 -0
  25. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
  26. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/indexes.py +0 -0
  27. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/queries.py +0 -0
  28. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/remediation.py +0 -0
  29. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/replication.py +0 -0
  30. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/server.py +0 -0
  31. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/mcp_server/tools/tables.py +0 -0
  32. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/__init__.py +0 -0
  33. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/__init__.py +0 -0
  34. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/_common.py +0 -0
  35. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/activity.py +0 -0
  36. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/analyze.py +0 -0
  37. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/doctor.py +0 -0
  38. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/index.py +0 -0
  39. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/init.py +0 -0
  40. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/overview.py +0 -0
  41. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/query.py +0 -0
  42. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/remediate.py +0 -0
  43. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/replication.py +0 -0
  44. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/secret.py +0 -0
  45. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/server.py +0 -0
  46. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/cli/table.py +0 -0
  47. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/config.py +0 -0
  48. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/connection.py +0 -0
  49. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/doctor.py +0 -0
  50. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/__init__.py +0 -0
  51. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/audit.py +0 -0
  52. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/budget.py +0 -0
  53. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/decorators.py +0 -0
  54. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/paths.py +0 -0
  55. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/patterns.py +0 -0
  56. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/policy.py +0 -0
  57. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/sanitize.py +0 -0
  58. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/governance/undo.py +0 -0
  59. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/__init__.py +0 -0
  60. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/_util.py +0 -0
  61. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/activity.py +0 -0
  62. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/analysis.py +0 -0
  63. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/indexes.py +0 -0
  64. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/overview.py +0 -0
  65. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/queries.py +0 -0
  66. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/remediation.py +0 -0
  67. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/replication.py +0 -0
  68. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/server.py +0 -0
  69. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/ops/tables.py +0 -0
  70. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/postgres_aiops/secretstore.py +0 -0
  71. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/SKILL.md +0 -0
  72. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  73. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  74. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  75. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/smithery.yaml +0 -0
  76. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/conftest.py +0 -0
  77. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_analysis.py +0 -0
  78. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_cli_writes.py +0 -0
  79. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_connection.py +0 -0
  80. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_doctor.py +0 -0
  81. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_governance_persistence.py +0 -0
  82. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_init.py +0 -0
  83. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_reads.py +0 -0
  84. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_secretstore.py +0 -0
  85. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_undo_replay.py +0 -0
  86. {postgres_aiops-0.2.1 → postgres_aiops-0.3.0}/tests/test_writes.py +0 -0
@@ -0,0 +1,55 @@
1
+ name: mcp-publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
12
+ jobs:
13
+ publish-mcp:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Wait for PyPI
18
+ # The release event also triggers the PyPI publish workflow; the MCP
19
+ # registry validates that the package version exists on PyPI, so poll
20
+ # until it has propagated (every 15s, up to 10 minutes).
21
+ run: |
22
+ python3 - <<'EOF'
23
+ import json
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+
29
+ with open("server.json", encoding="utf-8") as f:
30
+ package = json.load(f)["packages"][0]
31
+ name, version = package["identifier"], package["version"]
32
+ url = f"https://pypi.org/pypi/{name}/{version}/json"
33
+ deadline = time.monotonic() + 600
34
+ while True:
35
+ try:
36
+ with urllib.request.urlopen(url, timeout=10):
37
+ print(f"{name}=={version} is available on PyPI.")
38
+ sys.exit(0)
39
+ except (urllib.error.URLError, OSError) as exc:
40
+ print(f"{name}=={version} not on PyPI yet ({exc}); "
41
+ "retrying in 15s...")
42
+ if time.monotonic() >= deadline:
43
+ sys.exit(f"Timed out after 10 minutes waiting for "
44
+ f"{name}=={version} to appear on PyPI. "
45
+ "Check the PyPI publish workflow, then re-run "
46
+ "this workflow via workflow_dispatch.")
47
+ time.sleep(15)
48
+ EOF
49
+ - name: Install mcp-publisher
50
+ run: |
51
+ curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
52
+ - name: Login to MCP Registry (GitHub OIDC)
53
+ run: ./mcp-publisher login github-oidc
54
+ - name: Publish server.json
55
+ run: ./mcp-publisher publish
@@ -5,3 +5,4 @@ __pycache__/
5
5
  .pytest_cache/
6
6
  .ruff_cache/
7
7
  *.egg-info/
8
+ .coverage
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0 — 2026-07-17
4
+
5
+ ### Added
6
+ - **Undo executor**: `undo list` / `undo apply <id>` (CLI + MCP) — apply a recorded replayable inverse; the dispatched inverse is re-gated by its own risk tier; single-use, dry-run, double-confirm, both wrapper + inverse audited.
7
+
3
8
  ## v0.2.1 — 2026-07-16
4
9
 
5
10
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgres-aiops
3
- Version: 0.2.1
3
+ Version: 0.3.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 <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ from mcp_server.tools import ( # noqa: F401 — side effects
26
26
  replication,
27
27
  server,
28
28
  tables,
29
+ undo,
29
30
  )
30
31
 
31
32
  __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
@@ -0,0 +1,121 @@
1
+ """Undo executor — list recorded inverse descriptors and APPLY them.
2
+
3
+ Every governed write records a replayable inverse descriptor (tool + params) to
4
+ ``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
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.
10
+
11
+ Note: recorded ``undo_params`` are the redacted safe-params captured at record
12
+ time, so an inverse that would need a secret value cannot be replayed here — by
13
+ design, inverses in this line key off ids/names, not credentials.
14
+ """
15
+
16
+ import json
17
+ from typing import Any, Optional
18
+
19
+ from mcp_server._shared import mcp, tool_errors
20
+ from postgres_aiops.governance import governed_tool
21
+ from postgres_aiops.governance.undo import get_undo_store
22
+
23
+
24
+ def _resolve_tool(name: str) -> Any:
25
+ """Return the governed callable registered under ``name`` (or None)."""
26
+ tool = mcp._tool_manager._tools.get(name)
27
+ return getattr(tool, "fn", None) if tool else None
28
+
29
+
30
+ @mcp.tool()
31
+ @governed_tool(risk_level="low")
32
+ @tool_errors("dict")
33
+ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
34
+ """[READ] List recorded, not-yet-applied undo tokens (most recent first).
35
+
36
+ Each entry names the original tool, the inverse tool that ``undo_apply``
37
+ would run, and a human note. Use the ``undoId`` with ``undo_apply``.
38
+
39
+ Args:
40
+ limit: Max rows to return (default 50).
41
+ target: Unused (undo state is host-local); accepted for CLI uniformity.
42
+ """
43
+ rows = get_undo_store().list(status="recorded", limit=max(1, min(limit, 500)))
44
+ return {
45
+ "count": len(rows),
46
+ "undos": [
47
+ {
48
+ "undoId": r["undo_id"],
49
+ "ts": r["ts"],
50
+ "originalTool": r["tool"],
51
+ "inverseTool": r["undo_tool"],
52
+ "note": r.get("note", ""),
53
+ }
54
+ for r in rows
55
+ ],
56
+ }
57
+
58
+
59
+ @mcp.tool()
60
+ @governed_tool(risk_level="medium")
61
+ @tool_errors("dict")
62
+ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
63
+ """[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
64
+
65
+ The inverse runs through its own governed tool, so its real risk tier and
66
+ any approver requirement are enforced there. Pass dry_run=True to preview
67
+ the inverse call without executing it. A token can only be applied once.
68
+
69
+ Args:
70
+ undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
71
+ dry_run: If True, preview the inverse tool + params without running it.
72
+ target: Passed through to the inverse tool when it accepts a target.
73
+ """
74
+ store = get_undo_store()
75
+ rec = store.get(undo_id)
76
+ if not rec:
77
+ raise ValueError(f"Unknown undo id '{undo_id}'. Run undo_list to see available tokens.")
78
+ if rec["status"] != "recorded":
79
+ raise ValueError(
80
+ f"Undo '{undo_id}' is already '{rec['status']}' — a token can only be applied once."
81
+ )
82
+
83
+ inverse_tool = rec["undo_tool"]
84
+ try:
85
+ params = json.loads(rec["undo_params"]) if rec["undo_params"] else {}
86
+ except (ValueError, TypeError):
87
+ params = {}
88
+ if not isinstance(params, dict):
89
+ params = {}
90
+
91
+ fn = _resolve_tool(inverse_tool)
92
+ if fn is None:
93
+ raise ValueError(
94
+ f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
95
+ )
96
+
97
+ if dry_run:
98
+ return {
99
+ "dryRun": True,
100
+ "undoId": undo_id,
101
+ "wouldApply": {"tool": inverse_tool, "params": params},
102
+ }
103
+
104
+ call_params = dict(params)
105
+ if target is not None and "target" not in call_params:
106
+ call_params["target"] = target
107
+ result = fn(**call_params)
108
+
109
+ # Only mark applied when the inverse did not itself return an error dict.
110
+ if not (isinstance(result, dict) and result.get("error")):
111
+ store.mark(undo_id, "applied")
112
+ applied = True
113
+ else:
114
+ applied = False
115
+
116
+ return {
117
+ "undoId": undo_id,
118
+ "applied": applied,
119
+ "inverseTool": inverse_tool,
120
+ "result": result,
121
+ }
@@ -17,6 +17,7 @@ from postgres_aiops.cli.replication import repl_app
17
17
  from postgres_aiops.cli.secret import secret_app
18
18
  from postgres_aiops.cli.server import server_app
19
19
  from postgres_aiops.cli.table import table_app
20
+ from postgres_aiops.cli.undo import undo_app
20
21
 
21
22
  app = typer.Typer(
22
23
  name="postgres-aiops",
@@ -33,6 +34,7 @@ app.add_typer(repl_app, name="repl")
33
34
  app.add_typer(analyze_app, name="analyze")
34
35
  app.add_typer(remediate_app, name="remediate")
35
36
  app.add_typer(secret_app, name="secret")
37
+ app.add_typer(undo_app, name="undo")
36
38
  app.command("init")(init_cmd)
37
39
  app.command("overview")(overview_cmd)
38
40
  app.command("doctor")(doctor_cmd)
@@ -0,0 +1,62 @@
1
+ """``postgres-aiops undo`` — list recorded undo tokens and apply them.
2
+
3
+ Real execution is delegated to the ``@governed_tool``-wrapped functions in
4
+ ``mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
5
+ path as any other write (the inverse tool it dispatches is itself re-gated).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from postgres_aiops.cli._common import (
16
+ DryRunOption,
17
+ TargetOption,
18
+ cli_errors,
19
+ console,
20
+ double_confirm,
21
+ dry_run_print,
22
+ )
23
+
24
+ undo_app = typer.Typer(
25
+ name="undo",
26
+ help="List recorded undo tokens and apply their inverse operations.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+ UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
31
+
32
+
33
+ @undo_app.command("list")
34
+ @cli_errors
35
+ def undo_list_cmd(
36
+ limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
37
+ target: TargetOption = None,
38
+ ) -> None:
39
+ """List recorded, not-yet-applied undo tokens."""
40
+ from mcp_server.tools import undo as gov
41
+
42
+ console.print_json(json.dumps(gov.undo_list(limit=limit, target=target)))
43
+
44
+
45
+ @undo_app.command("apply")
46
+ @cli_errors
47
+ def undo_apply_cmd(
48
+ undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
49
+ ) -> None:
50
+ """Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
51
+ from mcp_server.tools import undo as gov
52
+
53
+ if dry_run:
54
+ preview = gov.undo_apply(undo_id=undo_id, dry_run=True, target=target)
55
+ dry_run_print(
56
+ operation="undo_apply",
57
+ api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
58
+ parameters=preview.get("wouldApply", {}).get("params", {}),
59
+ )
60
+ return
61
+ double_confirm("apply undo", undo_id)
62
+ console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "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)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.2.1",
10
+ "version": "0.3.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.2.1",
15
+ "version": "0.3.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -34,6 +34,8 @@ EXPECTED_TOOLS = {
34
34
  # writes
35
35
  "reset_query_stats", "terminate_backend", "cancel_query", "run_vacuum",
36
36
  "run_analyze", "create_index", "drop_index", "reindex", "update_setting",
37
+ # undo executor
38
+ "undo_list", "undo_apply",
37
39
  }
38
40
 
39
41
  WRITE_RISK = {
@@ -64,6 +66,7 @@ def test_all_modules_import():
64
66
  "mcp_server.server", "mcp_server._shared",
65
67
  "mcp_server.tools.server", "mcp_server.tools.activity",
66
68
  "mcp_server.tools.queries", "mcp_server.tools.indexes",
69
+ "postgres_aiops.cli.undo", "mcp_server.tools.undo",
67
70
  "mcp_server.tools.tables", "mcp_server.tools.replication",
68
71
  "mcp_server.tools.analysis", "mcp_server.tools.remediation",
69
72
  ):
@@ -0,0 +1,138 @@
1
+ """Undo EXECUTOR — undo_apply dispatches a recorded inverse through its governed
2
+ tool, on a real undo.db in an isolated home. Closes the loop from "undo recorded"
3
+ to "undo actually executed".
4
+
5
+ Portable across the line: the dispatched inverse is a synthetic governed tool
6
+ registered on the real MCP instance, so this file is identical everywhere except
7
+ the package import path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sqlite3
13
+
14
+ import pytest
15
+
16
+ import postgres_aiops.governance.audit as audit_mod
17
+ import postgres_aiops.governance.policy as policy_mod
18
+ import postgres_aiops.governance.undo as undo_mod
19
+ from mcp_server._shared import mcp
20
+ from mcp_server.tools import undo as gov
21
+ from postgres_aiops.governance import governed_tool
22
+
23
+ _CALLS: list[dict] = []
24
+
25
+
26
+ @governed_tool(risk_level="low")
27
+ def _undo_probe(value: str = "", target=None) -> dict:
28
+ """Synthetic inverse target used only by the undo-executor tests."""
29
+ _CALLS.append({"value": value})
30
+ return {"ok": True, "value": value}
31
+
32
+
33
+ @pytest.fixture
34
+ def gov_home(tmp_path, monkeypatch):
35
+ _CALLS.clear()
36
+ # Register the probe only for the duration of these tests so it never
37
+ # pollutes the real tool registry (which exact-count smoke tests assert on).
38
+ mcp.add_tool(_undo_probe, name="_undo_probe")
39
+ monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
40
+ monkeypatch.setenv("POSTGRES_AUDIT_APPROVED_BY", "pytest")
41
+ audit_mod.reset_engine()
42
+ policy_mod.reset_policy_engine()
43
+ undo_mod.reset_undo_store()
44
+ yield tmp_path
45
+ mcp._tool_manager._tools.pop("_undo_probe", None)
46
+ audit_mod.reset_engine()
47
+ policy_mod.reset_policy_engine()
48
+ undo_mod.reset_undo_store()
49
+
50
+
51
+ def _record(undo_tool="_undo_probe", params=None):
52
+ descriptor = {"tool": undo_tool, "params": params if params is not None else {"value": "v1"}}
53
+ return undo_mod.get_undo_store().record(
54
+ skill="probe", tool="orig_op", undo_descriptor=descriptor,
55
+ )
56
+
57
+
58
+ @pytest.mark.unit
59
+ def test_undo_list_returns_recorded_tokens(gov_home):
60
+ uid = _record()
61
+ out = gov.undo_list()
62
+ assert out["count"] == 1
63
+ assert out["undos"][0]["undoId"] == uid
64
+ assert out["undos"][0]["inverseTool"] == "_undo_probe"
65
+
66
+
67
+ @pytest.mark.unit
68
+ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
69
+ uid = _record(params={"value": "restore-me"})
70
+ result = gov.undo_apply(undo_id=uid)
71
+ assert result["applied"] is True
72
+ assert result["inverseTool"] == "_undo_probe"
73
+ # the inverse governed tool actually ran with the recorded params
74
+ assert _CALLS == [{"value": "restore-me"}]
75
+ # the token is consumed (single-use)
76
+ assert undo_mod.get_undo_store().get(uid)["status"] == "applied"
77
+ assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
78
+
79
+
80
+ @pytest.mark.unit
81
+ def test_undo_apply_dry_run_previews_without_running(gov_home):
82
+ uid = _record()
83
+ out = gov.undo_apply(undo_id=uid, dry_run=True)
84
+ assert out["dryRun"] is True
85
+ assert out["wouldApply"]["tool"] == "_undo_probe"
86
+ assert _CALLS == []
87
+ assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
88
+
89
+
90
+ @pytest.mark.unit
91
+ def test_undo_apply_is_single_use(gov_home):
92
+ uid = _record()
93
+ gov.undo_apply(undo_id=uid)
94
+ second = gov.undo_apply(undo_id=uid)
95
+ assert "already 'applied'" in second["error"]
96
+
97
+
98
+ @pytest.mark.unit
99
+ def test_undo_apply_unknown_id_errors(gov_home):
100
+ out = gov.undo_apply(undo_id="deadbeef")
101
+ assert "Unknown undo id" in out["error"]
102
+
103
+
104
+ @pytest.mark.unit
105
+ def test_undo_apply_unregistered_inverse_errors(gov_home):
106
+ uid = _record(undo_tool="no_such_tool_xyz")
107
+ out = gov.undo_apply(undo_id=uid)
108
+ assert "not registered" in out["error"]
109
+ assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
110
+
111
+
112
+ @pytest.mark.unit
113
+ def test_cli_undo_apply_dry_run_renders(gov_home):
114
+ """The `undo apply --dry-run` CLI path must render without error — guards
115
+ against dry_run_print signature drift across tools (api_call vs detail)."""
116
+ from typer.testing import CliRunner
117
+
118
+ from postgres_aiops.cli import app
119
+
120
+ uid = _record()
121
+ result = CliRunner().invoke(app, ["undo", "apply", uid, "--dry-run"])
122
+ assert result.exit_code == 0, result.output
123
+ assert "DRY-RUN" in result.output
124
+ assert _CALLS == []
125
+ assert undo_mod.get_undo_store().get(uid)["status"] == "recorded"
126
+
127
+
128
+ @pytest.mark.unit
129
+ def test_undo_apply_audits_both_wrapper_and_inverse(gov_home):
130
+ uid = _record()
131
+ gov.undo_apply(undo_id=uid)
132
+ conn = sqlite3.connect(gov_home / "audit.db")
133
+ try:
134
+ tools = [r[0] for r in conn.execute("SELECT tool FROM audit_log ORDER BY id")]
135
+ finally:
136
+ conn.close()
137
+ assert "undo_apply" in tools
138
+ assert "_undo_probe" in tools
@@ -484,7 +484,7 @@ wheels = [
484
484
 
485
485
  [[package]]
486
486
  name = "postgres-aiops"
487
- version = "0.2.1"
487
+ version = "0.3.0"
488
488
  source = { editable = "." }
489
489
  dependencies = [
490
490
  { name = "cryptography" },
Binary file
@@ -1,23 +0,0 @@
1
- name: mcp-publish
2
-
3
- on:
4
- workflow_dispatch:
5
- release:
6
- types: [published]
7
-
8
- permissions:
9
- id-token: write
10
- contents: read
11
-
12
- jobs:
13
- publish-mcp:
14
- runs-on: ubuntu-latest
15
- steps:
16
- - uses: actions/checkout@v4
17
- - name: Install mcp-publisher
18
- run: |
19
- curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
20
- - name: Login to MCP Registry (GitHub OIDC)
21
- run: ./mcp-publisher login github-oidc
22
- - name: Publish server.json
23
- run: ./mcp-publisher publish
File without changes
File without changes