feat(remote): add --verify flag for TLS certificate verification (#4369)

This commit is contained in:
Jeremiah Lowin 2026-06-24 12:09:22 -04:00 committed by GitHub
commit a13e48ea6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 147 additions and 3 deletions

View file

@ -113,6 +113,26 @@ For local development servers over plain HTTP, disable OAuth when the server is
uvx fastmcp-remote http://localhost:8000/mcp --auth none
```
## Self-Signed Certificates
For servers behind a self-signed certificate, point `--verify` at a CA bundle that trusts the certificate:
```bash
uvx fastmcp-remote https://internal.example.com/mcp --verify /path/to/ca-bundle.pem
```
To disable certificate verification entirely, pass `--verify false`. This is insecure and should only be used for trusted servers on private networks:
```bash
uvx fastmcp-remote https://internal.example.com/mcp --verify false
```
To trust a CA bundle without a flag, set the standard `SSL_CERT_FILE` environment variable, which OpenSSL reads automatically:
```bash
SSL_CERT_FILE=/path/to/ca-bundle.pem uvx fastmcp-remote https://internal.example.com/mcp
```
## OAuth Storage
OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory:
@ -140,6 +160,7 @@ uvx fastmcp-remote https://example.com/mcp 3334 --host 127.0.0.1
| `--transport` | Choose `http` or `sse`. Defaults to `http`. |
| `--header` | Add a header to upstream requests, for example `--header "Authorization: Bearer <token>"`. Values may contain colons. Quote headers whose values contain spaces. Use `${VAR}` to expand environment variables inside values. Repeat for multiple headers. |
| `--auth` | Choose `oauth` or `none`. The default uses OAuth unless an `Authorization` header is provided. |
| `--verify` | Control TLS certificate verification. Pass a path to a CA bundle to trust a self-signed certificate, or `false` to disable verification (insecure). Defaults to verification enabled. |
| `--resource` | Isolate OAuth token storage for a named remote resource. |
| `--host` | Set the OAuth callback hostname. Defaults to `localhost`. |
| `--auth-timeout` | Set how long to wait for the OAuth callback. Defaults to 300 seconds. |

View file

@ -73,6 +73,24 @@ Use `--auth none` for unauthenticated development servers:
uvx fastmcp-remote http://localhost:8000/mcp --auth none
```
For servers behind a self-signed certificate, point `--verify` at a CA bundle that trusts the certificate:
```bash
uvx fastmcp-remote https://internal.example.com/mcp --verify /path/to/ca-bundle.pem
```
To disable certificate verification entirely (insecure, only for trusted private networks), pass `--verify false`:
```bash
uvx fastmcp-remote https://internal.example.com/mcp --verify false
```
A CA bundle can also be supplied through the standard `SSL_CERT_FILE` environment variable, which OpenSSL reads automatically:
```bash
SSL_CERT_FILE=/path/to/ca-bundle.pem uvx fastmcp-remote https://internal.example.com/mcp
```
## Options
- `--transport`: Choose `http` or `sse`. Defaults to `http`.
@ -82,5 +100,6 @@ uvx fastmcp-remote http://localhost:8000/mcp --auth none
- `--auth-timeout`: Set how long to wait for the OAuth callback. Defaults to 300 seconds.
- `--ignore-tool`: Hide tools whose names match a glob pattern.
- `--auth`: Choose `oauth` or `none`. The default uses OAuth unless an `Authorization` header is provided.
- `--verify`: Control TLS certificate verification. Pass a path to a CA bundle to trust a self-signed certificate, or `false` to disable verification (insecure). Defaults to verification enabled.
OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory.

View file

@ -45,6 +45,7 @@ class RemoteConfig:
ignore_tools: tuple[str, ...]
show_banner: bool
log_level: str | None
verify: bool | str | None
class IgnoreTools(Transform):
@ -84,6 +85,16 @@ def parse_header(value: str) -> tuple[str, str]:
return name.strip(), expanded_value.strip()
def parse_verify(value: str) -> bool | str:
"""Interpret the --verify value as a boolean toggle or a CA bundle path."""
lowered = value.strip().lower()
if lowered in {"false", "0", "no", "off"}:
return False
if lowered in {"true", "1", "yes", "on"}:
return True
return value
def default_storage_dir(resource: str | None = None) -> Path:
if config_dir := os.environ.get("FASTMCP_REMOTE_CONFIG_DIR"):
base = Path(config_dir).expanduser()
@ -147,6 +158,17 @@ def build_parser() -> argparse.ArgumentParser:
default=[],
help="Hide tools matching this glob pattern. Repeat for multiple patterns.",
)
parser.add_argument(
"--verify",
type=parse_verify,
default=None,
metavar="VERIFY",
help=(
"SSL certificate verification. Pass a path to a CA bundle file, or "
"'false' to disable verification (insecure, for self-signed "
"certificates). Defaults to verification enabled."
),
)
parser.add_argument(
"--debug",
action="store_true",
@ -190,6 +212,7 @@ def parse_args(argv: Sequence[str] | None = None) -> RemoteConfig:
ignore_tools=tuple(args.ignore_tool),
show_banner=not args.silent,
log_level=log_level,
verify=args.verify,
)
@ -228,8 +251,12 @@ def resolve_auth(config: RemoteConfig) -> OAuth | None:
def build_transport(config: RemoteConfig) -> SSETransport | StreamableHttpTransport:
auth = resolve_auth(config)
if config.transport == "sse":
return SSETransport(config.url, headers=config.headers, auth=auth)
return StreamableHttpTransport(config.url, headers=config.headers, auth=auth)
return SSETransport(
config.url, headers=config.headers, auth=auth, verify=config.verify
)
return StreamableHttpTransport(
config.url, headers=config.headers, auth=auth, verify=config.verify
)
async def run(config: RemoteConfig) -> None:

View file

@ -6,7 +6,13 @@ from fastmcp.client.auth import OAuth
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.tools import FunctionTool
from fastmcp.utilities.versions import VersionSpec
from fastmcp_remote.cli import IgnoreTools, build_transport, parse_args, parse_header
from fastmcp_remote.cli import (
IgnoreTools,
build_transport,
parse_args,
parse_header,
parse_verify,
)
def sample_tool() -> str:
@ -164,6 +170,77 @@ def test_sse_transport_strategy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
assert isinstance(transport, SSETransport)
@pytest.mark.parametrize("value", ["false", "False", "0", "no", "off"])
def test_parse_verify_disables_verification(value: str):
assert parse_verify(value) is False
@pytest.mark.parametrize("value", ["true", "True", "1", "yes", "on"])
def test_parse_verify_enables_verification(value: str):
assert parse_verify(value) is True
def test_parse_verify_treats_other_values_as_ca_bundle_path():
assert parse_verify("/etc/ssl/ca-bundle.pem") == "/etc/ssl/ca-bundle.pem"
def test_verify_defaults_to_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(["https://example.com/mcp", "--auth", "none"])
assert config.verify is None
assert build_transport(config).verify is None
def test_verify_false_disables_verification_on_transport(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(
["https://example.com/mcp", "--auth", "none", "--verify", "false"]
)
transport = build_transport(config)
assert config.verify is False
assert isinstance(transport, StreamableHttpTransport)
assert transport.verify is False
def test_verify_ca_bundle_path_passes_to_transport(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
ca_bundle = "/etc/ssl/custom-ca.pem"
config = parse_args(
["https://example.com/mcp", "--auth", "none", "--verify", ca_bundle]
)
assert build_transport(config).verify == ca_bundle
def test_verify_passes_to_sse_transport(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(
[
"https://example.com/sse",
"--transport",
"sse",
"--auth",
"none",
"--verify",
"false",
]
)
transport = build_transport(config)
assert isinstance(transport, SSETransport)
assert transport.verify is False
async def test_ignore_tools_transform_filters_matching_names():
tool = FunctionTool.from_function(sample_tool, name="delete_user")
transform = IgnoreTools(["delete*"])