Add fastmcp-remote bridge package (#4208)

This commit is contained in:
Jeremiah Lowin 2026-05-23 10:04:21 -04:00 committed by GitHub
commit 70013c5a91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 993 additions and 14 deletions

View file

@ -0,0 +1,87 @@
name: Publish fastmcp-remote to PyPI
on:
workflow_run:
workflows: ["Publish fastmcp-slim to PyPI"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
pypi-publish:
name: Upload fastmcp-remote to PyPI
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-remote
run: uv build --package fastmcp-remote
- name: Verify matching fastmcp-slim is published
run: |
SLIM_VERSION=$(python - <<'PY'
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp_remote-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
for value in metadata.get_all("Requires-Dist", []):
requirement, _, marker = value.partition(";")
if marker.strip():
continue
match = re.fullmatch(
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
requirement.strip(),
)
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the base fastmcp-slim dependency")
PY
)
for attempt in {1..12}; do
if python - "$SLIM_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-remote." >&2
exit 1
- name: Publish fastmcp-remote to PyPI
run: uv publish -v dist/fastmcp_remote-*.tar.gz dist/fastmcp_remote-*.whl

View file

@ -8,6 +8,7 @@ on:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "fastmcp_remote/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"

View file

@ -8,6 +8,7 @@ on:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "fastmcp_remote/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
@ -244,3 +245,23 @@ jobs:
assert CallToolResult is not None
assert ToolError is not None
PY
- name: Install fastmcp-remote from matching local wheels
run: |
uv venv /tmp/fastmcp-remote-smoke
REMOTE_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_remote-*.whl)
uv pip install --python /tmp/fastmcp-remote-smoke/bin/python --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
/tmp/fastmcp-remote-smoke/bin/python - <<'PY'
from importlib.metadata import entry_points
from importlib.metadata import requires
from fastmcp_remote.cli import build_parser
remote_reqs = requires("fastmcp-remote") or []
assert any("fastmcp-slim[client,server]" in req for req in remote_reqs)
assert any(
ep.name == "fastmcp-remote" and ep.value == "fastmcp_remote.cli:main"
for ep in entry_points(group="console_scripts")
)
assert build_parser().prog == "fastmcp-remote"
PY

View file

@ -138,3 +138,7 @@ Any server that appears here can be used by name with `list`, `call`, and other
For LLM agents that can execute shell commands but don't have native MCP support, the CLI provides a clean bridge. The agent calls `fastmcp list --json` to discover available tools with full schemas, then `fastmcp call --json` to invoke them with structured results.
Because the CLI handles connection management, transport selection, and type coercion internally, the agent doesn't need to understand MCP protocol details — it just reads JSON and constructs shell commands.
## Remote Stdio Bridges
For MCP hosts that expect a local stdio command but need to connect to a remote HTTP server, use [`fastmcp-remote`](/clients/fastmcp-remote). It provides a small standalone bridge for host configuration, while `fastmcp list` and `fastmcp call` remain focused on direct inspection and invocation from the terminal.

View file

@ -0,0 +1,142 @@
---
title: fastmcp-remote
description: Bridge remote MCP servers into stdio-only MCP hosts with uvx fastmcp-remote.
icon: bridge
---
`fastmcp-remote` is FastMCP's standalone stdio bridge for remote MCP servers. Use it when an MCP host expects to launch a local command, but the server you want to use is hosted over Streamable HTTP or SSE.
```json
{
"mcpServers": {
"linear": {
"command": "uvx",
"args": ["fastmcp-remote", "https://mcp.linear.app/mcp"]
}
}
}
```
The package is powered by FastMCP. It builds one FastMCP client for the remote URL, exposes that client as a local stdio proxy, and keeps the executable focused on that bridge. For running Python server files, local project environments, FastMCP config files, and development reload loops, use [`fastmcp run`](/cli/running).
The command shape follows the original [`mcp-remote`](https://github.com/geelen/mcp-remote) npm project, which established this stdio-to-remote bridge pattern for MCP hosts.
## Installation
Most MCP hosts can run `fastmcp-remote` directly through `uvx`, so you usually do not need to install it yourself:
```bash
uvx fastmcp-remote https://example.com/mcp
```
If your host requires an already-installed command, install the package with your Python package manager:
```bash
uv tool install fastmcp-remote
```
## Host Configuration
For hosts that use `mcpServers` JSON configuration, set the command to `uvx` and pass `fastmcp-remote` plus the remote server URL as arguments:
```json
{
"mcpServers": {
"remote-api": {
"command": "uvx",
"args": ["fastmcp-remote", "https://example.com/mcp"]
}
}
}
```
OAuth is enabled automatically for HTTPS servers. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs.
To pass a bearer token or another custom header directly, provide `--header` in `Name: Value` form. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument. An `Authorization` header disables OAuth by default:
```json
{
"mcpServers": {
"private-api": {
"command": "uvx",
"args": [
"fastmcp-remote",
"https://example.com/mcp",
"--header",
"Authorization: Bearer <token>"
]
}
}
}
```
Repeat `--header` to send multiple headers:
```bash
uvx fastmcp-remote https://example.com/mcp \
--header "Authorization: Bearer <token>" \
--header "X-Workspace: production" \
--header "X-Client-Name: My MCP Host" \
--header "X-Callback-Url: https://example.com/oauth/callback"
```
Some MCP hosts on Windows have trouble preserving spaces inside command arguments. Put the spaced value in an environment variable and reference it from the header value:
```json
{
"mcpServers": {
"remote-api": {
"command": "uvx",
"args": [
"fastmcp-remote",
"https://example.com/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <token>"
}
}
}
}
```
For local development servers over plain HTTP, disable OAuth when the server is unauthenticated:
```bash
uvx fastmcp-remote http://localhost:8000/mcp --auth none
```
## OAuth Storage
OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory:
```bash
FASTMCP_REMOTE_CONFIG_DIR=~/.config/fastmcp-remote uvx fastmcp-remote https://example.com/mcp
```
Use `--resource` to isolate tokens for a particular remote server identity:
```bash
uvx fastmcp-remote https://example.com/mcp --resource example-prod
```
If the remote authorization server requires a fixed callback port or hostname, pass them after the URL:
```bash
uvx fastmcp-remote https://example.com/mcp 3334 --host 127.0.0.1
```
## Options
| Option | Description |
| ------ | ----------- |
| `--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. |
| `--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. |
| `--ignore-tool` | Hide tools whose names match a glob pattern. Repeat for multiple patterns. |
| `--debug` | Enable debug logging. |
| `--silent` | Suppress non-critical logs. |

View file

@ -237,6 +237,7 @@
"clients/client",
"clients/client-only-package",
"clients/transports",
"clients/fastmcp-remote",
{
"collapsed": true,
"group": "Operations",

82
fastmcp_remote/README.md Normal file
View file

@ -0,0 +1,82 @@
# fastmcp-remote
`fastmcp-remote` is FastMCP's standalone Python stdio bridge for remote MCP servers. It lets MCP clients that launch local stdio processes connect to MCP servers hosted over Streamable HTTP or SSE.
```json
{
"mcpServers": {
"linear": {
"command": "uvx",
"args": ["fastmcp-remote", "https://mcp.linear.app/mcp"]
}
}
}
```
The CLI is powered by [FastMCP](https://gofastmcp.com). Its command shape is inspired by the original [`mcp-remote`](https://github.com/geelen/mcp-remote) npm project, which established the stdio-to-remote bridge pattern used across the MCP ecosystem.
`fastmcp-remote` is intentionally smaller than the general FastMCP CLI. It does not load Python files, discover local MCP configs, prepare project environments, or run development reload loops. It builds one FastMCP client for the URL you provide, exposes that client as a local stdio proxy, and leaves the rest alone.
## Usage
Run a remote MCP server through a local stdio bridge:
```bash
uvx fastmcp-remote https://example.com/mcp
```
For authenticated MCP servers, OAuth is enabled automatically. To pass a bearer token or other custom header instead, provide a header. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument:
```bash
uvx fastmcp-remote https://example.com/mcp \
--header "Authorization: Bearer <token>"
```
Repeat `--header` to send multiple headers. Header values use `Name: Value` format:
```bash
uvx fastmcp-remote https://example.com/mcp \
--header "Authorization: Bearer <token>" \
--header "X-Workspace: production" \
--header "X-Client-Name: My MCP Host" \
--header "X-Callback-Url: https://example.com/oauth/callback"
```
Some MCP hosts on Windows have trouble preserving spaces inside command arguments. Put the spaced value in an environment variable and reference it from the header value:
```json
{
"mcpServers": {
"remote-api": {
"command": "uvx",
"args": [
"fastmcp-remote",
"https://example.com/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <token>"
}
}
}
}
```
Use `--auth none` for unauthenticated development servers:
```bash
uvx fastmcp-remote http://localhost:8000/mcp --auth none
```
## Options
- `--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.
- `--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.
- `--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.
OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory.

View file

@ -0,0 +1,10 @@
"""Python stdio bridge for remote MCP servers."""
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("fastmcp-remote")
except PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["__version__"]

View file

@ -0,0 +1,249 @@
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import os
import re
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
import anyio
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.filetree import (
FileTreeStore,
FileTreeV1CollectionSanitizationStrategy,
FileTreeV1KeySanitizationStrategy,
)
from fastmcp import Client
from fastmcp.client.auth import OAuth
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.server import create_proxy
from fastmcp.server.transforms import GetToolNext, Transform
from fastmcp.tools import Tool
from fastmcp.utilities.versions import VersionSpec
RemoteTransport = Literal["http", "sse"]
AuthMode = Literal["oauth", "none"]
ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@dataclass(frozen=True)
class RemoteConfig:
url: str
headers: dict[str, str]
transport: RemoteTransport
auth: AuthMode | None
callback_port: int | None
callback_host: str
callback_timeout: float
storage_dir: Path
ignore_tools: tuple[str, ...]
show_banner: bool
log_level: str | None
class IgnoreTools(Transform):
def __init__(self, patterns: Sequence[str]) -> None:
self.patterns = tuple(patterns)
def _matches(self, name: str) -> bool:
return any(fnmatch.fnmatchcase(name, pattern) for pattern in self.patterns)
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [tool for tool in tools if not self._matches(tool.name)]
async def get_tool(
self,
name: str,
call_next: GetToolNext,
*,
version: VersionSpec | None = None,
) -> Tool | None:
if self._matches(name):
return None
return await call_next(name, version=version)
def parse_header(value: str) -> tuple[str, str]:
name, separator, header_value = value.partition(":")
if not separator or not name.strip():
raise argparse.ArgumentTypeError("Headers must use the format 'Name: Value'.")
try:
expanded_value = ENV_VAR_PATTERN.sub(
lambda match: os.environ[match.group(1)], header_value
)
except KeyError as exc:
raise argparse.ArgumentTypeError(
f"Environment variable {exc.args[0]} is not set."
) from exc
return name.strip(), expanded_value.strip()
def default_storage_dir(resource: str | None = None) -> Path:
if config_dir := os.environ.get("FASTMCP_REMOTE_CONFIG_DIR"):
base = Path(config_dir).expanduser()
else:
base = Path.home() / ".fastmcp" / "remote"
if resource is None:
return base
digest = hashlib.sha256(resource.encode()).hexdigest()[:16]
return base / "resources" / digest
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="fastmcp-remote",
description="Bridge a remote MCP server to a local stdio MCP process.",
)
parser.add_argument("url", help="Remote MCP server URL.")
parser.add_argument(
"callback_port",
nargs="?",
type=int,
help="OAuth callback port. Defaults to an available local port.",
)
parser.add_argument(
"--transport",
choices=["http", "sse"],
default="http",
help="Remote transport. Defaults to http.",
)
parser.add_argument(
"--header",
action="append",
default=[],
type=parse_header,
help="Header to send upstream, in 'Name: Value' form. Repeat for multiple headers.",
)
parser.add_argument(
"--auth",
choices=["oauth", "none"],
default=None,
help="Authentication mode. Defaults to OAuth unless Authorization is provided.",
)
parser.add_argument(
"--resource",
help="Resource identifier used to isolate OAuth token storage.",
)
parser.add_argument(
"--host",
default="localhost",
help="OAuth callback hostname. Defaults to localhost.",
)
parser.add_argument(
"--auth-timeout",
type=float,
default=300.0,
help="Seconds to wait for the OAuth callback. Defaults to 300.",
)
parser.add_argument(
"--ignore-tool",
action="append",
default=[],
help="Hide tools matching this glob pattern. Repeat for multiple patterns.",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug logging.",
)
parser.add_argument(
"--silent",
action="store_true",
help="Suppress non-critical logs.",
)
return parser
def parse_args(argv: Sequence[str] | None = None) -> RemoteConfig:
parser = build_parser()
args = parser.parse_args(argv)
parsed_url = urlparse(args.url)
if parsed_url.scheme not in {"http", "https"}:
parser.error("The remote MCP server URL must start with http:// or https://.")
headers = dict(args.header)
if args.silent and args.debug:
parser.error("--silent and --debug cannot be used together.")
if args.auth_timeout <= 0:
parser.error("--auth-timeout must be greater than 0.")
log_level = "DEBUG" if args.debug else None
if args.silent:
log_level = "CRITICAL"
return RemoteConfig(
url=args.url,
headers=headers,
transport=args.transport,
auth=args.auth,
callback_port=args.callback_port,
callback_host=args.host,
callback_timeout=args.auth_timeout,
storage_dir=default_storage_dir(args.resource),
ignore_tools=tuple(args.ignore_tool),
show_banner=not args.silent,
log_level=log_level,
)
def build_token_storage(storage_dir: Path) -> AsyncKeyValue:
storage_dir.mkdir(parents=True, exist_ok=True)
return FileTreeStore(
data_directory=storage_dir,
key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_dir),
collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(
storage_dir
),
)
def resolve_auth(config: RemoteConfig) -> OAuth | None:
authorization_header = any(
name.lower() == "authorization" for name in config.headers
)
auth_mode = config.auth
if auth_mode is None and authorization_header:
auth_mode = "none"
elif auth_mode is None:
auth_mode = "oauth"
if auth_mode == "none":
return None
return OAuth(
token_storage=build_token_storage(config.storage_dir),
callback_port=config.callback_port,
callback_host=config.callback_host,
callback_timeout=config.callback_timeout,
)
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)
async def run(config: RemoteConfig) -> None:
client = Client(build_transport(config))
server = create_proxy(client, name="fastmcp-remote")
if config.ignore_tools:
server.add_transform(IgnoreTools(config.ignore_tools))
await server.run_async(
transport="stdio",
show_banner=config.show_banner,
log_level=config.log_level,
)
def main(argv: Sequence[str] | None = None) -> None:
config = parse_args(argv)
anyio.run(run, config)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,62 @@
[project]
name = "fastmcp-remote"
dynamic = ["version", "dependencies"]
description = "A Python stdio bridge for remote MCP servers, powered by FastMCP."
authors = [{ name = "Jeremiah Lowin" }]
requires-python = ">=3.10"
readme = "README.md"
license = "Apache-2.0"
keywords = [
"mcp",
"fastmcp remote",
"mcp remote",
"model context protocol",
"fastmcp",
"stdio",
"oauth",
]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://gofastmcp.com"
Repository = "https://github.com/PrefectHQ/fastmcp"
Documentation = "https://gofastmcp.com"
"Original npm project" = "https://github.com/geelen/mcp-remote"
[project.scripts]
fastmcp-remote = "fastmcp_remote.cli:main"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["fastmcp_remote"]
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
fallback-version = "0.0.0"
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = [
"fastmcp-slim[client,server]=={{ version }}",
]

View file

@ -34,6 +34,18 @@ __all__ = ["OAuth"]
logger = get_logger(__name__)
def _normalize_callback_host_for_bind(host: str) -> str:
if host.startswith("[") and host.endswith("]"):
return host[1:-1]
return host
def _format_callback_host_for_url(host: str) -> str:
if ":" in host:
return f"[{host}]"
return host
class ClientNotFoundError(Exception):
"""Raised when OAuth client credentials are not found on the server."""
@ -179,6 +191,8 @@ class OAuth(OAuthClientProvider):
token_storage: AsyncKeyValue | None = None,
additional_client_metadata: dict[str, Any] | None = None,
callback_port: int | None = None,
callback_host: str = "localhost",
callback_timeout: float = 300.0,
httpx_client_factory: McpHttpClientFactory | None = None,
# Alternative to dynamic client registration:
# --- Clients host a static JSON document at an HTTPS URL ---
@ -200,6 +214,8 @@ class OAuth(OAuthClientProvider):
token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided
additional_client_metadata: Extra fields for OAuthClientMetadata
callback_port: Fixed port for OAuth callback (default: random available port)
callback_host: Hostname used for OAuth redirect URI and callback server.
callback_timeout: Seconds to wait for OAuth callback before timing out.
client_metadata_url: A CIMD (Client ID Metadata Document) URL. When
provided, this URL is used as the client_id instead of performing
Dynamic Client Registration. Must be an HTTPS URL with a non-root
@ -214,6 +230,8 @@ class OAuth(OAuthClientProvider):
self._token_storage = token_storage
self._additional_client_metadata = additional_client_metadata
self._callback_port = callback_port
self._callback_host = _normalize_callback_host_for_bind(callback_host)
self._callback_timeout = callback_timeout
self._client_metadata_url = client_metadata_url
self._client_id = client_id
self._client_secret = client_secret
@ -235,8 +253,11 @@ class OAuth(OAuthClientProvider):
mcp_url = mcp_url.rstrip("/")
self.redirect_port = self._callback_port or find_available_port()
redirect_uri = f"http://localhost:{self.redirect_port}/callback"
self.redirect_port = self._callback_port or find_available_port(
host=self._callback_host
)
redirect_host = _format_callback_host_for_url(self._callback_host)
redirect_uri = f"http://{redirect_host}:{self.redirect_port}/callback"
scopes_str: str
if isinstance(self._scopes, list):
@ -297,6 +318,7 @@ class OAuth(OAuthClientProvider):
storage=self.token_storage_adapter,
redirect_handler=self.redirect_handler,
callback_handler=self.callback_handler,
timeout=self._callback_timeout,
client_metadata_url=self._client_metadata_url,
)
@ -347,6 +369,7 @@ class OAuth(OAuthClientProvider):
# Create server with result tracking
server: Server = create_oauth_callback_server(
port=self.redirect_port,
host=self._callback_host,
server_url=self.mcp_url,
result_container=result,
result_ready=result_ready,
@ -356,19 +379,18 @@ class OAuth(OAuthClientProvider):
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
logger.info(
f"🎧 OAuth callback server started on http://localhost:{self.redirect_port}"
f"🎧 OAuth callback server started on http://{self._callback_host}:{self.redirect_port}"
)
TIMEOUT = 300.0 # 5 minute timeout
try:
with anyio.fail_after(TIMEOUT):
with anyio.fail_after(self._callback_timeout):
await result_ready.wait()
if result.error:
raise result.error
return result.code, result.state # type: ignore
except TimeoutError as e:
raise TimeoutError(
f"OAuth callback timed out after {TIMEOUT} seconds"
f"OAuth callback timed out after {self._callback_timeout} seconds"
) from e
finally:
server.should_exit = True

View file

@ -102,6 +102,7 @@ class OAuthCallbackResult:
def create_oauth_callback_server(
port: int,
host: str = "127.0.0.1",
callback_path: str = "/callback",
server_url: str | None = None,
result_container: OAuthCallbackResult | None = None,
@ -207,7 +208,7 @@ def create_oauth_callback_server(
return Server(
Config(
app=app,
host="127.0.0.1",
host=host,
port=port,
lifespan="off",
log_level="warning",

View file

@ -1,8 +1,9 @@
import socket
def find_available_port() -> int:
def find_available_port(host: str = "127.0.0.1") -> int:
"""Find an available port by letting the OS assign one."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
family = socket.AF_INET6 if ":" in host else socket.AF_INET
with socket.socket(family, socket.SOCK_STREAM) as s:
s.bind((host, 0))
return s.getsockname()[1]

View file

@ -67,7 +67,7 @@ bump = true
fallback-version = "0.0.0"
[tool.uv.workspace]
members = ["fastmcp_slim"]
members = ["fastmcp_slim", "fastmcp_remote"]
[tool.uv]
default-groups = ["dev"]
@ -79,6 +79,7 @@ dev = [
"dirty-equals>=0.9.0",
"fastmcp[anthropic,apps,azure,code-mode,gemini,openai,tasks]",
"fastapi>=0.115.12",
"fastmcp-remote",
"opentelemetry-sdk>=1.20.0",
"inline-snapshot[dirty-equals]>=0.27.2",
"ipython>=8.12.3",
@ -107,6 +108,7 @@ dev = [
[tool.uv.sources]
fastmcp = { workspace = true }
fastmcp-slim = { workspace = true }
fastmcp-remote = { workspace = true }
[tool.pytest.ini_options]
asyncio_mode = "auto"
@ -127,7 +129,7 @@ markers = [
"client_process: marks tests that spawn client processes via stdio transport. These can create issues when run in the same CI environment as other subprocess-based tests.",
"conformance: marks MCP conformance tests (require Node.js/npx)",
]
pythonpath = ["fastmcp_slim"]
pythonpath = ["fastmcp_slim", "fastmcp_remote"]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
@ -135,7 +137,7 @@ python_functions = ["test_*"]
addopts = ["--inline-snapshot=disable"]
[tool.ty.src]
include = ["fastmcp_slim", "tests"]
include = ["fastmcp_slim", "fastmcp_remote", "tests"]
exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"]
[tool.ty.environment]
@ -185,7 +187,7 @@ extend-select = [
]
[tool.ruff.lint.isort]
known-first-party = ["fastmcp"]
known-first-party = ["fastmcp", "fastmcp_remote"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001", "RUF013"]

View file

@ -0,0 +1,177 @@
from pathlib import Path
import pytest
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
def sample_tool() -> str:
return "ok"
def test_parse_header_accepts_spaced_value():
assert parse_header("Authorization: Bearer token") == (
"Authorization",
"Bearer token",
)
def test_parse_header_preserves_spaces_inside_value():
assert parse_header("X-Client-Name: My MCP Host") == (
"X-Client-Name",
"My MCP Host",
)
def test_parse_header_preserves_colons_inside_value():
assert parse_header("X-Callback-Url: https://example.com/oauth/callback") == (
"X-Callback-Url",
"https://example.com/oauth/callback",
)
def test_parse_header_expands_environment_variables_in_value(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("AUTH_HEADER", "Bearer token with spaces")
assert parse_header("Authorization:${AUTH_HEADER}") == (
"Authorization",
"Bearer token with spaces",
)
def test_parse_header_rejects_missing_environment_variable():
with pytest.raises(SystemExit):
parse_args(["https://example.com/mcp", "--header", "Authorization:${MISSING}"])
def test_parse_header_accepts_unspaced_value():
assert parse_header("Authorization:Bearer token") == (
"Authorization",
"Bearer token",
)
def test_parse_header_rejects_missing_colon():
with pytest.raises(SystemExit):
parse_args(["https://example.com/mcp", "--header", "Authorization"])
def test_http_urls_are_allowed():
config = parse_args(["http://localhost:8000/mcp", "--auth", "none"])
assert config.url == "http://localhost:8000/mcp"
def test_auth_defaults_to_oauth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(["https://example.com/mcp"])
transport = build_transport(config)
assert isinstance(transport, StreamableHttpTransport)
assert isinstance(transport.auth, OAuth)
def test_authorization_header_disables_oauth_by_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(
[
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
]
)
transport = build_transport(config)
assert isinstance(transport, StreamableHttpTransport)
assert transport.auth is None
assert transport.headers == {"Authorization": "Bearer token"}
def test_explicit_oauth_keeps_oauth_with_authorization_header(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(
[
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
"--auth",
"oauth",
]
)
transport = build_transport(config)
assert isinstance(transport.auth, OAuth)
def test_oauth_callback_options_pass_to_fastmcp_oauth(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(
[
"https://example.com/mcp",
"8765",
"--host",
"127.0.0.1",
"--auth-timeout",
"12.5",
]
)
transport = build_transport(config)
assert isinstance(transport.auth, OAuth)
assert transport.auth.context.client_metadata.redirect_uris is not None
assert str(transport.auth.context.client_metadata.redirect_uris[0]) == (
"http://127.0.0.1:8765/callback"
)
assert transport.auth._callback_timeout == 12.5
def test_resource_isolates_token_storage(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
default_config = parse_args(["https://example.com/mcp"])
resource_config = parse_args(
["https://example.com/mcp", "--resource", "linear-prod"]
)
assert default_config.storage_dir == tmp_path
assert resource_config.storage_dir.parent == tmp_path / "resources"
assert resource_config.storage_dir != default_config.storage_dir
def test_sse_transport_strategy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("FASTMCP_REMOTE_CONFIG_DIR", str(tmp_path))
config = parse_args(["https://example.com/sse", "--transport", "sse"])
transport = build_transport(config)
assert isinstance(transport, SSETransport)
async def test_ignore_tools_transform_filters_matching_names():
tool = FunctionTool.from_function(sample_tool, name="delete_user")
transform = IgnoreTools(["delete*"])
async def call_next(
name: str, *, version: VersionSpec | None = None
) -> FunctionTool:
return tool
assert await transform.list_tools([tool]) == []
assert await transform.get_tool("delete_user", call_next) is None

View file

@ -1,3 +1,4 @@
import socket
import time
from unittest.mock import patch
from urllib.parse import urlparse
@ -6,6 +7,8 @@ import httpx
import pytest
from mcp.types import TextResourceContents
import fastmcp.client.auth.oauth as oauth_module
import fastmcp.utilities.http as http_module
from fastmcp.client import Client
from fastmcp.client.auth import OAuth
from fastmcp.client.transports import StreamableHttpTransport
@ -176,6 +179,100 @@ class TestOAuthClientUrlHandling:
# Token storage should key by the full URL, not just the host
assert oauth.token_storage_adapter._server_url == mcp_url
def test_oauth_uses_configured_callback_host_port_and_timeout(self):
oauth = OAuth(
mcp_url="https://example.com/mcp",
callback_port=8765,
callback_host="127.0.0.1",
callback_timeout=12.5,
)
assert oauth.context.client_metadata.redirect_uris is not None
assert str(oauth.context.client_metadata.redirect_uris[0]) == (
"http://127.0.0.1:8765/callback"
)
assert oauth._callback_timeout == 12.5
@pytest.mark.parametrize("callback_host", ["::1", "[::1]"])
def test_oauth_brackets_ipv6_callback_host_in_redirect_uri(
self, callback_host: str
):
oauth = OAuth(
mcp_url="https://example.com/mcp",
callback_port=8765,
callback_host=callback_host,
)
assert oauth.context.client_metadata.redirect_uris is not None
assert str(oauth.context.client_metadata.redirect_uris[0]) == (
"http://[::1]:8765/callback"
)
assert oauth._callback_host == "::1"
def test_oauth_finds_available_port_on_callback_host(
self, monkeypatch: pytest.MonkeyPatch
):
seen_hosts: list[str] = []
def find_available_port(host: str = "127.0.0.1") -> int:
seen_hosts.append(host)
return 8765
monkeypatch.setattr(oauth_module, "find_available_port", find_available_port)
oauth = OAuth(
mcp_url="https://example.com/mcp",
callback_host="[::1]",
)
assert seen_hosts == ["::1"]
assert oauth.redirect_port == 8765
assert oauth.context.client_metadata.redirect_uris is not None
assert str(oauth.context.client_metadata.redirect_uris[0]) == (
"http://[::1]:8765/callback"
)
@pytest.mark.parametrize(
("host", "expected_family"),
[
("localhost", socket.AF_INET),
("127.0.0.1", socket.AF_INET),
("::1", socket.AF_INET6),
],
)
def test_available_port_uses_uvicorn_host_family(
self,
host: str,
expected_family: socket.AddressFamily,
monkeypatch: pytest.MonkeyPatch,
):
seen: list[tuple[socket.AddressFamily, tuple[str, int]]] = []
class FakeSocket:
def __init__(
self,
family: socket.AddressFamily,
socket_type: socket.SocketKind,
):
self.family = family
def __enter__(self):
return self
def __exit__(self, *args: object) -> None:
return None
def bind(self, address: tuple[str, int]) -> None:
seen.append((self.family, address))
def getsockname(self) -> tuple[str, int]:
return host, 8765
monkeypatch.setattr(http_module.socket, "socket", FakeSocket)
assert http_module.find_available_port(host=host) == 8765
assert seen == [(expected_family, (host, 0))]
class TestOAuthGeneratorCleanup:
"""Tests for OAuth async generator cleanup (issue #2643).

View file

@ -42,3 +42,9 @@ async def test_oauth_callback_result_ignores_subsequent_callbacks():
assert result.state == "s1"
tg.cancel_scope.cancel()
def test_oauth_callback_server_uses_configured_host():
server = create_oauth_callback_server(port=find_available_port(), host="localhost")
assert server.config.host == "localhost"

13
uv.lock generated
View file

@ -19,6 +19,7 @@ prefab-ui = false
[manifest]
members = [
"fastmcp",
"fastmcp-remote",
"fastmcp-slim",
]
@ -868,6 +869,7 @@ dev = [
{ name = "dirty-equals" },
{ name = "fastapi" },
{ name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] },
{ name = "fastmcp-remote" },
{ name = "inline-snapshot", extra = ["dirty-equals"] },
{ name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
@ -913,6 +915,7 @@ dev = [
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "fastmcp", extras = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"], editable = "." },
{ name = "fastmcp-remote", editable = "fastmcp_remote" },
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "loq", specifier = ">=0.1.0a3" },
@ -938,6 +941,16 @@ dev = [
{ name = "ty", specifier = ">=0.0.29" },
]
[[package]]
name = "fastmcp-remote"
source = { editable = "fastmcp_remote" }
dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] },
]
[package.metadata]
requires-dist = [{ name = "fastmcp-slim", extras = ["client", "server"], editable = "fastmcp_slim" }]
[[package]]
name = "fastmcp-slim"
source = { editable = "fastmcp_slim" }