Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/src/mcp_tef_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from mcp_tef_cli.models import (
DifferentiationRecommendationResponse,
HealthResponse,
MCPServerConfig,
ModelSettingsCreate,
OverlapMatrixResponse,
PaginatedTestCaseResponse,
Expand Down Expand Up @@ -174,7 +175,7 @@ async def create_test_case(
self,
name: str,
query: str,
available_mcp_servers: list[str],
available_mcp_servers: list[MCPServerConfig],
expected_mcp_server_url: str | None = None,
expected_tool_name: str | None = None,
expected_parameters: dict | None = None,
Expand All @@ -184,7 +185,7 @@ async def create_test_case(
Args:
name: Descriptive name for the test case
query: User query to evaluate
available_mcp_servers: List of MCP server URLs available for selection
available_mcp_servers: List of MCPServerConfig objects
expected_mcp_server_url: Expected MCP server URL (null for negative tests)
expected_tool_name: Expected tool name (null for negative tests)
expected_parameters: Expected parameters as dict
Expand Down
84 changes: 74 additions & 10 deletions cli/src/mcp_tef_cli/commands/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
EXIT_INVALID_ARGUMENTS,
EXIT_SUCCESS,
)
from mcp_tef_cli.models import PaginatedTestCaseResponse, TestCaseCreate, TestCaseResponse
from mcp_tef_cli.models import (
MCPServerConfig,
PaginatedTestCaseResponse,
TestCaseCreate,
TestCaseResponse,
)
from mcp_tef_cli.output import print_error, print_success
from mcp_tef_cli.utils import handle_api_errors, resolve_tef_url

Expand Down Expand Up @@ -168,7 +173,7 @@ def format_test_case_table(tc: TestCaseResponse, title: str = "Test Case") -> No
console.print(f"Expected Params: {json.dumps(tc.expected_parameters)}")
console.print("Available Servers:")
for server in tc.available_mcp_servers:
console.print(f" - {server}")
console.print(f" - {server.url} ({server.transport})")
console.print(f"Created: {tc.created_at}")
console.print(f"Updated: {tc.updated_at}")

Expand Down Expand Up @@ -275,6 +280,49 @@ def parse_set_option(values: tuple[str, ...]) -> dict[str, str]:
return result


def parse_server_spec(server_spec: str) -> MCPServerConfig:
"""Parse a server specification into MCPServerConfig.

Supports two formats:
- URL only: "http://localhost:3000/sse" (uses default transport)
- URL with transport: "http://localhost:3000/sse:sse"

Args:
server_spec: Server specification string

Returns:
MCPServerConfig object

Raises:
click.BadParameter: If format is invalid or transport is not recognized
"""
# Validate basic format
if not server_spec.startswith(("http://", "https://")):
raise click.BadParameter(
f"Invalid server URL: '{server_spec}'. Must start with http:// or https://"
)

# Check if transport is specified (format: url:transport)
# Use rsplit to handle URLs with ports (e.g., http://localhost:8080)
# We only treat it as transport if the part after the last ':' is a valid transport
parts = server_spec.rsplit(":", 1)

if len(parts) == 2:
url, potential_transport = parts
# Check if this is actually a transport spec or just part of the URL
# Valid transports are 'sse' or 'streamable-http'
if potential_transport in ("sse", "streamable-http"):
# Verify the URL part is still valid (starts with http:// or https://)
if not url.startswith(("http://", "https://")):
raise click.BadParameter(
f"Invalid URL in server spec: '{url}'. Must start with http:// or https://"
)
return MCPServerConfig(url=url, transport=potential_transport)

# No valid transport found, treat entire string as URL with default transport
return MCPServerConfig(url=server_spec)


@test_case.command(name="create")
@click.option("--name", default=None, help="Descriptive name for the test case")
@click.option("--query", default=None, help="User query to evaluate")
Expand All @@ -296,7 +344,12 @@ def parse_set_option(values: tuple[str, ...]) -> dict[str, str]:
@click.option(
"--servers",
default=None,
help="Comma-separated MCP server URLs available for selection",
help=(
"Comma-separated MCP server specifications. "
"Format: 'URL' or 'URL:transport'. "
"Transport defaults to 'streamable-http' if not specified. "
"Examples: 'http://localhost:3000/sse:sse' or 'http://localhost:3001'"
),
)
@click.option(
"--from-file",
Expand Down Expand Up @@ -367,7 +420,10 @@ def create(
"name": "Test case name", // required
"query": "User query to evaluate", // required
"available_mcp_servers": [ // required, non-empty
"${MCP_SERVER_URL}" // supports variable substitution
{
"url": "${MCP_SERVER_URL}", // required, supports variable substitution
"transport": "streamable-http" // optional, defaults to "streamable-http"
}
],
"expected_mcp_server_url": "...", // optional (null for negative tests)
"expected_tool_name": "tool_name", // optional (must pair with server)
Expand All @@ -383,20 +439,27 @@ def create(
Examples:

\b
# Create test case with expected tool
# Create test case with expected tool (SSE transport)
mtef test-case create \\
--name "Weather test" \\
--query "What is the weather in San Francisco?" \\
--expected-server "http://localhost:3000/sse" \\
--expected-tool "get_weather" \\
--servers "http://localhost:3000/sse"
--servers "http://localhost:3000/sse:sse"

\b
# Create test case with multiple servers (mixed transports)
mtef test-case create \\
--name "Multi-server test" \\
--query "Get my calendar events" \\
--servers "http://localhost:3000:sse,http://localhost:3001"

\b
# Create negative test case (no tool should be selected)
mtef test-case create \\
--name "No tool needed" \\
--query "What is 2 + 2?" \\
--servers "http://localhost:3000/sse"
--servers "http://localhost:3000/sse:sse"

\b
# Create from JSON file (single or multiple test cases)
Expand Down Expand Up @@ -447,8 +510,9 @@ def create(
print_error("--servers is required (or use --from-file)")
raise SystemExit(EXIT_INVALID_ARGUMENTS)

# Parse servers from comma-separated string
server_urls = [url.strip() for url in servers.split(",") if url.strip()]
# Parse servers from comma-separated string and convert to MCPServerConfig
server_specs = [spec.strip() for spec in servers.split(",") if spec.strip()]
server_configs = [parse_server_spec(spec) for spec in server_specs]

# Parse expected parameters JSON
try:
Expand All @@ -463,7 +527,7 @@ def create(
TestCaseCreate(
name=name,
query=query,
available_mcp_servers=server_urls,
available_mcp_servers=server_configs,
expected_mcp_server_url=expected_server,
expected_tool_name=expected_tool,
expected_parameters=expected_parameters,
Expand Down
87 changes: 75 additions & 12 deletions cli/src/mcp_tef_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator

__all__ = [
"HealthResponse",
Expand All @@ -18,6 +18,7 @@
"ToolQualityResult",
"ToolQualityResponse",
# Test case models
"MCPServerConfig",
"TestCaseCreate",
"TestCaseResponse",
"PaginatedTestCaseResponse",
Expand Down Expand Up @@ -102,6 +103,27 @@ class ToolQualityResponse(BaseModel):
# =============================================================================


class MCPServerConfig(BaseModel):
"""MCP server configuration with transport type.

Note: This model is vendored (copied) from src/mcp_tef/models/schemas.py
to avoid requiring the full server package as a CLI dependency.
Keep in sync with the main model for consistency.
"""

url: str = Field(
...,
min_length=1,
pattern=r"^https?://",
description="Server URL (must be http or https)",
)
transport: str = Field(
default="streamable-http",
pattern=r"^(sse|streamable-http)$",
description="Transport type: 'sse' or 'streamable-http'",
)


class ToolDefinition(BaseModel):
"""Definition of a tool available from an MCP server."""

Expand All @@ -123,10 +145,30 @@ class TestCaseCreate(BaseModel):
expected_parameters: dict | None = Field(
default=None, description="Expected parameters as JSON object"
)
available_mcp_servers: list[str] = Field(
..., description="MCP server URLs available for selection", min_length=1
available_mcp_servers: list[MCPServerConfig] = Field(
..., description="MCP server configurations available for selection", min_length=1
)

@field_validator("available_mcp_servers", mode="before")
@classmethod
def normalize_available_mcp_servers(cls, v: Any) -> list[Any]:
"""Convert string URLs to MCPServerConfig objects for convenience."""
if not isinstance(v, list):
return v

normalized = []
for item in v:
if isinstance(item, str):
# Convert string URL to MCPServerConfig dict
normalized.append({"url": item, "transport": "streamable-http"})
elif isinstance(item, dict):
# Already a dict, pass through (will be validated as MCPServerConfig)
normalized.append(item)
else:
# Already a MCPServerConfig object or other type
normalized.append(item)
return normalized

@model_validator(mode="after")
def validate_expected_tool_fields(self) -> "TestCaseCreate":
"""Validate cross-field constraints for expected tool configuration."""
Expand All @@ -138,14 +180,13 @@ def validate_expected_tool_fields(self) -> "TestCaseCreate":
)

# expected_server must be in available_mcp_servers
if (
self.expected_mcp_server_url
and self.expected_mcp_server_url not in self.available_mcp_servers
):
raise ValueError(
f"expected_mcp_server_url '{self.expected_mcp_server_url}' "
"must be in available_mcp_servers"
)
if self.expected_mcp_server_url:
available_urls = [server.url for server in self.available_mcp_servers]
if self.expected_mcp_server_url not in available_urls:
raise ValueError(
f"expected_mcp_server_url '{self.expected_mcp_server_url}' "
"must be in available_mcp_servers"
)

# expected_parameters requires expected_tool_name
if self.expected_parameters and not self.expected_tool_name:
Expand All @@ -163,13 +204,35 @@ class TestCaseResponse(BaseModel):
expected_mcp_server_url: str | None = Field(default=None, description="Expected MCP server URL")
expected_tool_name: str | None = Field(default=None, description="Expected tool name")
expected_parameters: dict | None = Field(default=None, description="Expected parameters")
available_mcp_servers: list[str] = Field(..., description="Available MCP servers")
available_mcp_servers: list[MCPServerConfig] = Field(
..., description="Available MCP server configurations"
)
available_tools: dict[str, list[ToolDefinition]] | None = Field(
default=None, description="Available tools by server URL"
)
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")

@field_validator("available_mcp_servers", mode="before")
@classmethod
def normalize_available_mcp_servers(cls, v: Any) -> list[Any]:
"""Convert string URLs to MCPServerConfig objects for convenience."""
if not isinstance(v, list):
return v

normalized = []
for item in v:
if isinstance(item, str):
# Convert string URL to MCPServerConfig dict
normalized.append({"url": item, "transport": "streamable-http"})
elif isinstance(item, dict):
# Already a dict, pass through (will be validated as MCPServerConfig)
normalized.append(item)
else:
# Already a MCPServerConfig object or other type
normalized.append(item)
return normalized


class PaginatedTestCaseResponse(BaseModel):
"""Paginated test case response."""
Expand Down
17 changes: 11 additions & 6 deletions cli/tests/unit/test_test_case_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ def test_load_with_env_var_substitution(self, tmp_path):
)

assert len(test_cases) == 1
assert test_cases[0].available_mcp_servers == ["http://localhost:3000/sse"]
assert len(test_cases[0].available_mcp_servers) == 1
assert test_cases[0].available_mcp_servers[0].url == "http://localhost:3000/sse"
assert test_cases[0].expected_mcp_server_url == "http://localhost:3000/sse"

def test_load_multiple_with_env_vars(self, tmp_path):
Expand All @@ -404,8 +405,10 @@ def test_load_multiple_with_env_vars(self, tmp_path):
)

assert len(test_cases) == 2
assert test_cases[0].available_mcp_servers == ["http://server:8000"]
assert test_cases[1].available_mcp_servers == ["http://server:8000"]
assert len(test_cases[0].available_mcp_servers) == 1
assert test_cases[0].available_mcp_servers[0].url == "http://server:8000"
assert len(test_cases[1].available_mcp_servers) == 1
assert test_cases[1].available_mcp_servers[0].url == "http://server:8000"

def test_load_with_unresolved_var_fails_validation(self, tmp_path):
"""Unresolved variable that results in invalid URL fails validation."""
Expand All @@ -420,8 +423,9 @@ def test_load_with_unresolved_var_fails_validation(self, tmp_path):

# The unresolved ${UNDEFINED} is kept as-is, which is a valid string
# but may not be a valid URL depending on use case
test_cases = load_test_cases_from_file(str(file_path), env_vars={})
assert test_cases[0].available_mcp_servers == ["${UNDEFINED}"]
# This should fail validation because ${UNDEFINED} doesn't match the URL pattern
with pytest.raises(BadParameter):
load_test_cases_from_file(str(file_path), env_vars={})

def test_load_from_os_env(self, tmp_path, monkeypatch):
"""Variables are resolved from OS environment."""
Expand All @@ -437,4 +441,5 @@ def test_load_from_os_env(self, tmp_path, monkeypatch):

test_cases = load_test_cases_from_file(str(file_path))

assert test_cases[0].available_mcp_servers == ["http://from-os:9000"]
assert len(test_cases[0].available_mcp_servers) == 1
assert test_cases[0].available_mcp_servers[0].url == "http://from-os:9000"
2 changes: 1 addition & 1 deletion docs/current-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ class MCPServerResponse(BaseModel):
id: str
name: str
url: str
transport: str # 'sse' or 'streamable_http'
transport: str # 'sse' or 'streamable-http'
status: str # 'active', 'failed', 'inactive'
last_connected_at: datetime | None
created_at: datetime
Expand Down
2 changes: 1 addition & 1 deletion docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ This document defines all entities, relationships, and data structures for the M
| id | TEXT | PK | UUID identifier |
| name | TEXT | NOT NULL, UNIQUE | Server name |
| url | TEXT | NOT NULL, UNIQUE | Server connection URL |
| transport | TEXT | NOT NULL | Connection type ('sse', 'streamable_http') |
| transport | TEXT | NOT NULL | Connection type ('sse', 'streamable-http') |
| status | TEXT | NOT NULL, DEFAULT 'inactive' | Status ('active', 'failed', 'inactive') |
| last_connected_at | TIMESTAMP | NULL | Last successful connection |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | Creation timestamp |
Expand Down
4 changes: 2 additions & 2 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ components:
format: uri
transport:
type: string
enum: [sse, streamable_http]
enum: [sse, streamable-http]

MCPServerUpdate:
type: object
Expand All @@ -654,7 +654,7 @@ components:
format: uri
transport:
type: string
enum: [sse, streamable_http]
enum: [sse, streamable-http]

MCPServerResponse:
type: object
Expand Down
Loading