fastmcp 0.1.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 +1,8 @@
1
- from .server import FastMCP
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,18 +25,21 @@ 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
- uv_directory: Optional[Path] = None,
31
+ with_editable: Optional[Path] = None,
32
+ with_packages: Optional[list[str]] = None,
33
+ force: bool = False,
32
34
  ) -> bool:
33
35
  """Add the MCP server to Claude's configuration.
34
36
 
35
37
  Args:
36
- file: Path to the server file
37
- server_name: Optional custom name for the server. If not provided,
38
- defaults to the file stem
39
- uv_directory: Optional directory containing pyproject.toml
38
+ file_spec: Path to the server file, optionally with :object suffix
39
+ server_name: Name for the server in Claude's config
40
+ with_editable: Optional directory to install in editable mode
41
+ with_packages: Optional list of additional packages to install
42
+ force: If True, replace existing server with same name
40
43
  """
41
44
  config_dir = get_claude_config_path()
42
45
  if not config_dir:
@@ -51,29 +54,49 @@ def update_claude_config(
51
54
  if "mcpServers" not in config:
52
55
  config["mcpServers"] = {}
53
56
 
54
- # Use provided server_name or fall back to file stem
55
- name = server_name or file.stem
56
- if name in config["mcpServers"]:
57
- logger.warning(
58
- f"Server '{name}' already exists in Claude config",
57
+ if server_name in config["mcpServers"]:
58
+ if not force:
59
+ logger.warning(
60
+ f"Server '{server_name}' already exists in Claude config. "
61
+ "Use `--force` to replace.",
62
+ extra={"config_file": str(config_file)},
63
+ )
64
+ return False
65
+ logger.info(
66
+ f"Replacing existing server '{server_name}' in Claude config",
59
67
  extra={"config_file": str(config_file)},
60
68
  )
61
- return False
62
69
 
63
70
  # Build uv run command
64
- args = []
65
- if uv_directory:
66
- args.extend(["--directory", str(uv_directory)])
67
- args.extend(["run", str(file)])
71
+ args = ["run", "--with", "fastmcp"]
68
72
 
69
- config["mcpServers"][name] = {
73
+ if with_editable:
74
+ args.extend(["--with-editable", str(with_editable)])
75
+
76
+ if with_packages:
77
+ for pkg in with_packages:
78
+ if pkg:
79
+ args.extend(["--with", pkg])
80
+
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])
91
+
92
+ config["mcpServers"][server_name] = {
70
93
  "command": "uv",
71
94
  "args": args,
72
95
  }
73
96
 
74
97
  config_file.write_text(json.dumps(config, indent=2))
75
98
  logger.info(
76
- f"Added server '{name}' to Claude config",
99
+ f"Added server '{server_name}' to Claude config",
77
100
  extra={"config_file": str(config_file)},
78
101
  )
79
102
  return True
fastmcp/cli/cli.py CHANGED
@@ -24,16 +24,25 @@ app = typer.Typer(
24
24
 
25
25
 
26
26
  def _build_uv_command(
27
- file: Path,
28
- uv_directory: Optional[Path] = None,
27
+ file_spec: str,
28
+ with_editable: Optional[Path] = None,
29
+ with_packages: Optional[list[str]] = None,
29
30
  ) -> list[str]:
30
- """Build the uv run command."""
31
+ """Build the uv run command that runs a FastMCP server through fastmcp run."""
31
32
  cmd = ["uv"]
32
33
 
33
- if uv_directory:
34
- cmd.extend(["--directory", str(uv_directory)])
34
+ cmd.extend(["run", "--with", "fastmcp"])
35
35
 
36
- cmd.extend(["run", str(file)])
36
+ if with_editable:
37
+ cmd.extend(["--with-editable", str(with_editable)])
38
+
39
+ if with_packages:
40
+ for pkg in with_packages:
41
+ if pkg:
42
+ cmd.extend(["--with", pkg])
43
+
44
+ # Add fastmcp run command
45
+ cmd.extend(["fastmcp", "run", file_spec])
37
46
  return cmd
38
47
 
39
48
 
@@ -81,7 +90,7 @@ def _import_server(file: Path, server_object: Optional[str] = None):
81
90
  module = importlib.util.module_from_spec(spec)
82
91
  spec.loader.exec_module(module)
83
92
 
84
- # If no object specified, try __main__ block
93
+ # If no object specified, try common server names
85
94
  if not server_object:
86
95
  # Look for the most common server object names
87
96
  for name in ["mcp", "server", "app"]:
@@ -89,7 +98,9 @@ def _import_server(file: Path, server_object: Optional[str] = None):
89
98
  return getattr(module, name)
90
99
 
91
100
  logger.error(
92
- 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",
93
104
  extra={"file": str(file)},
94
105
  )
95
106
  sys.exit(1)
@@ -137,17 +148,24 @@ def dev(
137
148
  ...,
138
149
  help="Python file to run, optionally with :object suffix",
139
150
  ),
140
- uv_directory: Annotated[
151
+ with_editable: Annotated[
141
152
  Optional[Path],
142
153
  typer.Option(
143
- "--uv-directory",
144
- "-d",
145
- help="Directory containing pyproject.toml (defaults to current directory)",
154
+ "--with-editable",
155
+ "-e",
156
+ help="Directory containing pyproject.toml to install in editable mode",
146
157
  exists=True,
147
158
  file_okay=False,
148
159
  resolve_path=True,
149
160
  ),
150
161
  ] = None,
162
+ with_packages: Annotated[
163
+ list[str],
164
+ typer.Option(
165
+ "--with",
166
+ help="Additional packages to install",
167
+ ),
168
+ ] = [],
151
169
  ) -> None:
152
170
  """Run a FastMCP server with the MCP Inspector."""
153
171
  file, server_object = _parse_file_path(file_spec)
@@ -157,12 +175,13 @@ def dev(
157
175
  extra={
158
176
  "file": str(file),
159
177
  "server_object": server_object,
160
- "uv_directory": str(uv_directory) if uv_directory else None,
178
+ "with_editable": str(with_editable) if with_editable else None,
179
+ "with_packages": with_packages,
161
180
  },
162
181
  )
163
182
 
164
183
  try:
165
- uv_cmd = _build_uv_command(file, uv_directory)
184
+ uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
166
185
  # Run the MCP Inspector command
167
186
  process = subprocess.run(
168
187
  ["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
@@ -201,19 +220,24 @@ def run(
201
220
  help="Transport protocol to use (stdio or sse)",
202
221
  ),
203
222
  ] = None,
204
- uv_directory: Annotated[
223
+ with_editable: Annotated[
205
224
  Optional[Path],
206
225
  typer.Option(
207
- "--uv-directory",
208
- "-d",
209
- help="Directory containing pyproject.toml (defaults to current directory)",
226
+ "--with-editable",
227
+ "-e",
228
+ help="Directory containing pyproject.toml to install in editable mode",
210
229
  exists=True,
211
230
  file_okay=False,
212
231
  resolve_path=True,
213
232
  ),
214
233
  ] = None,
215
234
  ) -> None:
216
- """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
+ """
217
241
  file, server_object = _parse_file_path(file_spec)
218
242
 
219
243
  logger.debug(
@@ -222,13 +246,11 @@ def run(
222
246
  "file": str(file),
223
247
  "server_object": server_object,
224
248
  "transport": transport,
225
- "uv_directory": str(uv_directory) if uv_directory else None,
249
+ "with_editable": str(with_editable) if with_editable else None,
226
250
  },
227
251
  )
228
252
 
229
253
  try:
230
- uv_cmd = _build_uv_command(file, uv_directory)
231
-
232
254
  # Import and get server object
233
255
  server = _import_server(file, server_object)
234
256
 
@@ -261,20 +283,35 @@ def install(
261
283
  typer.Option(
262
284
  "--name",
263
285
  "-n",
264
- help="Custom name for the server (defaults to file name)",
286
+ help="Custom name for the server (defaults to server's name attribute or file name)",
265
287
  ),
266
288
  ] = None,
267
- uv_directory: Annotated[
289
+ with_editable: Annotated[
268
290
  Optional[Path],
269
291
  typer.Option(
270
- "--uv-directory",
271
- "-d",
272
- help="Directory containing pyproject.toml (defaults to current directory)",
292
+ "--with-editable",
293
+ "-e",
294
+ help="Directory containing pyproject.toml to install in editable mode",
273
295
  exists=True,
274
296
  file_okay=False,
275
297
  resolve_path=True,
276
298
  ),
277
299
  ] = None,
300
+ with_packages: Annotated[
301
+ list[str],
302
+ typer.Option(
303
+ "--with",
304
+ help="Additional packages to install",
305
+ ),
306
+ ] = [],
307
+ force: Annotated[
308
+ bool,
309
+ typer.Option(
310
+ "--force",
311
+ "-f",
312
+ help="Replace existing server if one exists with the same name",
313
+ ),
314
+ ] = False,
278
315
  ) -> None:
279
316
  """Install a FastMCP server in the Claude desktop app."""
280
317
  file, server_object = _parse_file_path(file_spec)
@@ -285,7 +322,9 @@ def install(
285
322
  "file": str(file),
286
323
  "server_name": server_name,
287
324
  "server_object": server_object,
288
- "uv_directory": str(uv_directory) if uv_directory else None,
325
+ "with_editable": str(with_editable) if with_editable else None,
326
+ "with_packages": with_packages,
327
+ "force": force,
289
328
  },
290
329
  )
291
330
 
@@ -293,14 +332,27 @@ def install(
293
332
  logger.error("Claude app not found")
294
333
  sys.exit(1)
295
334
 
296
- if claude.update_claude_config(file, server_name, uv_directory=uv_directory):
297
- name = server_name or file.stem
335
+ # Try to import server to get its name, but fall back to file name if dependencies missing
336
+ name = server_name
337
+ if not name:
338
+ try:
339
+ server = _import_server(file, server_object)
340
+ name = server.name
341
+ except (ImportError, ModuleNotFoundError) as e:
342
+ logger.debug(
343
+ "Could not import server (likely missing dependencies), using file name",
344
+ extra={"error": str(e)},
345
+ )
346
+ name = file.stem
347
+
348
+ if claude.update_claude_config(
349
+ file_spec,
350
+ name,
351
+ with_editable=with_editable,
352
+ with_packages=with_packages,
353
+ force=force,
354
+ ):
298
355
  print(f"Successfully installed {name} in Claude app")
299
356
  else:
300
- name = server_name or file.stem
301
357
  print(f"Failed to install {name} in Claude app")
302
358
  sys.exit(1)
303
-
304
-
305
- if __name__ == "__main__":
306
- 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