Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 5 additions & 1 deletion .github/workflows/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
continue-on-error: true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put it up because it makes more sense since it's a job configuration, not a step one.

strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
Expand All @@ -45,4 +46,7 @@ jobs:

- name: Run pytest
run: uv run --frozen --no-sync pytest
continue-on-error: true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be set, should it be?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's needed, but I figured out why it's here: so if other tests on the matrix fail, it will continue to run.


# This must run last as it modifies the environment!
- name: Run pytest with lowest versions
run: uv run --all-extras --resolution lowest-direct --upgrade pytest
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def do_GET(self):
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: {query_params['error'][0]}</p>
<p>Error: {query_params["error"][0]}</p>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
Expand Down
6 changes: 4 additions & 2 deletions examples/fastmcp/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Self
from typing import Annotated, Self, TypeVar

import asyncpg
import numpy as np
Expand All @@ -35,6 +35,8 @@
DEFAULT_LLM_MODEL = "openai:gpt-4o"
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"

T = TypeVar("T")

mcp = FastMCP(
"memory",
dependencies=[
Expand All @@ -57,7 +59,7 @@ def cosine_similarity(a: list[float], b: list[float]) -> float:
return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array))


async def do_ai[T](
async def do_ai(
user_prompt: str,
system_prompt: str,
result_type: type[T] | Annotated,
Expand Down
2 changes: 1 addition & 1 deletion examples/fastmcp/unicode_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
mcp = FastMCP()


@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: " "á é í ó ú ñ 漢字 🎉")
@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉")
def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str:
"""
A simple tool that demonstrates Unicode handling in:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat
}

# Build simple login URL that points to login page
auth_url = f"{self.auth_callback_url}" f"?state={state}" f"&client_id={client.client_id}"
auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}"

return auth_url

Expand Down Expand Up @@ -117,7 +117,7 @@ async def get_login_page(self, state: str) -> HTMLResponse:
<p><strong>Username:</strong> demo_user<br>
<strong>Password:</strong> demo_password</p>

<form action="{self.server_url.rstrip('/')}/login/callback" method="post">
<form action="{self.server_url.rstrip("/")}/login/callback" method="post">
<input type="hidden" name="state" value="{state}">
<div class="form-group">
<label>Username:</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]:
for i in range(count):
await ctx.session.send_log_message(
level="info",
data=f"Notification {i+1}/{count} from caller: {caller}",
data=f"Notification {i + 1}/{count} from caller: {caller}",
logger="notification_stream",
related_request_id=ctx.request_id,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]:
for i in range(count):
# Include more detailed message for resumability demonstration
notification_msg = (
f"[{i+1}/{count}] Event from '{caller}' - "
f"[{i + 1}/{count}] Event from '{caller}' - "
f"Use Last-Event-ID to resume if disconnected"
)
await ctx.session.send_log_message(
Expand All @@ -69,7 +69,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]:
# - nowhere (if GET request isn't supported)
related_request_id=ctx.request_id,
)
logger.debug(f"Sent notification {i+1}/{count} for caller: {caller}")
logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}")
if i < count - 1: # Don't wait after the last notification
await anyio.sleep(interval)

Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"anyio>=4.5",
"httpx>=0.27",
"httpx-sse>=0.4",
"pydantic>=2.7.2,<3.0.0",
"pydantic>=2.8.0,<3.0.0",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2.7.2 resolves one of the tests wrong, and I found that pushing up 1 minor is not that damaging - pushing it to 2.11+ would be too much right now.

2.8.0 is from July 2024.

"starlette>=0.27",
"python-multipart>=0.0.9",
"sse-starlette>=1.6.1",
Expand All @@ -36,14 +36,13 @@ dependencies = [

[project.optional-dependencies]
rich = ["rich>=13.9.4"]
cli = ["typer>=0.12.4", "python-dotenv>=1.0.0"]
cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to bump typer because click broke it recently.

I don't think bumping this package is an issue given that people can usually easily bump this without coding changes on their applications.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pydantic or starlette would be more concerning.

ws = ["websockets>=15.0.1"]

[project.scripts]
mcp = "mcp.cli:app [cli]"

[tool.uv]
resolution = "lowest-direct"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now, we actually want the resolution to be the "highest" - which is the default. Because we have a test job in the pipeline that sets this resolution.

This way we can have the highest resolution on the lockfile, and actually test both highest and lowest-direct.

default-groups = ["dev", "docs"]
required-version = ">=0.7.2"

Expand All @@ -58,6 +57,7 @@ dev = [
"pytest-examples>=0.0.14",
"pytest-pretty>=1.2.0",
"inline-snapshot>=0.23.0",
"dirty-equals>=0.9.0",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes with the same line as inline-snapshots. It's a utility package that helps a lot.

See: https://dirty-equals.helpmanual.io/latest/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary - but it did make the test cleaner. I can remove it if requested.

]
docs = [
"mkdocs>=1.6.1",
Expand Down Expand Up @@ -123,5 +123,5 @@ filterwarnings = [
# This should be fixed on Uvicorn's side.
"ignore::DeprecationWarning:websockets",
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel"
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel",
]
2 changes: 1 addition & 1 deletion src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get_uv_path() -> str:
uv_path = shutil.which("uv")
if not uv_path:
logger.error(
"uv executable not found in PATH, falling back to 'uv'. " "Please ensure uv is installed and in your PATH"
"uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH"
)
return "uv" # Fall back to just "uv" if not found
return uv_path
Expand Down
12 changes: 6 additions & 6 deletions src/mcp/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ def _check_server_object(server_object: Any, object_name: str):
True if it's supported.
"""
if not isinstance(server_object, FastMCP):
logger.error(f"The server object {object_name} is of type " f"{type(server_object)} (expecting {FastMCP}).")
logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {FastMCP}).")
if isinstance(server_object, LowLevelServer):
logger.warning(
"Note that only FastMCP server is supported. Low level " "Server class is not yet supported."
"Note that only FastMCP server is supported. Low level Server class is not yet supported."
)
return False
return True
Expand All @@ -164,7 +164,7 @@ def _check_server_object(server_object: Any, object_name: str):
for name in ["mcp", "server", "app"]:
if hasattr(module, name):
if not _check_server_object(getattr(module, name), f"{file}:{name}"):
logger.error(f"Ignoring object '{file}:{name}' as it's not a valid " "server object")
logger.error(f"Ignoring object '{file}:{name}' as it's not a valid server object")
continue
return getattr(module, name)

Expand Down Expand Up @@ -269,7 +269,7 @@ def dev(
npx_cmd = _get_npx_command()
if not npx_cmd:
logger.error(
"npx not found. Please ensure Node.js and npm are properly installed " "and added to your system PATH."
"npx not found. Please ensure Node.js and npm are properly installed and added to your system PATH."
)
sys.exit(1)

Expand Down Expand Up @@ -371,7 +371,7 @@ def install(
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or" " file name)",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Expand Down Expand Up @@ -445,7 +445,7 @@ def install(
name = server.name
except (ImportError, ModuleNotFoundError) as e:
logger.debug(
"Could not import server (likely missing dependencies), using file" " name",
"Could not import server (likely missing dependencies), using file name",
extra={"error": str(e)},
)
name = file.stem
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ async def initialize(self) -> types.InitializeResult:
)

if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
raise RuntimeError("Unsupported protocol version from the server: " f"{result.protocolVersion}")
raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}")

await self.send_notification(
types.ClientNotification(types.InitializedNotification(method="notifications/initialized"))
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/client/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async def sse_reader(
or url_parsed.scheme != endpoint_parsed.scheme
):
error_msg = (
"Endpoint origin does not match " f"connection origin: {endpoint_url}"
f"Endpoint origin does not match connection origin: {endpoint_url}"
)
logger.error(error_msg)
raise ValueError(error_msg)
Expand Down Expand Up @@ -125,7 +125,7 @@ async def post_writer(endpoint_url: str):
),
)
response.raise_for_status()
logger.debug("Client message sent successfully: " f"{response.status_code}")
logger.debug(f"Client message sent successfully: {response.status_code}")
except Exception as exc:
logger.error(f"Error in post_writer: {exc}")
finally:
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/auth/handlers/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class AuthorizationRequest(BaseModel):
state: str | None = Field(None, description="Optional state parameter")
scope: str | None = Field(
None,
description="Optional scope; if specified, should be " "a space-separated list of scope strings",
description="Optional scope; if specified, should be a space-separated list of scope strings",
)
resource: str | None = Field(
None,
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/auth/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def handle(self, request: Request) -> Response:
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",
error_description="grant_types must be authorization_code " "and refresh_token",
error_description="grant_types must be authorization_code and refresh_token",
),
status_code=400,
)
Expand Down
8 changes: 3 additions & 5 deletions src/mcp/server/auth/handlers/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,7 @@ async def handle(self, request: Request):
return self.response(
TokenErrorResponse(
error="unsupported_grant_type",
error_description=(
f"Unsupported grant type (supported grant types are " f"{client_info.grant_types})"
),
error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"),
)
)

Expand Down Expand Up @@ -166,7 +164,7 @@ async def handle(self, request: Request):
return self.response(
TokenErrorResponse(
error="invalid_request",
error_description=("redirect_uri did not match the one " "used when creating auth code"),
error_description=("redirect_uri did not match the one used when creating auth code"),
)
)

Expand Down Expand Up @@ -222,7 +220,7 @@ async def handle(self, request: Request):
return self.response(
TokenErrorResponse(
error="invalid_scope",
error_description=(f"cannot request scope `{scope}` " "not provided by refresh token"),
error_description=(f"cannot request scope `{scope}` not provided by refresh token"),
)
)

Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ async def async_tool(x: int, context: Context) -> str:
# Check if user passed function directly instead of calling decorator
if callable(name):
raise TypeError(
"The @tool decorator was used incorrectly. " "Did you forget to call it? Use @tool() instead of @tool"
"The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool"
)

def decorator(fn: AnyFunction) -> AnyFunction:
Expand Down Expand Up @@ -497,7 +497,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:

if uri_params != func_params:
raise ValueError(
f"Mismatch between URI parameters {uri_params} " f"and function parameters {func_params}"
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
)

# Register as template
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/fastmcp/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Tool(BaseModel):
description: str = Field(description="Description of what the tool does")
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
fn_metadata: FuncMetadata = Field(
description="Metadata about the function including a pydantic model for tool" " arguments"
description="Metadata about the function including a pydantic model for tool arguments"
)
is_async: bool = Field(description="Whether the tool is async")
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
elif len(self.redirect_uris) == 1:
return self.redirect_uris[0]
else:
raise InvalidRedirectUriError("redirect_uri must be specified when client " "has multiple registered URIs")
raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")


class OAuthClientInformationFull(OAuthClientMetadata):
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,15 @@ async def _receive_loop(self) -> None:
except Exception as e:
# For other validation errors, log and continue
logging.warning(
f"Failed to validate notification: {e}. " f"Message was: {message.message.root}"
f"Failed to validate notification: {e}. Message was: {message.message.root}"
)
else: # Response or error
stream = self._response_streams.pop(message.message.root.id, None)
if stream:
await stream.send(message.message.root)
else:
await self._handle_incoming(
RuntimeError("Received response with an unknown " f"request ID: {message}")
RuntimeError(f"Received response with an unknown request ID: {message}")
)

except anyio.ClosedResourceError:
Expand Down
3 changes: 1 addition & 2 deletions tests/client/test_resource_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ async def mock_send(*args, **kwargs):

# Verify that no response streams were leaked
assert len(session._response_streams) == initial_stream_count, (
f"Expected {initial_stream_count} response streams after request, "
f"but found {len(session._response_streams)}"
f"Expected {initial_stream_count} response streams after request, but found {len(session._response_streams)}"
)

# Clean up
Expand Down
19 changes: 4 additions & 15 deletions tests/issues/test_188_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
from pydantic import AnyUrl

from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import (
create_connected_server_and_client_session as create_session,
)
from mcp.shared.memory import create_connected_server_and_client_session as create_session

_sleep_time_seconds = 0.01
_resource_name = "slow://slow_resource"


@pytest.mark.filterwarnings(
"ignore:coroutine 'test_messages_are_executed_concurrently.<locals>.slow_resource' was never awaited:RuntimeWarning"
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find the reason or package that triggered this - I tried bumping anyio, but that didn't seem to be the reason - but this seems an issue only with the test suite, so it doesn't have any impact.

@pytest.mark.anyio
async def test_messages_are_executed_concurrently():
server = FastMCP("test")
Expand Down Expand Up @@ -46,15 +47,3 @@ async def slow_resource():
active_calls -= 1
print(f"Max concurrent calls: {max_concurrent_calls}")
assert max_concurrent_calls > 1, "No concurrent calls were executed"


def main():
anyio.run(test_messages_are_executed_concurrently)


if __name__ == "__main__":
import logging

logging.basicConfig(level=logging.DEBUG)

main()
Comment on lines -49 to -60
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't have been merged - it seems it was here for debugging purposes.

2 changes: 1 addition & 1 deletion tests/server/fastmcp/auth/test_auth_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ async def test_client_registration_invalid_uri(self, test_client: httpx.AsyncCli
assert "error" in error_data
assert error_data["error"] == "invalid_client_metadata"
assert error_data["error_description"] == (
"redirect_uris.0: Input should be a valid URL, " "relative URL without a base"
"redirect_uris.0: Input should be a valid URL, relative URL without a base"
)

@pytest.mark.anyio
Expand Down
Loading
Loading