Skip to content
Open
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
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [2.1 Configuration](#21-configuration)
- [2.2 Example usage](#22-example-usage)
- [Named Servers](#named-servers)
- [Authentication](#authentication)
- [Installation](#installation)
- [Installing via PyPI](#installing-via-pypi)
- [Installing via Github repository (latest)](#installing-via-github-repository-latest)
Expand Down Expand Up @@ -121,9 +122,10 @@ Arguments
| `--port` | No, random available | The MCP server port to listen on | 8080 |
| `--host` | No, `127.0.0.1` by default | The host IP address that the MCP server will listen on | 0.0.0.0 |
| `--env` | No | Additional environment variables to pass to the MCP stdio server. Can be used multiple times. | FOO BAR |
| `--cwd` | No | The working directory to pass to the MCP stdio server process. | /tmp |
| `--pass-environment` | No | Pass through all environment variables when spawning the server | --no-pass-environment |
| `--allow-origin` | No | Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed. | --allow-origin "\*" |
| `--cwd` | No | The working directory to pass to the MCP stdio server process. | /tmp |
| `--pass-environment` | No | Pass through all environment variables when spawning the server | --no-pass-environment |
| `--allow-origin` | No | Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed. | --allow-origin "\*" |
| `--api-key` | No | API key for authentication. Can also be set via MCP_PROXY_API_KEY env var. | --api-key YOUR_SECRET_KEY |
| `--stateless` | No | Enable stateless mode for streamable http transports. Default is False | --no-stateless |
| `--named-server NAME COMMAND_STRING` | No | Defines a named stdio server. | --named-server fetch 'uvx mcp-server-fetch' |
| `--named-server-config FILE_PATH` | No | Path to a JSON file defining named stdio servers. | --named-server-config /path/to/servers.json |
Expand Down Expand Up @@ -209,6 +211,46 @@ The JSON file should follow this structure:
- `enabled`: (Optional) If `false`, this server definition will be skipped. Defaults to `true`.
- `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".

## Authentication

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.

### Configuration

You can configure authentication in two ways:

1. **Command-line argument**: `--api-key YOUR_SECRET_KEY`
2. **Environment variable**: `MCP_PROXY_API_KEY=YOUR_SECRET_KEY`

If no API key is configured, authentication is disabled by default (backward compatible).

### Usage

When authentication is enabled, clients must include the API key in their requests using the `X-API-Key` header (case-insensitive):

```bash
# Example: Connecting to a protected SSE endpoint
curl -H "X-API-Key: YOUR_SECRET_KEY" http://localhost:8080/sse

# Example: Starting a protected server
mcp-proxy --port 8080 --api-key YOUR_SECRET_KEY uvx mcp-server-fetch

# Example: Using environment variable
export MCP_PROXY_API_KEY=YOUR_SECRET_KEY
mcp-proxy --port 8080 uvx mcp-server-fetch
```

### Protected Endpoints

- `/sse` and `/servers/*/sse` - SSE endpoints
- `/mcp` and `/servers/*/mcp` - MCP endpoints
- `/messages/*` - Message endpoints

### Unprotected Endpoints

- `/status` - Health check endpoint (always accessible)
- OPTIONS requests - CORS preflight requests

## Installation

### Installing via PyPI
Expand Down Expand Up @@ -352,6 +394,7 @@ SSE server options:
--sse-host SSE_HOST (deprecated) Same as --host
--allow-origin ALLOW_ORIGIN [ALLOW_ORIGIN ...]
Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed.
--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.

Examples:
mcp-proxy http://localhost:8080/sse
Expand All @@ -363,6 +406,7 @@ Examples:
mcp-proxy --port 8080 --named-server-config /path/to/servers.json -- my-default-command --arg1
mcp-proxy --port 8080 -e KEY VALUE -e ANOTHER_KEY ANOTHER_VALUE -- my-default-command
mcp-proxy --port 8080 --allow-origin='*' -- my-default-command
mcp-proxy --port 8080 --api-key YOUR_SECRET_KEY -- my-default-command
```

### Example config file
Expand Down
9 changes: 9 additions & 0 deletions src/mcp_proxy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ def _add_arguments_to_parser(parser: argparse.ArgumentParser) -> None:
"Default is no CORS allowed."
),
)
mcp_server_group.add_argument(
"--api-key",
default=os.getenv("MCP_PROXY_API_KEY"),
help=(
"API key for authentication. Can also be set via MCP_PROXY_API_KEY env var. "
"If not provided, authentication is disabled."
),
)


def _setup_logging(*, level: str, debug: bool) -> logging.Logger:
Expand Down Expand Up @@ -346,6 +354,7 @@ def _create_mcp_settings(args_parsed: argparse.Namespace) -> MCPServerSettings:
stateless=args_parsed.stateless,
allow_origins=args_parsed.allow_origin if len(args_parsed.allow_origin) > 0 else None,
log_level="DEBUG" if args_parsed.debug else args_parsed.log_level,
api_key=args_parsed.api_key,
)


Expand Down
55 changes: 55 additions & 0 deletions src/mcp_proxy/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Simple authentication middleware for MCP proxy."""

import logging
from collections.abc import Awaitable, Callable

from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

logger = logging.getLogger(__name__)


class AuthMiddleware(BaseHTTPMiddleware):
"""Simple API key authentication middleware."""

def __init__(self, app: Starlette, api_key: str | None = None) -> None:
"""Initialize middleware with optional API key."""
super().__init__(app)
self.api_key = api_key

async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Check API key for protected endpoints."""
# Skip auth if no API key configured
if not self.api_key:
return await call_next(request)

# Allow OPTIONS (CORS preflight) and /status endpoint
if request.method == "OPTIONS" or request.url.path == "/status":
return await call_next(request)

# Check if path needs protection (/sse, /mcp, /messages, /servers/*/sse, /servers/*/mcp)
path = request.url.path
needs_auth = (
path.startswith(("/sse", "/mcp", "/messages")) or "/sse" in path or "/mcp" in path
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path protection logic is duplicated and could match unintended paths. For example, /sse in path would match /something/sse/other. Consider using a more precise pattern matching approach or regex to avoid false positives.

Copilot uses AI. Check for mistakes.
)

if not needs_auth:
return await call_next(request)

# Check for API key in headers (case-insensitive)
api_key = request.headers.get("x-api-key", "")

if api_key != self.api_key:
logger.warning("Auth failed for %s %s", request.method, path)
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
)

return await call_next(request)
Loading
Loading