diff --git a/README.md b/README.md index 2361df1..c8d4966 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ Edit `.env`: ``` REDMINE_URL=https://your.redmine.com REDMINE_API_KEY=your_personal_api_key_here + +# Optional: enable Bearer token auth for remote access +# MCP_AUTH_TOKEN=your_secret_token_here ``` ### 3. Run the container @@ -77,7 +80,12 @@ The server will be available at `http://localhost:8765`. ### 4. Register with Claude Code ```bash +# Without auth: claude mcp add --scope user --transport http redmine http://localhost:8765/mcp + +# With Bearer token auth: +claude mcp add --scope user --transport http redmine http://localhost:8765/mcp \ + --header "Authorization: Bearer your_secret_token_here" ``` Verify it was added: @@ -98,7 +106,8 @@ curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash ```bash helm install redmine-mcp ./helm \ --set redmine.url=https://your.redmine.com \ - --set redmine.apiKey=your_api_key + --set redmine.apiKey=your_api_key \ + --set auth.token=your_secret_token_here # optional ``` **3. Register with Claude Code** (using port-forward): diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..f7df9ef --- /dev/null +++ b/app/auth.py @@ -0,0 +1,23 @@ +from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.responses import Response + + +class BearerAuthMiddleware: + """ + ASGI middleware that enforces Bearer token authentication. + Only active when MCP_AUTH_TOKEN is set — if empty the request passes through. + """ + + def __init__(self, app: ASGIApp, token: str) -> None: + self.app = app + self.token = token + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + headers = dict(scope.get("headers", [])) + auth = headers.get(b"authorization", b"").decode() + if not auth.startswith("Bearer ") or auth[7:].strip() != self.token: + response = Response("Unauthorized", status_code=401, media_type="text/plain") + await response(scope, receive, send) + return + await self.app(scope, receive, send) diff --git a/compose.yml b/compose.yml index d2d7f0d..f9b4439 100644 --- a/compose.yml +++ b/compose.yml @@ -9,4 +9,5 @@ services: MCP_TRANSPORT: http MCP_HOST: 0.0.0.0 MCP_PORT: 8000 + MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} restart: on-failure diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml index 148f01b..3d2655c 100644 --- a/helm/templates/secret.yaml +++ b/helm/templates/secret.yaml @@ -9,4 +9,7 @@ type: Opaque stringData: REDMINE_URL: {{ .Values.redmine.url | required "redmine.url is required" | quote }} REDMINE_API_KEY: {{ .Values.redmine.apiKey | required "redmine.apiKey is required" | quote }} + {{- if .Values.auth.token }} + MCP_AUTH_TOKEN: {{ .Values.auth.token | quote }} + {{- end }} {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 6db4a7b..a61fdd7 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -12,6 +12,10 @@ redmine: # -- Or reference an existing Secret with keys REDMINE_URL and REDMINE_API_KEY existingSecret: "" +auth: + # Bearer token for HTTP transport (leave empty to disable auth) + token: "" + service: type: ClusterIP port: 8000 diff --git a/server.py b/server.py index a5bf3d5..b7d4d66 100644 --- a/server.py +++ b/server.py @@ -8,9 +8,11 @@ Environment variables: MCP_TRANSPORT — "stdio" (default) or "http" MCP_HOST — bind host for HTTP transport (default: 0.0.0.0) MCP_PORT — bind port for HTTP transport (default: 8000) + MCP_AUTH_TOKEN — Bearer token for HTTP transport auth (optional) """ import os +import uvicorn from app.config import validate from app import mcp # imports entities as a side effect via __init__ @@ -18,8 +20,19 @@ if __name__ == "__main__": validate() transport = os.environ.get("MCP_TRANSPORT", "stdio") if transport == "http": - mcp.settings.host = os.environ.get("MCP_HOST", "0.0.0.0") - mcp.settings.port = int(os.environ.get("MCP_PORT", "8000")) - mcp.run(transport="streamable-http") + host = os.environ.get("MCP_HOST", "0.0.0.0") + port = int(os.environ.get("MCP_PORT", "8000")) + token = os.environ.get("MCP_AUTH_TOKEN", "") + + app = mcp.streamable_http_app() + + if token: + from app.auth import BearerAuthMiddleware + app = BearerAuthMiddleware(app, token) + print("Auth: Bearer token enabled.", flush=True) + else: + print("Auth: disabled (MCP_AUTH_TOKEN not set).", flush=True) + + uvicorn.run(app, host=host, port=port) else: mcp.run(transport="stdio")