From a13e48ea6ff277e6e735346141f3fc1031bd2423 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:09:22 -0400 Subject: [PATCH] feat(remote): add --verify flag for TLS certificate verification (#4369) --- docs/clients/fastmcp-remote.mdx | 21 ++++++++ fastmcp_remote/README.md | 19 +++++++ fastmcp_remote/fastmcp_remote/cli.py | 31 ++++++++++- tests/cli/test_fastmcp_remote.py | 79 +++++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/docs/clients/fastmcp-remote.mdx b/docs/clients/fastmcp-remote.mdx index b60eeb118..ee218afe0 100644 --- a/docs/clients/fastmcp-remote.mdx +++ b/docs/clients/fastmcp-remote.mdx @@ -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 "`. 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. | diff --git a/fastmcp_remote/README.md b/fastmcp_remote/README.md index 641fee72e..7f576eb70 100644 --- a/fastmcp_remote/README.md +++ b/fastmcp_remote/README.md @@ -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. diff --git a/fastmcp_remote/fastmcp_remote/cli.py b/fastmcp_remote/fastmcp_remote/cli.py index c0878a311..6c917eef9 100644 --- a/fastmcp_remote/fastmcp_remote/cli.py +++ b/fastmcp_remote/fastmcp_remote/cli.py @@ -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: diff --git a/tests/cli/test_fastmcp_remote.py b/tests/cli/test_fastmcp_remote.py index b07545f56..522bc0190 100644 --- a/tests/cli/test_fastmcp_remote.py +++ b/tests/cli/test_fastmcp_remote.py @@ -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*"])