From 0d4580fef32a8666b93dabbef86e05d763307ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E5=81=9A=E4=BA=86=E7=9D=A1=E5=A4=A7=E8=A7=89?= <64798754+stakeswky@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:23:27 +0800 Subject: [PATCH 1/7] fix: prevent MCP transport auth header from leaking to downstream OpenAPI APIs (#3260) (#3262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent MCP transport auth header from leaking to downstream OpenAPI APIs (#3260) Two issues in OpenAPITool.run(): 1. get_http_headers() does not exclude 'authorization', so the MCP client's auth token is included in forwarded headers. 2. mcp_headers.update() overwrites existing request headers, including the Authorization header that was already set from the httpx client's configured API key. Fix: - Add 'authorization' to exclude_headers in get_http_headers() to prevent MCP transport credentials from being forwarded by default. - Change mcp_headers forwarding to use the same non-overwriting pattern as client headers (only set if key not already present), making the behavior consistent and preventing accidental overwrites. Fixes #3260 * Add include parameter to get_http_headers(); update proxy transports The authorization exclusion is correct for the default case (OpenAPI tools should not forward MCP transport credentials), but proxy transports need auth headers forwarded to upstream MCP servers. The new `include` parameter lets callers opt specific headers back in despite the default exclusion set. Proxy transports now explicitly request authorization forwarding. * Include authorization header in CurrentHeaders dependency CurrentHeaders is user-facing — tools use it to inspect the caller's auth token for custom logic. Reading a header in your own code is safe; the exclusion is meant to prevent blindly forwarding it to third-party APIs. --------- Co-authored-by: User Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/client/transports/http.py | 2 +- src/fastmcp/client/transports/sse.py | 4 ++- src/fastmcp/server/dependencies.py | 26 ++++++++++++++----- .../server/providers/openapi/components.py | 4 ++- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py index 83dbb7cc8..f7e74ca60 100644 --- a/src/fastmcp/client/transports/http.py +++ b/src/fastmcp/client/transports/http.py @@ -95,7 +95,7 @@ class StreamableHttpTransport(ClientTransport): # Load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. - headers = get_http_headers() | self.headers + headers = get_http_headers(include={"authorization"}) | self.headers # Configure timeout if provided, preserving MCP's 30s connect default timeout: httpx.Timeout | None = None diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py index 45db01bee..36fa0ebc0 100644 --- a/src/fastmcp/client/transports/sse.py +++ b/src/fastmcp/client/transports/sse.py @@ -69,7 +69,9 @@ class SSETransport(ClientTransport): # load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. - client_kwargs["headers"] = get_http_headers() | self.headers + client_kwargs["headers"] = ( + get_http_headers(include={"authorization"}) | self.headers + ) # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 15c8c3124..3e72d9f84 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -434,14 +434,22 @@ def get_http_request() -> Request: return request -def get_http_headers(include_all: bool = False) -> dict[str, str]: +def get_http_headers( + include_all: bool = False, + include: set[str] | None = None, +) -> dict[str, str]: """Extract headers from the current HTTP request if available. Never raises an exception, even if there is no active HTTP request (in which case an empty dict is returned). - By default, strips problematic headers like `content-length` that cause issues - if forwarded to downstream clients. If `include_all` is True, all headers are returned. + By default, strips problematic headers like `content-length` and `authorization` + that cause issues if forwarded to downstream services. If `include_all` is True, + all headers are returned. + + The `include` parameter allows specific headers to be included even if they would + normally be excluded. This is useful for proxy transports that need to forward + authorization headers to upstream MCP servers. """ if include_all: exclude_headers: set[str] = set() @@ -457,6 +465,7 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: "keep-alive", "expect", "accept", + "authorization", # Proxy-related headers "proxy-authenticate", "proxy-authorization", @@ -464,6 +473,8 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: # MCP-related headers "mcp-session-id", } + if include: + exclude_headers -= {h.lower() for h in include} # (just in case) if not all(h.lower() == h for h in exclude_headers): raise ValueError("Excluded headers must be lowercase") @@ -1037,7 +1048,7 @@ class _CurrentHeaders(Dependency): # type: ignore[misc] """Async context manager for HTTP Headers dependency.""" async def __aenter__(self) -> dict[str, str]: - return get_http_headers() + return get_http_headers(include={"authorization"}) async def __aexit__(self, *args: object) -> None: pass @@ -1046,9 +1057,10 @@ class _CurrentHeaders(Dependency): # type: ignore[misc] def CurrentHeaders() -> dict[str, str]: """Get the current HTTP request headers. - This dependency provides access to the HTTP headers for the current request. - Returns an empty dictionary when no HTTP request is available, making it - safe to use in code that might run over any transport. + This dependency provides access to the HTTP headers for the current request, + including the authorization header. Returns an empty dictionary when no HTTP + request is available, making it safe to use in code that might run over any + transport. Returns: A dependency that resolves to a dictionary of header name -> value diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 6b942d22e..1f52033fa 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -172,7 +172,9 @@ class OpenAPITool(Tool): mcp_headers = get_http_headers() if mcp_headers: - request.headers.update(mcp_headers) + for key, value in mcp_headers.items(): + if key not in request.headers: + request.headers[key] = value except Exception as e: raise ValueError( f"Error building request for {self._route.method.upper()} " From 83d6254757806c1b9f2e07c9ffcbd580a5b96aec Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Sat, 21 Feb 2026 15:07:27 -0600 Subject: [PATCH 2/7] Allow Marvin to open PRs on comment (#3267) * Update marvin-comment-on-issue.yml * remove conflicting instructions --- .github/workflows/marvin-comment-on-issue.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml index 1ecc91806..8d297a226 100644 --- a/.github/workflows/marvin-comment-on-issue.yml +++ b/.github/workflows/marvin-comment-on-issue.yml @@ -8,9 +8,10 @@ on: types: [created] permissions: + actions: read contents: write issues: write - pull-requests: read + pull-requests: write id-token: write jobs: @@ -20,7 +21,7 @@ jobs: contains(github.event.comment.body, '/marvin') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 60 steps: - name: Checkout repository @@ -79,12 +80,9 @@ jobs: - This workflow allows read, write, and execute capabilities but cannot push changes. - You CAN: Read/analyze code, modify files, write code, run tests, execute commands - You CANNOT: Commit code, push changes, create branches, checkout branches, create pull requests + You CAN: Commit code, push changes, create branches, create pull requests - **Important**: You cannot push changes to the repository - you can only make changes locally and provide feedback or recommendations. From d85cfb84e1f6dfb66ad402383356f361ad79b1d2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:13:06 -0500 Subject: [PATCH 3/7] Revert to long-lived PR approach for auto-generated docs/schema (#3272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code https://claude.ai/code/session_01LUn4EnV6nZS5UgFz9wtyB3 Co-authored-by: Claude --- .github/workflows/update-config-schema.yml | 43 ++++++++++++---------- .github/workflows/update-sdk-docs.yml | 43 ++++++++++++---------- AGENTS.md | 3 +- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index e5d0509f4..ed66ea209 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -1,10 +1,10 @@ name: Update MCPServerConfig Schema -# Regenerates config schema on PRs and commits it back to the branch, -# so the PR is self-contained and main is correct after merge. +# Regenerates config schema on pushes to main and opens a long-lived PR +# with the changes, so contributor PRs stay clean. on: - pull_request: + push: branches: ["main"] paths: - "src/fastmcp/utilities/mcp_server_config/**" @@ -13,14 +13,12 @@ on: permissions: contents: write + pull-requests: write jobs: update-config-schema: timeout-minutes: 5 runs-on: ubuntu-latest - if: >- - github.event_name == 'workflow_dispatch' || - github.event.pull_request.head.repo.full_name == github.repository steps: - name: Generate Marvin App token @@ -32,7 +30,6 @@ jobs: - uses: actions/checkout@v6 with: - ref: ${{ github.head_ref || github.ref }} token: ${{ steps.marvin-token.outputs.token }} - name: Install uv @@ -53,15 +50,23 @@ jobs: generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') " - - name: Commit and push if changed - run: | - git config user.name "marvin-context-protocol[bot]" - git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" - git add docs/public/schemas/ src/fastmcp/utilities/mcp_server_config/v1/schema.json - if git diff --cached --quiet; then - echo "Config schema is up to date" - else - git commit -m "chore: Update fastmcp.json schema" - git push - echo "Config schema updated and pushed" - fi + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.marvin-token.outputs.token }} + commit-message: "chore: Update fastmcp.json schema" + title: "chore: Update fastmcp.json schema" + body: | + This PR updates the fastmcp.json schema files to match the current source code. + + 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. + + 🤖 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 122f6ddfc..6ca5eb61d 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -1,10 +1,10 @@ name: Update SDK Documentation -# Regenerates SDK docs on PRs and commits them back to the branch, -# so the PR is self-contained and main is correct after merge. +# Regenerates SDK docs on pushes to main and opens a long-lived PR +# with the changes, so contributor PRs stay clean. on: - pull_request: + push: branches: ["main"] paths: - "src/**" @@ -13,14 +13,12 @@ on: permissions: contents: write + pull-requests: write jobs: update-sdk-docs: timeout-minutes: 5 runs-on: ubuntu-latest - if: >- - github.event_name == 'workflow_dispatch' || - github.event.pull_request.head.repo.full_name == github.repository steps: - name: Generate Marvin App token @@ -32,7 +30,6 @@ jobs: - uses: actions/checkout@v6 with: - ref: ${{ github.head_ref || github.ref }} token: ${{ steps.marvin-token.outputs.token }} - name: Install uv @@ -50,15 +47,23 @@ jobs: - name: Generate SDK documentation run: just api-ref-all - - name: Commit and push if changed - run: | - git config user.name "marvin-context-protocol[bot]" - git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" - git add docs/python-sdk/ - if git diff --cached --quiet; then - echo "SDK documentation is up to date" - else - git commit -m "chore: Update SDK documentation" - git push - echo "SDK documentation updated and pushed" - fi + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.marvin-token.outputs.token }} + commit-message: "chore: Update SDK documentation" + title: "chore: Update SDK documentation" + body: | + This PR updates the auto-generated SDK documentation to reflect the latest source code changes. + + 📚 Documentation is automatically generated from the source code docstrings and type annotations. + + **Note:** This PR is fully automated and will update itself with any subsequent changes to the SDK, or close automatically if the documentation becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. + + 🤖 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>" diff --git a/AGENTS.md b/AGENTS.md index b34e83e23..bed11163a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,8 @@ When modifying MCP functionality, changes typically need to be applied across al - Uses Mintlify framework - Files must be in docs.json to be included -- Do not manually modify `docs/python-sdk/**` — a bot automatically updates these files via commits added to PRs. Changes to these files in PR diffs are expected and should not be flagged during review. +- Do not manually modify `docs/python-sdk/**` — these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs. +- Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR. - **Core Principle:** A feature doesn't exist unless it is documented! ### Documentation Guidelines From 40d3190317707d46019ded953325cbcad78bca57 Mon Sep 17 00:00:00 2001 From: Guillaume FORTAINE Date: Sun, 22 Feb 2026 17:16:30 +0100 Subject: [PATCH 4/7] fix: propagate origin_request_id to background task workers (#3175) * Fix background Context request correlation * Make OptionalCurrentContext type-safe Refactor OptionalCurrentContext to wrap CurrentContext instead of overriding __aenter__ with a wider return type. Adds a background-task origin_request_id round-trip test and applies ruff formatting. --- src/fastmcp/server/context.py | 17 ++++- src/fastmcp/server/dependencies.py | 75 ++++++++++++++++++- src/fastmcp/server/tasks/handlers.py | 8 ++ .../tasks/test_context_background_task.py | 38 ++++++++++ tests/server/tasks/test_task_return_types.py | 32 +++++--- tests/server/test_dependencies.py | 36 +++++++++ 6 files changed, 192 insertions(+), 14 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 4cf8b7050..afe8966f5 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -191,12 +191,14 @@ class Context: session: ServerSession | None = None, *, task_id: str | None = None, + origin_request_id: str | None = None, ): self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp) self._session: ServerSession | None = session # For state ops during init self._tokens: list[Token] = [] # Background task support (SEP-1686) self._task_id: str | None = task_id + self._origin_request_id: str | None = origin_request_id # Request-scoped state for non-serializable values (serializable=False) self._request_state: dict[str, Any] = {} @@ -227,6 +229,18 @@ class Context: """ return self._task_id + @property + def origin_request_id(self) -> str | None: + """Get the request ID that originated this execution, if available. + + In foreground request mode, this is the current request_id. + In background task mode, this is the request_id captured when the task + was submitted, if one was available. + """ + if self.request_context is not None: + return str(self.request_context.request_id) + return self._origin_request_id + @property def fastmcp(self) -> FastMCP: """Get the FastMCP instance.""" @@ -533,13 +547,14 @@ class Context: extra: Optional mapping for additional arguments """ data = LogData(msg=message, extra=extra) + related_request_id = self.origin_request_id await _log_to_server_and_client( data=data, session=self.session, level=level or "info", logger_name=logger_name, - related_request_id=self.request_id, + related_request_id=related_request_id, ) @property diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 3e72d9f84..ccee89adc 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -270,11 +270,14 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: # First pass: identify which params need transformation params_to_transform: set[str] = set() + optional_context_params: set[str] = set() for name, param in sig.parameters.items(): annotation = type_hints.get(name, param.annotation) if is_class_member_of_type(annotation, Context): if not isinstance(param.default, Dependency): params_to_transform.add(name) + if param.default is None: + optional_context_params.add(name) if not params_to_transform: return fn @@ -300,7 +303,10 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: # We use CurrentContext() instead of Depends(get_context) because # get_context() returns the Context which is an AsyncContextManager, # and the DI system would try to enter it again (it's already entered) - param = param.replace(default=CurrentContext()) + if name in optional_context_params: + param = param.replace(default=OptionalCurrentContext()) + else: + param = param.replace(default=CurrentContext()) # Sort into buckets based on parameter kind if param.kind == P.POSITIONAL_ONLY: @@ -792,6 +798,36 @@ async def _restore_task_access_token( return None +async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None: + """Restore the origin request ID snapshot for a background task. + + Returns None if no request ID was captured at submission time. + """ + docket = _current_docket.get() + if docket is None: + return None + + request_id_key = docket.key( + f"fastmcp:task:{session_id}:{task_id}:origin_request_id" + ) + try: + async with docket.redis() as redis: + request_id_data = await redis.get(request_id_key) + if request_id_data is None: + return None + if isinstance(request_id_data, bytes): + return request_id_data.decode() + return str(request_id_data) + except Exception: + _logger.warning( + "Failed to restore origin request ID for task %s:%s", + session_id, + task_id, + exc_info=True, + ) + return None + + class _CurrentContext(Dependency): # type: ignore[misc] """Async context manager for Context dependency. @@ -818,11 +854,15 @@ class _CurrentContext(Dependency): # type: ignore[misc] session = get_task_session(task_info.session_id) # Get server from ContextVar server = get_server() + origin_request_id = await _restore_task_origin_request_id( + task_info.session_id, task_info.task_id + ) # Create task-aware Context self._context = Context( fastmcp=server, session=session, task_id=task_info.task_id, + origin_request_id=origin_request_id, ) # Enter the context to set up ContextVars await self._context.__aenter__() @@ -853,6 +893,34 @@ class _CurrentContext(Dependency): # type: ignore[misc] self._context = None +class _OptionalCurrentContext(Dependency): # type: ignore[misc] + """Context dependency that degrades to None when no context is active. + + This is implemented as a wrapper (composition), not a subclass of + `_CurrentContext`, to avoid overriding `__aenter__` with an incompatible + return type. + """ + + _inner: _CurrentContext | None = None + + async def __aenter__(self) -> Context | None: + inner = _CurrentContext() + try: + context = await inner.__aenter__() + except RuntimeError as exc: + if "No active context found" in str(exc): + return None + raise + self._inner = inner + return context + + async def __aexit__(self, *args: object) -> None: + if self._inner is None: + return + await self._inner.__aexit__(*args) + self._inner = None + + def CurrentContext() -> Context: """Get the current FastMCP Context instance. @@ -878,6 +946,11 @@ def CurrentContext() -> Context: return cast("Context", _CurrentContext()) +def OptionalCurrentContext() -> Context | None: + """Get the current FastMCP Context, or None when no context is active.""" + return cast("Context | None", _OptionalCurrentContext()) + + class _CurrentDocket(Dependency): # type: ignore[misc] """Async context manager for Docket dependency.""" diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index be7bddd61..10bf18b6d 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -98,7 +98,13 @@ async def submit_to_docket( poll_interval_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:poll_interval" ) + origin_request_id_key = docket.key( + f"fastmcp:task:{session_id}:{server_task_id}:origin_request_id" + ) poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) + origin_request_id = ( + str(ctx.request_context.request_id) if ctx.request_context is not None else None + ) # Snapshot the current access token (if any) for background task access (#3095) access_token = get_access_token() @@ -110,6 +116,8 @@ async def submit_to_docket( await redis.set(task_meta_key, task_key, ex=ttl_seconds) await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) + if origin_request_id is not None: + await redis.set(origin_request_id_key, origin_request_id, ex=ttl_seconds) if access_token is not None: await redis.set( access_token_key, access_token.model_dump_json(), ex=ttl_seconds diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py index c7eb9e90c..8b2a92889 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -14,6 +14,7 @@ from mcp import ServerSession from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.elicitation import ElicitResult +from fastmcp.dependencies import CurrentDocket from fastmcp.server.auth import AccessToken from fastmcp.server.context import Context from fastmcp.server.dependencies import get_access_token @@ -229,6 +230,43 @@ class TestBackgroundTaskIntegration: assert captured["session_id"] is not None assert captured["is_background"] is True + async def test_origin_request_id_round_trips_through_background_task(self): + """E2E: origin_request_id captured at submit time is restored in worker. + + We validate this by comparing ctx.origin_request_id with the value + stored in Docket's Redis for this task. + """ + + mcp = FastMCP("origin-request-id-roundtrip") + + @mcp.tool(task=True) + async def check_origin_request_id(ctx: Context, docket=CurrentDocket()) -> str: + assert ctx.is_background_task is True + assert ctx.request_context is None + assert ctx.task_id is not None + + origin = ctx.origin_request_id + assert origin is not None + assert isinstance(origin, str) + assert origin != "" + + key = docket.key( + f"fastmcp:task:{ctx.session_id}:{ctx.task_id}:origin_request_id" + ) + async with docket.redis() as redis: + raw = await redis.get(key) + + assert raw is not None + if isinstance(raw, bytes): + raw = raw.decode() + assert str(raw) == origin + return "ok" + + async with Client(mcp) as client: + task = await client.call_tool("check_origin_request_id", {}, task=True) + result = await task.result() + assert result.data == "ok" + async def test_elicit_accept_flow(self): """E2E: tool elicits input, client accepts via elicitation_handler.""" mcp = FastMCP("elicit-accept-test") diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py index cbceac4b4..3ef352902 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/server/tasks/test_task_return_types.py @@ -402,9 +402,11 @@ async def media_server(tmp_path): ), ( "return_image_data", - lambda r: len(r.content) == 1 - and r.content[0].type == "image" - and r.content[0].mimeType == "image/png", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "image" + and r.content[0].mimeType == "image/png" + ), ), ( "return_audio", @@ -615,15 +617,19 @@ async def mcp_content_server(tmp_path): [ ( "return_text_content", - lambda r: len(r.content) == 1 - and r.content[0].type == "text" - and r.content[0].text == "Direct text content", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "text" + and r.content[0].text == "Direct text content" + ), ), ( "return_image_content", - lambda r: len(r.content) == 1 - and r.content[0].type == "image" - and r.content[0].mimeType == "image/png", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "image" + and r.content[0].mimeType == "image/png" + ), ), ( "return_embedded_resource", @@ -631,9 +637,11 @@ async def mcp_content_server(tmp_path): ), ( "return_resource_link", - lambda r: len(r.content) == 1 - and r.content[0].type == "resource_link" - and str(r.content[0].uri) == "test://linked", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "resource_link" + and str(r.content[0].uri) == "test://linked" + ), ), ], ) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 106babecc..df7f04697 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -754,6 +754,42 @@ async def test_validation_error_propagates_from_dependency(mcp: FastMCP): class TestTransformContextAnnotations: """Tests for the transform_context_annotations function.""" + async def test_optional_context_degrades_to_none_without_active_context(self): + """Optional Context should resolve to None when no context is active.""" + import inspect + + from fastmcp.server.dependencies import transform_context_annotations + + async def fn_with_optional_ctx(name: str, ctx: Context | None = None) -> str: + return name + + transform_context_annotations(fn_with_optional_ctx) + sig = inspect.signature(fn_with_optional_ctx) + ctx_dependency = sig.parameters["ctx"].default + + resolved_ctx = await ctx_dependency.__aenter__() + try: + assert resolved_ctx is None + finally: + await ctx_dependency.__aexit__(None, None, None) + + async def test_optional_context_still_injected_in_foreground_requests( + self, mcp: FastMCP + ): + """Optional Context should still be injected for normal MCP requests.""" + + @mcp.tool() + async def tool_with_optional_context( + name: str, ctx: Context | None = None + ) -> str: + if ctx is None: + return f"missing:{name}" + return f"present:{ctx.session_id}:{name}" + + async with Client(mcp) as client: + result = await client.call_tool("tool_with_optional_context", {"name": "x"}) + assert result.content[0].text.startswith("present:") + async def test_basic_context_transformation(self, mcp: FastMCP): """Test basic Context type annotation is transformed.""" From c71840631e03cddfd391ad2a91d5bbab7aef05d6 Mon Sep 17 00:00:00 2001 From: Manrique Vargas Date: Sun, 22 Feb 2026 11:24:22 -0500 Subject: [PATCH 5/7] docs: add context-aware tool factory example (#3264) Fixes PrefectHQ/fastmcp#1841 Signed-off-by: machov --- .../transforms/tool-transformation.mdx | 38 +++++++++++++++++++ docs/v2/patterns/tool-transformation.mdx | 32 ++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/docs/servers/transforms/tool-transformation.mdx b/docs/servers/transforms/tool-transformation.mdx index 8eee6cd23..393b47b5a 100644 --- a/docs/servers/transforms/tool-transformation.mdx +++ b/docs/servers/transforms/tool-transformation.mdx @@ -191,3 +191,41 @@ mcp.add_tool(safe_division) The `forward()` function handles argument mapping automatically. Call it with the transformed argument names, and it maps them back to the original function's parameters. For direct access to the original function without mapping, use `forward_raw()` with the original parameter names. + +## Context-Aware Tool Factories + +You can write functions that act as "factories," generating specialized versions of a tool for different contexts. For example, create a `get_my_data` tool for the current user by hiding the `user_id` parameter and providing it automatically. + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool, tool +from fastmcp.tools.tool_transform import ArgTransform + +# A generic tool that requires a user_id +@tool +def get_user_data(user_id: str, query: str) -> str: + """Fetch data for a specific user.""" + return f"Data for user {user_id}: {query}" + + +def create_user_tool(user_id: str) -> Tool: + """Factory that creates a user-specific version of get_user_data.""" + return Tool.from_tool( + get_user_data, + name="get_my_data", + description="Fetch your data. No need to specify a user ID.", + transform_args={ + "user_id": ArgTransform(hide=True, default=user_id), + }, + ) + + +# Create a server with a tool customized for the current user +mcp = FastMCP("User Server") +current_user_id = "user-123" # e.g., from auth context +mcp.add_tool(create_user_tool(current_user_id)) + +# Clients see "get_my_data(query: str)" — user_id is injected automatically +``` + +This pattern is useful for multi-tenant servers where each connection gets tools pre-configured with their identity, or for wrapping generic tools with environment-specific defaults. diff --git a/docs/v2/patterns/tool-transformation.mdx b/docs/v2/patterns/tool-transformation.mdx index ce16bb338..0808f6878 100644 --- a/docs/v2/patterns/tool-transformation.mdx +++ b/docs/v2/patterns/tool-transformation.mdx @@ -705,3 +705,35 @@ You can chain transformations by using an already transformed tool as the parent ### Context-Aware Tool Factories You can write functions that act as "factories," generating specialized versions of a tool for different contexts. For example, you could create a `get_my_data` tool that is specific to the currently logged-in user by hiding the `user_id` parameter and providing it automatically. + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool, tool +from fastmcp.tools.tool_transform import ArgTransform + +# A generic tool that requires a user_id +@tool +def get_user_data(user_id: str, query: str) -> str: + """Fetch data for a specific user.""" + return f"Data for user {user_id}: {query}" + + +def create_user_tool(user_id: str) -> Tool: + """Factory that creates a user-specific version of get_user_data.""" + return Tool.from_tool( + get_user_data, + name="get_my_data", + description="Fetch your data. No need to specify a user ID.", + transform_args={ + "user_id": ArgTransform(hide=True, default=user_id), + }, + ) + + +# Create a server with a tool customized for the current user +mcp = FastMCP("User Server") +current_user_id = "user-123" # e.g., from auth context +mcp.add_tool(create_user_tool(current_user_id)) + +# Clients see "get_my_data(query: str)" — user_id is injected automatically +``` From e19f2396b33ffbb1b58f97f344b121e100348611 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:41:37 -0500 Subject: [PATCH 6/7] Add v3.0.2 release notes (#3276) --- docs/changelog.mdx | 19 ++++++++++++++++++- docs/updates.mdx | 10 ++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 4b746e55e..ae9e94fe9 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,23 @@ rss: true tag: NEW --- + + +**[v3.0.2: Threecovery Mode II](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2)** + +Two community-contributed fixes: auth headers from MCP transport no longer leak through to downstream OpenAPI APIs, and background task workers now correctly receive the originating request ID. Plus a new docs example for context-aware tool factories. + +### Fixes 🐞 +* fix: prevent MCP transport auth header from leaking to downstream OpenAPI APIs by [@stakeswky](https://github.com/stakeswky) in [#3262](https://github.com/PrefectHQ/fastmcp/pull/3262) +* fix: propagate origin_request_id to background task workers by [@gfortaine](https://github.com/gfortaine) in [#3175](https://github.com/PrefectHQ/fastmcp/pull/3175) +### Docs 📚 +* Add v3.0.1 release notes by [@jlowin](https://github.com/jlowin) in [#3259](https://github.com/PrefectHQ/fastmcp/pull/3259) +* docs: add context-aware tool factory example by [@machov](https://github.com/machov) in [#3264](https://github.com/PrefectHQ/fastmcp/pull/3264) + +**Full Changelog**: [v3.0.1...v3.0.2](https://github.com/PrefectHQ/fastmcp/compare/v3.0.1...v3.0.2) + + + **[v3.0.1: Three-covery Mode](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.1)** @@ -13,7 +30,6 @@ First patch after 3.0 — mostly smoothing out rough edges discovered in the wil ### Enhancements 🔧 * Add verify_id_token option to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#3248](https://github.com/PrefectHQ/fastmcp/pull/3248) - ### Fixes 🐞 * Fix v3.0.0 changelog compare link by [@jlowin](https://github.com/jlowin) in [#3223](https://github.com/PrefectHQ/fastmcp/pull/3223) * Fix MDX parse error in upgrade guide prompts by [@jlowin](https://github.com/jlowin) in [#3227](https://github.com/PrefectHQ/fastmcp/pull/3227) @@ -27,6 +43,7 @@ First patch after 3.0 — mostly smoothing out rough edges discovered in the wil * Fix ty compatibility with upgraded deps by [@jlowin](https://github.com/jlowin) in [#3257](https://github.com/PrefectHQ/fastmcp/pull/3257) * Fix decorator overload return types for function mode by [@jlowin](https://github.com/jlowin) in [#3258](https://github.com/PrefectHQ/fastmcp/pull/3258) + ### Docs 📚 * Sync README with welcome.mdx, fix install count by [@jlowin](https://github.com/jlowin) in [#3224](https://github.com/PrefectHQ/fastmcp/pull/3224) * Document dict-to-Message prompt migration in upgrade guides by [@jlowin](https://github.com/jlowin) in [#3225](https://github.com/PrefectHQ/fastmcp/pull/3225) diff --git a/docs/updates.mdx b/docs/updates.mdx index ab602d13d..e3134fb61 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,16 @@ icon: "sparkles" tag: NEW --- + + +Two community-contributed fixes: auth headers from MCP transport no longer leak through to downstream OpenAPI APIs, and background task workers now correctly receive the originating request ID. Plus a new docs example for context-aware tool factories. + + + Date: Mon, 23 Feb 2026 11:26:48 -0500 Subject: [PATCH 7/7] fix: remove position override from docs banner CSS (#3282) --- docs/css/banner.css | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/css/banner.css b/docs/css/banner.css index 093d9b797..38b18477c 100644 --- a/docs/css/banner.css +++ b/docs/css/banner.css @@ -6,7 +6,6 @@ font-weight: 600 !important; padding-top: 12px !important; padding-bottom: 12px !important; - position: relative !important; overflow: hidden !important; }