feat: load profiles.json v2 contexts shape with enabled enforcement and LLM-safe output (#120)

Support the canonical contexts-shape version 2 config (contexts / profiles /
projects / rules) alongside the existing environments shape and v1:

- Require a boolean 'enabled' on every context, profile, service, and
  project. Disabled entries are surfaced in audits but fail closed at
  selection/resolution — never a silent fallback to another profile,
  service, or credential source.
- Resolve the active identity from GITEA_MCP_PROFILE via the existing
  select_profile path; profile base_url falls back to the context's enabled
  gitea block.
- Add resolve_service() and project_for_path() for context service and
  project-to-context resolution (internal use; fail closed on disabled).
- get_auth_header now propagates ConfigError when GITEA_MCP_CONFIG is set
  instead of silently degrading to Basic auth.
- Hide endpoint URLs and keychain ids from normal LLM-facing output:
  gitea_whoami / gitea_get_profile report logical names and auth status
  only; new gitea_audit_config tool reports enabled/disabled state and safe
  one-line service summaries. The GITEA_MCP_REVEAL_ENDPOINTS opt-in (and
  'python3 gitea_config.py audit --reveal-endpoints' locally) restores
  endpoints and auth source names for admin diagnostics; token values are
  never printed on any path.
- Ship gitea-mcp.v2-contexts.example.json (synthetic values) and validate
  it in tests.

Implements #120

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 02:19:39 -04:00
parent fbf1bc5f5c
commit ff920a6496
8 changed files with 1127 additions and 22 deletions
+145 -3
View File
@@ -3,6 +3,7 @@
Each tool is tested by calling the underlying function directly (not through
the MCP protocol) with mocked API responses.
"""
import json
import os
import sys
import unittest
@@ -880,7 +881,9 @@ class TestWhoami(unittest.TestCase):
self.assertEqual(result["username"], "reviewer-bot")
self.assertEqual(result["display_name"], "Reviewer Bot")
self.assertEqual(result["user_id"], 42)
self.assertEqual(result["server"], "https://gitea.prgs.cc")
# Endpoint URLs are hidden from normal LLM-facing output (#120);
# the logical remote name is the addressing surface.
self.assertNotIn("server", result)
self.assertEqual(result["remote"], "prgs")
# Read-only: GET against the authenticated-user endpoint.
call_args = mock_api.call_args
@@ -1035,8 +1038,12 @@ class TestProfileDiscovery(unittest.TestCase):
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
self.assertEqual(result["authenticated_username"], "reviewer-bot")
self.assertEqual(result["identity_status"], "verified")
self.assertEqual(result["server"], "https://gitea.prgs.cc")
self.assertEqual(result["token_source_name"], "GITEA_TOKEN")
# Endpoint URLs and token source names are hidden from normal
# LLM-facing output (#120); auth is reported as a status only.
self.assertNotIn("server", result)
self.assertNotIn("base_url", result)
self.assertNotIn("token_source_name", result)
self.assertEqual(result["auth_status"], "configured")
# Read-only: only a GET to the user endpoint was issued.
self.assertEqual(mock_api.call_args[0][0], "GET")
self.assertTrue(mock_api.call_args[0][1].endswith("/api/v1/user"))
@@ -1669,3 +1676,138 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
# branch name fallback
self.assertEqual(extract_linked_issue_numbers("", branch_name="issue-123"), [123])
self.assertEqual(extract_linked_issue_numbers("", branch_name="feat/issue-123-foo"), [123])
# ---------------------------------------------------------------------------
# Endpoint/keychain redaction in LLM-facing output — issue #120
# ---------------------------------------------------------------------------
class TestEndpointRedaction(unittest.TestCase):
"""Normal MCP output hides endpoint URLs and keychain ids; the admin
opt-in (GITEA_MCP_REVEAL_ENDPOINTS) restores them for local diagnostics
without ever revealing token values."""
def _contexts_config_file(self):
import tempfile
config = {
"version": 2,
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "kind": "gitea",
"base_url": "https://gitea.prgs.cc"},
"services": {
"jenkins": {
"enabled": True, "kind": "jenkins",
"label": "PRGS Jenkins",
"base_url": "https://jenkins.prgs.cc",
"auth": {"type": "keychain",
"id": "prgs-jenkins-token"},
"capabilities": ["read"],
},
"sentry": {
"enabled": False, "kind": "sentry",
"label": "PRGS Sentry", "base_url": "",
"auth": {"type": "keychain",
"id": "prgs-sentry-token"},
"capabilities": ["read"],
},
},
},
},
"profiles": {
"prgs-author": {
"enabled": True, "context": "prgs", "role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain",
"id": "prgs-gitea-author-token"},
"allowed_operations": ["read"],
"forbidden_operations": [],
},
},
"projects": {},
"rules": {"hide_service_urls_from_llm": True,
"hide_keychain_ids_from_llm": True,
"mcp_resolves_endpoints": True},
}
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(config, fh)
return path
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_whoami_hides_endpoint_url_by_default(self, _auth, mock_api):
mock_api.return_value = {"id": 1, "login": "someone"}
with patch.dict(os.environ, {}, clear=True):
result = gitea_whoami(remote="prgs")
self.assertNotIn("server", result)
self.assertNotIn("https://", repr(result))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_whoami_reveals_endpoint_with_admin_optin(self, _auth, mock_api):
mock_api.return_value = {"id": 1, "login": "someone"}
with patch.dict(os.environ,
{"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_whoami(remote="prgs")
self.assertEqual(result["server"], "https://gitea.prgs.cc")
def test_get_profile_hides_url_and_token_source_by_default(self):
env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_BASE_URL": "https://gitea.example.invalid",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
blob = repr(result)
for leaked in ("https://", "keychain:", "some-item-id",
"base_url", "server", "token_source_name"):
self.assertNotIn(leaked, blob)
self.assertEqual(result["auth_status"], "configured")
def test_get_profile_reports_unconfigured_auth(self):
with patch.dict(os.environ,
{"GITEA_PROFILE_NAME": "gitea-author"}, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
self.assertEqual(result["auth_status"], "unconfigured")
def test_get_profile_reveals_with_admin_optin(self):
env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
self.assertEqual(result["server"], "https://gitea.prgs.cc")
self.assertEqual(result["token_source_name"], "keychain:some-item-id")
def test_audit_tool_reports_state_without_urls_or_ids(self):
from mcp_server import gitea_audit_config
path = self._contexts_config_file()
try:
env = {"GITEA_MCP_CONFIG": path,
"GITEA_MCP_PROFILE": "prgs-author"}
with patch.dict(os.environ, env, clear=True), \
patch("gitea_config._keychain_token", return_value="x"):
result = gitea_audit_config()
finally:
os.unlink(path)
blob = json.dumps(result)
self.assertIn("PRGS Jenkins: enabled, read-only, authenticated",
result["summaries"])
self.assertIn("PRGS Sentry: disabled", result["summaries"])
for leaked in ("https://", "http://", "prgs-jenkins-token",
"prgs-gitea-author-token", "base_url"):
self.assertNotIn(leaked, blob)
def test_audit_tool_without_config_reports_off(self):
with patch.dict(os.environ, {}, clear=True):
from mcp_server import gitea_audit_config
result = gitea_audit_config()
self.assertFalse(result["configured"])