From 41d6b6fc1505ef51aef2df37237763aba6fa891f Mon Sep 17 00:00:00 2001 From: Vincent Liu <128127889+vl-kp@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:30:21 +0800 Subject: [PATCH 01/26] fix: fix StructuredLoggingMiddleware payload serialization --- src/fastmcp/server/middleware/logging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index f770e2faa..0e0390385 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -152,7 +152,7 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log(self.log_level, json.dumps(start_entry)) + self.logger.log(self.log_level, json.dumps(start_entry, default=str)) try: result = await call_next(context) @@ -162,7 +162,7 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry)) + self.logger.log(self.log_level, json.dumps(success_entry, default=str)) return result except Exception as e: @@ -172,5 +172,5 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry)) + self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) raise From 0e849021561118e7377619c0e70fd95c7fd69447 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 13:55:24 +0800 Subject: [PATCH 02/26] chore: add test --- tests/server/middleware/test_logging.py | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 9217bb485..4bb0b98ac 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -191,6 +191,57 @@ class TestStructuredLoggingMiddleware: assert error_entry["error_type"] == "ValueError" assert error_entry["error_message"] == "test error" + async def test_on_message_with_pydantic_types_in_payload( + self, mock_context, mock_call_next, caplog + ): + """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" + from pydantic import AnyUrl + + mock_context.message.__dict__["url"] = AnyUrl("test://example/1") + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) == 2 + start_entry = json.loads(log_lines[0]) + + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["url"] == "test://example/1" + + async def test_on_message_with_resource_template_in_payload( + self, mock_context, mock_call_next, caplog + ): + """Ensure ResourceTemplate in payload serializes via default=str without errors.""" + from fastmcp.resources import ResourceTemplate + + template = ResourceTemplate( + name="tmpl", + uri_template="tmpl://{id}", + parameters={"id": {"type": "string"}}, + ) + + mock_context.message.__dict__["template"] = template + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) == 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert "template" in start_entry["payload"] + # After json.loads, default=str ensures complex object became a JSON string + assert isinstance(start_entry["payload"]["template"], str) + @pytest.fixture def logging_server(): From e03061d79ac8da1f4eb51eff5e78edc17a5e31f1 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 22:01:11 +0800 Subject: [PATCH 03/26] chore: add configurable serializer --- src/fastmcp/server/middleware/logging.py | 38 +++++++++++++-- tests/server/middleware/test_logging.py | 61 ++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 0e0390385..7fcfe37f8 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -2,8 +2,11 @@ import json import logging +from collections.abc import Callable from typing import Any +import pydantic_core + from .middleware import CallNext, Middleware, MiddlewareContext @@ -111,6 +114,7 @@ class StructuredLoggingMiddleware(Middleware): log_level: int = logging.INFO, include_payloads: bool = False, methods: list[str] | None = None, + serializer: Callable[[Any], Any] | None = None, ): """Initialize structured logging middleware. @@ -119,11 +123,33 @@ class StructuredLoggingMiddleware(Middleware): log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs methods: List of methods to log. If None, logs all methods. + serializer: Optional callable to convert objects to JSON-serializable + values when logging. Defaults to a safe converter that tries + pydantic_core.to_jsonable_python and falls back to str. """ self.logger = logger or logging.getLogger("fastmcp.structured") self.log_level = log_level self.include_payloads = include_payloads self.methods = methods + self.serializer = serializer + + def _json_default(self, obj: Any) -> Any: + """Default converter for json.dumps to handle non-serializable objects. + + Tries a user-provided serializer first, then pydantic conversion, then str. + """ + if self.serializer is not None: + try: + return self.serializer(obj) + except Exception: + pass + try: + return pydantic_core.to_jsonable_python(obj) + except Exception: + try: + return str(obj) + except Exception: + return "" def _create_log_entry( self, context: MiddlewareContext, event: str, **extra_fields @@ -152,7 +178,9 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log(self.log_level, json.dumps(start_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(start_entry, default=self._json_default) + ) try: result = await call_next(context) @@ -162,7 +190,9 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(success_entry, default=self._json_default) + ) return result except Exception as e: @@ -172,5 +202,7 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) + self.logger.log( + logging.ERROR, json.dumps(error_entry, default=self._json_default) + ) raise diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 4bb0b98ac..46c84cf00 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -216,7 +216,7 @@ class TestStructuredLoggingMiddleware: async def test_on_message_with_resource_template_in_payload( self, mock_context, mock_call_next, caplog ): - """Ensure ResourceTemplate in payload serializes via default=str without errors.""" + """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" from fastmcp.resources import ResourceTemplate template = ResourceTemplate( @@ -239,8 +239,63 @@ class TestStructuredLoggingMiddleware: start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" assert "template" in start_entry["payload"] - # After json.loads, default=str ensures complex object became a JSON string - assert isinstance(start_entry["payload"]["template"], str) + # With pydantic conversion, complex object becomes a JSONable dict + assert isinstance(start_entry["payload"]["template"], dict) + assert start_entry["payload"]["template"]["uri_template"] == "tmpl://{id}" + + async def test_on_message_with_nonserializable_payload_falls_back_to_str( + self, mock_context, mock_call_next, caplog + ): + """Ensure non-JSONable objects fall back to string serialization in payload.""" + + class NonSerializable: + def __str__(self) -> str: + return "NON_SERIALIZABLE" + + mock_context.message.__dict__["obj"] = NonSerializable() + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["obj"] == "NON_SERIALIZABLE" + + async def test_on_message_with_custom_serializer_applied( + self, mock_context, mock_call_next, caplog + ): + """Ensure a custom serializer is used for non-JSONable payloads.""" + + class CustomType: + pass + + def custom_serializer(o): + if isinstance(o, CustomType): + return "CUSTOM:CustomType" + raise TypeError("unsupported") + + mock_context.message.__dict__["special"] = CustomType() + + middleware = StructuredLoggingMiddleware( + include_payloads=True, serializer=custom_serializer + ) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["special"] == "CUSTOM:CustomType" @pytest.fixture From 0822093f4693408b8d338adb5d310945d669c0ce Mon Sep 17 00:00:00 2001 From: Vincent Liu <128127889+vl-kp@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:30:21 +0800 Subject: [PATCH 04/26] fix: fix StructuredLoggingMiddleware payload serialization --- src/fastmcp/server/middleware/logging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index f770e2faa..0e0390385 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -152,7 +152,7 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log(self.log_level, json.dumps(start_entry)) + self.logger.log(self.log_level, json.dumps(start_entry, default=str)) try: result = await call_next(context) @@ -162,7 +162,7 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry)) + self.logger.log(self.log_level, json.dumps(success_entry, default=str)) return result except Exception as e: @@ -172,5 +172,5 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry)) + self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) raise From 76a0ed89541d0bd6f1f6557a69daac50124ec8f6 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 13:55:24 +0800 Subject: [PATCH 05/26] chore: add test --- tests/server/middleware/test_logging.py | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 9217bb485..4bb0b98ac 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -191,6 +191,57 @@ class TestStructuredLoggingMiddleware: assert error_entry["error_type"] == "ValueError" assert error_entry["error_message"] == "test error" + async def test_on_message_with_pydantic_types_in_payload( + self, mock_context, mock_call_next, caplog + ): + """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" + from pydantic import AnyUrl + + mock_context.message.__dict__["url"] = AnyUrl("test://example/1") + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) == 2 + start_entry = json.loads(log_lines[0]) + + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["url"] == "test://example/1" + + async def test_on_message_with_resource_template_in_payload( + self, mock_context, mock_call_next, caplog + ): + """Ensure ResourceTemplate in payload serializes via default=str without errors.""" + from fastmcp.resources import ResourceTemplate + + template = ResourceTemplate( + name="tmpl", + uri_template="tmpl://{id}", + parameters={"id": {"type": "string"}}, + ) + + mock_context.message.__dict__["template"] = template + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) == 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert "template" in start_entry["payload"] + # After json.loads, default=str ensures complex object became a JSON string + assert isinstance(start_entry["payload"]["template"], str) + @pytest.fixture def logging_server(): From 36975a518282601a964e7ce92bc998df5d4abecc Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 22:01:11 +0800 Subject: [PATCH 06/26] chore: add configurable serializer --- src/fastmcp/server/middleware/logging.py | 38 +++++++++++++-- tests/server/middleware/test_logging.py | 61 ++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 0e0390385..7fcfe37f8 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -2,8 +2,11 @@ import json import logging +from collections.abc import Callable from typing import Any +import pydantic_core + from .middleware import CallNext, Middleware, MiddlewareContext @@ -111,6 +114,7 @@ class StructuredLoggingMiddleware(Middleware): log_level: int = logging.INFO, include_payloads: bool = False, methods: list[str] | None = None, + serializer: Callable[[Any], Any] | None = None, ): """Initialize structured logging middleware. @@ -119,11 +123,33 @@ class StructuredLoggingMiddleware(Middleware): log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs methods: List of methods to log. If None, logs all methods. + serializer: Optional callable to convert objects to JSON-serializable + values when logging. Defaults to a safe converter that tries + pydantic_core.to_jsonable_python and falls back to str. """ self.logger = logger or logging.getLogger("fastmcp.structured") self.log_level = log_level self.include_payloads = include_payloads self.methods = methods + self.serializer = serializer + + def _json_default(self, obj: Any) -> Any: + """Default converter for json.dumps to handle non-serializable objects. + + Tries a user-provided serializer first, then pydantic conversion, then str. + """ + if self.serializer is not None: + try: + return self.serializer(obj) + except Exception: + pass + try: + return pydantic_core.to_jsonable_python(obj) + except Exception: + try: + return str(obj) + except Exception: + return "" def _create_log_entry( self, context: MiddlewareContext, event: str, **extra_fields @@ -152,7 +178,9 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log(self.log_level, json.dumps(start_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(start_entry, default=self._json_default) + ) try: result = await call_next(context) @@ -162,7 +190,9 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(success_entry, default=self._json_default) + ) return result except Exception as e: @@ -172,5 +202,7 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) + self.logger.log( + logging.ERROR, json.dumps(error_entry, default=self._json_default) + ) raise diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 4bb0b98ac..46c84cf00 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -216,7 +216,7 @@ class TestStructuredLoggingMiddleware: async def test_on_message_with_resource_template_in_payload( self, mock_context, mock_call_next, caplog ): - """Ensure ResourceTemplate in payload serializes via default=str without errors.""" + """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" from fastmcp.resources import ResourceTemplate template = ResourceTemplate( @@ -239,8 +239,63 @@ class TestStructuredLoggingMiddleware: start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" assert "template" in start_entry["payload"] - # After json.loads, default=str ensures complex object became a JSON string - assert isinstance(start_entry["payload"]["template"], str) + # With pydantic conversion, complex object becomes a JSONable dict + assert isinstance(start_entry["payload"]["template"], dict) + assert start_entry["payload"]["template"]["uri_template"] == "tmpl://{id}" + + async def test_on_message_with_nonserializable_payload_falls_back_to_str( + self, mock_context, mock_call_next, caplog + ): + """Ensure non-JSONable objects fall back to string serialization in payload.""" + + class NonSerializable: + def __str__(self) -> str: + return "NON_SERIALIZABLE" + + mock_context.message.__dict__["obj"] = NonSerializable() + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["obj"] == "NON_SERIALIZABLE" + + async def test_on_message_with_custom_serializer_applied( + self, mock_context, mock_call_next, caplog + ): + """Ensure a custom serializer is used for non-JSONable payloads.""" + + class CustomType: + pass + + def custom_serializer(o): + if isinstance(o, CustomType): + return "CUSTOM:CustomType" + raise TypeError("unsupported") + + mock_context.message.__dict__["special"] = CustomType() + + middleware = StructuredLoggingMiddleware( + include_payloads=True, serializer=custom_serializer + ) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["special"] == "CUSTOM:CustomType" @pytest.fixture From 5419176e842ac6fc5a5f8fe8e07d651fc6a1f43d Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 27 Aug 2025 09:38:50 +0800 Subject: [PATCH 07/26] chore: move serializer into _create_log_entry --- src/fastmcp/server/middleware/logging.py | 58 ++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 7fcfe37f8..bf57233db 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -133,23 +133,38 @@ class StructuredLoggingMiddleware(Middleware): self.methods = methods self.serializer = serializer - def _json_default(self, obj: Any) -> Any: - """Default converter for json.dumps to handle non-serializable objects. + def _normalize(self, value: Any) -> Any: + """Normalize a Python object to a JSON-serializable value. - Tries a user-provided serializer first, then pydantic conversion, then str. + Order: custom serializer → primitives → mappings/sequences (recursive) + → pydantic conversion → str fallback. """ if self.serializer is not None: try: - return self.serializer(obj) + value = self.serializer(value) except Exception: pass + + if value is None or isinstance(value, (str, int, float, bool)): # noqa: UP038 + return value + + if isinstance(value, dict): + return {str(k): self._normalize(v) for k, v in value.items()} + + if isinstance(value, (list, tuple, set)): # noqa: UP038 + return [self._normalize(v) for v in list(value)] + try: - return pydantic_core.to_jsonable_python(obj) + converted = pydantic_core.to_jsonable_python(value) + if converted is not value: + return self._normalize(converted) except Exception: - try: - return str(obj) - except Exception: - return "" + pass + + try: + return str(value) + except Exception: + return "" def _create_log_entry( self, context: MiddlewareContext, event: str, **extra_fields @@ -164,11 +179,14 @@ class StructuredLoggingMiddleware(Middleware): **extra_fields, } - if self.include_payloads and hasattr(context.message, "__dict__"): - try: - entry["payload"] = context.message.__dict__ - except (TypeError, ValueError): - entry["payload"] = "" + if self.include_payloads: + payload_obj = ( + context.message.__dict__ + if hasattr(context.message, "__dict__") + else context.message + ) + + entry["payload"] = self._normalize(payload_obj) return entry @@ -178,9 +196,7 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log( - self.log_level, json.dumps(start_entry, default=self._json_default) - ) + self.logger.log(self.log_level, json.dumps(start_entry)) try: result = await call_next(context) @@ -190,9 +206,7 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log( - self.log_level, json.dumps(success_entry, default=self._json_default) - ) + self.logger.log(self.log_level, json.dumps(success_entry)) return result except Exception as e: @@ -202,7 +216,5 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log( - logging.ERROR, json.dumps(error_entry, default=self._json_default) - ) + self.logger.log(logging.ERROR, json.dumps(error_entry)) raise From f763a4a31be1b6d7fc34143e1627170653ce4828 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 27 Aug 2025 09:58:27 +0800 Subject: [PATCH 08/26] chore: use default_serializer --- src/fastmcp/server/middleware/logging.py | 51 +++++------------------- tests/server/middleware/test_logging.py | 33 ++++++++------- 2 files changed, 27 insertions(+), 57 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index bf57233db..0f24c7fd0 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -5,7 +5,7 @@ import logging from collections.abc import Callable from typing import Any -import pydantic_core +from fastmcp.tools.tool import default_serializer from .middleware import CallNext, Middleware, MiddlewareContext @@ -114,7 +114,7 @@ class StructuredLoggingMiddleware(Middleware): log_level: int = logging.INFO, include_payloads: bool = False, methods: list[str] | None = None, - serializer: Callable[[Any], Any] | None = None, + serializer: Callable[[Any], str] | None = None, ): """Initialize structured logging middleware. @@ -123,48 +123,14 @@ class StructuredLoggingMiddleware(Middleware): log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs methods: List of methods to log. If None, logs all methods. - serializer: Optional callable to convert objects to JSON-serializable - values when logging. Defaults to a safe converter that tries - pydantic_core.to_jsonable_python and falls back to str. + serializer: Callable that converts objects to a JSON string for the + payload. If not provided, uses FastMCP's default tool serializer. """ self.logger = logger or logging.getLogger("fastmcp.structured") self.log_level = log_level self.include_payloads = include_payloads self.methods = methods - self.serializer = serializer - - def _normalize(self, value: Any) -> Any: - """Normalize a Python object to a JSON-serializable value. - - Order: custom serializer → primitives → mappings/sequences (recursive) - → pydantic conversion → str fallback. - """ - if self.serializer is not None: - try: - value = self.serializer(value) - except Exception: - pass - - if value is None or isinstance(value, (str, int, float, bool)): # noqa: UP038 - return value - - if isinstance(value, dict): - return {str(k): self._normalize(v) for k, v in value.items()} - - if isinstance(value, (list, tuple, set)): # noqa: UP038 - return [self._normalize(v) for v in list(value)] - - try: - converted = pydantic_core.to_jsonable_python(value) - if converted is not value: - return self._normalize(converted) - except Exception: - pass - - try: - return str(value) - except Exception: - return "" + self.serializer = serializer or default_serializer def _create_log_entry( self, context: MiddlewareContext, event: str, **extra_fields @@ -186,7 +152,12 @@ class StructuredLoggingMiddleware(Middleware): else context.message ) - entry["payload"] = self._normalize(payload_obj) + try: + payload_str = self.serializer(payload_obj) + except Exception: + payload_str = default_serializer(payload_obj) + + entry["payload"] = payload_str return entry diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 46c84cf00..a465e5281 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -2,6 +2,7 @@ import json import logging +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -137,8 +138,8 @@ class TestStructuredLoggingMiddleware: """Test creating log entry with payload.""" middleware = StructuredLoggingMiddleware(include_payloads=True) entry = middleware._create_log_entry(mock_context, "test_event") - - assert entry["payload"] == {"param": "value"} + assert isinstance(entry["payload"], str) + assert json.loads(entry["payload"]) == {"param": "value"} def test_create_log_entry_with_extra_fields(self, mock_context): """Test creating log entry with extra fields.""" @@ -211,7 +212,8 @@ class TestStructuredLoggingMiddleware: start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" - assert start_entry["payload"]["url"] == "test://example/1" + payload = json.loads(start_entry["payload"]) + assert payload["url"] == "test://example/1" async def test_on_message_with_resource_template_in_payload( self, mock_context, mock_call_next, caplog @@ -238,10 +240,10 @@ class TestStructuredLoggingMiddleware: assert len(log_lines) == 2 start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" - assert "template" in start_entry["payload"] - # With pydantic conversion, complex object becomes a JSONable dict - assert isinstance(start_entry["payload"]["template"], dict) - assert start_entry["payload"]["template"]["uri_template"] == "tmpl://{id}" + payload = json.loads(start_entry["payload"]) + assert "template" in payload + assert isinstance(payload["template"], dict) + assert payload["template"]["uri_template"] == "tmpl://{id}" async def test_on_message_with_nonserializable_payload_falls_back_to_str( self, mock_context, mock_call_next, caplog @@ -265,22 +267,19 @@ class TestStructuredLoggingMiddleware: assert len(log_lines) >= 2 start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" - assert start_entry["payload"]["obj"] == "NON_SERIALIZABLE" + payload = json.loads(start_entry["payload"]) + assert payload["obj"] == "NON_SERIALIZABLE" async def test_on_message_with_custom_serializer_applied( self, mock_context, mock_call_next, caplog ): """Ensure a custom serializer is used for non-JSONable payloads.""" - class CustomType: - pass + # Provide a serializer that replaces entire payload with a fixed string + def custom_serializer(_: Any) -> str: + return "CUSTOM_PAYLOAD" - def custom_serializer(o): - if isinstance(o, CustomType): - return "CUSTOM:CustomType" - raise TypeError("unsupported") - - mock_context.message.__dict__["special"] = CustomType() + mock_context.message.__dict__["special"] = object() middleware = StructuredLoggingMiddleware( include_payloads=True, serializer=custom_serializer @@ -295,7 +294,7 @@ class TestStructuredLoggingMiddleware: assert len(log_lines) >= 2 start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" - assert start_entry["payload"]["special"] == "CUSTOM:CustomType" + assert start_entry["payload"] == "CUSTOM_PAYLOAD" @pytest.fixture From 27401a3d5f578e57f5584236df8b315fd5ed3aa0 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 26 Aug 2025 23:54:34 -0500 Subject: [PATCH 09/26] Refactor logging middleware --- src/fastmcp/server/middleware/logging.py | 93 +++--- tests/server/middleware/test_logging.py | 348 ++++++++++++++++------- 2 files changed, 303 insertions(+), 138 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 0f24c7fd0..03f9b2936 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -3,13 +3,19 @@ import json import logging from collections.abc import Callable +from logging import Logger from typing import Any -from fastmcp.tools.tool import default_serializer +import pydantic_core from .middleware import CallNext, Middleware, MiddlewareContext +def default_serializer(data: Any) -> str: + """The default serializer for Payloads in the logging middleware.""" + return pydantic_core.to_json(data, fallback=str).decode() + + class LoggingMiddleware(Middleware): """Middleware that provides comprehensive request and response logging. @@ -36,6 +42,7 @@ class LoggingMiddleware(Middleware): include_payloads: bool = False, max_payload_length: int = 1000, methods: list[str] | None = None, + payload_serializer: Callable[[Any], str] | None = None, ): """Initialize logging middleware. @@ -46,13 +53,14 @@ class LoggingMiddleware(Middleware): max_payload_length: Maximum length of payload to log (prevents huge logs) methods: List of methods to log. If None, logs all methods. """ - self.logger = logger or logging.getLogger("fastmcp.requests") - self.log_level = log_level - self.include_payloads = include_payloads - self.max_payload_length = max_payload_length - self.methods = methods + self.logger: Logger = logger or logging.getLogger("fastmcp.requests") + self.log_level: int = log_level + self.include_payloads: bool = include_payloads + self.max_payload_length: int = max_payload_length + self.methods: list[str] | None = methods + self.payload_serializer: Callable[[Any], str] | None = payload_serializer - def _format_message(self, context: MiddlewareContext) -> str: + def _format_message(self, context: MiddlewareContext[Any]) -> str: """Format a message for logging.""" parts = [ f"source={context.source}", @@ -60,18 +68,29 @@ class LoggingMiddleware(Middleware): f"method={context.method or 'unknown'}", ] - if self.include_payloads and hasattr(context.message, "__dict__"): - try: - payload = json.dumps(context.message.__dict__, default=str) - if len(payload) > self.max_payload_length: - payload = payload[: self.max_payload_length] + "..." - parts.append(f"payload={payload}") - except (TypeError, ValueError): - parts.append("payload=") + if self.include_payloads: + payload: str + if not self.payload_serializer: + payload = default_serializer(context.message) + else: + try: + payload = self.payload_serializer(context.message) + except Exception as e: + self.logger.warning( + f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}." + ) + payload = default_serializer(context.message) + + if len(payload) > self.max_payload_length: + payload = payload[: self.max_payload_length] + "..." + + parts.append(f"payload={payload}") return " ".join(parts) - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: + async def on_message( + self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] + ) -> Any: """Log all messages.""" message_info = self._format_message(context) if self.methods and context.method not in self.methods: @@ -114,7 +133,7 @@ class StructuredLoggingMiddleware(Middleware): log_level: int = logging.INFO, include_payloads: bool = False, methods: list[str] | None = None, - serializer: Callable[[Any], str] | None = None, + payload_serializer: Callable[[Any], str] | None = None, ): """Initialize structured logging middleware. @@ -126,15 +145,15 @@ class StructuredLoggingMiddleware(Middleware): serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ - self.logger = logger or logging.getLogger("fastmcp.structured") - self.log_level = log_level - self.include_payloads = include_payloads - self.methods = methods - self.serializer = serializer or default_serializer + self.logger: Logger = logger or logging.getLogger("fastmcp.structured") + self.log_level: int = log_level + self.include_payloads: bool = include_payloads + self.methods: list[str] | None = methods + self.payload_serializer: Callable[[Any], str] | None = payload_serializer def _create_log_entry( - self, context: MiddlewareContext, event: str, **extra_fields - ) -> dict: + self, context: MiddlewareContext[Any], event: str, **extra_fields: Any + ) -> dict[str, Any]: """Create a structured log entry.""" entry = { "event": event, @@ -146,22 +165,26 @@ class StructuredLoggingMiddleware(Middleware): } if self.include_payloads: - payload_obj = ( - context.message.__dict__ - if hasattr(context.message, "__dict__") - else context.message - ) + payload: str - try: - payload_str = self.serializer(payload_obj) - except Exception: - payload_str = default_serializer(payload_obj) + if not self.payload_serializer: + payload = default_serializer(context.message) + else: + try: + payload = self.payload_serializer(context.message) + except Exception as e: + self.logger.warning( + f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}." + ) + payload = default_serializer(context.message) - entry["payload"] = payload_str + entry["payload"] = payload return entry - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: + async def on_message( + self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] + ) -> Any: """Log structured message information.""" start_entry = self._create_log_entry(context, "request_start") if self.methods and context.method not in self.methods: diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index a465e5281..d0118f219 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -1,31 +1,58 @@ """Tests for logging middleware.""" +import datetime import json import logging -from typing import Any +from typing import Any, Literal, TypeVar from unittest.mock import AsyncMock, MagicMock +import mcp import pytest +from inline_snapshot import snapshot +from pydantic import AnyUrl +from fastmcp.resources.template import ResourceTemplate from fastmcp.server.middleware.logging import ( LoggingMiddleware, StructuredLoggingMiddleware, ) -from fastmcp.server.middleware.middleware import MiddlewareContext +from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext +from fastmcp.server.server import FastMCP + +FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc) + +T = TypeVar("T") + + +def new_mock_context( + message: T, + method: str | None = None, + source: Literal["server", "client"] | None = None, + type: Literal["request", "notification"] | None = None, +) -> MiddlewareContext[T]: + """Create a new mock middleware context.""" + context = MagicMock(spec=MiddlewareContext[T]) + context.method = method or "test_method" + context.source = source or "client" + context.type = type or "request" + context.message = message + context.timestamp = FIXED_DATE + return context @pytest.fixture def mock_context(): """Create a mock middleware context.""" - context = MagicMock(spec=MiddlewareContext) - context.method = "test_method" - context.source = "client" - context.type = "request" - context.message = MagicMock() - context.message.__dict__ = {"param": "value"} - context.timestamp = MagicMock() - context.timestamp.isoformat.return_value = "2023-01-01T00:00:00Z" - return context + + return new_mock_context( + message=mcp.types.CallToolRequest( + method="tools/call", + params=mcp.types.CallToolRequestParams( + name="test_method", + arguments={"param": "value"}, + ), + ) + ) @pytest.fixture @@ -59,7 +86,9 @@ class TestLoggingMiddleware: assert middleware.include_payloads is True assert middleware.max_payload_length == 500 - def test_format_message_without_payloads(self, mock_context): + def test_format_message_without_payloads( + self, mock_context: MiddlewareContext[Any] + ): """Test message formatting without payloads.""" middleware = LoggingMiddleware() formatted = middleware._format_message(mock_context) @@ -69,17 +98,16 @@ class TestLoggingMiddleware: assert "method=test_method" in formatted assert "payload=" not in formatted - def test_format_message_with_payloads(self, mock_context): + def test_format_message_with_payloads(self, mock_context: MiddlewareContext[Any]): """Test message formatting with payloads.""" middleware = LoggingMiddleware(include_payloads=True) formatted = middleware._format_message(mock_context) - assert "source=client" in formatted - assert "type=request" in formatted - assert "method=test_method" in formatted - assert 'payload={"param": "value"}' in formatted + assert formatted == snapshot( + 'source=client type=request method=test_method payload={"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}' + ) - def test_format_message_long_payload(self, mock_context): + def test_format_message_long_payload(self, mock_context: MiddlewareContext[Any]): """Test message formatting with long payload truncation.""" middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10) formatted = middleware._format_message(mock_context) @@ -87,7 +115,12 @@ class TestLoggingMiddleware: assert "payload=" in formatted assert "..." in formatted - async def test_on_message_success(self, mock_context, mock_call_next, caplog): + async def test_on_message_success( + self, + mock_context: MiddlewareContext[Any], + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): """Test logging successful messages.""" middleware = LoggingMiddleware() @@ -99,7 +132,9 @@ class TestLoggingMiddleware: assert "Processing message:" in caplog.text assert "Completed message: test_method" in caplog.text - async def test_on_message_failure(self, mock_context, caplog): + async def test_on_message_failure( + self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture + ): """Test logging failed messages.""" middleware = LoggingMiddleware() mock_call_next = AsyncMock(side_effect=ValueError("test error")) @@ -122,26 +157,40 @@ class TestStructuredLoggingMiddleware: assert middleware.log_level == logging.INFO assert middleware.include_payloads is False - def test_create_log_entry_basic(self, mock_context): + def test_create_log_entry_basic(self, mock_context: MiddlewareContext[Any]): """Test creating basic log entry.""" middleware = StructuredLoggingMiddleware() entry = middleware._create_log_entry(mock_context, "test_event") - assert entry["event"] == "test_event" - assert entry["timestamp"] == "2023-01-01T00:00:00Z" - assert entry["source"] == "client" - assert entry["type"] == "request" - assert entry["method"] == "test_method" - assert "payload" not in entry + assert entry == snapshot( + { + "event": "test_event", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + } + ) - def test_create_log_entry_with_payload(self, mock_context): + def test_create_log_entry_with_payload(self, mock_context: MiddlewareContext[Any]): """Test creating log entry with payload.""" middleware = StructuredLoggingMiddleware(include_payloads=True) entry = middleware._create_log_entry(mock_context, "test_event") - assert isinstance(entry["payload"], str) - assert json.loads(entry["payload"]) == {"param": "value"} - def test_create_log_entry_with_extra_fields(self, mock_context): + assert entry == snapshot( + { + "event": "test_event", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}', + } + ) + + def test_create_log_entry_with_extra_fields( + self, mock_context: MiddlewareContext[Any] + ): """Test creating log entry with extra fields.""" middleware = StructuredLoggingMiddleware() entry = middleware._create_log_entry( @@ -150,7 +199,12 @@ class TestStructuredLoggingMiddleware: assert entry["extra_field"] == "extra_value" - async def test_on_message_success(self, mock_context, mock_call_next, caplog): + async def test_on_message_success( + self, + mock_context: MiddlewareContext[Any], + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): """Test structured logging of successful messages.""" middleware = StructuredLoggingMiddleware() @@ -161,17 +215,33 @@ class TestStructuredLoggingMiddleware: # Check that we have structured JSON logs log_lines = [record.message for record in caplog.records] + assert len(log_lines) == 2 # start and success entries - start_entry = json.loads(log_lines[0]) - assert start_entry["event"] == "request_start" - assert start_entry["method"] == "test_method" + assert json.loads(log_lines[0]) == snapshot( + { + "event": "request_start", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + } + ) - success_entry = json.loads(log_lines[1]) - assert success_entry["event"] == "request_success" - assert success_entry["result_type"] == "str" + assert json.loads(log_lines[1]) == snapshot( + { + "event": "request_success", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "result_type": "str", + } + ) - async def test_on_message_failure(self, mock_context, caplog): + async def test_on_message_failure( + self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture + ): """Test structured logging of failed messages.""" middleware = StructuredLoggingMiddleware() mock_call_next = AsyncMock(side_effect=ValueError("test error")) @@ -187,47 +257,80 @@ class TestStructuredLoggingMiddleware: start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" - error_entry = json.loads(log_lines[1]) - assert error_entry["event"] == "request_error" - assert error_entry["error_type"] == "ValueError" - assert error_entry["error_message"] == "test error" - - async def test_on_message_with_pydantic_types_in_payload( - self, mock_context, mock_call_next, caplog - ): - """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" - from pydantic import AnyUrl - - mock_context.message.__dict__["url"] = AnyUrl("test://example/1") - - middleware = StructuredLoggingMiddleware(include_payloads=True) - - with caplog.at_level(logging.INFO): - result = await middleware.on_message(mock_context, mock_call_next) - - assert result == "test_result" - - log_lines = [record.message for record in caplog.records] - assert len(log_lines) == 2 - start_entry = json.loads(log_lines[0]) - - assert start_entry["event"] == "request_start" - payload = json.loads(start_entry["payload"]) - assert payload["url"] == "test://example/1" - - async def test_on_message_with_resource_template_in_payload( - self, mock_context, mock_call_next, caplog - ): - """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" - from fastmcp.resources import ResourceTemplate - - template = ResourceTemplate( - name="tmpl", - uri_template="tmpl://{id}", - parameters={"id": {"type": "string"}}, + assert json.loads(log_lines[1]) == snapshot( + { + "event": "request_error", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "error_type": "ValueError", + "error_message": "test error", + } ) - mock_context.message.__dict__["template"] = template + async def test_on_message_with_pydantic_types_in_payload( + self, + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): + """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" + + mock_context = new_mock_context( + message=mcp.types.ReadResourceRequest( + method="resources/read", + params=mcp.types.ReadResourceRequestParams( + uri=AnyUrl("test://example/1"), + ), + ) + ) + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + + assert len(log_lines) == 2 + assert json.loads(log_lines[0]) == snapshot( + { + "event": "request_start", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "payload": '{"method":"resources/read","params":{"_meta":null,"uri":"test://example/1"}}', + } + ) + assert json.loads(log_lines[1]) == snapshot( + { + "event": "request_success", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "result_type": "str", + "payload": '{"method":"resources/read","params":{"_meta":null,"uri":"test://example/1"}}', + } + ) + + async def test_on_message_with_resource_template_in_payload( + self, + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): + """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" + + mock_context = new_mock_context( + message=ResourceTemplate( + name="tmpl", + uri_template="tmpl://{id}", + parameters={"id": {"type": "string"}}, + ) + ) middleware = StructuredLoggingMiddleware(include_payloads=True) @@ -238,15 +341,19 @@ class TestStructuredLoggingMiddleware: log_lines = [record.message for record in caplog.records] assert len(log_lines) == 2 - start_entry = json.loads(log_lines[0]) - assert start_entry["event"] == "request_start" - payload = json.loads(start_entry["payload"]) - assert "template" in payload - assert isinstance(payload["template"], dict) - assert payload["template"]["uri_template"] == "tmpl://{id}" + assert json.loads(log_lines[0]) == snapshot( + { + "event": "request_start", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "payload": '{"name":"tmpl","title":null,"description":null,"tags":[],"meta":null,"enabled":true,"uri_template":"tmpl://{id}","mime_type":"text/plain","parameters":{"id":{"type":"string"}},"annotations":null}', + } + ) async def test_on_message_with_nonserializable_payload_falls_back_to_str( - self, mock_context, mock_call_next, caplog + self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture ): """Ensure non-JSONable objects fall back to string serialization in payload.""" @@ -254,7 +361,15 @@ class TestStructuredLoggingMiddleware: def __str__(self) -> str: return "NON_SERIALIZABLE" - mock_context.message.__dict__["obj"] = NonSerializable() + mock_context = new_mock_context( + message=mcp.types.CallToolRequest( + method="tools/call", + params=mcp.types.CallToolRequestParams( + name="test_method", + arguments={"obj": NonSerializable()}, + ), + ) + ) middleware = StructuredLoggingMiddleware(include_payloads=True) @@ -265,13 +380,19 @@ class TestStructuredLoggingMiddleware: log_lines = [record.message for record in caplog.records] assert len(log_lines) >= 2 - start_entry = json.loads(log_lines[0]) - assert start_entry["event"] == "request_start" - payload = json.loads(start_entry["payload"]) - assert payload["obj"] == "NON_SERIALIZABLE" + assert json.loads(log_lines[0]) == snapshot( + { + "event": "request_start", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"obj":"NON_SERIALIZABLE"}}}', + } + ) async def test_on_message_with_custom_serializer_applied( - self, mock_context, mock_call_next, caplog + self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture ): """Ensure a custom serializer is used for non-JSONable payloads.""" @@ -279,10 +400,18 @@ class TestStructuredLoggingMiddleware: def custom_serializer(_: Any) -> str: return "CUSTOM_PAYLOAD" - mock_context.message.__dict__["special"] = object() + mock_context = new_mock_context( + message=mcp.types.CallToolRequest( + method="tools/call", + params=mcp.types.CallToolRequestParams( + name="test_method", + arguments={"obj": "OBJECT"}, + ), + ) + ) middleware = StructuredLoggingMiddleware( - include_payloads=True, serializer=custom_serializer + include_payloads=True, payload_serializer=custom_serializer ) with caplog.at_level(logging.INFO): @@ -292,9 +421,16 @@ class TestStructuredLoggingMiddleware: log_lines = [record.message for record in caplog.records] assert len(log_lines) >= 2 - start_entry = json.loads(log_lines[0]) - assert start_entry["event"] == "request_start" - assert start_entry["payload"] == "CUSTOM_PAYLOAD" + assert json.loads(log_lines[0]) == snapshot( + { + "event": "request_start", + "timestamp": "2023-01-01T00:00:00+00:00", + "source": "client", + "type": "request", + "method": "test_method", + "payload": "CUSTOM_PAYLOAD", + } + ) @pytest.fixture @@ -338,7 +474,7 @@ class TestLoggingMiddlewareIntegration: """Integration tests for logging middleware with real FastMCP server.""" async def test_logging_middleware_logs_successful_operations( - self, logging_server, caplog + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture ): """Test that logging middleware captures successful operations.""" from fastmcp.client import Client @@ -364,7 +500,9 @@ class TestLoggingMiddlewareIntegration: assert processing_count == 2 assert completion_count == 2 - async def test_logging_middleware_logs_failures(self, logging_server, caplog): + async def test_logging_middleware_logs_failures( + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture + ): """Test that logging middleware captures failed operations.""" from fastmcp.client import Client @@ -384,7 +522,9 @@ class TestLoggingMiddlewareIntegration: assert "Processing message:" in log_text assert "Failed message: tools/call" in log_text - async def test_logging_middleware_with_payloads(self, logging_server, caplog): + async def test_logging_middleware_with_payloads( + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture + ): """Test logging middleware when configured to include payloads.""" from fastmcp.client import Client @@ -405,7 +545,7 @@ class TestLoggingMiddlewareIntegration: assert "payload=" in log_text async def test_structured_logging_middleware_produces_json( - self, logging_server, caplog + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture ): """Test that structured logging middleware produces parseable JSON logs.""" import json @@ -439,7 +579,7 @@ class TestLoggingMiddlewareIntegration: assert "method" in log_entry async def test_structured_logging_middleware_handles_errors( - self, logging_server, caplog + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture ): """Test structured logging of errors with JSON format.""" import json @@ -480,7 +620,7 @@ class TestLoggingMiddlewareIntegration: assert "error_message" in error_entry async def test_logging_middleware_with_different_operations( - self, logging_server, caplog + self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture ): """Test logging middleware with various MCP operations.""" from fastmcp.client import Client @@ -515,7 +655,9 @@ class TestLoggingMiddlewareIntegration: assert processing_count == 4 assert completion_count == 4 - async def test_logging_middleware_custom_configuration(self, logging_server): + async def test_logging_middleware_custom_configuration( + self, logging_server: FastMCP + ): """Test logging middleware with custom logger configuration.""" import io import logging From ac8caca269b708930d9e8f6ea99f5850f8f6b366 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 27 Aug 2025 08:03:16 -0500 Subject: [PATCH 10/26] PR Feedback --- src/fastmcp/server/middleware/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 03f9b2936..3511a3388 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -78,7 +78,7 @@ class LoggingMiddleware(Middleware): payload = self.payload_serializer(context.message) except Exception as e: self.logger.warning( - f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}." + f"Failed {e} to serialize payload: {context.type} {context.method} {context.source}." ) payload = default_serializer(context.message) From 6d9088704ed4bbada9264f9a27dbf7b04cf3381c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:19:56 -0400 Subject: [PATCH 11/26] Fix OAuth redirect URI validation for DCR compatibility (#1661) --- docs/servers/auth/oauth-proxy.mdx | 42 +++++++++++++------ src/fastmcp/server/auth/oauth_proxy.py | 9 ++++ .../server/auth/redirect_validation.py | 15 +++---- .../test_oauth_proxy_redirect_validation.py | 40 ++++++++++-------- tests/server/auth/test_redirect_validation.py | 41 ++++++++++++------ 5 files changed, 95 insertions(+), 52 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 7f566ff68..378a3ac26 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -164,8 +164,8 @@ The `OAuthProxy` class provides the complete proxy implementation: List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - - `None` (default): Only localhost redirect URIs allowed (`http://localhost:*`, `http://127.0.0.1:*`) - - Empty list `[]`: All redirect URIs allowed (not recommended for production) + - `None` (default): All redirect URIs allowed (for MCP/DCR compatibility) + - Empty list `[]`: No redirect URIs allowed - Custom list: Only matching patterns allowed These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. @@ -230,29 +230,45 @@ The proxy automatically: ## Client Redirect URI Security - -By default, OAuth Proxy only accepts localhost redirect URIs from MCP clients for security. You can customize this with the `allowed_client_redirect_uris` parameter: + +OAuth Proxy accepts all redirect URIs by default to maintain compatibility with MCP's Dynamic Client Registration (DCR) pattern, where clients register with unpredictable redirect URIs. + +If you know which clients will connect, you can restrict redirect URIs using the `allowed_client_redirect_uris` parameter: ```python -# Default: localhost only (secure) +# Default: allow all (for DCR compatibility) auth = OAuthProxy(...) +# Restrict to localhost only +auth = OAuthProxy( + ..., + allowed_client_redirect_uris=[ + "http://localhost:*", + "http://127.0.0.1:*" + ] +) + +# Allow specific known clients (e.g., Claude.ai) +auth = OAuthProxy( + ..., + allowed_client_redirect_uris=[ + "http://localhost:*", + "https://claude.ai/api/mcp/auth_callback" + ] +) + # Custom patterns with wildcards auth = OAuthProxy( ..., allowed_client_redirect_uris=[ "http://localhost:*", - "https://app.example.com/auth/*" + "https://*.example.com/auth/*" ] ) - -# Allow all (NOT recommended for production) -auth = OAuthProxy( - ..., - allowed_client_redirect_uris=[] -) ``` - + +**Tip:** Check your server logs for debug messages that say "Client registered with redirect_uri" messages to see what redirect URIs your clients are using. + ## Client Compatibility diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index c2a011041..2954f8baf 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -422,6 +422,15 @@ class OAuthProxy(OAuthProvider): # Store the ProxyDCRClient using the upstream ID self._clients[upstream_id] = proxy_client + # Log redirect URIs to help users discover what patterns they might need + if client_info.redirect_uris: + for uri in client_info.redirect_uris: + logger.debug( + "Client registered with redirect_uri: %s - if restricting redirect URIs, " + "ensure this pattern is allowed in allowed_client_redirect_uris", + uri, + ) + logger.debug( "Registered client %s with %d redirect URIs", upstream_id, diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py index 597c7d23f..f49958ad5 100644 --- a/src/fastmcp/server/auth/redirect_validation.py +++ b/src/fastmcp/server/auth/redirect_validation.py @@ -33,8 +33,9 @@ def validate_redirect_uri( Args: redirect_uri: The redirect URI to validate - allowed_patterns: List of allowed patterns. If None, defaults to localhost. - If empty list, all URIs are allowed. + allowed_patterns: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility). + If empty list, no URIs are allowed. + To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS. Returns: True if the redirect URI is allowed @@ -44,15 +45,9 @@ def validate_redirect_uri( uri_str = str(redirect_uri) - # If no patterns specified, default to localhost only + # If no patterns specified, allow all for DCR compatibility + # (clients need to dynamically register with their own redirect URIs) if allowed_patterns is None: - allowed_patterns = [ - "http://localhost:*", - "http://127.0.0.1:*", - ] - - # Empty list means allow all - if len(allowed_patterns) == 0: return True # Check if URI matches any allowed pattern diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index bfb8c8108..10b44218e 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -21,15 +21,15 @@ class MockTokenVerifier(TokenVerifier): class TestProxyDCRClient: """Test ProxyDCRClient redirect URI validation.""" - def test_default_localhost_only(self): - """Test that default configuration only allows localhost.""" + def test_default_allows_all(self): + """Test that default configuration allows all URIs for DCR compatibility.""" client = ProxyDCRClient( client_id="test", client_secret="secret", redirect_uris=[AnyUrl("http://localhost:3000")], ) - # Localhost should be allowed + # All URIs should be allowed by default for DCR compatibility assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) == AnyUrl( "http://localhost:3000" ) @@ -39,11 +39,12 @@ class TestProxyDCRClient: assert client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) == AnyUrl( "http://127.0.0.1:3000" ) - - # Non-localhost should fallback to base validation - # This will check against registered redirect_uris - with pytest.raises(InvalidRedirectUriError): - client.validate_redirect_uri(AnyUrl("http://example.com")) + assert client.validate_redirect_uri(AnyUrl("http://example.com")) == AnyUrl( + "http://example.com" + ) + assert client.validate_redirect_uri( + AnyUrl("https://claude.ai/api/mcp/auth_callback") + ) == AnyUrl("https://claude.ai/api/mcp/auth_callback") def test_custom_patterns(self): """Test custom redirect URI patterns.""" @@ -65,8 +66,8 @@ class TestProxyDCRClient: with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) - def test_empty_list_allows_all(self): - """Test that empty pattern list allows all URIs.""" + def test_empty_list_allows_none(self): + """Test that empty pattern list allows no URIs.""" client = ProxyDCRClient( client_id="test", client_secret="secret", @@ -74,10 +75,15 @@ class TestProxyDCRClient: allowed_redirect_uri_patterns=[], ) - # Everything should be allowed + # Nothing should be allowed (except the pre-registered one via fallback) + # Pre-registered URI should work via fallback to base validation assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) - assert client.validate_redirect_uri(AnyUrl("http://example.com")) - assert client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path")) + + # Non-registered URIs should be rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://example.com")) + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path")) def test_none_redirect_uri(self): """Test that None redirect URI uses default behavior.""" @@ -95,8 +101,8 @@ class TestProxyDCRClient: class TestOAuthProxyRedirectValidation: """Test OAuth proxy with redirect URI validation.""" - def test_proxy_default_localhost_validation(self): - """Test that OAuth proxy defaults to localhost-only validation.""" + def test_proxy_default_allows_all(self): + """Test that OAuth proxy defaults to allowing all URIs for DCR compatibility.""" proxy = OAuthProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", @@ -106,7 +112,7 @@ class TestOAuthProxyRedirectValidation: base_url="http://localhost:8000", ) - # The proxy should store None for default localhost patterns + # The proxy should store None for default (allow all) assert proxy._allowed_client_redirect_uris is None def test_proxy_custom_patterns(self): @@ -126,7 +132,7 @@ class TestOAuthProxyRedirectValidation: assert proxy._allowed_client_redirect_uris == custom_patterns def test_proxy_empty_list_validation(self): - """Test OAuth proxy with empty list (allow all).""" + """Test OAuth proxy with empty list (allow none).""" proxy = OAuthProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py index 53d96e356..87071a91f 100644 --- a/tests/server/auth/test_redirect_validation.py +++ b/tests/server/auth/test_redirect_validation.py @@ -66,21 +66,20 @@ class TestValidateRedirectUri: assert validate_redirect_uri(None, []) assert validate_redirect_uri(None, ["http://localhost:*"]) - def test_default_localhost_patterns(self): - """Test default localhost-only patterns when None is provided.""" - # Localhost patterns should be allowed by default + def test_default_allows_all(self): + """Test that None (default) allows all URIs for DCR compatibility.""" + # All URIs should be allowed when None is provided (DCR compatibility) assert validate_redirect_uri("http://localhost:3000", None) assert validate_redirect_uri("http://127.0.0.1:8080", None) + assert validate_redirect_uri("http://example.com", None) + assert validate_redirect_uri("https://app.example.com", None) + assert validate_redirect_uri("https://claude.ai/api/mcp/auth_callback", None) - # Non-localhost should be rejected by default - assert not validate_redirect_uri("http://example.com", None) - assert not validate_redirect_uri("https://app.example.com", None) - - def test_empty_list_allows_all(self): - """Test that empty list allows all redirect URIs.""" - assert validate_redirect_uri("http://localhost:3000", []) - assert validate_redirect_uri("http://example.com", []) - assert validate_redirect_uri("https://anywhere.com:9999/path", []) + def test_empty_list_allows_none(self): + """Test that empty list allows no redirect URIs.""" + assert not validate_redirect_uri("http://localhost:3000", []) + assert not validate_redirect_uri("http://example.com", []) + assert not validate_redirect_uri("https://anywhere.com:9999/path", []) def test_custom_patterns(self): """Test validation with custom pattern list.""" @@ -122,3 +121,21 @@ class TestDefaultPatterns: """Test that default patterns include localhost variations.""" assert "http://localhost:*" in DEFAULT_LOCALHOST_PATTERNS assert "http://127.0.0.1:*" in DEFAULT_LOCALHOST_PATTERNS + + def test_explicit_localhost_patterns(self): + """Test that explicitly passing DEFAULT_LOCALHOST_PATTERNS restricts to localhost.""" + # Localhost patterns should be allowed + assert validate_redirect_uri( + "http://localhost:3000", DEFAULT_LOCALHOST_PATTERNS + ) + assert validate_redirect_uri( + "http://127.0.0.1:8080", DEFAULT_LOCALHOST_PATTERNS + ) + + # Non-localhost should be rejected + assert not validate_redirect_uri( + "http://example.com", DEFAULT_LOCALHOST_PATTERNS + ) + assert not validate_redirect_uri( + "https://claude.ai/api/mcp/auth_callback", DEFAULT_LOCALHOST_PATTERNS + ) From f7d2caf83c7dade7cb1ff2b0114a81285eaca1e1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:35:35 -0400 Subject: [PATCH 12/26] Add allowed_client_redirect_uris to OAuth provider subclasses (#1662) --- src/fastmcp/server/auth/providers/azure.py | 4 ++++ src/fastmcp/server/auth/providers/github.py | 4 ++++ src/fastmcp/server/auth/providers/google.py | 4 ++++ src/fastmcp/server/auth/providers/workos.py | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index a4de21021..362f35f58 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -160,6 +160,7 @@ class AzureProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | None = None, ): """Initialize Azure OAuth provider. @@ -171,6 +172,8 @@ class AzureProvider(OAuthProxy): redirect_path: Redirect path configured in Azure (defaults to "/auth/callback") required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"]) timeout_seconds: HTTP request timeout for Azure API calls + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ settings = AzureProviderSettings.model_validate( { @@ -247,6 +250,7 @@ class AzureProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, + allowed_client_redirect_uris=allowed_client_redirect_uris, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 4f3fcc862..20dc23616 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -201,6 +201,7 @@ class GitHubProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | None = None, ): """Initialize GitHub OAuth provider. @@ -211,6 +212,8 @@ class GitHubProvider(OAuthProxy): redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") required_scopes: Required GitHub scopes (defaults to ["user"]) timeout_seconds: HTTP request timeout for GitHub API calls + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ settings = GitHubProviderSettings.model_validate( { @@ -264,6 +267,7 @@ class GitHubProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, # We act as the issuer for client registration + allowed_client_redirect_uris=allowed_client_redirect_uris, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 920034395..f025fe67b 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -217,6 +217,7 @@ class GoogleProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | None = None, ): """Initialize Google OAuth provider. @@ -230,6 +231,8 @@ class GoogleProvider(OAuthProxy): - "https://www.googleapis.com/auth/userinfo.email" for email access - "https://www.googleapis.com/auth/userinfo.profile" for profile info timeout_seconds: HTTP request timeout for Google API calls + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ settings = GoogleProviderSettings.model_validate( { @@ -284,6 +287,7 @@ class GoogleProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, # We act as the issuer for client registration + allowed_client_redirect_uris=allowed_client_redirect_uris, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 2fc138141..a0f939fb6 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -167,6 +167,7 @@ class WorkOSProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | None = None, ): """Initialize WorkOS OAuth provider. @@ -178,6 +179,8 @@ class WorkOSProvider(OAuthProxy): redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") required_scopes: Required OAuth scopes (no default) timeout_seconds: HTTP request timeout for WorkOS API calls + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ settings = WorkOSProviderSettings.model_validate( { @@ -241,6 +244,7 @@ class WorkOSProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, + allowed_client_redirect_uris=allowed_client_redirect_uris, ) logger.info( From 536ccde9e88c42690a75d6783f124c82d993a4b6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:43:39 -0400 Subject: [PATCH 13/26] Consolidate CLI config parsing and prevent infinite loops (#1660) --- src/fastmcp/cli/claude.py | 13 +- src/fastmcp/cli/cli.py | 394 ++++++------------ src/fastmcp/cli/install/claude_code.py | 9 +- src/fastmcp/cli/install/claude_desktop.py | 10 +- src/fastmcp/cli/install/cursor.py | 20 +- src/fastmcp/cli/install/mcp_json.py | 10 +- src/fastmcp/cli/install/shared.py | 4 +- src/fastmcp/cli/run.py | 105 ++--- src/fastmcp/utilities/cli.py | 124 ++++++ .../fastmcp_config/v1/fastmcp_config.py | 26 +- tests/cli/test_config.py | 33 +- tests/cli/test_fastmcp_config_integration.py | 10 +- tests/cli/test_run_config.py | 14 +- tests/cli/test_run_with_uv.py | 10 +- tests/utilities/test_cli.py | 99 ++--- 15 files changed, 393 insertions(+), 488 deletions(-) diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index 8873a3a7a..af42da058 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -98,12 +98,11 @@ def update_claude_config( if not deduplicated_packages: deduplicated_packages = None - # Build uv run command using Environment.build_uv_args() + # Build uv run command using Environment.build_uv_run_command() env_config = Environment( dependencies=deduplicated_packages, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() # Convert file path to absolute before adding to command # Split off any :object suffix first @@ -113,10 +112,14 @@ def update_claude_config( else: file_spec = str(Path(file_spec).resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", file_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", file_spec]) - server_config: dict[str, Any] = {"command": "uv", "args": args} + # Extract command and args for the config + server_config: dict[str, Any] = { + "command": full_command[0], + "args": full_command[1:], + } # Add environment variables if specified if env_vars: diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 34e1a908c..09de389db 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -13,7 +13,6 @@ from typing import Annotated, Literal import cyclopts import pyperclip -from pydantic import ValidationError from rich.console import Console from rich.table import Table @@ -22,14 +21,12 @@ from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.inspect import ( InspectFormat, format_info, inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli") console = Console() @@ -195,75 +192,33 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - from pathlib import Path + from fastmcp.utilities.cli import load_and_merge_config - from fastmcp.utilities.fastmcp_config import FastMCPConfig - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + editable=[str(p) for p in with_editable] if with_editable else None, + port=server_port, # Use deployment config for server port + ) - config = None - config_path = None + # Get server port from config if not specified via CLI + if not server_port: + server_port = config.deployment.port - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - sys.exit(1) - server_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") - - # Create FastMCPConfig from server_spec - if server_spec.endswith(".json"): - # Load existing config - config = FastMCPConfig.from_file(Path(server_spec)) - - # Merge environment settings with CLI args (CLI takes precedence) - if config.environment: - python = python or config.environment.python - project = project or ( - Path(config.environment.project) if config.environment.project else None - ) - with_requirements = with_requirements or ( - Path(config.environment.requirements) - if config.environment.requirements - else None - ) - # Merge editable paths from config with CLI args - if config.environment.editable and not with_editable: - with_editable = [Path(p) for p in config.environment.editable] - - # Merge packages from both sources - if config.environment.dependencies: - packages = list(config.environment.dependencies) - if with_packages: - packages.extend(with_packages) - with_packages = packages - - # Get server port from deployment config if not specified - if config.deployment and config.deployment.port: - server_port = server_port or config.deployment.port - else: - # Create config from file path - source = FileSystemSource(path=server_spec) - config = FastMCPConfig(source=source) + except FileNotFoundError: + sys.exit(1) logger.debug( "Starting dev server", extra={ "server_spec": server_spec, - "with_editable": [str(p) for p in with_editable] if with_editable else None, - "with_packages": with_packages, + "with_editable": config.environment.editable, + "with_packages": config.environment.dependencies, "ui_port": ui_port, "server_port": server_port, }, @@ -271,6 +226,10 @@ async def dev( try: # Load server to check for deprecated dependencies + if not config: + logger.error("No configuration available") + sys.exit(1) + assert config is not None # For type checker server: FastMCP = await config.source.load_server() if server.dependencies: import warnings @@ -282,7 +241,13 @@ async def dev( DeprecationWarning, stacklevel=2, ) - with_packages = list(set(with_packages + server.dependencies)) + # Merge server dependencies with environment dependencies + env_deps = config.environment.dependencies or [] + all_deps = list(set(env_deps + server.dependencies)) + if not config.environment: + config.environment = Environment(dependencies=all_deps) + else: + config.environment.dependencies = all_deps env_vars = {} if ui_port: @@ -303,18 +268,13 @@ async def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - # Create Environment object from CLI args - env_config = Environment( - python=python, - dependencies=with_packages if with_packages else None, - 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, + # Use the environment from config (already has CLI overrides applied) + uv_cmd = config.environment.build_uv_run_command( + ["fastmcp", "run", server_spec, "--no-banner"] ) - uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) - # Add --no-banner flag for dev command - uv_cmd.append("--no-banner") + # Set marker to prevent infinite loops when subprocess calls FastMCP + env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"} # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" @@ -322,7 +282,7 @@ async def dev( [npx_cmd, inspector_cmd] + uv_cmd, check=True, shell=shell, - env=dict(os.environ.items()) | env_vars, + env=env, ) sys.exit(process.returncode) except subprocess.CalledProcessError as e: @@ -454,135 +414,74 @@ async def run( Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ - # Convert None to empty lists for list parameters - with_packages = with_packages or [] - # Load configuration if needed - from pathlib import Path + from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config - from fastmcp.utilities.fastmcp_config import FastMCPConfig + # Check if we were spawned by uv (or user explicitly set --skip-env) + if skip_env or is_already_in_uv_subprocess(): + skip_env = True - config = None - config_path = None - editable = None # Initialize editable variable + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + transport=transport, + host=host, + port=port, + path=path, + log_level=log_level, + server_args=list(server_args) if server_args else None, + ) + except FileNotFoundError: + sys.exit(1) - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - sys.exit(1) + # Get effective values (CLI overrides take precedence) + final_transport = transport or config.deployment.transport + final_host = host or config.deployment.host + final_port = port or config.deployment.port + final_path = path or config.deployment.path + final_log_level = log_level or config.deployment.log_level + final_server_args = server_args or config.deployment.args - server_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") - - # Load config if server_spec is a .json file - if server_spec.endswith(".json"): - config_path = Path(server_spec) - if config_path.exists(): - # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig - try: - with open(config_path) as f: - data = json.load(f) - - # Check if it's an MCPConfig first (has canonical mcpServers key) - if "mcpServers" in data: - # It's an MCPConfig, we don't process these in the run command - # They should be handled through different code paths - config = None - else: - # Try to parse as FastMCPConfig - try: - adapter = get_cached_typeadapter(FastMCPConfig) - config = adapter.validate_python(data) - - # Merge deployment config with CLI values (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - server_args = ( - tuple(server_args) - if server_args - else tuple(config.deployment.args or ()) - ) - - # Merge environment config with CLI values (CLI takes precedence) - # BUT: Skip this if --skip-env is set - if config.environment and not skip_env: - python = python or config.environment.python - project = project or ( - Path(config.environment.project) - if config.environment.project - else None - ) - with_requirements = with_requirements or ( - Path(config.environment.requirements) - if config.environment.requirements - else None - ) - # Extract editable from config (no CLI override for this) - editable = config.environment.editable - - # Merge packages from both sources - if config.environment.dependencies: - packages = list(config.environment.dependencies) - if with_packages: - packages.extend(with_packages) - with_packages = packages - except ValidationError: - # Not a valid FastMCPConfig, treat as regular server spec - config = None - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, treat as regular server spec - config = None - else: - config = None logger.debug( "Running server or client", extra={ "server_spec": server_spec, - "transport": transport, - "host": host, - "port": port, - "path": path, - "log_level": log_level, - "server_args": list(server_args), + "transport": final_transport, + "host": final_host, + "port": final_port, + "path": final_path, + "log_level": final_log_level, + "server_args": list(final_server_args) if final_server_args else [], }, ) - # Check if we need to use uv run (either from CLI args or config) - # When --skip-env is set, we ignore config.environment entirely - needs_uv = python or with_packages or with_requirements or project or editable - if not needs_uv and config and config.environment and not skip_env: - # Check if config's environment needs uv (but only if not skipping env) - needs_uv = config.environment.needs_uv() + # Check if we need to use uv run (but skip if we're already in uv or user said to skip) + needs_uv = config.environment.needs_uv() and not skip_env if needs_uv: # Use uv run subprocess - always use run_with_uv which handles output correctly try: run_module.run_with_uv( server_spec=server_spec, - python_version=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, + python_version=config.environment.python, + with_packages=config.environment.dependencies, + with_requirements=Path(config.environment.requirements) + if config.environment.requirements + else None, + project=Path(config.environment.project) + if config.environment.project + else None, + transport=final_transport, + host=final_host, + port=final_port, + path=final_path, + log_level=final_log_level, show_banner=not no_banner, - editable=editable, + editable=config.environment.editable, ) except Exception as e: logger.error( @@ -598,12 +497,12 @@ async def run( try: await run_module.run_command( server_spec=server_spec, - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=list(server_args), + transport=final_transport, + host=final_host, + port=final_port, + path=final_path, + log_level=final_log_level, + server_args=list(final_server_args) if final_server_args else [], show_banner=not no_banner, skip_source=skip_source, ) @@ -696,100 +595,47 @@ async def inspect( Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ - # Convert None to empty lists for list parameters - with_packages = with_packages or [] - config = None - config_path = None + from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - sys.exit(1) + # Check if we were spawned by uv (or user explicitly set --skip-env) + if skip_env or is_already_in_uv_subprocess(): + skip_env = True - server_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + ) - # Create FastMCPConfig from server_spec - if server_spec.endswith(".json"): - config_path = Path(server_spec) - if config_path.exists(): + # Check if it's an MCPConfig (which inspect doesn't support) + if server_spec.endswith(".json") and config is None: + # This might be an MCPConfig, check the file try: - with open(config_path) as f: + with open(Path(server_spec)) as f: data = json.load(f) - - # Check if it's an MCPConfig (has mcpServers key) if "mcpServers" in data: - # MCPConfig - we don't process these in inspect logger.error("MCPConfig files are not supported by inspect command") sys.exit(1) - else: - # It's a FastMCPConfig - config = FastMCPConfig.from_file(config_path) + except (json.JSONDecodeError, FileNotFoundError): + pass - # Merge environment settings from config with CLI (CLI takes precedence) - if config.environment: - python = python or config.environment.python - project = project or ( - Path(config.environment.project) - if config.environment.project - else None - ) - with_requirements = with_requirements or ( - Path(config.environment.requirements) - if config.environment.requirements - else None - ) + except FileNotFoundError: + sys.exit(1) - # Merge packages from both sources - if config.environment.dependencies: - packages = list(config.environment.dependencies) - if with_packages: - packages.extend(with_packages) - with_packages = packages - except (json.JSONDecodeError, ValidationError) as e: - logger.error(f"Invalid configuration file: {e}") - sys.exit(1) - else: - logger.error(f"Configuration file not found: {config_path}") - sys.exit(1) - else: - # Create config from file path - source = FileSystemSource(path=server_spec) - config = FastMCPConfig(source=source) - - # Check if we need to use uv run (skip if --skip-env is set) - needs_uv = False - if not skip_env: - needs_uv = python or with_packages or with_requirements or project - if not needs_uv and config and config.environment: - needs_uv = config.environment.needs_uv() + # Check if we need to use uv run (but skip if we're already in uv or user said to skip) + needs_uv = config.environment.needs_uv() and not skip_env if needs_uv: # Build and run uv command - - # Create or update environment config - env_config = Environment( - python=python, - dependencies=with_packages if with_packages else None, - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - ) - + # The environment is already configured in the config object inspect_command = [ "fastmcp", "inspect", server_spec, - "--skip-env", # Prevent infinite loop when calling through uv ] # Add format and output flags if specified @@ -797,7 +643,7 @@ async def inspect( inspect_command.extend(["--format", format.value]) if output: inspect_command.extend(["--output", str(output)]) - env_config.run_with_uv(inspect_command) + config.environment.run_with_uv(inspect_command) return # run_with_uv exits the process logger.debug( @@ -811,6 +657,10 @@ async def inspect( try: # Load the server using the config + if not config: + logger.error("No configuration available") + sys.exit(1) + assert config is not None # For type checker server = await config.source.load_server() # Get basic server information @@ -936,8 +786,6 @@ async def prepare( """ from pathlib import Path - from fastmcp.utilities.fastmcp_config import FastMCPConfig - # Require output-dir if output_dir is None: logger.error( diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index aaa07503c..0117f67b4 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -115,7 +115,7 @@ def install_claude_code( if not deduplicated_packages: deduplicated_packages = None - # Build uv run command using Environment.build_uv_args() + # Build uv run command using Environment.build_uv_run_command() env_config = Environment( python=python_version, dependencies=deduplicated_packages, @@ -123,7 +123,6 @@ def install_claude_code( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -131,8 +130,8 @@ def install_claude_code( else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add"] @@ -144,7 +143,7 @@ def install_claude_code( # Add server name and command cmd_parts.extend([name, "--"]) - cmd_parts.extend(["uv"] + args) + cmd_parts.extend(full_command) try: # Run the claude mcp add command diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 28ce20eee..d4184f95d 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -88,21 +88,19 @@ def install_claude_desktop( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index bbb94a872..f10192e16 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -122,21 +122,19 @@ def install_cursor_workspace( project=str(project.resolve()) if project else None, editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) @@ -202,16 +200,14 @@ def install_cursor( project=str(project.resolve()) if project else None, editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # If workspace is specified, install to workspace-specific config if workspace: @@ -230,8 +226,8 @@ def install_cursor( # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 42696ae6c..5ea466590 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -63,21 +63,19 @@ def install_mcp_json( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Build MCP server configuration server_config = { - "command": "uv", - "args": args, + "command": full_command[0], + "args": full_command[1:], } # Add environment variables if provided diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 6db83403f..54b61977d 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -62,9 +62,9 @@ async def process_common_args( config = FastMCPConfig.from_file(config_path) # Merge packages from config if not overridden - if config.environment and config.environment.dependencies: + if config.environment.dependencies: # Merge with CLI packages (CLI takes precedence) - config_packages = list(config.environment.dependencies) or [] + config_packages = list(config.environment.dependencies) with_packages = list(set(with_packages + config_packages)) except (json.JSONDecodeError, ValidationError) as e: print(f"[red]Invalid configuration file: {e}[/red]") diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 47302f210..2b812e625 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -1,6 +1,7 @@ """FastMCP run command implementation with enhanced type hints.""" import json +import os import re import subprocess import sys @@ -8,7 +9,6 @@ from pathlib import Path from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x -from pydantic import ValidationError from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import ( @@ -17,7 +17,6 @@ from fastmcp.utilities.fastmcp_config import ( ) from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli.run") @@ -48,6 +47,10 @@ def run_with_uv( ) -> None: """Run a MCP server using uv run subprocess. + This function is called when we need to set up a Python environment with specific + dependencies before running the server. The config parsing and merging should already + be done by the caller. + Args: server_spec: Python file, object specification (file:obj), config file, or URL python_version: Python version to use (e.g. "3.10") @@ -60,70 +63,10 @@ def run_with_uv( path: Path to bind to when using http transport log_level: Log level show_banner: Whether to show the server banner + editable: Editable package paths """ - # Check if server_spec is a .json file - if server_spec.endswith(".json"): - config_path = Path(server_spec).resolve() # Get absolute path - if config_path.exists(): - # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig - try: - with open(config_path) as f: - data = json.load(f) - # Check if it's an MCPConfig first (has canonical mcpServers key) - if "mcpServers" in data: - # It's an MCPConfig, we don't process it here - just pass through - pass - else: - # Try to parse as FastMCPConfig - try: - adapter = get_cached_typeadapter(FastMCPConfig) - config: FastMCPConfig = adapter.validate_python(data) - - # Apply deployment settings - if config.deployment: - config.deployment.apply_runtime_settings(config_path) - - # Merge environment config with CLI args (CLI takes precedence) - if config.environment: - # Use CLI values if provided, otherwise fall back to config - python_version = python_version or config.environment.python - project = project or ( - Path(config.environment.project) - if config.environment.project - else None - ) - with_requirements = with_requirements or ( - Path(config.environment.requirements) - if config.environment.requirements - else None - ) - # Note: config editable is a list but CLI currently only supports single path - # Just pass through for now - Environment will handle the list - if not editable and config.environment.editable: - editable = config.environment.editable - - # Merge packages from both sources - # Only merge if with_packages doesn't already contain them - # (they may have been merged already in CLI) - if config.environment.dependencies and not with_packages: - with_packages = list(config.environment.dependencies) - - # Merge deployment config with CLI args (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - except ValidationError: - # Not a valid FastMCPConfig, just pass through - pass - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, just pass through - pass - - # Build uv command using Environment.build_uv_args() + # Build uv command using Environment.build_uv_run_command() env_config = Environment( python=python_version, dependencies=with_packages if with_packages else None, @@ -133,9 +76,9 @@ def run_with_uv( if isinstance(editable, list) else ([editable] if editable else None), ) - # Build the uv command - # Build the inner fastmcp command with --skip-env to prevent infinite recursion - inner_cmd = ["fastmcp", "run", "--skip-env", server_spec] + + # Build the inner fastmcp command (environment variable prevents infinite recursion) + inner_cmd = ["fastmcp", "run", server_spec] # Add transport options to the inner command if transport: @@ -154,13 +97,15 @@ def run_with_uv( inner_cmd.append("--no-banner") # Build the full uv command - uv_args = env_config.build_uv_args(inner_cmd) - cmd = ["uv"] + uv_args + cmd = env_config.build_uv_run_command(inner_cmd) + + # Set marker to prevent infinite loops when subprocess calls FastMCP again + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} # Run the command logger.debug(f"Running command: {' '.join(cmd)}") try: - process = subprocess.run(cmd, check=True) + process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error(f"Failed to run server: {e}") @@ -210,8 +155,7 @@ def load_fastmcp_config(config_path: Path) -> FastMCPConfig: config = FastMCPConfig.from_file(config_path) # Apply runtime settings from deployment config - if config.deployment: - config.deployment.apply_runtime_settings(config_path) + config.deployment.apply_runtime_settings(config_path) return config @@ -263,15 +207,14 @@ async def run_command( config = load_fastmcp_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - server_args = ( - server_args if server_args is not None else config.deployment.args - ) + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + server_args if server_args is not None else config.deployment.args + ) # Prepare source only (environment is handled by uv run) await config.prepare_source() if not skip_source else None diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 1c208da4d..55081a5ad 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -1,8 +1,12 @@ from __future__ import annotations +import json +import os from importlib.metadata import version +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +from pydantic import ValidationError from rich.align import Align from rich.console import Console, Group from rich.panel import Panel @@ -10,10 +14,130 @@ from rich.table import Table from rich.text import Text import fastmcp +from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter if TYPE_CHECKING: from fastmcp import FastMCP +logger = get_logger("cli.config") + + +def is_already_in_uv_subprocess() -> bool: + """Check if we're already running in a FastMCP uv subprocess.""" + return bool(os.environ.get("FASTMCP_UV_SPAWNED")) + + +def load_and_merge_config( + server_spec: str | None, + **cli_overrides, +) -> tuple[FastMCPConfig, str]: + """Load config from server_spec and apply CLI overrides. + + This consolidates the config parsing logic that was duplicated across + run, inspect, and dev commands. + + Args: + server_spec: Python file, config file, URL, or None to auto-detect + cli_overrides: CLI arguments that override config values + + Returns: + Tuple of (FastMCPConfig, resolved_server_spec) + """ + config = None + config_path = None + + # Auto-detect fastmcp.json if no server_spec provided + if server_spec is None: + config_path = Path("fastmcp.json") + if not config_path.exists(): + found_config = FastMCPConfig.find_config() + if found_config: + config_path = found_config + else: + logger.error( + "No server specification provided and no fastmcp.json found in current directory.\n" + "Please specify a server file or create a fastmcp.json configuration." + ) + raise FileNotFoundError("No server specification or fastmcp.json found") + + resolved_spec = str(config_path) + logger.info(f"Using configuration from {config_path}") + else: + resolved_spec = server_spec + + # Load config if server_spec is a .json file + if resolved_spec.endswith(".json"): + config_path = Path(resolved_spec) + if config_path.exists(): + try: + with open(config_path) as f: + data = json.load(f) + + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # MCPConfig - we don't process these here, just pass through + pass + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) + + # Apply deployment settings + if config.deployment: + config.deployment.apply_runtime_settings(config_path) + + except ValidationError: + # Not a valid FastMCPConfig, just pass through + pass + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, just pass through + pass + + # If we don't have a config object yet, create one from filesystem source + if config is None: + source = FileSystemSource(path=resolved_spec) + config = FastMCPConfig(source=source) + + # Convert to dict for immutable transformation + config_dict = config.model_dump() + + # Apply CLI overrides to config's environment (always exists due to default_factory) + if python_override := cli_overrides.get("python"): + config_dict["environment"]["python"] = python_override + if packages_override := cli_overrides.get("with_packages"): + # Merge packages - CLI packages are added to config packages + existing = config_dict["environment"].get("dependencies") or [] + config_dict["environment"]["dependencies"] = packages_override + existing + if requirements_override := cli_overrides.get("with_requirements"): + config_dict["environment"]["requirements"] = str(requirements_override) + if project_override := cli_overrides.get("project"): + config_dict["environment"]["project"] = str(project_override) + if editable_override := cli_overrides.get("editable"): + config_dict["environment"]["editable"] = editable_override + + # Apply deployment CLI overrides (always exists due to default_factory) + if transport_override := cli_overrides.get("transport"): + config_dict["deployment"]["transport"] = transport_override + if host_override := cli_overrides.get("host"): + config_dict["deployment"]["host"] = host_override + if port_override := cli_overrides.get("port"): + config_dict["deployment"]["port"] = port_override + if path_override := cli_overrides.get("path"): + config_dict["deployment"]["path"] = path_override + if log_level_override := cli_overrides.get("log_level"): + config_dict["deployment"]["log_level"] = log_level_override + if server_args_override := cli_overrides.get("server_args"): + config_dict["deployment"]["args"] = server_args_override + + # Create new config from modified dict + new_config = FastMCPConfig(**config_dict) + return new_config, resolved_spec + + LOGO_ASCII = r""" _ __ ___ _____ __ __ _____________ ____ ____ _ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \ diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py index 4fb197cbc..01cd1b846 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py @@ -63,16 +63,16 @@ class Environment(BaseModel): examples=[[".", "../my-package"], ["/path/to/package"]], ) - def build_uv_args(self, command: str | list[str] | None = None) -> list[str]: - """Build uv run arguments from this environment configuration. + def build_uv_run_command(self, command: list[str]) -> list[str]: + """Build complete uv run command with environment args and command to execute. Args: - command: Optional command to append (string or list of args) + command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) Returns: - List of arguments for uv run command + Complete command ready for subprocess.run, including "uv" prefix """ - args = ["run"] + args = ["uv", "run"] # Add project if specified if self.project: @@ -97,12 +97,8 @@ class Environment(BaseModel): for editable_path in self.editable: args.extend(["--with-editable", str(editable_path)]) - # Add the command if provided - if command: - if isinstance(command, str): - args.append(command) - else: - args.extend(command) + # Add the command + args.extend(command) return args @@ -116,14 +112,16 @@ class Environment(BaseModel): import sys # Build the full uv command - uv_args = self.build_uv_args(command) - cmd = ["uv"] + uv_args + cmd = self.build_uv_run_command(command) + + # Set marker to prevent infinite loops when subprocess calls FastMCP again + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} logger.debug(f"Running command: {' '.join(cmd)}") try: # Run without capturing output so it flows through naturally - process = subprocess.run(cmd, check=True) + process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error(f"Command failed: {e}") diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 094b9bc52..ec8b413c1 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -92,8 +92,8 @@ class TestEnvironment: ) assert config.environment.needs_uv() - def test_build_uv_args(self): - """Test build_uv_args() method.""" + def test_build_uv_run_command(self): + """Test build_uv_run_command() method.""" config = FastMCPConfig( source={"path": "server.py"}, environment={ @@ -104,23 +104,24 @@ class TestEnvironment: }, ) - args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) - assert args[0] == "run" + assert cmd[0] == "uv" + assert cmd[1] == "run" # Python version not added when project is specified (project defines its own Python) - assert "--python" not in args - assert "3.12" not in args - assert "--project" in args - assert "." in args - assert "--with" in args - assert "requests" in args - assert "numpy" in args - assert "--with-requirements" in args - assert "requirements.txt" in args + assert "--python" not in cmd + assert "3.12" not in cmd + assert "--project" in cmd + assert "." in cmd + assert "--with" in cmd + assert "requests" in cmd + assert "numpy" in cmd + assert "--with-requirements" in cmd + assert "requirements.txt" in cmd # Command args should be at the end - assert "fastmcp" in args[-3:] - assert "run" in args[-2:] - assert "server.py" in args[-1:] + assert "fastmcp" in cmd[-3:] + assert "run" in cmd[-2:] + assert "server.py" in cmd[-1:] def test_run_with_uv(self): """Test run_with_uv() subprocess execution.""" diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_fastmcp_config_integration.py index 98070385f..800b23b08 100644 --- a/tests/cli/test_fastmcp_config_integration.py +++ b/tests/cli/test_fastmcp_config_integration.py @@ -227,14 +227,14 @@ class TestPathResolution: environment={"requirements": "requirements.txt"}, # type: ignore[arg-type] ) - # Build UV args + # Build UV command assert config.environment is not None - uv_args = config.environment.build_uv_args(["fastmcp", "run"]) + uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run"]) # Should include requirements file - assert "--with-requirements" in uv_args - req_idx = uv_args.index("--with-requirements") + 1 - assert uv_args[req_idx] == "requirements.txt" + assert "--with-requirements" in uv_cmd + req_idx = uv_cmd.index("--with-requirements") + 1 + assert uv_cmd[req_idx] == "requirements.txt" class TestConfigValidation: diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index 5ef7c26e1..07443414f 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -261,13 +261,11 @@ def test_environment_config_path_resolution(tmp_path): config = load_fastmcp_config(config_file) - # Check that UV args are built with resolved paths - uv_args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) + # Check that UV command is built with resolved paths + uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) - assert "--with-requirements" in uv_args - assert "--project" in uv_args + assert "--with-requirements" in uv_cmd + assert "--project" in uv_cmd # Path should be resolved relative to config file - req_idx = uv_args.index("--with-requirements") + 1 - assert ( - Path(uv_args[req_idx]).is_absolute() or uv_args[req_idx] == "requirements.txt" - ) + req_idx = uv_cmd.index("--with-requirements") + 1 + assert Path(uv_cmd[req_idx]).is_absolute() or uv_cmd[req_idx] == "requirements.txt" diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 027939191..446b21787 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -25,16 +25,18 @@ class TestRunWithUv: # Check the command that was called mock_run.assert_called_once() cmd = mock_run.call_args[0][0] + env = mock_run.call_args.kwargs.get("env", {}) expected = [ "uv", "run", "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected + # Check that the environment marker is set + assert env.get("FASTMCP_UV_SPAWNED") == "1" @patch("subprocess.run") def test_run_with_uv_python_version(self, mock_run): @@ -54,7 +56,6 @@ class TestRunWithUv: "3.11", "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -80,7 +81,6 @@ class TestRunWithUv: assert cmd[4:] == [ "fastmcp", "run", - "--skip-env", "server.py", ] @@ -104,7 +104,6 @@ class TestRunWithUv: "numpy", # original order preserved "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -128,7 +127,6 @@ class TestRunWithUv: str(req_path.resolve()), # auto-resolved to absolute path "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -157,7 +155,6 @@ class TestRunWithUv: "run", "fastmcp", "run", - "--skip-env", "server.py", "--transport", "http", @@ -220,7 +217,6 @@ class TestRunWithUv: assert cmd[next_idx:] == [ "fastmcp", "run", - "--skip-env", "server.py", "--transport", "http", diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index bcb45f664..cd8b55dcb 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -3,22 +3,23 @@ from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment -class TestEnvironmentBuildUVArgs: - """Test the Environment.build_uv_args() method.""" +class TestEnvironmentBuildUVRunCommand: + """Test the Environment.build_uv_run_command() method.""" - def test_build_uv_args_basic(self): - """Test building basic uv args with no environment config.""" + def test_build_uv_run_command_basic(self): + """Test building basic uv command with no environment config.""" env = Environment() - args = env.build_uv_args(["fastmcp", "run", "server.py"]) - expected = ["run", "fastmcp", "run", "server.py"] - assert args == expected + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + expected = ["uv", "run", "fastmcp", "run", "server.py"] + assert cmd == expected - def test_build_uv_args_with_editable(self): - """Test building uv args with editable package.""" + def test_build_uv_run_command_with_editable(self): + """Test building uv command with editable package.""" editable_path = "/path/to/package" env = Environment(editable=[editable_path]) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with-editable", editable_path, @@ -26,13 +27,14 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_packages(self): - """Test building uv args with additional packages.""" + def test_build_uv_run_command_with_packages(self): + """Test building uv command with additional packages.""" env = Environment(dependencies=["pkg1", "pkg2"]) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with", "pkg1", @@ -42,13 +44,14 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_python_version(self): - """Test building uv args with Python version.""" + def test_build_uv_run_command_with_python_version(self): + """Test building uv command with Python version.""" env = Environment(python="3.10") - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--python", "3.10", @@ -56,14 +59,15 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_requirements(self): - """Test building uv args with requirements file.""" + def test_build_uv_run_command_with_requirements(self): + """Test building uv command with requirements file.""" requirements_path = "/path/to/requirements.txt" env = Environment(requirements=requirements_path) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with-requirements", requirements_path, @@ -71,18 +75,26 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_project(self): - """Test building uv args with project directory.""" + def test_build_uv_run_command_with_project(self): + """Test building uv command with project directory.""" project_path = "/path/to/project" env = Environment(project=project_path) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) - expected = ["run", "--project", project_path, "fastmcp", "run", "server.py"] - assert args == expected + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + expected = [ + "uv", + "run", + "--project", + project_path, + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected - def test_build_uv_args_with_everything(self): - """Test building uv args with all options.""" + 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" env = Environment( @@ -91,8 +103,9 @@ class TestEnvironmentBuildUVArgs: requirements=requirements_path, editable=[editable_path], ) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--python", "3.10", @@ -108,23 +121,12 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_no_command(self): - """Test building uv args without command.""" - env = Environment(dependencies=["pkg1"]) - args = env.build_uv_args() - expected = ["run", "--with", "pkg1"] - assert args == expected + # Note: These tests are removed because build_uv_run_command now requires a command + # and only accepts a list, not optional or string commands - def test_build_uv_args_with_string_command(self): - """Test building uv args with string command.""" - env = Environment() - args = env.build_uv_args("python") - expected = ["run", "python"] - assert args == expected - - def test_build_uv_args_project_with_extras(self): + def test_build_uv_run_command_project_with_extras(self): """Test that project flag works with additional dependencies.""" project_path = "/path/to/project" env = Environment( @@ -133,8 +135,9 @@ class TestEnvironmentBuildUVArgs: dependencies=["pandas"], # Should be added on top of project editable=["/pkg"], # Should be added on top of project ) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--project", project_path, @@ -146,7 +149,7 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected class TestEnvironmentNeedsUV: From 210e9cc5ca4003a0bcfac73399051a780b67a43a Mon Sep 17 00:00:00 2001 From: romanusyk Date: Fri, 29 Aug 2025 16:58:45 +0300 Subject: [PATCH 14/26] Add default scope handling in OAuth proxy (#1667) --- docs/servers/auth/oauth-proxy.mdx | 27 ++++++++++++++++++++++++++ src/fastmcp/server/auth/oauth_proxy.py | 3 +++ tests/server/auth/test_oauth_proxy.py | 16 +++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 378a3ac26..5f3a1c1ad 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -172,6 +172,33 @@ The `OAuthProxy` class provides the complete proxy implementation: +### Dynamic client scope + +When `OAuthProxy` creates a dynamic client (`ProxyDCRClient`) during registration or for temporary/unregistered access, it sets the client's `scope` string from your `TokenVerifier.required_scopes` (joined with spaces). If `required_scopes` is empty or `None`, the client's `scope` will be an empty string. + +```python +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy + +token_verifier = JWTVerifier( + jwks_uri="https://provider/.well-known/jwks.json", + issuer="https://provider", + audience="my-app", +) +token_verifier.required_scopes = ["read", "write"] + +auth = OAuthProxy( + upstream_authorization_endpoint="https://provider/authorize", + upstream_token_endpoint="https://provider/token", + upstream_client_id="cid", + upstream_client_secret="secret", + token_verifier=token_verifier, + base_url="https://your-server.com", +) + +# Any dynamic client created by the proxy will have scope "read write" +``` + ```python from fastmcp import FastMCP from fastmcp.server.auth import OAuthProxy diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 2954f8baf..8f7418a83 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -317,6 +317,7 @@ class OAuthProxy(OAuthProvider): self._upstream_client_id = upstream_client_id self._upstream_client_secret = SecretStr(upstream_client_secret) self._upstream_revocation_endpoint = upstream_revocation_endpoint + self._default_scope_str = " ".join(self.required_scopes or []) # Store redirect configuration self._redirect_path = ( @@ -376,6 +377,7 @@ class OAuthProxy(OAuthProvider): AnyUrl("http://localhost") ], # Placeholder, validation uses allowed_patterns grant_types=["authorization_code", "refresh_token"], + scope=self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) @@ -415,6 +417,7 @@ class OAuthProxy(OAuthProvider): redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], grant_types=client_info.grant_types or ["authorization_code", "refresh_token"], + scope=self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 56b3e9729..28019f39a 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -220,6 +220,7 @@ class TestOAuthProxyComprehensive: assert stored_client is not None assert stored_client.client_id == "test-client-id" assert stored_client.client_secret == "test-client-secret" + assert stored_client.scope == "read write" async def test_register_client_empty_grant_types(self, oauth_proxy): """Test client registration with empty grant types.""" @@ -266,6 +267,7 @@ class TestOAuthProxyComprehensive: assert len(temp_client.redirect_uris) >= 1 # ProxyDCRClient uses a placeholder URL but accepts any localhost URI assert str(temp_client.redirect_uris[0]) == "http://localhost/" + assert temp_client.scope == "read write" # Test that it accepts any localhost redirect URI from pydantic import AnyUrl @@ -379,6 +381,20 @@ class TestOAuthProxyComprehensive: # Proxy should NOT add any scopes - providers handle their own defaults assert "scope" not in query_params + async def test_client_scope_empty_when_no_required_scopes(self): + """When required_scopes is None/empty, client scope should be empty string.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=Mock(required_scopes=None), + base_url="https://api.example.com", + ) + + temp_client = await proxy.get_client("any-client") + assert temp_client.scope == "" + async def test_load_authorization_code_valid(self, oauth_proxy): """Test loading a valid authorization code.""" # Store a client code From 5fe8e2fe6b457d13a91e22ffdbba846712851468 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Aug 2025 21:14:56 -0400 Subject: [PATCH 15/26] Fix OAuth token expiry handling (#1649) (#1671) --- src/fastmcp/client/auth/oauth.py | 65 ++++++-- tests/client/auth/test_oauth_token_expiry.py | 152 +++++++++++++++++++ 2 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 tests/client/auth/test_oauth_token_expiry.py diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 88a8a9d33..b589c2873 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -4,6 +4,7 @@ import asyncio import json import webbrowser from asyncio import Future +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal from urllib.parse import urlparse @@ -18,7 +19,7 @@ from mcp.shared.auth import ( from mcp.shared.auth import ( OAuthToken as OAuthToken, ) -from pydantic import AnyHttpUrl, ValidationError +from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError from uvicorn.server import Server from fastmcp import settings as fastmcp_global_settings @@ -33,6 +34,17 @@ __all__ = ["OAuth"] logger = get_logger(__name__) +class StoredToken(BaseModel): + """Token storage format with absolute expiry time.""" + + token_payload: OAuthToken + expires_at: datetime | None + + +# Create TypeAdapter at module level for efficient parsing +stored_token_adapter = TypeAdapter(StoredToken) + + def default_cache_dir() -> Path: return fastmcp_global_settings.home / "oauth-mcp-client-cache" @@ -77,13 +89,28 @@ class FileTokenStorage(TokenStorage): path = self._get_file_path("tokens") try: - tokens = OAuthToken.model_validate_json(path.read_text()) - # now = datetime.datetime.now(datetime.timezone.utc) - # if tokens.expires_at is not None and tokens.expires_at <= now: - # logger.debug(f"Token expired for {self.get_base_url(self.server_url)}") - # return None - return tokens - except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e: + # Parse JSON and validate as StoredToken + stored = stored_token_adapter.validate_json(path.read_text()) + + # Check if token is expired + if stored.expires_at is not None: + now = datetime.now(timezone.utc) + if now >= stored.expires_at: + logger.debug( + f"Token expired for {self.get_base_url(self.server_url)}" + ) + return None + + # Recalculate expires_in to be correct relative to now + if stored.token_payload.expires_in is not None: + remaining = stored.expires_at - now + stored.token_payload.expires_in = max( + 0, int(remaining.total_seconds()) + ) + + return stored.token_payload + + except (FileNotFoundError, ValidationError) as e: logger.debug( f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}" ) @@ -92,7 +119,18 @@ class FileTokenStorage(TokenStorage): async def set_tokens(self, tokens: OAuthToken) -> None: """Save tokens to file storage.""" path = self._get_file_path("tokens") - path.write_text(tokens.model_dump_json(indent=2)) + + # Calculate absolute expiry time if expires_in is present + expires_at = None + if tokens.expires_in is not None: + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=tokens.expires_in + ) + + # Create StoredToken and save using Pydantic serialization + stored = StoredToken(token_payload=tokens, expires_at=expires_at) + + path.write_text(stored.model_dump_json(indent=2)) logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}") async def get_client_info(self) -> OAuthClientInformationFull | None: @@ -252,6 +290,15 @@ class OAuth(OAuthClientProvider): callback_handler=self.callback_handler, ) + async def _initialize(self) -> None: + """Load stored tokens and client info, properly setting token expiry.""" + # Call parent's _initialize to load tokens and client info + await super()._initialize() + + # If tokens were loaded and have expires_in, update the context's token_expiry_time + if self.context.current_tokens and self.context.current_tokens.expires_in: + self.context.update_token_expiry(self.context.current_tokens) + async def redirect_handler(self, authorization_url: str) -> None: """Open browser for authorization.""" logger.info(f"OAuth authorization URL: {authorization_url}") diff --git a/tests/client/auth/test_oauth_token_expiry.py b/tests/client/auth/test_oauth_token_expiry.py new file mode 100644 index 000000000..77e5d552e --- /dev/null +++ b/tests/client/auth/test_oauth_token_expiry.py @@ -0,0 +1,152 @@ +"""Test OAuth token expiry handling with absolute timestamps.""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from mcp.shared.auth import OAuthToken + +from fastmcp.client.auth.oauth import FileTokenStorage + + +@pytest.mark.asyncio +async def test_token_storage_with_expiry(tmp_path: Path): + """Test that tokens are stored with absolute expiry time and loaded correctly.""" + storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) + + # Create a token with 3600 seconds expiry + token = OAuthToken( + access_token="test_token", + token_type="Bearer", + expires_in=3600, + refresh_token="refresh_token", + ) + + # Save the token + await storage.set_tokens(token) + + # Check that the file contains the dataclass format + token_file = storage._get_file_path("tokens") + data = json.loads(token_file.read_text()) + + assert "token_payload" in data + assert "expires_at" in data + assert data["expires_at"] is not None + # expires_at should be approximately now + 3600 seconds + expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) + expected = datetime.now(timezone.utc) + timedelta(seconds=3600) + assert abs((expires_at - expected).total_seconds()) < 2 + + # Load the token back + loaded_token = await storage.get_tokens() + assert loaded_token is not None + assert loaded_token.access_token == "test_token" + # expires_in should be recalculated to be approximately 3600 (minus loading time) + assert loaded_token.expires_in is not None + assert 3595 <= loaded_token.expires_in <= 3600 + + +@pytest.mark.asyncio +async def test_expired_token_returns_none(tmp_path: Path): + """Test that expired tokens return None when loaded.""" + storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) + + # Manually create an already-expired token file + token_file = storage._get_file_path("tokens") + past_expiry = datetime.now(timezone.utc) - timedelta( + seconds=10 + ) # Expired 10 seconds ago + + expired_token = { + "token_payload": { + "access_token": "test_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh_token", + }, + "expires_at": past_expiry.isoformat(), + } + token_file.write_text(json.dumps(expired_token, indent=2, default=str)) + + # Load the token - should return None since it's expired + loaded_token = await storage.get_tokens() + assert loaded_token is None + + +@pytest.mark.asyncio +async def test_token_without_expiry(tmp_path: Path): + """Test that tokens without expires_in are handled correctly.""" + storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) + + # Create a token without expires_in (perpetual token) + token = OAuthToken( + access_token="test_token", + token_type="Bearer", + expires_in=None, + refresh_token="refresh_token", + ) + + # Save the token + await storage.set_tokens(token) + + # Check that expires_at is None in the file + token_file = storage._get_file_path("tokens") + data = json.loads(token_file.read_text()) + assert data["expires_at"] is None + + # Load the token back - should work since no expiry + loaded_token = await storage.get_tokens() + assert loaded_token is not None + assert loaded_token.access_token == "test_token" + assert loaded_token.expires_in is None + + +@pytest.mark.asyncio +async def test_invalid_format_returns_none(tmp_path: Path): + """Test that invalid token format returns None.""" + storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) + + # Manually write an invalid format token file (missing required fields) + token_file = storage._get_file_path("tokens") + invalid_token = { + "access_token": "invalid_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh_token", + } + token_file.write_text(json.dumps(invalid_token, indent=2)) + + # Try to load - should return None + loaded_token = await storage.get_tokens() + assert loaded_token is None + + +@pytest.mark.asyncio +async def test_token_expiry_recalculated_on_load(tmp_path: Path): + """Test that expires_in is correctly recalculated when loading tokens.""" + storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) + + # Manually create a token file with a specific expires_at + token_file = storage._get_file_path("tokens") + future_expiry = datetime.now(timezone.utc) + timedelta( + seconds=1800 + ) # 30 minutes from now + + stored_token = { + "token_payload": { + "access_token": "test_token", + "token_type": "Bearer", + "expires_in": 3600, # Original value (will be recalculated) + "refresh_token": "refresh_token", + }, + "expires_at": future_expiry.isoformat(), + } + token_file.write_text(json.dumps(stored_token, indent=2, default=str)) + + # Load the token + loaded_token = await storage.get_tokens() + assert loaded_token is not None + # expires_in should be recalculated to approximately 1800 seconds + assert loaded_token.expires_in is not None + assert 1795 <= loaded_token.expires_in <= 1800 From 6d46192659200eb1d93f1741c4cf42be432e291b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Aug 2025 21:37:39 -0400 Subject: [PATCH 16/26] Internal refactor: fastmcp config to mcp server config (#1672) --- .github/workflows/update-config-schema.yml | 18 ++--- .../latest.json | 0 .../v1.json | 0 docs/docs.json | 42 ++++++------ docs/python-sdk/fastmcp-cli-cli.mdx | 12 ++-- .../fastmcp-cli-install-claude_code.mdx | 2 +- .../fastmcp-cli-install-claude_desktop.mdx | 2 +- .../python-sdk/fastmcp-cli-install-cursor.mdx | 4 +- .../fastmcp-cli-install-mcp_json.mdx | 2 +- docs/python-sdk/fastmcp-cli-run.mdx | 23 ++++--- docs/python-sdk/fastmcp-client-auth-oauth.mdx | 34 ++++++---- docs/python-sdk/fastmcp-client-client.mdx | 66 +++++++++---------- .../fastmcp-server-auth-oauth_proxy.mdx | 20 +++--- .../fastmcp-server-auth-providers-workos.mdx | 6 +- ...astmcp-server-auth-redirect_validation.mdx | 5 +- .../fastmcp-server-middleware-logging.mdx | 24 +++++-- docs/python-sdk/fastmcp-utilities-cli.mdx | 32 ++++++++- ...-utilities-mcp_server_config-__init__.mdx} | 2 +- ...ilities-mcp_server_config-v1-__init__.mdx} | 2 +- ...cp_server_config-v1-mcp_server_config.mdx} | 62 ++++++++--------- ...mcp_server_config-v1-sources-__init__.mdx} | 2 +- ...ies-mcp_server_config-v1-sources-base.mdx} | 8 +-- ...p_server_config-v1-sources-filesystem.mdx} | 8 +-- examples/fastmcp_config_demo/README.md | 6 +- src/fastmcp/cli/claude.py | 2 +- src/fastmcp/cli/cli.py | 6 +- src/fastmcp/cli/install/claude_code.py | 2 +- src/fastmcp/cli/install/claude_desktop.py | 2 +- src/fastmcp/cli/install/cursor.py | 2 +- src/fastmcp/cli/install/mcp_json.py | 2 +- src/fastmcp/cli/install/shared.py | 12 ++-- src/fastmcp/cli/run.py | 22 +++---- src/fastmcp/client/transports.py | 2 +- src/fastmcp/utilities/cli.py | 20 +++--- .../__init__.py | 10 +-- .../v1/__init__.py | 0 .../v1/mcp_server_config.py} | 16 ++--- .../v1/schema.json | 0 .../v1/sources/__init__.py | 0 .../v1/sources/base.py | 0 .../v1/sources/filesystem.py | 2 +- tests/cli/test_cli.py | 2 +- tests/cli/test_config.py | 62 ++++++++--------- ... => test_mcp_server_config_integration.py} | 30 ++++----- ...ma.py => test_mcp_server_config_schema.py} | 6 +- tests/cli/test_project_prepare.py | 42 ++++++------ tests/cli/test_run.py | 6 +- tests/cli/test_run_config.py | 28 ++++---- tests/cli/test_server_args.py | 12 ++-- tests/utilities/test_cli.py | 2 +- 50 files changed, 363 insertions(+), 309 deletions(-) rename docs/assets/schemas/{fastmcp_config => mcp_server_config}/latest.json (100%) rename docs/assets/schemas/{fastmcp_config => mcp_server_config}/v1.json (100%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-__init__.mdx => fastmcp-utilities-mcp_server_config-__init__.mdx} (84%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-v1-__init__.mdx => fastmcp-utilities-mcp_server_config-v1-__init__.mdx} (74%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx => fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx} (65%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx => fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx} (70%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-v1-sources-base.mdx => fastmcp-utilities-mcp_server_config-v1-sources-base.mdx} (63%) rename docs/python-sdk/{fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx => fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx} (51%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/__init__.py (56%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/v1/__init__.py (100%) rename src/fastmcp/utilities/{fastmcp_config/v1/fastmcp_config.py => mcp_server_config/v1/mcp_server_config.py} (98%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/v1/schema.json (100%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/v1/sources/__init__.py (100%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/v1/sources/base.py (100%) rename src/fastmcp/utilities/{fastmcp_config => mcp_server_config}/v1/sources/filesystem.py (99%) rename tests/cli/{test_fastmcp_config_integration.py => test_mcp_server_config_integration.py} (93%) rename tests/cli/{test_fastmcp_config_schema.py => test_mcp_server_config_schema.py} (96%) diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 9b3fa71a6..eb4115dd7 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -1,4 +1,4 @@ -name: Update FastMCPConfig Schema +name: Update MCPServerConfig Schema # This workflow runs on merges to main to automatically update the config schema # by creating a PR when changes are needed. @@ -7,8 +7,8 @@ on: push: branches: ["main"] paths: - - "src/fastmcp/utilities/fastmcp_config/**" - - "!src/fastmcp/utilities/fastmcp_config/v1/schema.json" # Exclude the local schema file + - "src/fastmcp/utilities/mcp_server_config/**" + - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" # Exclude the local schema file workflow_dispatch: permissions: @@ -45,23 +45,23 @@ jobs: # Generate schema in docs/public for web access uv run python -c " - from fastmcp.utilities.fastmcp_config import generate_schema + from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/latest.json') print('✅ Latest schema generated in docs/public') " # Also update the v1 schema in docs/public uv run python -c " - from fastmcp.utilities.fastmcp_config import generate_schema + from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/v1.json') print('✅ v1 schema generated in docs/public') " # Generate schema in the source directory for local development uv run python -c " - from fastmcp.utilities.fastmcp_config import generate_schema - generate_schema('src/fastmcp/utilities/fastmcp_config/v1/schema.json') - print('✅ Schema generated in utilities/fastmcp_config/v1/') + from fastmcp.utilities.mcp_server_config import generate_schema + generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') + print('✅ Schema generated in utilities/mcp_server_config/v1/') " - name: Create Pull Request @@ -73,7 +73,7 @@ jobs: body: | This PR updates the fastmcp.json schema files to match the current source code. - The schema is automatically generated from `src/fastmcp/utilities/fastmcp_config/` to ensure consistency. + The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency. **Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. diff --git a/docs/assets/schemas/fastmcp_config/latest.json b/docs/assets/schemas/mcp_server_config/latest.json similarity index 100% rename from docs/assets/schemas/fastmcp_config/latest.json rename to docs/assets/schemas/mcp_server_config/latest.json diff --git a/docs/assets/schemas/fastmcp_config/v1.json b/docs/assets/schemas/mcp_server_config/v1.json similarity index 100% rename from docs/assets/schemas/fastmcp_config/v1.json rename to docs/assets/schemas/mcp_server_config/v1.json diff --git a/docs/docs.json b/docs/docs.json index 9687185dc..3cd031270 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -380,33 +380,33 @@ "python-sdk/fastmcp-utilities-cli", "python-sdk/fastmcp-utilities-components", "python-sdk/fastmcp-utilities-exceptions", - { - "group": "fastmcp_config", - "pages": [ - "python-sdk/fastmcp-utilities-fastmcp_config-__init__", - { - "group": "v1", - "pages": [ - "python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__", - "python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config", - { - "group": "sources", - "pages": [ - "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__", - "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base", - "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem" - ] - } - ] - } - ] - }, "python-sdk/fastmcp-utilities-http", "python-sdk/fastmcp-utilities-inspect", "python-sdk/fastmcp-utilities-json_schema", "python-sdk/fastmcp-utilities-json_schema_type", "python-sdk/fastmcp-utilities-logging", "python-sdk/fastmcp-utilities-mcp_config", + { + "group": "mcp_server_config", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-__init__", + { + "group": "v1", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config", + { + "group": "sources", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem" + ] + } + ] + } + ] + }, "python-sdk/fastmcp-utilities-openapi", "python-sdk/fastmcp-utilities-tests", "python-sdk/fastmcp-utilities-types" diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 60838c769..83677c671 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx index fd1d888ff..3889da224 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx @@ -57,7 +57,7 @@ Install FastMCP server in Claude Code. - True if installation was successful, False otherwise -### `claude_code_command` +### `claude_code_command` ```python claude_code_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx index e31704d32..145de8ea8 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx @@ -44,7 +44,7 @@ Install FastMCP server in Claude Desktop. - True if installation was successful, False otherwise -### `claude_desktop_command` +### `claude_desktop_command` ```python claude_desktop_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index 218575f86..6c9ada03c 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration. - True if installation was successful, False otherwise -### `install_cursor` +### `install_cursor` ```python install_cursor(file: Path, server_object: str | None, name: str) -> bool @@ -93,7 +93,7 @@ Install FastMCP server in Cursor. - True if installation was successful, False otherwise -### `cursor_command` +### `cursor_command` ```python cursor_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx index 0d218623e..e13343bd7 100644 --- a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx +++ b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx @@ -35,7 +35,7 @@ Generate MCP configuration JSON for manual installation. - True if generation was successful, False otherwise -### `mcp_json_command` +### `mcp_json_command` ```python mcp_json_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index 9f352a1e1..f88d8f431 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,7 +20,7 @@ is_url(path: str) -> bool Check if a string is a URL. -### `run_with_uv` +### `run_with_uv` ```python run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, editable: str | list[str] | None = None) -> None @@ -29,6 +29,10 @@ run_with_uv(server_spec: str, python_version: str | None = None, with_packages: Run a MCP server using uv run subprocess. +This function is called when we need to set up a Python environment with specific +dependencies before running the server. The config parsing and merging should already +be done by the caller. + **Args:** - `server_spec`: Python file, object specification (file\:obj), config file, or URL - `python_version`: Python version to use (e.g. "3.10") @@ -41,9 +45,10 @@ Run a MCP server using uv run subprocess. - `path`: Path to bind to when using http transport - `log_level`: Log level - `show_banner`: Whether to show the server banner +- `editable`: Editable package paths -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -59,7 +64,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -69,10 +74,10 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `load_fastmcp_config` +### `load_mcp_server_config` ```python -load_fastmcp_config(config_path: Path) -> FastMCPConfig +load_mcp_server_config(config_path: Path) -> MCPServerConfig ``` @@ -82,10 +87,10 @@ Load a FastMCP configuration from a fastmcp.json file. - `config_path`: Path to fastmcp.json file **Returns:** -- FastMCPConfig object +- MCPServerConfig object -### `run_command` +### `run_command` ```python run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False) -> None @@ -107,7 +112,7 @@ Run a MCP server or connect to a remote one. - `skip_source`: Whether to skip source preparation step -### `run_v1_server` +### `run_v1_server` ```python run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 0b261f9b0..1bc7c1704 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -7,13 +7,13 @@ sidebarTitle: oauth ## Functions -### `default_cache_dir` +### `default_cache_dir` ```python default_cache_dir() -> Path ``` -### `check_if_auth_required` +### `check_if_auth_required` ```python check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool @@ -28,7 +28,13 @@ Check if the MCP endpoint requires authentication by making a test request. ## Classes -### `FileTokenStorage` +### `StoredToken` + + +Token storage format with absolute expiry time. + + +### `FileTokenStorage` File-based token storage implementation for OAuth credentials and tokens. @@ -39,7 +45,7 @@ Each instance is tied to a specific server URL for proper token isolation. **Methods:** -#### `get_base_url` +#### `get_base_url` ```python get_base_url(url: str) -> str @@ -48,7 +54,7 @@ get_base_url(url: str) -> str Extract the base URL (scheme + host) from a URL. -#### `get_cache_key` +#### `get_cache_key` ```python get_cache_key(self) -> str @@ -57,7 +63,7 @@ get_cache_key(self) -> str Generate a safe filesystem key from the server's base URL. -#### `get_tokens` +#### `get_tokens` ```python get_tokens(self) -> OAuthToken | None @@ -66,7 +72,7 @@ get_tokens(self) -> OAuthToken | None Load tokens from file storage. -#### `set_tokens` +#### `set_tokens` ```python set_tokens(self, tokens: OAuthToken) -> None @@ -75,7 +81,7 @@ set_tokens(self, tokens: OAuthToken) -> None Save tokens to file storage. -#### `get_client_info` +#### `get_client_info` ```python get_client_info(self) -> OAuthClientInformationFull | None @@ -84,7 +90,7 @@ get_client_info(self) -> OAuthClientInformationFull | None Load client information from file storage. -#### `set_client_info` +#### `set_client_info` ```python set_client_info(self, client_info: OAuthClientInformationFull) -> None @@ -93,7 +99,7 @@ set_client_info(self, client_info: OAuthClientInformationFull) -> None Save client information to file storage. -#### `clear` +#### `clear` ```python clear(self) -> None @@ -102,7 +108,7 @@ clear(self) -> None Clear all cached data for this server. -#### `clear_all` +#### `clear_all` ```python clear_all(cls, cache_dir: Path | None = None) -> None @@ -111,7 +117,7 @@ clear_all(cls, cache_dir: Path | None = None) -> None Clear all cached data for all servers. -### `OAuth` +### `OAuth` OAuth client provider for MCP servers with browser-based authentication. @@ -122,7 +128,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -131,7 +137,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index 406d0c329..cc92038eb 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -7,7 +7,7 @@ sidebarTitle: client ## Classes -### `ClientSessionState` +### `ClientSessionState` Holds all session-related state for a Client instance. @@ -16,7 +16,7 @@ This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). -### `Client` +### `Client` MCP client that delegates connection management to a Transport instance. @@ -79,7 +79,7 @@ async with client: **Methods:** -#### `session` +#### `session` ```python session(self) -> ClientSession @@ -88,7 +88,7 @@ session(self) -> ClientSession Get the current active session. Raises RuntimeError if not connected. -#### `initialize_result` +#### `initialize_result` ```python initialize_result(self) -> mcp.types.InitializeResult @@ -97,7 +97,7 @@ initialize_result(self) -> mcp.types.InitializeResult Get the result of the initialization request. -#### `set_roots` +#### `set_roots` ```python set_roots(self, roots: RootsList | RootsHandler) -> None @@ -106,7 +106,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None Set the roots for the client. This does not automatically call `send_roots_list_changed`. -#### `set_sampling_callback` +#### `set_sampling_callback` ```python set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None @@ -115,7 +115,7 @@ set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None Set the sampling callback for the client. -#### `set_elicitation_callback` +#### `set_elicitation_callback` ```python set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None @@ -124,7 +124,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None Set the elicitation callback for the client. -#### `is_connected` +#### `is_connected` ```python is_connected(self) -> bool @@ -133,7 +133,7 @@ is_connected(self) -> bool Check if the client is currently connected. -#### `new` +#### `new` ```python new(self) -> Client[ClientTransportT] @@ -149,13 +149,13 @@ share state with the original client. - A new Client instance with the same configuration but disconnected state. -#### `close` +#### `close` ```python close(self) ``` -#### `ping` +#### `ping` ```python ping(self) -> bool @@ -164,7 +164,7 @@ ping(self) -> bool Send a ping request. -#### `cancel` +#### `cancel` ```python cancel(self, request_id: str | int, reason: str | None = None) -> None @@ -173,7 +173,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None Send a cancellation notification for an in-progress request. -#### `progress` +#### `progress` ```python progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None @@ -182,7 +182,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None = Send a progress notification. -#### `set_logging_level` +#### `set_logging_level` ```python set_logging_level(self, level: mcp.types.LoggingLevel) -> None @@ -191,7 +191,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None Send a logging/setLevel request. -#### `send_roots_list_changed` +#### `send_roots_list_changed` ```python send_roots_list_changed(self) -> None @@ -200,7 +200,7 @@ send_roots_list_changed(self) -> None Send a roots/list_changed notification. -#### `list_resources_mcp` +#### `list_resources_mcp` ```python list_resources_mcp(self) -> mcp.types.ListResourcesResult @@ -216,7 +216,7 @@ containing the list of resources and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[mcp.types.Resource] @@ -231,7 +231,7 @@ Retrieve a list of resources available on the server. - `RuntimeError`: If called while the client is not connected. -#### `list_resource_templates_mcp` +#### `list_resource_templates_mcp` ```python list_resource_templates_mcp(self) -> mcp.types.ListResourceTemplatesResult @@ -247,7 +247,7 @@ containing the list of resource templates and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> list[mcp.types.ResourceTemplate] @@ -262,7 +262,7 @@ Retrieve a list of resource templates available on the server. - `RuntimeError`: If called while the client is not connected. -#### `read_resource_mcp` +#### `read_resource_mcp` ```python read_resource_mcp(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult @@ -281,7 +281,7 @@ containing the resource contents and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] @@ -300,7 +300,7 @@ objects, typically containing either text or binary data. - `RuntimeError`: If called while the client is not connected. -#### `list_prompts_mcp` +#### `list_prompts_mcp` ```python list_prompts_mcp(self) -> mcp.types.ListPromptsResult @@ -316,7 +316,7 @@ containing the list of prompts and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[mcp.types.Prompt] @@ -331,7 +331,7 @@ Retrieve a list of prompts available on the server. - `RuntimeError`: If called while the client is not connected. -#### `get_prompt_mcp` +#### `get_prompt_mcp` ```python get_prompt_mcp(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult @@ -351,7 +351,7 @@ containing the prompt messages and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult @@ -371,7 +371,7 @@ containing the prompt messages and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `complete_mcp` +#### `complete_mcp` ```python complete_mcp(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.CompleteResult @@ -391,7 +391,7 @@ containing the completion and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `complete` +#### `complete` ```python complete(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.Completion @@ -410,7 +410,7 @@ Send a completion request to the server. - `RuntimeError`: If called while the client is not connected. -#### `list_tools_mcp` +#### `list_tools_mcp` ```python list_tools_mcp(self) -> mcp.types.ListToolsResult @@ -426,7 +426,7 @@ containing the list of tools and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> list[mcp.types.Tool] @@ -441,7 +441,7 @@ Retrieve a list of tools available on the server. - `RuntimeError`: If called while the client is not connected. -#### `call_tool_mcp` +#### `call_tool_mcp` ```python call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult @@ -466,7 +466,7 @@ containing the tool result and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult @@ -496,10 +496,10 @@ raw result object. - `RuntimeError`: If called while the client is not connected. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `CallToolResult` +### `CallToolResult` diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index b592bdc8c..163843467 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -182,7 +182,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -199,7 +199,7 @@ handles the case where a client with cached tokens reconnects on a different port. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -226,7 +226,7 @@ The flow: 4. When client reconnects with a different port, ProxyDCRClient accepts it -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -240,7 +240,7 @@ This implements the DCR-compliant proxy pattern: 3. Redirect to IdP with our fixed callback URL -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -252,7 +252,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -264,7 +264,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -273,7 +273,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -282,7 +282,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -294,7 +294,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -306,7 +306,7 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index 60781bf24..31d0145d2 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx index ba44efebe..c7aa26786 100644 --- a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx +++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx @@ -44,8 +44,9 @@ Validate a redirect URI against allowed patterns. **Args:** - `redirect_uri`: The redirect URI to validate -- `allowed_patterns`: List of allowed patterns. If None, defaults to localhost. - If empty list, all URIs are allowed. +- `allowed_patterns`: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility). + If empty list, no URIs are allowed. + To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS. **Returns:** - True if the redirect URI is allowed diff --git a/docs/python-sdk/fastmcp-server-middleware-logging.mdx b/docs/python-sdk/fastmcp-server-middleware-logging.mdx index 9beb8300a..853725a01 100644 --- a/docs/python-sdk/fastmcp-server-middleware-logging.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-logging.mdx @@ -8,9 +8,21 @@ sidebarTitle: logging Comprehensive logging middleware for FastMCP servers. +## Functions + +### `default_serializer` + +```python +default_serializer(data: Any) -> str +``` + + +The default serializer for Payloads in the logging middleware. + + ## Classes -### `LoggingMiddleware` +### `LoggingMiddleware` Middleware that provides comprehensive request and response logging. @@ -21,16 +33,16 @@ monitoring, and understanding server usage patterns. **Methods:** -#### `on_message` +#### `on_message` ```python -on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any +on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` Log all messages. -### `StructuredLoggingMiddleware` +### `StructuredLoggingMiddleware` Middleware that provides structured JSON logging for better log analysis. @@ -41,10 +53,10 @@ aggregation tools like ELK stack, Splunk, or cloud logging services. **Methods:** -#### `on_message` +#### `on_message` ```python -on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any +on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` Log structured message information. diff --git a/docs/python-sdk/fastmcp-utilities-cli.mdx b/docs/python-sdk/fastmcp-utilities-cli.mdx index 6d49ca6e0..eb4691358 100644 --- a/docs/python-sdk/fastmcp-utilities-cli.mdx +++ b/docs/python-sdk/fastmcp-utilities-cli.mdx @@ -7,7 +7,37 @@ sidebarTitle: cli ## Functions -### `log_server_banner` +### `is_already_in_uv_subprocess` + +```python +is_already_in_uv_subprocess() -> bool +``` + + +Check if we're already running in a FastMCP uv subprocess. + + +### `load_and_merge_config` + +```python +load_and_merge_config(server_spec: str | None, **cli_overrides) -> tuple[MCPServerConfig, str] +``` + + +Load config from server_spec and apply CLI overrides. + +This consolidates the config parsing logic that was duplicated across +run, inspect, and dev commands. + +**Args:** +- `server_spec`: Python file, config file, URL, or None to auto-detect +- `cli_overrides`: CLI arguments that override config values + +**Returns:** +- Tuple of (MCPServerConfig, resolved_server_spec) + + +### `log_server_banner` ```python log_server_banner(server: FastMCP[Any], transport: Literal['stdio', 'http', 'sse', 'streamable-http']) -> None diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx similarity index 84% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx index f804aa19f..bc8fc9500 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx @@ -3,7 +3,7 @@ title: __init__ sidebarTitle: __init__ --- -# `fastmcp.utilities.fastmcp_config` +# `fastmcp.utilities.mcp_server_config` FastMCP Configuration module. diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx similarity index 74% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx index 4195f2dc2..d29664280 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx @@ -3,6 +3,6 @@ title: __init__ sidebarTitle: __init__ --- -# `fastmcp.utilities.fastmcp_config.v1` +# `fastmcp.utilities.mcp_server_config.v1` *This module is empty or contains only private/internal implementations.* diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx similarity index 65% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx index d66382bc1..fe7815a9b 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx @@ -1,9 +1,9 @@ --- -title: fastmcp_config -sidebarTitle: fastmcp_config +title: mcp_server_config +sidebarTitle: mcp_server_config --- -# `fastmcp.utilities.fastmcp_config.v1.fastmcp_config` +# `fastmcp.utilities.mcp_server_config.v1.mcp_server_config` FastMCP Configuration File Support. @@ -15,7 +15,7 @@ command-line arguments. ## Functions -### `generate_schema` +### `generate_schema` ```python generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None @@ -38,7 +38,7 @@ validation and auto-completion. ## Classes -### `Environment` +### `Environment` Configuration for Python environment setup. @@ -46,22 +46,22 @@ Configuration for Python environment setup. **Methods:** -#### `build_uv_args` +#### `build_uv_run_command` ```python -build_uv_args(self, command: str | list[str] | None = None) -> list[str] +build_uv_run_command(self, command: list[str]) -> list[str] ``` -Build uv run arguments from this environment configuration. +Build complete uv run command with environment args and command to execute. **Args:** -- `command`: Optional command to append (string or list of args) +- `command`: Command to execute (e.g., ["fastmcp", "run", "server.py"]) **Returns:** -- List of arguments for uv run command +- Complete command ready for subprocess.run, including "uv" prefix -#### `run_with_uv` +#### `run_with_uv` ```python run_with_uv(self, command: list[str]) -> None @@ -73,7 +73,7 @@ Execute a command using uv run with this environment configuration. - `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) -#### `needs_uv` +#### `needs_uv` ```python needs_uv(self) -> bool @@ -85,7 +85,7 @@ Check if this environment config requires uv to set up. - True if any environment settings require uv run -#### `prepare` +#### `prepare` ```python prepare(self, output_dir: Path | None = None) -> None @@ -98,7 +98,7 @@ Prepare the Python environment using uv. If None, creates a temporary directory for ephemeral use. -### `Deployment` +### `Deployment` Configuration for server deployment and runtime settings. @@ -106,7 +106,7 @@ Configuration for server deployment and runtime settings. **Methods:** -#### `apply_runtime_settings` +#### `apply_runtime_settings` ```python apply_runtime_settings(self, config_path: Path | None = None) -> None @@ -122,7 +122,7 @@ For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" will substitute the value of the ENVIRONMENT variable at runtime. -### `FastMCPConfig` +### `MCPServerConfig` Configuration for a FastMCP server. @@ -133,7 +133,7 @@ a FastMCP server in a declarative format. **Methods:** -#### `validate_source` +#### `validate_source` ```python validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource @@ -146,10 +146,10 @@ Supports: - FileSystemSource instance (passed through) No string parsing happens here - that's only at CLI boundaries. -FastMCPConfig works only with properly typed objects. +MCPServerConfig works only with properly typed objects. -#### `validate_environment` +#### `validate_environment` ```python validate_environment(cls, v: dict | Environment) -> Environment @@ -162,7 +162,7 @@ Accepts: - dict that can be converted to Environment -#### `validate_deployment` +#### `validate_deployment` ```python validate_deployment(cls, v: dict | Deployment) -> Deployment @@ -175,10 +175,10 @@ Accepts: - dict that can be converted to Deployment -#### `from_file` +#### `from_file` ```python -from_file(cls, file_path: Path) -> FastMCPConfig +from_file(cls, file_path: Path) -> MCPServerConfig ``` Load configuration from a JSON file. @@ -187,7 +187,7 @@ Load configuration from a JSON file. - `file_path`: Path to the configuration file **Returns:** -- FastMCPConfig instance +- MCPServerConfig instance **Raises:** - `FileNotFoundError`: If the file doesn't exist @@ -195,10 +195,10 @@ Load configuration from a JSON file. - `pydantic.ValidationError`: If the configuration is invalid -#### `from_cli_args` +#### `from_cli_args` ```python -from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> FastMCPConfig +from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> MCPServerConfig ``` Create a config from CLI arguments. @@ -223,10 +223,10 @@ goes through a config object. - `args`: Server arguments **Returns:** -- FastMCPConfig instance +- MCPServerConfig instance -#### `find_config` +#### `find_config` ```python find_config(cls, start_path: Path | None = None) -> Path | None @@ -241,7 +241,7 @@ Find a fastmcp.json file in the specified directory. - Path to the configuration file, or None if not found -#### `prepare` +#### `prepare` ```python prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None @@ -257,7 +257,7 @@ When output_dir is None, does ephemeral caching (for backwards compatibility). - `output_dir`: Directory to create the persistent uv project in (optional) -#### `prepare_environment` +#### `prepare_environment` ```python prepare_environment(self, output_dir: Path | None = None) -> None @@ -272,7 +272,7 @@ Prepare the Python environment. Delegates to the environment's prepare() method -#### `prepare_source` +#### `prepare_source` ```python prepare_source(self) -> None @@ -283,7 +283,7 @@ Prepare the source for loading. Delegates to the source's prepare() method. -#### `run_server` +#### `run_server` ```python run_server(self, **kwargs: Any) -> None diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx similarity index 70% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx index c7259c5a1..38d102b15 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx @@ -3,6 +3,6 @@ title: __init__ sidebarTitle: __init__ --- -# `fastmcp.utilities.fastmcp_config.v1.sources` +# `fastmcp.utilities.mcp_server_config.v1.sources` *This module is empty or contains only private/internal implementations.* diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx similarity index 63% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx index 15d2876f7..bf80ca1d6 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx @@ -3,11 +3,11 @@ title: base sidebarTitle: base --- -# `fastmcp.utilities.fastmcp_config.v1.sources.base` +# `fastmcp.utilities.mcp_server_config.v1.sources.base` ## Classes -### `BaseSource` +### `BaseSource` Abstract base class for all source types. @@ -15,7 +15,7 @@ Abstract base class for all source types. **Methods:** -#### `prepare` +#### `prepare` ```python prepare(self) -> None @@ -28,7 +28,7 @@ this method performs that preparation. For sources that don't need preparation (e.g., local files), this is a no-op. -#### `load_server` +#### `load_server` ```python load_server(self) -> Any diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx similarity index 51% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx index 68f3bd6fb..9ff83063b 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx @@ -3,11 +3,11 @@ title: filesystem sidebarTitle: filesystem --- -# `fastmcp.utilities.fastmcp_config.v1.sources.filesystem` +# `fastmcp.utilities.mcp_server_config.v1.sources.filesystem` ## Classes -### `FileSystemSource` +### `FileSystemSource` Source for local Python files. @@ -15,7 +15,7 @@ Source for local Python files. **Methods:** -#### `parse_path_with_object` +#### `parse_path_with_object` ```python parse_path_with_object(cls, v: str) -> str @@ -27,7 +27,7 @@ This validator runs before the model is created, allowing us to handle the "file.py:object" syntax at the model boundary. -#### `load_server` +#### `load_server` ```python load_server(self) -> Any diff --git a/examples/fastmcp_config_demo/README.md b/examples/fastmcp_config_demo/README.md index 23c17975b..7abf11c3d 100644 --- a/examples/fastmcp_config_demo/README.md +++ b/examples/fastmcp_config_demo/README.md @@ -26,14 +26,14 @@ With the configuration file in place, you can run the server in several ways: ```bash # Auto-detect fastmcp.json in current directory -cd examples/fastmcp_config_demo +cd examples/mcp_server_config_demo fastmcp run # Or specify the config file explicitly -fastmcp run examples/fastmcp_config_demo/fastmcp.json +fastmcp run examples/mcp_server_config_demo/fastmcp.json # Or use development mode with the Inspector UI -fastmcp dev examples/fastmcp_config_demo/fastmcp.json +fastmcp dev examples/mcp_server_config_demo/fastmcp.json ``` ## Benefits diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index af42da058..5004ccebd 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -6,8 +6,8 @@ import sys from pathlib import Path from typing import Any -from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment logger = get_logger(__name__) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 09de389db..dd7e65a01 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -20,13 +20,13 @@ import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig from fastmcp.utilities.inspect import ( InspectFormat, format_info, inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment, MCPServerConfig logger = get_logger("cli") console = Console() @@ -796,7 +796,7 @@ async def prepare( # Auto-detect fastmcp.json if not provided if config_path is None: - found_config = FastMCPConfig.find_config() + found_config = MCPServerConfig.find_config() if found_config: config_path = str(found_config) logger.info(f"Using configuration from {config_path}") @@ -816,7 +816,7 @@ async def prepare( try: # Load the configuration - config = FastMCPConfig.from_file(config_file) + config = MCPServerConfig.from_file(config_file) # Prepare environment and source await config.prepare( diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 0117f67b4..7c04c02e2 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -9,8 +9,8 @@ from typing import Annotated import cyclopts from rich import print -from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment from .shared import process_common_args diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index d4184f95d..df93a8856 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -9,8 +9,8 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file -from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment from .shared import process_common_args diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index f10192e16..650ef57ff 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -10,8 +10,8 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file -from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment from .shared import process_common_args diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 5ea466590..7fab1e067 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -9,8 +9,8 @@ import cyclopts import pyperclip from rich import print -from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import Environment from .shared import process_common_args diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 54b61977d..3544a2b6b 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -8,9 +8,9 @@ from dotenv import dotenv_values from pydantic import ValidationError from rich import print -from fastmcp.utilities.fastmcp_config import FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger(__name__) @@ -40,7 +40,7 @@ async def process_common_args( # Convert None to empty lists for list parameters with_packages = with_packages or [] env_vars = env_vars or [] - # Create FastMCPConfig from server_spec + # Create MCPServerConfig from server_spec config = None if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() @@ -58,8 +58,8 @@ async def process_common_args( print("[red]MCPConfig files are not supported for installation[/red]") sys.exit(1) else: - # It's a FastMCPConfig - config = FastMCPConfig.from_file(config_path) + # It's a MCPServerConfig + config = MCPServerConfig.from_file(config_path) # Merge packages from config if not overridden if config.environment.dependencies: @@ -72,7 +72,7 @@ async def process_common_args( else: # Create config from file path source = FileSystemSource(path=server_spec) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) # Extract file and server_object from the source # The FileSystemSource handles parsing path:object syntax diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 2b812e625..c01ea0085 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -11,12 +11,12 @@ from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config import ( - Environment, - FastMCPConfig, -) -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import ( + Environment, + MCPServerConfig, +) +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.run") @@ -143,16 +143,16 @@ def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]: return server -def load_fastmcp_config(config_path: Path) -> FastMCPConfig: +def load_mcp_server_config(config_path: Path) -> MCPServerConfig: """Load a FastMCP configuration from a fastmcp.json file. Args: config_path: Path to fastmcp.json file Returns: - FastMCPConfig object + MCPServerConfig object """ - config = FastMCPConfig.from_file(config_path) + config = MCPServerConfig.from_file(config_path) # Apply runtime settings from deployment config config.deployment.apply_runtime_settings(config_path) @@ -204,7 +204,7 @@ async def run_command( server = create_mcp_config_server(config_path) else: # It's a FastMCP config - load it properly - config = load_fastmcp_config(config_path) + config = load_mcp_server_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) transport = transport or config.deployment.transport @@ -232,9 +232,9 @@ async def run_command( logger.debug(f'Found server "{server.name}" from config {config_path}') else: - # Regular file case - create a FastMCPConfig with FileSystemSource + # Regular file case - create a MCPServerConfig with FileSystemSource source = FileSystemSource(path=server_spec) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) # Prepare source only (environment is handled by uv run) await config.prepare_source() if not skip_source else None diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index ec94a1f17..c2b4c596f 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -36,8 +36,8 @@ from fastmcp.client.auth.oauth import OAuth from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import Environment logger = get_logger(__name__) diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 55081a5ad..2ae540d89 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -14,9 +14,9 @@ from rich.table import Table from rich.text import Text import fastmcp -from fastmcp.utilities.fastmcp_config import FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config import MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.types import get_cached_typeadapter if TYPE_CHECKING: @@ -33,7 +33,7 @@ def is_already_in_uv_subprocess() -> bool: def load_and_merge_config( server_spec: str | None, **cli_overrides, -) -> tuple[FastMCPConfig, str]: +) -> tuple[MCPServerConfig, str]: """Load config from server_spec and apply CLI overrides. This consolidates the config parsing logic that was duplicated across @@ -44,7 +44,7 @@ def load_and_merge_config( cli_overrides: CLI arguments that override config values Returns: - Tuple of (FastMCPConfig, resolved_server_spec) + Tuple of (MCPServerConfig, resolved_server_spec) """ config = None config_path = None @@ -53,7 +53,7 @@ def load_and_merge_config( if server_spec is None: config_path = Path("fastmcp.json") if not config_path.exists(): - found_config = FastMCPConfig.find_config() + found_config = MCPServerConfig.find_config() if found_config: config_path = found_config else: @@ -81,9 +81,9 @@ def load_and_merge_config( # MCPConfig - we don't process these here, just pass through pass else: - # Try to parse as FastMCPConfig + # Try to parse as MCPServerConfig try: - adapter = get_cached_typeadapter(FastMCPConfig) + adapter = get_cached_typeadapter(MCPServerConfig) config = adapter.validate_python(data) # Apply deployment settings @@ -91,7 +91,7 @@ def load_and_merge_config( config.deployment.apply_runtime_settings(config_path) except ValidationError: - # Not a valid FastMCPConfig, just pass through + # Not a valid MCPServerConfig, just pass through pass except (json.JSONDecodeError, FileNotFoundError): # Not a valid JSON file, just pass through @@ -100,7 +100,7 @@ def load_and_merge_config( # If we don't have a config object yet, create one from filesystem source if config is None: source = FileSystemSource(path=resolved_spec) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) # Convert to dict for immutable transformation config_dict = config.model_dump() @@ -134,7 +134,7 @@ def load_and_merge_config( config_dict["deployment"]["args"] = server_args_override # Create new config from modified dict - new_config = FastMCPConfig(**config_dict) + new_config = MCPServerConfig(**config_dict) return new_config, resolved_spec diff --git a/src/fastmcp/utilities/fastmcp_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py similarity index 56% rename from src/fastmcp/utilities/fastmcp_config/__init__.py rename to src/fastmcp/utilities/mcp_server_config/__init__.py index 28236fe42..363aa3b30 100644 --- a/src/fastmcp/utilities/fastmcp_config/__init__.py +++ b/src/fastmcp/utilities/mcp_server_config/__init__.py @@ -4,20 +4,20 @@ This module provides versioned configuration support for FastMCP servers. The current version is v1, which is re-exported here for convenience. """ -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import ( +from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( Deployment, Environment, - FastMCPConfig, + MCPServerConfig, generate_schema, ) -from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config.v1.sources.base import BaseSource +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource __all__ = [ "BaseSource", "Deployment", "Environment", - "FastMCPConfig", + "MCPServerConfig", "FileSystemSource", "generate_schema", ] diff --git a/src/fastmcp/utilities/fastmcp_config/v1/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/__init__.py similarity index 100% rename from src/fastmcp/utilities/fastmcp_config/v1/__init__.py rename to src/fastmcp/utilities/mcp_server_config/v1/__init__.py diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py similarity index 98% rename from src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py rename to src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index 01cd1b846..ca50470e7 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -17,8 +17,8 @@ from typing import TYPE_CHECKING, Any, Literal, overload from pydantic import BaseModel, Field, field_validator -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.config") @@ -406,7 +406,7 @@ class Deployment(BaseModel): return re.sub(r"\$\{([^}]+)\}", replace_var, value) -class FastMCPConfig(BaseModel): +class MCPServerConfig(BaseModel): """Configuration for a FastMCP server. This configuration file allows you to specify all settings needed to run @@ -463,7 +463,7 @@ class FastMCPConfig(BaseModel): - FileSystemSource instance (passed through) No string parsing happens here - that's only at CLI boundaries. - FastMCPConfig works only with properly typed objects. + MCPServerConfig works only with properly typed objects. """ if isinstance(v, FileSystemSource): # Already a FileSystemSource instance, return as-is @@ -510,14 +510,14 @@ class FastMCPConfig(BaseModel): raise ValueError("deployment must be a dict, Deployment instance") @classmethod - def from_file(cls, file_path: Path) -> FastMCPConfig: + def from_file(cls, file_path: Path) -> MCPServerConfig: """Load configuration from a JSON file. Args: file_path: Path to the configuration file Returns: - FastMCPConfig instance + MCPServerConfig instance Raises: FileNotFoundError: If the file doesn't exist @@ -550,7 +550,7 @@ class FastMCPConfig(BaseModel): env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None, - ) -> FastMCPConfig: + ) -> MCPServerConfig: """Create a config from CLI arguments. This allows us to have a single code path where everything @@ -573,7 +573,7 @@ class FastMCPConfig(BaseModel): args: Server arguments Returns: - FastMCPConfig instance + MCPServerConfig instance """ # Build environment config if any env args provided environment = None @@ -716,7 +716,7 @@ def generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | N Returns: JSON schema as a dictionary if output_path is None, otherwise None """ - schema = FastMCPConfig.model_json_schema() + schema = MCPServerConfig.model_json_schema() # Add some metadata schema["$id"] = FASTMCP_JSON_SCHEMA diff --git a/src/fastmcp/utilities/fastmcp_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json similarity index 100% rename from src/fastmcp/utilities/fastmcp_config/v1/schema.json rename to src/fastmcp/utilities/mcp_server_config/v1/schema.json diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py similarity index 100% rename from src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py rename to src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py similarity index 100% rename from src/fastmcp/utilities/fastmcp_config/v1/sources/base.py rename to src/fastmcp/utilities/mcp_server_config/v1/sources/base.py diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py similarity index 99% rename from src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py rename to src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py index 92fb7ae3e..29c2aeede 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py @@ -6,8 +6,8 @@ from typing import Any, Literal from pydantic import Field, field_validator -from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.sources.base import BaseSource logger = get_logger(__name__) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 1d0d078a6..0d8b57c08 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -458,7 +458,7 @@ class TestWindowsSpecific: """Test parsing Windows paths with drive letters and colons.""" from pathlib import Path - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import ( FileSystemSource, ) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index ec8b413c1..6d187f934 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -7,12 +7,12 @@ from pathlib import Path import pytest from pydantic import ValidationError -from fastmcp.utilities.fastmcp_config import ( +from fastmcp.utilities.mcp_server_config import ( Deployment, Environment, - FastMCPConfig, + MCPServerConfig, ) -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestFileSystemSource: @@ -20,7 +20,7 @@ class TestFileSystemSource: def test_dict_source_minimal(self): """Test that dict source is converted to FileSystemSource.""" - config = FastMCPConfig(source={"path": "server.py"}) + config = MCPServerConfig(source={"path": "server.py"}) # Dict is converted to FileSystemSource assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" @@ -29,7 +29,7 @@ class TestFileSystemSource: def test_dict_source_with_entrypoint(self): """Test dict source with entrypoint field.""" - config = FastMCPConfig(source={"path": "server.py", "entrypoint": "app"}) + config = MCPServerConfig(source={"path": "server.py", "entrypoint": "app"}) # Dict with entrypoint is converted to FileSystemSource assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" @@ -38,7 +38,7 @@ class TestFileSystemSource: def test_filesystem_source_entrypoint(self): """Test FileSystemSource entrypoint format.""" - config = FastMCPConfig( + config = MCPServerConfig( source=FileSystemSource(path="src/server.py", entrypoint="mcp") ) assert isinstance(config.source, FileSystemSource) @@ -52,7 +52,7 @@ class TestEnvironment: def test_environment_config_fields(self): """Test all Environment fields.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", @@ -73,28 +73,28 @@ class TestEnvironment: def test_needs_uv(self): """Test needs_uv() method.""" # No environment config - doesn't need UV - config = FastMCPConfig(source={"path": "server.py"}) + config = MCPServerConfig(source={"path": "server.py"}) assert not config.environment.needs_uv() # Empty environment - doesn't need UV - config = FastMCPConfig(source={"path": "server.py"}, environment={}) + config = MCPServerConfig(source={"path": "server.py"}, environment={}) assert not config.environment.needs_uv() # With dependencies - needs UV - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) assert config.environment.needs_uv() # With Python version - needs UV - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"} ) assert config.environment.needs_uv() def test_build_uv_run_command(self): """Test build_uv_run_command() method.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", @@ -125,7 +125,7 @@ class TestEnvironment: def test_run_with_uv(self): """Test run_with_uv() subprocess execution.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) @@ -144,7 +144,7 @@ class TestDeployment: def test_deployment_config_fields(self): """Test all Deployment fields.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={ "transport": "http", @@ -176,7 +176,7 @@ class TestDeployment: work_dir = tmp_path / "work" work_dir.mkdir() - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={ "env": {"TEST_VAR": "test_value"}, @@ -212,7 +212,7 @@ class TestDeployment: os.environ["BASE_URL"] = "example.com" os.environ["ENV_NAME"] = "production" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={ "env": { @@ -253,12 +253,12 @@ class TestDeployment: os.environ[key] = value -class TestFastMCPConfig: - """Test FastMCPConfig root configuration.""" +class TestMCPServerConfig: + """Test MCPServerConfig root configuration.""" def test_minimal_config(self): """Test creating a config with only required fields.""" - config = FastMCPConfig(source={"path": "server.py"}) + config = MCPServerConfig(source={"path": "server.py"}) assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" assert config.source.entrypoint is None @@ -274,7 +274,7 @@ class TestFastMCPConfig: def test_nested_structure(self): """Test the nested configuration structure.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", @@ -304,7 +304,7 @@ class TestFastMCPConfig: config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) - config = FastMCPConfig.from_file(config_file) + config = MCPServerConfig.from_file(config_file) # When loaded from JSON with entrypoint format, it becomes EntrypointConfig assert isinstance(config.source, FileSystemSource) @@ -325,7 +325,7 @@ class TestFastMCPConfig: config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) - config = FastMCPConfig.from_file(config_file) + config = MCPServerConfig.from_file(config_file) # String entrypoint with : should be converted to EntrypointConfig assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" @@ -342,7 +342,7 @@ class TestFastMCPConfig: config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) - config = FastMCPConfig.from_file(config_file) + config = MCPServerConfig.from_file(config_file) # Should be parsed into EntrypointConfig assert isinstance(config.source, FileSystemSource) @@ -365,7 +365,7 @@ class TestFastMCPConfig: original_cwd = os.getcwd() try: os.chdir(tmp_path) - found = FastMCPConfig.find_config() + found = MCPServerConfig.find_config() assert found == config_file finally: os.chdir(original_cwd) @@ -379,7 +379,7 @@ class TestFastMCPConfig: subdir.mkdir() # Should NOT find config in parent directory - found = FastMCPConfig.find_config(subdir) + found = MCPServerConfig.find_config(subdir) assert found is None def test_find_config_in_specified_dir(self, tmp_path): @@ -388,12 +388,12 @@ class TestFastMCPConfig: config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should find config when looking in the directory that contains it - found = FastMCPConfig.find_config(tmp_path) + found = MCPServerConfig.find_config(tmp_path) assert found == config_file def test_find_config_not_found(self, tmp_path): """Test when config is not found.""" - found = FastMCPConfig.find_config(tmp_path) + found = MCPServerConfig.find_config(tmp_path) assert found is None def test_invalid_transport(self, tmp_path): @@ -407,12 +407,12 @@ class TestFastMCPConfig: config_file.write_text(json.dumps(config_data)) with pytest.raises(ValidationError): - FastMCPConfig.from_file(config_file) + MCPServerConfig.from_file(config_file) def test_optional_sections(self): """Test that all config sections are optional except source.""" # Only source is required - config = FastMCPConfig(source={"path": "server.py"}) + config = MCPServerConfig(source={"path": "server.py"}) assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" # Environment and deployment are now always present but may be empty @@ -420,7 +420,7 @@ class TestFastMCPConfig: assert isinstance(config.deployment, Deployment) # Only environment with values - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"} ) assert config.environment.python == "3.12" @@ -431,7 +431,7 @@ class TestFastMCPConfig: ) # Only deployment with values - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "http"} ) assert isinstance(config.environment, Environment) diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_mcp_server_config_integration.py similarity index 93% rename from tests/cli/test_fastmcp_config_integration.py rename to tests/cli/test_mcp_server_config_integration.py index 800b23b08..e925f1bb4 100644 --- a/tests/cli/test_fastmcp_config_integration.py +++ b/tests/cli/test_mcp_server_config_integration.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from fastmcp.client import Client -from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.mcp_server_config import MCPServerConfig @pytest.fixture @@ -89,7 +89,7 @@ class TestConfigWithClient: """Test that a server loaded from config works with a client.""" # Load the config config_file = server_with_config / "fastmcp.json" - config = FastMCPConfig.from_file(config_file) + config = MCPServerConfig.from_file(config_file) # Import the server using the source import importlib.util @@ -132,7 +132,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_dependencies(self): """Test that environment with dependencies needs UV.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"dependencies": ["requests", "numpy"]}, # type: ignore[arg-type] ) @@ -142,7 +142,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_python_version(self): """Test that environment with Python version needs UV.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"}, # type: ignore[arg-type] ) @@ -152,7 +152,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_without_environment(self): """Test that no UV is needed without environment config.""" - config = FastMCPConfig(source={"path": "server.py"}) + config = MCPServerConfig(source={"path": "server.py"}) # Environment is now always present but may be empty assert config.environment is not None @@ -160,7 +160,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_with_empty_environment(self): """Test that no UV is needed with empty environment config.""" - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={}, # type: ignore[arg-type] ) @@ -184,7 +184,7 @@ class TestPathResolution: server_file = src_dir / "server.py" server_file.write_text("# Server") - config = FastMCPConfig(source={"path": "../src/server.py"}) + config = MCPServerConfig(source={"path": "../src/server.py"}) # The source path is resolved during load_server # For now, just check that the source is created correctly @@ -198,7 +198,7 @@ class TestPathResolution: work_dir = tmp_path / "work" work_dir.mkdir() - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={"cwd": "work"}, # type: ignore[arg-type] ) @@ -222,7 +222,7 @@ class TestPathResolution: reqs_file = tmp_path / "requirements.txt" reqs_file.write_text("fastmcp>=2.0") - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, environment={"requirements": "requirements.txt"}, # type: ignore[arg-type] ) @@ -243,7 +243,7 @@ class TestConfigValidation: def test_invalid_transport_rejected(self): """Test that invalid transport values are rejected.""" with pytest.raises(ValueError): - FastMCPConfig( + MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "invalid_transport"}, # type: ignore[arg-type] ) @@ -251,7 +251,7 @@ class TestConfigValidation: def test_streamable_http_transport_rejected(self): """Test that streamable-http transport is rejected in fastmcp.json config.""" with pytest.raises(ValueError): - FastMCPConfig( + MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "streamable-http"}, # type: ignore[arg-type] ) @@ -259,7 +259,7 @@ class TestConfigValidation: def test_invalid_log_level_rejected(self): """Test that invalid log level values are rejected.""" with pytest.raises(ValueError): - FastMCPConfig( + MCPServerConfig( source={"path": "server.py"}, deployment={"log_level": "INVALID"}, # type: ignore[arg-type] ) @@ -267,12 +267,12 @@ class TestConfigValidation: def test_missing_source_rejected(self): """Test that config without source is rejected.""" with pytest.raises(ValueError): - FastMCPConfig() # type: ignore[call-arg] + MCPServerConfig() # type: ignore[call-arg] def test_valid_transport_values(self): """Test that all valid transport values are accepted.""" for transport in ["stdio", "http", "sse"]: - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": transport}, # type: ignore[arg-type] ) @@ -282,7 +282,7 @@ class TestConfigValidation: def test_valid_log_levels(self): """Test that all valid log levels are accepted.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: - config = FastMCPConfig( + config = MCPServerConfig( source={"path": "server.py"}, deployment={"log_level": level}, # type: ignore[arg-type] ) diff --git a/tests/cli/test_fastmcp_config_schema.py b/tests/cli/test_mcp_server_config_schema.py similarity index 96% rename from tests/cli/test_fastmcp_config_schema.py rename to tests/cli/test_mcp_server_config_schema.py index 5c05cd65f..1fda6e91b 100644 --- a/tests/cli/test_fastmcp_config_schema.py +++ b/tests/cli/test_mcp_server_config_schema.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import generate_schema +from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import generate_schema def test_schema_file_matches_pydantic_model(): @@ -14,7 +14,7 @@ def test_schema_file_matches_pydantic_model(): / "src" / "fastmcp" / "utilities" - / "fastmcp_config" + / "mcp_server_config" / "v1" / "schema.json" ) @@ -30,7 +30,7 @@ def test_schema_file_matches_pydantic_model(): assert file_schema == generated_schema, ( "The schema.json file does not match the Pydantic model schema. " "Please regenerate the schema file by running:\n" - 'uv run python -c "from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import generate_schema; ' + 'uv run python -c "from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import generate_schema; ' 'import json; print(json.dumps(generate_schema(), indent=2))" > ' f"{schema_file}" ) diff --git a/tests/cli/test_project_prepare.py b/tests/cli/test_project_prepare.py index d23f61f32..5a2ad15b1 100644 --- a/tests/cli/test_project_prepare.py +++ b/tests/cli/test_project_prepare.py @@ -6,24 +6,24 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config import Environment, MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource -class TestFastMCPConfigPrepare: - """Test the FastMCPConfig.prepare() method.""" +class TestMCPServerConfigPrepare: + """Test the MCPServerConfig.prepare() method.""" @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_calls_both_methods(self, mock_env, mock_src): """Test that prepare() calls both prepare_environment and prepare_source.""" - config = FastMCPConfig( + config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=Environment(python="3.10"), ) @@ -34,16 +34,16 @@ class TestFastMCPConfigPrepare: mock_src.assert_called_once() @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_with_output_dir(self, mock_env, mock_src): """Test that prepare() with output_dir calls prepare_environment with it.""" - config = FastMCPConfig( + config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=Environment(python="3.10"), ) @@ -55,16 +55,16 @@ class TestFastMCPConfigPrepare: mock_src.assert_called_once() @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_skip_source(self, mock_env, mock_src): """Test that prepare() skips source when skip_source=True.""" - config = FastMCPConfig( + config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=Environment(python="3.10"), ) @@ -75,16 +75,16 @@ class TestFastMCPConfigPrepare: mock_src.assert_not_called() @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( - "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment.prepare", + "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.Environment.prepare", new_callable=AsyncMock, ) async def test_prepare_no_environment_settings(self, mock_env_prepare, mock_src): """Test that prepare() works with default empty environment config.""" - config = FastMCPConfig( + config = MCPServerConfig( source=FileSystemSource(path="server.py"), # environment defaults to empty Environment() ) @@ -188,8 +188,8 @@ class TestEnvironmentPrepare: class TestProjectPrepareCommand: """Test the CLI project prepare command.""" - @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file") - @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.find_config") + @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") + @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config") async def test_project_prepare_auto_detect(self, mock_find, mock_from_file): """Test project prepare with auto-detected config.""" from fastmcp.cli.cli import prepare @@ -220,7 +220,7 @@ class TestProjectPrepareCommand: assert "Project prepared successfully" in success_call @patch("pathlib.Path.exists") - @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file") + @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") async def test_project_prepare_explicit_path(self, mock_from_file, mock_exists): """Test project prepare with explicit config path.""" from fastmcp.cli.cli import prepare @@ -243,7 +243,7 @@ class TestProjectPrepareCommand: output_dir=Path("./test-env"), ) - @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.find_config") + @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config") async def test_project_prepare_no_config_found(self, mock_find): """Test project prepare when no config is found.""" from fastmcp.cli.cli import prepare @@ -280,7 +280,7 @@ class TestProjectPrepareCommand: assert "--output-dir parameter is required" in error_msg @patch("pathlib.Path.exists") - @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file") + @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") async def test_project_prepare_failure(self, mock_from_file, mock_exists): """Test project prepare when prepare() fails.""" from fastmcp.cli.cli import prepare diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index f5375f7e0..685e8aff0 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -13,7 +13,7 @@ from fastmcp.client.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.mcp_config import MCPConfig, StdioMCPServer from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestUrlDetection: @@ -339,7 +339,7 @@ mcp = fastmcp.FastMCP("TestServer") from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import ( FileSystemSource, ) @@ -368,7 +368,7 @@ mcp = fastmcp.FastMCP("TestServer") from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import ( FileSystemSource, ) diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index 07443414f..abc1f10af 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -6,13 +6,13 @@ from pathlib import Path import pytest -from fastmcp.cli.run import load_fastmcp_config -from fastmcp.utilities.fastmcp_config import ( +from fastmcp.cli.run import load_mcp_server_config +from fastmcp.utilities.mcp_server_config import ( Deployment, Environment, - FastMCPConfig, + MCPServerConfig, ) -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource @pytest.fixture @@ -43,17 +43,17 @@ def test_tool(message: str) -> str: return config_file -def test_load_fastmcp_config(sample_config, monkeypatch): +def test_load_mcp_server_config(sample_config, monkeypatch): """Test loading configuration and returning config subsets.""" # Capture environment changes original_env = dict(os.environ) try: - config = load_fastmcp_config(sample_config) + config = load_mcp_server_config(sample_config) # Check that we got the right types - assert isinstance(config, FastMCPConfig) + assert isinstance(config, MCPServerConfig) assert isinstance(config.source, FileSystemSource) assert isinstance(config.deployment, Deployment) assert isinstance(config.environment, Environment) @@ -95,7 +95,7 @@ def test_load_config_with_entrypoint_source(tmp_path): server_file = src_dir / "server.py" server_file.write_text("# Server") - config = load_fastmcp_config(config_file) + config = load_mcp_server_config(config_file) # Check source - path is not resolved yet, only during load_server assert config.source.path == "src/server.py" @@ -125,7 +125,7 @@ def test_load_config_with_cwd(tmp_path): original_cwd = os.getcwd() try: - config = load_fastmcp_config(config_file) # noqa: F841 + config = load_mcp_server_config(config_file) # noqa: F841 # Check that working directory was changed assert Path.cwd() == subdir.resolve() @@ -160,7 +160,7 @@ def test_load_config_with_relative_cwd(tmp_path): original_cwd = os.getcwd() try: - config = load_fastmcp_config(config_file) # noqa: F841 + config = load_mcp_server_config(config_file) # noqa: F841 # Should change to parent directory of config file assert Path.cwd() == subdir1.resolve() @@ -180,7 +180,7 @@ def test_load_minimal_config(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - config = load_fastmcp_config(config_file) + config = load_mcp_server_config(config_file) # Check we got source - path is not resolved yet, only during load_server assert isinstance(config.source, FileSystemSource) @@ -201,7 +201,7 @@ def test_load_config_with_server_args(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - config = load_fastmcp_config(config_file) + config = load_mcp_server_config(config_file) assert config.deployment.args == ["--debug", "--config", "custom.json"] @@ -221,7 +221,7 @@ def test_config_subset_independence(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - config = load_fastmcp_config(config_file) + config = load_mcp_server_config(config_file) # Each subset should be independently usable # Path is not resolved yet, only during load_server @@ -259,7 +259,7 @@ def test_environment_config_path_resolution(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - config = load_fastmcp_config(config_file) + config = load_mcp_server_config(config_file) # Check that UV command is built with resolved paths uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) diff --git a/tests/cli/test_server_args.py b/tests/cli/test_server_args.py index 5ec7ccc6a..8f1782dbf 100644 --- a/tests/cli/test_server_args.py +++ b/tests/cli/test_server_args.py @@ -4,8 +4,8 @@ from pathlib import Path import pytest -from fastmcp.utilities.fastmcp_config import FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.mcp_server_config import MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestServerArguments: @@ -39,7 +39,7 @@ def get_config() -> dict: # Test with arguments source = FileSystemSource(path=str(server_file)) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) from fastmcp.cli.cli import with_argv @@ -69,7 +69,7 @@ mcp = FastMCP(args.name) """) source = FileSystemSource(path=str(server_file)) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) from fastmcp.cli.cli import with_argv @@ -96,7 +96,7 @@ mcp = FastMCP(name) """) source = FileSystemSource(path=str(server_file)) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) from fastmcp.cli.cli import with_argv @@ -123,7 +123,7 @@ mcp = FastMCP(name) pytest.skip("config_server.py example not found") source = FileSystemSource(path=str(config_server)) - config = FastMCPConfig(source=source) + config = MCPServerConfig(source=source) from fastmcp.cli.cli import with_argv diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index cd8b55dcb..56d5a5255 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,6 +1,6 @@ """Tests for CLI utility functions.""" -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment +from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import Environment class TestEnvironmentBuildUVRunCommand: From ea47d232bc7c4c360a36ffafe9bdca25a9612a0e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:29:35 -0400 Subject: [PATCH 17/26] Refactor Environment to support multiple runtime types (#1673) --- src/fastmcp/cli/claude.py | 6 +- src/fastmcp/cli/cli.py | 25 +- src/fastmcp/cli/install/claude_code.py | 6 +- src/fastmcp/cli/install/claude_desktop.py | 6 +- src/fastmcp/cli/install/cursor.py | 10 +- src/fastmcp/cli/install/mcp_json.py | 6 +- src/fastmcp/cli/run.py | 6 +- src/fastmcp/client/transports.py | 4 +- .../utilities/mcp_server_config/__init__.py | 4 +- .../v1/environments/__init__.py | 6 + .../mcp_server_config/v1/environments/base.py | 28 ++ .../mcp_server_config/v1/environments/uv.py | 303 ++++++++++++++++++ .../mcp_server_config/v1/mcp_server_config.py | 301 +---------------- .../mcp_server_config/v1/schema.json | 76 ++--- tests/cli/test_config.py | 14 +- .../cli/test_mcp_server_config_integration.py | 2 +- tests/cli/test_project_prepare.py | 21 +- tests/cli/test_run_config.py | 6 +- tests/cli/test_run_with_uv.py | 6 +- tests/utilities/test_cli.py | 51 +-- 20 files changed, 480 insertions(+), 407 deletions(-) create mode 100644 src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py create mode 100644 src/fastmcp/utilities/mcp_server_config/v1/environments/base.py create mode 100644 src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index 5004ccebd..1a97defb6 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment logger = get_logger(__name__) @@ -99,7 +99,7 @@ def update_claude_config( deduplicated_packages = None # Build uv run command using Environment.build_uv_run_command() - env_config = Environment( + env_config = UVEnvironment( dependencies=deduplicated_packages, editable=[str(p) for p in with_editable] if with_editable else None, ) @@ -113,7 +113,7 @@ def update_claude_config( file_spec = str(Path(file_spec).resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", file_spec]) + full_command = env_config.build_command(["fastmcp", "run", file_spec]) # Extract command and args for the config server_config: dict[str, Any] = { diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index dd7e65a01..076277932 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -26,7 +26,8 @@ from fastmcp.utilities.inspect import ( inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment, MCPServerConfig +from fastmcp.utilities.mcp_server_config import MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment logger = get_logger("cli") console = Console() @@ -245,7 +246,7 @@ async def dev( env_deps = config.environment.dependencies or [] all_deps = list(set(env_deps + server.dependencies)) if not config.environment: - config.environment = Environment(dependencies=all_deps) + config.environment = UVEnvironment(dependencies=all_deps) else: config.environment.dependencies = all_deps @@ -269,7 +270,7 @@ async def dev( inspector_cmd += f"@{inspector_version}" # Use the environment from config (already has CLI overrides applied) - uv_cmd = config.environment.build_uv_run_command( + uv_cmd = config.environment.build_command( ["fastmcp", "run", server_spec, "--no-banner"] ) @@ -460,7 +461,9 @@ async def run( ) # Check if we need to use uv run (but skip if we're already in uv or user said to skip) - needs_uv = config.environment.needs_uv() and not skip_env + # We check if the environment would modify the command + test_cmd = ["test"] + needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Use uv run subprocess - always use run_with_uv which handles output correctly @@ -627,7 +630,9 @@ async def inspect( sys.exit(1) # Check if we need to use uv run (but skip if we're already in uv or user said to skip) - needs_uv = config.environment.needs_uv() and not skip_env + # We check if the environment would modify the command + test_cmd = ["test"] + needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Build and run uv command @@ -643,8 +648,14 @@ async def inspect( inspect_command.extend(["--format", format.value]) if output: inspect_command.extend(["--output", str(output)]) - config.environment.run_with_uv(inspect_command) - return # run_with_uv exits the process + + # Run the command using subprocess + import subprocess + + cmd = config.environment.build_command(inspect_command) + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} + process = subprocess.run(cmd, check=True, env=env) + sys.exit(process.returncode) logger.debug( "Inspecting server", diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 7c04c02e2..e3ecbf26c 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -10,7 +10,7 @@ import cyclopts from rich import print from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args @@ -116,7 +116,7 @@ def install_claude_code( deduplicated_packages = None # Build uv run command using Environment.build_uv_run_command() - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, @@ -131,7 +131,7 @@ def install_claude_code( server_spec = str(file.resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) + full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add"] diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index df93a8856..44bca8c70 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -10,7 +10,7 @@ from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args @@ -81,7 +81,7 @@ def install_claude_desktop( if not deduplicated_packages: deduplicated_packages = None - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, @@ -95,7 +95,7 @@ def install_claude_desktop( server_spec = str(file.resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) + full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 650ef57ff..387787367 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -11,7 +11,7 @@ from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args @@ -115,7 +115,7 @@ def install_cursor_workspace( if not deduplicated_packages: deduplicated_packages = None - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, @@ -129,7 +129,7 @@ def install_cursor_workspace( server_spec = str(file.resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) + full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( @@ -193,7 +193,7 @@ def install_cursor( if not deduplicated_packages: deduplicated_packages = None - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, @@ -207,7 +207,7 @@ def install_cursor( server_spec = str(file.resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) + full_command = env_config.build_command(["fastmcp", "run", server_spec]) # If workspace is specified, install to workspace-specific config if workspace: diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 7fab1e067..aa860eebf 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -10,7 +10,7 @@ import pyperclip from rich import print from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args @@ -56,7 +56,7 @@ def install_mcp_json( if not deduplicated_packages: deduplicated_packages = None - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, @@ -70,7 +70,7 @@ def install_mcp_json( server_spec = str(file.resolve()) # Build the full command - full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) + full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Build MCP server configuration server_config = { diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index c01ea0085..f63737ed8 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -13,9 +13,9 @@ from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import ( - Environment, MCPServerConfig, ) +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.run") @@ -67,7 +67,7 @@ def run_with_uv( """ # Build uv command using Environment.build_uv_run_command() - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=with_packages if with_packages else None, requirements=str(with_requirements.resolve()) if with_requirements else None, @@ -97,7 +97,7 @@ def run_with_uv( inner_cmd.append("--no-banner") # Build the full uv command - cmd = env_config.build_uv_run_command(inner_cmd) + cmd = env_config.build_command(inner_cmd) # Set marker to prevent infinite loops when subprocess calls FastMCP again env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index c2b4c596f..5e031da38 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -37,7 +37,7 @@ from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment logger = get_logger(__name__) @@ -597,7 +597,7 @@ class UvStdioTransport(StdioTransport): ) # Create Environment from provided parameters (internal use) - env_config = Environment( + env_config = UVEnvironment( python=python_version, dependencies=with_packages, requirements=with_requirements, diff --git a/src/fastmcp/utilities/mcp_server_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py index 363aa3b30..a51dbcf6d 100644 --- a/src/fastmcp/utilities/mcp_server_config/__init__.py +++ b/src/fastmcp/utilities/mcp_server_config/__init__.py @@ -4,9 +4,10 @@ This module provides versioned configuration support for FastMCP servers. The current version is v1, which is re-exported here for convenience. """ +from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( Deployment, - Environment, MCPServerConfig, generate_schema, ) @@ -17,6 +18,7 @@ __all__ = [ "BaseSource", "Deployment", "Environment", + "UVEnvironment", "MCPServerConfig", "FileSystemSource", "generate_schema", diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py new file mode 100644 index 000000000..3cccf548f --- /dev/null +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py @@ -0,0 +1,6 @@ +"""Environment configuration for MCP servers.""" + +from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment + +__all__ = ["Environment", "UVEnvironment"] diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py new file mode 100644 index 000000000..c4a23a716 --- /dev/null +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from pydantic import BaseModel + + +class Environment(BaseModel, ABC): + """Base class for environment configuration.""" + + @abstractmethod + def build_command(self, command: list[str]) -> list[str]: + """Build the full command with environment setup. + + Args: + command: Base command to wrap with environment setup + + Returns: + Full command ready for subprocess execution + """ + pass + + async def prepare(self, output_dir: Path | None = None) -> None: + """Prepare the environment (optional, can be no-op). + + Args: + output_dir: Directory for persistent environment setup + """ + pass # Default no-op implementation diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py new file mode 100644 index 000000000..a0500ca22 --- /dev/null +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py @@ -0,0 +1,303 @@ +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from pydantic import Field + +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment + +logger = get_logger("cli.config") + + +class UVEnvironment(Environment): + """Configuration for Python environment setup.""" + + python: str | None = Field( + default=None, + description="Python version constraint", + examples=["3.10", "3.11", "3.12"], + ) + + dependencies: list[str] | None = Field( + default=None, + description="Python packages to install with PEP 508 specifiers", + examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], + ) + + requirements: str | None = Field( + default=None, + description="Path to requirements.txt file", + examples=["requirements.txt", "../requirements/prod.txt"], + ) + + project: str | None = Field( + default=None, + description="Path to project directory containing pyproject.toml", + examples=[".", "../my-project"], + ) + + editable: list[str] | None = Field( + default=None, + description="Directories to install in editable mode", + examples=[[".", "../my-package"], ["/path/to/package"]], + ) + + def build_command(self, command: list[str]) -> list[str]: + """Build complete uv run command with environment args and command to execute. + + Args: + command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) + + Returns: + Complete command ready for subprocess.run, including "uv" prefix if needed. + If no environment configuration is set, returns the command unchanged. + """ + # If no environment setup is needed, return command as-is + if not self._needs_setup(): + return command + + args = ["uv", "run"] + + # Add project if specified + if self.project: + args.extend(["--project", str(self.project)]) + + # Add Python version if specified (only if no project, as project has its own Python) + if self.python and not self.project: + args.extend(["--python", self.python]) + + # Always add dependencies, requirements, and editable packages + # These work with --project to add additional packages on top of the project env + if self.dependencies: + for dep in self.dependencies: + args.extend(["--with", dep]) + + # Add requirements file + if self.requirements: + args.extend(["--with-requirements", str(self.requirements)]) + + # Add editable packages + if self.editable: + for editable_path in self.editable: + args.extend(["--with-editable", str(editable_path)]) + + # Add the command + args.extend(command) + + return args + + def run_with_uv(self, command: list[str]) -> None: + """Execute a command using uv run with this environment configuration. + + Args: + command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) + """ + import subprocess + + # Build the full uv command + cmd = self.build_command(command) + + # Set marker to prevent infinite loops when subprocess calls FastMCP again + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} + + logger.debug(f"Running command: {' '.join(cmd)}") + + try: + # Run without capturing output so it flows through naturally + process = subprocess.run(cmd, check=True, env=env) + sys.exit(process.returncode) + except subprocess.CalledProcessError as e: + logger.error(f"Command failed: {e}") + sys.exit(e.returncode) + + def _needs_setup(self) -> bool: + """Check if this environment config requires uv to set up. + + Returns: + True if any environment settings require uv run + """ + return any( + [ + self.python is not None, + self.dependencies is not None, + self.requirements is not None, + self.project is not None, + self.editable is not None, + ] + ) + + # Backward compatibility aliases + def needs_uv(self) -> bool: + """Deprecated: Use _needs_setup() internally or check if build_command modifies the command.""" + return self._needs_setup() + + def build_uv_run_command(self, command: list[str]) -> list[str]: + """Deprecated: Use build_command() instead.""" + return self.build_command(command) + + async def prepare(self, output_dir: Path | None = None) -> None: + """Prepare the Python environment using uv. + + Args: + output_dir: Directory where the persistent uv project will be created. + If None, creates a temporary directory for ephemeral use. + """ + + # Check if uv is available + if not shutil.which("uv"): + raise RuntimeError( + "uv is not installed. Please install it with: " + "curl -LsSf https://astral.sh/uv/install.sh | sh" + ) + + # Only prepare environment if there are actual settings to apply + if not self._needs_setup(): + logger.debug("No environment settings configured, skipping preparation") + return + + # Handle None case for ephemeral use + if output_dir is None: + import tempfile + + output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-")) + logger.info(f"Creating ephemeral environment in {output_dir}") + else: + logger.info(f"Creating persistent environment in {output_dir}") + output_dir = Path(output_dir).resolve() + + # Initialize the project + logger.debug(f"Initializing uv project in {output_dir}") + try: + subprocess.run( + [ + "uv", + "init", + "--project", + str(output_dir), + "--name", + "fastmcp-env", + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + # If project already exists, that's fine - continue + if "already initialized" in e.stderr.lower(): + logger.debug( + f"Project already initialized at {output_dir}, continuing..." + ) + else: + logger.error(f"Failed to initialize project: {e.stderr}") + raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e + + # Pin Python version if specified + if self.python: + logger.debug(f"Pinning Python version to {self.python}") + try: + subprocess.run( + [ + "uv", + "python", + "pin", + self.python, + "--project", + str(output_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to pin Python version: {e.stderr}") + raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e + + # Add dependencies with --no-sync to defer installation + # dependencies ALWAYS include fastmcp; this is compatible with + # specific fastmcp versions that might be in the dependencies list + dependencies = (self.dependencies or []) + ["fastmcp"] + logger.debug(f"Adding dependencies: {', '.join(dependencies)}") + try: + subprocess.run( + [ + "uv", + "add", + *dependencies, + "--no-sync", + "--project", + str(output_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to add dependencies: {e.stderr}") + raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e + + # Add requirements file if specified + if self.requirements: + logger.debug(f"Adding requirements from {self.requirements}") + # Resolve requirements path relative to current directory + req_path = Path(self.requirements).resolve() + try: + subprocess.run( + [ + "uv", + "add", + "-r", + str(req_path), + "--no-sync", + "--project", + str(output_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to add requirements: {e.stderr}") + raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e + + # Add editable packages if specified + if self.editable: + editable_paths = [str(Path(e).resolve()) for e in self.editable] + logger.debug(f"Adding editable packages: {', '.join(editable_paths)}") + try: + subprocess.run( + [ + "uv", + "add", + "--editable", + *editable_paths, + "--no-sync", + "--project", + str(output_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to add editable packages: {e.stderr}") + raise RuntimeError( + f"Failed to add editable packages: {e.stderr}" + ) from e + + # Final sync to install everything + logger.info("Installing dependencies...") + try: + subprocess.run( + ["uv", "sync", "--project", str(output_dir)], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to sync dependencies: {e.stderr}") + raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e + + logger.info(f"Environment prepared successfully in {output_dir}") diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index ca50470e7..690f7bf48 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -10,14 +10,13 @@ from __future__ import annotations import json import os import re -import shutil -import subprocess from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, overload from pydantic import BaseModel, Field, field_validator from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.config") @@ -27,285 +26,9 @@ FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json # Type alias for source union (will expand with GitSource, etc in future) -SourceType = FileSystemSource - - -class Environment(BaseModel): - """Configuration for Python environment setup.""" - - python: str | None = Field( - default=None, - description="Python version constraint", - examples=["3.10", "3.11", "3.12"], - ) - - dependencies: list[str] | None = Field( - default=None, - description="Python packages to install with PEP 508 specifiers", - examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], - ) - - requirements: str | None = Field( - default=None, - description="Path to requirements.txt file", - examples=["requirements.txt", "../requirements/prod.txt"], - ) - - project: str | None = Field( - default=None, - description="Path to project directory containing pyproject.toml", - examples=[".", "../my-project"], - ) - - editable: list[str] | None = Field( - default=None, - description="Directories to install in editable mode", - examples=[[".", "../my-package"], ["/path/to/package"]], - ) - - def build_uv_run_command(self, command: list[str]) -> list[str]: - """Build complete uv run command with environment args and command to execute. - - Args: - command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) - - Returns: - Complete command ready for subprocess.run, including "uv" prefix - """ - args = ["uv", "run"] - - # Add project if specified - if self.project: - args.extend(["--project", str(self.project)]) - - # Add Python version if specified (only if no project, as project has its own Python) - if self.python and not self.project: - args.extend(["--python", self.python]) - - # Always add dependencies, requirements, and editable packages - # These work with --project to add additional packages on top of the project env - if self.dependencies: - for dep in self.dependencies: - args.extend(["--with", dep]) - - # Add requirements file - if self.requirements: - args.extend(["--with-requirements", str(self.requirements)]) - - # Add editable packages - if self.editable: - for editable_path in self.editable: - args.extend(["--with-editable", str(editable_path)]) - - # Add the command - args.extend(command) - - return args - - def run_with_uv(self, command: list[str]) -> None: - """Execute a command using uv run with this environment configuration. - - Args: - command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) - """ - import subprocess - import sys - - # Build the full uv command - cmd = self.build_uv_run_command(command) - - # Set marker to prevent infinite loops when subprocess calls FastMCP again - env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} - - logger.debug(f"Running command: {' '.join(cmd)}") - - try: - # Run without capturing output so it flows through naturally - process = subprocess.run(cmd, check=True, env=env) - sys.exit(process.returncode) - except subprocess.CalledProcessError as e: - logger.error(f"Command failed: {e}") - sys.exit(e.returncode) - - def needs_uv(self) -> bool: - """Check if this environment config requires uv to set up. - - Returns: - True if any environment settings require uv run - """ - return any( - [ - self.python is not None, - self.dependencies is not None, - self.requirements is not None, - self.project is not None, - self.editable is not None, - ] - ) - - async def prepare(self, output_dir: Path | None = None) -> None: - """Prepare the Python environment using uv. - - Args: - output_dir: Directory where the persistent uv project will be created. - If None, creates a temporary directory for ephemeral use. - """ - - # Check if uv is available - if not shutil.which("uv"): - raise RuntimeError( - "uv is not installed. Please install it with: " - "curl -LsSf https://astral.sh/uv/install.sh | sh" - ) - - # Only prepare environment if there are actual settings to apply - if not self.needs_uv(): - logger.debug("No environment settings configured, skipping preparation") - return - - # Handle None case for ephemeral use - if output_dir is None: - import tempfile - - output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-")) - logger.info(f"Creating ephemeral environment in {output_dir}") - else: - logger.info(f"Creating persistent environment in {output_dir}") - output_dir = Path(output_dir).resolve() - - # Initialize the project - logger.debug(f"Initializing uv project in {output_dir}") - try: - subprocess.run( - [ - "uv", - "init", - "--project", - str(output_dir), - "--name", - "fastmcp-env", - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - # If project already exists, that's fine - continue - if "already initialized" in e.stderr.lower(): - logger.debug( - f"Project already initialized at {output_dir}, continuing..." - ) - else: - logger.error(f"Failed to initialize project: {e.stderr}") - raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e - - # Pin Python version if specified - if self.python: - logger.debug(f"Pinning Python version to {self.python}") - try: - subprocess.run( - [ - "uv", - "python", - "pin", - self.python, - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to pin Python version: {e.stderr}") - raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e - - # Add dependencies with --no-sync to defer installation - # dependencies ALWAYS include fastmcp; this is compatible with - # specific fastmcp versions that might be in the dependencies list - dependencies = (self.dependencies or []) + ["fastmcp"] - logger.debug(f"Adding dependencies: {', '.join(dependencies)}") - try: - subprocess.run( - [ - "uv", - "add", - *dependencies, - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add dependencies: {e.stderr}") - raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e - - # Add requirements file if specified - if self.requirements: - logger.debug(f"Adding requirements from {self.requirements}") - # Resolve requirements path relative to current directory - req_path = Path(self.requirements).resolve() - try: - subprocess.run( - [ - "uv", - "add", - "-r", - str(req_path), - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add requirements: {e.stderr}") - raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e - - # Add editable packages if specified - if self.editable: - editable_paths = [str(Path(e).resolve()) for e in self.editable] - logger.debug(f"Adding editable packages: {', '.join(editable_paths)}") - try: - subprocess.run( - [ - "uv", - "add", - "--editable", - *editable_paths, - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add editable packages: {e.stderr}") - raise RuntimeError( - f"Failed to add editable packages: {e.stderr}" - ) from e - - # Final sync to install everything - logger.info("Installing dependencies...") - try: - subprocess.run( - ["uv", "sync", "--project", str(output_dir)], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to sync dependencies: {e.stderr}") - raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e - - logger.info(f"Environment prepared successfully in {output_dir}") +SourceType: TypeAlias = FileSystemSource +# Type alias for environment union (will expand with other environments in future) +EnvironmentType: TypeAlias = UVEnvironment class Deployment(BaseModel): @@ -431,8 +154,8 @@ class MCPServerConfig(BaseModel): ) # Environment configuration - environment: Environment = Field( - default_factory=lambda: Environment(), + environment: EnvironmentType = Field( + default_factory=lambda: UVEnvironment(), description="Python environment setup configuration", ) @@ -448,7 +171,7 @@ class MCPServerConfig(BaseModel): @overload def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ... @overload - def __init__(self, *, environment: dict | Environment, **data) -> None: ... + def __init__(self, *, environment: dict | UVEnvironment, **data) -> None: ... @overload def __init__(self, *, deployment: dict | Deployment, **data) -> None: ... def __init__(self, **data) -> None: ... @@ -478,17 +201,17 @@ class MCPServerConfig(BaseModel): @field_validator("environment", mode="before") @classmethod - def validate_environment(cls, v: dict | Environment) -> Environment: + def validate_environment(cls, v: dict | UVEnvironment) -> UVEnvironment: """Validate and convert environment to Environment. Accepts: - Environment instance - dict that can be converted to Environment """ - if isinstance(v, Environment): + if isinstance(v, UVEnvironment): return v elif isinstance(v, dict): - return Environment(**v) # type: ignore[arg-type] + return UVEnvironment(**v) # type: ignore[arg-type] else: raise ValueError("environment must be a dict, Environment instance") @@ -578,7 +301,7 @@ class MCPServerConfig(BaseModel): # Build environment config if any env args provided environment = None if any([python, dependencies, requirements, project, editable]): - environment = Environment( + environment = UVEnvironment( python=python, dependencies=dependencies, requirements=requirements, diff --git a/src/fastmcp/utilities/mcp_server_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json index c28a50452..bc072a67d 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/schema.json +++ b/src/fastmcp/utilities/mcp_server_config/v1/schema.json @@ -162,7 +162,42 @@ "title": "Deployment", "type": "object" }, - "Environment": { + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + }, + "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -266,42 +301,7 @@ "title": "Editable" } }, - "title": "Environment", - "type": "object" - }, - "FileSystemSource": { - "description": "Source for local Python files.", - "properties": { - "type": { - "const": "filesystem", - "default": "filesystem", - "description": "Source type", - "title": "Type", - "type": "string" - }, - "path": { - "description": "Path to Python file containing the server", - "title": "Path", - "type": "string" - }, - "entrypoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - "title": "Entrypoint" - } - }, - "required": [ - "path" - ], - "title": "FileSystemSource", + "title": "UVEnvironment", "type": "object" } }, @@ -339,7 +339,7 @@ ] }, "environment": { - "$ref": "#/$defs/Environment", + "$ref": "#/$defs/UVEnvironment", "description": "Python environment setup configuration" }, "deployment": { diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 6d187f934..4ea56fdf6 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -9,9 +9,9 @@ from pydantic import ValidationError from fastmcp.utilities.mcp_server_config import ( Deployment, - Environment, MCPServerConfig, ) +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource @@ -104,7 +104,7 @@ class TestEnvironment: }, ) - cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) + cmd = config.environment.build_command(["fastmcp", "run", "server.py"]) assert cmd[0] == "uv" assert cmd[1] == "run" @@ -263,7 +263,7 @@ class TestMCPServerConfig: assert config.source.path == "server.py" assert config.source.entrypoint is None # Environment and deployment are now always present but empty - assert isinstance(config.environment, Environment) + assert isinstance(config.environment, UVEnvironment) assert isinstance(config.deployment, Deployment) # Check they have no values set assert not config.environment.needs_uv() @@ -289,7 +289,7 @@ class TestMCPServerConfig: assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" assert config.source.entrypoint is None - assert isinstance(config.environment, Environment) + assert isinstance(config.environment, UVEnvironment) assert isinstance(config.deployment, Deployment) def test_from_file(self, tmp_path): @@ -416,7 +416,7 @@ class TestMCPServerConfig: assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" # Environment and deployment are now always present but may be empty - assert isinstance(config.environment, Environment) + assert isinstance(config.environment, UVEnvironment) assert isinstance(config.deployment, Deployment) # Only environment with values @@ -434,9 +434,9 @@ class TestMCPServerConfig: config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "http"} ) - assert isinstance(config.environment, Environment) + assert isinstance(config.environment, UVEnvironment) assert all( getattr(config.environment, field, None) is None - for field in Environment.model_fields + for field in UVEnvironment.model_fields ) assert config.deployment.transport == "http" diff --git a/tests/cli/test_mcp_server_config_integration.py b/tests/cli/test_mcp_server_config_integration.py index e925f1bb4..1a25d14dd 100644 --- a/tests/cli/test_mcp_server_config_integration.py +++ b/tests/cli/test_mcp_server_config_integration.py @@ -229,7 +229,7 @@ class TestPathResolution: # Build UV command assert config.environment is not None - uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run"]) + uv_cmd = config.environment.build_command(["fastmcp", "run"]) # Should include requirements file assert "--with-requirements" in uv_cmd diff --git a/tests/cli/test_project_prepare.py b/tests/cli/test_project_prepare.py index 5a2ad15b1..88e3fe5c2 100644 --- a/tests/cli/test_project_prepare.py +++ b/tests/cli/test_project_prepare.py @@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastmcp.utilities.mcp_server_config import Environment, MCPServerConfig +from fastmcp.utilities.mcp_server_config import MCPServerConfig +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource @@ -25,7 +26,7 @@ class TestMCPServerConfigPrepare: """Test that prepare() calls both prepare_environment and prepare_source.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), - environment=Environment(python="3.10"), + environment=UVEnvironment(python="3.10"), ) await config.prepare() @@ -45,7 +46,7 @@ class TestMCPServerConfigPrepare: """Test that prepare() with output_dir calls prepare_environment with it.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), - environment=Environment(python="3.10"), + environment=UVEnvironment(python="3.10"), ) output_path = Path("/tmp/test-env") @@ -66,7 +67,7 @@ class TestMCPServerConfigPrepare: """Test that prepare() skips source when skip_source=True.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), - environment=Environment(python="3.10"), + environment=UVEnvironment(python="3.10"), ) await config.prepare(skip_source=True) @@ -79,7 +80,7 @@ class TestMCPServerConfigPrepare: new_callable=AsyncMock, ) @patch( - "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.Environment.prepare", + "fastmcp.utilities.mcp_server_config.v1.environments.uv.UVEnvironment.prepare", new_callable=AsyncMock, ) async def test_prepare_no_environment_settings(self, mock_env_prepare, mock_src): @@ -104,7 +105,7 @@ class TestEnvironmentPrepare: """Test that prepare() raises error when uv is not installed.""" mock_which.return_value = None - env = Environment(python="3.10") + env = UVEnvironment(python="3.10") with pytest.raises(RuntimeError, match="uv is not installed"): await env.prepare(tmp_path / "test-env") @@ -115,7 +116,7 @@ class TestEnvironmentPrepare: """Test that prepare() does nothing when no settings are configured.""" mock_which.return_value = "/usr/bin/uv" - env = Environment() # No settings + env = UVEnvironment() # No settings await env.prepare(tmp_path / "test-env") @@ -131,7 +132,7 @@ class TestEnvironmentPrepare: returncode=0, stdout="Environment cached", stderr="" ) - env = Environment(python="3.10") + env = UVEnvironment(python="3.10") await env.prepare(tmp_path / "test-env") @@ -150,7 +151,7 @@ class TestEnvironmentPrepare: mock_which.return_value = "/usr/bin/uv" mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - env = Environment(dependencies=["numpy", "pandas"]) + env = UVEnvironment(dependencies=["numpy", "pandas"]) await env.prepare(tmp_path / "test-env") @@ -179,7 +180,7 @@ class TestEnvironmentPrepare: 1, ["uv"], stderr="Package not found" ) - env = Environment(python="3.10") + env = UVEnvironment(python="3.10") with pytest.raises(RuntimeError, match="Failed to initialize project"): await env.prepare(tmp_path / "test-env") diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index abc1f10af..b8114aa07 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -9,9 +9,9 @@ import pytest from fastmcp.cli.run import load_mcp_server_config from fastmcp.utilities.mcp_server_config import ( Deployment, - Environment, MCPServerConfig, ) +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource @@ -56,7 +56,7 @@ def test_load_mcp_server_config(sample_config, monkeypatch): assert isinstance(config, MCPServerConfig) assert isinstance(config.source, FileSystemSource) assert isinstance(config.deployment, Deployment) - assert isinstance(config.environment, Environment) + assert isinstance(config.environment, UVEnvironment) # Check source - path is not resolved yet, only during load_server assert config.source.path == "server.py" @@ -262,7 +262,7 @@ def test_environment_config_path_resolution(tmp_path): config = load_mcp_server_config(config_file) # Check that UV command is built with resolved paths - uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) + uv_cmd = config.environment.build_command(["fastmcp", "run", "server.py"]) assert "--with-requirements" in uv_cmd assert "--project" in uv_cmd diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 446b21787..3bc302269 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -27,9 +27,8 @@ class TestRunWithUv: cmd = mock_run.call_args[0][0] env = mock_run.call_args.kwargs.get("env", {}) + # With no environment config, the command should be returned unchanged expected = [ - "uv", - "run", "fastmcp", "run", "server.py", @@ -150,9 +149,8 @@ class TestRunWithUv: assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] + # With no environment config, no uv run prefix expected = [ - "uv", - "run", "fastmcp", "run", "server.py", diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index 56d5a5255..afe17deda 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,6 +1,6 @@ """Tests for CLI utility functions.""" -from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import Environment +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment class TestEnvironmentBuildUVRunCommand: @@ -8,16 +8,17 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_basic(self): """Test building basic uv command with no environment config.""" - env = Environment() - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) - expected = ["uv", "run", "fastmcp", "run", "server.py"] + env = UVEnvironment() + cmd = env.build_command(["fastmcp", "run", "server.py"]) + # With no config, the command should be returned unchanged + expected = ["fastmcp", "run", "server.py"] assert cmd == expected def test_build_uv_run_command_with_editable(self): """Test building uv command with editable package.""" editable_path = "/path/to/package" - env = Environment(editable=[editable_path]) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + env = UVEnvironment(editable=[editable_path]) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -31,8 +32,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_packages(self): """Test building uv command with additional packages.""" - env = Environment(dependencies=["pkg1", "pkg2"]) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + env = UVEnvironment(dependencies=["pkg1", "pkg2"]) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -48,8 +49,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_python_version(self): """Test building uv command with Python version.""" - env = Environment(python="3.10") - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + env = UVEnvironment(python="3.10") + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -64,8 +65,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_requirements(self): """Test building uv command with requirements file.""" requirements_path = "/path/to/requirements.txt" - env = Environment(requirements=requirements_path) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + env = UVEnvironment(requirements=requirements_path) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -80,8 +81,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_project(self): """Test building uv command with project directory.""" project_path = "/path/to/project" - env = Environment(project=project_path) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + env = UVEnvironment(project=project_path) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -97,13 +98,13 @@ class TestEnvironmentBuildUVRunCommand: """Test building uv command with all options.""" requirements_path = "/path/to/requirements.txt" editable_path = "/local/pkg" - env = Environment( + env = UVEnvironment( python="3.10", dependencies=["pandas", "numpy"], requirements=requirements_path, editable=[editable_path], ) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -129,13 +130,13 @@ 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" - env = Environment( + 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 ) - cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", @@ -157,35 +158,35 @@ class TestEnvironmentNeedsUV: def test_needs_uv_with_python(self): """Test that needs_uv returns True with Python version.""" - env = Environment(python="3.10") + env = UVEnvironment(python="3.10") assert env.needs_uv() is True def test_needs_uv_with_dependencies(self): """Test that needs_uv returns True with dependencies.""" - env = Environment(dependencies=["pandas"]) + env = UVEnvironment(dependencies=["pandas"]) assert env.needs_uv() is True def test_needs_uv_with_requirements(self): """Test that needs_uv returns True with requirements.""" - env = Environment(requirements="/path/to/requirements.txt") + env = UVEnvironment(requirements="/path/to/requirements.txt") assert env.needs_uv() is True def test_needs_uv_with_project(self): """Test that needs_uv returns True with project.""" - env = Environment(project="/path/to/project") + env = UVEnvironment(project="/path/to/project") assert env.needs_uv() is True def test_needs_uv_with_editable(self): """Test that needs_uv returns True with editable.""" - env = Environment(editable=["/pkg"]) + env = UVEnvironment(editable=["/pkg"]) assert env.needs_uv() is True def test_needs_uv_empty(self): """Test that needs_uv returns False with empty config.""" - env = Environment() + env = UVEnvironment() assert env.needs_uv() is False def test_needs_uv_with_empty_lists(self): """Test that needs_uv returns False with empty lists.""" - env = Environment(dependencies=None, editable=None) + env = UVEnvironment(dependencies=None, editable=None) assert env.needs_uv() is False From c71c7ef8e3c0e0c216be6a0b887105407c85ad5f Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:35:08 -0400 Subject: [PATCH 18/26] chore: Update fastmcp.json schema (#1674) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/public/schemas/fastmcp.json/latest.json | 76 ++++++++++---------- docs/public/schemas/fastmcp.json/v1.json | 76 ++++++++++---------- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index c28a50452..bc072a67d 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -162,7 +162,42 @@ "title": "Deployment", "type": "object" }, - "Environment": { + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + }, + "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -266,42 +301,7 @@ "title": "Editable" } }, - "title": "Environment", - "type": "object" - }, - "FileSystemSource": { - "description": "Source for local Python files.", - "properties": { - "type": { - "const": "filesystem", - "default": "filesystem", - "description": "Source type", - "title": "Type", - "type": "string" - }, - "path": { - "description": "Path to Python file containing the server", - "title": "Path", - "type": "string" - }, - "entrypoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - "title": "Entrypoint" - } - }, - "required": [ - "path" - ], - "title": "FileSystemSource", + "title": "UVEnvironment", "type": "object" } }, @@ -339,7 +339,7 @@ ] }, "environment": { - "$ref": "#/$defs/Environment", + "$ref": "#/$defs/UVEnvironment", "description": "Python environment setup configuration" }, "deployment": { diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index c28a50452..bc072a67d 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -162,7 +162,42 @@ "title": "Deployment", "type": "object" }, - "Environment": { + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + }, + "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -266,42 +301,7 @@ "title": "Editable" } }, - "title": "Environment", - "type": "object" - }, - "FileSystemSource": { - "description": "Source for local Python files.", - "properties": { - "type": { - "const": "filesystem", - "default": "filesystem", - "description": "Source type", - "title": "Type", - "type": "string" - }, - "path": { - "description": "Path to Python file containing the server", - "title": "Path", - "type": "string" - }, - "entrypoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - "title": "Entrypoint" - } - }, - "required": [ - "path" - ], - "title": "FileSystemSource", + "title": "UVEnvironment", "type": "object" } }, @@ -339,7 +339,7 @@ ] }, "environment": { - "$ref": "#/$defs/Environment", + "$ref": "#/$defs/UVEnvironment", "description": "Python environment setup configuration" }, "deployment": { From bc84961ab97c9e2d2515d16a72cb89f7de87b2ac Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:35:28 -0400 Subject: [PATCH 19/26] chore: Update SDK documentation (#1675) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/docs.json | 8 ++ docs/python-sdk/fastmcp-cli-cli.mdx | 12 +-- ...server_config-v1-environments-__init__.mdx | 9 ++ ...mcp_server_config-v1-environments-base.mdx | 43 +++++++++ ...s-mcp_server_config-v1-environments-uv.mdx | 75 ++++++++++++++++ ...mcp_server_config-v1-mcp_server_config.mdx | 90 ++++--------------- 6 files changed, 156 insertions(+), 81 deletions(-) create mode 100644 docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx create mode 100644 docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx create mode 100644 docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx diff --git a/docs/docs.json b/docs/docs.json index 3cd031270..e2bc06e02 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -394,6 +394,14 @@ "group": "v1", "pages": [ "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__", + { + "group": "environments", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv" + ] + }, "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config", { "group": "sources", diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 83677c671..4dddc51ad 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx new file mode 100644 index 000000000..01f147482 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx @@ -0,0 +1,9 @@ +--- +title: __init__ +sidebarTitle: __init__ +--- + +# `fastmcp.utilities.mcp_server_config.v1.environments` + + +Environment configuration for MCP servers. diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx new file mode 100644 index 000000000..52b0de9a8 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx @@ -0,0 +1,43 @@ +--- +title: base +sidebarTitle: base +--- + +# `fastmcp.utilities.mcp_server_config.v1.environments.base` + +## Classes + +### `Environment` + + +Base class for environment configuration. + + +**Methods:** + +#### `build_command` + +```python +build_command(self, command: list[str]) -> list[str] +``` + +Build the full command with environment setup. + +**Args:** +- `command`: Base command to wrap with environment setup + +**Returns:** +- Full command ready for subprocess execution + + +#### `prepare` + +```python +prepare(self, output_dir: Path | None = None) -> None +``` + +Prepare the environment (optional, can be no-op). + +**Args:** +- `output_dir`: Directory for persistent environment setup + diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx new file mode 100644 index 000000000..84d117078 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx @@ -0,0 +1,75 @@ +--- +title: uv +sidebarTitle: uv +--- + +# `fastmcp.utilities.mcp_server_config.v1.environments.uv` + +## Classes + +### `UVEnvironment` + + +Configuration for Python environment setup. + + +**Methods:** + +#### `build_command` + +```python +build_command(self, command: list[str]) -> list[str] +``` + +Build complete uv run command with environment args and command to execute. + +**Args:** +- `command`: Command to execute (e.g., ["fastmcp", "run", "server.py"]) + +**Returns:** +- Complete command ready for subprocess.run, including "uv" prefix if needed. +- If no environment configuration is set, returns the command unchanged. + + +#### `run_with_uv` + +```python +run_with_uv(self, command: list[str]) -> None +``` + +Execute a command using uv run with this environment configuration. + +**Args:** +- `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) + + +#### `needs_uv` + +```python +needs_uv(self) -> bool +``` + +Deprecated: Use _needs_setup() internally or check if build_command modifies the command. + + +#### `build_uv_run_command` + +```python +build_uv_run_command(self, command: list[str]) -> list[str] +``` + +Deprecated: Use build_command() instead. + + +#### `prepare` + +```python +prepare(self, output_dir: Path | None = None) -> None +``` + +Prepare the Python environment using uv. + +**Args:** +- `output_dir`: Directory where the persistent uv project will be created. + If None, creates a temporary directory for ephemeral use. + diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx index fe7815a9b..7096185a0 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx @@ -15,7 +15,7 @@ command-line arguments. ## Functions -### `generate_schema` +### `generate_schema` ```python generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None @@ -38,67 +38,7 @@ validation and auto-completion. ## Classes -### `Environment` - - -Configuration for Python environment setup. - - -**Methods:** - -#### `build_uv_run_command` - -```python -build_uv_run_command(self, command: list[str]) -> list[str] -``` - -Build complete uv run command with environment args and command to execute. - -**Args:** -- `command`: Command to execute (e.g., ["fastmcp", "run", "server.py"]) - -**Returns:** -- Complete command ready for subprocess.run, including "uv" prefix - - -#### `run_with_uv` - -```python -run_with_uv(self, command: list[str]) -> None -``` - -Execute a command using uv run with this environment configuration. - -**Args:** -- `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) - - -#### `needs_uv` - -```python -needs_uv(self) -> bool -``` - -Check if this environment config requires uv to set up. - -**Returns:** -- True if any environment settings require uv run - - -#### `prepare` - -```python -prepare(self, output_dir: Path | None = None) -> None -``` - -Prepare the Python environment using uv. - -**Args:** -- `output_dir`: Directory where the persistent uv project will be created. - If None, creates a temporary directory for ephemeral use. - - -### `Deployment` +### `Deployment` Configuration for server deployment and runtime settings. @@ -106,7 +46,7 @@ Configuration for server deployment and runtime settings. **Methods:** -#### `apply_runtime_settings` +#### `apply_runtime_settings` ```python apply_runtime_settings(self, config_path: Path | None = None) -> None @@ -122,7 +62,7 @@ For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" will substitute the value of the ENVIRONMENT variable at runtime. -### `MCPServerConfig` +### `MCPServerConfig` Configuration for a FastMCP server. @@ -133,7 +73,7 @@ a FastMCP server in a declarative format. **Methods:** -#### `validate_source` +#### `validate_source` ```python validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource @@ -149,10 +89,10 @@ No string parsing happens here - that's only at CLI boundaries. MCPServerConfig works only with properly typed objects. -#### `validate_environment` +#### `validate_environment` ```python -validate_environment(cls, v: dict | Environment) -> Environment +validate_environment(cls, v: dict | UVEnvironment) -> UVEnvironment ``` Validate and convert environment to Environment. @@ -162,7 +102,7 @@ Accepts: - dict that can be converted to Environment -#### `validate_deployment` +#### `validate_deployment` ```python validate_deployment(cls, v: dict | Deployment) -> Deployment @@ -175,7 +115,7 @@ Accepts: - dict that can be converted to Deployment -#### `from_file` +#### `from_file` ```python from_file(cls, file_path: Path) -> MCPServerConfig @@ -195,7 +135,7 @@ Load configuration from a JSON file. - `pydantic.ValidationError`: If the configuration is invalid -#### `from_cli_args` +#### `from_cli_args` ```python from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> MCPServerConfig @@ -226,7 +166,7 @@ goes through a config object. - MCPServerConfig instance -#### `find_config` +#### `find_config` ```python find_config(cls, start_path: Path | None = None) -> Path | None @@ -241,7 +181,7 @@ Find a fastmcp.json file in the specified directory. - Path to the configuration file, or None if not found -#### `prepare` +#### `prepare` ```python prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None @@ -257,7 +197,7 @@ When output_dir is None, does ephemeral caching (for backwards compatibility). - `output_dir`: Directory to create the persistent uv project in (optional) -#### `prepare_environment` +#### `prepare_environment` ```python prepare_environment(self, output_dir: Path | None = None) -> None @@ -272,7 +212,7 @@ Prepare the Python environment. Delegates to the environment's prepare() method -#### `prepare_source` +#### `prepare_source` ```python prepare_source(self) -> None @@ -283,7 +223,7 @@ Prepare the source for loading. Delegates to the source's prepare() method. -#### `run_server` +#### `run_server` ```python run_server(self, **kwargs: Any) -> None From 183275c8c3453fc10c2840cce5dd82e288cbe863 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 30 Aug 2025 07:47:24 -0400 Subject: [PATCH 20/26] Add type field to Environment base class (#1676) --- docs/deployment/server-configuration.mdx | 50 ++++++++++++++---- .../utilities/mcp_server_config/__init__.py | 4 +- .../mcp_server_config/v1/environments/base.py | 4 +- .../mcp_server_config/v1/environments/uv.py | 3 ++ .../mcp_server_config/v1/mcp_server_config.py | 41 +++++---------- .../mcp_server_config/v1/sources/base.py | 2 +- .../v1/sources/filesystem.py | 7 +-- tests/cli/test_config.py | 2 + tests/cli/test_mcp_server_config_schema.py | 51 ++++++------------- 9 files changed, 86 insertions(+), 78 deletions(-) diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx index 44cbfbd3c..789a8005b 100644 --- a/docs/deployment/server-configuration.mdx +++ b/docs/deployment/server-configuration.mdx @@ -44,14 +44,20 @@ This conceptual model helps you understand the purpose of each configuration sec "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { // WHERE: Location of your server code + "type": "filesystem", // Optional, defaults to "filesystem" "path": "server.py", "entrypoint": "mcp" }, "environment": { - // WHAT: Python environment and dependencies + // WHAT: Environment setup and dependencies + "type": "uv", // Optional, defaults to "uv" + "python": ">=3.10", + "dependencies": ["pandas", "numpy"] }, "deployment": { // HOW: Runtime configuration + "transport": "stdio", + "log_level": "INFO" } } ``` @@ -128,15 +134,21 @@ Future releases will support additional source types: ### Environment Configuration -The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment using `uv`'s powerful dependency management. This section ensures your server runs with the exact Python version and dependencies it requires, creating isolated, reproducible environments across different systems. +The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment, ensuring your server runs with the exact Python version and dependencies it requires. This section creates isolated, reproducible environments across different systems. -These settings leverage standard `uv` arguments for environment creation. When any environment field is specified, FastMCP automatically creates an isolated environment before running your server. This build-time configuration happens once when the server starts, not during runtime execution. +FastMCP uses an extensible environment system with a base `Environment` class that can be implemented by different environment providers. Currently, FastMCP supports the `UVEnvironment` for Python environment management using `uv`'s powerful dependency resolver. - + - Optional Python environment configuration. When any field is specified, FastMCP automatically creates an isolated environment using `uv`. + Optional environment configuration. When specified, FastMCP uses the appropriate environment implementation to set up your server's runtime. - + + The environment type identifier that determines which implementation to use. Currently supports `"uv"` for Python environments managed by uv. If omitted, defaults to `"uv"`. + + + + When `type` is `"uv"` (or omitted), the environment uses uv to manage Python dependencies: + Python version constraint. Examples: - Exact version: `"3.12"` @@ -175,17 +187,36 @@ These settings leverage standard `uv` arguments for environment creation. When a "editable": [".", "../shared-lib", "/path/to/another-package"] ``` + + **Example:** + ```json + "environment": { + "type": "uv", + "python": ">=3.10", + "dependencies": ["pandas", "numpy"], + "editable": ["."] + } + ``` + + Note: When any UVEnvironment field is specified, FastMCP automatically creates an isolated environment using `uv` before running your server. When environment configuration is provided, FastMCP: -1. Creates an isolated Python environment using `uv` -2. Installs the specified dependencies -3. Runs your server in this clean environment +1. Detects the environment type (defaults to `"uv"` if not specified) +2. Creates an isolated environment using the appropriate provider +3. Installs the specified dependencies +4. Runs your server in this clean environment This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects. + +**Future Environment Types** + +Similar to source types, future releases may support additional environment types for different runtime requirements, such as Docker containers or language-specific environments beyond Python. + + ### Deployment Configuration The deployment configuration controls **HOW** your server runs. It defines the runtime behavior including network settings, environment variables, and execution context. These settings determine how your server operates when it executes, from transport protocols to logging levels. @@ -437,6 +468,7 @@ A configuration optimized for local development: }, // WHAT dependencies does it need? "environment": { + "type": "uv", "python": "3.12", "dependencies": ["fastmcp[dev]"], "editable": "." diff --git a/src/fastmcp/utilities/mcp_server_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py index a51dbcf6d..cbbfe5aa3 100644 --- a/src/fastmcp/utilities/mcp_server_config/__init__.py +++ b/src/fastmcp/utilities/mcp_server_config/__init__.py @@ -11,11 +11,11 @@ from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( MCPServerConfig, generate_schema, ) -from fastmcp.utilities.mcp_server_config.v1.sources.base import BaseSource +from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource __all__ = [ - "BaseSource", + "Source", "Deployment", "Environment", "UVEnvironment", diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py index c4a23a716..8209c7f4f 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py @@ -1,12 +1,14 @@ from abc import ABC, abstractmethod from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, Field class Environment(BaseModel, ABC): """Base class for environment configuration.""" + type: str = Field(description="Environment type identifier") + @abstractmethod def build_command(self, command: list[str]) -> list[str]: """Build the full command with environment setup. diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py index a0500ca22..3531dc569 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py @@ -3,6 +3,7 @@ import shutil import subprocess import sys from pathlib import Path +from typing import Literal from pydantic import Field @@ -15,6 +16,8 @@ logger = get_logger("cli.config") class UVEnvironment(Environment): """Configuration for Python environment setup.""" + type: Literal["uv"] = "uv" + python: str | None = Field( default=None, description="Python version constraint", diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index 690f7bf48..4c871cb11 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -11,12 +11,13 @@ import json import os import re from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, overload +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload from pydantic import BaseModel, Field, field_validator from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment +from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.config") @@ -27,6 +28,7 @@ FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json # Type alias for source union (will expand with GitSource, etc in future) SourceType: TypeAlias = FileSystemSource + # Type alias for environment union (will expand with other environments in future) EnvironmentType: TypeAlias = UVEnvironment @@ -178,7 +180,7 @@ class MCPServerConfig(BaseModel): @field_validator("source", mode="before") @classmethod - def validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource: + def validate_source(cls, v: dict | Source) -> SourceType: """Validate and convert source to proper format. Supports: @@ -188,32 +190,20 @@ class MCPServerConfig(BaseModel): No string parsing happens here - that's only at CLI boundaries. MCPServerConfig works only with properly typed objects. """ - if isinstance(v, FileSystemSource): - # Already a FileSystemSource instance, return as-is - return v - elif isinstance(v, dict): - # Dict can have type field or not (filesystem is default) - if "type" not in v: - v["type"] = "filesystem" + if isinstance(v, dict): return FileSystemSource(**v) - else: - raise ValueError("source must be a dict or FileSystemSource instance") + return v @field_validator("environment", mode="before") @classmethod - def validate_environment(cls, v: dict | UVEnvironment) -> UVEnvironment: - """Validate and convert environment to Environment. + def validate_environment(cls, v: dict | Any) -> EnvironmentType: + """Ensure environment has a type field for discrimination. - Accepts: - - Environment instance - - dict that can be converted to Environment + For backward compatibility, if no type is specified, default to "uv". """ - if isinstance(v, UVEnvironment): - return v - elif isinstance(v, dict): - return UVEnvironment(**v) # type: ignore[arg-type] - else: - raise ValueError("environment must be a dict, Environment instance") + if isinstance(v, dict): + return UVEnvironment(**v) + return v @field_validator("deployment", mode="before") @classmethod @@ -225,12 +215,9 @@ class MCPServerConfig(BaseModel): - dict that can be converted to Deployment """ - if isinstance(v, Deployment): - return v - elif isinstance(v, dict): + if isinstance(v, dict): return Deployment(**v) # type: ignore[arg-type] - else: - raise ValueError("deployment must be a dict, Deployment instance") + return cast(Deployment, v) @classmethod def from_file(cls, file_path: Path) -> MCPServerConfig: diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py index 1eeb593d9..fa6509353 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py @@ -4,7 +4,7 @@ from typing import Any from pydantic import BaseModel, Field -class BaseSource(BaseModel, ABC): +class Source(BaseModel, ABC): """Abstract base class for all source types.""" type: str = Field(description="Source type identifier") diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py index 29c2aeede..bddffe955 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py @@ -7,15 +7,16 @@ from typing import Any, Literal from pydantic import Field, field_validator from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.sources.base import BaseSource +from fastmcp.utilities.mcp_server_config.v1.sources.base import Source logger = get_logger(__name__) -class FileSystemSource(BaseSource): +class FileSystemSource(Source): """Source for local Python files.""" - type: Literal["filesystem"] = Field(default="filesystem", description="Source type") + type: Literal["filesystem"] = "filesystem" + path: str = Field(description="Path to Python file containing the server") entrypoint: str | None = Field( default=None, diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 4ea56fdf6..a3cc76fd3 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -435,8 +435,10 @@ class TestMCPServerConfig: source={"path": "server.py"}, deployment={"transport": "http"} ) assert isinstance(config.environment, UVEnvironment) + # Check all fields except 'type' which has a default value assert all( getattr(config.environment, field, None) is None for field in UVEnvironment.model_fields + if field != "type" ) assert config.deployment.transport == "http" diff --git a/tests/cli/test_mcp_server_config_schema.py b/tests/cli/test_mcp_server_config_schema.py index 1fda6e91b..d4f739911 100644 --- a/tests/cli/test_mcp_server_config_schema.py +++ b/tests/cli/test_mcp_server_config_schema.py @@ -1,41 +1,8 @@ -"""Test that the JSON schema file matches the Pydantic model.""" - -import json -from pathlib import Path +"""Test that the generated JSON schema has the correct structure.""" from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import generate_schema -def test_schema_file_matches_pydantic_model(): - """Test that the schema.json file matches what the Pydantic model generates.""" - # Path to the schema file - schema_file = ( - Path(__file__).parent.parent.parent - / "src" - / "fastmcp" - / "utilities" - / "mcp_server_config" - / "v1" - / "schema.json" - ) - - # Load the schema file - with open(schema_file) as f: - file_schema = json.load(f) - - # Generate schema from Pydantic model - generated_schema = generate_schema() - - # They should be identical - assert file_schema == generated_schema, ( - "The schema.json file does not match the Pydantic model schema. " - "Please regenerate the schema file by running:\n" - 'uv run python -c "from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import generate_schema; ' - 'import json; print(json.dumps(generate_schema(), indent=2))" > ' - f"{schema_file}" - ) - - def test_schema_has_correct_id(): """Test that the schema has the correct $id field.""" generated_schema = generate_schema() @@ -72,8 +39,22 @@ def test_schema_nested_structure(): # Check environment section assert "environment" in properties env_schema = properties["environment"] - if "properties" in env_schema: + # Environment can be in anyOf or direct properties + if "anyOf" in env_schema: + # Find the UVEnvironment in anyOf + for option in env_schema["anyOf"]: + if option.get("type") == "object" and "properties" in option: + env_props = option["properties"] + assert "type" in env_props # New type field + assert "python" in env_props + assert "dependencies" in env_props + assert "requirements" in env_props + assert "project" in env_props + assert "editable" in env_props + break + elif "properties" in env_schema: env_props = env_schema["properties"] + assert "type" in env_props # New type field assert "python" in env_props assert "dependencies" in env_props assert "requirements" in env_props From 8c678b552be5d11183378ba4ecd0a7aa1beeddfd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 30 Aug 2025 08:33:13 -0400 Subject: [PATCH 21/26] Add resource_server_url parameter to OAuth proxy providers (#1682) --- docs/servers/auth/oauth-proxy.mdx | 7 +++- docs/servers/auth/remote-oauth.mdx | 6 +-- examples/auth/azure_oauth/server.py | 1 + examples/auth/github_oauth/server.py | 1 + examples/auth/google_oauth/server.py | 1 + examples/auth/workos_oauth/server.py | 1 + src/fastmcp/server/auth/oauth_proxy.py | 43 +++++---------------- src/fastmcp/server/auth/providers/azure.py | 14 ++++++- src/fastmcp/server/auth/providers/github.py | 16 ++++++-- src/fastmcp/server/auth/providers/google.py | 14 +++++-- src/fastmcp/server/auth/providers/workos.py | 14 ++++++- 11 files changed, 69 insertions(+), 49 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 5f3a1c1ad..fadf9b03c 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -159,7 +159,7 @@ The `OAuthProxy` class provides the complete proxy implementation: - Resource server URL (defaults to base_url) + Path of the FastMCP server (defaults to base_url). **Important**: This should point to your MCP endpoint path. For example, if your MCP server is accessible at `{base_url}/mcp`, specify `https://your-server.com/mcp` here for proper RFC 8707 compliance. @@ -228,7 +228,10 @@ auth = OAuthProxy( base_url="https://your-server.com", # Optional: customize callback path (defaults to "/auth/callback") - redirect_path="/auth/callback" + redirect_path="/auth/callback", + + # Optional: specify MCP endpoint path if different from base_url + # resource_server_url="https://your-server.com/mcp" ) mcp = FastMCP(name="My Server", auth=auth) diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index 5f6a485cd..6ffe876e5 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -111,7 +111,7 @@ token_verifier = JWTVerifier( auth = RemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], - resource_server_url="https://api.yourcompany.com", + resource_server_url="https://api.yourcompany.com/mcp", # Point to your MCP endpoint # Optional: customize allowed client redirect URIs (defaults to localhost only) allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"] ) @@ -121,7 +121,7 @@ mcp = FastMCP(name="Company API", auth=auth) This configuration creates a server that accepts tokens issued by `auth.yourcompany.com` and provides the OAuth discovery metadata that MCP clients need. The `JWTVerifier` handles token validation using your identity provider's public keys, while the `RemoteAuthProvider` generates the required OAuth endpoints. -The `authorization_servers` list tells MCP clients which identity providers you trust. The `resource_server_url` identifies your server in OAuth metadata, enabling proper token audience validation. +The `authorization_servers` list tells MCP clients which identity providers you trust. The `resource_server_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `resource_server_url` should point to your actual MCP endpoint - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use that full path rather than just the base URL. ### Custom Endpoints @@ -143,7 +143,7 @@ class CompanyAuthProvider(RemoteAuthProvider): super().__init__( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], - resource_server_url="https://api.yourcompany.com" + resource_server_url="https://api.yourcompany.com/mcp" # Your MCP endpoint path ) def get_routes(self) -> list[Route]: diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index 2d5062612..81687cfd4 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -23,6 +23,7 @@ auth = AzureProvider( tenant_id=os.getenv("AZURE_TENANT_ID") or "", # Required for single-tenant apps - get from Azure Portal base_url="http://localhost:8000", + resource_server_url="http://localhost:8000/mcp", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py index 1f88c6977..a0fdc0504 100644 --- a/examples/auth/github_oauth/server.py +++ b/examples/auth/github_oauth/server.py @@ -19,6 +19,7 @@ auth = GitHubProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "", base_url="http://localhost:8000", + resource_server_url="http://localhost:8000/mcp", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py index 2a5b1c7df..feb1fe1d4 100644 --- a/examples/auth/google_oauth/server.py +++ b/examples/auth/google_oauth/server.py @@ -19,6 +19,7 @@ auth = GoogleProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "", base_url="http://localhost:8000", + resource_server_url="http://localhost:8000/mcp", # redirect_path="/auth/callback", # Default path - change if using a different callback URL # Optional: specify required scopes # required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py index 08c1db62b..8db24f13b 100644 --- a/examples/auth/workos_oauth/server.py +++ b/examples/auth/workos_oauth/server.py @@ -21,6 +21,7 @@ auth = WorkOSProvider( client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "", authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app", base_url="http://localhost:8000", + resource_server_url="http://localhost:8000/mcp", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 8f7418a83..14a6e9fd0 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -254,45 +254,20 @@ class OAuthProxy(OAuthProvider): upstream_client_secret: Client secret for upstream server upstream_revocation_endpoint: Optional upstream revocation endpoint token_verifier: Token verifier for validating access tokens - base_url: Public URL of this FastMCP server + base_url: Public URL of the server that exposes this FastMCP server; redirect path is + relative to this URL redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") issuer_url: Issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: Optional service documentation URL - resource_server_url: Resource server URL (defaults to base_url) + resource_server_url: Path of the FastMCP server. If None, FastMCP will + attempt to overwrite this with the correct path to the server + e.g. {base_url}/mcp allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). If None (default), only localhost redirect URIs are allowed. If empty list, all redirect URIs are allowed (not recommended for production). These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. """ - # Convert string URLs to AnyHttpUrl for parent class - base_url_parsed = ( - AnyHttpUrl(base_url) if isinstance(base_url, str) else base_url - ) - issuer_url_parsed = ( - (AnyHttpUrl(issuer_url) if isinstance(issuer_url, str) else issuer_url) - if issuer_url - else None - ) - service_documentation_url_parsed = ( - ( - AnyHttpUrl(service_documentation_url) - if isinstance(service_documentation_url, str) - else service_documentation_url - ) - if service_documentation_url - else None - ) - resource_server_url_parsed = ( - ( - AnyHttpUrl(resource_server_url) - if isinstance(resource_server_url, str) - else resource_server_url - ) - if resource_server_url - else None - ) - # Always enable DCR since we implement it locally for MCP clients client_registration_options = ClientRegistrationOptions(enabled=True) @@ -302,13 +277,13 @@ class OAuthProxy(OAuthProvider): ) super().__init__( - base_url=base_url_parsed, - issuer_url=issuer_url_parsed, - service_documentation_url=service_documentation_url_parsed, + base_url=base_url, + issuer_url=issuer_url, + service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, required_scopes=token_verifier.required_scopes, - resource_server_url=resource_server_url_parsed, + resource_server_url=resource_server_url, ) # Store upstream configuration diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 362f35f58..806dddf18 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -36,6 +36,8 @@ class AzureProviderSettings(BaseSettings): redirect_path: str | None = None required_scopes: list[str] | None = None timeout_seconds: int | None = None + resource_server_url: str | None = None + allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod @@ -160,7 +162,8 @@ class AzureProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, - allowed_client_redirect_uris: list[str] | None = None, + resource_server_url: str | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, ): """Initialize Azure OAuth provider. @@ -172,6 +175,8 @@ class AzureProvider(OAuthProxy): redirect_path: Redirect path configured in Azure (defaults to "/auth/callback") required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"]) timeout_seconds: HTTP request timeout for Azure API calls + resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at + a different path like {base_url}/mcp, specify it here for RFC 8707 compliance. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ @@ -186,6 +191,8 @@ class AzureProvider(OAuthProxy): "redirect_path": redirect_path, "required_scopes": required_scopes, "timeout_seconds": timeout_seconds, + "resource_server_url": resource_server_url, + "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet } @@ -220,6 +227,8 @@ class AzureProvider(OAuthProxy): "openid", "profile", ] + resource_server_url_final = settings.resource_server_url or base_url_final + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris # Extract secret string from SecretStr client_secret_str = ( @@ -250,7 +259,8 @@ class AzureProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, - allowed_client_redirect_uris=allowed_client_redirect_uris, + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + resource_server_url=resource_server_url_final, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 20dc23616..49613897a 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -51,6 +51,8 @@ class GitHubProviderSettings(BaseSettings): redirect_path: str | None = None required_scopes: list[str] | None = None timeout_seconds: int | None = None + resource_server_url: AnyHttpUrl | str | None = None + allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod @@ -199,9 +201,10 @@ class GitHubProvider(OAuthProxy): client_secret: str | NotSetT = NotSet, base_url: AnyHttpUrl | str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, - allowed_client_redirect_uris: list[str] | None = None, + resource_server_url: AnyHttpUrl | str | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, ): """Initialize GitHub OAuth provider. @@ -212,6 +215,8 @@ class GitHubProvider(OAuthProxy): redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") required_scopes: Required GitHub scopes (defaults to ["user"]) timeout_seconds: HTTP request timeout for GitHub API calls + resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at + a different path like {base_url}/mcp, specify it here for RFC 8707 compliance. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ @@ -225,6 +230,8 @@ class GitHubProvider(OAuthProxy): "redirect_path": redirect_path, "required_scopes": required_scopes, "timeout_seconds": timeout_seconds, + "resource_server_url": resource_server_url, + "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet } @@ -245,6 +252,8 @@ class GitHubProvider(OAuthProxy): redirect_path_final = settings.redirect_path or "/auth/callback" timeout_seconds_final = settings.timeout_seconds or 10 required_scopes_final = settings.required_scopes or ["user"] + resource_server_url_final = settings.resource_server_url or base_url_final + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris # Create GitHub token verifier token_verifier = GitHubTokenVerifier( @@ -267,7 +276,8 @@ class GitHubProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, # We act as the issuer for client registration - allowed_client_redirect_uris=allowed_client_redirect_uris, + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + resource_server_url=resource_server_url_final, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index f025fe67b..337bf6157 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -53,6 +53,8 @@ class GoogleProviderSettings(BaseSettings): redirect_path: str | None = None required_scopes: list[str] | None = None timeout_seconds: int | None = None + resource_server_url: AnyHttpUrl | str | None = None + allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod @@ -215,9 +217,10 @@ class GoogleProvider(OAuthProxy): client_secret: str | NotSetT = NotSet, base_url: AnyHttpUrl | str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, - required_scopes: list[str] | None | NotSetT = NotSet, + required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, - allowed_client_redirect_uris: list[str] | None = None, + resource_server_url: AnyHttpUrl | str | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, ): """Initialize Google OAuth provider. @@ -244,6 +247,8 @@ class GoogleProvider(OAuthProxy): "redirect_path": redirect_path, "required_scopes": required_scopes, "timeout_seconds": timeout_seconds, + "resource_server_url": resource_server_url, + "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet } @@ -265,6 +270,8 @@ class GoogleProvider(OAuthProxy): timeout_seconds_final = settings.timeout_seconds or 10 # Google requires at least one scope - openid is the minimal OIDC scope required_scopes_final = settings.required_scopes or ["openid"] + resource_server_url_final = settings.resource_server_url or base_url_final + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris # Create Google token verifier token_verifier = GoogleTokenVerifier( @@ -287,7 +294,8 @@ class GoogleProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, # We act as the issuer for client registration - allowed_client_redirect_uris=allowed_client_redirect_uris, + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + resource_server_url=resource_server_url_final, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index a0f939fb6..2c443eb64 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -43,6 +43,8 @@ class WorkOSProviderSettings(BaseSettings): redirect_path: str | None = None required_scopes: list[str] | None = None timeout_seconds: int | None = None + resource_server_url: AnyHttpUrl | str | None = None + allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod @@ -167,7 +169,8 @@ class WorkOSProvider(OAuthProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, - allowed_client_redirect_uris: list[str] | None = None, + resource_server_url: AnyHttpUrl | str | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, ): """Initialize WorkOS OAuth provider. @@ -179,6 +182,8 @@ class WorkOSProvider(OAuthProxy): redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") required_scopes: Required OAuth scopes (no default) timeout_seconds: HTTP request timeout for WorkOS API calls + resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at + a different path like {base_url}/mcp, specify it here for RFC 8707 compliance. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. """ @@ -193,6 +198,8 @@ class WorkOSProvider(OAuthProxy): "redirect_path": redirect_path, "required_scopes": required_scopes, "timeout_seconds": timeout_seconds, + "resource_server_url": resource_server_url, + "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet } @@ -221,6 +228,8 @@ class WorkOSProvider(OAuthProxy): redirect_path_final = settings.redirect_path or "/auth/callback" timeout_seconds_final = settings.timeout_seconds or 10 scopes_final = settings.required_scopes or [] + resource_server_url_final = settings.resource_server_url or base_url_final + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris # Extract secret string from SecretStr client_secret_str = ( @@ -244,7 +253,8 @@ class WorkOSProvider(OAuthProxy): base_url=base_url_final, redirect_path=redirect_path_final, issuer_url=base_url_final, - allowed_client_redirect_uris=allowed_client_redirect_uris, + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + resource_server_url=resource_server_url_final, ) logger.info( From 97e0987070f1f596a74df6b15589a9649ae3ae69 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 10:36:23 -0400 Subject: [PATCH 22/26] chore: Update fastmcp.json schema (#1680) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/public/schemas/fastmcp.json/latest.json | 7 ++++++- docs/public/schemas/fastmcp.json/v1.json | 7 ++++++- src/fastmcp/utilities/mcp_server_config/v1/schema.json | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index bc072a67d..fe2e21ce9 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -168,7 +168,6 @@ "type": { "const": "filesystem", "default": "filesystem", - "description": "Source type", "title": "Type", "type": "string" }, @@ -200,6 +199,12 @@ "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { + "type": { + "const": "uv", + "default": "uv", + "title": "Type", + "type": "string" + }, "python": { "anyOf": [ { diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index bc072a67d..fe2e21ce9 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -168,7 +168,6 @@ "type": { "const": "filesystem", "default": "filesystem", - "description": "Source type", "title": "Type", "type": "string" }, @@ -200,6 +199,12 @@ "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { + "type": { + "const": "uv", + "default": "uv", + "title": "Type", + "type": "string" + }, "python": { "anyOf": [ { diff --git a/src/fastmcp/utilities/mcp_server_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json index bc072a67d..fe2e21ce9 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/schema.json +++ b/src/fastmcp/utilities/mcp_server_config/v1/schema.json @@ -168,7 +168,6 @@ "type": { "const": "filesystem", "default": "filesystem", - "description": "Source type", "title": "Type", "type": "string" }, @@ -200,6 +199,12 @@ "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { + "type": { + "const": "uv", + "default": "uv", + "title": "Type", + "type": "string" + }, "python": { "anyOf": [ { From f7d7303dee9154fcea96161abe1da646eaa9366a Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 10:36:31 -0400 Subject: [PATCH 23/26] chore: Update SDK documentation (#1679) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- .../fastmcp-server-auth-oauth_proxy.mdx | 20 +++++----- .../fastmcp-server-auth-providers-azure.mdx | 6 +-- .../fastmcp-server-auth-providers-github.mdx | 6 +-- .../fastmcp-server-auth-providers-google.mdx | 6 +-- .../fastmcp-server-auth-providers-workos.mdx | 12 +++--- ...mcp_server_config-v1-environments-base.mdx | 4 +- ...s-mcp_server_config-v1-environments-uv.mdx | 12 +++--- ...mcp_server_config-v1-mcp_server_config.mdx | 38 +++++++++---------- ...ties-mcp_server_config-v1-sources-base.mdx | 2 +- ...cp_server_config-v1-sources-filesystem.mdx | 4 +- 10 files changed, 54 insertions(+), 56 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index 163843467..6d261e0a4 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -182,7 +182,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -199,7 +199,7 @@ handles the case where a client with cached tokens reconnects on a different port. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -226,7 +226,7 @@ The flow: 4. When client reconnects with a different port, ProxyDCRClient accepts it -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -240,7 +240,7 @@ This implements the DCR-compliant proxy pattern: 3. Redirect to IdP with our fixed callback URL -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -252,7 +252,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -264,7 +264,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -273,7 +273,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -282,7 +282,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -294,7 +294,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -306,7 +306,7 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index ed66062f9..343128eba 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -20,7 +20,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. Settings for Azure OAuth provider. -### `AzureTokenVerifier` +### `AzureTokenVerifier` Token verifier for Azure OAuth tokens. @@ -31,7 +31,7 @@ to get user information and validate the token. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -40,7 +40,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Azure OAuth token by calling Microsoft Graph API. -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index e6f23ab15..91d86fa87 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -35,7 +35,7 @@ Example: Settings for GitHub OAuth provider. -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 4f4d4688b..2eaf874cc 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -35,7 +35,7 @@ Example: Settings for Google OAuth provider. -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index 31d0145d2..fdacdf859 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -24,7 +24,7 @@ Choose based on your WorkOS setup and authentication requirements. Settings for WorkOS OAuth provider. -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx index 52b0de9a8..3a97b5d9e 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx @@ -15,7 +15,7 @@ Base class for environment configuration. **Methods:** -#### `build_command` +#### `build_command` ```python build_command(self, command: list[str]) -> list[str] @@ -30,7 +30,7 @@ Build the full command with environment setup. - Full command ready for subprocess execution -#### `prepare` +#### `prepare` ```python prepare(self, output_dir: Path | None = None) -> None diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx index 84d117078..de51b8098 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx @@ -7,7 +7,7 @@ sidebarTitle: uv ## Classes -### `UVEnvironment` +### `UVEnvironment` Configuration for Python environment setup. @@ -15,7 +15,7 @@ Configuration for Python environment setup. **Methods:** -#### `build_command` +#### `build_command` ```python build_command(self, command: list[str]) -> list[str] @@ -31,7 +31,7 @@ Build complete uv run command with environment args and command to execute. - If no environment configuration is set, returns the command unchanged. -#### `run_with_uv` +#### `run_with_uv` ```python run_with_uv(self, command: list[str]) -> None @@ -43,7 +43,7 @@ Execute a command using uv run with this environment configuration. - `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) -#### `needs_uv` +#### `needs_uv` ```python needs_uv(self) -> bool @@ -52,7 +52,7 @@ needs_uv(self) -> bool Deprecated: Use _needs_setup() internally or check if build_command modifies the command. -#### `build_uv_run_command` +#### `build_uv_run_command` ```python build_uv_run_command(self, command: list[str]) -> list[str] @@ -61,7 +61,7 @@ build_uv_run_command(self, command: list[str]) -> list[str] Deprecated: Use build_command() instead. -#### `prepare` +#### `prepare` ```python prepare(self, output_dir: Path | None = None) -> None diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx index 7096185a0..e9d8c398d 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx @@ -15,7 +15,7 @@ command-line arguments. ## Functions -### `generate_schema` +### `generate_schema` ```python generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None @@ -38,7 +38,7 @@ validation and auto-completion. ## Classes -### `Deployment` +### `Deployment` Configuration for server deployment and runtime settings. @@ -46,7 +46,7 @@ Configuration for server deployment and runtime settings. **Methods:** -#### `apply_runtime_settings` +#### `apply_runtime_settings` ```python apply_runtime_settings(self, config_path: Path | None = None) -> None @@ -62,7 +62,7 @@ For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" will substitute the value of the ENVIRONMENT variable at runtime. -### `MCPServerConfig` +### `MCPServerConfig` Configuration for a FastMCP server. @@ -73,10 +73,10 @@ a FastMCP server in a declarative format. **Methods:** -#### `validate_source` +#### `validate_source` ```python -validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource +validate_source(cls, v: dict | Source) -> SourceType ``` Validate and convert source to proper format. @@ -89,20 +89,18 @@ No string parsing happens here - that's only at CLI boundaries. MCPServerConfig works only with properly typed objects. -#### `validate_environment` +#### `validate_environment` ```python -validate_environment(cls, v: dict | UVEnvironment) -> UVEnvironment +validate_environment(cls, v: dict | Any) -> EnvironmentType ``` -Validate and convert environment to Environment. +Ensure environment has a type field for discrimination. -Accepts: -- Environment instance -- dict that can be converted to Environment +For backward compatibility, if no type is specified, default to "uv". -#### `validate_deployment` +#### `validate_deployment` ```python validate_deployment(cls, v: dict | Deployment) -> Deployment @@ -115,7 +113,7 @@ Accepts: - dict that can be converted to Deployment -#### `from_file` +#### `from_file` ```python from_file(cls, file_path: Path) -> MCPServerConfig @@ -135,7 +133,7 @@ Load configuration from a JSON file. - `pydantic.ValidationError`: If the configuration is invalid -#### `from_cli_args` +#### `from_cli_args` ```python from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> MCPServerConfig @@ -166,7 +164,7 @@ goes through a config object. - MCPServerConfig instance -#### `find_config` +#### `find_config` ```python find_config(cls, start_path: Path | None = None) -> Path | None @@ -181,7 +179,7 @@ Find a fastmcp.json file in the specified directory. - Path to the configuration file, or None if not found -#### `prepare` +#### `prepare` ```python prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None @@ -197,7 +195,7 @@ When output_dir is None, does ephemeral caching (for backwards compatibility). - `output_dir`: Directory to create the persistent uv project in (optional) -#### `prepare_environment` +#### `prepare_environment` ```python prepare_environment(self, output_dir: Path | None = None) -> None @@ -212,7 +210,7 @@ Prepare the Python environment. Delegates to the environment's prepare() method -#### `prepare_source` +#### `prepare_source` ```python prepare_source(self) -> None @@ -223,7 +221,7 @@ Prepare the source for loading. Delegates to the source's prepare() method. -#### `run_server` +#### `run_server` ```python run_server(self, **kwargs: Any) -> None diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx index bf80ca1d6..0a2aa84ac 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx @@ -7,7 +7,7 @@ sidebarTitle: base ## Classes -### `BaseSource` +### `Source` Abstract base class for all source types. diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx index 9ff83063b..b557613c8 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx @@ -15,7 +15,7 @@ Source for local Python files. **Methods:** -#### `parse_path_with_object` +#### `parse_path_with_object` ```python parse_path_with_object(cls, v: str) -> str @@ -27,7 +27,7 @@ This validator runs before the model is created, allowing us to handle the "file.py:object" syntax at the model boundary. -#### `load_server` +#### `load_server` ```python load_server(self) -> Any From 1c6ba0a0119913e7999d06838d966e44175a75e7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 30 Aug 2025 10:40:42 -0400 Subject: [PATCH 24/26] Ignore chore PRs (#1684) --- .github/workflows/update-config-schema.yml | 2 ++ .github/workflows/update-sdk-docs.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index eb4115dd7..6d0d662e6 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -79,6 +79,8 @@ jobs: 🤖 Generated by Marvin branch: marvin/update-config-schema + labels: | + ignore in release notes delete-branch: true author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index f275d5234..83ffe7859 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -62,6 +62,8 @@ jobs: 🤖 Generated by Marvin branch: marvin/update-sdk-docs + labels: | + ignore in release notes delete-branch: true author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" From 1db76fb31c87f48c6d9a606da552e69b7f3c92f1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 31 Aug 2025 17:47:42 -0400 Subject: [PATCH 25/26] Update changelog.mdx (#1691) --- docs/changelog.mdx | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index da758efe6..0b181f8d7 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,60 @@ icon: "list-check" rss: true --- + + +**[v2.12.0: Auth to the Races](https://github.com/jlowin/fastmcp/releases/tag/v2.12.0)** + +This release introduces major authentication and configuration enhancements that make FastMCP more accessible and powerful for developers working with various identity providers and deployment scenarios. + +## OAuth Proxy: Broader Provider Support + +The OAuth Proxy bridges the gap for authentication providers that don't support Dynamic Client Registration (DCR), a requirement for standard MCP OAuth flows. This feature enables seamless integration with major platforms that previously required complex workarounds. + +**Native integrations now available:** +- GitHub +- Google +- WorkOS +- Azure + +With the OAuth Proxy, you can authenticate users through these providers with minimal configuration, expanding the ecosystem of supported identity platforms and making FastMCP servers more accessible to enterprise environments. + +## Declarative JSON Configuration + +The new `fastmcp.json` configuration system establishes a single source of truth for server settings, replacing scattered configuration across multiple files and environment variables. + +**Configure everything in one place:** +- Dependencies and requirements +- Transport settings +- Server entrypoints +- Metadata and descriptions +- Environment variables + +This standardization not only simplifies deployment but also enables portable server descriptions that can be shared and reused across projects. The typed source system provides validation and autocompletion, reducing configuration errors. + +## Sampling API Fallback + +Not all MCP clients support advanced features like the Sampling API for LLM completions. The new fallback mechanism solves this adoption challenge by allowing servers to generate sampling completions server-side when clients lack support. + +This approach: +- Maintains compatibility with all clients +- Encourages feature adoption without breaking existing integrations +- Provides a smooth upgrade path as client capabilities evolve + +## Breaking Changes +- The `inspect` command now provides structured output with format options for better integration with tooling + +## Additional Enhancements +- Improved CLI configuration parsing with better error messages +- Support for multiple `--with-editable` flags for development workflows +- Comma-separated OAuth scope support for fine-grained permissions +- Configurable logging middleware for better debugging +- Support for importing custom route endpoints + +**Full Changelog**: [v2.11.3...v2.12.0](https://github.com/jlowin/fastmcp/compare/v2.11.3...v2.12.0) + + + **[v2.11.3: API-tite for Change](https://github.com/jlowin/fastmcp/releases/tag/v2.11.3)** From be17a18420a64c5eefc40ac13930f701c9b24334 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:03:55 -0400 Subject: [PATCH 26/26] Fix documentation: use StreamableHttpTransport for headers in testing (#1702) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- docs/deployment/testing.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/deployment/testing.mdx b/docs/deployment/testing.mdx index f5db67b58..a7bb63a57 100644 --- a/docs/deployment/testing.mdx +++ b/docs/deployment/testing.mdx @@ -133,11 +133,15 @@ async def test_deployed_server(): The FastMCP Client handles authentication transparently, making it easy to test secured servers: ```python +from fastmcp.client.transports import StreamableHttpTransport + async def test_authenticated_server(): # Bearer token authentication async with Client( - "https://api.example.com/mcp", - headers={"Authorization": "Bearer test-token"} + StreamableHttpTransport( + "https://api.example.com/mcp", + headers={"Authorization": "Bearer test-token"} + ) ) as client: await client.ping() tools = await client.list_tools()