Merge branch 'main' into switch-kvstore

This commit is contained in:
William Easton 2025-09-29 20:17:05 -05:00 committed by GitHub
commit fc2a5c3b8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 172 additions and 153 deletions

View file

@ -17,6 +17,7 @@ jobs:
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
@ -29,21 +30,22 @@ jobs:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Create dedupe prompt
- name: Set dedupe prompt
id: dedupe-prompt
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/dedupe-prompt.txt << 'EOF'
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
Find up to 3 likely duplicate issues for GitHub issue ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}.
Follow these steps precisely:
1. Use the Task tool to check if the GitHub issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
1. Check if the GitHub issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. Use the Task tool to view the GitHub issue, and ask the agent to return a summary of the issue
2. View the GitHub issue and produce a summary of the issue
3. Then, launch 3 parallel agents using the Task tool to search GitHub for duplicates of this issue, using diverse keywords and search approaches, using the summary from step 2
4. Next, feed the results from steps 2 and 3 into another agent using the Task tool, so that it can filter out false positives that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
4. Next, consider the results from steps 2 and 3 and filter out false positives that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates). If there are no duplicates, DO NOT COMMENT. Just exit.
@ -51,6 +53,7 @@ jobs:
- Use `gh` to interact with GitHub, rather than web fetch
- Do not use other tools, beyond `gh` and Task (eg. don't use other MCP servers, file edit, etc.)
- Make a todo list first
- Never include this issue as a duplicate of itself
For your comment, follow this format precisely (example with 3 suspected duplicates):
@ -67,14 +70,20 @@ jobs:
- To prevent auto-closure, add a comment or 👎 this comment
---
PROMPT_END
EOF
- name: Run Marvin dedupe command
uses: anthropics/claude-code-base-action@beta
uses: anthropics/claude-code-action@v1
with:
model: claude-3-5-haiku-latest
prompt_file: /tmp/claude-prompts/dedupe-prompt.txt
allowed_tools: "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task"
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
claude_env: |
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task
settings: |
{
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}

View file

@ -24,6 +24,7 @@ jobs:
contents: read
issues: write
pull-requests: write
id-token: write
steps:
- name: Checkout base repository
@ -39,10 +40,11 @@ jobs:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Create triage prompt
- name: Set triage prompt
id: triage-prompt
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/triage-prompt.txt << 'EOF'
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients. Your task is to analyze issues/PRs and apply appropriate labels.
IMPORTANT: Your ONLY action should be to apply labels using mcp__github__update_issue. DO NOT post any comments.
@ -119,6 +121,7 @@ jobs:
4. Apply selected labels:
Use mcp__github__update_issue to apply your selected labels
DO NOT post any comments
PROMPT_END
EOF
- name: Setup GitHub MCP Server
@ -146,12 +149,17 @@ jobs:
EOF
- name: Run Marvin for Issue Triage
uses: anthropics/claude-code-base-action@beta
uses: anthropics/claude-code-action@v1
with:
prompt_file: /tmp/claude-prompts/triage-prompt.txt
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files"
timeout_minutes: "5"
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
mcp_config: /tmp/mcp-config/mcp-servers.json
claude_env: |
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files
--mcp-config /tmp/mcp-config/mcp-servers.json
settings: |
{
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}

View file

@ -59,13 +59,14 @@ jobs:
# Marvin Assistant
- name: Run Marvin
uses: anthropics/claude-code-action@beta
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
mode: tag
trigger_phrase: "/marvin"
allowed_bots: "*"
allowed_tools: "WebSearch,WebFetch,Bash(uv:*),Bash(pre-commit:*),Bash(pytest:*),Bash(ruff:*),Bash(ty:*),Bash(git:*),Bash(gh:*),mcp__github__add_issue_comment,mcp__github__create_issue,mcp__github__get_issue,mcp__github__list_issues,mcp__github__search_issues,mcp__github__update_issue,mcp__github__update_issue_comment,mcp__github__create_pull_request,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__list_pull_requests,mcp__github__update_pull_request,mcp__github__update_pull_request_branch,mcp__github__update_pull_request_comment,mcp__github__merge_pull_request"
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools WebSearch,WebFetch,Bash(uv:*),Bash(pre-commit:*),Bash(pytest:*),Bash(ruff:*),Bash(ty:*),Bash(git:*),Bash(gh:*),mcp__github__add_issue_comment,mcp__github__create_issue,mcp__github__get_issue,mcp__github__list_issues,mcp__github__search_issues,mcp__github__update_issue,mcp__github__update_issue_comment,mcp__github__create_pull_request,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__list_pull_requests,mcp__github__update_pull_request,mcp__github__update_pull_request_branch,mcp__github__update_pull_request_comment,mcp__github__merge_pull_request
additional_permissions: |
actions: read

View file

@ -48,10 +48,10 @@ jobs:
run: uv sync --frozen
- name: Run tests (excluding integration and client_process)
run: uv run pytest --inline-snapshot=disable -v tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
run: uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
- name: Run client process tests separately
run: uv run pytest --inline-snapshot=disable -v tests -m "client_process" -x
run: uv run pytest --inline-snapshot=disable tests -m "client_process" -x
run_integration_tests:
name: "Run integration tests"
@ -74,7 +74,7 @@ jobs:
- name: Run integration tests
# use longer per-test timeout than the default 3s
run: uv run pytest -v tests -m "integration" --timeout=15 --numprocesses auto --maxprocesses 2 --dist worksteal
run: uv run pytest tests -m "integration" --timeout=15 --numprocesses auto --maxprocesses 2 --dist worksteal
env:
FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }}
FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }}

View file

@ -250,6 +250,7 @@
"requirements": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -267,6 +268,7 @@
"project": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -285,6 +287,7 @@
"anyOf": [
{
"items": {
"format": "path",
"type": "string"
},
"type": "array"

View file

@ -250,6 +250,7 @@
"requirements": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -267,6 +268,7 @@
"project": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -285,6 +287,7 @@
"anyOf": [
{
"items": {
"format": "path",
"type": "string"
},
"type": "array"

View file

@ -110,9 +110,9 @@ def install_claude_code(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components

View file

@ -76,9 +76,9 @@ def install_claude_desktop(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components
if server_object:

View file

@ -110,9 +110,9 @@ def install_cursor_workspace(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements.resolve()) if with_requirements else None,
project=str(project.resolve()) if project else None,
editable=[str(p.resolve()) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components
if server_object:
@ -180,9 +180,9 @@ def install_cursor(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements.resolve()) if with_requirements else None,
project=str(project.resolve()) if project else None,
editable=[str(p.resolve()) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components
if server_object:

View file

@ -107,9 +107,9 @@ def install_gemini_cli(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components

View file

@ -51,9 +51,9 @@ def install_mcp_json(
env_config = UVEnvironment(
python=python_version,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
requirements=with_requirements,
project=project,
editable=with_editable,
)
# Build server spec from parsed components
if server_object:

View file

@ -583,15 +583,15 @@ class UvStdioTransport(StdioTransport):
command: str,
args: list[str] | None = None,
module: bool = False,
project_directory: str | None = None,
project_directory: Path | None = None,
python_version: str | None = None,
with_packages: list[str] | None = None,
with_requirements: str | None = None,
with_requirements: Path | None = None,
env_vars: dict[str, str] | None = None,
keep_alive: bool | None = None,
):
# Basic validation
if project_directory and not Path(project_directory).exists():
if project_directory and not project_directory.exists():
raise NotADirectoryError(
f"Project directory not found: {project_directory}"
)

View file

@ -3,10 +3,7 @@ from __future__ import annotations
from typing import Any
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import (
BearerAuthBackend,
RequireAuthMiddleware,
)
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
from mcp.server.auth.provider import (
AccessToken as _SDKAccessToken,
)
@ -81,7 +78,6 @@ class AuthProvider(TokenVerifierProtocol):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get the routes for this authentication provider.
@ -93,30 +89,13 @@ class AuthProvider(TokenVerifierProtocol):
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
mcp_endpoint: The MCP endpoint handler to protect with auth
This is used to advertise the resource URL in metadata, but the
provider does not create the actual MCP endpoint route.
Returns:
List of routes for this provider, including protected MCP endpoints if provided
List of routes for this provider (excluding the MCP endpoint itself)
"""
routes = []
# Add protected MCP endpoint if provided
if mcp_path and mcp_endpoint:
resource_metadata_url = self._get_resource_url(
"/.well-known/oauth-protected-resource"
)
routes.append(
Route(
mcp_path,
endpoint=RequireAuthMiddleware(
mcp_endpoint, self.required_scopes, resource_metadata_url
),
)
)
return routes
return []
def get_middleware(self) -> list:
"""Get HTTP application-level middleware for this auth provider.
@ -225,14 +204,13 @@ class RemoteAuthProvider(AuthProvider):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth routes for this provider.
Creates protected resource metadata routes and optionally wraps MCP endpoints with auth.
Creates protected resource metadata routes.
"""
# Start with base routes (protected MCP endpoint)
routes = super().get_routes(mcp_path, mcp_endpoint)
# Start with base routes
routes = super().get_routes(mcp_path)
# Get the resource URL based on the MCP path
resource_url = self._get_resource_url(mcp_path)
@ -326,14 +304,12 @@ class OAuthProvider(
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth authorization server routes and optional protected resource routes.
This method creates the full set of OAuth routes including:
- Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.)
- Optional protected resource routes
- Protected MCP endpoints if provided
Returns:
List of OAuth routes
@ -366,7 +342,7 @@ class OAuthProvider(
)
oauth_routes.extend(protected_routes)
# Add protected MCP endpoint from base class
oauth_routes.extend(super().get_routes(mcp_path, mcp_endpoint))
# Add base routes
oauth_routes.extend(super().get_routes(mcp_path))
return oauth_routes

View file

@ -857,7 +857,6 @@ class OAuthProxy(OAuthProvider):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth routes with custom proxy token handler.
@ -866,10 +865,10 @@ class OAuthProxy(OAuthProvider):
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
mcp_endpoint: The MCP endpoint handler to protect with auth
This is used to advertise the resource URL in metadata.
"""
# Get standard OAuth routes from parent class
routes = super().get_routes(mcp_path, mcp_endpoint)
routes = super().get_routes(mcp_path)
custom_routes = []
token_route_found = False

View file

@ -7,8 +7,6 @@ for seamless MCP client authentication.
from __future__ import annotations
from typing import Any
import httpx
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -127,7 +125,6 @@ class DescopeProvider(RemoteAuthProvider):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth routes including Descope authorization server metadata forwarding.
@ -136,10 +133,10 @@ class DescopeProvider(RemoteAuthProvider):
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
mcp_endpoint: The MCP endpoint handler to protect with auth
This is used to advertise the resource URL in metadata.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path, mcp_endpoint)
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""

View file

@ -7,8 +7,6 @@ authentication for seamless MCP client authentication.
from __future__ import annotations
from typing import Any
import httpx
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -135,7 +133,6 @@ class ScalekitProvider(RemoteAuthProvider):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth routes including Scalekit authorization server metadata forwarding.
@ -144,10 +141,10 @@ class ScalekitProvider(RemoteAuthProvider):
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
mcp_endpoint: The MCP endpoint handler to protect with auth
This is used to advertise the resource URL in metadata.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path, mcp_endpoint)
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""

View file

@ -10,8 +10,6 @@ Choose based on your WorkOS setup and authentication requirements.
from __future__ import annotations
from typing import Any
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl, SecretStr, field_validator
@ -364,7 +362,6 @@ class AuthKitProvider(RemoteAuthProvider):
def get_routes(
self,
mcp_path: str | None = None,
mcp_endpoint: Any | None = None,
) -> list[Route]:
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
@ -373,10 +370,10 @@ class AuthKitProvider(RemoteAuthProvider):
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
mcp_endpoint: The MCP endpoint handler to protect with auth
This is used to advertise the resource URL in metadata.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path, mcp_endpoint)
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""

View file

@ -167,16 +167,25 @@ def create_sse_app(
# Get auth middleware from the provider
auth_middleware = auth.get_middleware()
# Get auth routes including protected MCP endpoint
auth_routes = auth.get_routes(
mcp_path=sse_path,
mcp_endpoint=handle_sse,
)
# Get auth provider's own routes (OAuth endpoints, metadata, etc)
auth_routes = auth.get_routes(mcp_path=sse_path)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Manually wrap the SSE message endpoint with RequireAuthMiddleware
# Create protected SSE endpoint route with GET method only
server_routes.append(
Route(
sse_path,
endpoint=RequireAuthMiddleware(
handle_sse,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
),
methods=["GET"],
)
)
# Wrap the SSE message endpoint with RequireAuthMiddleware
server_routes.append(
Mount(
message_path,
@ -274,14 +283,22 @@ def create_streamable_http_app(
# Get auth middleware from the provider
auth_middleware = auth.get_middleware()
# Get auth routes including protected MCP endpoint
auth_routes = auth.get_routes(
mcp_path=streamable_http_path,
mcp_endpoint=streamable_http_app,
)
# Get auth provider's own routes (OAuth endpoints, metadata, etc)
auth_routes = auth.get_routes(mcp_path=streamable_http_path)
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)
# Create protected HTTP endpoint route
server_routes.append(
Route(
streamable_http_path,
endpoint=RequireAuthMiddleware(
streamable_http_app,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
),
)
)
else:
# No auth required
server_routes.append(

View file

@ -28,19 +28,19 @@ class UVEnvironment(Environment):
examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
)
requirements: str | None = Field(
requirements: Path | None = Field(
default=None,
description="Path to requirements.txt file",
examples=["requirements.txt", "../requirements/prod.txt"],
)
project: str | None = Field(
project: Path | None = Field(
default=None,
description="Path to project directory containing pyproject.toml",
examples=[".", "../my-project"],
)
editable: list[str] | None = Field(
editable: list[Path] | None = Field(
default=None,
description="Directories to install in editable mode",
examples=[[".", "../my-package"], ["/path/to/package"]],
@ -64,7 +64,7 @@ class UVEnvironment(Environment):
# Add project if specified
if self.project:
args.extend(["--project", str(self.project)])
args.extend(["--project", str(self.project.resolve())])
# Add Python version if specified (only if no project, as project has its own Python)
if self.python and not self.project:
@ -78,12 +78,12 @@ class UVEnvironment(Environment):
# Add requirements file
if self.requirements:
args.extend(["--with-requirements", str(self.requirements)])
args.extend(["--with-requirements", str(self.requirements.resolve())])
# Add editable packages
if self.editable:
for editable_path in self.editable:
args.extend(["--with-editable", str(editable_path)])
args.extend(["--with-editable", str(editable_path.resolve())])
# Add the command
args.extend(command)

View file

@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel):
environment = UVEnvironment(
python=python,
dependencies=dependencies,
requirements=requirements,
project=project,
editable=[editable] if editable else None,
requirements=Path(requirements) if requirements else None,
project=Path(project) if project else None,
editable=[Path(editable)] if editable else None,
)
# Build deployment config if any deployment args provided

View file

@ -250,6 +250,7 @@
"requirements": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -267,6 +268,7 @@
"project": {
"anyOf": [
{
"format": "path",
"type": "string"
},
{
@ -285,6 +287,7 @@
"anyOf": [
{
"items": {
"format": "path",
"type": "string"
},
"type": "array"

View file

@ -66,9 +66,10 @@ class TestEnvironment:
env = config.environment
assert env.python == "3.12"
assert env.dependencies == ["requests", "numpy>=2.0"]
assert env.requirements == "requirements.txt"
assert env.project == "."
assert env.editable == ["../my-package"]
# Paths are stored as Path objects
assert env.requirements == Path("requirements.txt")
assert env.project == Path(".")
assert env.editable == [Path("../my-package")]
def test_needs_uv(self):
"""Test needs_uv() method."""
@ -112,12 +113,16 @@ class TestEnvironment:
assert "--python" not in cmd
assert "3.12" not in cmd
assert "--project" in cmd
assert "." in cmd
# Project path should be resolved to absolute path
project_idx = cmd.index("--project")
assert Path(cmd[project_idx + 1]).is_absolute()
assert "--with" in cmd
assert "requests" in cmd
assert "numpy" in cmd
assert "--with-requirements" in cmd
assert "requirements.txt" in cmd
# Requirements path should be resolved to absolute path
req_idx = cmd.index("--with-requirements")
assert Path(cmd[req_idx + 1]).is_absolute()
# Command args should be at the end
assert "fastmcp" in cmd[-3:]
assert "run" in cmd[-2:]

View file

@ -234,10 +234,11 @@ class TestPathResolution:
assert config.environment is not None
uv_cmd = config.environment.build_command(["fastmcp", "run"])
# Should include requirements file
# Should include requirements file with absolute path
assert "--with-requirements" in uv_cmd
req_idx = uv_cmd.index("--with-requirements") + 1
assert uv_cmd[req_idx] == "requirements.txt"
assert Path(uv_cmd[req_idx]).is_absolute()
assert Path(uv_cmd[req_idx]).name == "requirements.txt"
class TestConfigValidation:

View file

@ -130,18 +130,18 @@ class TestTimeout:
async def test_timeout(self, sse_server: str):
with pytest.raises(
McpError,
match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
match="Timed out while waiting for response to ClientRequest. Waited 0.03 seconds",
):
async with Client(
transport=SSETransport(sse_server),
timeout=0.01,
timeout=0.03,
) as client:
await client.call_tool("sleep", {"seconds": 0.1})
async def test_timeout_tool_call(self, sse_server: str):
async with Client(transport=SSETransport(sse_server)) as client:
with pytest.raises(McpError, match="Timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03)
async def test_timeout_tool_call_overrides_client_timeout_if_lower(
self, sse_server: str
@ -151,7 +151,7 @@ class TestTimeout:
timeout=2,
) as client:
with pytest.raises(McpError, match="Timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03)
async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
self, sse_server: str
@ -165,4 +165,4 @@ class TestTimeout:
transport=SSETransport(sse_server),
timeout=0.1,
) as client:
await client.call_tool("sleep", {"seconds": 0.01}, timeout=2)
await client.call_tool("sleep", {"seconds": 0.03}, timeout=2)

View file

@ -84,7 +84,7 @@ async def test_uv_transport_module():
with_packages=["fastmcp"],
command="my_module",
module=True,
project_directory=tmpdir,
project_directory=Path(tmpdir),
keep_alive=False,
)
)

View file

@ -1,5 +1,7 @@
"""Tests for CLI utility functions."""
from pathlib import Path
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
@ -16,14 +18,14 @@ class TestEnvironmentBuildUVRunCommand:
def test_build_uv_run_command_with_editable(self):
"""Test building uv command with editable package."""
editable_path = "/path/to/package"
editable_path = Path("/path/to/package")
env = UVEnvironment(editable=[editable_path])
cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
"uv",
"run",
"--with-editable",
editable_path,
str(editable_path.resolve()),
"fastmcp",
"run",
"server.py",
@ -64,14 +66,14 @@ class TestEnvironmentBuildUVRunCommand:
def test_build_uv_run_command_with_requirements(self):
"""Test building uv command with requirements file."""
requirements_path = "/path/to/requirements.txt"
requirements_path = Path("/path/to/requirements.txt")
env = UVEnvironment(requirements=requirements_path)
cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
"uv",
"run",
"--with-requirements",
requirements_path,
str(requirements_path.resolve()),
"fastmcp",
"run",
"server.py",
@ -80,14 +82,14 @@ class TestEnvironmentBuildUVRunCommand:
def test_build_uv_run_command_with_project(self):
"""Test building uv command with project directory."""
project_path = "/path/to/project"
project_path = Path("/path/to/project")
env = UVEnvironment(project=project_path)
cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
"uv",
"run",
"--project",
project_path,
str(project_path.resolve()),
"fastmcp",
"run",
"server.py",
@ -96,8 +98,8 @@ class TestEnvironmentBuildUVRunCommand:
def test_build_uv_run_command_with_everything(self):
"""Test building uv command with all options."""
requirements_path = "/path/to/requirements.txt"
editable_path = "/local/pkg"
requirements_path = Path("/path/to/requirements.txt")
editable_path = Path("/local/pkg")
env = UVEnvironment(
python="3.10",
dependencies=["pandas", "numpy"],
@ -115,9 +117,9 @@ class TestEnvironmentBuildUVRunCommand:
"--with",
"pandas",
"--with-requirements",
requirements_path,
str(requirements_path.resolve()),
"--with-editable",
editable_path,
str(editable_path.resolve()),
"fastmcp",
"run",
"server.py",
@ -129,23 +131,24 @@ class TestEnvironmentBuildUVRunCommand:
def test_build_uv_run_command_project_with_extras(self):
"""Test that project flag works with additional dependencies."""
project_path = "/path/to/project"
project_path = Path("/path/to/project")
editable_path = Path("/pkg")
env = UVEnvironment(
project=project_path,
python="3.10", # Should be ignored with project
dependencies=["pandas"], # Should be added on top of project
editable=["/pkg"], # Should be added on top of project
editable=[editable_path], # Should be added on top of project
)
cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
"uv",
"run",
"--project",
project_path,
str(project_path.resolve()),
"--with",
"pandas",
"--with-editable",
"/pkg",
str(editable_path.resolve()),
"fastmcp",
"run",
"server.py",
@ -168,17 +171,17 @@ class TestEnvironmentNeedsUV:
def test_needs_uv_with_requirements(self):
"""Test that needs_uv returns True with requirements."""
env = UVEnvironment(requirements="/path/to/requirements.txt")
env = UVEnvironment(requirements=Path("/path/to/requirements.txt"))
assert env._must_run_with_uv() is True
def test_needs_uv_with_project(self):
"""Test that needs_uv returns True with project."""
env = UVEnvironment(project="/path/to/project")
env = UVEnvironment(project=Path("/path/to/project"))
assert env._must_run_with_uv() is True
def test_needs_uv_with_editable(self):
"""Test that needs_uv returns True with editable."""
env = UVEnvironment(editable=["/pkg"])
env = UVEnvironment(editable=[Path("/pkg")])
assert env._must_run_with_uv() is True
def test_needs_uv_empty(self):