mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 10:18:08 +02:00
Merge branch 'main' into claude/issue-1625-20250826-0138
This commit is contained in:
commit
36760418c2
53 changed files with 1505 additions and 336 deletions
|
|
@ -509,6 +509,42 @@ class TestInspectCommand:
|
|||
# Output is parsed as a Path object
|
||||
assert bound.arguments["output"] == output_file
|
||||
|
||||
async def test_inspect_command_text_summary(self, tmp_path, capsys):
|
||||
"""Test inspect command with no format shows text summary."""
|
||||
# Create a real server file
|
||||
server_file = tmp_path / "test_server.py"
|
||||
server_file.write_text("""
|
||||
import fastmcp
|
||||
|
||||
mcp = fastmcp.FastMCP("InspectTestServer", instructions="Test instructions", version="1.0.0")
|
||||
|
||||
@mcp.tool
|
||||
def test_tool(x: int) -> int:
|
||||
return x * 2
|
||||
""")
|
||||
|
||||
# Parse and execute the command without format or output
|
||||
command, bound, _ = app.parse_args(
|
||||
[
|
||||
"inspect",
|
||||
str(server_file),
|
||||
]
|
||||
)
|
||||
|
||||
await command(**bound.arguments)
|
||||
|
||||
# Check the console output
|
||||
captured = capsys.readouterr()
|
||||
# Check for the table format output
|
||||
assert "InspectTestServer" in captured.out
|
||||
assert "Test instructions" in captured.out
|
||||
assert "1.0.0" in captured.out
|
||||
assert "Tools" in captured.out
|
||||
assert "1" in captured.out # number of tools
|
||||
assert "FastMCP" in captured.out
|
||||
assert "MCP" in captured.out
|
||||
assert "Use --format [fastmcp|mcp] for complete JSON output" in captured.out
|
||||
|
||||
async def test_inspect_command_with_real_server(self, tmp_path):
|
||||
"""Test inspect command with a real server file."""
|
||||
# Create a real server file
|
||||
|
|
@ -529,11 +565,13 @@ def test_prompt(name: str) -> str:
|
|||
|
||||
output_file = tmp_path / "inspect_output.json"
|
||||
|
||||
# Parse and execute the command
|
||||
# Parse and execute the command with format and output file
|
||||
command, bound, _ = app.parse_args(
|
||||
[
|
||||
"inspect",
|
||||
str(server_file),
|
||||
"--format",
|
||||
"fastmcp",
|
||||
"--output",
|
||||
str(output_file),
|
||||
]
|
||||
|
|
@ -545,7 +583,10 @@ def test_prompt(name: str) -> str:
|
|||
assert output_file.exists()
|
||||
content = output_file.read_text()
|
||||
|
||||
# Basic checks that the inspection worked
|
||||
assert "InspectTestServer" in content
|
||||
assert "test_tool" in content
|
||||
assert "test_prompt" in content
|
||||
# Basic checks that the fastmcp format worked
|
||||
import json
|
||||
|
||||
data = json.loads(content)
|
||||
assert data["server"]["name"] == "InspectTestServer"
|
||||
assert len(data["tools"]) == 1
|
||||
assert len(data["prompts"]) == 1
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ class TestInstallCursor:
|
|||
file=Path("/path/to/server.py"),
|
||||
server_object="custom_app",
|
||||
name="test-server",
|
||||
with_editable=editable_path,
|
||||
with_editable=[editable_path],
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
|
@ -328,7 +328,7 @@ class TestCursorCommand:
|
|||
file=Path("server.py"),
|
||||
server_object=None,
|
||||
name="test-server",
|
||||
with_editable=None,
|
||||
with_editable=[],
|
||||
with_packages=[],
|
||||
env_vars={},
|
||||
python_version=None,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ class TestAzureProvider:
|
|||
parsed_token = urlparse(provider._upstream_token_endpoint)
|
||||
assert "87654321-4321-4321-4321-210987654321" in parsed_token.path
|
||||
|
||||
def test_init_with_env_vars(self):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes_env",
|
||||
[
|
||||
"User.Read,Calendar.Read",
|
||||
'["User.Read", "Calendar.Read"]',
|
||||
],
|
||||
)
|
||||
def test_init_with_env_vars(self, scopes_env):
|
||||
"""Test AzureProvider initialization from environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
|
|
@ -40,7 +47,7 @@ class TestAzureProvider:
|
|||
"FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET": "env-secret",
|
||||
"FASTMCP_SERVER_AUTH_AZURE_TENANT_ID": "env-tenant-id",
|
||||
"FASTMCP_SERVER_AUTH_AZURE_BASE_URL": "https://envserver.com",
|
||||
"FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": '["User.Read", "Calendar.Read"]',
|
||||
"FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": scopes_env,
|
||||
},
|
||||
):
|
||||
provider = AzureProvider()
|
||||
|
|
@ -48,6 +55,10 @@ class TestAzureProvider:
|
|||
assert provider._upstream_client_id == "env-client-id"
|
||||
assert provider._upstream_client_secret.get_secret_value() == "env-secret"
|
||||
assert str(provider.base_url) == "https://envserver.com/"
|
||||
assert provider._token_validator.required_scopes == [
|
||||
"User.Read",
|
||||
"Calendar.Read",
|
||||
]
|
||||
# Check tenant is in the endpoints
|
||||
parsed_auth = urlparse(provider._upstream_authorization_endpoint)
|
||||
assert "env-tenant-id" in parsed_auth.path
|
||||
|
|
|
|||
|
|
@ -83,7 +83,14 @@ class TestGitHubProvider:
|
|||
) # URLs get normalized with trailing slash
|
||||
assert provider._redirect_path == "/custom/callback"
|
||||
|
||||
def test_init_with_env_vars(self):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes_env",
|
||||
[
|
||||
"user,repo",
|
||||
'["user", "repo"]',
|
||||
],
|
||||
)
|
||||
def test_init_with_env_vars(self, scopes_env):
|
||||
"""Test initialization with environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
|
|
@ -91,6 +98,7 @@ class TestGitHubProvider:
|
|||
"FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id",
|
||||
"FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret",
|
||||
"FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://env-example.com",
|
||||
"FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES": scopes_env,
|
||||
},
|
||||
):
|
||||
provider = GitHubProvider()
|
||||
|
|
@ -98,6 +106,7 @@ class TestGitHubProvider:
|
|||
assert provider._upstream_client_id == "env_client_id"
|
||||
assert provider._upstream_client_secret.get_secret_value() == "env_secret"
|
||||
assert str(provider.base_url) == "https://env-example.com/"
|
||||
assert provider._token_validator.required_scopes == ["user", "repo"]
|
||||
|
||||
def test_init_explicit_overrides_env(self):
|
||||
"""Test that explicit parameters override environment variables."""
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ class TestGoogleProvider:
|
|||
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
|
||||
assert str(provider.base_url) == "https://myserver.com/"
|
||||
|
||||
def test_init_with_env_vars(self):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes_env",
|
||||
[
|
||||
"openid,https://www.googleapis.com/auth/userinfo.email",
|
||||
'["openid", "https://www.googleapis.com/auth/userinfo.email"]',
|
||||
],
|
||||
)
|
||||
def test_init_with_env_vars(self, scopes_env):
|
||||
"""Test GoogleProvider initialization from environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
|
|
@ -32,7 +39,7 @@ class TestGoogleProvider:
|
|||
"FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID": "env123.apps.googleusercontent.com",
|
||||
"FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET": "GOCSPX-env456",
|
||||
"FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL": "https://envserver.com",
|
||||
"FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES": '["openid", "https://www.googleapis.com/auth/userinfo.email"]',
|
||||
"FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES": scopes_env,
|
||||
},
|
||||
):
|
||||
provider = GoogleProvider()
|
||||
|
|
@ -42,6 +49,10 @@ class TestGoogleProvider:
|
|||
provider._upstream_client_secret.get_secret_value() == "GOCSPX-env456"
|
||||
)
|
||||
assert str(provider.base_url) == "https://envserver.com/"
|
||||
assert provider._token_validator.required_scopes == [
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
]
|
||||
|
||||
def test_init_missing_client_id_raises_error(self):
|
||||
"""Test that missing client_id raises ValueError."""
|
||||
|
|
|
|||
|
|
@ -26,7 +26,14 @@ class TestWorkOSProvider:
|
|||
assert provider._upstream_client_secret.get_secret_value() == "secret_test456"
|
||||
assert str(provider.base_url) == "https://myserver.com/"
|
||||
|
||||
def test_init_with_env_vars(self):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes_env",
|
||||
[
|
||||
"openid,email",
|
||||
'["openid", "email"]',
|
||||
],
|
||||
)
|
||||
def test_init_with_env_vars(self, scopes_env):
|
||||
"""Test WorkOSProvider initialization from environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
|
|
@ -35,7 +42,7 @@ class TestWorkOSProvider:
|
|||
"FASTMCP_SERVER_AUTH_WORKOS_CLIENT_SECRET": "env_secret",
|
||||
"FASTMCP_SERVER_AUTH_WORKOS_AUTHKIT_DOMAIN": "https://env.authkit.app",
|
||||
"FASTMCP_SERVER_AUTH_WORKOS_BASE_URL": "https://envserver.com",
|
||||
"FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES": '["openid", "email"]',
|
||||
"FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES": scopes_env,
|
||||
},
|
||||
):
|
||||
provider = WorkOSProvider()
|
||||
|
|
@ -43,6 +50,10 @@ class TestWorkOSProvider:
|
|||
assert provider._upstream_client_id == "env_client"
|
||||
assert provider._upstream_client_secret.get_secret_value() == "env_secret"
|
||||
assert str(provider.base_url) == "https://envserver.com/"
|
||||
assert provider._token_validator.required_scopes == [
|
||||
"openid",
|
||||
"email",
|
||||
]
|
||||
|
||||
def test_init_missing_client_id_raises_error(self):
|
||||
"""Test that missing client_id raises ValueError."""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import fastmcp
|
|||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.utilities.inspect import (
|
||||
FastMCPInfo,
|
||||
InspectFormat,
|
||||
ToolInfo,
|
||||
format_fastmcp_info,
|
||||
format_info,
|
||||
format_mcp_info,
|
||||
inspect_fastmcp,
|
||||
inspect_fastmcp_v1,
|
||||
)
|
||||
|
|
@ -20,14 +24,22 @@ class TestFastMCPInfo:
|
|||
def test_fastmcp_info_creation(self):
|
||||
"""Test that FastMCPInfo can be created with all required fields."""
|
||||
tool = ToolInfo(
|
||||
key="tool1", name="tool1", description="Test tool", input_schema={}
|
||||
key="tool1",
|
||||
name="tool1",
|
||||
description="Test tool",
|
||||
input_schema={},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
info = FastMCPInfo(
|
||||
name="TestServer",
|
||||
instructions="Test instructions",
|
||||
fastmcp_version="1.0.0",
|
||||
mcp_version="1.0.0",
|
||||
server_version="1.0.0",
|
||||
server_generation=2,
|
||||
version="1.0.0",
|
||||
tools=[tool],
|
||||
prompts=[],
|
||||
resources=[],
|
||||
|
|
@ -39,7 +51,8 @@ class TestFastMCPInfo:
|
|||
assert info.instructions == "Test instructions"
|
||||
assert info.fastmcp_version == "1.0.0"
|
||||
assert info.mcp_version == "1.0.0"
|
||||
assert info.server_version == "1.0.0"
|
||||
assert info.server_generation == 2
|
||||
assert info.version == "1.0.0"
|
||||
assert len(info.tools) == 1
|
||||
assert info.tools[0].name == "tool1"
|
||||
assert info.capabilities == {"tools": {"listChanged": True}}
|
||||
|
|
@ -51,7 +64,8 @@ class TestFastMCPInfo:
|
|||
instructions=None,
|
||||
fastmcp_version="1.0.0",
|
||||
mcp_version="1.0.0",
|
||||
server_version="1.0.0",
|
||||
server_generation=2,
|
||||
version="1.0.0",
|
||||
tools=[],
|
||||
prompts=[],
|
||||
resources=[],
|
||||
|
|
@ -75,7 +89,8 @@ class TestGetFastMCPInfo:
|
|||
assert info.instructions is None
|
||||
assert info.fastmcp_version == fastmcp.__version__
|
||||
assert info.mcp_version == importlib.metadata.version("mcp")
|
||||
assert info.server_version is None
|
||||
assert info.server_generation == 2 # v2 server
|
||||
assert info.version is None
|
||||
assert info.tools == []
|
||||
assert info.prompts == []
|
||||
assert info.resources == []
|
||||
|
|
@ -95,7 +110,7 @@ class TestGetFastMCPInfo:
|
|||
"""Test get_fastmcp_info with a server that has a version."""
|
||||
mcp = FastMCP("VersionServer", version="1.2.3")
|
||||
info = await inspect_fastmcp(mcp)
|
||||
assert info.server_version == "1.2.3"
|
||||
assert info.version == "1.2.3"
|
||||
|
||||
async def test_server_with_tools(self):
|
||||
"""Test get_fastmcp_info with a server that has tools."""
|
||||
|
|
@ -266,9 +281,10 @@ class TestFastMCP1xCompatibility:
|
|||
|
||||
assert info.name == "Test1x"
|
||||
assert info.instructions is None
|
||||
assert info.fastmcp_version == importlib.metadata.version("mcp")
|
||||
assert info.fastmcp_version == fastmcp.__version__ # CLI version
|
||||
assert info.mcp_version == importlib.metadata.version("mcp")
|
||||
assert info.server_version is None
|
||||
assert info.server_generation == 1 # v1 server
|
||||
assert info.version is None
|
||||
assert info.tools == []
|
||||
assert info.prompts == []
|
||||
assert info.resources == []
|
||||
|
|
@ -310,6 +326,7 @@ class TestFastMCP1xCompatibility:
|
|||
resource_uris = [res.uri for res in info.resources]
|
||||
assert "resource://data" in resource_uris
|
||||
assert len(info.templates) == 0 # No templates added in this test
|
||||
assert info.server_generation == 1 # v1 server
|
||||
|
||||
async def test_fastmcp1x_with_prompts(self):
|
||||
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has prompts."""
|
||||
|
|
@ -341,6 +358,7 @@ class TestFastMCP1xCompatibility:
|
|||
tool_names = [tool.name for tool in info.tools]
|
||||
assert "test_tool" in tool_names
|
||||
assert len(info.templates) == 0 # No templates added in this test
|
||||
assert info.server_generation == 1 # v1 server
|
||||
|
||||
async def test_dispatcher_with_fastmcp2x(self):
|
||||
"""Test that the main get_fastmcp_info function correctly dispatches to v2."""
|
||||
|
|
@ -384,9 +402,191 @@ class TestFastMCP1xCompatibility:
|
|||
assert "tool2x" in tool2x_names
|
||||
|
||||
# Check server versions
|
||||
assert info1x.server_version is None
|
||||
assert info2x.server_version is None
|
||||
assert info1x.server_generation == 1 # v1
|
||||
assert info2x.server_generation == 2 # v2
|
||||
assert info1x.version is None
|
||||
assert info2x.version is None
|
||||
|
||||
# No templates added in these tests
|
||||
assert len(info1x.templates) == 0
|
||||
assert len(info2x.templates) == 0
|
||||
|
||||
|
||||
class TestFormatFunctions:
|
||||
"""Tests for the formatting functions."""
|
||||
|
||||
async def test_format_fastmcp_info(self):
|
||||
"""Test formatting as FastMCP-specific JSON."""
|
||||
mcp = FastMCP("TestServer", instructions="Test instructions", version="1.2.3")
|
||||
|
||||
@mcp.tool
|
||||
def test_tool(x: int) -> dict:
|
||||
"""A test tool."""
|
||||
return {"result": x * 2}
|
||||
|
||||
info = await inspect_fastmcp(mcp)
|
||||
json_bytes = await format_fastmcp_info(info)
|
||||
|
||||
# Verify it's valid JSON
|
||||
import json
|
||||
|
||||
data = json.loads(json_bytes)
|
||||
|
||||
# Check FastMCP-specific fields are present
|
||||
assert "server" in data
|
||||
assert data["server"]["name"] == "TestServer"
|
||||
assert data["server"]["instructions"] == "Test instructions"
|
||||
assert data["server"]["generation"] == 2 # v2 server
|
||||
assert data["server"]["version"] == "1.2.3"
|
||||
assert "capabilities" in data["server"]
|
||||
|
||||
# Check environment information
|
||||
assert "environment" in data
|
||||
assert data["environment"]["fastmcp"] == fastmcp.__version__
|
||||
assert data["environment"]["mcp"] == importlib.metadata.version("mcp")
|
||||
|
||||
# Check tools
|
||||
assert len(data["tools"]) == 1
|
||||
assert data["tools"][0]["name"] == "test_tool"
|
||||
assert data["tools"][0]["enabled"] is True
|
||||
assert "tags" in data["tools"][0]
|
||||
|
||||
async def test_format_mcp_info(self):
|
||||
"""Test formatting as MCP protocol JSON."""
|
||||
mcp = FastMCP("TestServer", instructions="Test instructions", version="2.0.0")
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
@mcp.prompt
|
||||
def test_prompt(name: str) -> list:
|
||||
"""Test prompt."""
|
||||
return [{"role": "user", "content": f"Hello {name}"}]
|
||||
|
||||
json_bytes = await format_mcp_info(mcp)
|
||||
|
||||
# Verify it's valid JSON
|
||||
import json
|
||||
|
||||
data = json.loads(json_bytes)
|
||||
|
||||
# Check MCP protocol structure with camelCase
|
||||
assert "serverInfo" in data
|
||||
assert data["serverInfo"]["name"] == "TestServer"
|
||||
|
||||
# Check server version in MCP format
|
||||
assert data["serverInfo"]["version"] == "2.0.0"
|
||||
|
||||
# MCP format SHOULD have environment fields
|
||||
assert "environment" in data
|
||||
assert data["environment"]["fastmcp"] == fastmcp.__version__
|
||||
assert data["environment"]["mcp"] == importlib.metadata.version("mcp")
|
||||
assert "capabilities" in data
|
||||
|
||||
assert "tools" in data
|
||||
assert "prompts" in data
|
||||
assert "resources" in data
|
||||
assert "resourceTemplates" in data
|
||||
|
||||
# Check tools have MCP format (camelCase fields)
|
||||
assert len(data["tools"]) == 1
|
||||
assert data["tools"][0]["name"] == "add"
|
||||
assert "inputSchema" in data["tools"][0]
|
||||
|
||||
# FastMCP-specific fields should not be present
|
||||
assert "tags" not in data["tools"][0]
|
||||
assert "enabled" not in data["tools"][0]
|
||||
|
||||
async def test_format_info_with_fastmcp_format(self):
|
||||
"""Test format_info with fastmcp format."""
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
def test() -> str:
|
||||
return "test"
|
||||
|
||||
# Test with string format
|
||||
json_bytes = await format_info(mcp, "fastmcp")
|
||||
import json
|
||||
|
||||
data = json.loads(json_bytes)
|
||||
assert data["server"]["name"] == "TestServer"
|
||||
assert "tags" in data["tools"][0] # FastMCP-specific field
|
||||
|
||||
# Test with enum format
|
||||
json_bytes = await format_info(mcp, InspectFormat.FASTMCP)
|
||||
data = json.loads(json_bytes)
|
||||
assert data["server"]["name"] == "TestServer"
|
||||
|
||||
async def test_format_info_with_mcp_format(self):
|
||||
"""Test format_info with mcp format."""
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
def test() -> str:
|
||||
return "test"
|
||||
|
||||
json_bytes = await format_info(mcp, "mcp")
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(json_bytes)
|
||||
assert "serverInfo" in data
|
||||
assert "tools" in data
|
||||
assert "inputSchema" in data["tools"][0] # MCP uses camelCase
|
||||
|
||||
async def test_format_info_requires_format(self):
|
||||
"""Test that format_info requires a format parameter."""
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool
|
||||
def test() -> str:
|
||||
return "test"
|
||||
|
||||
# Should work with valid formats
|
||||
json_bytes = await format_info(mcp, "fastmcp")
|
||||
assert json_bytes
|
||||
|
||||
json_bytes = await format_info(mcp, "mcp")
|
||||
assert json_bytes
|
||||
|
||||
# Should fail with invalid format
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="not a valid InspectFormat"):
|
||||
await format_info(mcp, "invalid") # type: ignore
|
||||
|
||||
async def test_tool_with_output_schema(self):
|
||||
"""Test that output_schema is properly extracted and included."""
|
||||
mcp = FastMCP("TestServer")
|
||||
|
||||
@mcp.tool(
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {"type": "number"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
}
|
||||
)
|
||||
def compute(x: int) -> dict:
|
||||
"""Compute something."""
|
||||
return {"result": x * 2, "message": f"Doubled {x}"}
|
||||
|
||||
info = await inspect_fastmcp(mcp)
|
||||
|
||||
# Check output_schema is captured
|
||||
assert len(info.tools) == 1
|
||||
assert info.tools[0].output_schema is not None
|
||||
assert info.tools[0].output_schema["type"] == "object"
|
||||
assert "result" in info.tools[0].output_schema["properties"]
|
||||
|
||||
# Verify it's included in FastMCP format
|
||||
json_bytes = await format_fastmcp_info(info)
|
||||
import json
|
||||
|
||||
data = json.loads(json_bytes)
|
||||
# Tools are at the top level, not nested
|
||||
assert data["tools"][0]["output_schema"]["type"] == "object"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue