mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
Merge branch 'main' into switch-kvstore
This commit is contained in:
commit
fc2a5c3b8b
26 changed files with 172 additions and 153 deletions
33
.github/workflows/marvin-dedupe-issues.yml
vendored
33
.github/workflows/marvin-dedupe-issues.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
|
|
|
|||
28
.github/workflows/marvin-label-triage.yml
vendored
28
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
|
|
|
|||
7
.github/workflows/marvin.yml
vendored
7
.github/workflows/marvin.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/run-tests.yml
vendored
6
.github/workflows/run-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue