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.
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
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/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;
}
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/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.
+
+
+
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
+```
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/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 15c8c3124..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:
@@ -434,14 +440,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 +471,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 +479,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")
@@ -781,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.
@@ -807,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__()
@@ -842,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.
@@ -867,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."""
@@ -1037,7 +1121,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 +1130,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()} "
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."""