fastmcp 2.1.2__py3-none-any.whl → 2.2.1__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 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._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
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
- raise RuntimeError("Client is already connected in an async context.")
89
+ # We're already connected, no need to add None to the session_cms list
90
+ return self
91
+
93
92
  try:
94
- self._session_cm = self.transport.connect_session(**self._session_kwargs)
95
- self._session = await self._session_cm.__aenter__()
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._session_cm = None
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._session_cm:
107
- await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
108
- self._session = None
109
- self._session_cm = None
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.GetPromptResult:
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,
@@ -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 mcp import ClientSession, StdioServerParameters
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
- # create_connected_server_and_client_session manages the session lifecycle itself
386
- async with create_connected_server_and_client_session(
387
- server=self._fastmcp._mcp_server,
388
- **session_kwargs,
389
- ) as session:
390
- yield session
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__ or "",
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 PromptError
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 = DuplicateBehavior.WARN):
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, name: str) -> Prompt | None:
23
- """Get prompt by name."""
24
- return self._prompts.get(name)
32
+ def get_prompt(self, key: str) -> Prompt | None:
33
+ """Get prompt by key."""
34
+ return self._prompts.get(key)
25
35
 
26
- def list_prompts(self) -> list[Prompt]:
27
- """List all registered prompts."""
28
- return list(self._prompts.values())
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(prompt.name)
56
+ existing = self._prompts.get(key)
46
57
  if existing:
47
- if self.duplicate_behavior == DuplicateBehavior.WARN:
48
- logger.warning(f"Prompt already exists: {prompt.name}")
49
- self._prompts[prompt.name] = prompt
50
- elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
51
- self._prompts[prompt.name] = prompt
52
- elif self.duplicate_behavior == DuplicateBehavior.ERROR:
53
- raise ValueError(f"Prompt already exists: {prompt.name}")
54
- elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
55
- pass
56
-
57
- self._prompts[prompt.name] = prompt
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 PromptError(f"Unknown prompt: {name}")
77
+ raise NotFoundError(f"Unknown prompt: {name}")
67
78
 
68
79
  return await prompt.render(arguments)
69
80
 
70
- def import_prompts(
71
- self, manager: "PromptManager", prefix: str | None = None
72
- ) -> None:
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
@@ -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)