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
42 changes: 41 additions & 1 deletion docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1517,19 +1517,59 @@ gaia mcp test --query "Hello from GAIA MCP!"
|--------|---------|-------------|
| `--host` | `localhost` | Bind address |
| `--port` | `8765` | Port for the MCP bridge |
| `--auth-token` | _none_ | If set, require `Authorization: Bearer <token>` on every request |
| `--auth-token` | `$GAIA_MCP_AUTH_TOKEN` | Require `Authorization: Bearer <token>` on every request except `/health` |
| `--no-streaming` | off | Disable SSE streaming; reply synchronously |
| `--background` | off | Detach; use `gaia mcp stop` to terminate |
| `--log-file` | stdout | Write logs to this path (useful with `--background`) |
| `--verbose` | off | Verbose logging |
| `--ctx-size` | `32768` | Context window hint passed to Lemonade for loaded models |

##### Authentication

The bridge is **unauthenticated by default**. Anything that can reach the port
can enumerate and invoke its tools, which is why `--host` defaults to
`localhost`. Pass `--auth-token` before exposing it anywhere else:

```bash
export GAIA_MCP_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
gaia mcp start --host 0.0.0.0 --port 8765
```

Prefer the environment variable over `--auth-token <token>`: on Linux and macOS
command-line arguments are visible to any local user via `ps`.

Clients then send the token on every call:

```bash
curl -H "Authorization: Bearer $GAIA_MCP_AUTH_TOKEN" http://localhost:8765/status
```

| Request | Response |
|---------|----------|
| Valid `Bearer` token | Normal response |
| Missing or malformed `Authorization` header | `401` |
| Well-formed header, wrong token | `403` |

`GET /health` stays public so container and orchestrator liveness probes keep
working; it reports only liveness plus agent/tool **counts**. Every other
endpoint β€” `/status`, `/tools`, `/chat`, `/llm`, `/jira`, `/summarize`, and the
JSON-RPC endpoint β€” requires the token. `gaia mcp status`, `gaia mcp test`, and
`gaia mcp agent` accept `--auth-token` (also defaulting to
`$GAIA_MCP_AUTH_TOKEN`) so they can reach a protected bridge.

<Warning>
The bridge speaks plain HTTP. A bearer token stops anonymous callers but does
not encrypt traffic β€” put it behind a TLS-terminating reverse proxy, or keep it
on loopback, before sending a token across an untrusted network.
</Warning>

#### `gaia mcp test` options

| Option | Default | Description |
|--------|---------|-------------|
| `--query` | _required_ | Text to send to the server |
| `--tool` | `gaia.chat` | Which MCP tool to invoke when testing |
| `--auth-token` | `$GAIA_MCP_AUTH_TOKEN` | Bearer token, if the bridge requires one |

#### `gaia mcp agent`

Expand Down
104 changes: 93 additions & 11 deletions src/gaia/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2748,7 +2748,12 @@ def build_parser():
)
# Note: --base-url is inherited from parent_parser
mcp_start_parser.add_argument(
"--auth-token", help="Optional authentication token for secure connections"
"--auth-token",
help=(
"Require 'Authorization: Bearer <token>' on every request except "
"/health. Defaults to $GAIA_MCP_AUTH_TOKEN. Without it the bridge "
"is unauthenticated."
),
)
mcp_start_parser.add_argument(
"--no-streaming", action="store_true", help="Disable streaming responses"
Expand Down Expand Up @@ -2783,6 +2788,10 @@ def build_parser():
mcp_status_parser.add_argument(
"--port", type=int, default=8765, help="Port to check (default: 8765)"
)
mcp_status_parser.add_argument(
"--auth-token",
help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)",
)

# MCP stop command
_ = mcp_subparsers.add_parser("stop", help="Stop background MCP bridge server")
Expand All @@ -2803,6 +2812,10 @@ def build_parser():
mcp_test_parser.add_argument(
"--tool", default="gaia.chat", help="Tool to test (default: gaia.chat)"
)
mcp_test_parser.add_argument(
"--auth-token",
help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)",
)

# MCP agent command
mcp_agent_parser = mcp_subparsers.add_parser(
Expand All @@ -2825,6 +2838,10 @@ def build_parser():
mcp_agent_parser.add_argument(
"--context", help="Optional additional context about the request"
)
mcp_agent_parser.add_argument(
"--auth-token",
help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)",
)

# MCP Docker command (per-agent MCP server)
mcp_docker_parser = mcp_subparsers.add_parser(
Expand Down Expand Up @@ -8147,6 +8164,18 @@ def handle_mcp_command(args):
print(f"❌ Unknown MCP action: {args.mcp_action}")


# Kept in sync with gaia.mcp.mcp_bridge.AUTH_TOKEN_ENV_VAR by
# tests/unit/test_mcp_bridge_auth.py β€” duplicated here so the client-side
# `mcp status` / `mcp test` paths don't have to import the heavy bridge module.
MCP_AUTH_TOKEN_ENV = "GAIA_MCP_AUTH_TOKEN"


def _mcp_auth_headers(args):
"""Bearer headers for reaching a token-protected MCP bridge, else {}."""
token = getattr(args, "auth_token", None) or os.environ.get(MCP_AUTH_TOKEN_ENV)
return {"Authorization": f"Bearer {token}"} if token else {}


def handle_mcp_start(args):
"""Start the MCP bridge server (HTTP-native implementation)."""
log = get_logger(__name__)
Expand Down Expand Up @@ -8213,18 +8242,27 @@ def handle_mcp_start(args):
# Add optional arguments if provided
if args.base_url:
cmd_args.extend(["--base-url", args.base_url])
if args.auth_token:
cmd_args.extend(["--auth-token", args.auth_token])
if args.no_streaming:
cmd_args.append("--no-streaming")
if getattr(args, "verbose", False):
cmd_args.append("--verbose")
if getattr(args, "no_lemonade_check", False):
cmd_args.append("--no-lemonade-check")

# Hand the token over the environment, not argv β€” argv is world
# readable via `ps` on Linux/macOS.
child_env = os.environ.copy()
bg_token = args.auth_token or os.environ.get(MCP_AUTH_TOKEN_ENV) or None
if bg_token:
child_env[MCP_AUTH_TOKEN_ENV] = bg_token

print("πŸš€ Starting GAIA MCP Bridge in background")
print(f"πŸ“ Host: {args.host}:{args.port}")
print(f"πŸ“„ Log file: {log_file_path}")
if bg_token:
print("πŸ”’ Authentication enabled (Bearer token required)")
else:
print("πŸ”“ Authentication disabled - pass --auth-token to require one")

# Write initial banner BEFORE starting subprocess (prevents truncation issues)
import datetime
Expand Down Expand Up @@ -8254,6 +8292,7 @@ def handle_mcp_start(args):
stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
cwd=os.getcwd(),
env=child_env,
text=True,
)
else:
Expand All @@ -8265,6 +8304,7 @@ def handle_mcp_start(args):
stderr=subprocess.STDOUT,
start_new_session=True,
cwd=os.getcwd(),
env=child_env,
text=True,
)
except Exception:
Expand Down Expand Up @@ -8294,8 +8334,11 @@ def handle_mcp_start(args):
log.info("Starting GAIA MCP Bridge on %s:%s", args.host, args.port)
print(f"πŸš€ Starting GAIA MCP Bridge on {args.host}:{args.port}")

if args.auth_token:
print("πŸ”’ Authentication enabled")
auth_token = args.auth_token or os.environ.get(MCP_AUTH_TOKEN_ENV) or None
if auth_token:
print("πŸ”’ Authentication enabled (Bearer token required; /health public)")
else:
print("πŸ”“ Authentication disabled - pass --auth-token to require one")

print(f"πŸ”— GAIA LLM server: {args.base_url}")
print(f"πŸ“‘ Streaming: {'disabled' if args.no_streaming else 'enabled'}")
Expand All @@ -8307,7 +8350,11 @@ def handle_mcp_start(args):
# Start HTTP-native MCP bridge
verbose = getattr(args, "verbose", False)
start_mcp_http(
host=args.host, port=args.port, base_url=args.base_url, verbose=verbose
host=args.host,
port=args.port,
base_url=args.base_url,
verbose=verbose,
auth_token=auth_token,
)

except KeyboardInterrupt:
Expand Down Expand Up @@ -8406,8 +8453,12 @@ def handle_mcp_status(args):

# First try the new /status endpoint
status_url = f"http://{args.host}:{args.port}/status"
auth_headers = _mcp_auth_headers(args)
try:
with urllib.request.urlopen(status_url, timeout=3) as response:
status_req = urllib.request.Request(
status_url, headers=auth_headers
)
with urllib.request.urlopen(status_req, timeout=3) as response:
data = json.loads(response.read().decode())

if data.get("status") == "healthy":
Expand Down Expand Up @@ -8450,6 +8501,13 @@ def handle_mcp_status(args):
else:
print("⚠️ Server is running but may not be healthy")
except urllib.error.HTTPError as e:
if e.code in (401, 403):
print("πŸ”’ MCP server requires authentication")
print(
" Pass --auth-token <token> or set "
f"{MCP_AUTH_TOKEN_ENV} to inspect it"
)
return
if e.code == 404:
# Fall back to /health for older versions
health_url = f"http://{args.host}:{args.port}/health"
Expand Down Expand Up @@ -8524,7 +8582,12 @@ def handle_mcp_test(args):
url = f"http://{args.host}:{args.port}/"
data = json.dumps(rpc_request).encode("utf-8")
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}
url,
data=data,
headers={
"Content-Type": "application/json",
**_mcp_auth_headers(args),
},
)

with urllib.request.urlopen(req, timeout=30) as response:
Expand Down Expand Up @@ -8553,7 +8616,14 @@ def handle_mcp_test(args):
print("❌ Unexpected response format")

except urllib.error.HTTPError as e:
print(f"❌ HTTP Error: {e.code} {e.reason}")
if e.code in (401, 403):
print(f"πŸ”’ MCP server rejected the request ({e.code})")
print(
" The bridge was started with --auth-token. Pass the same "
f"token via --auth-token, or set {MCP_AUTH_TOKEN_ENV}."
)
else:
print(f"❌ HTTP Error: {e.code} {e.reason}")
except urllib.error.URLError as e:
print(f"❌ Connection error: {e.reason}")
except json.JSONDecodeError as e:
Expand Down Expand Up @@ -8613,7 +8683,12 @@ def handle_mcp_agent(args):
url = f"http://{args.host}:{args.port}/"
data = json.dumps(rpc_request).encode("utf-8")
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}
url,
data=data,
headers={
"Content-Type": "application/json",
**_mcp_auth_headers(args),
},
)

print("πŸ”„ Agent is analyzing request and orchestrating tools...")
Expand Down Expand Up @@ -8671,7 +8746,14 @@ def handle_mcp_agent(args):
print("❌ Unexpected response format")

except urllib.error.HTTPError as e:
print(f"❌ HTTP Error: {e.code} {e.reason}")
if e.code in (401, 403):
print(f"πŸ”’ MCP server rejected the request ({e.code})")
print(
" The bridge was started with --auth-token. Pass the same "
f"token via --auth-token, or set {MCP_AUTH_TOKEN_ENV}."
)
else:
print(f"❌ HTTP Error: {e.code} {e.reason}")
except urllib.error.URLError as e:
print(f"❌ Connection error: {e.reason}")
except json.JSONDecodeError as e:
Expand Down
Loading
Loading