Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ Serwer FastAPI z endpointami (poza MCP STDIO):
- `POST /refactor/recommend` - Rekomendacje refaktoryzacji
- `GET /health` - Healthcheck

Synchronizacja i zapis cache repozytoriów wymagają ustawienia
`MCP_SKILLS_ALLOW_SYNC=1`. Uruchamianie narzędzi oraz reDSL (także przez HTTP)
wymaga osobnego `MCP_SKILLS_ALLOW_EXECUTE=1`. Obie możliwości są domyślnie
wyłączone, a wszystkie `repo_id`, ścieżki fragmentów i archiwa są ograniczone
do `SKILLS_REPO_BASE`.

## MCP Skills - Narzędzia

### analyze_code_structure
Expand Down Expand Up @@ -420,6 +426,20 @@ Najważniejsze endpointy `mcp-git-proxy`:
- `POST /repos/{repo_id}/run-tests` - test commitu przed pushem
- `POST /repos/{repo_id}/push` - push po pozytywnych testach

Proxy uruchamia się w trybie tylko do odczytu. Lokalne zmiany i synchronizacja
wymagają `GIT_PROXY_ALLOW_MUTATION=1`, dowolne polecenia testowe wymagają
`GIT_PROXY_ALLOW_EXECUTE=1`, a push i tworzenie repozytoriów na GitHubie są
osobno chronione przez `GIT_PROXY_ALLOW_REMOTE_WRITE=1`. Identyfikatory repo,
ścieżki zmian, checkpointy i importowane archiwa pozostają wewnątrz
skonfigurowanych katalogów proxy. Port proxy jest domyślnie publikowany tylko
na `127.0.0.1`; jawna zmiana `GIT_PROXY_BIND_HOST` rozszerza tę granicę sieciową.

`gh2mcp` nie udostępnia pełnego PAT przez HTTP. Zapis `.env` wymaga
`GH2MCP_ALLOW_MUTATION=1`, a formularze sekretów w testowym WebUI wymagają
`MCP_WEBUI_ALLOW_SECRET_WRITE=1`. Synchronizacja repo i wywołania modeli w
WebUI mają osobne bramki `MCP_WEBUI_ALLOW_MUTATION=1` i
`MCP_WEBUI_ALLOW_EXECUTE=1`; oba porty są domyślnie tylko na loopbacku.

## Przykłady Użycia

### Analiza lokalnego repozytorium
Expand Down
17 changes: 13 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ services:
environment:
- GIT_PROXY_REPO_ROOT=/git-repos
- GIT_PROXY_CACHE_ROOT=/git-cache
- GIT_PROXY_ALLOW_MUTATION=${GIT_PROXY_ALLOW_MUTATION:-false}
- GIT_PROXY_ALLOW_EXECUTE=${GIT_PROXY_ALLOW_EXECUTE:-false}
- GIT_PROXY_ALLOW_REMOTE_WRITE=${GIT_PROXY_ALLOW_REMOTE_WRITE:-false}
volumes:
- git-repo-storage:/git-repos
- git-cache-storage:/git-cache
- ./repos:/host-repos:ro
- ..:/host-semcod:ro
ports:
- "${PORT_GIT_PROXY:-8081}:8080"
- "${GIT_PROXY_BIND_HOST:-127.0.0.1}:${PORT_GIT_PROXY:-8081}:8080"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 20s
Expand All @@ -45,7 +48,8 @@ services:
container_name: gh2mcp-agent
environment:
- GH2MCP_ENV_FILE=/app/.env
- GH2MCP_SYNC_ON_START=${GH2MCP_SYNC_ON_START:-true}
- GH2MCP_ALLOW_MUTATION=${GH2MCP_ALLOW_MUTATION:-false}
- GH2MCP_SYNC_ON_START=${GH2MCP_SYNC_ON_START:-false}
- GH2MCP_SYNC_INTERVAL=${GH2MCP_SYNC_INTERVAL:-0}
- GH_TOKEN=${GH_TOKEN:-}
- GITHUB_PAT=${GITHUB_PAT:-}
Expand All @@ -54,7 +58,7 @@ services:
- ./.env:/app/.env
- ${HOME}/.config/gh:/root/.config/gh:ro
ports:
- "${PORT_GH2MCP:-8079}:8079"
- "${GH2MCP_BIND_HOST:-127.0.0.1}:${PORT_GH2MCP:-8079}:8079"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8079/health', timeout=3)"]
interval: 20s
Expand All @@ -81,6 +85,8 @@ services:
- MCP_SKILLS_TRANSPORT=http
- MCP_SKILLS_HTTP_HOST=0.0.0.0
- MCP_SKILLS_HTTP_PORT=8080
- MCP_SKILLS_ALLOW_SYNC=${MCP_SKILLS_ALLOW_SYNC:-false}
- MCP_SKILLS_ALLOW_EXECUTE=${MCP_SKILLS_ALLOW_EXECUTE:-false}
- GITHUB_PAT=${GITHUB_PAT:-}
- GH_TOKEN=${GH_TOKEN:-}
volumes:
Expand Down Expand Up @@ -218,10 +224,13 @@ services:
- GIT_PROXY_URL=http://mcp-git-proxy:8080
- WEBUI_API_KEY=${WEBUI_API_KEY:-sk-mcp-default-dev-key}
- GH2MCP_URL=http://gh2mcp-agent:8079
- MCP_WEBUI_ALLOW_SECRET_WRITE=${MCP_WEBUI_ALLOW_SECRET_WRITE:-false}
- MCP_WEBUI_ALLOW_MUTATION=${MCP_WEBUI_ALLOW_MUTATION:-false}
- MCP_WEBUI_ALLOW_EXECUTE=${MCP_WEBUI_ALLOW_EXECUTE:-false}
volumes:
- ./.env:/app/.env
ports:
- "${PORT_WEBUI:-8092}:8090"
- "${MCP_WEBUI_BIND_HOST:-127.0.0.1}:${PORT_WEBUI:-8092}:8090"
depends_on:
mcp-gateway:
condition: service_healthy
Expand Down
22 changes: 19 additions & 3 deletions env2mcp/env2mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
import re
from pathlib import Path
from typing import Dict, Optional
from typing import Dict


class EnvConfig:
Expand Down Expand Up @@ -74,14 +74,19 @@ def _format_value(self, key: str, value: str) -> str:

def save(self, create_backup: bool = True) -> None:
"""Save configuration to .env file."""
if self.env_path.is_symlink():
raise ValueError(f"Refusing to write secrets through symlink: {self.env_path}")
if create_backup and self.env_path.exists():
backup_path = self.env_path.with_suffix(".env.backup")
if backup_path.is_symlink():
raise ValueError(f"Refusing to write backup through symlink: {backup_path}")
backup_path.write_text(self.env_path.read_text(), encoding="utf-8")
backup_path.chmod(0o600)

lines = []

# Add header
lines.append(f"# Generated by env2mcp v0.1.0")
lines.append("# Generated by env2mcp v0.1.0")
lines.append(f"# {self.env_path.absolute()}")
lines.append("")

Expand Down Expand Up @@ -124,7 +129,18 @@ def save(self, create_backup: bool = True) -> None:
lines.append(f'{key}={self._format_value(key, value)}')
lines.append("")

self.env_path.write_text("\n".join(lines), encoding="utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(self.env_path, flags, 0o600)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
fd = -1
handle.write("\n".join(lines))
finally:
if fd >= 0:
os.close(fd)

def __contains__(self, key: str) -> bool:
return key in self._data or key in os.environ
Expand Down
5 changes: 5 additions & 0 deletions gh2mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Kontener uruchamia API:
- `POST /sync/token`
- `POST /repo/last-pushed`

API nigdy nie zwraca pełnego tokenu. Zapis tokenu i organizacji jest domyślnie
wyłączony i wymaga `GH2MCP_ALLOW_MUTATION=1`; automatyczna synchronizacja przy
starcie wymaga dodatkowo `GH2MCP_SYNC_ON_START=true`. Port kontenera jest
domyślnie związany z `127.0.0.1`.


## License

Expand Down
40 changes: 31 additions & 9 deletions gh2mcp/gh2mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@
import asyncio
import os

from fastapi import FastAPI
from fastapi import Query
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from .sync import GitHubTokenSyncService

ENV_FILE = os.getenv("GH2MCP_ENV_FILE", "/app/.env")
SYNC_ON_START = os.getenv("GH2MCP_SYNC_ON_START", "true").lower() in {"1", "true", "yes"}
SYNC_ON_START = os.getenv("GH2MCP_SYNC_ON_START", "false").lower() in {"1", "true", "yes"}
SYNC_INTERVAL = int(os.getenv("GH2MCP_SYNC_INTERVAL", "0"))
_MUTATION_ENV = "GH2MCP_ALLOW_MUTATION"

app = FastAPI(title="gh2mcp", version="0.1.0")
service = GitHubTokenSyncService(ENV_FILE)


class SyncTokenRequest(BaseModel):
force_gh_cli: bool = False
include_token: bool = False


class SetOrgRequest(BaseModel):
Expand All @@ -44,19 +44,39 @@ class RecentReposRequest(BaseModel):
_sync_task: asyncio.Task | None = None


def _mutation_enabled() -> bool:
return os.getenv(_MUTATION_ENV, "").strip().lower() in {"1", "true", "yes", "on"}


def _require_mutation(action: str) -> None:
if not _mutation_enabled():
raise PermissionError(
f"gh2mcp mutation '{action}' is disabled; start the service with {_MUTATION_ENV}=1"
)


@app.exception_handler(PermissionError)
async def permission_error_handler(
_request: Request,
exc: PermissionError,
) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": str(exc)})


async def _periodic_sync() -> None:
while True:
_require_mutation("periodic_sync")
service.sync_token(force_gh_cli=False)
await asyncio.sleep(SYNC_INTERVAL)


@app.on_event("startup")
async def on_startup() -> None:
global _sync_task
if SYNC_ON_START:
if SYNC_ON_START and _mutation_enabled():
service.sync_token(force_gh_cli=False)

if SYNC_INTERVAL > 0:
if SYNC_INTERVAL > 0 and _mutation_enabled():
_sync_task = asyncio.create_task(_periodic_sync())


Expand All @@ -74,20 +94,22 @@ def health() -> dict:


@app.get("/status")
def status(include_token: bool = Query(False)) -> dict:
return service.get_status(include_token=include_token)
def status() -> dict:
return service.get_status(include_token=False)


@app.post("/sync/token")
def sync_token(payload: SyncTokenRequest) -> dict:
_require_mutation("sync_token")
return service.sync_token(
force_gh_cli=payload.force_gh_cli,
include_token=payload.include_token,
include_token=False,
)


@app.post("/org/set")
def set_org(payload: SetOrgRequest) -> dict:
_require_mutation("set_org")
return service.set_org(org=payload.org)


Expand Down
87 changes: 83 additions & 4 deletions gh2mcp/tests/test_gh2mcp.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
from __future__ import annotations

import os
from pathlib import Path
import stat
import sys

import pytest

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.modules.pop("env2mcp", None)
sys.path.insert(0, str(ROOT / "env2mcp"))

import gh2mcp.sync as sync_module
from gh2mcp.sync import GitHubTokenSyncService
import gh2mcp.sync as sync_module # noqa: E402
from gh2mcp.sync import GitHubTokenSyncService # noqa: E402


@pytest.fixture(autouse=True)
def restore_github_environment():
keys = ("GITHUB_PAT", "GITHUB_TOKEN", "GITHUB_USER", "GITHUB_ORG")
original = {key: os.environ.get(key) for key in keys}
yield
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value


class _GhUnavailable:
Expand Down Expand Up @@ -84,7 +100,70 @@ def test_sync_token_saves_from_env_and_reads_back(monkeypatch, tmp_path: Path):
assert status["token"] == "ghp_env_token_123456"
assert status["token_hint"].startswith("ghp_env_")
assert env_path.exists()
assert 'GITHUB_PAT="ghp_env_token_123456"' in env_path.read_text(encoding="utf-8")
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600
assert "GITHUB_PAT=ghp_env_token_123456" in env_path.read_text(encoding="utf-8")


def test_http_api_never_returns_raw_token(monkeypatch, tmp_path: Path):
import gh2mcp.server as server_module
from fastapi.testclient import TestClient

monkeypatch.setenv("GITHUB_PAT", "ghp_http_secret_123456")
monkeypatch.setenv("GH2MCP_ALLOW_MUTATION", "1")
monkeypatch.setattr(sync_module, "GitHubCLI", _GhUnavailable)
monkeypatch.setattr(
server_module,
"service",
GitHubTokenSyncService(tmp_path / ".env"),
)
client = TestClient(server_module.app)

status = client.get("/status?include_token=true")
assert status.status_code == 200
assert "token" not in status.json()

synced = client.post(
"/sync/token",
json={"force_gh_cli": False, "include_token": True},
)
assert synced.status_code == 200
assert synced.json()["success"] is True
assert "token" not in synced.json()


def test_http_mutations_require_operator_capability(monkeypatch, tmp_path: Path):
import gh2mcp.server as server_module
from fastapi.testclient import TestClient

monkeypatch.delenv("GH2MCP_ALLOW_MUTATION", raising=False)
monkeypatch.setattr(
server_module,
"service",
GitHubTokenSyncService(tmp_path / ".env"),
)
client = TestClient(server_module.app)

synced = client.post("/sync/token", json={"force_gh_cli": False})
assert synced.status_code == 403
assert "GH2MCP_ALLOW_MUTATION" in synced.json()["detail"]

org = client.post("/org/set", json={"org": "semcod"})
assert org.status_code == 403
assert not (tmp_path / ".env").exists()


def test_env_config_rejects_secret_symlink(tmp_path: Path):
target = tmp_path / "target.env"
target.write_text("SAFE=1\n", encoding="utf-8")
env_path = tmp_path / ".env"
env_path.symlink_to(target)

cfg = sync_module.EnvConfig(env_path)
cfg["GITHUB_PAT"] = "ghp_should_not_write"

with pytest.raises(ValueError, match="symlink"):
cfg.save()
assert target.read_text(encoding="utf-8") == "SAFE=1\n"


def test_sync_token_reads_from_env_file_when_env_missing(monkeypatch, tmp_path: Path):
Expand Down Expand Up @@ -128,7 +207,7 @@ def test_set_org_defaults_to_gh_username(monkeypatch, tmp_path: Path):
result = service.set_org(org=None)
assert result["success"] is True
assert result["org"] == "alice"
assert 'GITHUB_ORG="alice"' in env_path.read_text(encoding="utf-8")
assert "GITHUB_ORG=alice" in env_path.read_text(encoding="utf-8")


def test_list_orgs_and_repos(monkeypatch, tmp_path: Path):
Expand Down
Loading