fastmcp 0.2.0__py3-none-any.whl → 0.3.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/__init__.py CHANGED
@@ -1,2 +1,8 @@
1
- from .server import FastMCP
2
- from .tools import Image
1
+ """FastMCP - A more ergonomic interface for MCP servers."""
2
+
3
+ from importlib.metadata import version
4
+ from .server import FastMCP, Context
5
+ from .utilities.types import Image
6
+
7
+ __version__ = version("fastmcp")
8
+ __all__ = ["FastMCP", "Context", "Image"]
fastmcp/cli/__init__.py CHANGED
@@ -2,4 +2,6 @@
2
2
 
3
3
  from .cli import app
4
4
 
5
- __all__ = ["app"]
5
+
6
+ if __name__ == "__main__":
7
+ app()
fastmcp/cli/claude.py CHANGED
@@ -25,8 +25,8 @@ def get_claude_config_path() -> Path | None:
25
25
 
26
26
 
27
27
  def update_claude_config(
28
- file: Path,
29
- server_name: Optional[str] = None,
28
+ file_spec: str,
29
+ server_name: str,
30
30
  *,
31
31
  with_editable: Optional[Path] = None,
32
32
  with_packages: Optional[list[str]] = None,
@@ -35,9 +35,8 @@ def update_claude_config(
35
35
  """Add the MCP server to Claude's configuration.
36
36
 
37
37
  Args:
38
- file: Path to the server file
39
- server_name: Optional custom name for the server. If not provided,
40
- defaults to the file stem
38
+ file_spec: Path to the server file, optionally with :object suffix
39
+ server_name: Name for the server in Claude's config
41
40
  with_editable: Optional directory to install in editable mode
42
41
  with_packages: Optional list of additional packages to install
43
42
  force: If True, replace existing server with same name
@@ -55,46 +54,49 @@ def update_claude_config(
55
54
  if "mcpServers" not in config:
56
55
  config["mcpServers"] = {}
57
56
 
58
- # Use provided server_name or fall back to file stem
59
- name = server_name or file.stem
60
- if name in config["mcpServers"]:
57
+ if server_name in config["mcpServers"]:
61
58
  if not force:
62
59
  logger.warning(
63
- f"Server '{name}' already exists in Claude config. "
60
+ f"Server '{server_name}' already exists in Claude config. "
64
61
  "Use `--force` to replace.",
65
62
  extra={"config_file": str(config_file)},
66
63
  )
67
64
  return False
68
65
  logger.info(
69
- f"Replacing existing server '{name}' in Claude config",
66
+ f"Replacing existing server '{server_name}' in Claude config",
70
67
  extra={"config_file": str(config_file)},
71
68
  )
72
69
 
73
70
  # Build uv run command
74
- args = ["run"]
71
+ args = ["run", "--with", "fastmcp"]
75
72
 
76
73
  if with_editable:
77
74
  args.extend(["--with-editable", str(with_editable)])
78
75
 
79
- # Always include fastmcp
80
- args.extend(["--with", "fastmcp"])
81
-
82
- # Add additional packages
83
76
  if with_packages:
84
77
  for pkg in with_packages:
85
78
  if pkg:
86
79
  args.extend(["--with", pkg])
87
80
 
88
- args.append(str(file))
81
+ # Convert file path to absolute before adding to command
82
+ # Split off any :object suffix first
83
+ if ":" in file_spec:
84
+ file_path, server_object = file_spec.rsplit(":", 1)
85
+ file_spec = f"{Path(file_path).resolve()}:{server_object}"
86
+ else:
87
+ file_spec = str(Path(file_spec).resolve())
88
+
89
+ # Add fastmcp run command
90
+ args.extend(["fastmcp", "run", file_spec])
89
91
 
90
- config["mcpServers"][name] = {
92
+ config["mcpServers"][server_name] = {
91
93
  "command": "uv",
92
94
  "args": args,
93
95
  }
94
96
 
95
97
  config_file.write_text(json.dumps(config, indent=2))
96
98
  logger.info(
97
- f"Added server '{name}' to Claude config",
99
+ f"Added server '{server_name}' to Claude config",
98
100
  extra={"config_file": str(config_file)},
99
101
  )
100
102
  return True
fastmcp/cli/cli.py CHANGED
@@ -24,11 +24,11 @@ app = typer.Typer(
24
24
 
25
25
 
26
26
  def _build_uv_command(
27
- file: Path,
27
+ file_spec: str,
28
28
  with_editable: Optional[Path] = None,
29
29
  with_packages: Optional[list[str]] = None,
30
30
  ) -> list[str]:
31
- """Build the uv run command."""
31
+ """Build the uv run command that runs a FastMCP server through fastmcp run."""
32
32
  cmd = ["uv"]
33
33
 
34
34
  cmd.extend(["run", "--with", "fastmcp"])
@@ -41,7 +41,8 @@ def _build_uv_command(
41
41
  if pkg:
42
42
  cmd.extend(["--with", pkg])
43
43
 
44
- cmd.append(str(file))
44
+ # Add fastmcp run command
45
+ cmd.extend(["fastmcp", "run", file_spec])
45
46
  return cmd
46
47
 
47
48
 
@@ -89,7 +90,7 @@ def _import_server(file: Path, server_object: Optional[str] = None):
89
90
  module = importlib.util.module_from_spec(spec)
90
91
  spec.loader.exec_module(module)
91
92
 
92
- # If no object specified, try __main__ block
93
+ # If no object specified, try common server names
93
94
  if not server_object:
94
95
  # Look for the most common server object names
95
96
  for name in ["mcp", "server", "app"]:
@@ -97,7 +98,9 @@ def _import_server(file: Path, server_object: Optional[str] = None):
97
98
  return getattr(module, name)
98
99
 
99
100
  logger.error(
100
- f"No server object found in {file}. Please specify the object name with file:object syntax.",
101
+ f"No server object found in {file}. Please either:\n"
102
+ "1. Use a standard variable name (mcp, server, or app)\n"
103
+ "2. Specify the object name with file:object syntax",
101
104
  extra={"file": str(file)},
102
105
  )
103
106
  sys.exit(1)
@@ -178,7 +181,7 @@ def dev(
178
181
  )
179
182
 
180
183
  try:
181
- uv_cmd = _build_uv_command(file, with_editable, with_packages)
184
+ uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
182
185
  # Run the MCP Inspector command
183
186
  process = subprocess.run(
184
187
  ["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
@@ -229,7 +232,12 @@ def run(
229
232
  ),
230
233
  ] = None,
231
234
  ) -> None:
232
- """Run a FastMCP server."""
235
+ """Run a FastMCP server.
236
+
237
+ The server can be specified in two ways:
238
+ 1. Module approach: server.py - runs the module directly, expecting a server.run() call
239
+ 2. Import approach: server.py:app - imports and runs the specified server object
240
+ """
233
241
  file, server_object = _parse_file_path(file_spec)
234
242
 
235
243
  logger.debug(
@@ -338,7 +346,7 @@ def install(
338
346
  name = file.stem
339
347
 
340
348
  if claude.update_claude_config(
341
- file,
349
+ file_spec,
342
350
  name,
343
351
  with_editable=with_editable,
344
352
  with_packages=with_packages,
@@ -348,7 +356,3 @@ def install(
348
356
  else:
349
357
  print(f"Failed to install {name} in Claude app")
350
358
  sys.exit(1)
351
-
352
-
353
- if __name__ == "__main__":
354
- app()
@@ -0,0 +1,4 @@
1
+ from .base import Prompt
2
+ from .manager import PromptManager
3
+
4
+ __all__ = ["Prompt", "PromptManager"]
@@ -0,0 +1,149 @@
1
+ """Base classes for FastMCP prompts."""
2
+
3
+ import json
4
+ from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
5
+ import inspect
6
+
7
+ from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
8
+ from mcp.types import TextContent, ImageContent, EmbeddedResource
9
+ import pydantic_core
10
+
11
+
12
+ class Message(BaseModel):
13
+ """Base class for all prompt messages."""
14
+
15
+ role: Literal["user", "assistant"]
16
+ content: Union[TextContent, ImageContent, EmbeddedResource]
17
+
18
+ def __init__(self, content, **kwargs):
19
+ super().__init__(content=content, **kwargs)
20
+
21
+ @field_validator("content", mode="before")
22
+ def validate_content(cls, v):
23
+ if isinstance(v, str):
24
+ return TextContent(type="text", text=v)
25
+ return v
26
+
27
+
28
+ class UserMessage(Message):
29
+ """A message from the user."""
30
+
31
+ role: Literal["user"] = "user"
32
+
33
+
34
+ class AssistantMessage(Message):
35
+ """A message from the assistant."""
36
+
37
+ role: Literal["assistant"] = "assistant"
38
+
39
+
40
+ message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
41
+
42
+
43
+ class PromptArgument(BaseModel):
44
+ """An argument that can be passed to a prompt."""
45
+
46
+ name: str = Field(description="Name of the argument")
47
+ description: str | None = Field(
48
+ None, description="Description of what the argument does"
49
+ )
50
+ required: bool = Field(
51
+ default=False, description="Whether the argument is required"
52
+ )
53
+
54
+
55
+ class Prompt(BaseModel):
56
+ """A prompt template that can be rendered with parameters."""
57
+
58
+ name: str = Field(description="Name of the prompt")
59
+ description: str | None = Field(
60
+ None, description="Description of what the prompt does"
61
+ )
62
+ arguments: list[PromptArgument] | None = Field(
63
+ None, description="Arguments that can be passed to the prompt"
64
+ )
65
+ fn: Callable = Field(exclude=True)
66
+
67
+ @classmethod
68
+ def from_function(
69
+ cls,
70
+ fn: Callable[..., Sequence[Message]],
71
+ name: Optional[str] = None,
72
+ description: Optional[str] = None,
73
+ ) -> "Prompt":
74
+ """Create a Prompt from a function."""
75
+ func_name = name or fn.__name__
76
+
77
+ if func_name == "<lambda>":
78
+ raise ValueError("You must provide a name for lambda functions")
79
+
80
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
81
+ parameters = TypeAdapter(fn).json_schema()
82
+
83
+ # Convert parameters to PromptArguments
84
+ arguments = []
85
+ if "properties" in parameters:
86
+ for param_name, param in parameters["properties"].items():
87
+ required = param_name in parameters.get("required", [])
88
+ arguments.append(
89
+ PromptArgument(
90
+ name=param_name,
91
+ description=param.get("description"),
92
+ required=required,
93
+ )
94
+ )
95
+
96
+ # ensure the arguments are properly cast
97
+ fn = validate_call(fn)
98
+
99
+ return cls(
100
+ name=func_name,
101
+ description=description or fn.__doc__ or "",
102
+ arguments=arguments,
103
+ fn=fn,
104
+ )
105
+
106
+ async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]:
107
+ """Render the prompt with arguments."""
108
+ # Validate required arguments
109
+ if self.arguments:
110
+ required = {arg.name for arg in self.arguments if arg.required}
111
+ provided = set(arguments or {})
112
+ missing = required - provided
113
+ if missing:
114
+ raise ValueError(f"Missing required arguments: {missing}")
115
+
116
+ try:
117
+ # Call function and check if result is a coroutine
118
+ result = self.fn(**(arguments or {}))
119
+ if inspect.iscoroutine(result):
120
+ result = await result
121
+
122
+ # Validate messages
123
+ if not isinstance(result, (list, tuple)):
124
+ result = [result]
125
+
126
+ # Convert result to messages
127
+ messages = []
128
+ for msg in result:
129
+ try:
130
+ if isinstance(msg, Message):
131
+ messages.append(msg)
132
+ elif isinstance(msg, dict):
133
+ msg = message_validator.validate_python(msg)
134
+ messages.append(msg)
135
+ elif isinstance(msg, str):
136
+ messages.append(
137
+ UserMessage(content=TextContent(type="text", text=msg))
138
+ )
139
+ else:
140
+ msg = json.dumps(pydantic_core.to_jsonable_python(msg))
141
+ messages.append(Message(role="user", content=msg))
142
+ except Exception:
143
+ raise ValueError(
144
+ f"Could not convert prompt result to message: {msg}"
145
+ )
146
+
147
+ return messages
148
+ except Exception as e:
149
+ raise ValueError(f"Error rendering prompt {self.name}: {e}")
@@ -0,0 +1,50 @@
1
+ """Prompt management functionality."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from fastmcp.prompts.base import Message, Prompt
6
+ from fastmcp.utilities.logging import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ class PromptManager:
12
+ """Manages FastMCP prompts."""
13
+
14
+ def __init__(self, warn_on_duplicate_prompts: bool = True):
15
+ self._prompts: Dict[str, Prompt] = {}
16
+ self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
17
+
18
+ def get_prompt(self, name: str) -> Optional[Prompt]:
19
+ """Get prompt by name."""
20
+ return self._prompts.get(name)
21
+
22
+ def list_prompts(self) -> list[Prompt]:
23
+ """List all registered prompts."""
24
+ return list(self._prompts.values())
25
+
26
+ def add_prompt(
27
+ self,
28
+ prompt: Prompt,
29
+ ) -> Prompt:
30
+ """Add a prompt to the manager."""
31
+
32
+ # Check for duplicates
33
+ existing = self._prompts.get(prompt.name)
34
+ if existing:
35
+ if self.warn_on_duplicate_prompts:
36
+ logger.warning(f"Prompt already exists: {prompt.name}")
37
+ return existing
38
+
39
+ self._prompts[prompt.name] = prompt
40
+ return prompt
41
+
42
+ async def render_prompt(
43
+ self, name: str, arguments: Optional[Dict[str, Any]] = None
44
+ ) -> list[Message]:
45
+ """Render a prompt by name with arguments."""
46
+ prompt = self.get_prompt(name)
47
+ if not prompt:
48
+ raise ValueError(f"Unknown prompt: {name}")
49
+
50
+ return await prompt.render(arguments)
@@ -0,0 +1,36 @@
1
+ """Prompt management functionality."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+
6
+ from fastmcp.prompts.base import Prompt
7
+ from fastmcp.utilities.logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ class PromptManager:
13
+ """Manages FastMCP prompts."""
14
+
15
+ def __init__(self, warn_on_duplicate_prompts: bool = True):
16
+ self._prompts: Dict[str, Prompt] = {}
17
+ self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
18
+
19
+ def add_prompt(self, prompt: Prompt) -> Prompt:
20
+ """Add a prompt to the manager."""
21
+ logger.debug(f"Adding prompt: {prompt.name}")
22
+ existing = self._prompts.get(prompt.name)
23
+ if existing:
24
+ if self.warn_on_duplicate_prompts:
25
+ logger.warning(f"Prompt already exists: {prompt.name}")
26
+ return existing
27
+ self._prompts[prompt.name] = prompt
28
+ return prompt
29
+
30
+ def get_prompt(self, name: str) -> Optional[Prompt]:
31
+ """Get prompt by name."""
32
+ return self._prompts.get(name)
33
+
34
+ def list_prompts(self) -> list[Prompt]:
35
+ """List all registered prompts."""
36
+ return list(self._prompts.values())
@@ -0,0 +1,23 @@
1
+ from .base import Resource
2
+ from .types import (
3
+ TextResource,
4
+ BinaryResource,
5
+ FunctionResource,
6
+ FileResource,
7
+ HttpResource,
8
+ DirectoryResource,
9
+ )
10
+ from .templates import ResourceTemplate
11
+ from .resource_manager import ResourceManager
12
+
13
+ __all__ = [
14
+ "Resource",
15
+ "TextResource",
16
+ "BinaryResource",
17
+ "FunctionResource",
18
+ "FileResource",
19
+ "HttpResource",
20
+ "DirectoryResource",
21
+ "ResourceTemplate",
22
+ "ResourceManager",
23
+ ]
@@ -0,0 +1,63 @@
1
+ """Base classes and interfaces for FastMCP resources."""
2
+
3
+ import abc
4
+ from typing import Annotated, Union
5
+
6
+ from pydantic import (
7
+ AnyUrl,
8
+ BaseModel,
9
+ BeforeValidator,
10
+ ConfigDict,
11
+ Field,
12
+ FileUrl,
13
+ ValidationInfo,
14
+ field_validator,
15
+ )
16
+ from pydantic.networks import _BaseUrl # TODO: remove this once pydantic is updated
17
+
18
+
19
+ def maybe_cast_str_to_any_url(x) -> AnyUrl:
20
+ if isinstance(x, FileUrl):
21
+ return x
22
+ elif isinstance(x, AnyUrl):
23
+ return x
24
+ elif isinstance(x, str):
25
+ if x.startswith("file://"):
26
+ return FileUrl(x)
27
+ return AnyUrl(x)
28
+ raise ValueError(f"Expected str or AnyUrl, got {type(x)}")
29
+
30
+
31
+ LaxAnyUrl = Annotated[_BaseUrl | str, BeforeValidator(maybe_cast_str_to_any_url)]
32
+
33
+
34
+ class Resource(BaseModel, abc.ABC):
35
+ """Base class for all resources."""
36
+
37
+ model_config = ConfigDict(validate_default=True)
38
+
39
+ uri: LaxAnyUrl = Field(default=..., description="URI of the resource")
40
+ name: str | None = Field(description="Name of the resource", default=None)
41
+ description: str | None = Field(
42
+ description="Description of the resource", default=None
43
+ )
44
+ mime_type: str = Field(
45
+ default="text/plain",
46
+ description="MIME type of the resource content",
47
+ pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
48
+ )
49
+
50
+ @field_validator("name", mode="before")
51
+ @classmethod
52
+ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
53
+ """Set default name from URI if not provided."""
54
+ if name:
55
+ return name
56
+ if uri := info.data.get("uri"):
57
+ return str(uri)
58
+ raise ValueError("Either name or uri must be provided")
59
+
60
+ @abc.abstractmethod
61
+ async def read(self) -> Union[str, bytes]:
62
+ """Read the resource content."""
63
+ pass
@@ -0,0 +1,94 @@
1
+ """Resource manager functionality."""
2
+
3
+ from typing import Callable, Dict, Optional, Union
4
+
5
+ from pydantic import AnyUrl
6
+
7
+ from fastmcp.resources.base import Resource
8
+ from fastmcp.resources.templates import ResourceTemplate
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class ResourceManager:
15
+ """Manages FastMCP resources."""
16
+
17
+ def __init__(self, warn_on_duplicate_resources: bool = True):
18
+ self._resources: Dict[str, Resource] = {}
19
+ self._templates: Dict[str, ResourceTemplate] = {}
20
+ self.warn_on_duplicate_resources = warn_on_duplicate_resources
21
+
22
+ def add_resource(self, resource: Resource) -> Resource:
23
+ """Add a resource to the manager.
24
+
25
+ Args:
26
+ resource: A Resource instance to add
27
+
28
+ Returns:
29
+ The added resource. If a resource with the same URI already exists,
30
+ returns the existing resource.
31
+ """
32
+ logger.debug(
33
+ "Adding resource",
34
+ extra={
35
+ "uri": resource.uri,
36
+ "type": type(resource).__name__,
37
+ "name": resource.name,
38
+ },
39
+ )
40
+ existing = self._resources.get(str(resource.uri))
41
+ if existing:
42
+ if self.warn_on_duplicate_resources:
43
+ logger.warning(f"Resource already exists: {resource.uri}")
44
+ return existing
45
+ self._resources[str(resource.uri)] = resource
46
+ return resource
47
+
48
+ def add_template(
49
+ self,
50
+ fn: Callable,
51
+ uri_template: str,
52
+ name: Optional[str] = None,
53
+ description: Optional[str] = None,
54
+ mime_type: Optional[str] = None,
55
+ ) -> ResourceTemplate:
56
+ """Add a template from a function."""
57
+ template = ResourceTemplate.from_function(
58
+ fn,
59
+ uri_template=uri_template,
60
+ name=name,
61
+ description=description,
62
+ mime_type=mime_type,
63
+ )
64
+ self._templates[template.uri_template] = template
65
+ return template
66
+
67
+ async def get_resource(self, uri: Union[AnyUrl, str]) -> Optional[Resource]:
68
+ """Get resource by URI, checking concrete resources first, then templates."""
69
+ uri_str = str(uri)
70
+ logger.debug("Getting resource", extra={"uri": uri_str})
71
+
72
+ # First check concrete resources
73
+ if resource := self._resources.get(uri_str):
74
+ return resource
75
+
76
+ # Then check templates
77
+ for template in self._templates.values():
78
+ if params := template.matches(uri_str):
79
+ try:
80
+ return await template.create_resource(uri_str, params)
81
+ except Exception as e:
82
+ raise ValueError(f"Error creating resource from template: {e}")
83
+
84
+ raise ValueError(f"Unknown resource: {uri}")
85
+
86
+ def list_resources(self) -> list[Resource]:
87
+ """List all registered resources."""
88
+ logger.debug("Listing resources", extra={"count": len(self._resources)})
89
+ return list(self._resources.values())
90
+
91
+ def list_templates(self) -> list[ResourceTemplate]:
92
+ """List all registered templates."""
93
+ logger.debug("Listing templates", extra={"count": len(self._templates)})
94
+ return list(self._templates.values())