fastmcp 2.1.2__py3-none-any.whl → 2.2.0__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/cli/cli.py +32 -0
- fastmcp/client/client.py +16 -15
- fastmcp/client/transports.py +28 -7
- fastmcp/exceptions.py +8 -0
- fastmcp/prompts/prompt.py +20 -1
- fastmcp/prompts/prompt_manager.py +37 -47
- fastmcp/resources/resource.py +20 -1
- fastmcp/resources/resource_manager.py +83 -116
- fastmcp/resources/template.py +81 -8
- fastmcp/server/openapi.py +10 -16
- fastmcp/server/proxy.py +102 -76
- fastmcp/server/server.py +319 -256
- fastmcp/settings.py +8 -11
- fastmcp/tools/tool.py +67 -7
- fastmcp/tools/tool_manager.py +46 -47
- {fastmcp-2.1.2.dist-info → fastmcp-2.2.0.dist-info}/METADATA +6 -5
- {fastmcp-2.1.2.dist-info → fastmcp-2.2.0.dist-info}/RECORD +20 -20
- {fastmcp-2.1.2.dist-info → fastmcp-2.2.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.1.2.dist-info → fastmcp-2.2.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.1.2.dist-info → fastmcp-2.2.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/cli.py
CHANGED
|
@@ -297,6 +297,29 @@ def run(
|
|
|
297
297
|
help="Transport protocol to use (stdio or sse)",
|
|
298
298
|
),
|
|
299
299
|
] = None,
|
|
300
|
+
host: Annotated[
|
|
301
|
+
str | None,
|
|
302
|
+
typer.Option(
|
|
303
|
+
"--host",
|
|
304
|
+
help="Host to bind to when using sse transport (default: 0.0.0.0)",
|
|
305
|
+
),
|
|
306
|
+
] = None,
|
|
307
|
+
port: Annotated[
|
|
308
|
+
int | None,
|
|
309
|
+
typer.Option(
|
|
310
|
+
"--port",
|
|
311
|
+
"-p",
|
|
312
|
+
help="Port to bind to when using sse transport (default: 8000)",
|
|
313
|
+
),
|
|
314
|
+
] = None,
|
|
315
|
+
log_level: Annotated[
|
|
316
|
+
str | None,
|
|
317
|
+
typer.Option(
|
|
318
|
+
"--log-level",
|
|
319
|
+
"-l",
|
|
320
|
+
help="Log level for sse transport (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
|
321
|
+
),
|
|
322
|
+
] = None,
|
|
300
323
|
) -> None:
|
|
301
324
|
"""Run a MCP server.
|
|
302
325
|
|
|
@@ -316,6 +339,9 @@ def run(
|
|
|
316
339
|
"file": str(file),
|
|
317
340
|
"server_object": server_object,
|
|
318
341
|
"transport": transport,
|
|
342
|
+
"host": host,
|
|
343
|
+
"port": port,
|
|
344
|
+
"log_level": log_level,
|
|
319
345
|
},
|
|
320
346
|
)
|
|
321
347
|
|
|
@@ -329,6 +355,12 @@ def run(
|
|
|
329
355
|
kwargs = {}
|
|
330
356
|
if transport:
|
|
331
357
|
kwargs["transport"] = transport
|
|
358
|
+
if host:
|
|
359
|
+
kwargs["host"] = host
|
|
360
|
+
if port:
|
|
361
|
+
kwargs["port"] = port
|
|
362
|
+
if log_level:
|
|
363
|
+
kwargs["log_level"] = log_level
|
|
332
364
|
|
|
333
365
|
server.run(**kwargs)
|
|
334
366
|
|
fastmcp/client/client.py
CHANGED
|
@@ -17,6 +17,7 @@ from fastmcp.client.roots import (
|
|
|
17
17
|
create_roots_callback,
|
|
18
18
|
)
|
|
19
19
|
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
|
20
|
+
from fastmcp.exceptions import ClientError
|
|
20
21
|
from fastmcp.server import FastMCP
|
|
21
22
|
|
|
22
23
|
from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
@@ -24,10 +25,6 @@ from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
|
24
25
|
__all__ = ["Client", "RootsHandler", "RootsList"]
|
|
25
26
|
|
|
26
27
|
|
|
27
|
-
class ClientError(ValueError):
|
|
28
|
-
"""Base class for errors raised by the client."""
|
|
29
|
-
|
|
30
|
-
|
|
31
28
|
class Client:
|
|
32
29
|
"""
|
|
33
30
|
MCP client that delegates connection management to a Transport instance.
|
|
@@ -48,7 +45,7 @@ class Client:
|
|
|
48
45
|
):
|
|
49
46
|
self.transport = infer_transport(transport)
|
|
50
47
|
self._session: ClientSession | None = None
|
|
51
|
-
self.
|
|
48
|
+
self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = []
|
|
52
49
|
|
|
53
50
|
self._session_kwargs: SessionKwargs = {
|
|
54
51
|
"sampling_callback": None,
|
|
@@ -89,24 +86,28 @@ class Client:
|
|
|
89
86
|
|
|
90
87
|
async def __aenter__(self):
|
|
91
88
|
if self.is_connected():
|
|
92
|
-
|
|
89
|
+
# We're already connected, no need to add None to the session_cms list
|
|
90
|
+
return self
|
|
91
|
+
|
|
93
92
|
try:
|
|
94
|
-
|
|
95
|
-
self.
|
|
93
|
+
session_cm = self.transport.connect_session(**self._session_kwargs)
|
|
94
|
+
self._session_cms.append(session_cm)
|
|
95
|
+
self._session = await self._session_cms[-1].__aenter__()
|
|
96
96
|
return self
|
|
97
97
|
except Exception as e:
|
|
98
98
|
# Ensure cleanup if __aenter__ fails partially
|
|
99
99
|
self._session = None
|
|
100
|
-
self.
|
|
100
|
+
if self._session_cms:
|
|
101
|
+
self._session_cms.pop()
|
|
101
102
|
raise ConnectionError(
|
|
102
103
|
f"Failed to connect using {self.transport}: {e}"
|
|
103
104
|
) from e
|
|
104
105
|
|
|
105
106
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
106
|
-
if self.
|
|
107
|
-
await self.
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
if self._session_cms:
|
|
108
|
+
await self._session_cms[-1].__aexit__(exc_type, exc_val, exc_tb)
|
|
109
|
+
self._session = None
|
|
110
|
+
self._session_cms.pop()
|
|
110
111
|
|
|
111
112
|
# --- MCP Client Methods ---
|
|
112
113
|
async def ping(self) -> None:
|
|
@@ -168,10 +169,10 @@ class Client:
|
|
|
168
169
|
|
|
169
170
|
async def get_prompt(
|
|
170
171
|
self, name: str, arguments: dict[str, str] | None = None
|
|
171
|
-
) -> mcp.types.
|
|
172
|
+
) -> list[mcp.types.PromptMessage]:
|
|
172
173
|
"""Send a prompts/get request."""
|
|
173
174
|
result = await self.session.get_prompt(name, arguments)
|
|
174
|
-
return result
|
|
175
|
+
return result.messages
|
|
175
176
|
|
|
176
177
|
async def complete(
|
|
177
178
|
self,
|
fastmcp/client/transports.py
CHANGED
|
@@ -2,13 +2,15 @@ import abc
|
|
|
2
2
|
import contextlib
|
|
3
3
|
import datetime
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
from collections.abc import AsyncIterator
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import (
|
|
8
9
|
TypedDict,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
|
-
from
|
|
12
|
+
from exceptiongroup import BaseExceptionGroup, catch
|
|
13
|
+
from mcp import ClientSession, McpError, StdioServerParameters
|
|
12
14
|
from mcp.client.session import (
|
|
13
15
|
ListRootsFnT,
|
|
14
16
|
LoggingFnT,
|
|
@@ -22,6 +24,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
22
24
|
from pydantic import AnyUrl
|
|
23
25
|
from typing_extensions import Unpack
|
|
24
26
|
|
|
27
|
+
from fastmcp.exceptions import ClientError
|
|
25
28
|
from fastmcp.server import FastMCP as FastMCPServer
|
|
26
29
|
|
|
27
30
|
|
|
@@ -341,6 +344,10 @@ class NpxStdioTransport(StdioTransport):
|
|
|
341
344
|
env_vars: Additional environment variables
|
|
342
345
|
use_package_lock: Whether to use package-lock.json (--prefer-offline)
|
|
343
346
|
"""
|
|
347
|
+
# verify npx is installed
|
|
348
|
+
if shutil.which("npx") is None:
|
|
349
|
+
raise ValueError("Command 'npx' not found")
|
|
350
|
+
|
|
344
351
|
# Basic validation
|
|
345
352
|
if project_directory and not Path(project_directory).exists():
|
|
346
353
|
raise NotADirectoryError(
|
|
@@ -382,12 +389,26 @@ class FastMCPTransport(ClientTransport):
|
|
|
382
389
|
async def connect_session(
|
|
383
390
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
384
391
|
) -> AsyncIterator[ClientSession]:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
392
|
+
def exception_handler(excgroup: BaseExceptionGroup):
|
|
393
|
+
for exc in excgroup.exceptions:
|
|
394
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
395
|
+
exception_handler(exc)
|
|
396
|
+
raise exc
|
|
397
|
+
|
|
398
|
+
def mcperror_handler(excgroup: BaseExceptionGroup):
|
|
399
|
+
for exc in excgroup.exceptions:
|
|
400
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
401
|
+
mcperror_handler(exc)
|
|
402
|
+
raise ClientError(exc)
|
|
403
|
+
|
|
404
|
+
# backport of 3.11's except* syntax
|
|
405
|
+
with catch({McpError: mcperror_handler, Exception: exception_handler}):
|
|
406
|
+
# create_connected_server_and_client_session manages the session lifecycle itself
|
|
407
|
+
async with create_connected_server_and_client_session(
|
|
408
|
+
server=self._fastmcp._mcp_server,
|
|
409
|
+
**session_kwargs,
|
|
410
|
+
) as session:
|
|
411
|
+
yield session
|
|
391
412
|
|
|
392
413
|
def __repr__(self) -> str:
|
|
393
414
|
return f"<FastMCP(server='{self._fastmcp.name}')>"
|
fastmcp/exceptions.py
CHANGED
|
@@ -23,3 +23,11 @@ class PromptError(FastMCPError):
|
|
|
23
23
|
|
|
24
24
|
class InvalidSignature(Exception):
|
|
25
25
|
"""Invalid signature for use with FastMCP."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClientError(Exception):
|
|
29
|
+
"""Error in client operations."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(Exception):
|
|
33
|
+
"""Object not found."""
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import Annotated, Any, Literal
|
|
|
7
7
|
|
|
8
8
|
import pydantic_core
|
|
9
9
|
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
10
|
+
from mcp.types import Prompt as MCPPrompt
|
|
11
|
+
from mcp.types import PromptArgument as MCPPromptArgument
|
|
10
12
|
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
|
11
13
|
|
|
12
14
|
from fastmcp.utilities.types import _convert_set_defaults
|
|
@@ -113,7 +115,7 @@ class Prompt(BaseModel):
|
|
|
113
115
|
|
|
114
116
|
return cls(
|
|
115
117
|
name=func_name,
|
|
116
|
-
description=description or fn.__doc__
|
|
118
|
+
description=description or fn.__doc__,
|
|
117
119
|
arguments=arguments,
|
|
118
120
|
fn=fn,
|
|
119
121
|
tags=tags or set(),
|
|
@@ -166,3 +168,20 @@ class Prompt(BaseModel):
|
|
|
166
168
|
if not isinstance(other, Prompt):
|
|
167
169
|
return False
|
|
168
170
|
return self.model_dump() == other.model_dump()
|
|
171
|
+
|
|
172
|
+
def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
|
|
173
|
+
"""Convert the prompt to an MCP prompt."""
|
|
174
|
+
arguments = [
|
|
175
|
+
MCPPromptArgument(
|
|
176
|
+
name=arg.name,
|
|
177
|
+
description=arg.description,
|
|
178
|
+
required=arg.required,
|
|
179
|
+
)
|
|
180
|
+
for arg in self.arguments or []
|
|
181
|
+
]
|
|
182
|
+
kwargs = {
|
|
183
|
+
"name": self.name,
|
|
184
|
+
"description": self.description,
|
|
185
|
+
"arguments": arguments,
|
|
186
|
+
}
|
|
187
|
+
return MCPPrompt(**kwargs | overrides)
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Prompt management functionality."""
|
|
2
2
|
|
|
3
|
-
import copy
|
|
4
3
|
from collections.abc import Awaitable, Callable
|
|
5
4
|
from typing import Any
|
|
6
5
|
|
|
7
|
-
from fastmcp.exceptions import
|
|
6
|
+
from fastmcp.exceptions import NotFoundError
|
|
8
7
|
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
|
9
8
|
from fastmcp.settings import DuplicateBehavior
|
|
10
9
|
from fastmcp.utilities.logging import get_logger
|
|
@@ -15,17 +14,28 @@ logger = get_logger(__name__)
|
|
|
15
14
|
class PromptManager:
|
|
16
15
|
"""Manages FastMCP prompts."""
|
|
17
16
|
|
|
18
|
-
def __init__(self, duplicate_behavior: DuplicateBehavior =
|
|
17
|
+
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
|
|
19
18
|
self._prompts: dict[str, Prompt] = {}
|
|
19
|
+
|
|
20
|
+
# Default to "warn" if None is provided
|
|
21
|
+
if duplicate_behavior is None:
|
|
22
|
+
duplicate_behavior = "warn"
|
|
23
|
+
|
|
24
|
+
if duplicate_behavior not in DuplicateBehavior.__args__:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Invalid duplicate_behavior: {duplicate_behavior}. "
|
|
27
|
+
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
|
|
28
|
+
)
|
|
29
|
+
|
|
20
30
|
self.duplicate_behavior = duplicate_behavior
|
|
21
31
|
|
|
22
|
-
def get_prompt(self,
|
|
23
|
-
"""Get prompt by
|
|
24
|
-
return self._prompts.get(
|
|
32
|
+
def get_prompt(self, key: str) -> Prompt | None:
|
|
33
|
+
"""Get prompt by key."""
|
|
34
|
+
return self._prompts.get(key)
|
|
25
35
|
|
|
26
|
-
def
|
|
27
|
-
"""
|
|
28
|
-
return
|
|
36
|
+
def get_prompts(self) -> dict[str, Prompt]:
|
|
37
|
+
"""Get all registered prompts, indexed by registered key."""
|
|
38
|
+
return self._prompts
|
|
29
39
|
|
|
30
40
|
def add_prompt_from_fn(
|
|
31
41
|
self,
|
|
@@ -38,23 +48,24 @@ class PromptManager:
|
|
|
38
48
|
prompt = Prompt.from_function(fn, name=name, description=description, tags=tags)
|
|
39
49
|
return self.add_prompt(prompt)
|
|
40
50
|
|
|
41
|
-
def add_prompt(self, prompt: Prompt) -> Prompt:
|
|
51
|
+
def add_prompt(self, prompt: Prompt, key: str | None = None) -> Prompt:
|
|
42
52
|
"""Add a prompt to the manager."""
|
|
53
|
+
key = key or prompt.name
|
|
43
54
|
|
|
44
55
|
# Check for duplicates
|
|
45
|
-
existing = self._prompts.get(
|
|
56
|
+
existing = self._prompts.get(key)
|
|
46
57
|
if existing:
|
|
47
|
-
if self.duplicate_behavior ==
|
|
48
|
-
logger.warning(f"Prompt already exists: {
|
|
49
|
-
self._prompts[
|
|
50
|
-
elif self.duplicate_behavior ==
|
|
51
|
-
self._prompts[
|
|
52
|
-
elif self.duplicate_behavior ==
|
|
53
|
-
raise ValueError(f"Prompt already exists: {
|
|
54
|
-
elif self.duplicate_behavior ==
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
if self.duplicate_behavior == "warn":
|
|
59
|
+
logger.warning(f"Prompt already exists: {key}")
|
|
60
|
+
self._prompts[key] = prompt
|
|
61
|
+
elif self.duplicate_behavior == "replace":
|
|
62
|
+
self._prompts[key] = prompt
|
|
63
|
+
elif self.duplicate_behavior == "error":
|
|
64
|
+
raise ValueError(f"Prompt already exists: {key}")
|
|
65
|
+
elif self.duplicate_behavior == "ignore":
|
|
66
|
+
return existing
|
|
67
|
+
else:
|
|
68
|
+
self._prompts[key] = prompt
|
|
58
69
|
return prompt
|
|
59
70
|
|
|
60
71
|
async def render_prompt(
|
|
@@ -63,31 +74,10 @@ class PromptManager:
|
|
|
63
74
|
"""Render a prompt by name with arguments."""
|
|
64
75
|
prompt = self.get_prompt(name)
|
|
65
76
|
if not prompt:
|
|
66
|
-
raise
|
|
77
|
+
raise NotFoundError(f"Unknown prompt: {name}")
|
|
67
78
|
|
|
68
79
|
return await prompt.render(arguments)
|
|
69
80
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"""
|
|
74
|
-
Import all prompts from another PromptManager with prefixed names.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
manager: Another PromptManager instance to import prompts from
|
|
78
|
-
prefix: Prefix to add to prompt names. The resulting prompt name will
|
|
79
|
-
be in the format "{prefix}{original_name}" if prefix is provided,
|
|
80
|
-
otherwise the original name is used.
|
|
81
|
-
For example, with prefix "weather/" and prompt "forecast_prompt",
|
|
82
|
-
the imported prompt would be available as "weather/forecast_prompt"
|
|
83
|
-
"""
|
|
84
|
-
for name, prompt in manager._prompts.items():
|
|
85
|
-
# Create prefixed name
|
|
86
|
-
prefixed_name = f"{prefix}{name}" if prefix else name
|
|
87
|
-
|
|
88
|
-
new_prompt = copy.copy(prompt)
|
|
89
|
-
new_prompt.name = prefixed_name
|
|
90
|
-
|
|
91
|
-
# Store the prompt with the prefixed name
|
|
92
|
-
self.add_prompt(new_prompt)
|
|
93
|
-
logger.debug(f'Imported prompt "{name}" as "{prefixed_name}"')
|
|
81
|
+
def has_prompt(self, key: str) -> bool:
|
|
82
|
+
"""Check if a prompt exists."""
|
|
83
|
+
return key in self._prompts
|
fastmcp/resources/resource.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Base classes and interfaces for FastMCP resources."""
|
|
2
2
|
|
|
3
3
|
import abc
|
|
4
|
-
from typing import Annotated
|
|
4
|
+
from typing import Annotated, Any
|
|
5
5
|
|
|
6
|
+
from mcp.types import Resource as MCPResource
|
|
6
7
|
from pydantic import (
|
|
7
8
|
AnyUrl,
|
|
8
9
|
BaseModel,
|
|
@@ -38,6 +39,14 @@ class Resource(BaseModel, abc.ABC):
|
|
|
38
39
|
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
|
|
39
40
|
)
|
|
40
41
|
|
|
42
|
+
@field_validator("mime_type", mode="before")
|
|
43
|
+
@classmethod
|
|
44
|
+
def set_default_mime_type(cls, mime_type: str | None) -> str:
|
|
45
|
+
"""Set default MIME type if not provided."""
|
|
46
|
+
if mime_type:
|
|
47
|
+
return mime_type
|
|
48
|
+
return "text/plain"
|
|
49
|
+
|
|
41
50
|
@field_validator("name", mode="before")
|
|
42
51
|
@classmethod
|
|
43
52
|
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
|
|
@@ -57,3 +66,13 @@ class Resource(BaseModel, abc.ABC):
|
|
|
57
66
|
if not isinstance(other, Resource):
|
|
58
67
|
return False
|
|
59
68
|
return self.model_dump() == other.model_dump()
|
|
69
|
+
|
|
70
|
+
def to_mcp_resource(self, **overrides: Any) -> MCPResource:
|
|
71
|
+
"""Convert the resource to an MCPResource."""
|
|
72
|
+
kwargs = {
|
|
73
|
+
"uri": self.uri,
|
|
74
|
+
"name": self.name,
|
|
75
|
+
"description": self.description,
|
|
76
|
+
"mimeType": self.mime_type,
|
|
77
|
+
}
|
|
78
|
+
return MCPResource(**kwargs | overrides)
|