Spaces:
Running
Running
Jeremiah Lowin Claude commited on
Commit ·
923cf05
1
Parent(s): 94e6d64
Add fastmcp inspect command with detailed server analysis
Browse files- Add comprehensive server inspection utility supporting both FastMCP 1.x and 2.x
- Create detailed info dataclasses for tools, prompts, resources, and templates
- Implement CLI command with path:object notation and JSON output
- Add version reporting (fastmcp_version, mcp_version, server_version)
- Include comprehensive unit tests for utilities and CLI
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- src/fastmcp/cli/cli.py +107 -0
- src/fastmcp/utilities/inspect.py +326 -0
- tests/cli/test_inspect.py +354 -0
- tests/utilities/test_inspect.py +388 -0
src/fastmcp/cli/cli.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""FastMCP CLI tools."""
|
| 2 |
|
|
|
|
| 3 |
import importlib.metadata
|
| 4 |
import importlib.util
|
|
|
|
| 5 |
import os
|
| 6 |
import platform
|
| 7 |
import subprocess
|
|
@@ -19,6 +21,7 @@ import fastmcp
|
|
| 19 |
from fastmcp.cli import claude
|
| 20 |
from fastmcp.cli import run as run_module
|
| 21 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 22 |
from fastmcp.utilities.logging import get_logger
|
| 23 |
|
| 24 |
logger = get_logger("cli")
|
|
@@ -435,3 +438,107 @@ def install(
|
|
| 435 |
else:
|
| 436 |
logger.error(f"Failed to install {name} in Claude app")
|
| 437 |
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""FastMCP CLI tools."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import importlib.metadata
|
| 5 |
import importlib.util
|
| 6 |
+
import json
|
| 7 |
import os
|
| 8 |
import platform
|
| 9 |
import subprocess
|
|
|
|
| 21 |
from fastmcp.cli import claude
|
| 22 |
from fastmcp.cli import run as run_module
|
| 23 |
from fastmcp.server.server import FastMCP
|
| 24 |
+
from fastmcp.utilities.inspect import get_fastmcp_info
|
| 25 |
from fastmcp.utilities.logging import get_logger
|
| 26 |
|
| 27 |
logger = get_logger("cli")
|
|
|
|
| 438 |
else:
|
| 439 |
logger.error(f"Failed to install {name} in Claude app")
|
| 440 |
sys.exit(1)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@app.command()
|
| 444 |
+
def inspect(
|
| 445 |
+
server_spec: str = typer.Argument(
|
| 446 |
+
...,
|
| 447 |
+
help="Python file to inspect, optionally with :object suffix",
|
| 448 |
+
),
|
| 449 |
+
output: Annotated[
|
| 450 |
+
Path,
|
| 451 |
+
typer.Option(
|
| 452 |
+
"--output",
|
| 453 |
+
"-o",
|
| 454 |
+
help="Output file path for the JSON report (default: server-info.json)",
|
| 455 |
+
),
|
| 456 |
+
] = Path("server-info.json"),
|
| 457 |
+
) -> None:
|
| 458 |
+
"""Inspect a FastMCP server and generate a JSON report.
|
| 459 |
+
|
| 460 |
+
This command analyzes a FastMCP server (v1.x or v2.x) and generates
|
| 461 |
+
a comprehensive JSON report containing information about the server's
|
| 462 |
+
name, instructions, version, tools, prompts, resources, templates,
|
| 463 |
+
and capabilities.
|
| 464 |
+
|
| 465 |
+
Examples:
|
| 466 |
+
fastmcp inspect server.py
|
| 467 |
+
fastmcp inspect server.py -o report.json
|
| 468 |
+
fastmcp inspect server.py:mcp -o analysis.json
|
| 469 |
+
fastmcp inspect path/to/server.py:app -o /tmp/server-info.json
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
# Parse the server specification
|
| 473 |
+
file, server_object = run_module.parse_file_path(server_spec)
|
| 474 |
+
|
| 475 |
+
logger.debug(
|
| 476 |
+
"Inspecting server",
|
| 477 |
+
extra={
|
| 478 |
+
"file": str(file),
|
| 479 |
+
"server_object": server_object,
|
| 480 |
+
"output": str(output),
|
| 481 |
+
},
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
try:
|
| 485 |
+
# Import the server
|
| 486 |
+
server = run_module.import_server(file, server_object)
|
| 487 |
+
|
| 488 |
+
# Get server information
|
| 489 |
+
async def get_info():
|
| 490 |
+
return await get_fastmcp_info(server)
|
| 491 |
+
|
| 492 |
+
info = asyncio.run(get_info())
|
| 493 |
+
|
| 494 |
+
# Convert to dict for JSON serialization
|
| 495 |
+
def convert_dataclass_to_dict(obj):
|
| 496 |
+
"""Convert dataclass instances to dicts for JSON serialization."""
|
| 497 |
+
if hasattr(obj, "__dataclass_fields__"):
|
| 498 |
+
return {
|
| 499 |
+
k: convert_dataclass_to_dict(v) for k, v in obj.__dict__.items()
|
| 500 |
+
}
|
| 501 |
+
elif isinstance(obj, list):
|
| 502 |
+
return [convert_dataclass_to_dict(item) for item in obj]
|
| 503 |
+
elif isinstance(obj, set):
|
| 504 |
+
return list(obj)
|
| 505 |
+
elif hasattr(obj, "model_dump"): # Pydantic models
|
| 506 |
+
return obj.model_dump()
|
| 507 |
+
elif hasattr(obj, "__dict__"): # Other objects with __dict__
|
| 508 |
+
return {
|
| 509 |
+
k: convert_dataclass_to_dict(v) for k, v in obj.__dict__.items()
|
| 510 |
+
}
|
| 511 |
+
else:
|
| 512 |
+
return obj
|
| 513 |
+
|
| 514 |
+
info_dict = convert_dataclass_to_dict(info)
|
| 515 |
+
|
| 516 |
+
# Ensure output directory exists
|
| 517 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 518 |
+
|
| 519 |
+
# Write JSON report (always pretty-printed)
|
| 520 |
+
with output.open("w", encoding="utf-8") as f:
|
| 521 |
+
json.dump(info_dict, f, indent=2, ensure_ascii=False)
|
| 522 |
+
|
| 523 |
+
logger.info(f"Server inspection complete. Report saved to {output}")
|
| 524 |
+
|
| 525 |
+
# Print summary to console
|
| 526 |
+
console.print(
|
| 527 |
+
f"[bold green]✓[/bold green] Inspected server: [bold]{info.name}[/bold]"
|
| 528 |
+
)
|
| 529 |
+
console.print(f" Tools: {len(info.tools)}")
|
| 530 |
+
console.print(f" Prompts: {len(info.prompts)}")
|
| 531 |
+
console.print(f" Resources: {len(info.resources)}")
|
| 532 |
+
console.print(f" Templates: {len(info.templates)}")
|
| 533 |
+
console.print(f" Report saved to: [cyan]{output}[/cyan]")
|
| 534 |
+
|
| 535 |
+
except Exception as e:
|
| 536 |
+
logger.error(
|
| 537 |
+
f"Failed to inspect server: {e}",
|
| 538 |
+
extra={
|
| 539 |
+
"server_spec": server_spec,
|
| 540 |
+
"error": str(e),
|
| 541 |
+
},
|
| 542 |
+
)
|
| 543 |
+
console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
|
| 544 |
+
sys.exit(1)
|
src/fastmcp/utilities/inspect.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utilities for inspecting FastMCP instances."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib.metadata
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from mcp.server.fastmcp import FastMCP as FastMCP1x
|
| 10 |
+
|
| 11 |
+
import fastmcp
|
| 12 |
+
from fastmcp.server.server import FastMCP
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class ToolInfo:
|
| 17 |
+
"""Information about a tool."""
|
| 18 |
+
|
| 19 |
+
key: str
|
| 20 |
+
name: str
|
| 21 |
+
description: str | None
|
| 22 |
+
input_schema: dict[str, Any]
|
| 23 |
+
annotations: dict[str, Any] | None = None
|
| 24 |
+
tags: list[str] | None = None
|
| 25 |
+
enabled: bool | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class PromptInfo:
|
| 30 |
+
"""Information about a prompt."""
|
| 31 |
+
|
| 32 |
+
key: str
|
| 33 |
+
name: str
|
| 34 |
+
description: str | None
|
| 35 |
+
arguments: list[dict[str, Any]] | None = None
|
| 36 |
+
tags: list[str] | None = None
|
| 37 |
+
enabled: bool | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class ResourceInfo:
|
| 42 |
+
"""Information about a resource."""
|
| 43 |
+
|
| 44 |
+
key: str
|
| 45 |
+
uri: str
|
| 46 |
+
name: str | None
|
| 47 |
+
description: str | None
|
| 48 |
+
mime_type: str | None = None
|
| 49 |
+
tags: list[str] | None = None
|
| 50 |
+
enabled: bool | None = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class TemplateInfo:
|
| 55 |
+
"""Information about a resource template."""
|
| 56 |
+
|
| 57 |
+
key: str
|
| 58 |
+
uri_template: str
|
| 59 |
+
name: str | None
|
| 60 |
+
description: str | None
|
| 61 |
+
mime_type: str | None = None
|
| 62 |
+
tags: list[str] | None = None
|
| 63 |
+
enabled: bool | None = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class FastMCPInfo:
|
| 68 |
+
"""Information extracted from a FastMCP instance."""
|
| 69 |
+
|
| 70 |
+
name: str
|
| 71 |
+
instructions: str | None
|
| 72 |
+
fastmcp_version: str
|
| 73 |
+
mcp_version: str
|
| 74 |
+
server_version: str
|
| 75 |
+
tools: list[ToolInfo]
|
| 76 |
+
prompts: list[PromptInfo]
|
| 77 |
+
resources: list[ResourceInfo]
|
| 78 |
+
templates: list[TemplateInfo]
|
| 79 |
+
capabilities: dict[str, Any]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def get_fastmcp_info_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
| 83 |
+
"""Extract information from a FastMCP v2.x instance.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
mcp: The FastMCP v2.x instance to inspect
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
FastMCPInfo dataclass containing the extracted information
|
| 90 |
+
"""
|
| 91 |
+
# Get all the components using FastMCP2's direct methods
|
| 92 |
+
tools_dict = await mcp.get_tools()
|
| 93 |
+
prompts_dict = await mcp.get_prompts()
|
| 94 |
+
resources_dict = await mcp.get_resources()
|
| 95 |
+
templates_dict = await mcp.get_resource_templates()
|
| 96 |
+
|
| 97 |
+
# Extract detailed tool information
|
| 98 |
+
tool_infos = []
|
| 99 |
+
for key, tool in tools_dict.items():
|
| 100 |
+
# Convert to MCP tool to get input schema
|
| 101 |
+
mcp_tool = tool.to_mcp_tool(name=key)
|
| 102 |
+
tool_infos.append(
|
| 103 |
+
ToolInfo(
|
| 104 |
+
key=key,
|
| 105 |
+
name=tool.name or key,
|
| 106 |
+
description=tool.description,
|
| 107 |
+
input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
|
| 108 |
+
annotations=tool.annotations.model_dump() if tool.annotations else None,
|
| 109 |
+
tags=list(tool.tags) if tool.tags else None,
|
| 110 |
+
enabled=tool.enabled,
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Extract detailed prompt information
|
| 115 |
+
prompt_infos = []
|
| 116 |
+
for key, prompt in prompts_dict.items():
|
| 117 |
+
prompt_infos.append(
|
| 118 |
+
PromptInfo(
|
| 119 |
+
key=key,
|
| 120 |
+
name=prompt.name or key,
|
| 121 |
+
description=prompt.description,
|
| 122 |
+
arguments=[arg.model_dump() for arg in prompt.arguments]
|
| 123 |
+
if prompt.arguments
|
| 124 |
+
else None,
|
| 125 |
+
tags=list(prompt.tags) if prompt.tags else None,
|
| 126 |
+
enabled=prompt.enabled,
|
| 127 |
+
)
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Extract detailed resource information
|
| 131 |
+
resource_infos = []
|
| 132 |
+
for key, resource in resources_dict.items():
|
| 133 |
+
resource_infos.append(
|
| 134 |
+
ResourceInfo(
|
| 135 |
+
key=key,
|
| 136 |
+
uri=key, # For v2, key is the URI
|
| 137 |
+
name=resource.name,
|
| 138 |
+
description=resource.description,
|
| 139 |
+
mime_type=resource.mime_type,
|
| 140 |
+
tags=list(resource.tags) if resource.tags else None,
|
| 141 |
+
enabled=resource.enabled,
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Extract detailed template information
|
| 146 |
+
template_infos = []
|
| 147 |
+
for key, template in templates_dict.items():
|
| 148 |
+
template_infos.append(
|
| 149 |
+
TemplateInfo(
|
| 150 |
+
key=key,
|
| 151 |
+
uri_template=key, # For v2, key is the URI template
|
| 152 |
+
name=template.name,
|
| 153 |
+
description=template.description,
|
| 154 |
+
mime_type=template.mime_type,
|
| 155 |
+
tags=list(template.tags) if template.tags else None,
|
| 156 |
+
enabled=template.enabled,
|
| 157 |
+
)
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Basic MCP capabilities that FastMCP supports
|
| 161 |
+
capabilities = {
|
| 162 |
+
"tools": {"listChanged": True},
|
| 163 |
+
"resources": {"subscribe": False, "listChanged": False},
|
| 164 |
+
"prompts": {"listChanged": False},
|
| 165 |
+
"logging": {},
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return FastMCPInfo(
|
| 169 |
+
name=mcp.name,
|
| 170 |
+
instructions=mcp.instructions,
|
| 171 |
+
fastmcp_version=fastmcp.__version__,
|
| 172 |
+
mcp_version=importlib.metadata.version("mcp"),
|
| 173 |
+
server_version=fastmcp.__version__, # v2.x uses FastMCP version
|
| 174 |
+
tools=tool_infos,
|
| 175 |
+
prompts=prompt_infos,
|
| 176 |
+
resources=resource_infos,
|
| 177 |
+
templates=template_infos,
|
| 178 |
+
capabilities=capabilities,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def get_fastmcp_info_v1(mcp: Any) -> FastMCPInfo:
|
| 183 |
+
"""Extract information from a FastMCP v1.x instance using a Client.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
mcp: The FastMCP v1.x instance to inspect
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
FastMCPInfo dataclass containing the extracted information
|
| 190 |
+
"""
|
| 191 |
+
from fastmcp import Client
|
| 192 |
+
|
| 193 |
+
# Use a client to interact with the FastMCP1x server
|
| 194 |
+
async with Client(mcp) as client:
|
| 195 |
+
# Get components via client calls (these return MCP objects)
|
| 196 |
+
mcp_tools = await client.list_tools()
|
| 197 |
+
mcp_prompts = await client.list_prompts()
|
| 198 |
+
mcp_resources = await client.list_resources()
|
| 199 |
+
|
| 200 |
+
# Try to get resource templates (FastMCP 1.x does have templates)
|
| 201 |
+
try:
|
| 202 |
+
mcp_templates = await client.list_resource_templates()
|
| 203 |
+
except Exception:
|
| 204 |
+
mcp_templates = []
|
| 205 |
+
|
| 206 |
+
# Extract detailed tool information from MCP Tool objects
|
| 207 |
+
tool_infos = []
|
| 208 |
+
for mcp_tool in mcp_tools:
|
| 209 |
+
# Extract annotations if they exist
|
| 210 |
+
annotations = None
|
| 211 |
+
if hasattr(mcp_tool, "annotations") and mcp_tool.annotations:
|
| 212 |
+
if hasattr(mcp_tool.annotations, "model_dump"):
|
| 213 |
+
annotations = mcp_tool.annotations.model_dump()
|
| 214 |
+
elif isinstance(mcp_tool.annotations, dict):
|
| 215 |
+
annotations = mcp_tool.annotations
|
| 216 |
+
else:
|
| 217 |
+
annotations = None
|
| 218 |
+
|
| 219 |
+
tool_infos.append(
|
| 220 |
+
ToolInfo(
|
| 221 |
+
key=mcp_tool.name, # For 1.x, key and name are the same
|
| 222 |
+
name=mcp_tool.name,
|
| 223 |
+
description=mcp_tool.description,
|
| 224 |
+
input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
|
| 225 |
+
annotations=annotations,
|
| 226 |
+
tags=None, # 1.x doesn't have tags
|
| 227 |
+
enabled=None, # 1.x doesn't have enabled field
|
| 228 |
+
)
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Extract detailed prompt information from MCP Prompt objects
|
| 232 |
+
prompt_infos = []
|
| 233 |
+
for mcp_prompt in mcp_prompts:
|
| 234 |
+
# Convert arguments if they exist
|
| 235 |
+
arguments = None
|
| 236 |
+
if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments:
|
| 237 |
+
arguments = [arg.model_dump() for arg in mcp_prompt.arguments]
|
| 238 |
+
|
| 239 |
+
prompt_infos.append(
|
| 240 |
+
PromptInfo(
|
| 241 |
+
key=mcp_prompt.name, # For 1.x, key and name are the same
|
| 242 |
+
name=mcp_prompt.name,
|
| 243 |
+
description=mcp_prompt.description,
|
| 244 |
+
arguments=arguments,
|
| 245 |
+
tags=None, # 1.x doesn't have tags
|
| 246 |
+
enabled=None, # 1.x doesn't have enabled field
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Extract detailed resource information from MCP Resource objects
|
| 251 |
+
resource_infos = []
|
| 252 |
+
for mcp_resource in mcp_resources:
|
| 253 |
+
resource_infos.append(
|
| 254 |
+
ResourceInfo(
|
| 255 |
+
key=str(mcp_resource.uri), # For 1.x, key and uri are the same
|
| 256 |
+
uri=str(mcp_resource.uri),
|
| 257 |
+
name=mcp_resource.name,
|
| 258 |
+
description=mcp_resource.description,
|
| 259 |
+
mime_type=mcp_resource.mimeType,
|
| 260 |
+
tags=None, # 1.x doesn't have tags
|
| 261 |
+
enabled=None, # 1.x doesn't have enabled field
|
| 262 |
+
)
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Extract detailed template information from MCP ResourceTemplate objects
|
| 266 |
+
template_infos = []
|
| 267 |
+
for mcp_template in mcp_templates:
|
| 268 |
+
template_infos.append(
|
| 269 |
+
TemplateInfo(
|
| 270 |
+
key=str(
|
| 271 |
+
mcp_template.uriTemplate
|
| 272 |
+
), # For 1.x, key and uriTemplate are the same
|
| 273 |
+
uri_template=str(mcp_template.uriTemplate),
|
| 274 |
+
name=mcp_template.name,
|
| 275 |
+
description=mcp_template.description,
|
| 276 |
+
mime_type=mcp_template.mimeType,
|
| 277 |
+
tags=None, # 1.x doesn't have tags
|
| 278 |
+
enabled=None, # 1.x doesn't have enabled field
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# Basic MCP capabilities
|
| 283 |
+
capabilities = {
|
| 284 |
+
"tools": {"listChanged": True},
|
| 285 |
+
"resources": {"subscribe": False, "listChanged": False},
|
| 286 |
+
"prompts": {"listChanged": False},
|
| 287 |
+
"logging": {},
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
return FastMCPInfo(
|
| 291 |
+
name=mcp.name,
|
| 292 |
+
instructions=getattr(mcp, "instructions", None),
|
| 293 |
+
fastmcp_version=fastmcp.__version__, # Report current fastmcp version
|
| 294 |
+
mcp_version=importlib.metadata.version("mcp"),
|
| 295 |
+
server_version="1.0", # FastMCP 1.x version
|
| 296 |
+
tools=tool_infos,
|
| 297 |
+
prompts=prompt_infos,
|
| 298 |
+
resources=resource_infos,
|
| 299 |
+
templates=template_infos, # FastMCP1x does have templates
|
| 300 |
+
capabilities=capabilities,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _is_fastmcp_v1(mcp: Any) -> bool:
|
| 305 |
+
"""Check if the given instance is a FastMCP v1.x instance."""
|
| 306 |
+
|
| 307 |
+
# Check if it's an instance of FastMCP1x and not FastMCP2
|
| 308 |
+
return isinstance(mcp, FastMCP1x) and not isinstance(mcp, FastMCP)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
async def get_fastmcp_info(mcp: FastMCP[Any] | Any) -> FastMCPInfo:
|
| 312 |
+
"""Extract information from a FastMCP instance into a dataclass.
|
| 313 |
+
|
| 314 |
+
This function automatically detects whether the instance is FastMCP v1.x or v2.x
|
| 315 |
+
and uses the appropriate extraction method.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
mcp: The FastMCP instance to inspect (v1.x or v2.x)
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
FastMCPInfo dataclass containing the extracted information
|
| 322 |
+
"""
|
| 323 |
+
if _is_fastmcp_v1(mcp):
|
| 324 |
+
return await get_fastmcp_info_v1(mcp)
|
| 325 |
+
else:
|
| 326 |
+
return await get_fastmcp_info_v2(mcp)
|
tests/cli/test_inspect.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the CLI inspect command."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from typer.testing import CliRunner
|
| 8 |
+
|
| 9 |
+
from fastmcp.cli.cli import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestInspectCommand:
|
| 13 |
+
"""Tests for the fastmcp inspect CLI command."""
|
| 14 |
+
|
| 15 |
+
def setup_method(self):
|
| 16 |
+
"""Set up test fixtures."""
|
| 17 |
+
self.runner = CliRunner()
|
| 18 |
+
|
| 19 |
+
def test_inspect_basic_server(self):
|
| 20 |
+
"""Test inspecting a basic FastMCP 2.x server."""
|
| 21 |
+
# Create a temporary server file
|
| 22 |
+
server_content = '''
|
| 23 |
+
from fastmcp import FastMCP
|
| 24 |
+
|
| 25 |
+
mcp = FastMCP("TestServer", instructions="A test server")
|
| 26 |
+
|
| 27 |
+
@mcp.tool
|
| 28 |
+
def add(a: int, b: int) -> int:
|
| 29 |
+
"""Add two numbers."""
|
| 30 |
+
return a + b
|
| 31 |
+
|
| 32 |
+
@mcp.resource("resource://data")
|
| 33 |
+
def get_data() -> str:
|
| 34 |
+
"""Get test data."""
|
| 35 |
+
return "test data"
|
| 36 |
+
|
| 37 |
+
@mcp.prompt
|
| 38 |
+
def test_prompt(message: str) -> list:
|
| 39 |
+
"""Test prompt."""
|
| 40 |
+
return [{"role": "user", "content": message}]
|
| 41 |
+
'''
|
| 42 |
+
|
| 43 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 44 |
+
f.write(server_content)
|
| 45 |
+
server_file = f.name
|
| 46 |
+
|
| 47 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
| 48 |
+
output_file = f.name
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
# Run the inspect command
|
| 52 |
+
result = self.runner.invoke(
|
| 53 |
+
app, ["inspect", server_file, "-o", output_file]
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
assert result.exit_code == 0
|
| 57 |
+
assert "✓ Inspected server: TestServer" in result.stdout
|
| 58 |
+
assert "Tools: 1" in result.stdout
|
| 59 |
+
assert "Prompts: 1" in result.stdout
|
| 60 |
+
assert "Resources: 1" in result.stdout
|
| 61 |
+
|
| 62 |
+
# Check the JSON output
|
| 63 |
+
with open(output_file) as f:
|
| 64 |
+
data = json.load(f)
|
| 65 |
+
|
| 66 |
+
assert data["name"] == "TestServer"
|
| 67 |
+
assert data["instructions"] == "A test server"
|
| 68 |
+
assert "fastmcp_version" in data
|
| 69 |
+
assert "mcp_version" in data
|
| 70 |
+
assert "server_version" in data
|
| 71 |
+
|
| 72 |
+
# Check tools
|
| 73 |
+
assert len(data["tools"]) == 1
|
| 74 |
+
tool = data["tools"][0]
|
| 75 |
+
assert tool["key"] == "add"
|
| 76 |
+
assert tool["name"] == "add"
|
| 77 |
+
assert tool["description"] == "Add two numbers."
|
| 78 |
+
assert "input_schema" in tool
|
| 79 |
+
assert tool["enabled"] is True
|
| 80 |
+
|
| 81 |
+
# Check resources
|
| 82 |
+
assert len(data["resources"]) == 1
|
| 83 |
+
resource = data["resources"][0]
|
| 84 |
+
assert resource["key"] == "resource://data"
|
| 85 |
+
assert resource["uri"] == "resource://data"
|
| 86 |
+
assert resource["name"] == "get_data"
|
| 87 |
+
|
| 88 |
+
# Check prompts
|
| 89 |
+
assert len(data["prompts"]) == 1
|
| 90 |
+
prompt = data["prompts"][0]
|
| 91 |
+
assert prompt["key"] == "test_prompt"
|
| 92 |
+
assert prompt["name"] == "test_prompt"
|
| 93 |
+
assert prompt["description"] == "Test prompt."
|
| 94 |
+
|
| 95 |
+
# Check capabilities
|
| 96 |
+
assert "capabilities" in data
|
| 97 |
+
assert "tools" in data["capabilities"]
|
| 98 |
+
|
| 99 |
+
finally:
|
| 100 |
+
# Clean up
|
| 101 |
+
Path(server_file).unlink(missing_ok=True)
|
| 102 |
+
Path(output_file).unlink(missing_ok=True)
|
| 103 |
+
|
| 104 |
+
def test_inspect_with_object_spec(self):
|
| 105 |
+
"""Test inspecting a server with object specification."""
|
| 106 |
+
server_content = '''
|
| 107 |
+
from fastmcp import FastMCP
|
| 108 |
+
|
| 109 |
+
server = FastMCP("ObjectSpecServer")
|
| 110 |
+
|
| 111 |
+
@server.tool
|
| 112 |
+
def multiply(a: int, b: int) -> int:
|
| 113 |
+
"""Multiply two numbers."""
|
| 114 |
+
return a * b
|
| 115 |
+
'''
|
| 116 |
+
|
| 117 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 118 |
+
f.write(server_content)
|
| 119 |
+
server_file = f.name
|
| 120 |
+
|
| 121 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
| 122 |
+
output_file = f.name
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# Run the inspect command with object specification
|
| 126 |
+
result = self.runner.invoke(
|
| 127 |
+
app, ["inspect", f"{server_file}:server", "-o", output_file]
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
assert result.exit_code == 0
|
| 131 |
+
assert "✓ Inspected server: ObjectSpecServer" in result.stdout
|
| 132 |
+
|
| 133 |
+
# Check the JSON output
|
| 134 |
+
with open(output_file) as f:
|
| 135 |
+
data = json.load(f)
|
| 136 |
+
|
| 137 |
+
assert data["name"] == "ObjectSpecServer"
|
| 138 |
+
assert len(data["tools"]) == 1
|
| 139 |
+
assert data["tools"][0]["name"] == "multiply"
|
| 140 |
+
|
| 141 |
+
finally:
|
| 142 |
+
# Clean up
|
| 143 |
+
Path(server_file).unlink(missing_ok=True)
|
| 144 |
+
Path(output_file).unlink(missing_ok=True)
|
| 145 |
+
|
| 146 |
+
def test_inspect_default_output(self):
|
| 147 |
+
"""Test inspecting with default output filename."""
|
| 148 |
+
server_content = '''
|
| 149 |
+
from fastmcp import FastMCP
|
| 150 |
+
|
| 151 |
+
mcp = FastMCP("DefaultOutputServer")
|
| 152 |
+
|
| 153 |
+
@mcp.tool
|
| 154 |
+
def test_tool() -> str:
|
| 155 |
+
"""Test tool."""
|
| 156 |
+
return "test"
|
| 157 |
+
'''
|
| 158 |
+
|
| 159 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 160 |
+
f.write(server_content)
|
| 161 |
+
server_file = f.name
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
# Run the inspect command without specifying output file
|
| 165 |
+
result = self.runner.invoke(app, ["inspect", server_file])
|
| 166 |
+
|
| 167 |
+
assert result.exit_code == 0
|
| 168 |
+
assert "✓ Inspected server: DefaultOutputServer" in result.stdout
|
| 169 |
+
assert "Report saved to: server-info.json" in result.stdout
|
| 170 |
+
|
| 171 |
+
# Check the default output file exists
|
| 172 |
+
default_output = Path("server-info.json")
|
| 173 |
+
assert default_output.exists()
|
| 174 |
+
|
| 175 |
+
# Check the JSON content
|
| 176 |
+
with open(default_output) as f:
|
| 177 |
+
data = json.load(f)
|
| 178 |
+
|
| 179 |
+
assert data["name"] == "DefaultOutputServer"
|
| 180 |
+
|
| 181 |
+
finally:
|
| 182 |
+
# Clean up
|
| 183 |
+
Path(server_file).unlink(missing_ok=True)
|
| 184 |
+
Path("server-info.json").unlink(missing_ok=True)
|
| 185 |
+
|
| 186 |
+
def test_inspect_invalid_server_file(self):
|
| 187 |
+
"""Test inspecting a non-existent server file."""
|
| 188 |
+
result = self.runner.invoke(
|
| 189 |
+
app, ["inspect", "nonexistent.py", "-o", "output.json"]
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
assert result.exit_code == 1
|
| 193 |
+
# The error happens at the file parsing level, so no stdout output
|
| 194 |
+
|
| 195 |
+
def test_inspect_server_with_error(self):
|
| 196 |
+
"""Test inspecting a server file with syntax errors."""
|
| 197 |
+
server_content = """
|
| 198 |
+
from fastmcp import FastMCP
|
| 199 |
+
|
| 200 |
+
mcp = FastMCP("ErrorServer")
|
| 201 |
+
# Syntax error below
|
| 202 |
+
@mcp.tool
|
| 203 |
+
def broken_tool(
|
| 204 |
+
"""
|
| 205 |
+
|
| 206 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 207 |
+
f.write(server_content)
|
| 208 |
+
server_file = f.name
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
result = self.runner.invoke(
|
| 212 |
+
app, ["inspect", server_file, "-o", "output.json"]
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
assert result.exit_code == 1
|
| 216 |
+
assert "✗ Failed to inspect server:" in result.stdout
|
| 217 |
+
|
| 218 |
+
finally:
|
| 219 |
+
# Clean up
|
| 220 |
+
Path(server_file).unlink(missing_ok=True)
|
| 221 |
+
Path("output.json").unlink(missing_ok=True)
|
| 222 |
+
|
| 223 |
+
def test_inspect_comprehensive_json_structure(self):
|
| 224 |
+
"""Test that the JSON output has the correct structure."""
|
| 225 |
+
server_content = '''
|
| 226 |
+
from fastmcp import FastMCP
|
| 227 |
+
|
| 228 |
+
mcp = FastMCP("ComprehensiveServer", instructions="Full test server")
|
| 229 |
+
|
| 230 |
+
@mcp.tool
|
| 231 |
+
def calculate(x: int, y: int) -> int:
|
| 232 |
+
"""Calculate something."""
|
| 233 |
+
return x + y
|
| 234 |
+
|
| 235 |
+
@mcp.resource("resource://static")
|
| 236 |
+
def static_resource() -> str:
|
| 237 |
+
"""Static resource."""
|
| 238 |
+
return "static"
|
| 239 |
+
|
| 240 |
+
@mcp.resource("resource://template/{id}")
|
| 241 |
+
def template_resource(id: str) -> str:
|
| 242 |
+
"""Template resource."""
|
| 243 |
+
return f"data-{id}"
|
| 244 |
+
|
| 245 |
+
@mcp.prompt
|
| 246 |
+
def analysis_prompt(data: str) -> list:
|
| 247 |
+
"""Analysis prompt."""
|
| 248 |
+
return [{"role": "user", "content": f"Analyze: {data}"}]
|
| 249 |
+
'''
|
| 250 |
+
|
| 251 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 252 |
+
f.write(server_content)
|
| 253 |
+
server_file = f.name
|
| 254 |
+
|
| 255 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
| 256 |
+
output_file = f.name
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
result = self.runner.invoke(
|
| 260 |
+
app, ["inspect", server_file, "-o", output_file]
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
assert result.exit_code == 0
|
| 264 |
+
|
| 265 |
+
# Load and validate JSON structure
|
| 266 |
+
with open(output_file) as f:
|
| 267 |
+
data = json.load(f)
|
| 268 |
+
|
| 269 |
+
# Check top-level structure
|
| 270 |
+
required_fields = [
|
| 271 |
+
"name",
|
| 272 |
+
"instructions",
|
| 273 |
+
"fastmcp_version",
|
| 274 |
+
"mcp_version",
|
| 275 |
+
"server_version",
|
| 276 |
+
"tools",
|
| 277 |
+
"prompts",
|
| 278 |
+
"resources",
|
| 279 |
+
"templates",
|
| 280 |
+
"capabilities",
|
| 281 |
+
]
|
| 282 |
+
for field in required_fields:
|
| 283 |
+
assert field in data, f"Missing field: {field}"
|
| 284 |
+
|
| 285 |
+
# Check version fields are strings
|
| 286 |
+
assert isinstance(data["fastmcp_version"], str)
|
| 287 |
+
assert isinstance(data["mcp_version"], str)
|
| 288 |
+
assert isinstance(data["server_version"], str)
|
| 289 |
+
|
| 290 |
+
# Check that we have the expected components
|
| 291 |
+
assert len(data["tools"]) == 1
|
| 292 |
+
assert len(data["resources"]) == 1
|
| 293 |
+
assert len(data["templates"]) == 1
|
| 294 |
+
assert len(data["prompts"]) == 1
|
| 295 |
+
|
| 296 |
+
# Check tool structure
|
| 297 |
+
tool = data["tools"][0]
|
| 298 |
+
tool_fields = [
|
| 299 |
+
"key",
|
| 300 |
+
"name",
|
| 301 |
+
"description",
|
| 302 |
+
"input_schema",
|
| 303 |
+
"annotations",
|
| 304 |
+
"tags",
|
| 305 |
+
"enabled",
|
| 306 |
+
]
|
| 307 |
+
for field in tool_fields:
|
| 308 |
+
assert field in tool, f"Missing tool field: {field}"
|
| 309 |
+
|
| 310 |
+
# Check resource structure
|
| 311 |
+
resource = data["resources"][0]
|
| 312 |
+
resource_fields = [
|
| 313 |
+
"key",
|
| 314 |
+
"uri",
|
| 315 |
+
"name",
|
| 316 |
+
"description",
|
| 317 |
+
"mime_type",
|
| 318 |
+
"tags",
|
| 319 |
+
"enabled",
|
| 320 |
+
]
|
| 321 |
+
for field in resource_fields:
|
| 322 |
+
assert field in resource, f"Missing resource field: {field}"
|
| 323 |
+
|
| 324 |
+
# Check template structure
|
| 325 |
+
template = data["templates"][0]
|
| 326 |
+
template_fields = [
|
| 327 |
+
"key",
|
| 328 |
+
"uri_template",
|
| 329 |
+
"name",
|
| 330 |
+
"description",
|
| 331 |
+
"mime_type",
|
| 332 |
+
"tags",
|
| 333 |
+
"enabled",
|
| 334 |
+
]
|
| 335 |
+
for field in template_fields:
|
| 336 |
+
assert field in template, f"Missing template field: {field}"
|
| 337 |
+
|
| 338 |
+
# Check prompt structure
|
| 339 |
+
prompt = data["prompts"][0]
|
| 340 |
+
prompt_fields = [
|
| 341 |
+
"key",
|
| 342 |
+
"name",
|
| 343 |
+
"description",
|
| 344 |
+
"arguments",
|
| 345 |
+
"tags",
|
| 346 |
+
"enabled",
|
| 347 |
+
]
|
| 348 |
+
for field in prompt_fields:
|
| 349 |
+
assert field in prompt, f"Missing prompt field: {field}"
|
| 350 |
+
|
| 351 |
+
finally:
|
| 352 |
+
# Clean up
|
| 353 |
+
Path(server_file).unlink(missing_ok=True)
|
| 354 |
+
Path(output_file).unlink(missing_ok=True)
|
tests/utilities/test_inspect.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the inspect.py module."""
|
| 2 |
+
|
| 3 |
+
# Import FastMCP1x for testing (always available since mcp is a dependency)
|
| 4 |
+
from mcp.server.fastmcp import FastMCP as FastMCP1x
|
| 5 |
+
|
| 6 |
+
import fastmcp
|
| 7 |
+
from fastmcp import Client, FastMCP
|
| 8 |
+
from fastmcp.utilities.inspect import (
|
| 9 |
+
FastMCPInfo,
|
| 10 |
+
ToolInfo,
|
| 11 |
+
_is_fastmcp_v1,
|
| 12 |
+
get_fastmcp_info,
|
| 13 |
+
get_fastmcp_info_v1,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestFastMCPInfo:
|
| 18 |
+
"""Tests for the FastMCPInfo dataclass."""
|
| 19 |
+
|
| 20 |
+
def test_fastmcp_info_creation(self):
|
| 21 |
+
"""Test that FastMCPInfo can be created with all required fields."""
|
| 22 |
+
tool = ToolInfo(
|
| 23 |
+
key="tool1", name="tool1", description="Test tool", input_schema={}
|
| 24 |
+
)
|
| 25 |
+
info = FastMCPInfo(
|
| 26 |
+
name="TestServer",
|
| 27 |
+
instructions="Test instructions",
|
| 28 |
+
fastmcp_version="1.0.0",
|
| 29 |
+
mcp_version="1.0.0",
|
| 30 |
+
server_version="1.0.0",
|
| 31 |
+
tools=[tool],
|
| 32 |
+
prompts=[],
|
| 33 |
+
resources=[],
|
| 34 |
+
templates=[],
|
| 35 |
+
capabilities={"tools": {"listChanged": True}},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
assert info.name == "TestServer"
|
| 39 |
+
assert info.instructions == "Test instructions"
|
| 40 |
+
assert info.fastmcp_version == "1.0.0"
|
| 41 |
+
assert info.mcp_version == "1.0.0"
|
| 42 |
+
assert info.server_version == "1.0.0"
|
| 43 |
+
assert len(info.tools) == 1
|
| 44 |
+
assert info.tools[0].name == "tool1"
|
| 45 |
+
assert info.capabilities == {"tools": {"listChanged": True}}
|
| 46 |
+
|
| 47 |
+
def test_fastmcp_info_with_none_instructions(self):
|
| 48 |
+
"""Test that FastMCPInfo works with None instructions."""
|
| 49 |
+
info = FastMCPInfo(
|
| 50 |
+
name="TestServer",
|
| 51 |
+
instructions=None,
|
| 52 |
+
fastmcp_version="1.0.0",
|
| 53 |
+
mcp_version="1.0.0",
|
| 54 |
+
server_version="1.0.0",
|
| 55 |
+
tools=[],
|
| 56 |
+
prompts=[],
|
| 57 |
+
resources=[],
|
| 58 |
+
templates=[],
|
| 59 |
+
capabilities={},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
assert info.instructions is None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestGetFastMCPInfo:
|
| 66 |
+
"""Tests for the get_fastmcp_info function."""
|
| 67 |
+
|
| 68 |
+
async def test_empty_server(self):
|
| 69 |
+
"""Test get_fastmcp_info with an empty server."""
|
| 70 |
+
mcp = FastMCP("EmptyServer", instructions="Empty server for testing")
|
| 71 |
+
|
| 72 |
+
info = await get_fastmcp_info(mcp)
|
| 73 |
+
|
| 74 |
+
assert info.name == "EmptyServer"
|
| 75 |
+
assert info.instructions == "Empty server for testing"
|
| 76 |
+
assert info.fastmcp_version == fastmcp.__version__
|
| 77 |
+
assert info.mcp_version is not None
|
| 78 |
+
assert info.server_version == fastmcp.__version__ # v2.x uses FastMCP version
|
| 79 |
+
assert info.tools == []
|
| 80 |
+
assert info.prompts == []
|
| 81 |
+
assert info.resources == []
|
| 82 |
+
assert info.templates == []
|
| 83 |
+
assert "tools" in info.capabilities
|
| 84 |
+
assert "resources" in info.capabilities
|
| 85 |
+
assert "prompts" in info.capabilities
|
| 86 |
+
assert "logging" in info.capabilities
|
| 87 |
+
|
| 88 |
+
async def test_server_with_tools(self):
|
| 89 |
+
"""Test get_fastmcp_info with a server that has tools."""
|
| 90 |
+
mcp = FastMCP("ToolServer")
|
| 91 |
+
|
| 92 |
+
@mcp.tool
|
| 93 |
+
def add_numbers(a: int, b: int) -> int:
|
| 94 |
+
return a + b
|
| 95 |
+
|
| 96 |
+
@mcp.tool
|
| 97 |
+
def greet(name: str) -> str:
|
| 98 |
+
return f"Hello, {name}!"
|
| 99 |
+
|
| 100 |
+
info = await get_fastmcp_info(mcp)
|
| 101 |
+
|
| 102 |
+
assert info.name == "ToolServer"
|
| 103 |
+
assert len(info.tools) == 2
|
| 104 |
+
tool_names = [tool.name for tool in info.tools]
|
| 105 |
+
assert "add_numbers" in tool_names
|
| 106 |
+
assert "greet" in tool_names
|
| 107 |
+
|
| 108 |
+
async def test_server_with_resources(self):
|
| 109 |
+
"""Test get_fastmcp_info with a server that has resources."""
|
| 110 |
+
mcp = FastMCP("ResourceServer")
|
| 111 |
+
|
| 112 |
+
@mcp.resource("resource://static")
|
| 113 |
+
def get_static_data() -> str:
|
| 114 |
+
return "Static data"
|
| 115 |
+
|
| 116 |
+
@mcp.resource("resource://dynamic/{param}")
|
| 117 |
+
def get_dynamic_data(param: str) -> str:
|
| 118 |
+
return f"Dynamic data: {param}"
|
| 119 |
+
|
| 120 |
+
info = await get_fastmcp_info(mcp)
|
| 121 |
+
|
| 122 |
+
assert info.name == "ResourceServer"
|
| 123 |
+
assert len(info.resources) == 1 # Static resource
|
| 124 |
+
assert len(info.templates) == 1 # Dynamic resource becomes template
|
| 125 |
+
resource_uris = [res.uri for res in info.resources]
|
| 126 |
+
template_uris = [tmpl.uri_template for tmpl in info.templates]
|
| 127 |
+
assert "resource://static" in resource_uris
|
| 128 |
+
assert "resource://dynamic/{param}" in template_uris
|
| 129 |
+
|
| 130 |
+
async def test_server_with_prompts(self):
|
| 131 |
+
"""Test get_fastmcp_info with a server that has prompts."""
|
| 132 |
+
mcp = FastMCP("PromptServer")
|
| 133 |
+
|
| 134 |
+
@mcp.prompt
|
| 135 |
+
def analyze_data(data: str) -> list:
|
| 136 |
+
return [{"role": "user", "content": f"Analyze: {data}"}]
|
| 137 |
+
|
| 138 |
+
@mcp.prompt("custom_prompt")
|
| 139 |
+
def custom_analysis(text: str) -> list:
|
| 140 |
+
return [{"role": "user", "content": f"Custom: {text}"}]
|
| 141 |
+
|
| 142 |
+
info = await get_fastmcp_info(mcp)
|
| 143 |
+
|
| 144 |
+
assert info.name == "PromptServer"
|
| 145 |
+
assert len(info.prompts) == 2
|
| 146 |
+
prompt_names = [prompt.name for prompt in info.prompts]
|
| 147 |
+
assert "analyze_data" in prompt_names
|
| 148 |
+
assert "custom_prompt" in prompt_names
|
| 149 |
+
|
| 150 |
+
async def test_comprehensive_server(self):
|
| 151 |
+
"""Test get_fastmcp_info with a server that has all component types."""
|
| 152 |
+
mcp = FastMCP("ComprehensiveServer", instructions="A server with everything")
|
| 153 |
+
|
| 154 |
+
# Add a tool
|
| 155 |
+
@mcp.tool
|
| 156 |
+
def calculate(x: int, y: int) -> int:
|
| 157 |
+
return x * y
|
| 158 |
+
|
| 159 |
+
# Add a resource
|
| 160 |
+
@mcp.resource("resource://data")
|
| 161 |
+
def get_data() -> str:
|
| 162 |
+
return "Some data"
|
| 163 |
+
|
| 164 |
+
# Add a template
|
| 165 |
+
@mcp.resource("resource://item/{id}")
|
| 166 |
+
def get_item(id: str) -> str:
|
| 167 |
+
return f"Item {id}"
|
| 168 |
+
|
| 169 |
+
# Add a prompt
|
| 170 |
+
@mcp.prompt
|
| 171 |
+
def analyze(content: str) -> list:
|
| 172 |
+
return [{"role": "user", "content": content}]
|
| 173 |
+
|
| 174 |
+
info = await get_fastmcp_info(mcp)
|
| 175 |
+
|
| 176 |
+
assert info.name == "ComprehensiveServer"
|
| 177 |
+
assert info.instructions == "A server with everything"
|
| 178 |
+
assert info.fastmcp_version == fastmcp.__version__
|
| 179 |
+
|
| 180 |
+
# Check all components are present
|
| 181 |
+
assert len(info.tools) == 1
|
| 182 |
+
tool_names = [tool.name for tool in info.tools]
|
| 183 |
+
assert "calculate" in tool_names
|
| 184 |
+
|
| 185 |
+
assert len(info.resources) == 1
|
| 186 |
+
resource_uris = [res.uri for res in info.resources]
|
| 187 |
+
assert "resource://data" in resource_uris
|
| 188 |
+
|
| 189 |
+
assert len(info.templates) == 1
|
| 190 |
+
template_uris = [tmpl.uri_template for tmpl in info.templates]
|
| 191 |
+
assert "resource://item/{id}" in template_uris
|
| 192 |
+
|
| 193 |
+
assert len(info.prompts) == 1
|
| 194 |
+
prompt_names = [prompt.name for prompt in info.prompts]
|
| 195 |
+
assert "analyze" in prompt_names
|
| 196 |
+
|
| 197 |
+
# Check capabilities
|
| 198 |
+
assert "tools" in info.capabilities
|
| 199 |
+
assert "resources" in info.capabilities
|
| 200 |
+
assert "prompts" in info.capabilities
|
| 201 |
+
assert "logging" in info.capabilities
|
| 202 |
+
|
| 203 |
+
async def test_server_no_instructions(self):
|
| 204 |
+
"""Test get_fastmcp_info with a server that has no instructions."""
|
| 205 |
+
mcp = FastMCP("NoInstructionsServer")
|
| 206 |
+
|
| 207 |
+
info = await get_fastmcp_info(mcp)
|
| 208 |
+
|
| 209 |
+
assert info.name == "NoInstructionsServer"
|
| 210 |
+
assert info.instructions is None
|
| 211 |
+
|
| 212 |
+
async def test_server_with_client_integration(self):
|
| 213 |
+
"""Test that the extracted info matches what a client would see."""
|
| 214 |
+
mcp = FastMCP("IntegrationServer")
|
| 215 |
+
|
| 216 |
+
@mcp.tool
|
| 217 |
+
def test_tool() -> str:
|
| 218 |
+
return "test"
|
| 219 |
+
|
| 220 |
+
@mcp.resource("resource://test")
|
| 221 |
+
def test_resource() -> str:
|
| 222 |
+
return "test resource"
|
| 223 |
+
|
| 224 |
+
@mcp.prompt
|
| 225 |
+
def test_prompt() -> list:
|
| 226 |
+
return [{"role": "user", "content": "test"}]
|
| 227 |
+
|
| 228 |
+
# Get info using our function
|
| 229 |
+
info = await get_fastmcp_info(mcp)
|
| 230 |
+
|
| 231 |
+
# Verify using client
|
| 232 |
+
async with Client(mcp) as client:
|
| 233 |
+
tools = await client.list_tools()
|
| 234 |
+
resources = await client.list_resources()
|
| 235 |
+
prompts = await client.list_prompts()
|
| 236 |
+
|
| 237 |
+
assert len(info.tools) == len(tools)
|
| 238 |
+
assert len(info.resources) == len(resources)
|
| 239 |
+
assert len(info.prompts) == len(prompts)
|
| 240 |
+
|
| 241 |
+
assert info.tools[0].name == tools[0].name
|
| 242 |
+
assert info.resources[0].uri == str(resources[0].uri)
|
| 243 |
+
assert info.prompts[0].name == prompts[0].name
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class TestFastMCP1xCompatibility:
|
| 247 |
+
"""Tests for FastMCP 1.x compatibility."""
|
| 248 |
+
|
| 249 |
+
async def test_fastmcp1x_detection(self):
|
| 250 |
+
"""Test that FastMCP1x instances are correctly detected."""
|
| 251 |
+
mcp1x = FastMCP1x("Test1x")
|
| 252 |
+
mcp2x = FastMCP("Test2x")
|
| 253 |
+
|
| 254 |
+
assert _is_fastmcp_v1(mcp1x) is True
|
| 255 |
+
assert _is_fastmcp_v1(mcp2x) is False
|
| 256 |
+
|
| 257 |
+
async def test_fastmcp1x_empty_server(self):
|
| 258 |
+
"""Test get_fastmcp_info_v1 with an empty FastMCP1x server."""
|
| 259 |
+
mcp = FastMCP1x("Test1x")
|
| 260 |
+
|
| 261 |
+
info = await get_fastmcp_info_v1(mcp)
|
| 262 |
+
|
| 263 |
+
assert info.name == "Test1x"
|
| 264 |
+
assert info.instructions is None
|
| 265 |
+
assert info.fastmcp_version == fastmcp.__version__
|
| 266 |
+
assert info.mcp_version is not None
|
| 267 |
+
assert info.server_version == "1.0" # v1.x servers use "1.0"
|
| 268 |
+
assert info.tools == []
|
| 269 |
+
assert info.prompts == []
|
| 270 |
+
assert info.resources == []
|
| 271 |
+
assert info.templates == [] # No templates added in this test
|
| 272 |
+
assert "tools" in info.capabilities
|
| 273 |
+
|
| 274 |
+
async def test_fastmcp1x_with_tools(self):
|
| 275 |
+
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has tools."""
|
| 276 |
+
mcp = FastMCP1x("Test1x")
|
| 277 |
+
|
| 278 |
+
@mcp.tool()
|
| 279 |
+
def add_numbers(a: int, b: int) -> int:
|
| 280 |
+
return a + b
|
| 281 |
+
|
| 282 |
+
@mcp.tool()
|
| 283 |
+
def greet(name: str) -> str:
|
| 284 |
+
return f"Hello, {name}!"
|
| 285 |
+
|
| 286 |
+
info = await get_fastmcp_info_v1(mcp)
|
| 287 |
+
|
| 288 |
+
assert info.name == "Test1x"
|
| 289 |
+
assert len(info.tools) == 2
|
| 290 |
+
tool_names = [tool.name for tool in info.tools]
|
| 291 |
+
assert "add_numbers" in tool_names
|
| 292 |
+
assert "greet" in tool_names
|
| 293 |
+
|
| 294 |
+
async def test_fastmcp1x_with_resources(self):
|
| 295 |
+
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has resources."""
|
| 296 |
+
mcp = FastMCP1x("Test1x")
|
| 297 |
+
|
| 298 |
+
@mcp.resource("resource://data")
|
| 299 |
+
def get_data() -> str:
|
| 300 |
+
return "Some data"
|
| 301 |
+
|
| 302 |
+
info = await get_fastmcp_info_v1(mcp)
|
| 303 |
+
|
| 304 |
+
assert info.name == "Test1x"
|
| 305 |
+
assert len(info.resources) == 1
|
| 306 |
+
resource_uris = [res.uri for res in info.resources]
|
| 307 |
+
assert "resource://data" in resource_uris
|
| 308 |
+
assert len(info.templates) == 0 # No templates added in this test
|
| 309 |
+
|
| 310 |
+
async def test_fastmcp1x_with_prompts(self):
|
| 311 |
+
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has prompts."""
|
| 312 |
+
mcp = FastMCP1x("Test1x")
|
| 313 |
+
|
| 314 |
+
@mcp.prompt("analyze")
|
| 315 |
+
def analyze_data(data: str) -> list:
|
| 316 |
+
return [{"role": "user", "content": f"Analyze: {data}"}]
|
| 317 |
+
|
| 318 |
+
info = await get_fastmcp_info_v1(mcp)
|
| 319 |
+
|
| 320 |
+
assert info.name == "Test1x"
|
| 321 |
+
assert len(info.prompts) == 1
|
| 322 |
+
prompt_names = [prompt.name for prompt in info.prompts]
|
| 323 |
+
assert "analyze" in prompt_names
|
| 324 |
+
|
| 325 |
+
async def test_dispatcher_with_fastmcp1x(self):
|
| 326 |
+
"""Test that the main get_fastmcp_info function correctly dispatches to v1."""
|
| 327 |
+
mcp = FastMCP1x("Test1x")
|
| 328 |
+
|
| 329 |
+
@mcp.tool()
|
| 330 |
+
def test_tool() -> str:
|
| 331 |
+
return "test"
|
| 332 |
+
|
| 333 |
+
info = await get_fastmcp_info(mcp)
|
| 334 |
+
|
| 335 |
+
assert info.name == "Test1x"
|
| 336 |
+
assert len(info.tools) == 1
|
| 337 |
+
tool_names = [tool.name for tool in info.tools]
|
| 338 |
+
assert "test_tool" in tool_names
|
| 339 |
+
assert len(info.templates) == 0 # No templates added in this test
|
| 340 |
+
|
| 341 |
+
async def test_dispatcher_with_fastmcp2x(self):
|
| 342 |
+
"""Test that the main get_fastmcp_info function correctly dispatches to v2."""
|
| 343 |
+
mcp = FastMCP("Test2x")
|
| 344 |
+
|
| 345 |
+
@mcp.tool
|
| 346 |
+
def test_tool() -> str:
|
| 347 |
+
return "test"
|
| 348 |
+
|
| 349 |
+
info = await get_fastmcp_info(mcp)
|
| 350 |
+
|
| 351 |
+
assert info.name == "Test2x"
|
| 352 |
+
assert len(info.tools) == 1
|
| 353 |
+
tool_names = [tool.name for tool in info.tools]
|
| 354 |
+
assert "test_tool" in tool_names
|
| 355 |
+
|
| 356 |
+
async def test_fastmcp1x_vs_fastmcp2x_comparison(self):
|
| 357 |
+
"""Test that both versions can be inspected and compared."""
|
| 358 |
+
mcp1x = FastMCP1x("Test1x")
|
| 359 |
+
mcp2x = FastMCP("Test2x")
|
| 360 |
+
|
| 361 |
+
@mcp1x.tool()
|
| 362 |
+
def tool1x() -> str:
|
| 363 |
+
return "1x"
|
| 364 |
+
|
| 365 |
+
@mcp2x.tool
|
| 366 |
+
def tool2x() -> str:
|
| 367 |
+
return "2x"
|
| 368 |
+
|
| 369 |
+
info1x = await get_fastmcp_info(mcp1x)
|
| 370 |
+
info2x = await get_fastmcp_info(mcp2x)
|
| 371 |
+
|
| 372 |
+
assert info1x.name == "Test1x"
|
| 373 |
+
assert info2x.name == "Test2x"
|
| 374 |
+
assert len(info1x.tools) == 1
|
| 375 |
+
assert len(info2x.tools) == 1
|
| 376 |
+
|
| 377 |
+
tool1x_names = [tool.name for tool in info1x.tools]
|
| 378 |
+
tool2x_names = [tool.name for tool in info2x.tools]
|
| 379 |
+
assert "tool1x" in tool1x_names
|
| 380 |
+
assert "tool2x" in tool2x_names
|
| 381 |
+
|
| 382 |
+
# Check server versions
|
| 383 |
+
assert info1x.server_version == "1.0"
|
| 384 |
+
assert info2x.server_version == fastmcp.__version__
|
| 385 |
+
|
| 386 |
+
# No templates added in these tests
|
| 387 |
+
assert len(info1x.templates) == 0
|
| 388 |
+
assert len(info2x.templates) == 0
|