fastmcp 2.2.9__py3-none-any.whl → 2.3.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +2 -1
- fastmcp/cli/cli.py +1 -1
- fastmcp/client/client.py +3 -2
- fastmcp/client/transports.py +45 -1
- fastmcp/prompts/prompt.py +10 -15
- fastmcp/prompts/prompt_manager.py +3 -10
- fastmcp/resources/resource.py +2 -7
- fastmcp/resources/resource_manager.py +2 -4
- fastmcp/resources/template.py +11 -24
- fastmcp/resources/types.py +15 -44
- fastmcp/server/__init__.py +1 -0
- fastmcp/server/context.py +50 -38
- fastmcp/server/dependencies.py +35 -0
- fastmcp/server/http.py +309 -0
- fastmcp/server/openapi.py +5 -16
- fastmcp/server/proxy.py +4 -13
- fastmcp/server/server.py +196 -271
- fastmcp/server/streamable_http_manager.py +241 -0
- fastmcp/settings.py +20 -0
- fastmcp/tools/tool.py +40 -33
- fastmcp/tools/tool_manager.py +3 -9
- fastmcp/utilities/cache.py +26 -0
- fastmcp/utilities/tests.py +113 -0
- fastmcp/utilities/types.py +4 -7
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0rc1.dist-info}/METADATA +6 -2
- fastmcp-2.3.0rc1.dist-info/RECORD +55 -0
- fastmcp/utilities/http.py +0 -44
- fastmcp-2.2.9.dist-info/RECORD +0 -51
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0rc1.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0rc1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0rc1.dist-info}/licenses/LICENSE +0 -0
fastmcp/__init__.py
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import version
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
from fastmcp.server.server import FastMCP
|
|
7
6
|
from fastmcp.server.context import Context
|
|
7
|
+
import fastmcp.server
|
|
8
|
+
|
|
8
9
|
from fastmcp.client import Client
|
|
9
10
|
from fastmcp.utilities.types import Image
|
|
10
11
|
from . import client, settings
|
fastmcp/cli/cli.py
CHANGED
fastmcp/client/client.py
CHANGED
|
@@ -108,9 +108,10 @@ class Client:
|
|
|
108
108
|
|
|
109
109
|
# --- MCP Client Methods ---
|
|
110
110
|
|
|
111
|
-
async def ping(self) ->
|
|
111
|
+
async def ping(self) -> bool:
|
|
112
112
|
"""Send a ping request."""
|
|
113
|
-
await self.session.send_ping()
|
|
113
|
+
result = await self.session.send_ping()
|
|
114
|
+
return isinstance(result, mcp.types.EmptyResult)
|
|
114
115
|
|
|
115
116
|
async def progress(
|
|
116
117
|
self,
|
fastmcp/client/transports.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import contextlib
|
|
3
3
|
import datetime
|
|
4
|
+
import inspect
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
6
7
|
import sys
|
|
8
|
+
import warnings
|
|
7
9
|
from collections.abc import AsyncIterator
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Any, TypedDict
|
|
@@ -18,6 +20,7 @@ from mcp.client.session import (
|
|
|
18
20
|
)
|
|
19
21
|
from mcp.client.sse import sse_client
|
|
20
22
|
from mcp.client.stdio import stdio_client
|
|
23
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
21
24
|
from mcp.client.websocket import websocket_client
|
|
22
25
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
23
26
|
from pydantic import AnyUrl
|
|
@@ -125,6 +128,33 @@ class SSETransport(ClientTransport):
|
|
|
125
128
|
return f"<SSE(url='{self.url}')>"
|
|
126
129
|
|
|
127
130
|
|
|
131
|
+
class StreamableHttpTransport(ClientTransport):
|
|
132
|
+
"""Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
|
|
135
|
+
if isinstance(url, AnyUrl):
|
|
136
|
+
url = str(url)
|
|
137
|
+
if not isinstance(url, str) or not url.startswith("http"):
|
|
138
|
+
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
|
|
139
|
+
self.url = url
|
|
140
|
+
self.headers = headers or {}
|
|
141
|
+
|
|
142
|
+
@contextlib.asynccontextmanager
|
|
143
|
+
async def connect_session(
|
|
144
|
+
self, **session_kwargs: Unpack[SessionKwargs]
|
|
145
|
+
) -> AsyncIterator[ClientSession]:
|
|
146
|
+
async with streamablehttp_client(self.url, headers=self.headers) as transport:
|
|
147
|
+
read_stream, write_stream, _ = transport
|
|
148
|
+
async with ClientSession(
|
|
149
|
+
read_stream, write_stream, **session_kwargs
|
|
150
|
+
) as session:
|
|
151
|
+
await session.initialize()
|
|
152
|
+
yield session
|
|
153
|
+
|
|
154
|
+
def __repr__(self) -> str:
|
|
155
|
+
return f"<StreamableHttp(url='{self.url}')>"
|
|
156
|
+
|
|
157
|
+
|
|
128
158
|
class StdioTransport(ClientTransport):
|
|
129
159
|
"""
|
|
130
160
|
Base transport for connecting to an MCP server via subprocess with stdio.
|
|
@@ -422,6 +452,8 @@ def infer_transport(
|
|
|
422
452
|
This function attempts to infer the correct transport type from the provided
|
|
423
453
|
argument, handling various input types and converting them to the appropriate
|
|
424
454
|
ClientTransport subclass.
|
|
455
|
+
|
|
456
|
+
For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
|
|
425
457
|
"""
|
|
426
458
|
# the transport is already a ClientTransport
|
|
427
459
|
if isinstance(transport, ClientTransport):
|
|
@@ -442,7 +474,19 @@ def infer_transport(
|
|
|
442
474
|
|
|
443
475
|
# the transport is an http(s) URL
|
|
444
476
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
|
|
445
|
-
|
|
477
|
+
if str(transport).rstrip("/").endswith("/sse"):
|
|
478
|
+
warnings.warn(
|
|
479
|
+
inspect.cleandoc(
|
|
480
|
+
"""
|
|
481
|
+
As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP.
|
|
482
|
+
The provided URL ends in `/sse`, so you may encounter unexpected behavior.
|
|
483
|
+
If you intended to use SSE, please use the `SSETransport` class directly.
|
|
484
|
+
"""
|
|
485
|
+
),
|
|
486
|
+
category=UserWarning,
|
|
487
|
+
stacklevel=2,
|
|
488
|
+
)
|
|
489
|
+
return StreamableHttpTransport(url=transport)
|
|
446
490
|
|
|
447
491
|
# the transport is a websocket URL
|
|
448
492
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -12,6 +12,7 @@ from mcp.types import Prompt as MCPPrompt
|
|
|
12
12
|
from mcp.types import PromptArgument as MCPPromptArgument
|
|
13
13
|
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
|
14
14
|
|
|
15
|
+
from fastmcp.server.dependencies import get_context
|
|
15
16
|
from fastmcp.utilities.json_schema import prune_params
|
|
16
17
|
from fastmcp.utilities.types import (
|
|
17
18
|
_convert_set_defaults,
|
|
@@ -20,10 +21,7 @@ from fastmcp.utilities.types import (
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
|
-
|
|
24
|
-
from mcp.shared.context import LifespanContextT
|
|
25
|
-
|
|
26
|
-
from fastmcp.server import Context
|
|
24
|
+
pass
|
|
27
25
|
|
|
28
26
|
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
|
29
27
|
|
|
@@ -76,9 +74,6 @@ class Prompt(BaseModel):
|
|
|
76
74
|
None, description="Arguments that can be passed to the prompt"
|
|
77
75
|
)
|
|
78
76
|
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
|
79
|
-
context_kwarg: str | None = Field(
|
|
80
|
-
None, description="Name of the kwarg that should receive context"
|
|
81
|
-
)
|
|
82
77
|
|
|
83
78
|
@classmethod
|
|
84
79
|
def from_function(
|
|
@@ -87,7 +82,6 @@ class Prompt(BaseModel):
|
|
|
87
82
|
name: str | None = None,
|
|
88
83
|
description: str | None = None,
|
|
89
84
|
tags: set[str] | None = None,
|
|
90
|
-
context_kwarg: str | None = None,
|
|
91
85
|
) -> Prompt:
|
|
92
86
|
"""Create a Prompt from a function.
|
|
93
87
|
|
|
@@ -97,7 +91,7 @@ class Prompt(BaseModel):
|
|
|
97
91
|
- A dict (converted to a message)
|
|
98
92
|
- A sequence of any of the above
|
|
99
93
|
"""
|
|
100
|
-
from fastmcp import Context
|
|
94
|
+
from fastmcp.server.context import Context
|
|
101
95
|
|
|
102
96
|
func_name = name or fn.__name__
|
|
103
97
|
|
|
@@ -115,8 +109,8 @@ class Prompt(BaseModel):
|
|
|
115
109
|
parameters = type_adapter.json_schema()
|
|
116
110
|
|
|
117
111
|
# Auto-detect context parameter if not provided
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
|
|
113
|
+
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
120
114
|
if context_kwarg:
|
|
121
115
|
parameters = prune_params(parameters, params=[context_kwarg])
|
|
122
116
|
|
|
@@ -141,15 +135,15 @@ class Prompt(BaseModel):
|
|
|
141
135
|
arguments=arguments,
|
|
142
136
|
fn=fn,
|
|
143
137
|
tags=tags or set(),
|
|
144
|
-
context_kwarg=context_kwarg,
|
|
145
138
|
)
|
|
146
139
|
|
|
147
140
|
async def render(
|
|
148
141
|
self,
|
|
149
142
|
arguments: dict[str, Any] | None = None,
|
|
150
|
-
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
151
143
|
) -> list[PromptMessage]:
|
|
152
144
|
"""Render the prompt with arguments."""
|
|
145
|
+
from fastmcp.server.context import Context
|
|
146
|
+
|
|
153
147
|
# Validate required arguments
|
|
154
148
|
if self.arguments:
|
|
155
149
|
required = {arg.name for arg in self.arguments if arg.required}
|
|
@@ -161,8 +155,9 @@ class Prompt(BaseModel):
|
|
|
161
155
|
try:
|
|
162
156
|
# Prepare arguments with context
|
|
163
157
|
kwargs = arguments.copy() if arguments else {}
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
159
|
+
if context_kwarg and context_kwarg not in kwargs:
|
|
160
|
+
kwargs[context_kwarg] = get_context()
|
|
166
161
|
|
|
167
162
|
# Call function and check if result is a coroutine
|
|
168
163
|
result = self.fn(**kwargs)
|
|
@@ -13,10 +13,7 @@ from fastmcp.settings import DuplicateBehavior
|
|
|
13
13
|
from fastmcp.utilities.logging import get_logger
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
|
-
|
|
17
|
-
from mcp.shared.context import LifespanContextT
|
|
18
|
-
|
|
19
|
-
from fastmcp.server import Context
|
|
16
|
+
pass
|
|
20
17
|
|
|
21
18
|
logger = get_logger(__name__)
|
|
22
19
|
|
|
@@ -82,19 +79,15 @@ class PromptManager:
|
|
|
82
79
|
self,
|
|
83
80
|
name: str,
|
|
84
81
|
arguments: dict[str, Any] | None = None,
|
|
85
|
-
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
86
82
|
) -> GetPromptResult:
|
|
87
83
|
"""Render a prompt by name with arguments."""
|
|
88
84
|
prompt = self.get_prompt(name)
|
|
89
85
|
if not prompt:
|
|
90
86
|
raise NotFoundError(f"Unknown prompt: {name}")
|
|
91
87
|
|
|
92
|
-
messages = await prompt.render(arguments
|
|
88
|
+
messages = await prompt.render(arguments)
|
|
93
89
|
|
|
94
|
-
return GetPromptResult(
|
|
95
|
-
description=prompt.description,
|
|
96
|
-
messages=messages,
|
|
97
|
-
)
|
|
90
|
+
return GetPromptResult(description=prompt.description, messages=messages)
|
|
98
91
|
|
|
99
92
|
def has_prompt(self, key: str) -> bool:
|
|
100
93
|
"""Check if a prompt exists."""
|
fastmcp/resources/resource.py
CHANGED
|
@@ -20,10 +20,7 @@ from pydantic import (
|
|
|
20
20
|
from fastmcp.utilities.types import _convert_set_defaults
|
|
21
21
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
|
-
|
|
24
|
-
from mcp.shared.context import LifespanContextT
|
|
25
|
-
|
|
26
|
-
from fastmcp.server import Context
|
|
23
|
+
pass
|
|
27
24
|
|
|
28
25
|
|
|
29
26
|
class Resource(BaseModel, abc.ABC):
|
|
@@ -66,9 +63,7 @@ class Resource(BaseModel, abc.ABC):
|
|
|
66
63
|
raise ValueError("Either name or uri must be provided")
|
|
67
64
|
|
|
68
65
|
@abc.abstractmethod
|
|
69
|
-
async def read(
|
|
70
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
71
|
-
) -> str | bytes:
|
|
66
|
+
async def read(self) -> str | bytes:
|
|
72
67
|
"""Read the resource content."""
|
|
73
68
|
pass
|
|
74
69
|
|
|
@@ -109,7 +109,7 @@ class ResourceManager:
|
|
|
109
109
|
The added resource. If a resource with the same URI already exists,
|
|
110
110
|
returns the existing resource.
|
|
111
111
|
"""
|
|
112
|
-
resource = FunctionResource
|
|
112
|
+
resource = FunctionResource(
|
|
113
113
|
fn=fn,
|
|
114
114
|
uri=AnyUrl(uri),
|
|
115
115
|
name=name,
|
|
@@ -219,12 +219,11 @@ class ResourceManager:
|
|
|
219
219
|
return True
|
|
220
220
|
return False
|
|
221
221
|
|
|
222
|
-
async def get_resource(self, uri: AnyUrl | str
|
|
222
|
+
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
|
223
223
|
"""Get resource by URI, checking concrete resources first, then templates.
|
|
224
224
|
|
|
225
225
|
Args:
|
|
226
226
|
uri: The URI of the resource to get
|
|
227
|
-
context: Optional context object to pass to template resources
|
|
228
227
|
|
|
229
228
|
Raises:
|
|
230
229
|
NotFoundError: If no resource or template matching the URI is found.
|
|
@@ -244,7 +243,6 @@ class ResourceManager:
|
|
|
244
243
|
return await template.create_resource(
|
|
245
244
|
uri_str,
|
|
246
245
|
params=params,
|
|
247
|
-
context=context,
|
|
248
246
|
)
|
|
249
247
|
except Exception as e:
|
|
250
248
|
raise ValueError(f"Error creating resource from template: {e}")
|
fastmcp/resources/template.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import inspect
|
|
6
6
|
import re
|
|
7
7
|
from collections.abc import Callable
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Annotated, Any
|
|
9
9
|
from urllib.parse import unquote
|
|
10
10
|
|
|
11
11
|
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
@@ -20,17 +20,12 @@ from pydantic import (
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
from fastmcp.resources.types import FunctionResource, Resource
|
|
23
|
+
from fastmcp.server.dependencies import get_context
|
|
23
24
|
from fastmcp.utilities.types import (
|
|
24
25
|
_convert_set_defaults,
|
|
25
26
|
find_kwarg_by_type,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
if TYPE_CHECKING:
|
|
29
|
-
from mcp.server.session import ServerSessionT
|
|
30
|
-
from mcp.shared.context import LifespanContextT
|
|
31
|
-
|
|
32
|
-
from fastmcp.server import Context
|
|
33
|
-
|
|
34
29
|
|
|
35
30
|
def build_regex(template: str) -> re.Pattern:
|
|
36
31
|
parts = re.split(r"(\{[^}]+\})", template)
|
|
@@ -79,9 +74,6 @@ class ResourceTemplate(BaseModel):
|
|
|
79
74
|
parameters: dict[str, Any] = Field(
|
|
80
75
|
description="JSON schema for function parameters"
|
|
81
76
|
)
|
|
82
|
-
context_kwarg: str | None = Field(
|
|
83
|
-
None, description="Name of the kwarg that should receive context"
|
|
84
|
-
)
|
|
85
77
|
|
|
86
78
|
@field_validator("mime_type", mode="before")
|
|
87
79
|
@classmethod
|
|
@@ -100,10 +92,9 @@ class ResourceTemplate(BaseModel):
|
|
|
100
92
|
description: str | None = None,
|
|
101
93
|
mime_type: str | None = None,
|
|
102
94
|
tags: set[str] | None = None,
|
|
103
|
-
context_kwarg: str | None = None,
|
|
104
95
|
) -> ResourceTemplate:
|
|
105
96
|
"""Create a template from a function."""
|
|
106
|
-
from fastmcp import Context
|
|
97
|
+
from fastmcp.server.context import Context
|
|
107
98
|
|
|
108
99
|
func_name = name or fn.__name__
|
|
109
100
|
if func_name == "<lambda>":
|
|
@@ -119,8 +110,8 @@ class ResourceTemplate(BaseModel):
|
|
|
119
110
|
)
|
|
120
111
|
|
|
121
112
|
# Auto-detect context parameter if not provided
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
|
|
114
|
+
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
124
115
|
|
|
125
116
|
# Validate that URI params match function params
|
|
126
117
|
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
|
|
@@ -170,25 +161,22 @@ class ResourceTemplate(BaseModel):
|
|
|
170
161
|
fn=fn,
|
|
171
162
|
parameters=parameters,
|
|
172
163
|
tags=tags or set(),
|
|
173
|
-
context_kwarg=context_kwarg,
|
|
174
164
|
)
|
|
175
165
|
|
|
176
166
|
def matches(self, uri: str) -> dict[str, Any] | None:
|
|
177
167
|
"""Check if URI matches template and extract parameters."""
|
|
178
168
|
return match_uri_template(uri, self.uri_template)
|
|
179
169
|
|
|
180
|
-
async def create_resource(
|
|
181
|
-
self,
|
|
182
|
-
uri: str,
|
|
183
|
-
params: dict[str, Any],
|
|
184
|
-
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
185
|
-
) -> Resource:
|
|
170
|
+
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
|
|
186
171
|
"""Create a resource from the template with the given parameters."""
|
|
172
|
+
from fastmcp.server.context import Context
|
|
173
|
+
|
|
187
174
|
try:
|
|
188
175
|
# Add context to parameters if needed
|
|
189
176
|
kwargs = params.copy()
|
|
190
|
-
|
|
191
|
-
|
|
177
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
178
|
+
if context_kwarg and context_kwarg not in kwargs:
|
|
179
|
+
kwargs[context_kwarg] = get_context()
|
|
192
180
|
|
|
193
181
|
# Call function and check if result is a coroutine
|
|
194
182
|
result = self.fn(**kwargs)
|
|
@@ -202,7 +190,6 @@ class ResourceTemplate(BaseModel):
|
|
|
202
190
|
mime_type=self.mime_type,
|
|
203
191
|
fn=lambda **kwargs: result, # Capture result in closure
|
|
204
192
|
tags=self.tags,
|
|
205
|
-
context_kwarg=self.context_kwarg,
|
|
206
193
|
)
|
|
207
194
|
except Exception as e:
|
|
208
195
|
raise ValueError(f"Error creating resource from template: {e}")
|
fastmcp/resources/types.py
CHANGED
|
@@ -15,14 +15,12 @@ import pydantic.json
|
|
|
15
15
|
import pydantic_core
|
|
16
16
|
from pydantic import Field, ValidationInfo
|
|
17
17
|
|
|
18
|
-
import fastmcp
|
|
19
18
|
from fastmcp.resources.resource import Resource
|
|
19
|
+
from fastmcp.server.dependencies import get_context
|
|
20
|
+
from fastmcp.utilities.types import find_kwarg_by_type
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING:
|
|
22
|
-
|
|
23
|
-
from mcp.shared.context import LifespanContextT
|
|
24
|
-
|
|
25
|
-
from fastmcp.server import Context
|
|
23
|
+
pass
|
|
26
24
|
|
|
27
25
|
|
|
28
26
|
class TextResource(Resource):
|
|
@@ -30,9 +28,7 @@ class TextResource(Resource):
|
|
|
30
28
|
|
|
31
29
|
text: str = Field(description="Text content of the resource")
|
|
32
30
|
|
|
33
|
-
async def read(
|
|
34
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
35
|
-
) -> str:
|
|
31
|
+
async def read(self) -> str:
|
|
36
32
|
"""Read the text content."""
|
|
37
33
|
return self.text
|
|
38
34
|
|
|
@@ -42,9 +38,7 @@ class BinaryResource(Resource):
|
|
|
42
38
|
|
|
43
39
|
data: bytes = Field(description="Binary content of the resource")
|
|
44
40
|
|
|
45
|
-
async def read(
|
|
46
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
47
|
-
) -> bytes:
|
|
41
|
+
async def read(self) -> bytes:
|
|
48
42
|
"""Read the binary content."""
|
|
49
43
|
return self.data
|
|
50
44
|
|
|
@@ -63,40 +57,23 @@ class FunctionResource(Resource):
|
|
|
63
57
|
"""
|
|
64
58
|
|
|
65
59
|
fn: Callable[[], Any]
|
|
66
|
-
context_kwarg: str | None = Field(
|
|
67
|
-
default=None, description="Name of the kwarg that should receive context"
|
|
68
|
-
)
|
|
69
60
|
|
|
70
|
-
|
|
71
|
-
def from_function(
|
|
72
|
-
cls, fn: Callable[[], Any], context_kwarg: str | None = None, **kwargs
|
|
73
|
-
) -> FunctionResource:
|
|
74
|
-
if context_kwarg is None:
|
|
75
|
-
parameters = inspect.signature(fn).parameters
|
|
76
|
-
context_param = next(
|
|
77
|
-
(p for p in parameters.values() if p.annotation is fastmcp.Context),
|
|
78
|
-
None,
|
|
79
|
-
)
|
|
80
|
-
if context_param is not None:
|
|
81
|
-
context_kwarg = context_param.name
|
|
82
|
-
return cls(fn=fn, context_kwarg=context_kwarg, **kwargs)
|
|
83
|
-
|
|
84
|
-
async def read(
|
|
85
|
-
self,
|
|
86
|
-
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
87
|
-
) -> str | bytes:
|
|
61
|
+
async def read(self) -> str | bytes:
|
|
88
62
|
"""Read the resource by calling the wrapped function."""
|
|
63
|
+
from fastmcp.server.context import Context
|
|
64
|
+
|
|
89
65
|
try:
|
|
90
66
|
kwargs = {}
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
68
|
+
if context_kwarg is not None:
|
|
69
|
+
kwargs[context_kwarg] = get_context()
|
|
93
70
|
|
|
94
71
|
result = self.fn(**kwargs)
|
|
95
72
|
if inspect.iscoroutinefunction(self.fn):
|
|
96
73
|
result = await result
|
|
97
74
|
|
|
98
75
|
if isinstance(result, Resource):
|
|
99
|
-
return await result.read(
|
|
76
|
+
return await result.read()
|
|
100
77
|
elif isinstance(result, bytes):
|
|
101
78
|
return result
|
|
102
79
|
elif isinstance(result, str):
|
|
@@ -140,9 +117,7 @@ class FileResource(Resource):
|
|
|
140
117
|
mime_type = info.data.get("mime_type", "text/plain")
|
|
141
118
|
return not mime_type.startswith("text/")
|
|
142
119
|
|
|
143
|
-
async def read(
|
|
144
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
145
|
-
) -> str | bytes:
|
|
120
|
+
async def read(self) -> str | bytes:
|
|
146
121
|
"""Read the file content."""
|
|
147
122
|
try:
|
|
148
123
|
if self.is_binary:
|
|
@@ -160,9 +135,7 @@ class HttpResource(Resource):
|
|
|
160
135
|
default="application/json", description="MIME type of the resource content"
|
|
161
136
|
)
|
|
162
137
|
|
|
163
|
-
async def read(
|
|
164
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
165
|
-
) -> str | bytes:
|
|
138
|
+
async def read(self) -> str | bytes:
|
|
166
139
|
"""Read the HTTP content."""
|
|
167
140
|
async with httpx.AsyncClient() as client:
|
|
168
141
|
response = await client.get(self.url)
|
|
@@ -214,9 +187,7 @@ class DirectoryResource(Resource):
|
|
|
214
187
|
except Exception as e:
|
|
215
188
|
raise ValueError(f"Error listing directory {self.path}: {e}")
|
|
216
189
|
|
|
217
|
-
async def read(
|
|
218
|
-
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
219
|
-
) -> str: # Always returns JSON string
|
|
190
|
+
async def read(self) -> str: # Always returns JSON string
|
|
220
191
|
"""Read the directory listing."""
|
|
221
192
|
try:
|
|
222
193
|
files = await anyio.to_thread.run_sync(self.list_files)
|
fastmcp/server/__init__.py
CHANGED
fastmcp/server/context.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations as _annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar, Token
|
|
7
|
+
from dataclasses import dataclass
|
|
4
8
|
|
|
5
9
|
from mcp import LoggingLevel
|
|
6
10
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
7
|
-
from mcp.
|
|
8
|
-
from mcp.shared.context import LifespanContextT, RequestContext
|
|
11
|
+
from mcp.shared.context import RequestContext
|
|
9
12
|
from mcp.types import (
|
|
10
13
|
CreateMessageResult,
|
|
11
14
|
ImageContent,
|
|
@@ -13,18 +16,29 @@ from mcp.types import (
|
|
|
13
16
|
SamplingMessage,
|
|
14
17
|
TextContent,
|
|
15
18
|
)
|
|
16
|
-
from pydantic import BaseModel, ConfigDict
|
|
17
19
|
from pydantic.networks import AnyUrl
|
|
18
20
|
from starlette.requests import Request
|
|
19
21
|
|
|
22
|
+
import fastmcp.server.dependencies
|
|
20
23
|
from fastmcp.server.server import FastMCP
|
|
21
|
-
from fastmcp.utilities.http import get_current_starlette_request
|
|
22
24
|
from fastmcp.utilities.logging import get_logger
|
|
23
25
|
|
|
24
26
|
logger = get_logger(__name__)
|
|
25
27
|
|
|
28
|
+
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def set_context(context: Context) -> Generator[Context, None, None]:
|
|
33
|
+
token = _current_context.set(context)
|
|
34
|
+
try:
|
|
35
|
+
yield context
|
|
36
|
+
finally:
|
|
37
|
+
_current_context.reset(token)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Context:
|
|
28
42
|
"""Context object providing access to MCP capabilities.
|
|
29
43
|
|
|
30
44
|
This provides a cleaner interface to MCP's RequestContext functionality.
|
|
@@ -56,37 +70,30 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
|
56
70
|
|
|
57
71
|
The context parameter name can be anything as long as it's annotated with Context.
|
|
58
72
|
The context is optional - tools that don't need it can omit the parameter.
|
|
73
|
+
|
|
59
74
|
"""
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
def __init__(self, fastmcp: FastMCP):
|
|
77
|
+
self.fastmcp = fastmcp
|
|
78
|
+
self._tokens: list[Token] = []
|
|
63
79
|
|
|
64
|
-
|
|
80
|
+
def __enter__(self) -> Context:
|
|
81
|
+
"""Enter the context manager and set this context as the current context."""
|
|
82
|
+
# Always set this context and save the token
|
|
83
|
+
token = _current_context.set(self)
|
|
84
|
+
self._tokens.append(token)
|
|
85
|
+
return self
|
|
65
86
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
**kwargs: Any,
|
|
72
|
-
):
|
|
73
|
-
super().__init__(**kwargs)
|
|
74
|
-
self._request_context = request_context
|
|
75
|
-
self._fastmcp = fastmcp
|
|
87
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
88
|
+
"""Exit the context manager and reset the most recent token."""
|
|
89
|
+
if self._tokens:
|
|
90
|
+
token = self._tokens.pop()
|
|
91
|
+
_current_context.reset(token)
|
|
76
92
|
|
|
77
93
|
@property
|
|
78
|
-
def
|
|
79
|
-
"""Access to the FastMCP server."""
|
|
80
|
-
if self._fastmcp is None:
|
|
81
|
-
raise ValueError("Context is not available outside of a request")
|
|
82
|
-
return self._fastmcp
|
|
83
|
-
|
|
84
|
-
@property
|
|
85
|
-
def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]:
|
|
94
|
+
def request_context(self) -> RequestContext:
|
|
86
95
|
"""Access to the underlying request context."""
|
|
87
|
-
|
|
88
|
-
raise ValueError("Context is not available outside of a request")
|
|
89
|
-
return self._request_context
|
|
96
|
+
return self.fastmcp._mcp_server.request_context
|
|
90
97
|
|
|
91
98
|
async def report_progress(
|
|
92
99
|
self, progress: float, total: float | None = None
|
|
@@ -120,10 +127,8 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
|
120
127
|
Returns:
|
|
121
128
|
The resource content as either text or bytes
|
|
122
129
|
"""
|
|
123
|
-
assert self.
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
return await self._fastmcp._mcp_read_resource(uri)
|
|
130
|
+
assert self.fastmcp is not None, "Context is not available outside of a request"
|
|
131
|
+
return await self.fastmcp._mcp_read_resource(uri)
|
|
127
132
|
|
|
128
133
|
async def log(
|
|
129
134
|
self,
|
|
@@ -229,7 +234,14 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
|
229
234
|
|
|
230
235
|
def get_http_request(self) -> Request:
|
|
231
236
|
"""Get the active starlette request."""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
|
|
238
|
+
# Deprecation warning, added in FastMCP 2.2.11
|
|
239
|
+
warnings.warn(
|
|
240
|
+
"Context.get_http_request() is deprecated and will be removed in a future version. "
|
|
241
|
+
"Use get_http_request() from fastmcp.server.dependencies instead. "
|
|
242
|
+
"See https://gofastmcp.com/patterns/http-requests for more details.",
|
|
243
|
+
DeprecationWarning,
|
|
244
|
+
stacklevel=2,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return fastmcp.server.dependencies.get_http_request()
|