Skip to content

Commit d09de51

Browse files
committed
feat: Add authentication to the endpoints
1 parent 1e5091d commit d09de51

File tree

6 files changed

+313
-0
lines changed

6 files changed

+313
-0
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [2.1 Configuration](#21-configuration)
1616
- [2.2 Example usage](#22-example-usage)
1717
- [Named Servers](#named-servers)
18+
- [Authentication](#authentication)
1819
- [Installation](#installation)
1920
- [Installing via Smithery](#installing-via-smithery)
2021
- [Installing via PyPI](#installing-via-pypi)
@@ -126,6 +127,7 @@ Arguments
126127
| `--cwd` | No | The working directory to pass to the MCP stdio server process. | /tmp |
127128
| `--pass-environment` | No | Pass through all environment variables when spawning the server | --no-pass-environment |
128129
| `--allow-origin` | No | Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed. | --allow-origin "\*" |
130+
| `--api-key` | No | API key for authentication. Can also be set via MCP_PROXY_API_KEY env var. | --api-key YOUR_SECRET_KEY |
129131
| `--stateless` | No | Enable stateless mode for streamable http transports. Default is False | --no-stateless |
130132
| `--named-server NAME COMMAND_STRING` | No | Defines a named stdio server. | --named-server fetch 'uvx mcp-server-fetch' |
131133
| `--named-server-config FILE_PATH` | No | Path to a JSON file defining named stdio servers. | --named-server-config /path/to/servers.json |
@@ -211,6 +213,46 @@ The JSON file should follow this structure:
211213
- `enabled`: (Optional) If `false`, this server definition will be skipped. Defaults to `true`.
212214
- `timeout` and `transportType`: These fields are present in standard MCP client configurations but are currently **ignored** by `mcp-proxy` when loading named servers. The transport type is implicitly "stdio".
213215

216+
## Authentication
217+
218+
The MCP proxy supports optional API key authentication to protect your endpoints. When enabled, all requests to `/sse` and `/mcp` endpoints (including named server paths like `/servers/*/sse` and `/servers/*/mcp`) require a valid API key.
219+
220+
### Configuration
221+
222+
You can configure authentication in two ways:
223+
224+
1. **Command-line argument**: `--api-key YOUR_SECRET_KEY`
225+
2. **Environment variable**: `MCP_PROXY_API_KEY=YOUR_SECRET_KEY`
226+
227+
If no API key is configured, authentication is disabled by default (backward compatible).
228+
229+
### Usage
230+
231+
When authentication is enabled, clients must include the API key in their requests using the `X-API-Key` header (case-insensitive):
232+
233+
```bash
234+
# Example: Connecting to a protected SSE endpoint
235+
curl -H "X-API-Key: YOUR_SECRET_KEY" http://localhost:8080/sse
236+
237+
# Example: Starting a protected server
238+
mcp-proxy --port 8080 --api-key YOUR_SECRET_KEY uvx mcp-server-fetch
239+
240+
# Example: Using environment variable
241+
export MCP_PROXY_API_KEY=YOUR_SECRET_KEY
242+
mcp-proxy --port 8080 uvx mcp-server-fetch
243+
```
244+
245+
### Protected Endpoints
246+
247+
- `/sse` and `/servers/*/sse` - SSE endpoints
248+
- `/mcp` and `/servers/*/mcp` - MCP endpoints
249+
- `/messages/*` - Message endpoints
250+
251+
### Unprotected Endpoints
252+
253+
- `/status` - Health check endpoint (always accessible)
254+
- OPTIONS requests - CORS preflight requests
255+
214256
## Installation
215257

216258
### Installing via Smithery
@@ -361,6 +403,7 @@ SSE server options:
361403
--sse-host SSE_HOST (deprecated) Same as --host
362404
--allow-origin ALLOW_ORIGIN [ALLOW_ORIGIN ...]
363405
Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed.
406+
--api-key API_KEY API key for authentication. Can also be set via MCP_PROXY_API_KEY env var. If not provided, authentication is disabled.
364407
365408
Examples:
366409
mcp-proxy http://localhost:8080/sse
@@ -372,6 +415,7 @@ Examples:
372415
mcp-proxy --port 8080 --named-server-config /path/to/servers.json -- my-default-command --arg1
373416
mcp-proxy --port 8080 -e KEY VALUE -e ANOTHER_KEY ANOTHER_VALUE -- my-default-command
374417
mcp-proxy --port 8080 --allow-origin='*' -- my-default-command
418+
mcp-proxy --port 8080 --api-key YOUR_SECRET_KEY -- my-default-command
375419
```
376420

377421
### Example config file

src/mcp_proxy/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ def _add_arguments_to_parser(parser: argparse.ArgumentParser) -> None:
201201
"Default is no CORS allowed."
202202
),
203203
)
204+
mcp_server_group.add_argument(
205+
"--api-key",
206+
default=os.getenv("MCP_PROXY_API_KEY"),
207+
help=(
208+
"API key for authentication. Can also be set via MCP_PROXY_API_KEY env var. "
209+
"If not provided, authentication is disabled."
210+
),
211+
)
204212

205213

206214
def _setup_logging(*, debug: bool) -> logging.Logger:
@@ -335,6 +343,7 @@ def _create_mcp_settings(args_parsed: argparse.Namespace) -> MCPServerSettings:
335343
stateless=args_parsed.stateless,
336344
allow_origins=args_parsed.allow_origin if len(args_parsed.allow_origin) > 0 else None,
337345
log_level="DEBUG" if args_parsed.debug else "INFO",
346+
api_key=args_parsed.api_key,
338347
)
339348

340349

src/mcp_proxy/auth.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Simple authentication middleware for MCP proxy."""
2+
3+
import logging
4+
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse, Response
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class AuthMiddleware(BaseHTTPMiddleware):
13+
"""Simple API key authentication middleware."""
14+
15+
def __init__(self, app, api_key: str | None = None) -> None:
16+
"""Initialize middleware with optional API key."""
17+
super().__init__(app)
18+
self.api_key = api_key
19+
20+
async def dispatch(self, request: Request, call_next) -> Response:
21+
"""Check API key for protected endpoints."""
22+
# Skip auth if no API key configured
23+
if not self.api_key:
24+
return await call_next(request)
25+
26+
# Allow OPTIONS (CORS preflight) and /status endpoint
27+
if request.method == "OPTIONS" or request.url.path == "/status":
28+
return await call_next(request)
29+
30+
# Check if path needs protection (/sse, /mcp, /messages, /servers/*/sse, /servers/*/mcp)
31+
path = request.url.path
32+
needs_auth = (
33+
path.startswith("/sse") or
34+
path.startswith("/mcp") or
35+
path.startswith("/messages") or
36+
"/sse" in path or
37+
"/mcp" in path
38+
)
39+
40+
if not needs_auth:
41+
return await call_next(request)
42+
43+
# Check for API key in headers (case-insensitive)
44+
api_key = request.headers.get("x-api-key", "")
45+
46+
if api_key != self.api_key:
47+
logger.warning("Auth failed for %s %s", request.method, path)
48+
return JSONResponse(
49+
{"error": "Unauthorized"},
50+
status_code=401
51+
)
52+
53+
return await call_next(request)

src/mcp_proxy/mcp_server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from starlette.routing import BaseRoute, Mount, Route
2222
from starlette.types import Receive, Scope, Send
2323

24+
from .auth import AuthMiddleware
2425
from .proxy_server import create_proxy_server
2526

2627
logger = logging.getLogger(__name__)
@@ -35,6 +36,7 @@ class MCPServerSettings:
3536
stateless: bool = False
3637
allow_origins: list[str] | None = None
3738
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
39+
api_key: str | None = None
3840

3941

4042
# To store last activity for multiple servers if needed, though status endpoint is global for now.
@@ -169,6 +171,10 @@ async def combined_lifespan(_app: Starlette) -> AsyncIterator[None]:
169171
return
170172

171173
middleware: list[Middleware] = []
174+
if mcp_settings.api_key:
175+
middleware.append(
176+
Middleware(AuthMiddleware, api_key=mcp_settings.api_key),
177+
)
172178
if mcp_settings.allow_origins:
173179
middleware.append(
174180
Middleware(

tests/test_auth_simple.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Simplified tests for authentication middleware."""
2+
3+
import pytest
4+
from starlette.applications import Starlette
5+
from starlette.middleware import Middleware
6+
from starlette.responses import JSONResponse
7+
from starlette.routing import Route
8+
from starlette.testclient import TestClient
9+
10+
from mcp_proxy.auth import AuthMiddleware
11+
12+
13+
async def dummy_endpoint(request):
14+
"""Simple endpoint for testing."""
15+
return JSONResponse({"message": "success"})
16+
17+
18+
async def status_endpoint(request):
19+
"""Status endpoint."""
20+
return JSONResponse({"status": "ok"})
21+
22+
23+
def create_app_without_auth():
24+
"""Create app without authentication."""
25+
routes = [
26+
Route("/sse", dummy_endpoint),
27+
Route("/mcp/test", dummy_endpoint),
28+
Route("/messages/test", dummy_endpoint),
29+
Route("/status", status_endpoint),
30+
Route("/other", dummy_endpoint),
31+
]
32+
return Starlette(routes=routes)
33+
34+
35+
def create_app_with_auth():
36+
"""Create app with authentication."""
37+
routes = [
38+
Route("/sse", dummy_endpoint),
39+
Route("/mcp/test", dummy_endpoint),
40+
Route("/messages/test", dummy_endpoint),
41+
Route("/status", status_endpoint),
42+
Route("/other", dummy_endpoint),
43+
Route("/servers/test/sse", dummy_endpoint),
44+
Route("/servers/test/mcp", dummy_endpoint),
45+
]
46+
middleware = [Middleware(AuthMiddleware, api_key="test-api-key")]
47+
return Starlette(routes=routes, middleware=middleware)
48+
49+
50+
def test_no_auth_allows_all():
51+
"""Test that all requests work without authentication configured."""
52+
app = create_app_without_auth()
53+
with TestClient(app) as client:
54+
assert client.get("/sse").status_code == 200
55+
assert client.get("/mcp/test").status_code == 200
56+
assert client.get("/status").status_code == 200
57+
58+
59+
def test_auth_blocks_protected_endpoints():
60+
"""Test that protected endpoints are blocked without API key."""
61+
app = create_app_with_auth()
62+
with TestClient(app) as client:
63+
response = client.get("/sse")
64+
assert response.status_code == 401
65+
assert response.json() == {"error": "Unauthorized"}
66+
67+
response = client.get("/mcp/test")
68+
assert response.status_code == 401
69+
70+
response = client.get("/messages/test")
71+
assert response.status_code == 401
72+
73+
74+
def test_auth_allows_with_key():
75+
"""Test that requests work with correct API key."""
76+
app = create_app_with_auth()
77+
with TestClient(app) as client:
78+
headers = {"x-api-key": "test-api-key"}
79+
80+
response = client.get("/sse", headers=headers)
81+
assert response.status_code == 200
82+
assert response.json() == {"message": "success"}
83+
84+
response = client.get("/mcp/test", headers=headers)
85+
assert response.status_code == 200
86+
87+
88+
def test_auth_blocks_wrong_key():
89+
"""Test that requests are blocked with wrong API key."""
90+
app = create_app_with_auth()
91+
with TestClient(app) as client:
92+
headers = {"x-api-key": "wrong-key"}
93+
94+
response = client.get("/sse", headers=headers)
95+
assert response.status_code == 401
96+
97+
98+
def test_status_not_protected():
99+
"""Test that /status endpoint is not protected."""
100+
app = create_app_with_auth()
101+
with TestClient(app) as client:
102+
response = client.get("/status")
103+
assert response.status_code == 200
104+
assert response.json() == {"status": "ok"}
105+
106+
107+
def test_other_endpoints_not_protected():
108+
"""Test that non-SSE/MCP endpoints are not protected."""
109+
app = create_app_with_auth()
110+
with TestClient(app) as client:
111+
response = client.get("/other")
112+
assert response.status_code == 200
113+
114+
115+
def test_options_allowed():
116+
"""Test that OPTIONS requests are allowed without auth."""
117+
app = create_app_with_auth()
118+
with TestClient(app) as client:
119+
response = client.options("/sse")
120+
assert response.status_code != 401
121+
122+
123+
def test_case_insensitive_header():
124+
"""Test that API key header is case-insensitive."""
125+
app = create_app_with_auth()
126+
with TestClient(app) as client:
127+
# Different case variations
128+
headers = {"X-API-KEY": "test-api-key"}
129+
response = client.get("/sse", headers=headers)
130+
assert response.status_code == 200
131+
132+
headers = {"X-Api-Key": "test-api-key"}
133+
response = client.get("/sse", headers=headers)
134+
assert response.status_code == 200
135+
136+
137+
def test_named_servers_protected():
138+
"""Test that named server endpoints are protected."""
139+
app = create_app_with_auth()
140+
with TestClient(app) as client:
141+
# Without auth
142+
response = client.get("/servers/test/sse")
143+
assert response.status_code == 401
144+
145+
response = client.get("/servers/test/mcp")
146+
assert response.status_code == 401
147+
148+
# With auth
149+
headers = {"x-api-key": "test-api-key"}
150+
response = client.get("/servers/test/sse", headers=headers)
151+
assert response.status_code == 200
152+
153+
response = client.get("/servers/test/mcp", headers=headers)
154+
assert response.status_code == 200
155+

tests/test_mcp_server.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,51 @@ async def test_run_mcp_server_exception_handling(
621621
assert "Connection failed" in str(e) # noqa: PT017
622622

623623

624+
async def test_run_mcp_server_with_authentication(
625+
mock_stdio_params: StdioServerParameters,
626+
) -> None:
627+
"""Test run_mcp_server with authentication enabled."""
628+
auth_settings = MCPServerSettings(
629+
bind_host="127.0.0.1",
630+
port=8080,
631+
api_key="test-secret-key",
632+
)
633+
634+
with (
635+
patch("mcp_proxy.mcp_server.stdio_client") as mock_stdio_client,
636+
patch("mcp_proxy.mcp_server.ClientSession") as mock_client_session,
637+
patch("mcp_proxy.mcp_server.create_proxy_server") as mock_create_proxy,
638+
patch("mcp_proxy.mcp_server.create_single_instance_routes") as mock_create_routes,
639+
patch("mcp_proxy.mcp_server.Starlette") as mock_starlette,
640+
patch("uvicorn.Server") as mock_uvicorn_server,
641+
):
642+
# Setup mocks
643+
mock_stdio_context, mock_session_context, mock_session, mock_http_manager, mock_routes = (
644+
setup_async_context_mocks()
645+
)
646+
mock_stdio_client.return_value = mock_stdio_context
647+
mock_client_session.return_value = mock_session_context
648+
649+
mock_proxy = AsyncMock()
650+
mock_create_proxy.return_value = mock_proxy
651+
mock_create_routes.return_value = (mock_routes, mock_http_manager)
652+
653+
mock_server_instance = AsyncMock()
654+
mock_uvicorn_server.return_value = mock_server_instance
655+
656+
# Run the function
657+
await run_mcp_server(auth_settings, mock_stdio_params, {})
658+
659+
# Verify Starlette was called with AuthMiddleware
660+
mock_starlette.assert_called_once()
661+
call_args = mock_starlette.call_args
662+
middleware = call_args.kwargs["middleware"]
663+
664+
assert len(middleware) == 1
665+
assert middleware[0].cls.__name__ == "AuthMiddleware"
666+
assert middleware[0].kwargs == {"api_key": "test-secret-key"}
667+
668+
624669
async def test_run_mcp_server_both_default_and_named_servers(
625670
mock_settings: MCPServerSettings,
626671
mock_stdio_params: StdioServerParameters,
@@ -672,3 +717,4 @@ async def test_run_mcp_server_both_default_and_named_servers(
672717
)
673718

674719
mock_server_instance.serve.assert_called_once()
720+

0 commit comments

Comments
 (0)