pythia-plsql 0.13.0__tar.gz → 0.14.1__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 (46) hide show
  1. {pythia_plsql-0.13.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.14.1}/PKG-INFO +1 -1
  2. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/pyproject.toml +1 -1
  3. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia.py +234 -2
  4. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1/scripts/pythia_plsql.egg-info}/PKG-INFO +1 -1
  5. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
  6. pythia_plsql-0.14.1/tests/test_mcp.py +296 -0
  7. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/LICENSE +0 -0
  8. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/README.md +0 -0
  9. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/compile-errors.sql +0 -0
  10. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/current-schema.sql +0 -0
  11. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/dependencies.sql +0 -0
  12. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/impact.sql +0 -0
  13. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/invalid-objects.sql +0 -0
  14. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/name-occupants.sql +0 -0
  15. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/object-names.sql +0 -0
  16. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/object-source.sql +0 -0
  17. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/plscope-enabled.sql +0 -0
  18. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/plscope-statements.sql +0 -0
  19. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/plscope-usages.sql +0 -0
  20. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/session-privileges.sql +0 -0
  21. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/similar-candidates.sql +0 -0
  22. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/queries/source.sql +0 -0
  23. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  24. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  25. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  26. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  27. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/setup.cfg +0 -0
  28. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-apply/SKILL.md +0 -0
  29. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-comment/SKILL.md +0 -0
  30. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-conventions/SKILL.md +0 -0
  31. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-explore/SKILL.md +0 -0
  32. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  33. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-impact/SKILL.md +0 -0
  34. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-review/SKILL.md +0 -0
  35. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-review/reference/antipatterns.md +0 -0
  36. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-setup/SKILL.md +0 -0
  37. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-skill-author/SKILL.md +0 -0
  38. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-spec/SKILL.md +0 -0
  39. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-write/SKILL.md +0 -0
  40. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/pythia-write/reference/patterns.md +0 -0
  41. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/skills/using-pythia/SKILL.md +0 -0
  42. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/tests/test_install.py +0 -0
  43. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/tests/test_phase1.py +0 -0
  44. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/tests/test_phase2.py +0 -0
  45. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/tests/test_phase3.py +0 -0
  46. {pythia_plsql-0.13.0 → pythia_plsql-0.14.1}/tests/test_phase5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.13.0
3
+ Version: 0.14.1
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.13.0"
7
+ version = "0.14.1"
8
8
  description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2921,6 +2921,10 @@ apply -> verify -> report.
2921
2921
  AND to the developer's approval
2922
2922
  journal restore undo, through the same six steps and the same gate
2923
2923
  unistr exact non-ASCII literals for what you are writing
2924
+ mcp Codex only: a write-free MCP approver. The agent
2925
+ calls its pythia_approve tool, Codex shows the card
2926
+ as a select prompt, and the developer's Approve mints
2927
+ the grant. It never writes; apply still verifies it.
2924
2928
  install · agent-user · guide setting the harness itself up
2925
2929
 
2926
2930
  The CLI enforces the gates: headless --yes is refused, policy cannot be
@@ -3392,6 +3396,205 @@ def merge_codex_hooks(path, events=None):
3392
3396
  return path, added
3393
3397
 
3394
3398
 
3399
+ # The MCP approver: a write-free stdio server whose one tool, pythia_approve,
3400
+ # mints the same grant `approve` mints — gated by an MCP elicitation the
3401
+ # developer answers in Codex. Writes never go through here; apply still
3402
+ # verifies the grant. Hand-rolled JSON-RPC (stdlib only).
3403
+ MCP_TOOL = "pythia_approve"
3404
+ MCP_PROTOCOL = "2025-06-18"
3405
+ MCP_ELICIT_METHOD = "elicitation/create" # the one spelling to adjust if a
3406
+ # live Codex disagrees (isolated)
3407
+ _MCP_TOOL_DEF = {
3408
+ "name": MCP_TOOL,
3409
+ "description": ("Ask the developer to approve a pythia apply preview by its "
3410
+ "token. Shows pythia's approval card and mints the one-time "
3411
+ "grant only if the developer picks Approve. Call it after "
3412
+ "`pythia apply <file>` prints a token; then run "
3413
+ "`pythia apply <file> --confirm <token>`."),
3414
+ "inputSchema": {"type": "object",
3415
+ "properties": {"token": {"type": "string"}},
3416
+ "required": ["token"]},
3417
+ }
3418
+
3419
+
3420
+ def _rpc_result(mid, result):
3421
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
3422
+
3423
+
3424
+ def _rpc_error(mid, code, message):
3425
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
3426
+
3427
+
3428
+ def mcp_handle(msg, state):
3429
+ """Dispatch one incoming JSON-RPC message that is NOT a tools/call (those
3430
+ need the elicitation round-trip and are handled in the loop). Returns
3431
+ (outgoing_messages, done). A notification (no id) is answered with []."""
3432
+ method = msg.get("method")
3433
+ mid = msg.get("id")
3434
+ if method == "initialize":
3435
+ proto = (msg.get("params") or {}).get("protocolVersion") or MCP_PROTOCOL
3436
+ return [_rpc_result(mid, {
3437
+ "protocolVersion": proto,
3438
+ "capabilities": {"tools": {}},
3439
+ "serverInfo": {"name": "pythia",
3440
+ "version": getattr(sys.modules[__name__],
3441
+ "__version__", "0")}})], False
3442
+ if method == "notifications/initialized":
3443
+ return [], False
3444
+ if method == "tools/list":
3445
+ return [_rpc_result(mid, {"tools": [_MCP_TOOL_DEF]})], False
3446
+ if mid is None:
3447
+ return [], False # any other notification
3448
+ return [_rpc_error(mid, -32601, f"method not found: {method}")], False
3449
+
3450
+
3451
+ def _mcp_text(text, is_error=False):
3452
+ return {"content": [{"type": "text", "text": text}], "isError": is_error}
3453
+
3454
+
3455
+ def mcp_approve_decision(root, token, elicit):
3456
+ """Mint on a developer's Approve, delivered through an MCP elicitation.
3457
+
3458
+ Transport-free: `elicit(message, schema) -> (action, content)` performs the
3459
+ round-trip (a fake in tests). The card is pythia's own, built from the
3460
+ journal — so there is nothing for the agent to paraphrase and no verbatim
3461
+ check to run. Fail-closed everywhere: the only path that mints is an
3462
+ explicit accept whose decision is exactly Approve."""
3463
+ token = str(token or "").strip().lower()
3464
+ try:
3465
+ _, meta, body = approval_card(root, token) # refuses unknown/applied
3466
+ except SystemExit as e:
3467
+ return _mcp_text(str(e), is_error=True)
3468
+ message = f"{invocation()} approve {token}\n" + "\n".join(body)
3469
+ schema = {"type": "object",
3470
+ "properties": {"decision": {"type": "string",
3471
+ "enum": ["Approve", "Reject"]}},
3472
+ "required": ["decision"]}
3473
+ try:
3474
+ action, content = elicit(message, schema)
3475
+ except Exception as e: # noqa: BLE001 — fail closed
3476
+ return _mcp_text(f"No grant minted: the approval prompt could not be "
3477
+ f"completed ({e}). Approve in a terminal instead: "
3478
+ f"{invocation()} approve {token}", is_error=True)
3479
+ if action != "accept" or (content or {}).get("decision") != "Approve":
3480
+ return _mcp_text(f"No grant minted for {token}: the developer did not "
3481
+ "approve (answered "
3482
+ f"{(content or {}).get('decision') or action!r}). "
3483
+ "Do not apply; ask what should change.", is_error=True)
3484
+ prune_expired_grants(root)
3485
+ mint_grant(root, token, meta.get("connection") or "",
3486
+ revalidate=fingerprint_text(meta.get("row_set")), approver="mcp")
3487
+ return _mcp_text(f"Approved — grant minted for {token} (single use, "
3488
+ f"{GRANT_TTL_MINUTES} min). Now run apply --confirm on the "
3489
+ "file you previewed YOURSELF — do not hand the developer a "
3490
+ f"command to type: {invocation()} apply <file> --confirm "
3491
+ f"{token}")
3492
+
3493
+
3494
+ def mcp_serve(reader, writer, root):
3495
+ """The stdio loop. Newline-delimited JSON-RPC in and out. A tools/call for
3496
+ pythia_approve runs the elicitation round-trip inline: send
3497
+ elicitation/create, then read until the matching-id response. One call in
3498
+ flight at a time (Codex calls tools serially in a turn)."""
3499
+ counter = [1000]
3500
+
3501
+ def send(msg):
3502
+ writer.write(json.dumps(msg) + "\n")
3503
+ writer.flush()
3504
+
3505
+ def read_msg():
3506
+ line = reader.readline()
3507
+ if not line:
3508
+ return None
3509
+ try:
3510
+ return json.loads(line)
3511
+ except ValueError:
3512
+ return {} # skip a malformed line
3513
+
3514
+ def elicit(message, schema):
3515
+ counter[0] += 1
3516
+ eid = counter[0]
3517
+ send({"jsonrpc": "2.0", "id": eid, "method": MCP_ELICIT_METHOD,
3518
+ "params": {"message": message, "requestedSchema": schema}})
3519
+ while True:
3520
+ m = read_msg()
3521
+ if m is None:
3522
+ raise RuntimeError("client closed before answering the elicitation")
3523
+ if m.get("id") == eid and ("result" in m or "error" in m):
3524
+ if "error" in m:
3525
+ raise RuntimeError(str(m["error"]))
3526
+ r = m["result"] or {}
3527
+ return r.get("action"), r.get("content") or {}
3528
+ # ignore anything else that arrives mid-elicitation
3529
+
3530
+ while True:
3531
+ msg = read_msg()
3532
+ if msg is None:
3533
+ return
3534
+ if msg.get("method") == "tools/call":
3535
+ mid = msg.get("id")
3536
+ params = msg.get("params") or {}
3537
+ if params.get("name") != MCP_TOOL:
3538
+ send(_rpc_error(mid, -32601, f"no such tool: {params.get('name')}"))
3539
+ continue
3540
+ token = (params.get("arguments") or {}).get("token", "")
3541
+ result = mcp_approve_decision(root, token, elicit)
3542
+ send(_rpc_result(mid, result))
3543
+ continue
3544
+ out, _ = mcp_handle(msg, {})
3545
+ for m in out:
3546
+ send(m)
3547
+
3548
+
3549
+ def cmd_mcp(conn, schema, ns):
3550
+ """Run the MCP approver on stdio. Launched by Codex per its
3551
+ [mcp_servers.pythia] config; lives for the session. Touches no database."""
3552
+ mcp_serve(sys.stdin, sys.stdout, ns.project_root)
3553
+
3554
+
3555
+ # Codex config is TOML, and the stdlib cannot parse or write it on 3.9
3556
+ # (tomllib is read-only, 3.11+). So these merges never parse TOML: they append
3557
+ # a marker-delimited block when the target tables are absent, and refuse
3558
+ # (writing nothing) when a table already exists — the installer then prints
3559
+ # what to add. ponytail: literal-text collision check, not a TOML engine; a
3560
+ # table header inside a string/comment is not worth a parser here.
3561
+ CODEX_MCP_BEGIN = "# pythia:begin (managed by `pythia install`)"
3562
+ CODEX_MCP_END = "# pythia:end"
3563
+ # Only the server roster — nothing else. An earlier attempt also wrote an
3564
+ # [approval_policy.granular] table, but that table requires several fields
3565
+ # (sandbox_approval, rules, ...) and an incomplete one broke Codex v0.154 at
3566
+ # config-load time. Elicitations surface under the default interactive
3567
+ # approval policy without any of that, and the developer's approval policy is
3568
+ # theirs, not ours to set.
3569
+ CODEX_MCP_BLOCK = (f"{CODEX_MCP_BEGIN}\n"
3570
+ "[mcp_servers.pythia]\n"
3571
+ 'command = "python"\n'
3572
+ 'args = ["-m", "pythia", "mcp"]\n'
3573
+ f"{CODEX_MCP_END}")
3574
+
3575
+
3576
+ def merge_codex_mcp_config(path):
3577
+ """Append the MCP server registration to <project>/.codex/config.toml.
3578
+ Returns (path, action): 'created' | 'appended' | 'present' | 'conflict'.
3579
+ Refuses (writes nothing) if [mcp_servers.pythia] already exists without our
3580
+ marker — the stdlib cannot safely merge TOML, so a collision is reported,
3581
+ never overwritten."""
3582
+ path = pathlib.Path(path)
3583
+ old = path.read_text(encoding="utf-8") if path.is_file() else None
3584
+ if old is not None and CODEX_MCP_BEGIN in old:
3585
+ return path, "present"
3586
+ if old is not None and "[mcp_servers.pythia]" in old:
3587
+ return path, "conflict"
3588
+ if old is None:
3589
+ new, action = CODEX_MCP_BLOCK + "\n", "created"
3590
+ else:
3591
+ tail = "" if old.endswith("\n\n") else "\n" if old.endswith("\n") else "\n\n"
3592
+ new, action = old + tail + CODEX_MCP_BLOCK + "\n", "appended"
3593
+ path.parent.mkdir(parents=True, exist_ok=True)
3594
+ path.write_text(new, encoding="utf-8")
3595
+ return path, action
3596
+
3597
+
3395
3598
  def install_claude_hooks(base_dir, events=None):
3396
3599
  """Merge the two hooks and the deny rule into <base>/.claude/settings.json
3397
3600
  — the project's, or the home directory's for -g. The file is Claude
@@ -3473,6 +3676,31 @@ def report_codex_hooks(project_root, ns):
3473
3676
  print(f"\nCodex session-start hook already in {path}.")
3474
3677
 
3475
3678
 
3679
+ def report_codex_mcp(project_root, ns):
3680
+ """Project scope: register the write-free MCP approver in Codex's config.
3681
+ --no-hooks skips it, as with the other Codex wiring. Needs Codex >= v0.120
3682
+ for elicitation; older Codex ignores the server and the console approve
3683
+ path still works."""
3684
+ if getattr(ns, "no_hooks", False):
3685
+ return
3686
+ cfg_path, cfg = merge_codex_mcp_config(pathlib.Path(project_root)
3687
+ / ".codex" / "config.toml")
3688
+ if cfg in ("created", "appended"):
3689
+ print(f"\nRegistered the pythia MCP approver in {cfg_path} "
3690
+ "(Codex >= v0.120; run /mcp to trust it).")
3691
+ elif cfg == "conflict":
3692
+ print(f"\n! {cfg_path} already defines [mcp_servers.pythia]; add this "
3693
+ "yourself:\n" + CODEX_MCP_BLOCK)
3694
+ else:
3695
+ print(f"\npythia MCP approver already registered in {cfg_path}.")
3696
+ # The one bypass of the elicitation is Codex auto-approving it under the
3697
+ # danger-full-access sandbox. pythia does not write requirements.toml to
3698
+ # forbid that — it is enterprise-managed config, and the schema is
3699
+ # version-fragile — so it warns instead.
3700
+ print("Do not run Codex under `danger-full-access` with pythia: that "
3701
+ "sandbox auto-approves elicitations, bypassing your Approve.")
3702
+
3703
+
3476
3704
  def cmd_install(conn, schema, ns):
3477
3705
  import shutil
3478
3706
  en = getattr(ns, "color", False)
@@ -3516,6 +3744,7 @@ def cmd_install(conn, schema, ns):
3516
3744
  report_claude_hooks(ns.project_root, ns)
3517
3745
  report_codex_agents_md(ns.project_root)
3518
3746
  report_codex_hooks(ns.project_root, ns)
3747
+ report_codex_mcp(ns.project_root, ns)
3519
3748
  print(f"\nNext: fill in {path}")
3520
3749
  print(f"Then: {invocation()} check")
3521
3750
  scripts_dir = installed_scripts_dir()
@@ -3536,10 +3765,10 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
3536
3765
  "approve": cmd_approve,
3537
3766
  "conventions": cmd_conventions, "guide": cmd_guide, "connections": cmd_connections, "install": cmd_install,
3538
3767
  "unistr": cmd_unistr, "agent-user": cmd_agent_user,
3539
- "history": cmd_history}
3768
+ "history": cmd_history, "mcp": cmd_mcp}
3540
3769
 
3541
3770
  NO_DB_COMMANDS = {"policy", "journal", "install", "unistr", "guide", "connections",
3542
- "history", "approve"}
3771
+ "history", "approve", "mcp"}
3543
3772
 
3544
3773
 
3545
3774
  # --- CLI ---------------------------------------------------------------------
@@ -3683,6 +3912,9 @@ def build_parser():
3683
3912
  s.add_argument("text", nargs="*", help="text; omit to read stdin")
3684
3913
  s.add_argument("--loi", action="store_true",
3685
3914
  help="wrap as 'loi:'||unistr(...)||':loi'")
3915
+ sub.add_parser("mcp", parents=[common()],
3916
+ help="run the MCP approver for Codex (stdio; Codex launches "
3917
+ "it — the developer approves via an elicitation)")
3686
3918
  s = sub.add_parser("install", parents=[common()],
3687
3919
  help="install the skill pack, scaffold .pythia/ config, wire the Claude Code hooks")
3688
3920
  s.add_argument("-g", "--global", dest="glob", action="store_true",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.13.0
3
+ Version: 0.14.1
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -37,6 +37,7 @@ skills/pythia-write/SKILL.md
37
37
  skills/pythia-write/reference/patterns.md
38
38
  skills/using-pythia/SKILL.md
39
39
  tests/test_install.py
40
+ tests/test_mcp.py
40
41
  tests/test_phase1.py
41
42
  tests/test_phase2.py
42
43
  tests/test_phase3.py
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """MCP approver self-checks — no database, no network, no live Codex.
3
+
4
+ Run: python tests/test_mcp.py
5
+ """
6
+ import io
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import sys
11
+ import tempfile
12
+
13
+ os.environ.setdefault("PYTHIA_CI", "1")
14
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "scripts"))
15
+ import pythia # noqa: E402
16
+
17
+
18
+ # --- Task 1: transport handshake + tools/list -------------------------------
19
+
20
+ def test_initialize_echoes_protocol_and_declares_tools():
21
+ out, done = pythia.mcp_handle(
22
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
23
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {},
24
+ "clientInfo": {"name": "codex", "version": "0.120.0"}}},
25
+ {})
26
+ assert done is False
27
+ assert len(out) == 1
28
+ r = out[0]
29
+ assert r["id"] == 1 and r["jsonrpc"] == "2.0"
30
+ assert r["result"]["protocolVersion"] == "2025-06-18" # echo client's
31
+ assert "tools" in r["result"]["capabilities"]
32
+ assert r["result"]["serverInfo"]["name"] == "pythia"
33
+
34
+
35
+ def test_initialized_notification_is_silent():
36
+ out, done = pythia.mcp_handle(
37
+ {"jsonrpc": "2.0", "method": "notifications/initialized"}, {})
38
+ assert out == [] and done is False
39
+
40
+
41
+ def test_tools_list_returns_only_pythia_approve():
42
+ out, _ = pythia.mcp_handle(
43
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, {})
44
+ tools = out[0]["result"]["tools"]
45
+ assert [t["name"] for t in tools] == ["pythia_approve"]
46
+ schema = tools[0]["inputSchema"]
47
+ assert schema["properties"]["token"]["type"] == "string"
48
+ assert schema["required"] == ["token"]
49
+
50
+
51
+ def test_unknown_method_with_id_is_an_error_not_a_crash():
52
+ out, _ = pythia.mcp_handle(
53
+ {"jsonrpc": "2.0", "id": 3, "method": "resources/list"}, {})
54
+ assert out[0]["error"]["code"] == -32601 # method not found
55
+ assert out[0]["id"] == 3
56
+
57
+
58
+ # --- Task 2: the elicitation round-trip and the mint (fail-closed) ----------
59
+
60
+ def _preview(root, token="a1b2c3"):
61
+ """A pending plsql_source preview in the journal, the way apply writes one."""
62
+ pythia.write_journal_entry(
63
+ root, "PACKAGE BODY", "PKG_ORDER", "old\n",
64
+ "CREATE OR REPLACE PACKAGE BODY pkg_order AS\n new;\nEND;\n",
65
+ {"token": token, "connection": "DEV", "schema": "APP",
66
+ "group": "plsql_source", "applied": False})
67
+ return token
68
+
69
+
70
+ def test_mint_only_on_accept_with_approve():
71
+ with tempfile.TemporaryDirectory() as td:
72
+ token = _preview(td)
73
+ seen = {}
74
+
75
+ def elicit(message, schema):
76
+ seen["message"] = message
77
+ seen["schema"] = schema
78
+ return "accept", {"decision": "Approve"}
79
+
80
+ res = pythia.mcp_approve_decision(td, token, elicit)
81
+ assert res["isError"] is False
82
+ text = res["content"][0]["text"]
83
+ assert token in text
84
+ # the message tells the AGENT to continue itself, not the developer to
85
+ # type a command — 0.14.1 fix
86
+ assert "yourself" in text.lower()
87
+ assert "PKG_ORDER" in seen["message"] # the card was shown
88
+ assert seen["schema"]["properties"]["decision"]["enum"] == ["Approve", "Reject"]
89
+ g = pythia.read_grant(td, token)
90
+ assert g and g["approver"] == "mcp" and g["used_at"] is None
91
+
92
+
93
+ def test_no_mint_on_reject_decline_or_cancel():
94
+ for action, content in (("accept", {"decision": "Reject"}),
95
+ ("decline", {}), ("cancel", {})):
96
+ with tempfile.TemporaryDirectory() as td:
97
+ token = _preview(td)
98
+ res = pythia.mcp_approve_decision(td, token, lambda m, s: (action, content))
99
+ assert res["isError"] is True
100
+ assert pythia.read_grant(td, token) is None # nothing minted
101
+
102
+
103
+ def test_no_mint_for_unknown_token_and_elicit_is_not_even_called():
104
+ with tempfile.TemporaryDirectory() as td:
105
+ called = []
106
+ res = pythia.mcp_approve_decision(
107
+ td, "nope99",
108
+ lambda m, s: called.append(1) or ("accept", {"decision": "Approve"}))
109
+ assert res["isError"] is True
110
+ assert called == [] # refused before asking
111
+ assert pythia.read_grant(td, "nope99") is None
112
+
113
+
114
+ def test_elicit_error_fails_closed():
115
+ with tempfile.TemporaryDirectory() as td:
116
+ token = _preview(td)
117
+
118
+ def boom(message, schema):
119
+ raise RuntimeError("transport died")
120
+
121
+ res = pythia.mcp_approve_decision(td, token, boom)
122
+ assert res["isError"] is True
123
+ assert pythia.read_grant(td, token) is None
124
+
125
+
126
+ # --- Task 3: the server loop, the mcp subcommand, and the guide -------------
127
+
128
+ class ScriptedClient:
129
+ """Feeds the server initialize -> tools/call, then answers the server's
130
+ elicitation/create with a fixed (action, decision). Acts as both reader
131
+ and writer for mcp_serve."""
132
+ def __init__(self, token, action="accept", decision="Approve"):
133
+ self.out = io.StringIO()
134
+ self.answered = False
135
+ self.action, self.decision = action, decision
136
+ self._pending = [
137
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
138
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {}}},
139
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
140
+ "params": {"name": "pythia_approve", "arguments": {"token": token}}},
141
+ ]
142
+
143
+ def readline(self):
144
+ if self._pending:
145
+ return json.dumps(self._pending.pop(0)) + "\n"
146
+ if not self.answered:
147
+ for line in reversed(self.out.getvalue().splitlines()):
148
+ m = json.loads(line)
149
+ if m.get("method") == pythia.MCP_ELICIT_METHOD:
150
+ self.answered = True
151
+ return json.dumps({"jsonrpc": "2.0", "id": m["id"],
152
+ "result": {"action": self.action,
153
+ "content": {"decision": self.decision}}}) + "\n"
154
+ return "" # EOF -> server loop ends
155
+
156
+ def write(self, s):
157
+ self.out.write(s)
158
+
159
+ def flush(self):
160
+ pass
161
+
162
+ def messages(self):
163
+ return [json.loads(l) for l in self.out.getvalue().splitlines() if l.strip()]
164
+
165
+
166
+ def test_serve_end_to_end_mints_on_scripted_approve():
167
+ with tempfile.TemporaryDirectory() as td:
168
+ token = _preview(td)
169
+ client = ScriptedClient(token)
170
+ pythia.mcp_serve(client, client, td)
171
+ msgs = client.messages()
172
+ assert any(m.get("result", {}).get("serverInfo", {}).get("name") == "pythia"
173
+ for m in msgs)
174
+ assert any(m.get("method") == pythia.MCP_ELICIT_METHOD for m in msgs)
175
+ call_result = [m for m in msgs if m.get("id") == 2 and "result" in m][-1]
176
+ assert call_result["result"]["isError"] is False
177
+ assert pythia.read_grant(td, token)["approver"] == "mcp"
178
+
179
+
180
+ def test_serve_fails_closed_on_scripted_decline():
181
+ with tempfile.TemporaryDirectory() as td:
182
+ token = _preview(td)
183
+ client = ScriptedClient(token, action="decline", decision="")
184
+ pythia.mcp_serve(client, client, td)
185
+ call_result = [m for m in client.messages()
186
+ if m.get("id") == 2 and "result" in m][-1]
187
+ assert call_result["result"]["isError"] is True
188
+ assert pythia.read_grant(td, token) is None
189
+
190
+
191
+ def test_mcp_is_a_no_db_command_in_the_guide():
192
+ assert "mcp" in pythia.COMMANDS
193
+ assert "mcp" in pythia.NO_DB_COMMANDS
194
+ assert "mcp" in pythia.OPERATING_GUIDE
195
+
196
+
197
+ # --- Task 4: config.toml and requirements.toml merges (TOML-safe) -----------
198
+
199
+ def test_merge_codex_mcp_config_appends_once_when_clean():
200
+ with tempfile.TemporaryDirectory() as td:
201
+ path = pathlib.Path(td) / ".codex" / "config.toml"
202
+ p, action = pythia.merge_codex_mcp_config(path)
203
+ assert p == path and action == "created"
204
+ body = path.read_text(encoding="utf-8")
205
+ assert "[mcp_servers.pythia]" in body
206
+ assert 'args = ["-m", "pythia", "mcp"]' in body
207
+ # 0.14.1: we do NOT touch approval_policy — an incomplete granular table
208
+ # broke Codex v0.154 config loading, and elicitations work under the
209
+ # default interactive policy anyway.
210
+ assert "approval_policy" not in body
211
+ assert pythia.merge_codex_mcp_config(path)[1] == "present" # idempotent
212
+
213
+
214
+ def test_merge_codex_mcp_config_preserves_other_content():
215
+ with tempfile.TemporaryDirectory() as td:
216
+ path = pathlib.Path(td) / ".codex" / "config.toml"
217
+ path.parent.mkdir(parents=True)
218
+ path.write_text('model = "gpt-5"\n', encoding="utf-8")
219
+ _, action = pythia.merge_codex_mcp_config(path)
220
+ assert action == "appended"
221
+ body = path.read_text(encoding="utf-8")
222
+ assert body.startswith('model = "gpt-5"\n') # kept
223
+ assert "[mcp_servers.pythia]" in body
224
+
225
+
226
+ def test_merge_codex_mcp_config_refuses_on_server_collision():
227
+ with tempfile.TemporaryDirectory() as td:
228
+ path = pathlib.Path(td) / ".codex" / "config.toml"
229
+ path.parent.mkdir(parents=True)
230
+ original = '[mcp_servers.pythia]\ncommand = "other"\n'
231
+ path.write_text(original, encoding="utf-8")
232
+ p, action = pythia.merge_codex_mcp_config(path)
233
+ assert action == "conflict"
234
+ assert path.read_text(encoding="utf-8") == original # untouched
235
+
236
+
237
+ # --- Task 5: wire the MCP install into cmd_install (project scope) ----------
238
+
239
+ def test_cmd_install_registers_the_mcp_server_and_warns_off_danger():
240
+ import argparse
241
+ import contextlib
242
+ old = os.environ.get("PATH")
243
+ os.environ["PATH"] = ""
244
+ try:
245
+ with tempfile.TemporaryDirectory() as td:
246
+ ns = argparse.Namespace(project_root=td, glob=False, source=None,
247
+ color=False, json=False, no_hooks=False)
248
+ buf = io.StringIO()
249
+ with contextlib.redirect_stdout(buf):
250
+ pythia.cmd_install(None, None, ns)
251
+ cfg = pathlib.Path(td) / ".codex" / "config.toml"
252
+ body = cfg.read_text(encoding="utf-8")
253
+ assert "[mcp_servers.pythia]" in body
254
+ assert "approval_policy" not in body # 0.14.1: never touched
255
+ # requirements.toml is no longer written; the pin became a warning
256
+ assert not (pathlib.Path(td) / ".codex" / "requirements.toml").exists()
257
+ assert "danger-full-access" in buf.getvalue() # the warning
258
+ finally:
259
+ if old is not None:
260
+ os.environ["PATH"] = old
261
+
262
+
263
+ def test_cmd_install_no_hooks_skips_the_mcp_files():
264
+ import argparse
265
+ import contextlib
266
+ old = os.environ.get("PATH")
267
+ os.environ["PATH"] = ""
268
+ try:
269
+ with tempfile.TemporaryDirectory() as td:
270
+ ns = argparse.Namespace(project_root=td, glob=False, source=None,
271
+ color=False, json=False, no_hooks=True)
272
+ with contextlib.redirect_stdout(io.StringIO()):
273
+ pythia.cmd_install(None, None, ns)
274
+ assert not (pathlib.Path(td) / ".codex" / "config.toml").exists()
275
+ finally:
276
+ if old is not None:
277
+ os.environ["PATH"] = old
278
+
279
+
280
+ def main():
281
+ failed = 0
282
+ for name, fn in sorted(globals().items()):
283
+ if name.startswith("test_") and callable(fn):
284
+ try:
285
+ fn()
286
+ print(f"PASS {name}")
287
+ except (Exception, SystemExit) as e: # noqa: BLE001 — keep going
288
+ failed += 1
289
+ print(f"FAIL {name}: {e!r}")
290
+ if failed:
291
+ sys.exit(f"{failed} test(s) failed")
292
+ print("OK")
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()
File without changes
File without changes
File without changes