feat: add MCP server + shared auth module (#7, #1)

- New: mcp_server.py — FastMCP stdio server exposing 7 tools:
  gitea_create_issue, gitea_create_pr, gitea_close_issue,
  gitea_list_issues, gitea_view_issue, gitea_mark_issue,
  gitea_mirror_refs
- New: auth.py — shared authentication and API helpers
  (get_credentials, get_auth_header, api_request, repo_api_url)
- Refactored: create_pr.py, create_issue.py, manage_labels.py
  to use shared auth module (eliminates credential duplication)
- New: tests/test_mcp_server.py — 17 tests for all MCP tools
- Updated: tests/test_credentials.py — now tests auth.py directly
- Updated: tests/test_create_issue.py — adapted for refactored imports
- New: requirements.txt — frozen venv deps (mcp[cli], pytest)
- Updated: README.md — MCP server as primary interface
- Config: added gitea-tools to mcp_config.json

Closes #1. Resolves #2, #5. Relates to #7.
This commit is contained in:
2026-06-21 20:08:07 -04:00
parent dd6f1308c1
commit b7e195e426
11 changed files with 978 additions and 214 deletions
+33 -59
View File
@@ -1,7 +1,7 @@
"""Tests for create_issue.py.
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
network calls or keychain access are performed.
Every test mocks auth functions so no real network calls or keychain
access are performed.
Note: create_issue.py may be inaccessible due to macOS sandbox restrictions.
If so, these tests are automatically skipped.
@@ -28,17 +28,6 @@ except (ImportError, PermissionError, OSError) as exc:
FAKE_CREDS = ("testuser", "testpass")
def _mock_urlopen(status=200, body=None):
"""Return a mock context-manager for urllib.request.urlopen."""
if body is None:
body = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode("utf-8")
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
@@ -46,9 +35,9 @@ def _mock_urlopen(status=200, body=None):
class TestArgParsing(unittest.TestCase):
"""Ensure argparse accepts the expected flags and rejects bad input."""
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_minimal_args(self, _cred, _url):
def test_minimal_args(self, _cred, _api):
rc = create_issue.main(["--title", "Hello"])
self.assertEqual(rc, 0)
@@ -57,9 +46,9 @@ class TestArgParsing(unittest.TestCase):
create_issue.main(["--body", "no title given"])
self.assertNotEqual(ctx.exception.code, 0)
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_remote_choices(self, _cred, _url):
def test_remote_choices(self, _cred, _api):
for remote in ("dadeschools", "prgs"):
rc = create_issue.main(["--remote", remote, "--title", "X"])
self.assertEqual(rc, 0, f"--remote {remote} should be accepted")
@@ -76,21 +65,21 @@ class TestArgParsing(unittest.TestCase):
class TestRemoteResolution(unittest.TestCase):
"""Verify the correct host/org/repo are selected per remote."""
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_dadeschools_default(self, mock_cred, mock_url):
def test_dadeschools_default(self, mock_cred, _api):
create_issue.main(["--title", "T"])
mock_cred.assert_called_with("gitea.dadeschools.net")
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_prgs_remote(self, mock_cred, mock_url):
def test_prgs_remote(self, mock_cred, _api):
create_issue.main(["--remote", "prgs", "--title", "T"])
mock_cred.assert_called_with("gitea.prgs.cc")
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_host_override(self, mock_cred, mock_url):
def test_host_override(self, mock_cred, _api):
create_issue.main(["--host", "custom.example.com", "--title", "T"])
mock_cred.assert_called_with("custom.example.com")
@@ -104,27 +93,19 @@ class TestAPIPayload(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_payload_title_and_body(self, _cred):
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--title", "My Title", "--body", "My Body"])
# Inspect the data kwarg passed to Request(url, data=..., ...)
call_kwargs = MockReq.call_args
data = json.loads(call_kwargs[1]["data"].decode("utf-8"))
self.assertEqual(data["title"], "My Title")
self.assertEqual(data["body"], "My Body")
with patch("create_issue.api_request",
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
create_issue.main(["--title", "My Title", "--body", "My Body"])
payload = mock_api.call_args[0][3]
self.assertEqual(payload["title"], "My Title")
self.assertEqual(payload["body"], "My Body")
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_url_construction(self, _cred):
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--remote", "prgs", "--title", "X"])
url = MockReq.call_args[0][0]
with patch("create_issue.api_request",
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
create_issue.main(["--remote", "prgs", "--title", "X"])
url = mock_api.call_args[0][1]
self.assertEqual(
url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
@@ -137,19 +118,16 @@ class TestAPIPayload(unittest.TestCase):
@unittest.skipIf(_SKIP, _REASON)
class TestBodyFile(unittest.TestCase):
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_body_from_file(self, _cred, _url):
def test_body_from_file(self, _cred):
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write("File body content")
f.flush()
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--title", "T", "--body-file", f.name])
data = json.loads(MockReq.call_args[1]["data"].decode("utf-8"))
self.assertEqual(data["body"], "File body content")
with patch("create_issue.api_request",
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
create_issue.main(["--title", "T", "--body-file", f.name])
payload = mock_api.call_args[0][3]
self.assertEqual(payload["body"], "File body content")
# ---------------------------------------------------------------------------
@@ -165,19 +143,15 @@ class TestAuthFailure(unittest.TestCase):
# ---------------------------------------------------------------------------
# HTTP error handling
# API error handling
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestHTTPError(unittest.TestCase):
class TestAPIError(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_http_error_returns_1(self, _cred):
import urllib.error
err = urllib.error.HTTPError(
url="https://example.com", code=422, msg="Unprocessable",
hdrs=None, fp=io.BytesIO(b'{"message":"duplicate"}'),
)
with patch("create_issue.urllib.request.urlopen", side_effect=err):
def test_api_error_returns_1(self, _cred):
with patch("create_issue.api_request",
side_effect=RuntimeError("HTTP 422: duplicate")):
rc = create_issue.main(["--title", "Dup"])
self.assertEqual(rc, 1)
+34 -12
View File
@@ -1,4 +1,4 @@
"""Tests for the shared get_credentials() function used by create_issue.py and create_pr.py.
"""Tests for the shared get_credentials() function in auth.py.
These test the credential parsing logic in isolation by mocking subprocess.Popen.
"""
@@ -7,7 +7,7 @@ import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import create_pr # noqa: E402 (get_credentials is identical in create_issue.py and create_pr.py)
import auth # noqa: E402
class TestGetCredentials(unittest.TestCase):
@@ -19,45 +19,45 @@ class TestGetCredentials(unittest.TestCase):
mock_proc.communicate.return_value = (output_text, "")
return mock_proc
@patch("create_pr.subprocess.Popen")
@patch("auth.subprocess.Popen")
def test_parses_standard_output(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen(
"protocol=https\nhost=gitea.example.com\nusername=admin\npassword=s3cret\n"
)
user, password = create_pr.get_credentials("gitea.example.com")
user, password = auth.get_credentials("gitea.example.com")
self.assertEqual(user, "admin")
self.assertEqual(password, "s3cret")
@patch("create_pr.subprocess.Popen")
@patch("auth.subprocess.Popen")
def test_handles_password_with_equals(self, mock_popen_cls):
# Tokens often contain '=' characters
mock_popen_cls.return_value = self._mock_popen(
"username=bot\npassword=abc=def=ghi\n"
)
user, password = create_pr.get_credentials("example.com")
user, password = auth.get_credentials("example.com")
self.assertEqual(user, "bot")
self.assertEqual(password, "abc=def=ghi")
@patch("create_pr.subprocess.Popen")
@patch("auth.subprocess.Popen")
def test_empty_output_returns_empty(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen("")
user, password = create_pr.get_credentials("example.com")
user, password = auth.get_credentials("example.com")
self.assertEqual(user, "")
self.assertEqual(password, "")
@patch("create_pr.subprocess.Popen")
@patch("auth.subprocess.Popen")
def test_missing_password_returns_empty(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen("username=admin\n")
user, password = create_pr.get_credentials("example.com")
user, password = auth.get_credentials("example.com")
self.assertEqual(user, "admin")
self.assertEqual(password, "")
@patch("create_pr.subprocess.Popen")
@patch("auth.subprocess.Popen")
def test_sends_correct_stdin(self, mock_popen_cls):
mock_proc = self._mock_popen("username=u\npassword=p\n")
mock_popen_cls.return_value = mock_proc
create_pr.get_credentials("gitea.prgs.cc")
auth.get_credentials("gitea.prgs.cc")
# Verify the correct input was sent to git credential fill
mock_proc.communicate.assert_called_once_with(
@@ -65,5 +65,27 @@ class TestGetCredentials(unittest.TestCase):
)
class TestGetAuthHeader(unittest.TestCase):
"""Test the get_auth_header function."""
@patch("auth.get_credentials", return_value=("user", "pass"))
def test_returns_basic_header(self, _cred):
header = auth.get_auth_header("example.com")
self.assertIsNotNone(header)
self.assertTrue(header.startswith("Basic "))
@patch("auth.get_credentials", return_value=("", ""))
def test_returns_none_for_missing_creds(self, _cred):
header = auth.get_auth_header("example.com")
self.assertIsNone(header)
class TestRepoApiUrl(unittest.TestCase):
def test_url_format(self):
url = auth.repo_api_url("gitea.prgs.cc", "Org", "Repo")
self.assertEqual(url, "https://gitea.prgs.cc/api/v1/repos/Org/Repo")
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
"""Tests for the MCP server tool functions.
Each tool is tested by calling the underlying function directly (not through
the MCP protocol) with mocked API responses.
"""
import os
import sys
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_server import ( # noqa: E402
gitea_create_issue,
gitea_create_pr,
gitea_close_issue,
gitea_list_issues,
gitea_view_issue,
gitea_mark_issue,
gitea_mirror_refs,
)
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
# ---------------------------------------------------------------------------
# Create Issue
# ---------------------------------------------------------------------------
class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_issue(self, _auth, mock_api):
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
result = gitea_create_issue(title="Test issue", body="body text")
self.assertEqual(result["number"], 1)
self.assertIn("issues/1", result["url"])
mock_api.assert_called_once()
# Verify payload
call_args = mock_api.call_args
self.assertEqual(call_args[0][0], "POST")
self.assertEqual(call_args[0][3]["title"], "Test issue")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_on_prgs(self, _auth, mock_api):
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
result = gitea_create_issue(title="Test", remote="prgs")
self.assertEqual(result["number"], 5)
url = mock_api.call_args[0][1]
self.assertIn("gitea.prgs.cc", url)
self.assertIn("Scaled-Tech-Consulting", url)
# ---------------------------------------------------------------------------
# Create PR
# ---------------------------------------------------------------------------
class TestCreatePR(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api):
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
result = gitea_create_pr(title="feat: X", head="feat/x", base="main")
self.assertEqual(result["number"], 3)
payload = mock_api.call_args[0][3]
self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main")
# ---------------------------------------------------------------------------
# Close Issue
# ---------------------------------------------------------------------------
class TestCloseIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_closes_issue(self, _auth, mock_api):
mock_api.return_value = {"state": "closed"}
result = gitea_close_issue(issue_number=42)
self.assertTrue(result["success"])
self.assertIn("42", result["message"])
payload = mock_api.call_args[0][3]
self.assertEqual(payload["state"], "closed")
# ---------------------------------------------------------------------------
# List Issues
# ---------------------------------------------------------------------------
class TestListIssues(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_returns_formatted_list(self, _auth, mock_api):
mock_api.return_value = [
{
"number": 1, "title": "Bug", "state": "open",
"labels": [{"name": "bug"}],
"assignee": {"login": "alice"},
},
{
"number": 2, "title": "Feature", "state": "open",
"labels": [], "assignee": None,
},
]
result = gitea_list_issues()
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["number"], 1)
self.assertEqual(result[0]["labels"], ["bug"])
self.assertEqual(result[0]["assignee"], "alice")
self.assertEqual(result[1]["assignee"], "")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_passes_label_filter(self, _auth, mock_api):
mock_api.return_value = []
gitea_list_issues(label="important")
url = mock_api.call_args[0][1]
self.assertIn("labels=important", url)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_passes_state_filter(self, _auth, mock_api):
mock_api.return_value = []
gitea_list_issues(state="closed")
url = mock_api.call_args[0][1]
self.assertIn("state=closed", url)
# ---------------------------------------------------------------------------
# View Issue
# ---------------------------------------------------------------------------
class TestViewIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_returns_full_details(self, _auth, mock_api):
mock_api.return_value = {
"number": 7, "title": "MCP server", "body": "Build it",
"state": "open", "labels": [{"name": "important"}],
"assignee": {"login": "jason"},
"html_url": "https://gitea.prgs.cc/issues/7",
}
result = gitea_view_issue(issue_number=7, remote="prgs")
self.assertEqual(result["number"], 7)
self.assertEqual(result["body"], "Build it")
self.assertEqual(result["labels"], ["important"])
self.assertEqual(result["assignee"], "jason")
# ---------------------------------------------------------------------------
# Mark Issue
# ---------------------------------------------------------------------------
class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_start_adds_label(self, _auth, mock_api):
# First call: get labels; second call: add label
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
]
result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"])
self.assertIn("claimed", result["message"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_done_removes_label(self, _auth, mock_api):
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
None,
]
result = gitea_mark_issue(issue_number=5, action="done")
self.assertTrue(result["success"])
self.assertIn("released", result["message"])
def test_invalid_action_raises(self):
with self.assertRaises(ValueError):
gitea_mark_issue(issue_number=5, action="pause")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_missing_label_raises(self, _auth, mock_api):
mock_api.return_value = [] # no labels exist
with self.assertRaises(RuntimeError):
gitea_mark_issue(issue_number=5, action="start")
# ---------------------------------------------------------------------------
# Auth errors
# ---------------------------------------------------------------------------
class TestAuthErrors(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=None)
def test_no_credentials_raises(self, _auth):
with self.assertRaises(RuntimeError):
gitea_create_issue(title="test")
def test_unknown_remote_raises(self):
with self.assertRaises(ValueError):
gitea_create_issue(title="test", remote="nonexistent")
# ---------------------------------------------------------------------------
# Mirror Refs
# ---------------------------------------------------------------------------
class TestMirrorRefs(unittest.TestCase):
@patch("mcp_server.subprocess.run")
def test_dry_run_default(self, mock_run):
mock_run.return_value = MagicMock(
stdout="DRY RUN\n", stderr="", returncode=0
)
result = gitea_mirror_refs()
self.assertEqual(result["return_code"], 0)
# Should NOT have --apply
args = mock_run.call_args[0][0]
self.assertNotIn("--apply", args)
self.assertNotIn("--force", args)
@patch("mcp_server.subprocess.run")
def test_apply_flag(self, mock_run):
mock_run.return_value = MagicMock(
stdout="done\n", stderr="", returncode=0
)
result = gitea_mirror_refs(apply=True)
args = mock_run.call_args[0][0]
self.assertIn("--apply", args)
@patch("mcp_server.subprocess.run")
def test_force_flag(self, mock_run):
mock_run.return_value = MagicMock(
stdout="", stderr="", returncode=0
)
gitea_mirror_refs(apply=True, force=True)
args = mock_run.call_args[0][0]
self.assertIn("--apply", args)
self.assertIn("--force", args)
if __name__ == "__main__":
unittest.main()