mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add fastmcp discover and name-based server resolution (#3055)
This commit is contained in:
parent
bd37763e98
commit
adf21ac630
9 changed files with 1237 additions and 20 deletions
|
|
@ -31,6 +31,41 @@ For servers that communicate over stdio (common with Node.js-based MCP servers),
|
|||
fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
|
||||
```
|
||||
|
||||
### Name-Based Resolution
|
||||
|
||||
If your MCP servers are already configured in an editor or tool, you can refer to them by name instead of spelling out URLs or file paths. The CLI scans config files from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose, and matches the name you provide.
|
||||
|
||||
```bash
|
||||
fastmcp list weather
|
||||
fastmcp call weather get_forecast city=London
|
||||
```
|
||||
|
||||
You can also use the `source:name` form to target a specific source directly, which is useful when the same server name appears in multiple configs or when you want to be explicit about which config you mean.
|
||||
|
||||
```bash
|
||||
fastmcp list claude-code:my-server
|
||||
fastmcp call cursor:weather get_forecast city=London
|
||||
```
|
||||
|
||||
The available source names are `claude-desktop`, `claude-code`, `cursor`, `gemini`, `goose`, and `project` (for `./mcp.json`). Run `fastmcp discover` to see what's available.
|
||||
|
||||
## Discovering Configured Servers
|
||||
|
||||
`fastmcp discover` scans your local editor and project configurations for MCP server definitions. It checks Claude Desktop, Claude Code (`~/.claude.json`), Cursor workspace configs (walking up from the current directory), Gemini CLI (`~/.gemini/settings.json`), Goose (`~/.config/goose/config.yaml`), and `mcp.json` in the current directory.
|
||||
|
||||
```bash
|
||||
fastmcp discover
|
||||
```
|
||||
|
||||
The output groups servers by source, showing each server's name and transport. Use `--source` to filter to specific sources, and `--json` for machine-readable output.
|
||||
|
||||
```bash
|
||||
fastmcp discover --source claude-code
|
||||
fastmcp discover --source cursor --source gemini --json
|
||||
```
|
||||
|
||||
Any server that appears here can be used by name (or `source:name`) with `fastmcp list` and `fastmcp call`, which means you can go from "I have a server configured in Claude Code" to querying it without copying any URLs or paths.
|
||||
|
||||
## Discovering Tools
|
||||
|
||||
`fastmcp list` connects to a server and prints every tool it exposes. The default output is compact: each tool appears as a function signature with its parameter names, types, and a description.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,28 @@ Key features:
|
|||
|
||||
Documentation: [Client CLI](/clients/cli)
|
||||
|
||||
### CLI: `fastmcp discover` and name-based resolution
|
||||
|
||||
`fastmcp discover` scans editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) and project-level `mcp.json` files for MCP server definitions. Discovered servers can be referenced by name — or `source:name` for precision — in `fastmcp list` and `fastmcp call`.
|
||||
|
||||
```bash
|
||||
# See all configured servers
|
||||
fastmcp discover
|
||||
|
||||
# Use a server by name
|
||||
fastmcp list weather
|
||||
fastmcp call weather get_forecast city=London
|
||||
|
||||
# Target a specific source with source:name
|
||||
fastmcp list claude-code:my-server
|
||||
fastmcp call cursor:weather get_forecast city=London
|
||||
|
||||
# Filter discovery to specific sources
|
||||
fastmcp discover --source claude-code --source cursor
|
||||
```
|
||||
|
||||
Documentation: [Client CLI](/clients/cli)
|
||||
|
||||
### CLI: Expanded Reload File Watching
|
||||
|
||||
The `--reload` flag now watches a comprehensive set of file types, making it suitable for MCP apps with frontend bundles ([#3028](https://github.com/jlowin/fastmcp/pull/3028)). Previously limited to `.py` files, it now watches JavaScript, TypeScript, HTML, CSS, config files, and media assets.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ dependencies = [
|
|||
"cyclopts>=4.0.0",
|
||||
"authlib>=1.6.5",
|
||||
"pydantic[email]>=2.11.7",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"pyperclip>=1.9.0",
|
||||
"py-key-value-aio[disk,keyring,memory]>=0.3.0,<0.4.0",
|
||||
"uvicorn>=0.35",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ All commands accept the same server targets:
|
|||
| Python file | `server.py` |
|
||||
| MCPConfig JSON | `mcp.json` (must have `mcpServers` key) |
|
||||
| Stdio command | `--command 'npx -y @mcp/server'` |
|
||||
| Discovered name | `weather` or `source:name` |
|
||||
|
||||
Servers configured in editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) or project-level `mcp.json` can be referenced by name. Use `source:name` (e.g. `claude-code:my-server`, `cursor:weather`) to target a specific source. Run `fastmcp discover` to see available names.
|
||||
|
||||
For SSE servers, pass `--transport sse`:
|
||||
|
||||
|
|
@ -78,16 +81,34 @@ HTTP targets automatically use OAuth (no-ops if the server doesn't require auth)
|
|||
fastmcp call http://server/mcp tool --auth none
|
||||
```
|
||||
|
||||
## Discovering Configured Servers
|
||||
|
||||
```bash
|
||||
# See all MCP servers in editor/project configs
|
||||
fastmcp discover
|
||||
|
||||
# Filter by source
|
||||
fastmcp discover --source claude-code
|
||||
|
||||
# JSON output
|
||||
fastmcp discover --json
|
||||
```
|
||||
|
||||
Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and `./mcp.json`. Sources: `claude-desktop`, `claude-code`, `cursor`, `gemini`, `goose`, `project`.
|
||||
|
||||
## Workflow Pattern
|
||||
|
||||
Discover tools first, then call them:
|
||||
|
||||
```bash
|
||||
# 1. See what's available
|
||||
fastmcp list server.py
|
||||
# 1. See what servers are configured
|
||||
fastmcp discover
|
||||
|
||||
# 2. Call a tool
|
||||
fastmcp call server.py tool_name arg=value
|
||||
# 2. See what tools a server has
|
||||
fastmcp list weather
|
||||
|
||||
# 3. Call a tool
|
||||
fastmcp call weather get_forecast city=London
|
||||
```
|
||||
|
||||
If you call a nonexistent tool, FastMCP suggests close matches.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from rich.table import Table
|
|||
|
||||
import fastmcp
|
||||
from fastmcp.cli import run as run_module
|
||||
from fastmcp.cli.client import call_command, list_command
|
||||
from fastmcp.cli.client import call_command, discover_command, list_command
|
||||
from fastmcp.cli.install import install_app
|
||||
from fastmcp.cli.tasks import tasks_app
|
||||
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
|
||||
|
|
@ -956,6 +956,7 @@ app.command(tasks_app)
|
|||
# Add client query commands
|
||||
app.command(list_command, name="list")
|
||||
app.command(call_command, name="call")
|
||||
app.command(discover_command, name="discover")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@ import cyclopts
|
|||
import mcp.types
|
||||
from rich.console import Console
|
||||
|
||||
from fastmcp.cli.discovery import DiscoveredServer, discover_servers, resolve_name
|
||||
from fastmcp.client.client import CallToolResult, Client
|
||||
from fastmcp.client.elicitation import ElicitResult
|
||||
from fastmcp.client.transports.base import ClientTransport
|
||||
from fastmcp.client.transports.http import StreamableHttpTransport
|
||||
from fastmcp.client.transports.sse import SSETransport
|
||||
from fastmcp.client.transports.stdio import StdioTransport
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ def resolve_server_spec(
|
|||
*,
|
||||
command: str | None = None,
|
||||
transport: str | None = None,
|
||||
) -> str | dict[str, Any] | StdioTransport:
|
||||
) -> str | dict[str, Any] | ClientTransport:
|
||||
"""Turn CLI inputs into something ``Client()`` accepts.
|
||||
|
||||
Exactly one of ``server_spec`` or ``command`` should be provided.
|
||||
|
|
@ -51,7 +55,7 @@ def resolve_server_spec(
|
|||
If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse``
|
||||
so ``infer_transport`` picks the right transport.
|
||||
2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``.
|
||||
3. Anything else — error with guidance.
|
||||
3. Anything else — name-based resolution via ``resolve_name``.
|
||||
|
||||
When ``command`` is provided, the string is shell-split into a
|
||||
``StdioTransport(command, args)``.
|
||||
|
|
@ -101,16 +105,12 @@ def resolve_server_spec(
|
|||
# .js — pass through for Client's infer_transport
|
||||
return spec
|
||||
|
||||
# 3. Unrecognised
|
||||
console.print(
|
||||
f"[bold red]Error:[/bold red] Could not resolve server spec: [cyan]{spec}[/cyan]\n\n"
|
||||
"Expected one of:\n"
|
||||
" • A URL (e.g. http://localhost:8000/mcp)\n"
|
||||
" • A Python file (e.g. server.py)\n"
|
||||
" • An MCPConfig (e.g. mcp.json)\n"
|
||||
" • --command (e.g. --command 'npx -y @mcp/server')\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
# 3. Name-based resolution (bare name or source:name)
|
||||
try:
|
||||
return resolve_name(spec)
|
||||
except ValueError as exc:
|
||||
console.print(f"[bold red]Error:[/bold red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _build_stdio_from_command(command_str: str) -> StdioTransport:
|
||||
|
|
@ -156,7 +156,7 @@ def _resolve_json_spec(path: Path) -> str | dict[str, Any]:
|
|||
sys.exit(1)
|
||||
|
||||
|
||||
def _is_http_target(resolved: str | dict[str, Any] | StdioTransport) -> bool:
|
||||
def _is_http_target(resolved: str | dict[str, Any] | ClientTransport) -> bool:
|
||||
"""Return True if the resolved target will use an HTTP-based transport.
|
||||
|
||||
MCPConfig dicts are excluded because ``MCPConfigTransport`` manages
|
||||
|
|
@ -164,7 +164,7 @@ def _is_http_target(resolved: str | dict[str, Any] | StdioTransport) -> bool:
|
|||
"""
|
||||
if isinstance(resolved, str):
|
||||
return resolved.startswith(("http://", "https://"))
|
||||
return False
|
||||
return isinstance(resolved, (StreamableHttpTransport, SSETransport))
|
||||
|
||||
|
||||
async def _terminal_elicitation_handler(
|
||||
|
|
@ -227,7 +227,7 @@ async def _terminal_elicitation_handler(
|
|||
|
||||
|
||||
def _build_client(
|
||||
resolved: str | dict[str, Any] | StdioTransport,
|
||||
resolved: str | dict[str, Any] | ClientTransport,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
auth: str | None = None,
|
||||
|
|
@ -870,3 +870,95 @@ async def call_command(
|
|||
except Exception as exc:
|
||||
console.print(f"[bold red]Error:[/bold red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def discover_command(
|
||||
*,
|
||||
source: Annotated[
|
||||
list[str] | None,
|
||||
cyclopts.Parameter(
|
||||
"--source",
|
||||
help="Only show servers from these sources (e.g. claude-code, cursor, gemini)",
|
||||
),
|
||||
] = None,
|
||||
json_output: Annotated[
|
||||
bool,
|
||||
cyclopts.Parameter("--json", help="Output as JSON"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Discover MCP servers configured in editor and project configs.
|
||||
|
||||
Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and
|
||||
project-level mcp.json files for MCP server definitions.
|
||||
|
||||
Discovered server names can be used directly with ``fastmcp list``
|
||||
and ``fastmcp call`` instead of specifying a URL or file path.
|
||||
|
||||
Examples:
|
||||
fastmcp discover
|
||||
fastmcp discover --source claude-code
|
||||
fastmcp discover --source cursor --source gemini --json
|
||||
fastmcp list weather
|
||||
fastmcp call cursor:weather get_forecast city=London
|
||||
"""
|
||||
|
||||
servers = discover_servers()
|
||||
|
||||
if source:
|
||||
servers = [s for s in servers if s.source in source]
|
||||
|
||||
if json_output:
|
||||
data: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": s.name,
|
||||
"source": s.source,
|
||||
"qualified_name": s.qualified_name,
|
||||
"transport_summary": s.transport_summary,
|
||||
"config_path": str(s.config_path),
|
||||
}
|
||||
for s in servers
|
||||
]
|
||||
console.print_json(json.dumps(data))
|
||||
return
|
||||
|
||||
if not servers:
|
||||
console.print("[dim]No MCP servers found.[/dim]")
|
||||
console.print()
|
||||
console.print("Searched:")
|
||||
console.print(" • Claude Desktop config")
|
||||
console.print(" • ~/.claude.json (Claude Code)")
|
||||
console.print(" • .cursor/mcp.json (walked up from cwd)")
|
||||
console.print(" • ~/.gemini/settings.json (Gemini CLI)")
|
||||
console.print(" • ~/.config/goose/config.yaml (Goose)")
|
||||
console.print(" • ./mcp.json")
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
# Group by source
|
||||
by_source: dict[str, list[DiscoveredServer]] = {}
|
||||
for s in servers:
|
||||
by_source.setdefault(s.source, []).append(s)
|
||||
|
||||
for source_name, group in by_source.items():
|
||||
console.print()
|
||||
console.print(f"[bold]Source:[/bold] {source_name}")
|
||||
console.print(f"[bold]Config:[/bold] [dim]{group[0].config_path}[/dim]")
|
||||
console.print()
|
||||
|
||||
table = Table(
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
show_edge=False,
|
||||
pad_edge=False,
|
||||
box=None,
|
||||
padding=(0, 2),
|
||||
)
|
||||
table.add_column("Server", style="cyan")
|
||||
table.add_column("Transport", style="dim")
|
||||
|
||||
for s in group:
|
||||
table.add_row(s.name, s.transport_summary)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
|
|
|||
375
src/fastmcp/cli/discovery.py
Normal file
375
src/fastmcp/cli/discovery.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"""Discover MCP servers configured in editor config files.
|
||||
|
||||
Scans filesystem-readable config files from editors like Claude Desktop,
|
||||
Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level
|
||||
``mcp.json`` files. Each discovered server can be resolved by name
|
||||
(or ``source:name``) so the CLI can connect without requiring a URL
|
||||
or file path.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from fastmcp.client.transports.base import ClientTransport
|
||||
from fastmcp.mcp_config import (
|
||||
MCPConfig,
|
||||
MCPServerTypes,
|
||||
RemoteMCPServer,
|
||||
StdioMCPServer,
|
||||
)
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger("cli.discovery")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredServer:
|
||||
"""A single MCP server found in an editor or project config."""
|
||||
|
||||
name: str
|
||||
source: str
|
||||
config: MCPServerTypes
|
||||
config_path: Path
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Fully qualified ``source:name`` identifier."""
|
||||
return f"{self.source}:{self.name}"
|
||||
|
||||
@property
|
||||
def transport_summary(self) -> str:
|
||||
"""Human-readable one-liner describing the transport."""
|
||||
cfg = self.config
|
||||
if isinstance(cfg, StdioMCPServer):
|
||||
parts = [cfg.command, *cfg.args]
|
||||
return f"stdio: {' '.join(parts)}"
|
||||
if isinstance(cfg, RemoteMCPServer):
|
||||
transport = cfg.transport or "http"
|
||||
return f"{transport}: {cfg.url}"
|
||||
return str(type(cfg).__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanners — one per config source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalize_server_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize editor-specific server config fields to MCPConfig format.
|
||||
|
||||
Handles two known differences:
|
||||
- Claude Code uses ``type`` where MCPConfig uses ``transport`` for
|
||||
remote servers.
|
||||
- Gemini CLI uses ``httpUrl`` where MCPConfig uses ``url``.
|
||||
"""
|
||||
# Gemini: httpUrl → url
|
||||
if "httpUrl" in entry and "url" not in entry:
|
||||
entry = {**entry, "url": entry["httpUrl"]}
|
||||
del entry["httpUrl"]
|
||||
|
||||
# Claude Code / others: type → transport (for url-based entries only)
|
||||
if "url" in entry and "type" in entry and "transport" not in entry:
|
||||
transport = entry["type"]
|
||||
entry = {k: v for k, v in entry.items() if k != "type"}
|
||||
entry["transport"] = transport
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _parse_mcp_servers(
|
||||
servers_dict: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
config_path: Path,
|
||||
) -> list[DiscoveredServer]:
|
||||
"""Parse an ``mcpServers``-style dict into discovered servers."""
|
||||
if not servers_dict:
|
||||
return []
|
||||
|
||||
normalized = {
|
||||
name: _normalize_server_entry(entry)
|
||||
for name, entry in servers_dict.items()
|
||||
if isinstance(entry, dict)
|
||||
}
|
||||
|
||||
try:
|
||||
config = MCPConfig.from_dict({"mcpServers": normalized})
|
||||
except Exception as exc:
|
||||
logger.warning("Could not parse MCP servers from %s: %s", config_path, exc)
|
||||
return []
|
||||
|
||||
return [
|
||||
DiscoveredServer(
|
||||
name=name, source=source, config=server, config_path=config_path
|
||||
)
|
||||
for name, server in config.mcpServers.items()
|
||||
]
|
||||
|
||||
|
||||
def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]:
|
||||
"""Parse an mcpServers-style JSON file into discovered servers."""
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError as exc:
|
||||
logger.debug("Could not read %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
try:
|
||||
data: dict[str, Any] = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Invalid JSON in %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
if not isinstance(data, dict) or "mcpServers" not in data:
|
||||
return []
|
||||
|
||||
return _parse_mcp_servers(data["mcpServers"], source=source, config_path=path)
|
||||
|
||||
|
||||
def _scan_claude_desktop() -> list[DiscoveredServer]:
|
||||
"""Scan the Claude Desktop config file."""
|
||||
if sys.platform == "win32":
|
||||
config_dir = Path(Path.home(), "AppData", "Roaming", "Claude")
|
||||
elif sys.platform == "darwin":
|
||||
config_dir = Path(Path.home(), "Library", "Application Support", "Claude")
|
||||
elif sys.platform.startswith("linux"):
|
||||
config_dir = Path(
|
||||
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude"
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
path = config_dir / "claude_desktop_config.json"
|
||||
return _parse_mcp_config(path, "claude-desktop")
|
||||
|
||||
|
||||
def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]:
|
||||
"""Scan ``~/.claude.json`` for global and project-scoped MCP servers."""
|
||||
path = Path.home() / ".claude.json"
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
try:
|
||||
data: dict[str, Any] = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Invalid JSON in %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
results: list[DiscoveredServer] = []
|
||||
|
||||
# Global servers
|
||||
if global_servers := data.get("mcpServers"):
|
||||
if isinstance(global_servers, dict):
|
||||
results.extend(
|
||||
_parse_mcp_servers(
|
||||
global_servers, source="claude-code", config_path=path
|
||||
)
|
||||
)
|
||||
|
||||
# Project-scoped servers matching start_dir
|
||||
resolved_dir = str(start_dir.resolve())
|
||||
projects = data.get("projects", {})
|
||||
if isinstance(projects, dict):
|
||||
project_data = projects.get(resolved_dir, {})
|
||||
if isinstance(project_data, dict):
|
||||
if project_servers := project_data.get("mcpServers"):
|
||||
if isinstance(project_servers, dict):
|
||||
results.extend(
|
||||
_parse_mcp_servers(
|
||||
project_servers,
|
||||
source="claude-code",
|
||||
config_path=path,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _scan_cursor_workspace(start_dir: Path) -> list[DiscoveredServer]:
|
||||
"""Walk up from *start_dir* looking for ``.cursor/mcp.json``."""
|
||||
current = start_dir.resolve()
|
||||
home = Path.home().resolve()
|
||||
|
||||
while True:
|
||||
candidate = current / ".cursor" / "mcp.json"
|
||||
if candidate.is_file():
|
||||
return _parse_mcp_config(candidate, "cursor")
|
||||
|
||||
parent = current.parent
|
||||
# Stop at filesystem root or home directory
|
||||
if parent == current or current == home:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _scan_project_mcp_json(start_dir: Path) -> list[DiscoveredServer]:
|
||||
"""Check for ``mcp.json`` in *start_dir*."""
|
||||
candidate = start_dir.resolve() / "mcp.json"
|
||||
if candidate.is_file():
|
||||
return _parse_mcp_config(candidate, "project")
|
||||
return []
|
||||
|
||||
|
||||
def _scan_gemini(start_dir: Path) -> list[DiscoveredServer]:
|
||||
"""Scan Gemini CLI settings for MCP servers.
|
||||
|
||||
Checks both user-level ``~/.gemini/settings.json`` and project-level
|
||||
``.gemini/settings.json``.
|
||||
"""
|
||||
results: list[DiscoveredServer] = []
|
||||
|
||||
# User-level
|
||||
user_path = Path.home() / ".gemini" / "settings.json"
|
||||
results.extend(_parse_mcp_config(user_path, "gemini"))
|
||||
|
||||
# Project-level
|
||||
project_path = start_dir.resolve() / ".gemini" / "settings.json"
|
||||
if project_path != user_path:
|
||||
results.extend(_parse_mcp_config(project_path, "gemini"))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _scan_goose() -> list[DiscoveredServer]:
|
||||
"""Scan Goose config for MCP server extensions.
|
||||
|
||||
Goose uses YAML (``~/.config/goose/config.yaml``) with a different
|
||||
schema — MCP servers are defined as ``extensions`` with ``type: stdio``.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
config_dir = Path(
|
||||
os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"),
|
||||
"Block",
|
||||
"goose",
|
||||
"config",
|
||||
)
|
||||
else:
|
||||
config_dir = Path(
|
||||
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"),
|
||||
"goose",
|
||||
)
|
||||
|
||||
path = config_dir / "config.yaml"
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError as exc:
|
||||
logger.warning("Invalid YAML in %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
extensions = data.get("extensions", {})
|
||||
if not isinstance(extensions, dict):
|
||||
return []
|
||||
|
||||
# Convert Goose extensions to mcpServers format
|
||||
servers: dict[str, Any] = {}
|
||||
for name, ext in extensions.items():
|
||||
if not isinstance(ext, dict):
|
||||
continue
|
||||
if not ext.get("enabled", True):
|
||||
continue
|
||||
ext_type = ext.get("type", "")
|
||||
if ext_type == "stdio" and "cmd" in ext:
|
||||
servers[name] = {
|
||||
"command": ext["cmd"],
|
||||
"args": ext.get("args", []),
|
||||
"env": ext.get("envs", {}),
|
||||
}
|
||||
elif ext_type == "sse" and "uri" in ext:
|
||||
servers[name] = {"url": ext["uri"], "transport": "sse"}
|
||||
|
||||
return _parse_mcp_servers(servers, source="goose", config_path=path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer]:
|
||||
"""Run all scanners and return the combined results.
|
||||
|
||||
Duplicate names across sources are preserved — callers can
|
||||
use :pyattr:`DiscoveredServer.qualified_name` to disambiguate.
|
||||
"""
|
||||
cwd = start_dir or Path.cwd()
|
||||
results: list[DiscoveredServer] = []
|
||||
results.extend(_scan_claude_desktop())
|
||||
results.extend(_scan_claude_code(cwd))
|
||||
results.extend(_scan_cursor_workspace(cwd))
|
||||
results.extend(_scan_gemini(cwd))
|
||||
results.extend(_scan_goose())
|
||||
results.extend(_scan_project_mcp_json(cwd))
|
||||
return results
|
||||
|
||||
|
||||
def resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport:
|
||||
"""Resolve a server name (or ``source:name``) to a transport.
|
||||
|
||||
Raises :class:`ValueError` when the name is not found or is ambiguous.
|
||||
"""
|
||||
servers = discover_servers(start_dir)
|
||||
|
||||
# Qualified form: "cursor:weather"
|
||||
if ":" in name:
|
||||
source, server_name = name.split(":", 1)
|
||||
matches = [s for s in servers if s.source == source and s.name == server_name]
|
||||
if not matches:
|
||||
raise ValueError(
|
||||
f"No server named '{server_name}' found in source '{source}'."
|
||||
)
|
||||
return matches[0].config.to_transport()
|
||||
|
||||
# Bare name: "weather"
|
||||
matches = [s for s in servers if s.name == name]
|
||||
|
||||
if not matches:
|
||||
if servers:
|
||||
available = ", ".join(sorted({s.name for s in servers}))
|
||||
raise ValueError(f"No server named '{name}' found. Available: {available}")
|
||||
locations = [
|
||||
"Claude Desktop config",
|
||||
"~/.claude.json (Claude Code)",
|
||||
".cursor/mcp.json (walked up from cwd)",
|
||||
"~/.gemini/settings.json (Gemini CLI)",
|
||||
"~/.config/goose/config.yaml (Goose)",
|
||||
"./mcp.json",
|
||||
]
|
||||
raise ValueError(
|
||||
f"No server named '{name}' found. Searched: {', '.join(locations)}"
|
||||
)
|
||||
|
||||
if len(matches) == 1:
|
||||
return matches[0].config.to_transport()
|
||||
|
||||
# Ambiguous — list qualified alternatives
|
||||
alternatives = ", ".join(f"'{m.qualified_name}'" for m in matches)
|
||||
raise ValueError(
|
||||
f"Ambiguous server name '{name}' — found in multiple sources. "
|
||||
f"Use a qualified name: {alternatives}"
|
||||
)
|
||||
668
tests/cli/test_discovery.py
Normal file
668
tests/cli/test_discovery.py
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
"""Tests for MCP server discovery and name-based resolution."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from fastmcp.cli.client import _is_http_target, resolve_server_spec
|
||||
from fastmcp.cli.discovery import (
|
||||
DiscoveredServer,
|
||||
_normalize_server_entry,
|
||||
_parse_mcp_config,
|
||||
_scan_claude_code,
|
||||
_scan_claude_desktop,
|
||||
_scan_cursor_workspace,
|
||||
_scan_gemini,
|
||||
_scan_goose,
|
||||
_scan_project_mcp_json,
|
||||
discover_servers,
|
||||
resolve_name,
|
||||
)
|
||||
from fastmcp.client.transports.http import StreamableHttpTransport
|
||||
from fastmcp.client.transports.sse import SSETransport
|
||||
from fastmcp.client.transports.stdio import StdioTransport
|
||||
from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STDIO_CONFIG: dict[str, Any] = {
|
||||
"mcpServers": {
|
||||
"weather": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mcp/weather"],
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mcp/github"],
|
||||
"env": {"GITHUB_TOKEN": "xxx"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_REMOTE_CONFIG: dict[str, Any] = {
|
||||
"mcpServers": {
|
||||
"api": {
|
||||
"url": "http://localhost:8000/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _write_config(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DiscoveredServer properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoveredServer:
|
||||
def test_qualified_name(self):
|
||||
server = DiscoveredServer(
|
||||
name="weather",
|
||||
source="claude-desktop",
|
||||
config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]),
|
||||
config_path=Path("/fake/config.json"),
|
||||
)
|
||||
assert server.qualified_name == "claude-desktop:weather"
|
||||
|
||||
def test_transport_summary_stdio(self):
|
||||
server = DiscoveredServer(
|
||||
name="weather",
|
||||
source="cursor",
|
||||
config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]),
|
||||
config_path=Path("/fake/config.json"),
|
||||
)
|
||||
assert server.transport_summary == "stdio: npx -y @mcp/weather"
|
||||
|
||||
def test_transport_summary_remote(self):
|
||||
server = DiscoveredServer(
|
||||
name="api",
|
||||
source="project",
|
||||
config=RemoteMCPServer(url="http://localhost:8000/mcp"),
|
||||
config_path=Path("/fake/config.json"),
|
||||
)
|
||||
assert server.transport_summary == "http: http://localhost:8000/mcp"
|
||||
|
||||
def test_transport_summary_remote_sse(self):
|
||||
server = DiscoveredServer(
|
||||
name="api",
|
||||
source="project",
|
||||
config=RemoteMCPServer(url="http://localhost:8000/sse", transport="sse"),
|
||||
config_path=Path("/fake/config.json"),
|
||||
)
|
||||
assert server.transport_summary == "sse: http://localhost:8000/sse"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_mcp_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseMcpConfig:
|
||||
def test_valid_config(self, tmp_path: Path):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(path, _STDIO_CONFIG)
|
||||
servers = _parse_mcp_config(path, "test-source")
|
||||
assert len(servers) == 2
|
||||
names = {s.name for s in servers}
|
||||
assert names == {"weather", "github"}
|
||||
assert all(s.source == "test-source" for s in servers)
|
||||
assert all(s.config_path == path for s in servers)
|
||||
|
||||
def test_missing_file(self, tmp_path: Path):
|
||||
path = tmp_path / "nonexistent.json"
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert servers == []
|
||||
|
||||
def test_invalid_json(self, tmp_path: Path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{not json")
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert servers == []
|
||||
|
||||
def test_no_mcp_servers_key(self, tmp_path: Path):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(path, {"something": "else"})
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert servers == []
|
||||
|
||||
def test_empty_mcp_servers(self, tmp_path: Path):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(path, {"mcpServers": {}})
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert servers == []
|
||||
|
||||
def test_remote_server(self, tmp_path: Path):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(path, _REMOTE_CONFIG)
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert len(servers) == 1
|
||||
assert isinstance(servers[0].config, RemoteMCPServer)
|
||||
assert servers[0].config.url == "http://localhost:8000/mcp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Claude Desktop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanClaudeDesktop:
|
||||
def test_finds_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
config_dir = tmp_path / "Claude"
|
||||
config_path = config_dir / "claude_desktop_config.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
# Force darwin for deterministic path
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin")
|
||||
|
||||
# We need to override the path construction. On macOS it's
|
||||
# ~/Library/Application Support/Claude — create that.
|
||||
mac_dir = tmp_path / "Library" / "Application Support" / "Claude"
|
||||
mac_path = mac_dir / "claude_desktop_config.json"
|
||||
_write_config(mac_path, _STDIO_CONFIG)
|
||||
|
||||
servers = _scan_claude_desktop()
|
||||
assert len(servers) == 2
|
||||
assert all(s.source == "claude-desktop" for s in servers)
|
||||
|
||||
def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin")
|
||||
servers = _scan_claude_desktop()
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalize server entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeServerEntry:
|
||||
def test_remote_type_becomes_transport(self):
|
||||
entry = {"url": "http://localhost:8000/sse", "type": "sse"}
|
||||
result = _normalize_server_entry(entry)
|
||||
assert result["transport"] == "sse"
|
||||
assert "type" not in result
|
||||
|
||||
def test_remote_with_transport_unchanged(self):
|
||||
entry = {"url": "http://localhost:8000/mcp", "transport": "http"}
|
||||
result = _normalize_server_entry(entry)
|
||||
assert result["transport"] == "http"
|
||||
|
||||
def test_stdio_type_unchanged(self):
|
||||
"""Stdio entries have ``type`` as a proper field — leave it alone."""
|
||||
entry = {"command": "npx", "args": [], "type": "stdio"}
|
||||
result = _normalize_server_entry(entry)
|
||||
assert result["type"] == "stdio"
|
||||
|
||||
def test_gemini_http_url_becomes_url(self):
|
||||
entry = {"httpUrl": "https://api.example.com/mcp/"}
|
||||
result = _normalize_server_entry(entry)
|
||||
assert result["url"] == "https://api.example.com/mcp/"
|
||||
assert "httpUrl" not in result
|
||||
|
||||
def test_gemini_http_url_does_not_override_url(self):
|
||||
entry = {"url": "http://real.com", "httpUrl": "http://other.com"}
|
||||
result = _normalize_server_entry(entry)
|
||||
assert result["url"] == "http://real.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Claude Code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _claude_code_config(
|
||||
*,
|
||||
global_servers: dict[str, Any] | None = None,
|
||||
project_path: str | None = None,
|
||||
project_servers: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a minimal ~/.claude.json structure."""
|
||||
data: dict[str, Any] = {}
|
||||
if global_servers is not None:
|
||||
data["mcpServers"] = global_servers
|
||||
if project_path and project_servers is not None:
|
||||
data["projects"] = {project_path: {"mcpServers": project_servers}}
|
||||
return data
|
||||
|
||||
|
||||
class TestScanClaudeCode:
|
||||
def test_global_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
config_path = tmp_path / ".claude.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
_claude_code_config(global_servers=_STDIO_CONFIG["mcpServers"]),
|
||||
)
|
||||
servers = _scan_claude_code(tmp_path)
|
||||
assert len(servers) == 2
|
||||
assert all(s.source == "claude-code" for s in servers)
|
||||
|
||||
def test_project_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
project_dir = tmp_path / "my-project"
|
||||
project_dir.mkdir()
|
||||
config_path = tmp_path / ".claude.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
_claude_code_config(
|
||||
project_path=str(project_dir),
|
||||
project_servers={"api": {"url": "http://localhost:8000/mcp"}},
|
||||
),
|
||||
)
|
||||
servers = _scan_claude_code(project_dir)
|
||||
assert len(servers) == 1
|
||||
assert servers[0].name == "api"
|
||||
|
||||
def test_global_and_project_combined(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
project_dir = tmp_path / "proj"
|
||||
project_dir.mkdir()
|
||||
config_path = tmp_path / ".claude.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
_claude_code_config(
|
||||
global_servers={"global-tool": {"command": "echo", "args": ["hi"]}},
|
||||
project_path=str(project_dir),
|
||||
project_servers={"local-tool": {"command": "cat", "args": []}},
|
||||
),
|
||||
)
|
||||
servers = _scan_claude_code(project_dir)
|
||||
names = {s.name for s in servers}
|
||||
assert names == {"global-tool", "local-tool"}
|
||||
|
||||
def test_type_normalized_to_transport(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Claude Code uses ``type: sse`` — verify it becomes ``transport``."""
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
config_path = tmp_path / ".claude.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
_claude_code_config(
|
||||
global_servers={
|
||||
"sse-server": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8000/sse",
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
servers = _scan_claude_code(tmp_path)
|
||||
assert len(servers) == 1
|
||||
assert isinstance(servers[0].config, RemoteMCPServer)
|
||||
assert servers[0].config.transport == "sse"
|
||||
|
||||
def test_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
servers = _scan_claude_code(tmp_path)
|
||||
assert servers == []
|
||||
|
||||
def test_no_matching_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
config_path = tmp_path / ".claude.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
_claude_code_config(
|
||||
project_path="/some/other/project",
|
||||
project_servers={"tool": {"command": "echo", "args": []}},
|
||||
),
|
||||
)
|
||||
servers = _scan_claude_code(tmp_path)
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Cursor workspace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanCursorWorkspace:
|
||||
def test_finds_config_in_cwd(self, tmp_path: Path):
|
||||
cursor_path = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_path, _STDIO_CONFIG)
|
||||
servers = _scan_cursor_workspace(tmp_path)
|
||||
assert len(servers) == 2
|
||||
assert all(s.source == "cursor" for s in servers)
|
||||
|
||||
def test_finds_config_in_parent(self, tmp_path: Path):
|
||||
cursor_path = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_path, _STDIO_CONFIG)
|
||||
child = tmp_path / "src" / "deep"
|
||||
child.mkdir(parents=True)
|
||||
servers = _scan_cursor_workspace(child)
|
||||
assert len(servers) == 2
|
||||
|
||||
def test_stops_at_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
# Place config above home — should not be found
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
above_home = tmp_path.parent / ".cursor" / "mcp.json"
|
||||
_write_config(above_home, _STDIO_CONFIG)
|
||||
child = tmp_path / "project"
|
||||
child.mkdir()
|
||||
servers = _scan_cursor_workspace(child)
|
||||
assert servers == []
|
||||
|
||||
def test_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
# Confine walk to tmp_path so it doesn't find sibling test dirs
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
servers = _scan_cursor_workspace(tmp_path)
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: project mcp.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanProjectMcpJson:
|
||||
def test_finds_config(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
servers = _scan_project_mcp_json(tmp_path)
|
||||
assert len(servers) == 2
|
||||
assert all(s.source == "project" for s in servers)
|
||||
|
||||
def test_no_config(self, tmp_path: Path):
|
||||
servers = _scan_project_mcp_json(tmp_path)
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Gemini CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanGemini:
|
||||
def test_user_level_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
config_path = tmp_path / ".gemini" / "settings.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
servers = _scan_gemini(tmp_path)
|
||||
assert len(servers) == 2
|
||||
assert all(s.source == "gemini" for s in servers)
|
||||
|
||||
def test_project_level_config(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
project_dir = tmp_path / "my-project"
|
||||
project_dir.mkdir()
|
||||
config_path = project_dir / ".gemini" / "settings.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
servers = _scan_gemini(project_dir)
|
||||
assert len(servers) == 2
|
||||
|
||||
def test_http_url_normalized(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Gemini uses ``httpUrl`` — verify it becomes ``url``."""
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
config_path = tmp_path / ".gemini" / "settings.json"
|
||||
_write_config(
|
||||
config_path,
|
||||
{
|
||||
"mcpServers": {
|
||||
"api": {"httpUrl": "https://api.example.com/mcp/"},
|
||||
}
|
||||
},
|
||||
)
|
||||
servers = _scan_gemini(tmp_path)
|
||||
assert len(servers) == 1
|
||||
assert isinstance(servers[0].config, RemoteMCPServer)
|
||||
assert servers[0].config.url == "https://api.example.com/mcp/"
|
||||
|
||||
def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
servers = _scan_gemini(tmp_path)
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Goose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GOOSE_CONFIG = {
|
||||
"extensions": {
|
||||
"developer": {
|
||||
"enabled": True,
|
||||
"name": "developer",
|
||||
"type": "builtin",
|
||||
},
|
||||
"tavily": {
|
||||
"cmd": "npx",
|
||||
"args": ["-y", "mcp-tavily-search"],
|
||||
"enabled": True,
|
||||
"envs": {"TAVILY_API_KEY": "xxx"},
|
||||
"type": "stdio",
|
||||
},
|
||||
"disabled-tool": {
|
||||
"cmd": "echo",
|
||||
"args": ["hi"],
|
||||
"enabled": False,
|
||||
"type": "stdio",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestScanGoose:
|
||||
def test_finds_stdio_extensions(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
config_dir = tmp_path / ".config" / "goose"
|
||||
config_path = config_dir / "config.yaml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(yaml.dump(_GOOSE_CONFIG))
|
||||
# Force non-windows platform for path logic
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
|
||||
servers = _scan_goose()
|
||||
assert len(servers) == 1
|
||||
assert servers[0].name == "tavily"
|
||||
assert servers[0].source == "goose"
|
||||
assert isinstance(servers[0].config, StdioMCPServer)
|
||||
assert servers[0].config.command == "npx"
|
||||
|
||||
def test_skips_builtin_and_disabled(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
config_dir = tmp_path / ".config" / "goose"
|
||||
config_path = config_dir / "config.yaml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(yaml.dump(_GOOSE_CONFIG))
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
|
||||
servers = _scan_goose()
|
||||
names = {s.name for s in servers}
|
||||
assert "developer" not in names
|
||||
assert "disabled-tool" not in names
|
||||
|
||||
def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
|
||||
servers = _scan_goose()
|
||||
assert servers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _suppress_user_scanners(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress all scanners that read real user config files."""
|
||||
monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_desktop", lambda: [])
|
||||
monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_code", lambda start_dir: [])
|
||||
monkeypatch.setattr("fastmcp.cli.discovery._scan_gemini", lambda start_dir: [])
|
||||
monkeypatch.setattr("fastmcp.cli.discovery._scan_goose", lambda: [])
|
||||
|
||||
|
||||
class TestDiscoverServers:
|
||||
def test_combines_sources(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
# Set up project mcp.json
|
||||
project_config = tmp_path / "mcp.json"
|
||||
_write_config(project_config, _STDIO_CONFIG)
|
||||
|
||||
# Set up cursor config
|
||||
cursor_config = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_config, _REMOTE_CONFIG)
|
||||
|
||||
_suppress_user_scanners(monkeypatch)
|
||||
|
||||
servers = discover_servers(start_dir=tmp_path)
|
||||
sources = {s.source for s in servers}
|
||||
assert "project" in sources
|
||||
assert "cursor" in sources
|
||||
assert len(servers) == 3 # 2 from project + 1 from cursor
|
||||
|
||||
def test_preserves_duplicates(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Same server name in multiple sources should appear multiple times."""
|
||||
project_config = tmp_path / "mcp.json"
|
||||
_write_config(project_config, _STDIO_CONFIG)
|
||||
|
||||
cursor_config = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_config, _STDIO_CONFIG)
|
||||
|
||||
_suppress_user_scanners(monkeypatch)
|
||||
|
||||
servers = discover_servers(start_dir=tmp_path)
|
||||
weather_servers = [s for s in servers if s.name == "weather"]
|
||||
assert len(weather_servers) == 2
|
||||
assert {s.source for s in weather_servers} == {"cursor", "project"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveName:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_scanners(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Suppress scanners that read real user configs and confine walks to tmp_path."""
|
||||
_suppress_user_scanners(monkeypatch)
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
|
||||
def test_unique_match(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
transport = resolve_name("weather", start_dir=tmp_path)
|
||||
assert isinstance(transport, StdioTransport)
|
||||
|
||||
def test_qualified_match(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
transport = resolve_name("project:weather", start_dir=tmp_path)
|
||||
assert isinstance(transport, StdioTransport)
|
||||
|
||||
def test_not_found_with_servers(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
with pytest.raises(ValueError, match="No server named 'nope'.*Available"):
|
||||
resolve_name("nope", start_dir=tmp_path)
|
||||
|
||||
def test_not_found_no_servers(self, tmp_path: Path):
|
||||
with pytest.raises(ValueError, match="No server named 'nope'.*Searched"):
|
||||
resolve_name("nope", start_dir=tmp_path)
|
||||
|
||||
def test_ambiguous_name(self, tmp_path: Path):
|
||||
project_config = tmp_path / "mcp.json"
|
||||
_write_config(project_config, _STDIO_CONFIG)
|
||||
cursor_config = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_config, _STDIO_CONFIG)
|
||||
with pytest.raises(ValueError, match="Ambiguous server name 'weather'"):
|
||||
resolve_name("weather", start_dir=tmp_path)
|
||||
|
||||
def test_ambiguous_resolved_by_qualified(self, tmp_path: Path):
|
||||
project_config = tmp_path / "mcp.json"
|
||||
_write_config(project_config, _STDIO_CONFIG)
|
||||
cursor_config = tmp_path / ".cursor" / "mcp.json"
|
||||
_write_config(cursor_config, _STDIO_CONFIG)
|
||||
transport = resolve_name("cursor:weather", start_dir=tmp_path)
|
||||
assert isinstance(transport, StdioTransport)
|
||||
|
||||
def test_qualified_not_found(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
with pytest.raises(
|
||||
ValueError, match="No server named 'nope' found in source 'project'"
|
||||
):
|
||||
resolve_name("project:nope", start_dir=tmp_path)
|
||||
|
||||
def test_remote_server_resolves_to_http_transport(self, tmp_path: Path):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _REMOTE_CONFIG)
|
||||
transport = resolve_name("api", start_dir=tmp_path)
|
||||
assert isinstance(transport, StreamableHttpTransport)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: resolve_server_spec falls through to name resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveServerSpecNameFallback:
|
||||
def test_bare_name_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
config_path = tmp_path / "mcp.json"
|
||||
_write_config(config_path, _STDIO_CONFIG)
|
||||
_suppress_user_scanners(monkeypatch)
|
||||
monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
|
||||
|
||||
# Monkeypatch resolve_name in client module to use our tmp_path
|
||||
original_resolve = resolve_name
|
||||
|
||||
def patched_resolve(name: str, start_dir: Path | None = None) -> Any:
|
||||
return original_resolve(name, start_dir=tmp_path)
|
||||
|
||||
monkeypatch.setattr("fastmcp.cli.client.resolve_name", patched_resolve)
|
||||
|
||||
result = resolve_server_spec("weather")
|
||||
assert isinstance(result, StdioTransport)
|
||||
|
||||
def test_url_takes_priority_over_name(self):
|
||||
"""URLs should be resolved before name lookup."""
|
||||
result = resolve_server_spec("http://localhost:8000/mcp")
|
||||
assert result == "http://localhost:8000/mcp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: _is_http_target detects transport objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsHttpTargetTransports:
|
||||
def test_streamable_http_transport(self):
|
||||
transport = StreamableHttpTransport("http://localhost:8000/mcp")
|
||||
assert _is_http_target(transport) is True
|
||||
|
||||
def test_sse_transport(self):
|
||||
transport = SSETransport("http://localhost:8000/sse")
|
||||
assert _is_http_target(transport) is True
|
||||
|
||||
def test_stdio_transport(self):
|
||||
transport = StdioTransport(command="echo", args=["hello"])
|
||||
assert _is_http_target(transport) is False
|
||||
|
||||
def test_string_url(self):
|
||||
assert _is_http_target("http://localhost:8000") is True
|
||||
|
||||
def test_string_non_url(self):
|
||||
assert _is_http_target("server.py") is False
|
||||
|
||||
def test_dict_config(self):
|
||||
assert _is_http_target({"mcpServers": {}}) is False
|
||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -696,6 +696,7 @@ dependencies = [
|
|||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "watchfiles" },
|
||||
|
|
@ -763,6 +764,7 @@ requires-dist = [
|
|||
{ name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" },
|
||||
{ name = "pyperclip", specifier = ">=1.9.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "rich", specifier = ">=13.9.4" },
|
||||
{ name = "uvicorn", specifier = ">=0.35" },
|
||||
{ name = "watchfiles", specifier = ">=1.0.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue