Merge branch 'main' into claude/issue-3049-20260131-2232

This commit is contained in:
Jeremiah Lowin 2026-02-01 21:29:57 -05:00 committed by GitHub
commit 186604a8a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3257 additions and 2 deletions

161
docs/clients/cli.mdx Normal file
View file

@ -0,0 +1,161 @@
---
title: Client CLI
sidebarTitle: CLI
description: Query and invoke MCP server tools directly from the terminal with fastmcp list and fastmcp call.
icon: terminal
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.0.0" />
MCP servers are designed for programmatic consumption by AI assistants and applications. But during development, you often want to poke at a server directly: check what tools it exposes, call one with test arguments, or verify that a deployment is responding correctly. The FastMCP CLI gives you that direct access with two commands, `fastmcp list` and `fastmcp call`, so you can query and invoke any MCP server without writing a single line of Python.
These commands are also valuable for LLM-based agents that lack native MCP support. An agent that can execute shell commands can use `fastmcp list --json` to discover available tools and `fastmcp call --json` to invoke them, with structured JSON output designed for programmatic consumption.
## Server Targets
Both commands need to know which server to talk to. You provide a "server spec" as the first argument, and FastMCP figures out the transport automatically. You can point at an HTTP URL for a running server, a Python file that defines one, a JSON configuration file that describes one, or a JavaScript file. The CLI resolves the right connection mechanism so you can focus on the query.
```bash
fastmcp list http://localhost:8000/mcp
fastmcp list server.py
fastmcp list mcp-config.json
```
Python files are handled with particular care. Rather than requiring your script to call `mcp.run()` at the bottom, the CLI routes it through `fastmcp run` internally, which means any Python file that defines a FastMCP server object works as a target with no boilerplate.
For servers that communicate over stdio (common with Node.js-based MCP servers), use the `--command` flag instead of a positional server spec. The string is shell-split into a command and arguments.
```bash
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.
```bash
fastmcp list http://localhost:8000/mcp
```
The output looks like a Python function signature, making it easy to see at a glance what a tool expects and what it returns. Required parameters appear with just their type annotation, while optional ones show their defaults.
When you need the full JSON Schema for a tool's inputs or outputs -- useful for understanding nested object structures or enum constraints -- opt into them with `--input-schema` or `--output-schema`. These print the raw schema beneath each tool signature.
### Beyond Tools
MCP servers can expose resources and prompts alongside tools. By default, `fastmcp list` only shows tools because they are the most common interaction point. Add `--resources` or `--prompts` to include those in the output.
```bash
fastmcp list server.py --resources --prompts
```
Resources appear with their URIs and descriptions. Prompts appear with their argument names so you can see what parameters they accept.
### Machine-Readable Output
The `--json` flag switches from human-friendly text to structured JSON. Each tool includes its name, description, and full input schema (and output schema when present). When combined with `--resources` or `--prompts`, those are included as additional top-level keys.
```bash
fastmcp list server.py --json
```
This is the format to use when building automation around MCP servers or feeding tool definitions to an LLM agent that needs to decide which tool to call.
## Calling Tools
`fastmcp call` invokes a single tool on a server. You provide the server spec, the tool name, and arguments as `key=value` pairs. The CLI fetches the tool's schema, coerces your string values to the correct types (integers, floats, booleans, arrays, objects), and makes the call.
```bash
fastmcp call http://localhost:8000/mcp search query=hello limit=5
```
Type coercion is driven by the tool's JSON Schema. If a parameter is declared as an integer, the string `"5"` becomes the integer `5`. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Array and object parameters are parsed as JSON.
For tools with complex or deeply nested arguments, the `key=value` syntax gets unwieldy. You can pass a single JSON object as the argument instead, and the CLI treats it as the full input dictionary.
```bash
fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale", "new"], "metadata": {"color": "blue"}}'
```
Alternatively, `--input-json` provides the base argument dictionary. Any `key=value` pairs you add alongside it override keys from the JSON, which is useful for templating a complex call and varying one parameter at a time.
### Error Handling
The CLI validates your call before sending it. If you misspell a tool name, it uses fuzzy matching to suggest corrections. If you omit a required argument, it tells you which ones are missing and prints the tool's signature as a reminder.
When a tool call itself returns an error (the server executed the tool but it failed), the error message is printed and the CLI exits with a non-zero status code, making it straightforward to use in scripts.
### Structured Output
Like `fastmcp list`, the `--json` flag on `fastmcp call` emits structured JSON instead of formatted text. The output includes the content blocks, error status, and structured content when the server provides it. Use this when you need to parse tool results programmatically.
```bash
fastmcp call server.py get_weather city=London --json
```
## Authentication
When the server target is an HTTP URL, the CLI automatically enables OAuth authentication. If the server requires it, you will be guided through the OAuth flow (typically opening a browser for authorization). If the server has no auth requirements, the OAuth setup is a silent no-op.
To explicitly disable authentication -- for example, when connecting to a local development server where OAuth setup would just slow you down -- pass `--auth none`.
```bash
fastmcp call http://localhost:8000/mcp my_tool --auth none
```
## Transport Override
FastMCP defaults to Streamable HTTP for URL targets. If you are connecting to a server that only supports Server-Sent Events (SSE), use `--transport sse` to force the older transport. This appends `/sse` to the URL path automatically so the client picks the correct protocol.
```bash
fastmcp list http://localhost:8000 --transport sse
```
## Interactive Elicitation
Some MCP tools request additional input from the user during execution through a mechanism called elicitation. When a tool sends an elicitation request, the CLI prints the server's question to the terminal and prompts you to respond. Each field in the elicitation schema is presented with its name and expected type, and required fields are clearly marked.
You can type `decline` to skip a question or `cancel` to abort the tool call entirely. This interactive behavior means the CLI works naturally with tools that have multi-step or conversational workflows.
## LLM Agent Integration
For LLM agents that can execute shell commands but lack built-in MCP support, the CLI provides a clean integration path. The agent calls `fastmcp list --json` to get a structured description of every available tool, including full input schemas, and then calls `fastmcp call --json` with the chosen tool and arguments. Both commands return well-formed JSON that is straightforward to parse.
Because the CLI handles connection management, transport selection, and type coercion internally, the agent does not need to understand MCP protocol details. It just needs to read JSON and construct shell commands.

View file

@ -6,6 +6,55 @@ This document tracks major features in FastMCP v3.0 for release notes preparatio
## 3.0.0beta2
### CLI: `fastmcp list` and `fastmcp call`
New client-side CLI commands for querying and invoking tools on any MCP server — remote URLs, local Python files, MCPConfig JSON, or arbitrary stdio commands. Especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands.
```bash
# Discover tools on a server
fastmcp list http://localhost:8000/mcp
fastmcp list server.py
fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
# Call a tool
fastmcp call server.py greet name=World
fastmcp call http://localhost:8000/mcp search query=hello limit=5
fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}'
```
Key features:
- Tool arguments are auto-coerced using the tool's JSON schema (`limit=5` → int)
- Single JSON objects work as positional args alongside `key=value` and `--input-json`
- `--input-schema` / `--output-schema` for full JSON schemas, `--json` for machine-readable output
- `--transport sse` for SSE servers, `--command` for stdio servers
- Auto OAuth for HTTP targets (no-ops if server doesn't require auth)
- Fuzzy tool name matching suggests alternatives on typos
- Interactive terminal elicitation for tools that request user input mid-execution
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.

View file

@ -168,6 +168,7 @@
"group": "Clients",
"pages": [
"clients/client",
"clients/cli",
"clients/transports",
{
"group": "Core Operations",

View file

@ -18,6 +18,8 @@ fastmcp --help
| Command | Purpose | Dependency Management |
| ------- | ------- | --------------------- |
| `list` | List tools on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) |
| `call` | Call a tool on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) |
| `run` | Run a FastMCP server directly | **Supports:** Local files, factory functions, URLs, fastmcp.json configs, MCP configs. **Deps:** Uses your local environment directly. With `--python`, `--with`, `--project`, or `--with-requirements`: Runs via `uv run` subprocess. With fastmcp.json: Automatically manages dependencies based on configuration |
| `dev` | Run a server with the MCP Inspector for testing | **Supports:** Local files and fastmcp.json configs. **Deps:** Always runs via `uv run` subprocess (never uses your local environment); dependencies must be specified or available in a uv-managed project. With fastmcp.json: Uses configured dependencies |
| `install` | Install a server in MCP client applications | **Supports:** Local files and fastmcp.json configs. **Deps:** Creates an isolated environment; dependencies must be explicitly specified with `--with` and/or `--with-editable`. With fastmcp.json: Uses configured dependencies |
@ -25,6 +27,138 @@ fastmcp --help
| `project prepare` | Create a persistent uv project from fastmcp.json environment config | **Supports:** fastmcp.json configs only. **Deps:** Creates a uv project directory with all dependencies pre-installed for reuse with `--project` flag |
| `version` | Display version information | N/A |
## `fastmcp list`
List tools available on any MCP server. This works with remote URLs, local Python files, MCPConfig JSON files, and arbitrary stdio commands. Together with `fastmcp call`, these commands are especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands.
```bash
fastmcp list http://localhost:8000/mcp
fastmcp list server.py
fastmcp list mcp.json
fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
```
By default, the output shows each tool's signature and description. Use `--input-schema` or `--output-schema` to include full JSON schemas, or `--json` for machine-readable output.
### Options
| Option | Flag | Description |
| ------ | ---- | ----------- |
| Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) |
| Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) |
| Resources | `--resources` | Also list resources |
| Prompts | `--prompts` | Also list prompts |
| Input Schema | `--input-schema` | Show full input schemas |
| Output Schema | `--output-schema` | Show full output schemas |
| JSON | `--json` | Output as JSON |
| Timeout | `--timeout` | Connection timeout in seconds |
| Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable |
### Server Targets
The `<server>` argument accepts:
1. **URLs** — `http://` or `https://` endpoints. Uses Streamable HTTP by default; pass `--transport sse` for SSE servers.
2. **Python files** — `.py` files are run via `fastmcp run` automatically.
3. **MCPConfig JSON** — `.json` files with an `mcpServers` key are treated as multi-server configs.
4. **Stdio commands** — Use `--command` to connect to any MCP server via stdio (e.g. `npx`, `uvx`).
### Examples
```bash
# List tools on a remote server
fastmcp list http://localhost:8000/mcp
# List tools from a local Python file
fastmcp list server.py
# Include full input schemas
fastmcp list server.py --input-schema
# Machine-readable JSON
fastmcp list server.py --json
# SSE server
fastmcp list http://localhost:8000/mcp --transport sse
# Stdio command
fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
# Include resources and prompts
fastmcp list server.py --resources --prompts
```
## `fastmcp call`
Call a tool on any MCP server. Arguments can be passed as `key=value` pairs, a single JSON object, or via `--input-json`.
```bash
fastmcp call server.py greet name=World
fastmcp call http://localhost:8000/mcp search query=hello limit=5
fastmcp call server.py create_item '{"name": "x", "tags": ["a", "b"]}'
```
Tool arguments are automatically coerced to the correct type based on the tool's input schema — string values like `limit=5` become integers when the schema expects one.
### Options
| Option | Flag | Description |
| ------ | ---- | ----------- |
| Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) |
| Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) |
| Input JSON | `--input-json` | JSON string of tool arguments (merged with key=value args) |
| JSON | `--json` | Output raw JSON result |
| Timeout | `--timeout` | Connection timeout in seconds |
| Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable |
### Argument Passing
There are three ways to pass arguments:
**Key=value pairs** are the simplest for flat arguments. Values are coerced using the tool's JSON schema (strings become ints, bools, etc.):
```bash
fastmcp call server.py search query=hello limit=5 verbose=true
```
**A single JSON object** works when you have structured or nested arguments:
```bash
fastmcp call server.py create_item '{"name": "Widget", "tags": ["new", "sale"]}'
```
**`--input-json`** provides a base dict that key=value pairs can override:
```bash
fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10
```
### Examples
```bash
# Call a tool with simple args
fastmcp call server.py greet name=World
# Call with JSON object
fastmcp call server.py create '{"name": "x", "tags": ["a"]}'
# Get JSON output for scripting
fastmcp call server.py add a=3 b=4 --json
# Call a tool on a remote server
fastmcp call http://localhost:8000/mcp search query=hello
# Call via stdio command
fastmcp call --command 'npx -y @mcp/server' tool_name arg=value
# Disable OAuth for HTTP targets
fastmcp call http://localhost:8000/mcp search query=hello --auth none
```
<Tip>
If you call a tool that doesn't exist, FastMCP will suggest similar tool names. Use `fastmcp list` to see all available tools on a server.
</Tip>
## `fastmcp run`
Run a FastMCP server directly or proxy a remote server.

47
examples/elicitation.py Normal file
View file

@ -0,0 +1,47 @@
"""
FastMCP Elicitation Example
Demonstrates tools that ask users for input during execution.
Try it with the CLI:
fastmcp list examples/elicitation.py
fastmcp call examples/elicitation.py greet
fastmcp call examples/elicitation.py survey
"""
from dataclasses import dataclass
from fastmcp import Context, FastMCP
mcp = FastMCP("Elicitation Demo")
@mcp.tool
async def greet(ctx: Context) -> str:
"""Greet the user by name (asks for their name)."""
result = await ctx.elicit("What is your name?", response_type=str)
if result.action == "accept":
return f"Hello, {result.data}!"
return "Maybe next time!"
@mcp.tool
async def survey(ctx: Context) -> str:
"""Run a short survey collecting structured info."""
@dataclass
class SurveyResponse:
favorite_color: str
lucky_number: int
result = await ctx.elicit(
"Quick survey — tell us about yourself:",
response_type=SurveyResponse,
)
if result.action == "accept":
resp = result.data
return f"Got it — you like {resp.favorite_color} and your lucky number is {resp.lucky_number}."
return "Survey skipped."

View file

@ -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",

View file

@ -0,0 +1,114 @@
---
name: fastmcp-client-cli
description: Query and invoke tools on MCP servers using fastmcp list and fastmcp call. Use when you need to discover what tools a server offers, call tools, or integrate MCP servers into workflows.
---
# FastMCP CLI: List and Call
Use `fastmcp list` and `fastmcp call` to interact with any MCP server from the command line.
## Listing Tools
```bash
# Remote server
fastmcp list http://localhost:8000/mcp
# Local Python file (runs via fastmcp run automatically)
fastmcp list server.py
# MCPConfig with multiple servers
fastmcp list mcp.json
# Stdio command (npx, uvx, etc.)
fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
# Include full input/output schemas
fastmcp list server.py --input-schema --output-schema
# Machine-readable JSON
fastmcp list server.py --json
# Include resources and prompts
fastmcp list server.py --resources --prompts
```
Default output shows tool signatures and descriptions. Use `--input-schema` or `--output-schema` to include full JSON schemas, `--json` for structured output.
## Calling Tools
```bash
# Key=value arguments (auto-coerced to correct types)
fastmcp call server.py greet name=World
fastmcp call server.py add a=3 b=4
# Single JSON object for complex/nested args
fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}'
# --input-json with key=value overrides
fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10
# JSON output for scripting
fastmcp call server.py add a=3 b=4 --json
```
Type coercion is automatic: `limit=5` becomes an integer, `verbose=true` becomes a boolean, based on the tool's input schema.
## Server Targets
All commands accept the same server targets:
| Target | Example |
|--------|---------|
| HTTP/HTTPS URL | `http://localhost:8000/mcp` |
| 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`:
```bash
fastmcp list http://localhost:8000/mcp --transport sse
```
## Auth
HTTP targets automatically use OAuth (no-ops if the server doesn't require auth). Disable with `--auth none`:
```bash
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 servers are configured
fastmcp discover
# 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.

View file

@ -19,6 +19,7 @@ from rich.table import Table
import fastmcp
from fastmcp.cli import run as run_module
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
@ -952,6 +953,11 @@ app.command(install_app)
# Add tasks subcommand group
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__":
app()

964
src/fastmcp/cli/client.py Normal file
View file

@ -0,0 +1,964 @@
"""Client-side CLI commands for querying and invoking MCP servers."""
import difflib
import json
import os
import shlex
import sys
from pathlib import Path
from typing import Annotated, Any, Literal
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
logger = get_logger("cli.client")
console = Console()
# ---------------------------------------------------------------------------
# Server spec resolution
# ---------------------------------------------------------------------------
_JSON_SCHEMA_TYPE_MAP: dict[str, str] = {
"string": "str",
"integer": "int",
"number": "float",
"boolean": "bool",
"array": "list",
"object": "dict",
"null": "None",
}
def resolve_server_spec(
server_spec: str | None,
*,
command: str | None = None,
transport: str | None = None,
) -> str | dict[str, Any] | ClientTransport:
"""Turn CLI inputs into something ``Client()`` accepts.
Exactly one of ``server_spec`` or ``command`` should be provided.
Resolution order for ``server_spec``:
1. URLs (``http://``, ``https://``) passed through as-is.
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 name-based resolution via ``resolve_name``.
When ``command`` is provided, the string is shell-split into a
``StdioTransport(command, args)``.
"""
if command is not None and server_spec is not None:
console.print(
"[bold red]Error:[/bold red] Cannot use both a server spec and --command"
)
sys.exit(1)
if command is not None:
return _build_stdio_from_command(command)
if server_spec is None:
console.print(
"[bold red]Error:[/bold red] Provide a server spec or use --command"
)
sys.exit(1)
assert isinstance(server_spec, str)
spec: str = server_spec
# 1. URL
if spec.startswith(("http://", "https://")):
if transport == "sse" and not spec.rstrip("/").endswith("/sse"):
spec = spec.rstrip("/") + "/sse"
return spec
# 2. File path (must be a file, not a directory)
path = Path(spec)
is_file = path.is_file() or (
not path.is_dir() and spec.endswith((".py", ".js", ".json"))
)
if is_file:
if spec.endswith(".json"):
return _resolve_json_spec(path)
if spec.endswith(".py"):
# Run via `fastmcp run` so scripts don't need mcp.run()
resolved_path = path.resolve()
return StdioTransport(
command="fastmcp",
args=["run", str(resolved_path), "--no-banner"],
log_file=Path(os.devnull),
)
# .js — pass through for Client's infer_transport
return spec
# 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:
"""Shell-split a command string into a ``StdioTransport``."""
try:
parts = shlex.split(command_str)
except ValueError as exc:
console.print(f"[bold red]Error:[/bold red] Invalid command: {exc}")
sys.exit(1)
if not parts:
console.print("[bold red]Error:[/bold red] Empty --command")
sys.exit(1)
return StdioTransport(command=parts[0], args=parts[1:], log_file=Path(os.devnull))
def _resolve_json_spec(path: Path) -> str | dict[str, Any]:
"""Disambiguate a ``.json`` server spec."""
if not path.exists():
console.print(
f"[bold red]Error:[/bold red] File not found: [cyan]{path}[/cyan]"
)
sys.exit(1)
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as exc:
console.print(f"[bold red]Error:[/bold red] Invalid JSON in {path}: {exc}")
sys.exit(1)
if isinstance(data, dict) and "mcpServers" in data:
return data
# Likely a fastmcp.json (MCPServerConfig) — not directly usable as a client target.
console.print(
f"[bold red]Error:[/bold red] [cyan]{path}[/cyan] is a FastMCP server config, not an MCPConfig.\n"
f"Start the server first, then query it:\n\n"
f" fastmcp run {path}\n"
f" fastmcp list http://localhost:8000/mcp\n"
)
sys.exit(1)
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
individual server transports internally and does not support top-level auth.
"""
if isinstance(resolved, str):
return resolved.startswith(("http://", "https://"))
return isinstance(resolved, (StreamableHttpTransport, SSETransport))
async def _terminal_elicitation_handler(
message: str,
response_type: type[Any] | None,
params: Any,
context: Any,
) -> ElicitResult[dict[str, Any]]:
"""Prompt the user on the terminal for elicitation responses.
Prints the server's message and prompts for each field in the schema.
The user can type 'decline' or 'cancel' instead of a value to abort.
"""
from mcp.types import ElicitRequestFormParams
console.print(f"\n[bold yellow]Server asks:[/bold yellow] {message}")
if not isinstance(params, ElicitRequestFormParams):
answer = console.input(
"[dim](press Enter to accept, or type 'decline'):[/dim] "
)
if answer.strip().lower() == "decline":
return ElicitResult(action="decline")
if answer.strip().lower() == "cancel":
return ElicitResult(action="cancel")
return ElicitResult(action="accept", content={})
schema = params.requestedSchema
properties = schema.get("properties", {})
required = set(schema.get("required", []))
if not properties:
answer = console.input(
"[dim](press Enter to accept, or type 'decline'):[/dim] "
)
if answer.strip().lower() == "decline":
return ElicitResult(action="decline")
if answer.strip().lower() == "cancel":
return ElicitResult(action="cancel")
return ElicitResult(action="accept", content={})
result: dict[str, Any] = {}
for field_name, field_schema in properties.items():
type_hint = field_schema.get("type", "string")
req_marker = " [red]*[/red]" if field_name in required else ""
prompt_text = f" [cyan]{field_name}[/cyan] ({type_hint}){req_marker}: "
raw = console.input(prompt_text)
if raw.strip().lower() == "decline":
return ElicitResult(action="decline")
if raw.strip().lower() == "cancel":
return ElicitResult(action="cancel")
if raw == "" and field_name not in required:
continue
result[field_name] = coerce_value(raw, field_schema)
return ElicitResult(action="accept", content=result)
def _build_client(
resolved: str | dict[str, Any] | ClientTransport,
*,
timeout: float | None = None,
auth: str | None = None,
) -> Client:
"""Build a ``Client`` from a resolved server spec.
Applies ``auth='oauth'`` automatically for HTTP-based targets unless
the caller explicitly passes ``--auth none`` to disable it.
``auth=None`` means "not specified" (use default), ``auth="none"``
means "explicitly disabled".
"""
if auth == "none":
effective_auth: str | None = None
elif auth is not None:
effective_auth = auth
elif _is_http_target(resolved):
effective_auth = "oauth"
else:
effective_auth = None
return Client(
resolved,
timeout=timeout,
auth=effective_auth,
elicitation_handler=_terminal_elicitation_handler,
)
# ---------------------------------------------------------------------------
# Argument coercion
# ---------------------------------------------------------------------------
def coerce_value(raw: str, schema: dict[str, Any]) -> Any:
"""Coerce a string CLI value according to a JSON-Schema type hint."""
schema_type = schema.get("type", "string")
if schema_type == "integer":
try:
return int(raw)
except ValueError:
raise ValueError(f"Expected integer, got {raw!r}") from None
if schema_type == "number":
try:
return float(raw)
except ValueError:
raise ValueError(f"Expected number, got {raw!r}") from None
if schema_type == "boolean":
if raw.lower() in ("true", "1", "yes"):
return True
if raw.lower() in ("false", "0", "no"):
return False
raise ValueError(f"Expected boolean, got {raw!r}")
if schema_type in ("array", "object"):
try:
return json.loads(raw)
except json.JSONDecodeError:
raise ValueError(f"Expected JSON {schema_type}, got {raw!r}") from None
# Default: treat as string
return raw
def parse_tool_arguments(
raw_args: tuple[str, ...],
input_json: str | None,
input_schema: dict[str, Any],
) -> dict[str, Any]:
"""Build a tool-call argument dict from CLI inputs.
A single JSON object argument is treated as the full argument dict.
``--input-json`` provides the base dict; ``key=value`` pairs override.
Values are coerced using the tool's ``inputSchema``.
"""
# A single positional arg that looks like JSON → treat as input-json
if len(raw_args) == 1 and raw_args[0].startswith("{") and input_json is None:
input_json = raw_args[0]
raw_args = ()
result: dict[str, Any] = {}
if input_json is not None:
try:
parsed = json.loads(input_json)
except json.JSONDecodeError as exc:
console.print(f"[bold red]Error:[/bold red] Invalid --input-json: {exc}")
sys.exit(1)
if not isinstance(parsed, dict):
console.print(
"[bold red]Error:[/bold red] --input-json must be a JSON object"
)
sys.exit(1)
result.update(parsed)
properties = input_schema.get("properties", {})
for arg in raw_args:
if "=" not in arg:
console.print(
f"[bold red]Error:[/bold red] Invalid argument [cyan]{arg}[/cyan] — expected key=value"
)
sys.exit(1)
key, value = arg.split("=", 1)
prop_schema = properties.get(key, {})
try:
result[key] = coerce_value(value, prop_schema)
except ValueError as exc:
console.print(
f"[bold red]Error:[/bold red] Argument [cyan]{key}[/cyan]: {exc}"
)
sys.exit(1)
return result
# ---------------------------------------------------------------------------
# Tool signature formatting
# ---------------------------------------------------------------------------
def _json_schema_type_to_str(schema: dict[str, Any]) -> str:
"""Produce a short Python-style type string from a JSON-Schema fragment."""
if "anyOf" in schema:
parts = [_json_schema_type_to_str(s) for s in schema["anyOf"]]
return " | ".join(parts)
schema_type = schema.get("type", "any")
if isinstance(schema_type, list):
return " | ".join(_JSON_SCHEMA_TYPE_MAP.get(t, t) for t in schema_type)
return _JSON_SCHEMA_TYPE_MAP.get(schema_type, schema_type)
def format_tool_signature(tool: mcp.types.Tool) -> str:
"""Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas."""
params: list[str] = []
schema = tool.inputSchema
properties = schema.get("properties", {})
required = set(schema.get("required", []))
for prop_name, prop_schema in properties.items():
type_str = _json_schema_type_to_str(prop_schema)
if prop_name in required:
params.append(f"{prop_name}: {type_str}")
else:
default = prop_schema.get("default")
default_repr = repr(default) if default is not None else "..."
params.append(f"{prop_name}: {type_str} = {default_repr}")
sig = f"{tool.name}({', '.join(params)})"
if tool.outputSchema:
ret = _json_schema_type_to_str(tool.outputSchema)
sig += f" -> {ret}"
return sig
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _print_schema(label: str, schema: dict[str, Any]) -> None:
"""Print a JSON schema with a label."""
properties = schema.get("properties", {})
if not properties:
return
console.print(f" [dim]{label}: {json.dumps(schema)}[/dim]")
def _format_call_result_text(result: CallToolResult) -> None:
"""Pretty-print a tool call result to the console."""
if result.is_error:
for block in result.content:
if isinstance(block, mcp.types.TextContent):
console.print(f"[bold red]Error:[/bold red] {block.text}")
else:
console.print(f"[bold red]Error:[/bold red] {block}")
return
if result.structured_content is not None:
console.print_json(json.dumps(result.structured_content))
return
for block in result.content:
if isinstance(block, mcp.types.TextContent):
console.print(block.text)
elif isinstance(block, mcp.types.ImageContent):
size = len(block.data) * 3 // 4 # rough decoded size
console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]")
elif isinstance(block, mcp.types.AudioContent):
size = len(block.data) * 3 // 4
console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]")
else:
console.print(str(block))
def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]:
"""Serialize a single content block to a JSON-safe dict."""
if isinstance(block, mcp.types.TextContent):
return {"type": "text", "text": block.text}
if isinstance(block, mcp.types.ImageContent):
return {"type": "image", "mimeType": block.mimeType, "data": block.data}
if isinstance(block, mcp.types.AudioContent):
return {"type": "audio", "mimeType": block.mimeType, "data": block.data}
return {"type": "unknown", "value": str(block)}
def _call_result_to_dict(result: CallToolResult) -> dict[str, Any]:
"""Serialize a ``CallToolResult`` to a JSON-safe dict."""
content_list = [_content_block_to_dict(block) for block in result.content]
out: dict[str, Any] = {"content": content_list, "is_error": result.is_error}
if result.structured_content is not None:
out["structured_content"] = result.structured_content
return out
def _tools_to_json(tools: list[mcp.types.Tool]) -> list[dict[str, Any]]:
"""Serialize a list of tools to JSON-safe dicts."""
return [
{
"name": t.name,
"description": t.description,
"inputSchema": t.inputSchema,
**({"outputSchema": t.outputSchema} if t.outputSchema else {}),
}
for t in tools
]
# ---------------------------------------------------------------------------
# Call handlers (tool, resource, prompt)
# ---------------------------------------------------------------------------
async def _handle_tool_call(
client: Client,
tool_name: str,
arguments: tuple[str, ...],
input_json: str | None,
json_output: bool,
) -> None:
"""Handle a tool call within an open client session."""
tools = await client.list_tools()
tool_map = {t.name: t for t in tools}
if tool_name not in tool_map:
close_matches = difflib.get_close_matches(
tool_name, tool_map.keys(), n=3, cutoff=0.5
)
msg = f"Tool [cyan]{tool_name}[/cyan] not found."
if close_matches:
suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches)
msg += f" Did you mean: {suggestions}?"
console.print(f"[bold red]Error:[/bold red] {msg}")
sys.exit(1)
tool = tool_map[tool_name]
parsed_args = parse_tool_arguments(arguments, input_json, tool.inputSchema)
required = set(tool.inputSchema.get("required", []))
provided = set(parsed_args.keys())
missing = required - provided
if missing:
missing_str = ", ".join(f"[cyan]{m}[/cyan]" for m in sorted(missing))
console.print(
f"[bold red]Error:[/bold red] Missing required arguments: {missing_str}"
)
console.print()
sig = format_tool_signature(tool)
console.print(f" [dim]{sig}[/dim]")
sys.exit(1)
result = await client.call_tool(tool_name, parsed_args, raise_on_error=False)
if json_output:
console.print_json(json.dumps(_call_result_to_dict(result)))
else:
_format_call_result_text(result)
if result.is_error:
sys.exit(1)
async def _handle_resource(
client: Client,
uri: str,
json_output: bool,
) -> None:
"""Handle a resource read within an open client session."""
contents = await client.read_resource(uri)
if json_output:
data = []
for block in contents:
if isinstance(block, mcp.types.TextResourceContents):
data.append(
{
"uri": str(block.uri),
"mimeType": block.mimeType,
"text": block.text,
}
)
elif isinstance(block, mcp.types.BlobResourceContents):
data.append(
{
"uri": str(block.uri),
"mimeType": block.mimeType,
"blob": block.blob,
}
)
console.print_json(json.dumps(data))
return
for block in contents:
if isinstance(block, mcp.types.TextResourceContents):
console.print(block.text)
elif isinstance(block, mcp.types.BlobResourceContents):
size = len(block.blob) * 3 // 4
console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]")
async def _handle_prompt(
client: Client,
prompt_name: str,
arguments: tuple[str, ...],
input_json: str | None,
json_output: bool,
) -> None:
"""Handle a prompt get within an open client session."""
# Prompt arguments are always string->string, but we reuse
# parse_tool_arguments for the key=value / --input-json parsing.
# Pass an empty schema so values stay as strings.
parsed_args = parse_tool_arguments(arguments, input_json, {"type": "object"})
prompts = await client.list_prompts()
prompt_map = {p.name: p for p in prompts}
if prompt_name not in prompt_map:
close_matches = difflib.get_close_matches(
prompt_name, prompt_map.keys(), n=3, cutoff=0.5
)
msg = f"Prompt [cyan]{prompt_name}[/cyan] not found."
if close_matches:
suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches)
msg += f" Did you mean: {suggestions}?"
console.print(f"[bold red]Error:[/bold red] {msg}")
sys.exit(1)
result = await client.get_prompt(prompt_name, parsed_args or None)
if json_output:
data: dict[str, Any] = {}
if result.description:
data["description"] = result.description
data["messages"] = [
{
"role": msg.role,
"content": _content_block_to_dict(msg.content),
}
for msg in result.messages
]
console.print_json(json.dumps(data))
return
for msg in result.messages:
console.print(f"[bold]{msg.role}:[/bold]")
if isinstance(msg.content, mcp.types.TextContent):
console.print(f" {msg.content.text}")
elif isinstance(msg.content, mcp.types.ImageContent):
size = len(msg.content.data) * 3 // 4
console.print(
f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]"
)
else:
console.print(f" {msg.content}")
console.print()
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
async def list_command(
server_spec: Annotated[
str | None,
cyclopts.Parameter(
help="Server URL, Python file, MCPConfig JSON, or .js file",
),
] = None,
*,
command: Annotated[
str | None,
cyclopts.Parameter(
"--command",
help="Stdio command to connect to (e.g. 'npx -y @mcp/server')",
),
] = None,
transport: Annotated[
Literal["http", "sse"] | None,
cyclopts.Parameter(
name=["--transport", "-t"],
help="Force transport type for URL targets (http or sse)",
),
] = None,
resources: Annotated[
bool,
cyclopts.Parameter("--resources", help="Also list resources"),
] = False,
prompts: Annotated[
bool,
cyclopts.Parameter("--prompts", help="Also list prompts"),
] = False,
input_schema: Annotated[
bool,
cyclopts.Parameter("--input-schema", help="Show full input schemas"),
] = False,
output_schema: Annotated[
bool,
cyclopts.Parameter("--output-schema", help="Show full output schemas"),
] = False,
json_output: Annotated[
bool,
cyclopts.Parameter("--json", help="Output as JSON"),
] = False,
timeout: Annotated[
float | None,
cyclopts.Parameter("--timeout", help="Connection timeout in seconds"),
] = None,
auth: Annotated[
str | None,
cyclopts.Parameter(
"--auth",
help="Auth method: 'oauth', a bearer token string, or 'none' to disable",
),
] = None,
) -> None:
"""List tools available on an MCP server.
Examples:
fastmcp list http://localhost:8000/mcp
fastmcp list server.py
fastmcp list mcp.json --json
fastmcp list --command 'npx -y @mcp/server' --resources
fastmcp list http://server/mcp --transport sse
"""
resolved = resolve_server_spec(server_spec, command=command, transport=transport)
client = _build_client(resolved, timeout=timeout, auth=auth)
try:
async with client:
tools = await client.list_tools()
if json_output:
data: dict[str, Any] = {"tools": _tools_to_json(tools)}
if resources:
res = await client.list_resources()
data["resources"] = [
{
"uri": str(r.uri),
"name": r.name,
"description": r.description,
"mimeType": r.mimeType,
}
for r in res
]
if prompts:
prm = await client.list_prompts()
data["prompts"] = [
{
"name": p.name,
"description": p.description,
"arguments": [a.model_dump() for a in (p.arguments or [])],
}
for p in prm
]
console.print_json(json.dumps(data))
return
# Text output
if not tools:
console.print("[dim]No tools found.[/dim]")
else:
console.print(f"[bold]Tools ({len(tools)})[/bold]")
console.print()
for tool in tools:
sig = format_tool_signature(tool)
console.print(f" [cyan]{sig}[/cyan]")
if tool.description:
console.print(f" {tool.description}")
if input_schema:
_print_schema("Input", tool.inputSchema)
if output_schema and tool.outputSchema:
_print_schema("Output", tool.outputSchema)
console.print()
if resources:
res = await client.list_resources()
console.print(f"[bold]Resources ({len(res)})[/bold]")
console.print()
if not res:
console.print(" [dim]No resources found.[/dim]")
for r in res:
console.print(f" [cyan]{r.uri}[/cyan]")
desc_parts = [r.name or "", r.description or ""]
desc = "".join(p for p in desc_parts if p)
if desc:
console.print(f" {desc}")
console.print()
if prompts:
prm = await client.list_prompts()
console.print(f"[bold]Prompts ({len(prm)})[/bold]")
console.print()
if not prm:
console.print(" [dim]No prompts found.[/dim]")
for p in prm:
args_str = ""
if p.arguments:
parts = [a.name for a in p.arguments]
args_str = f"({', '.join(parts)})"
console.print(f" [cyan]{p.name}{args_str}[/cyan]")
if p.description:
console.print(f" {p.description}")
console.print()
except Exception as exc:
console.print(f"[bold red]Error:[/bold red] {exc}")
sys.exit(1)
async def call_command(
server_spec: Annotated[
str | None,
cyclopts.Parameter(
help="Server URL, Python file, MCPConfig JSON, or .js file",
),
] = None,
target: Annotated[
str,
cyclopts.Parameter(
help="Tool name, resource URI, or prompt name (with --prompt)",
),
] = "",
*arguments: str,
command: Annotated[
str | None,
cyclopts.Parameter(
"--command",
help="Stdio command to connect to (e.g. 'npx -y @mcp/server')",
),
] = None,
transport: Annotated[
Literal["http", "sse"] | None,
cyclopts.Parameter(
name=["--transport", "-t"],
help="Force transport type for URL targets (http or sse)",
),
] = None,
prompt: Annotated[
bool,
cyclopts.Parameter("--prompt", help="Treat target as a prompt name"),
] = False,
input_json: Annotated[
str | None,
cyclopts.Parameter(
"--input-json",
help="JSON string of arguments (merged with key=value args)",
),
] = None,
json_output: Annotated[
bool,
cyclopts.Parameter("--json", help="Output raw JSON result"),
] = False,
timeout: Annotated[
float | None,
cyclopts.Parameter("--timeout", help="Connection timeout in seconds"),
] = None,
auth: Annotated[
str | None,
cyclopts.Parameter(
"--auth",
help="Auth method: 'oauth', a bearer token string, or 'none' to disable",
),
] = None,
) -> None:
"""Call a tool, read a resource, or get a prompt on an MCP server.
By default the target is treated as a tool name. If the target
contains ``://`` it is treated as a resource URI. Pass ``--prompt``
to treat it as a prompt name.
Arguments are passed as key=value pairs. Use --input-json for complex
or nested arguments.
Examples:
fastmcp call server.py greet name=World
fastmcp call server.py resource://docs/readme
fastmcp call server.py analyze --prompt data='[1,2,3]'
fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}'
"""
if not target:
console.print(
"[bold red]Error:[/bold red] Missing target.\n\n"
"Usage: fastmcp call <server> <target> [key=value ...]\n\n"
" target can be a tool name, a resource URI, or a prompt name (with --prompt).\n\n"
"Use [cyan]fastmcp list <server>[/cyan] to see available tools."
)
sys.exit(1)
resolved = resolve_server_spec(server_spec, command=command, transport=transport)
client = _build_client(resolved, timeout=timeout, auth=auth)
try:
async with client:
if prompt:
await _handle_prompt(client, target, arguments, input_json, json_output)
elif "://" in target:
await _handle_resource(client, target, json_output)
else:
await _handle_tool_call(
client, target, arguments, input_json, json_output
)
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()

View 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}"
)

View file

@ -297,13 +297,27 @@ class FunctionPrompt(Prompt):
# Convert string arguments to expected types BEFORE validation
kwargs = self._convert_string_arguments(kwargs)
# Filter out arguments that aren't in the function signature
# This is important for security: dependencies should not be overridable
# from external callers. self.fn is wrapped by without_injected_parameters,
# so we only accept arguments that are in the wrapped function's signature.
sig = inspect.signature(self.fn)
valid_params = set(sig.parameters.keys())
kwargs = {k: v for k, v in kwargs.items() if k in valid_params}
# Use type adapter to validate arguments and handle Field() defaults
# This matches the behavior of tools in function_tool
type_adapter = get_cached_typeadapter(self.fn)
# self.fn is wrapped by without_injected_parameters which handles
# dependency resolution internally
if inspect.iscoroutinefunction(self.fn):
result = await self.fn(**kwargs)
result = await type_adapter.validate_python(kwargs)
else:
# Run sync functions in threadpool to avoid blocking the event loop
result = await call_sync_fn_in_threadpool(self.fn, **kwargs)
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, kwargs
)
# Handle sync wrappers that return awaitables (e.g., partial(async_fn))
if inspect.isawaitable(result):
result = await result

View file

@ -0,0 +1,557 @@
"""Tests for fastmcp list and fastmcp call CLI commands."""
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import mcp.types
import pytest
from fastmcp import FastMCP
from fastmcp.cli import client as client_module
from fastmcp.cli.client import (
Client,
_build_client,
_build_stdio_from_command,
_format_call_result_text,
_is_http_target,
call_command,
coerce_value,
format_tool_signature,
list_command,
parse_tool_arguments,
resolve_server_spec,
)
from fastmcp.client.client import CallToolResult
from fastmcp.client.transports.stdio import StdioTransport
# ---------------------------------------------------------------------------
# coerce_value
# ---------------------------------------------------------------------------
class TestCoerceValue:
def test_integer(self):
assert coerce_value("42", {"type": "integer"}) == 42
def test_integer_negative(self):
assert coerce_value("-7", {"type": "integer"}) == -7
def test_integer_invalid(self):
with pytest.raises(ValueError, match="Expected integer"):
coerce_value("abc", {"type": "integer"})
def test_number(self):
assert coerce_value("3.14", {"type": "number"}) == 3.14
def test_number_integer_value(self):
assert coerce_value("5", {"type": "number"}) == 5.0
def test_number_invalid(self):
with pytest.raises(ValueError, match="Expected number"):
coerce_value("xyz", {"type": "number"})
def test_boolean_true_variants(self):
for val in ("true", "True", "TRUE", "1", "yes"):
assert coerce_value(val, {"type": "boolean"}) is True
def test_boolean_false_variants(self):
for val in ("false", "False", "FALSE", "0", "no"):
assert coerce_value(val, {"type": "boolean"}) is False
def test_boolean_invalid(self):
with pytest.raises(ValueError, match="Expected boolean"):
coerce_value("maybe", {"type": "boolean"})
def test_array(self):
assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3]
def test_array_invalid(self):
with pytest.raises(ValueError, match="Expected JSON array"):
coerce_value("not-json", {"type": "array"})
def test_object(self):
assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1}
def test_string(self):
assert coerce_value("hello", {"type": "string"}) == "hello"
def test_string_default(self):
"""Unknown or missing type treats value as string."""
assert coerce_value("hello", {}) == "hello"
def test_string_preserves_numeric_looking_values(self):
assert coerce_value("42", {"type": "string"}) == "42"
# ---------------------------------------------------------------------------
# parse_tool_arguments
# ---------------------------------------------------------------------------
class TestParseToolArguments:
SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"verbose": {"type": "boolean"},
},
"required": ["query"],
}
def test_basic_key_value(self):
result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA)
assert result == {"query": "hello", "limit": 10}
def test_input_json_only(self):
result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA)
assert result == {"query": "hello", "limit": 5}
def test_key_value_overrides_input_json(self):
result = parse_tool_arguments(
("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA
)
assert result == {"query": "hello", "limit": 20}
def test_value_containing_equals(self):
result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA)
assert result == {"query": "a=b=c"}
def test_invalid_arg_format_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments(("noequalssign",), None, self.SCHEMA)
def test_invalid_input_json_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments((), "not-valid-json", self.SCHEMA)
def test_input_json_non_object_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments((), "[1,2,3]", self.SCHEMA)
def test_single_json_object_as_positional(self):
result = parse_tool_arguments(
('{"query": "hello", "limit": 5}',), None, self.SCHEMA
)
assert result == {"query": "hello", "limit": 5}
def test_json_positional_ignored_when_input_json_set(self):
"""When --input-json is already provided, a JSON positional arg is not special."""
with pytest.raises(SystemExit):
parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA)
def test_coercion_error_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments(("limit=abc",), None, self.SCHEMA)
# ---------------------------------------------------------------------------
# format_tool_signature
# ---------------------------------------------------------------------------
class TestFormatToolSignature:
def _make_tool(
self,
name: str = "my_tool",
properties: dict[str, Any] | None = None,
required: list[str] | None = None,
output_schema: dict[str, Any] | None = None,
description: str | None = None,
) -> mcp.types.Tool:
input_schema: dict[str, Any] = {"type": "object"}
if properties is not None:
input_schema["properties"] = properties
if required is not None:
input_schema["required"] = required
return mcp.types.Tool(
name=name,
description=description,
inputSchema=input_schema,
outputSchema=output_schema,
)
def test_no_params(self):
tool = self._make_tool()
assert format_tool_signature(tool) == "my_tool()"
def test_required_param(self):
tool = self._make_tool(
properties={"query": {"type": "string"}},
required=["query"],
)
assert format_tool_signature(tool) == "my_tool(query: str)"
def test_optional_param_with_default(self):
tool = self._make_tool(
properties={"limit": {"type": "integer", "default": 10}},
)
assert format_tool_signature(tool) == "my_tool(limit: int = 10)"
def test_optional_param_without_default(self):
tool = self._make_tool(
properties={"limit": {"type": "integer"}},
)
assert format_tool_signature(tool) == "my_tool(limit: int = ...)"
def test_mixed_required_and_optional(self):
tool = self._make_tool(
properties={
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
},
required=["query"],
)
sig = format_tool_signature(tool)
assert sig == "my_tool(query: str, limit: int = 10)"
def test_with_output_schema(self):
tool = self._make_tool(
properties={"q": {"type": "string"}},
required=["q"],
output_schema={"type": "object"},
)
assert format_tool_signature(tool) == "my_tool(q: str) -> dict"
def test_anyof_type(self):
tool = self._make_tool(
properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
required=["value"],
)
assert format_tool_signature(tool) == "my_tool(value: str | int)"
# ---------------------------------------------------------------------------
# resolve_server_spec
# ---------------------------------------------------------------------------
class TestResolveServerSpec:
def test_http_url(self):
assert (
resolve_server_spec("http://localhost:8000/mcp")
== "http://localhost:8000/mcp"
)
def test_https_url(self):
assert (
resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp"
)
def test_python_file_existing(self, tmp_path: Path):
py_file = tmp_path / "server.py"
py_file.write_text("# empty")
result = resolve_server_spec(str(py_file))
assert isinstance(result, StdioTransport)
assert result.command == "fastmcp"
assert result.args == ["run", str(py_file.resolve()), "--no-banner"]
def test_json_mcp_config(self, tmp_path: Path):
config_file = tmp_path / "mcp.json"
config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}}
config_file.write_text(json.dumps(config))
result = resolve_server_spec(str(config_file))
assert isinstance(result, dict)
assert "mcpServers" in result
def test_json_fastmcp_config_exits(self, tmp_path: Path):
config_file = tmp_path / "fastmcp.json"
config_file.write_text(json.dumps({"source": {"type": "file"}}))
with pytest.raises(SystemExit):
resolve_server_spec(str(config_file))
def test_json_not_found_exits(self, tmp_path: Path):
with pytest.raises(SystemExit):
resolve_server_spec(str(tmp_path / "nonexistent.json"))
def test_directory_exits(self, tmp_path: Path):
"""Directories should not be treated as file paths."""
with pytest.raises(SystemExit):
resolve_server_spec(str(tmp_path))
def test_unrecognised_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec("some_random_thing")
def test_command_returns_stdio_transport(self):
result = resolve_server_spec(None, command="npx -y @mcp/server")
assert isinstance(result, StdioTransport)
assert result.command == "npx"
assert result.args == ["-y", "@mcp/server"]
def test_command_single_word(self):
result = resolve_server_spec(None, command="myserver")
assert isinstance(result, StdioTransport)
assert result.command == "myserver"
assert result.args == []
def test_server_spec_and_command_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec("http://localhost:8000", command="npx server")
def test_neither_server_spec_nor_command_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec(None)
def test_transport_sse_rewrites_url(self):
result = resolve_server_spec("http://localhost:8000/mcp", transport="sse")
assert result == "http://localhost:8000/mcp/sse"
def test_transport_sse_no_duplicate_suffix(self):
result = resolve_server_spec("http://localhost:8000/sse", transport="sse")
assert result == "http://localhost:8000/sse"
def test_transport_sse_trailing_slash(self):
result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse")
assert result == "http://localhost:8000/mcp/sse"
def test_transport_http_leaves_url_unchanged(self):
result = resolve_server_spec("http://localhost:8000/mcp", transport="http")
assert result == "http://localhost:8000/mcp"
# ---------------------------------------------------------------------------
# _build_stdio_from_command
# ---------------------------------------------------------------------------
class TestBuildStdioFromCommand:
def test_simple_command(self):
transport = _build_stdio_from_command("uvx my-server")
assert transport.command == "uvx"
assert transport.args == ["my-server"]
def test_quoted_args(self):
transport = _build_stdio_from_command("npx -y '@scope/server'")
assert transport.command == "npx"
assert transport.args == ["-y", "@scope/server"]
def test_empty_command_exits(self):
with pytest.raises(SystemExit):
_build_stdio_from_command("")
def test_invalid_shell_syntax_exits(self):
with pytest.raises(SystemExit):
_build_stdio_from_command("npx 'unterminated")
# ---------------------------------------------------------------------------
# _is_http_target
# ---------------------------------------------------------------------------
class TestIsHttpTarget:
def test_http_url(self):
assert _is_http_target("http://localhost:8000") is True
def test_https_url(self):
assert _is_http_target("https://example.com/mcp") is True
def test_file_path(self):
assert _is_http_target("/path/to/server.py") is False
def test_stdio_transport(self):
assert _is_http_target(StdioTransport(command="npx", args=[])) is False
def test_mcp_config_dict(self):
"""MCPConfig dicts are not HTTP targets — auth is per-server internally."""
assert _is_http_target({"mcpServers": {}}) is False
# ---------------------------------------------------------------------------
# _build_client
# ---------------------------------------------------------------------------
class TestBuildClient:
def test_http_target_gets_oauth_by_default(self):
client = _build_client("http://localhost:8000/mcp")
# OAuth is applied during Client init via _set_auth
assert client.transport.auth is not None
def test_stdio_target_no_auth(self):
transport = StdioTransport(command="npx", args=["-y", "@mcp/server"])
client = _build_client(transport)
# Stdio transports don't support auth — no auth should be set
assert not hasattr(client.transport, "auth") or client.transport.auth is None
def test_explicit_auth_none_disables_oauth(self):
client = _build_client("http://localhost:8000/mcp", auth="none")
# "none" explicitly disables auth, even for HTTP targets
assert client.transport.auth is None
def test_mcp_config_no_auth(self):
"""MCPConfig dicts handle auth per-server; no top-level auth applied."""
client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}})
# MCPConfigTransport doesn't support _set_auth — no crash means success
assert client.transport is not None
# ---------------------------------------------------------------------------
# Integration tests — invoke actual CLI commands via monkeypatched _build_client
# ---------------------------------------------------------------------------
def _build_test_server() -> FastMCP:
"""Create a minimal FastMCP server for integration tests."""
server = FastMCP("TestServer")
@server.tool
def greet(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
@server.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@server.resource("test://greeting")
def greeting_resource() -> str:
"""A static greeting resource."""
return "Hello from resource!"
@server.prompt
def ask(topic: str) -> str:
"""Ask about a topic."""
return f"Tell me about {topic}"
return server
@pytest.fixture()
def _patch_client():
"""Patch resolve_server_spec and _build_client so CLI commands use the
in-process test server without needing a real transport."""
server = _build_test_server()
def fake_resolve(server_spec: Any, **kwargs: Any) -> str:
return "fake"
def fake_build_client(resolved: Any, **kwargs: Any) -> Client:
return Client(server)
with (
patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve),
patch.object(client_module, "_build_client", side_effect=fake_build_client),
):
yield
class TestListCommandCLI:
@pytest.mark.usefixtures("_patch_client")
async def test_list_tools(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server")
captured = capsys.readouterr()
assert "greet" in captured.out
assert "add" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_list_json(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
names = {t["name"] for t in data["tools"]}
assert "greet" in names
assert "add" in names
@pytest.mark.usefixtures("_patch_client")
async def test_list_resources(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", resources=True)
captured = capsys.readouterr()
assert "test://greeting" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", prompts=True)
captured = capsys.readouterr()
assert "ask" in captured.out
class TestCallCommandCLI:
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "greet", "name=World")
captured = capsys.readouterr()
assert "Hello, World!" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "greet", "name=World", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data["is_error"] is False
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_not_found(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "nonexistent")
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_missing_args(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "greet")
@pytest.mark.usefixtures("_patch_client")
async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "test://greeting")
captured = capsys.readouterr()
assert "Hello from resource!" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "test://greeting", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert isinstance(data, list)
assert data[0]["text"] == "Hello from resource!"
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "ask", "topic=Python", prompt=True)
captured = capsys.readouterr()
assert "Python" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]):
await call_command(
"fake://server", "ask", "topic=Python", prompt=True, json_output=True
)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert "messages" in data
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt_not_found(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "nonexistent", prompt=True)
async def test_call_missing_target(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "")
# ---------------------------------------------------------------------------
# Structured content serialization
# ---------------------------------------------------------------------------
class TestFormatCallResult:
def test_structured_content_uses_dict_not_data(
self, capsys: pytest.CaptureFixture[str]
):
"""structured_content (raw dict) is used for display, not data (which may
be a non-serializable dataclass)."""
result = CallToolResult(
content=[mcp.types.TextContent(type="text", text="ok")],
structured_content={"key": "value"},
meta=None,
data=object(), # non-serializable on purpose
is_error=False,
)
# Should not raise — uses structured_content, not data
_format_call_result_text(result)
captured = capsys.readouterr()
assert "value" in captured.out

668
tests/cli/test_discovery.py Normal file
View 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

View file

@ -552,6 +552,88 @@ class TestPromptResult:
assert mcp_result.meta == {"key": "value"}
class TestPromptFieldDefaults:
"""Test prompts with Field() defaults."""
async def test_field_with_default(self):
"""Test that Field(default=...) correctly provides default values."""
from pydantic import Field
def prompt_with_defaults(
required: str = Field(description="Required parameter"),
optional: str = Field(
default="default_value", description="Optional parameter"
),
) -> str:
return f"required={required}, optional={optional}"
prompt = Prompt.from_function(prompt_with_defaults)
result = await prompt.render(arguments={"required": "test"})
assert result.messages == [Message("required=test, optional=default_value")]
async def test_annotated_field_with_default_in_signature(self):
"""Test that Annotated[type, Field(...)] with default in signature works."""
from typing import Annotated
from pydantic import Field
def prompt_with_annotated(
required: Annotated[str, Field(description="Required parameter")],
optional: Annotated[
str, Field(description="Optional parameter")
] = "default_value",
) -> str:
return f"required={required}, optional={optional}"
prompt = Prompt.from_function(prompt_with_annotated)
result = await prompt.render(arguments={"required": "test"})
assert result.messages == [Message("required=test, optional=default_value")]
async def test_multiple_field_defaults(self):
"""Test multiple parameters with Field() defaults."""
from pydantic import Field
def prompt_with_multiple_defaults(
name: str = Field(description="Name"),
greeting: str = Field(default="Hello", description="Greeting"),
punctuation: str = Field(default="!", description="Punctuation"),
) -> str:
return f"{greeting}, {name}{punctuation}"
prompt = Prompt.from_function(prompt_with_multiple_defaults)
# Test with only required parameter
result1 = await prompt.render(arguments={"name": "World"})
assert result1.messages == [Message("Hello, World!")]
# Test overriding one default
result2 = await prompt.render(arguments={"name": "World", "greeting": "Hi"})
assert result2.messages == [Message("Hi, World!")]
# Test overriding all defaults
result3 = await prompt.render(
arguments={"name": "World", "greeting": "Greetings", "punctuation": "."}
)
assert result3.messages == [Message("Greetings, World.")]
async def test_field_defaults_with_type_conversion(self):
"""Test Field() defaults work with type conversion for non-string types."""
from pydantic import Field
def prompt_with_typed_defaults(
count: int = Field(description="Count"),
multiplier: int = Field(default=2, description="Multiplier"),
) -> str:
return f"result={count * multiplier}"
prompt = Prompt.from_function(prompt_with_typed_defaults)
# Pass count as string (MCP requirement), should use default for multiplier
result = await prompt.render(arguments={"count": "5"})
assert result.messages == [Message("result=10")]
class TestPromptCallableAndConcurrency:
"""Test prompts with callable objects and concurrent execution."""

View file

@ -1007,3 +1007,83 @@ class TestQueryParameterWithWildcards:
assert result["path"] == "src/test/data.txt"
assert result["encoding"] == "utf-8" # default
assert result["lines"] == 50 # provided
class TestResourceTemplateFieldDefaults:
"""Test resource templates with Field() defaults."""
async def test_field_with_default(self):
"""Test that Field(default=...) correctly provides default values in resource templates."""
from pydantic import Field
def get_data(
id: str = Field(description="Resource ID"),
format: str = Field(default="json", description="Output format"),
) -> str:
return f"id={id}, format={format}"
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format}",
name="test",
)
# Test with only required parameter
resource = await template.create_resource("data://123", {"id": "123"})
result = await resource.read()
assert result == "id=123, format=json"
# Test with override
resource = await template.create_resource(
"data://123?format=xml", {"id": "123", "format": "xml"}
)
result = await resource.read()
assert result == "id=123, format=xml"
async def test_multiple_field_defaults(self):
"""Test multiple query parameters with Field() defaults."""
from typing import Any
from pydantic import Field
def fetch_data(
resource_id: str = Field(description="Resource ID"),
limit: int = Field(default=10, description="Result limit"),
offset: int = Field(default=0, description="Result offset"),
format: str = Field(default="json", description="Output format"),
) -> dict[str, Any]:
return {
"resource_id": resource_id,
"limit": limit,
"offset": offset,
"format": format,
}
template = ResourceTemplate.from_function(
fn=fetch_data,
uri_template="api://{resource_id}{?limit,offset,format}",
name="test",
)
# Test with only required parameter - all defaults should apply
resource1 = await template.create_resource(
"api://user123", {"resource_id": "user123"}
)
result1 = await resource1.read()
assert isinstance(result1, dict)
assert result1["resource_id"] == "user123"
assert result1["limit"] == 10
assert result1["offset"] == 0
assert result1["format"] == "json"
# Test with some overrides
resource2 = await template.create_resource(
"api://user123?limit=50&format=xml",
{"resource_id": "user123", "limit": "50", "format": "xml"},
)
result2 = await resource2.read()
assert isinstance(result2, dict)
assert result2["resource_id"] == "user123"
assert result2["limit"] == 50 # overridden
assert result2["offset"] == 0 # default
assert result2["format"] == "xml" # overridden

2
uv.lock generated
View file

@ -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" },