mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Merge pull request #3319 from PrefectHQ/update-published-docs
Fast-forward published-docs to include banner CSS fix
This commit is contained in:
commit
243df39f04
18 changed files with 370 additions and 71 deletions
10
.github/workflows/marvin-comment-on-issue.yml
vendored
10
.github/workflows/marvin-comment-on-issue.yml
vendored
|
|
@ -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:
|
|||
</task>
|
||||
|
||||
<constraints>
|
||||
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.
|
||||
</constraints>
|
||||
|
||||
<allowed_tools>
|
||||
|
|
|
|||
43
.github/workflows/update-config-schema.yml
vendored
43
.github/workflows/update-config-schema.yml
vendored
|
|
@ -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>"
|
||||
|
|
|
|||
43
.github/workflows/update-sdk-docs.yml
vendored
43
.github/workflows/update-sdk-docs.yml
vendored
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,23 @@ rss: true
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="v3.0.2" description="2026-02-22">
|
||||
|
||||
**[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)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.1" description="2026-02-20">
|
||||
|
||||
**[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)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
font-weight: 600 !important;
|
||||
padding-top: 12px !important;
|
||||
padding-bottom: 12px !important;
|
||||
position: relative !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ icon: "sparkles"
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="FastMCP 3.0.2" description="February 22, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.0.2: Threecovery Mode II"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
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.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.0.1" description="February 20, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.0.1: Three-covery Mode"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()} "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue