diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index 69b1aa4..0c1453c 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -18,6 +18,17 @@ behavior they rely on already exists (canonical runtime profiles, the interactive setup menu, identity/eligibility checks, gated review/merge, and audit logging). See [Related documents](#related-documents). +> **New session? Call the guide tools first (#128).** Before using any other +> Gitea MCP tool in a fresh session, call `mcp_get_control_plane_guide` +> (read-only): it reports the active profile, authenticated identity, +> allowed/forbidden operations, profile-aware do/don't guidance, and the +> non-negotiable rules (hard stops, fail-closed behavior, head-SHA pinning, +> merge confirmation, redaction, author/reviewer separation, profile +> switching). Then call `mcp_list_project_skills` to discover the available +> project workflows and `mcp_get_skill_guide()` for step-by-step +> instructions. This replaces long pasted operator prompts for the standard +> rules; operator prompts still control task-specific scope. + For cross-project use, copy the portable workflow skill at [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md). It extracts the issue-first, isolated-worktree, no-self-review, profile-safety, diff --git a/mcp_server.py b/mcp_server.py index e44b00f..45ba28b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -1503,6 +1503,444 @@ def gitea_create_issue_comment( return result +# ── Operator guide / project skills (#128) ─────────────────────────────────── +# Read-only capability discovery for new LLM sessions: what the active +# profile may do, how identity is verified, the non-negotiable safety rules, +# and which project workflows ("skills") exist. Nothing here mutates state or +# widens any permission — the guide describes the gates, it never bypasses +# them. + +def _role_kind(allowed, forbidden) -> str: + """Classify the active profile from its normalized operations. + + 'author' can create PRs / push branches; 'reviewer' can approve/merge; + 'mixed' can do both (a config smell — the reviewer-deadlock invariant + forbids it in v2 configs); 'limited' can do neither. + """ + def can(op): + return gitea_config.check_operation(op, allowed, forbidden)[0] + review = can("gitea.pr.approve") or can("gitea.pr.merge") + author = can("gitea.pr.create") or can("gitea.branch.push") + if review and author: + return "mixed" + if review: + return "reviewer" + if author: + return "author" + return "limited" + + +_HARD_STOPS = [ + "No self-review and no self-merge: the authenticated user must differ " + "from the PR author for review/approve/merge.", + "No tags or releases without an explicit operator instruction.", + "Never print token values, keychain IDs, passwords, or raw service " + "URLs in normal output.", + "No production access or production mutations.", + "No force-merge and no bypassing of merge gates; Gitea's own " + "mergeable signal is honoured, never overridden.", + "Do not rewrite the live profiles config; config changes are " + "operator-owned.", +] + +_GUIDE_RULES = { + "hard_stops": _HARD_STOPS, + "fail_closed": ( + "Every gate fails closed: unknown/disabled profiles, unknown " + "operations, empty allowed-operation lists, unresolved identity, " + "and unparseable config all deny the action instead of guessing. " + "Disabled profiles/services are never silently substituted."), + "head_sha_pinning": ( + "Before reviewing or merging, record the PR head SHA and pass it as " + "expected_head_sha; if the head moves between review and merge, the " + "tool refuses and the review must be redone."), + "merge_confirmation": ( + "gitea_merge_pr requires confirmation to equal 'MERGE PR ' " + "exactly (e.g. 'MERGE PR 42'); without it no API call is made. " + "Merge only when the operator has explicitly authorized it."), + "redaction": [ + "Normal output contains no endpoint URLs, keychain IDs, or token " + "values; auth is reported as type/status only.", + "GITEA_MCP_REVEAL_ENDPOINTS=1 is the local admin/debug opt-in for " + "web links and endpoint names; it never reveals token values.", + "Errors are redacted before being surfaced.", + ], + "separation": ( + "Author, reviewer, and merger are separate identities. An author " + "profile creates branches/commits/PRs and must never " + "review/approve/merge its own work; a reviewer/merger profile " + "reviews and merges and must never create PRs or push branches " + "(reviewer-deadlock invariant). Issue discussion comments " + "(gitea.issue.comment) are gated separately from PR reviews " + "(gitea.pr.*)."), + "profile_switching": [ + "The active profile comes from GITEA_MCP_PROFILE (with " + "GITEA_MCP_CONFIG pointing at the profiles config).", + "Switching profiles requires changing the launcher environment and " + "reconnecting the MCP server (operator action, e.g. /mcp); the " + "server process cannot switch identities in place.", + "After any switch, verify with gitea_whoami before doing anything " + "else.", + ], + "identity_verification": [ + "Call gitea_whoami with an explicit remote first; confirm the " + "authenticated username and profile match the role you were asked " + "to perform.", + "If the username, profile, or allowed operations do not match the " + "task, STOP and report instead of proceeding.", + "Runtime identity (whoami) is the source of truth; config-declared " + "roles are metadata only.", + ], +} + +_COMMON_WORKFLOWS = [ + "issue authoring: verify identity, create/claim the issue, keep scope " + "explicit (remote/org/repo).", + "implementation: claim issue, branch from fresh master, implement only " + "the issue scope, test, open a PR referencing the issue, stop.", + "PR review: verify reviewer identity, pin the head SHA, validate " + "independently, post a verdict; never review your own work.", + "PR merge: reviewer identity + eligibility check + pinned head + " + "explicit 'MERGE PR ' confirmation, only with operator " + "authorization.", +] + +# Skill registry (#128). status: 'available' = backed by tools in this +# server; 'designed-not-implemented' = design exists but no MCP tools yet +# (listed rather than omitted so sessions know the boundary); 'operator-only' +# = never performed by an LLM session. +_PROJECT_SKILLS = { + "gitea-issue-authoring": { + "description": "Create, view, label, claim, and close Gitea issues.", + "when_to_use": "Filing new work, updating issue state, claiming an " + "issue before implementation (gitea_mark_issue).", + "required_operations": ["gitea.read"], + "status": "available", + "notes": "Always pass remote/org/repo explicitly.", + "steps": [ + "Verify identity with gitea_whoami (explicit remote).", + "Check the issue queue with gitea_list_issues.", + "Create with gitea_create_issue or claim with gitea_mark_issue " + "action='start'.", + "Keep issue bodies free of secrets, tokens, and raw service " + "URLs.", + "Release the claim (action='done') or close when finished.", + ], + }, + "gitea-pr-creation": { + "description": "Author a feature branch and open a pull request.", + "when_to_use": "After implementing a claimed issue on a feature " + "branch; author profiles only.", + "required_operations": ["gitea.pr.create"], + "status": "available", + "steps": [ + "Branch from fresh master (git pull first).", + "Implement only the claimed issue's scope; stage files " + "explicitly.", + "Run targeted tests, the full suite, py_compile, git diff " + "--check, and a secret sweep before committing.", + "Open the PR with gitea_create_pr referencing the issue " + "('Closes #').", + "Stop: do not review or merge your own PR.", + ], + }, + "gitea-pr-review": { + "description": "Independently review a pull request and post a " + "verdict.", + "when_to_use": "Reviewer profile asked to evaluate someone else's " + "PR.", + "required_operations": ["gitea.pr.review"], + "status": "available", + "steps": [ + "Verify reviewer identity with gitea_whoami; the PR author " + "must be a different user.", + "Pin the PR head SHA (gitea_view_pr) before validating.", + "Validate independently: scope vs the linked issue, tests, " + "diff check, secret sweep.", + "Post the verdict with gitea_review_pr / " + "gitea_submit_pr_review.", + "Do not merge unless the operator explicitly authorizes it.", + ], + }, + "gitea-pr-merge": { + "description": "Gated merge of an approved pull request.", + "when_to_use": "Reviewer/merger profile with explicit operator " + "authorization to merge.", + "required_operations": ["gitea.pr.merge"], + "status": "available", + "steps": [ + "Confirm operator authorization for this specific merge.", + "Run gitea_check_pr_eligibility action='merge' and resolve " + "every reason it reports.", + "Call gitea_merge_pr with expected_head_sha pinned and " + "confirmation 'MERGE PR '.", + "Confirm linked issues auto-closed and the in-progress label " + "was released.", + ], + }, + "gitea-issue-comments": { + "description": "Read and post issue discussion comments (distinct " + "from PR reviews).", + "when_to_use": "Design discussions, review notes on issues, " + "decision records.", + "required_operations": ["gitea.issue.comment"], + "status": "available", + "notes": "Listing needs only gitea.read; posting needs " + "gitea.issue.comment, gated separately from gitea.pr.*.", + "steps": [ + "List with gitea_list_issue_comments (explicit issue number).", + "Post with gitea_create_issue_comment; markdown body, no " + "secrets or raw URLs.", + "Never use PR review tools for issue discussion or vice " + "versa.", + ], + }, + "profile-switching": { + "description": "Change the active MCP identity between author and " + "reviewer profiles.", + "when_to_use": "A task requires a role the current profile " + "forbids (e.g. moving from authoring to review).", + "required_operations": [], + "status": "available", + "steps": [ + "Ask the operator to update GITEA_MCP_PROFILE in the launcher " + "environment.", + "Operator reconnects the MCP server (e.g. /mcp).", + "Verify the new identity with gitea_whoami before acting.", + "If identity does not match the expected role, STOP.", + ], + }, + "redaction-security-review": { + "description": "Sweep changes and tool output for secrets, " + "keychain IDs, token values, and raw service URLs.", + "when_to_use": "Before every commit/PR and when reviewing any " + "diff.", + "required_operations": ["gitea.read"], + "status": "available", + "steps": [ + "Run git diff --check for whitespace damage.", + "Grep the diff for token/password/secret/keychain patterns; " + "only synthetic fixtures may match.", + "Confirm normal tool output contains no endpoint URLs or " + "keychain IDs (reveal opt-in excepted).", + "Confirm no live config or private machine paths are being " + "committed.", + ], + }, + "jenkins-readonly": { + "description": "Read-only Jenkins CI inspection (jobs, builds, " + "logs).", + "when_to_use": "Checking CI state once Jenkins MCP tools exist.", + "required_operations": ["jenkins.read"], + "status": "designed-not-implemented", + "notes": "Design exists (issues #72/#77); no Jenkins MCP server is " + "connected yet. Report SKIPPED rather than substituting " + "shell or direct API calls.", + "steps": [ + "Confirm a Jenkins MCP server is connected; if not, report " + "SKIPPED.", + "Use read-only operations only; never trigger, cancel, or " + "configure builds.", + ], + }, + "glitchtip-readonly": { + "description": "Read-only GlitchTip error/event inspection.", + "when_to_use": "Investigating reported errors once GlitchTip MCP " + "tools exist.", + "required_operations": ["glitchtip.read"], + "status": "designed-not-implemented", + "notes": "Design exists (issue #73); no GlitchTip MCP server is " + "connected yet. Report SKIPPED rather than substituting " + "shell or direct API calls.", + "steps": [ + "Confirm a GlitchTip MCP server is connected; if not, report " + "SKIPPED.", + "Use read-only operations only; never mutate issues or " + "settings.", + ], + }, + "release-operator": { + "description": "Release, tag, and version workflows.", + "when_to_use": "Never by an LLM session on its own: releases and " + "tags are operator-owned.", + "required_operations": [], + "status": "operator-only", + "notes": "See the release SOP docs. Hard stop: no tags or " + "releases without an explicit operator instruction.", + "steps": [ + "Stop and hand off to the operator; do not create tags or " + "releases.", + ], + }, +} + + +@mcp.tool() +def mcp_get_control_plane_guide( + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Structured operator guide for the current Gitea MCP session (#128). + + Read-only. Call this first in a new session: it reports the active + profile, the authenticated identity (fail-soft), what this profile may + and may not do, and the non-negotiable workflow rules (hard stops, + fail-closed behavior, head-SHA pinning, merge confirmation, redaction, + author/reviewer separation, profile switching). Guidance is + profile-aware; if identity cannot be resolved it instructs the session + to STOP. + + Args: + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + + Returns: + dict with 'read_only', 'remote', 'profile' (name, operations, + role_kind), 'identity' (username/status/instruction; 'server' only + with the reveal opt-in), 'guidance' (profile-specific), 'rules', + 'workflows', and 'skills_tool'. Never secrets. + """ + h, _, _ = _resolve(remote, host, None, None) + profile = get_profile() + allowed = profile["allowed_operations"] + forbidden = profile["forbidden_operations"] + role = _role_kind(allowed, forbidden) + username = _authenticated_username(h) + + identity = { + "authenticated_username": username, + "remote": remote, + "status": "verified" if username else "unresolved", + "instruction": ( + "Identity verified — confirm it matches the role this task " + "requires before acting." + if username else + "STOP: the authenticated identity could not be resolved. Do " + "not perform any mutating operation; report to the operator " + "and wait."), + } + if _reveal_endpoints(): + identity["server"] = h + + guidance = [] + if not username: + guidance.append( + "STOP: identity unresolved — no mutating operations until the " + "operator fixes credentials/profile and gitea_whoami verifies.") + if role == "author": + guidance.append( + "Author profile: creating branches, commits, issues, and PRs " + "is allowed; review, approve, and merge are forbidden for this " + "profile — never attempt them, and never review or merge your " + "own PR from any profile.") + elif role == "reviewer": + guidance.append( + "Reviewer profile: review/approve/merge may proceed ONLY after " + "gitea_check_pr_eligibility passes and the PR head SHA is " + "pinned (expected_head_sha); the PR author must be a different " + "user, and merging additionally requires explicit operator " + "authorization plus the 'MERGE PR ' confirmation.") + elif role == "mixed": + guidance.append( + "WARNING: this profile allows both authoring and " + "review/merge, which defeats two-party review. Treat it as " + "misconfigured: STOP and report to the operator before any " + "review or merge.") + else: + guidance.append( + "Limited profile: no authoring and no review/merge " + "operations are allowed. Read-only work only; anything else " + "fails closed.") + + return { + "read_only": True, + "remote": remote, + "profile": { + "name": profile["profile_name"], + "allowed_operations": allowed, + "forbidden_operations": forbidden, + "role_kind": role, + }, + "identity": identity, + "guidance": guidance, + "rules": _GUIDE_RULES, + "workflows": _COMMON_WORKFLOWS, + "skills_tool": "mcp_list_project_skills", + } + + +@mcp.tool() +def mcp_list_project_skills() -> dict: + """List the project's workflow skills and when to use them (#128). + + Read-only; makes no API calls. Each skill reports its name, + description, when-to-use guidance, required operations, status + ('available', 'designed-not-implemented', or 'operator-only'), and + whether the current profile's operations permit it. Use + mcp_get_skill_guide(name) for the step-by-step guide. + + Returns: + dict with 'read_only', 'count', and 'skills' (list). Never secrets. + """ + profile = get_profile() + allowed = profile["allowed_operations"] + forbidden = profile["forbidden_operations"] + skills = [] + for name, meta in _PROJECT_SKILLS.items(): + ops = meta["required_operations"] + available = all( + gitea_config.check_operation(op, allowed, forbidden)[0] + for op in ops + ) if ops else True + entry = { + "name": name, + "description": meta["description"], + "when_to_use": meta["when_to_use"], + "required_operations": ops, + "status": meta["status"], + "available_to_current_profile": ( + available and meta["status"] == "available"), + } + if meta.get("notes"): + entry["notes"] = meta["notes"] + skills.append(entry) + return {"read_only": True, "count": len(skills), "skills": skills} + + +@mcp.tool() +def mcp_get_skill_guide(skill_name: str) -> dict: + """Step-by-step guide for one named project skill (#128). + + Read-only; makes no API calls. Unknown skill names fail closed with + the list of valid names instead of guessing. + + Args: + skill_name: A name from mcp_list_project_skills (case-insensitive). + + Returns: + dict with 'success', 'skill' metadata, and 'steps'; on an unknown + name, 'success' False with 'reasons' and 'valid_skills'. + """ + key = (skill_name or "").strip().lower() + meta = _PROJECT_SKILLS.get(key) + if meta is None: + return { + "success": False, + "reasons": [f"unknown skill '{skill_name}' (fail closed)"], + "valid_skills": sorted(_PROJECT_SKILLS), + } + skill = { + "name": key, + "description": meta["description"], + "when_to_use": meta["when_to_use"], + "required_operations": meta["required_operations"], + "status": meta["status"], + } + if meta.get("notes"): + skill["notes"] = meta["notes"] + return {"success": True, "skill": skill, "steps": list(meta["steps"])} + + @mcp.tool() def gitea_whoami( remote: str = "dadeschools", diff --git a/tests/test_operator_guide.py b/tests/test_operator_guide.py new file mode 100644 index 0000000..38e9610 --- /dev/null +++ b/tests/test_operator_guide.py @@ -0,0 +1,232 @@ +"""Tests for the operator guide / project skills MCP tools (#128). + +Read-only capability-discovery tools: mcp_get_control_plane_guide, +mcp_list_project_skills, mcp_get_skill_guide. Each is tested by calling the +underlying function directly with mocked API responses. +""" +import json +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import mcp_server # noqa: E402 +from mcp_server import ( # noqa: E402 + mcp_get_control_plane_guide, + mcp_list_project_skills, + mcp_get_skill_guide, +) + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" + +AUTHOR_ENV = { + "GITEA_PROFILE_NAME": "author-test", + "GITEA_ALLOWED_OPERATIONS": + "gitea.read,gitea.repo.commit,gitea.branch.create," + "gitea.branch.push,gitea.pr.create,gitea.pr.comment", + "GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.approve,gitea.pr.merge", +} + +REVIEWER_ENV = { + "GITEA_PROFILE_NAME": "reviewer-test", + "GITEA_ALLOWED_OPERATIONS": + "gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve," + "gitea.pr.request_changes,gitea.pr.merge", + "GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push", +} + +EXPECTED_SKILLS = [ + "gitea-issue-authoring", + "gitea-pr-creation", + "gitea-pr-review", + "gitea-pr-merge", + "gitea-issue-comments", + "profile-switching", + "redaction-security-review", + "jenkins-readonly", + "glitchtip-readonly", + "release-operator", +] + + +class GuideTestBase(unittest.TestCase): + def setUp(self): + mcp_server._IDENTITY_CACHE.clear() + + def tearDown(self): + mcp_server._IDENTITY_CACHE.clear() + + +# --------------------------------------------------------------------------- +# mcp_get_control_plane_guide +# --------------------------------------------------------------------------- +class TestControlPlaneGuide(GuideTestBase): + + @patch("mcp_server.api_request", return_value={"login": "author-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_author_profile_guidance(self, _auth, _api): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + self.assertTrue(g["read_only"]) + self.assertEqual(g["profile"]["role_kind"], "author") + self.assertEqual(g["identity"]["authenticated_username"], "author-bot") + self.assertEqual(g["identity"]["status"], "verified") + blob = " ".join(g["guidance"]).lower() + self.assertIn("forbidden", blob) + self.assertIn("review", blob) + + @patch("mcp_server.api_request", return_value={"login": "reviewer-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_reviewer_profile_guidance(self, _auth, _api): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + self.assertEqual(g["profile"]["role_kind"], "reviewer") + blob = " ".join(g["guidance"]).lower() + self.assertIn("eligibility", blob) + self.assertIn("pinned", blob) + + @patch("mcp_server.get_auth_header", return_value=None) + def test_unresolved_identity_instructs_stop(self, _auth): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + self.assertEqual(g["identity"]["status"], "unresolved") + self.assertIsNone(g["identity"]["authenticated_username"]) + self.assertIn("STOP", g["identity"]["instruction"]) + + @patch("mcp_server.api_request", return_value={"login": "author-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_guide_is_read_only(self, _auth, mock_api): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + mcp_get_control_plane_guide(remote="prgs") + for call in mock_api.call_args_list: + self.assertEqual(call[0][0], "GET") + + @patch("mcp_server.api_request", return_value={"login": "author-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_no_urls_or_keychain_ids_by_default(self, _auth, _api): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + blob = json.dumps(g) + self.assertNotIn("https://", blob) + self.assertNotIn("http://", blob) + self.assertNotIn("keychain:", blob) + self.assertNotIn(FAKE_AUTH, blob) + self.assertNotIn("server", g["identity"]) + + @patch("mcp_server.api_request", return_value={"login": "author-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_reveal_opt_in_includes_server(self, _auth, _api): + env = dict(AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1") + with patch.dict(os.environ, env, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + self.assertIn("gitea.prgs.cc", g["identity"]["server"]) + + @patch("mcp_server.api_request", return_value={"login": "author-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_rules_cover_required_topics(self, _auth, _api): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + rules = g["rules"] + for key in ("hard_stops", "fail_closed", "head_sha_pinning", + "merge_confirmation", "redaction", "separation", + "profile_switching", "identity_verification"): + self.assertIn(key, rules) + self.assertIn("MERGE PR", json.dumps(rules["merge_confirmation"])) + self.assertTrue(rules["hard_stops"]) + + def test_unknown_remote_raises(self): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + with self.assertRaises(ValueError): + mcp_get_control_plane_guide(remote="nope") + + +# --------------------------------------------------------------------------- +# mcp_list_project_skills +# --------------------------------------------------------------------------- +class TestProjectSkills(GuideTestBase): + + @patch("mcp_server.api_request") + def test_registry_complete_and_no_api_calls(self, mock_api): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + r = mcp_list_project_skills() + self.assertTrue(r["read_only"]) + names = [s["name"] for s in r["skills"]] + for expected in EXPECTED_SKILLS: + self.assertIn(expected, names) + self.assertEqual(r["count"], len(r["skills"])) + for s in r["skills"]: + self.assertTrue(s["description"]) + self.assertTrue(s["when_to_use"]) + self.assertIn("required_operations", s) + self.assertIn("status", s) + self.assertIn("available_to_current_profile", s) + mock_api.assert_not_called() + + def test_profile_aware_availability(self): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + r = mcp_list_project_skills() + by_name = {s["name"]: s for s in r["skills"]} + self.assertTrue(by_name["gitea-pr-creation"]["available_to_current_profile"]) + self.assertFalse(by_name["gitea-pr-merge"]["available_to_current_profile"]) + + def test_unimplemented_services_marked(self): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + r = mcp_list_project_skills() + by_name = {s["name"]: s for s in r["skills"]} + self.assertNotEqual(by_name["jenkins-readonly"]["status"], "available") + self.assertNotEqual(by_name["glitchtip-readonly"]["status"], "available") + + def test_no_urls_in_registry(self): + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + r = mcp_list_project_skills() + blob = json.dumps(r) + self.assertNotIn("https://", blob) + self.assertNotIn("http://", blob) + self.assertNotIn("keychain:", blob) + + +# --------------------------------------------------------------------------- +# mcp_get_skill_guide +# --------------------------------------------------------------------------- +class TestSkillGuide(GuideTestBase): + + def test_known_skill_returns_steps(self): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + r = mcp_get_skill_guide("gitea-pr-merge") + self.assertTrue(r["success"]) + self.assertEqual(r["skill"]["name"], "gitea-pr-merge") + self.assertTrue(r["steps"]) + self.assertIn("MERGE PR", " ".join(r["steps"])) + + def test_case_and_whitespace_normalized(self): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + r = mcp_get_skill_guide(" Gitea-PR-Merge ") + self.assertTrue(r["success"]) + + def test_unknown_skill_fails_closed(self): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + r = mcp_get_skill_guide("no-such-skill") + self.assertFalse(r["success"]) + self.assertTrue(r["reasons"]) + for expected in EXPECTED_SKILLS: + self.assertIn(expected, r["valid_skills"]) + + @patch("mcp_server.api_request") + def test_read_only_no_api_calls(self, mock_api): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + mcp_get_skill_guide("gitea-pr-review") + mock_api.assert_not_called() + + def test_no_urls_in_skill_guides(self): + with patch.dict(os.environ, REVIEWER_ENV, clear=True): + for name in EXPECTED_SKILLS: + r = mcp_get_skill_guide(name) + blob = json.dumps(r) + self.assertNotIn("https://", blob) + self.assertNotIn("keychain:", blob) + + +if __name__ == "__main__": + unittest.main()