mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add fastmcp-remote bridge package (#4208)
This commit is contained in:
parent
b8c9d58d61
commit
70013c5a91
19 changed files with 993 additions and 14 deletions
87
.github/workflows/publish-fastmcp-remote.yml
vendored
Normal file
87
.github/workflows/publish-fastmcp-remote.yml
vendored
Normal 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
|
||||
1
.github/workflows/run-static.yml
vendored
1
.github/workflows/run-static.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
|||
branches: ["main"]
|
||||
paths:
|
||||
- "fastmcp_slim/**"
|
||||
- "fastmcp_remote/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
|
|
|
|||
21
.github/workflows/run-tests.yml
vendored
21
.github/workflows/run-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
142
docs/clients/fastmcp-remote.mdx
Normal file
142
docs/clients/fastmcp-remote.mdx
Normal 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. |
|
||||
|
|
@ -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
82
fastmcp_remote/README.md
Normal 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.
|
||||
10
fastmcp_remote/fastmcp_remote/__init__.py
Normal file
10
fastmcp_remote/fastmcp_remote/__init__.py
Normal 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__"]
|
||||
249
fastmcp_remote/fastmcp_remote/cli.py
Normal file
249
fastmcp_remote/fastmcp_remote/cli.py
Normal 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)
|
||||
1
fastmcp_remote/fastmcp_remote/py.typed
Normal file
1
fastmcp_remote/fastmcp_remote/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
62
fastmcp_remote/pyproject.toml
Normal file
62
fastmcp_remote/pyproject.toml
Normal 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 }}",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
177
tests/cli/test_fastmcp_remote.py
Normal file
177
tests/cli/test_fastmcp_remote.py
Normal 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
13
uv.lock
generated
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue