Jeremiah Lowin commited on
Commit
b8df5e6
·
1 Parent(s): fe2c69d

Use upstream FastMCP

Browse files
examples/fastapi_converter.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example demonstrating FastAPI to FastMCP conversion."""
2
+
3
+ from fastapi import FastAPI, Body, Path
4
+ from pydantic import BaseModel
5
+ from typing import List, Optional
6
+
7
+ from fastmcp.convert.fastapi import fastapi_to_fastmcp
8
+
9
+
10
+ # Create a sample FastAPI app
11
+ app = FastAPI(title="Todo API")
12
+
13
+
14
+ class TodoItem(BaseModel):
15
+ """A simple todo item."""
16
+
17
+ id: Optional[int] = None
18
+ title: str
19
+ description: str
20
+ completed: bool = False
21
+
22
+
23
+ # In-memory database
24
+ todos = {}
25
+ todo_id_counter = 1
26
+
27
+
28
+ @app.get("/todos", response_model=List[TodoItem])
29
+ async def get_todos():
30
+ """Retrieve all todo items."""
31
+ return list(todos.values())
32
+
33
+
34
+ @app.get("/todos/{todo_id}", response_model=TodoItem)
35
+ async def get_todo(todo_id: int = Path(..., description="The ID of the todo item")):
36
+ """Retrieve a specific todo item by ID."""
37
+ if todo_id not in todos:
38
+ return {"error": f"Todo {todo_id} not found"}
39
+ return todos[todo_id]
40
+
41
+
42
+ @app.post("/todos", response_model=TodoItem)
43
+ async def create_todo(
44
+ todo: TodoItem = Body(..., description="The todo item to create"),
45
+ ):
46
+ """Create a new todo item."""
47
+ global todo_id_counter
48
+ todo.id = todo_id_counter
49
+ todos[todo_id_counter] = todo
50
+ todo_id_counter += 1
51
+ return todo
52
+
53
+
54
+ @app.put("/todos/{todo_id}", response_model=TodoItem)
55
+ async def update_todo(
56
+ todo_id: int = Path(..., description="The ID of the todo item"),
57
+ todo: TodoItem = Body(..., description="The updated todo item"),
58
+ ):
59
+ """Update an existing todo item."""
60
+ if todo_id not in todos:
61
+ return {"error": f"Todo {todo_id} not found"}
62
+
63
+ todo.id = todo_id
64
+ todos[todo_id] = todo
65
+ return todo
66
+
67
+
68
+ @app.delete("/todos/{todo_id}")
69
+ async def delete_todo(todo_id: int = Path(..., description="The ID of the todo item")):
70
+ """Delete a todo item."""
71
+ if todo_id not in todos:
72
+ return {"error": f"Todo {todo_id} not found"}
73
+
74
+ del todos[todo_id]
75
+ return {"message": f"Todo {todo_id} deleted"}
76
+
77
+
78
+ # Convert the FastAPI app to a FastMCP server
79
+ mcp = fastapi_to_fastmcp(app)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ # Run the FastMCP server
84
+ mcp.run()
85
+
86
+ # Alternatively, you can run the original FastAPI app with uvicorn:
87
+ # import uvicorn
88
+ # uvicorn.run(app, host="0.0.0.0", port=8000)
pyproject.toml CHANGED
@@ -1,16 +1,9 @@
1
  [project]
2
  name = "fastmcp"
3
  dynamic = ["version"]
4
- description = "A more ergonomic interface for MCP servers"
5
  authors = [{ name = "Jeremiah Lowin" }]
6
- dependencies = [
7
- "httpx>=0.26.0",
8
- "mcp>=1.0.0,<2.0.0",
9
- "pydantic-settings>=2.6.1",
10
- "pydantic>=2.5.3,<3.0.0",
11
- "typer>=0.9.0",
12
- "python-dotenv>=1.0.1",
13
- ]
14
  requires-python = ">=3.10"
15
  readme = "README.md"
16
  license = { text = "MIT" }
@@ -22,8 +15,8 @@ fastmcp = "fastmcp.cli:app"
22
  requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"]
23
  build-backend = "hatchling.build"
24
 
25
- [project.optional-dependencies]
26
- tests = [
27
  "pre-commit",
28
  "pyright>=1.1.389",
29
  "pytest>=8.3.3",
@@ -31,8 +24,10 @@ tests = [
31
  "pytest-flakefinder",
32
  "pytest-xdist>=3.6.1",
33
  "ruff",
 
 
 
34
  ]
35
- dev = ["fastmcp[tests]", "copychat>=0.5.2", "ipython>=8.12.3", "pdbpp>=0.10.3"]
36
 
37
  [tool.pytest.ini_options]
38
  asyncio_mode = "auto"
 
1
  [project]
2
  name = "fastmcp"
3
  dynamic = ["version"]
4
+ description = "An ergonomic interface for MCP servers"
5
  authors = [{ name = "Jeremiah Lowin" }]
6
+ dependencies = ["mcp>=1.6.0,<2.0.0", "rich>=13.9.4"]
 
 
 
 
 
 
 
7
  requires-python = ">=3.10"
8
  readme = "README.md"
9
  license = { text = "MIT" }
 
15
  requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"]
16
  build-backend = "hatchling.build"
17
 
18
+ [dependency-groups]
19
+ dev = [
20
  "pre-commit",
21
  "pyright>=1.1.389",
22
  "pytest>=8.3.3",
 
24
  "pytest-flakefinder",
25
  "pytest-xdist>=3.6.1",
26
  "ruff",
27
+ "copychat>=0.5.2",
28
+ "ipython>=8.12.3",
29
+ "pdbpp>=0.10.3",
30
  ]
 
31
 
32
  [tool.pytest.ini_options]
33
  asyncio_mode = "auto"
src/fastmcp/__init__.py CHANGED
@@ -1,8 +1,7 @@
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"]
 
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
 
3
  from importlib.metadata import version
4
+ from mcp.server.fastmcp import FastMCP, Context, Image
 
5
 
6
  __version__ = version("fastmcp")
7
  __all__ = ["FastMCP", "Context", "Image"]
src/fastmcp/cli/__init__.py DELETED
@@ -1,7 +0,0 @@
1
- """FastMCP CLI package."""
2
-
3
- from .cli import app
4
-
5
-
6
- if __name__ == "__main__":
7
- app()
 
 
 
 
 
 
 
 
src/fastmcp/cli/claude.py DELETED
@@ -1,138 +0,0 @@
1
- """Claude app integration utilities."""
2
-
3
- import json
4
- import sys
5
- from pathlib import Path
6
- from typing import Optional, Dict
7
-
8
- from ..utilities.logging import get_logger
9
-
10
- logger = get_logger(__name__)
11
-
12
-
13
- def get_claude_config_path() -> Path | None:
14
- """Get the Claude config directory based on platform."""
15
- if sys.platform == "win32":
16
- path = Path(Path.home(), "AppData", "Roaming", "Claude")
17
- elif sys.platform == "darwin":
18
- path = Path(Path.home(), "Library", "Application Support", "Claude")
19
- else:
20
- return None
21
-
22
- if path.exists():
23
- return path
24
- return None
25
-
26
-
27
- def update_claude_config(
28
- file_spec: str,
29
- server_name: str,
30
- *,
31
- with_editable: Optional[Path] = None,
32
- with_packages: Optional[list[str]] = None,
33
- env_vars: Optional[Dict[str, str]] = None,
34
- ) -> bool:
35
- """Add or update a FastMCP server in Claude's configuration.
36
-
37
- Args:
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
- env_vars: Optional dictionary of environment variables. These are merged with
43
- any existing variables, with new values taking precedence.
44
-
45
- Raises:
46
- RuntimeError: If Claude Desktop's config directory is not found, indicating
47
- Claude Desktop may not be installed or properly set up.
48
- """
49
- config_dir = get_claude_config_path()
50
- if not config_dir:
51
- raise RuntimeError(
52
- "Claude Desktop config directory not found. Please ensure Claude Desktop "
53
- "is installed and has been run at least once to initialize its configuration."
54
- )
55
-
56
- config_file = config_dir / "claude_desktop_config.json"
57
- if not config_file.exists():
58
- try:
59
- config_file.write_text("{}")
60
- except Exception as e:
61
- logger.error(
62
- "Failed to create Claude config file",
63
- extra={
64
- "error": str(e),
65
- "config_file": str(config_file),
66
- },
67
- )
68
- return False
69
-
70
- try:
71
- config = json.loads(config_file.read_text())
72
- if "mcpServers" not in config:
73
- config["mcpServers"] = {}
74
-
75
- # Always preserve existing env vars and merge with new ones
76
- if (
77
- server_name in config["mcpServers"]
78
- and "env" in config["mcpServers"][server_name]
79
- ):
80
- existing_env = config["mcpServers"][server_name]["env"]
81
- if env_vars:
82
- # New vars take precedence over existing ones
83
- env_vars = {**existing_env, **env_vars}
84
- else:
85
- env_vars = existing_env
86
-
87
- # Build uv run command
88
- args = ["run"]
89
-
90
- # Collect all packages in a set to deduplicate
91
- packages = {"fastmcp"}
92
- if with_packages:
93
- packages.update(pkg for pkg in with_packages if pkg)
94
-
95
- # Add all packages with --with
96
- for pkg in sorted(packages):
97
- args.extend(["--with", pkg])
98
-
99
- if with_editable:
100
- args.extend(["--with-editable", str(with_editable)])
101
-
102
- # Convert file path to absolute before adding to command
103
- # Split off any :object suffix first
104
- if ":" in file_spec:
105
- file_path, server_object = file_spec.rsplit(":", 1)
106
- file_spec = f"{Path(file_path).resolve()}:{server_object}"
107
- else:
108
- file_spec = str(Path(file_spec).resolve())
109
-
110
- # Add fastmcp run command
111
- args.extend(["fastmcp", "run", file_spec])
112
-
113
- server_config = {
114
- "command": "uv",
115
- "args": args,
116
- }
117
-
118
- # Add environment variables if specified
119
- if env_vars:
120
- server_config["env"] = env_vars
121
-
122
- config["mcpServers"][server_name] = server_config
123
-
124
- config_file.write_text(json.dumps(config, indent=2))
125
- logger.info(
126
- f"Added server '{server_name}' to Claude config",
127
- extra={"config_file": str(config_file)},
128
- )
129
- return True
130
- except Exception as e:
131
- logger.error(
132
- "Failed to update Claude config",
133
- extra={
134
- "error": str(e),
135
- "config_file": str(config_file),
136
- },
137
- )
138
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/cli/cli.py DELETED
@@ -1,452 +0,0 @@
1
- """FastMCP CLI tools."""
2
-
3
- import importlib.metadata
4
- import importlib.util
5
- import os
6
- import subprocess
7
- import sys
8
- from pathlib import Path
9
- from typing import Dict, Optional, Tuple
10
-
11
- import dotenv
12
- import typer
13
- from typing_extensions import Annotated
14
-
15
- from fastmcp.cli import claude
16
- from fastmcp.utilities.logging import get_logger
17
-
18
- logger = get_logger("cli")
19
-
20
- app = typer.Typer(
21
- name="fastmcp",
22
- help="FastMCP development tools",
23
- add_completion=False,
24
- no_args_is_help=True, # Show help if no args provided
25
- )
26
-
27
-
28
- def _get_npx_command():
29
- """Get the correct npx command for the current platform."""
30
- if sys.platform == "win32":
31
- # Try both npx.cmd and npx.exe on Windows
32
- for cmd in ["npx.cmd", "npx.exe", "npx"]:
33
- try:
34
- subprocess.run(
35
- [cmd, "--version"], check=True, capture_output=True, shell=True
36
- )
37
- return cmd
38
- except subprocess.CalledProcessError:
39
- continue
40
- return None
41
- return "npx" # On Unix-like systems, just use npx
42
-
43
-
44
- def _parse_env_var(env_var: str) -> Tuple[str, str]:
45
- """Parse environment variable string in format KEY=VALUE."""
46
- if "=" not in env_var:
47
- logger.error(
48
- f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
49
- )
50
- sys.exit(1)
51
- key, value = env_var.split("=", 1)
52
- return key.strip(), value.strip()
53
-
54
-
55
- def _build_uv_command(
56
- file_spec: str,
57
- with_editable: Optional[Path] = None,
58
- with_packages: Optional[list[str]] = None,
59
- ) -> list[str]:
60
- """Build the uv run command that runs a FastMCP server through fastmcp run."""
61
- cmd = ["uv"]
62
-
63
- cmd.extend(["run", "--with", "fastmcp"])
64
-
65
- if with_editable:
66
- cmd.extend(["--with-editable", str(with_editable)])
67
-
68
- if with_packages:
69
- for pkg in with_packages:
70
- if pkg:
71
- cmd.extend(["--with", pkg])
72
-
73
- # Add fastmcp run command
74
- cmd.extend(["fastmcp", "run", file_spec])
75
- return cmd
76
-
77
-
78
- def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]:
79
- """Parse a file path that may include a server object specification.
80
-
81
- Args:
82
- file_spec: Path to file, optionally with :object suffix
83
-
84
- Returns:
85
- Tuple of (file_path, server_object)
86
- """
87
- # First check if we have a Windows path (e.g., C:\...)
88
- has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"
89
-
90
- # Split on the last colon, but only if it's not part of the Windows drive letter
91
- # and there's actually another colon in the string after the drive letter
92
- if ":" in (file_spec[2:] if has_windows_drive else file_spec):
93
- file_str, server_object = file_spec.rsplit(":", 1)
94
- else:
95
- file_str, server_object = file_spec, None
96
-
97
- # Resolve the file path
98
- file_path = Path(file_str).expanduser().resolve()
99
- if not file_path.exists():
100
- logger.error(f"File not found: {file_path}")
101
- sys.exit(1)
102
- if not file_path.is_file():
103
- logger.error(f"Not a file: {file_path}")
104
- sys.exit(1)
105
-
106
- return file_path, server_object
107
-
108
-
109
- def _import_server(file: Path, server_object: Optional[str] = None):
110
- """Import a FastMCP server from a file.
111
-
112
- Args:
113
- file: Path to the file
114
- server_object: Optional object name in format "module:object" or just "object"
115
-
116
- Returns:
117
- The server object
118
- """
119
- # Add parent directory to Python path so imports can be resolved
120
- file_dir = str(file.parent)
121
- if file_dir not in sys.path:
122
- sys.path.insert(0, file_dir)
123
-
124
- # Import the module
125
- spec = importlib.util.spec_from_file_location("server_module", file)
126
- if not spec or not spec.loader:
127
- logger.error("Could not load module", extra={"file": str(file)})
128
- sys.exit(1)
129
-
130
- module = importlib.util.module_from_spec(spec)
131
- spec.loader.exec_module(module)
132
-
133
- # If no object specified, try common server names
134
- if not server_object:
135
- # Look for the most common server object names
136
- for name in ["mcp", "server", "app"]:
137
- if hasattr(module, name):
138
- return getattr(module, name)
139
-
140
- logger.error(
141
- f"No server object found in {file}. Please either:\n"
142
- "1. Use a standard variable name (mcp, server, or app)\n"
143
- "2. Specify the object name with file:object syntax",
144
- extra={"file": str(file)},
145
- )
146
- sys.exit(1)
147
-
148
- # Handle module:object syntax
149
- if ":" in server_object:
150
- module_name, object_name = server_object.split(":", 1)
151
- try:
152
- server_module = importlib.import_module(module_name)
153
- server = getattr(server_module, object_name, None)
154
- except ImportError:
155
- logger.error(
156
- f"Could not import module '{module_name}'",
157
- extra={"file": str(file)},
158
- )
159
- sys.exit(1)
160
- else:
161
- # Just object name
162
- server = getattr(module, server_object, None)
163
-
164
- if server is None:
165
- logger.error(
166
- f"Server object '{server_object}' not found",
167
- extra={"file": str(file)},
168
- )
169
- sys.exit(1)
170
-
171
- return server
172
-
173
-
174
- @app.command()
175
- def version() -> None:
176
- """Show the FastMCP version."""
177
- try:
178
- version = importlib.metadata.version("fastmcp")
179
- print(f"FastMCP version {version}")
180
- except importlib.metadata.PackageNotFoundError:
181
- print("FastMCP version unknown (package not installed)")
182
- sys.exit(1)
183
-
184
-
185
- @app.command()
186
- def dev(
187
- file_spec: str = typer.Argument(
188
- ...,
189
- help="Python file to run, optionally with :object suffix",
190
- ),
191
- with_editable: Annotated[
192
- Optional[Path],
193
- typer.Option(
194
- "--with-editable",
195
- "-e",
196
- help="Directory containing pyproject.toml to install in editable mode",
197
- exists=True,
198
- file_okay=False,
199
- resolve_path=True,
200
- ),
201
- ] = None,
202
- with_packages: Annotated[
203
- list[str],
204
- typer.Option(
205
- "--with",
206
- help="Additional packages to install",
207
- ),
208
- ] = [],
209
- ) -> None:
210
- """Run a FastMCP server with the MCP Inspector."""
211
- file, server_object = _parse_file_path(file_spec)
212
-
213
- logger.debug(
214
- "Starting dev server",
215
- extra={
216
- "file": str(file),
217
- "server_object": server_object,
218
- "with_editable": str(with_editable) if with_editable else None,
219
- "with_packages": with_packages,
220
- },
221
- )
222
-
223
- try:
224
- # Import server to get dependencies
225
- server = _import_server(file, server_object)
226
- if hasattr(server, "dependencies"):
227
- with_packages = list(set(with_packages + server.dependencies))
228
-
229
- uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
230
-
231
- # Get the correct npx command
232
- npx_cmd = _get_npx_command()
233
- if not npx_cmd:
234
- logger.error(
235
- "npx not found. Please ensure Node.js and npm are properly installed "
236
- "and added to your system PATH."
237
- )
238
- sys.exit(1)
239
-
240
- # Run the MCP Inspector command with shell=True on Windows
241
- shell = sys.platform == "win32"
242
- process = subprocess.run(
243
- [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
244
- check=True,
245
- shell=shell,
246
- env=dict(os.environ.items()), # Convert to list of tuples for env update
247
- )
248
- sys.exit(process.returncode)
249
- except subprocess.CalledProcessError as e:
250
- logger.error(
251
- "Dev server failed",
252
- extra={
253
- "file": str(file),
254
- "error": str(e),
255
- "returncode": e.returncode,
256
- },
257
- )
258
- sys.exit(e.returncode)
259
- except FileNotFoundError:
260
- logger.error(
261
- "npx not found. Please ensure Node.js and npm are properly installed "
262
- "and added to your system PATH. You may need to restart your terminal "
263
- "after installation.",
264
- extra={"file": str(file)},
265
- )
266
- sys.exit(1)
267
-
268
-
269
- @app.command()
270
- def run(
271
- file_spec: str = typer.Argument(
272
- ...,
273
- help="Python file to run, optionally with :object suffix",
274
- ),
275
- transport: Annotated[
276
- Optional[str],
277
- typer.Option(
278
- "--transport",
279
- "-t",
280
- help="Transport protocol to use (stdio or sse)",
281
- ),
282
- ] = None,
283
- ) -> None:
284
- """Run a FastMCP server.
285
-
286
- The server can be specified in two ways:
287
- 1. Module approach: server.py - runs the module directly, expecting a server.run() call
288
- 2. Import approach: server.py:app - imports and runs the specified server object
289
-
290
- Note: This command runs the server directly. You are responsible for ensuring
291
- all dependencies are available. For dependency management, use fastmcp install
292
- or fastmcp dev instead.
293
- """
294
- file, server_object = _parse_file_path(file_spec)
295
-
296
- logger.debug(
297
- "Running server",
298
- extra={
299
- "file": str(file),
300
- "server_object": server_object,
301
- "transport": transport,
302
- },
303
- )
304
-
305
- try:
306
- # Import and get server object
307
- server = _import_server(file, server_object)
308
-
309
- # Run the server
310
- kwargs = {}
311
- if transport:
312
- kwargs["transport"] = transport
313
-
314
- server.run(**kwargs)
315
-
316
- except Exception as e:
317
- logger.error(
318
- f"Failed to run server: {e}",
319
- extra={
320
- "file": str(file),
321
- "error": str(e),
322
- },
323
- )
324
- sys.exit(1)
325
-
326
-
327
- @app.command()
328
- def install(
329
- file_spec: str = typer.Argument(
330
- ...,
331
- help="Python file to run, optionally with :object suffix",
332
- ),
333
- server_name: Annotated[
334
- Optional[str],
335
- typer.Option(
336
- "--name",
337
- "-n",
338
- help="Custom name for the server (defaults to server's name attribute or file name)",
339
- ),
340
- ] = None,
341
- with_editable: Annotated[
342
- Optional[Path],
343
- typer.Option(
344
- "--with-editable",
345
- "-e",
346
- help="Directory containing pyproject.toml to install in editable mode",
347
- exists=True,
348
- file_okay=False,
349
- resolve_path=True,
350
- ),
351
- ] = None,
352
- with_packages: Annotated[
353
- list[str],
354
- typer.Option(
355
- "--with",
356
- help="Additional packages to install",
357
- ),
358
- ] = [],
359
- env_vars: Annotated[
360
- list[str],
361
- typer.Option(
362
- "--env-var",
363
- "-e",
364
- help="Environment variables in KEY=VALUE format",
365
- ),
366
- ] = [],
367
- env_file: Annotated[
368
- Optional[Path],
369
- typer.Option(
370
- "--env-file",
371
- "-f",
372
- help="Load environment variables from a .env file",
373
- exists=True,
374
- file_okay=True,
375
- dir_okay=False,
376
- resolve_path=True,
377
- ),
378
- ] = None,
379
- ) -> None:
380
- """Install a FastMCP server in the Claude desktop app.
381
-
382
- Environment variables are preserved once added and only updated if new values
383
- are explicitly provided.
384
- """
385
- file, server_object = _parse_file_path(file_spec)
386
-
387
- logger.debug(
388
- "Installing server",
389
- extra={
390
- "file": str(file),
391
- "server_name": server_name,
392
- "server_object": server_object,
393
- "with_editable": str(with_editable) if with_editable else None,
394
- "with_packages": with_packages,
395
- },
396
- )
397
-
398
- if not claude.get_claude_config_path():
399
- logger.error("Claude app not found")
400
- sys.exit(1)
401
-
402
- # Try to import server to get its name, but fall back to file name if dependencies missing
403
- name = server_name
404
- server = None
405
- if not name:
406
- try:
407
- server = _import_server(file, server_object)
408
- name = server.name
409
- except (ImportError, ModuleNotFoundError) as e:
410
- logger.debug(
411
- "Could not import server (likely missing dependencies), using file name",
412
- extra={"error": str(e)},
413
- )
414
- name = file.stem
415
-
416
- # Get server dependencies if available
417
- server_dependencies = getattr(server, "dependencies", []) if server else []
418
- if server_dependencies:
419
- with_packages = list(set(with_packages + server_dependencies))
420
-
421
- # Process environment variables if provided
422
- env_dict: Optional[Dict[str, str]] = None
423
- if env_file or env_vars:
424
- env_dict = {}
425
- # Load from .env file if specified
426
- if env_file:
427
- try:
428
- env_dict |= {
429
- k: v
430
- for k, v in dotenv.dotenv_values(env_file).items()
431
- if v is not None
432
- }
433
- except Exception as e:
434
- logger.error(f"Failed to load .env file: {e}")
435
- sys.exit(1)
436
-
437
- # Add command line environment variables
438
- for env_var in env_vars:
439
- key, value = _parse_env_var(env_var)
440
- env_dict[key] = value
441
-
442
- if claude.update_claude_config(
443
- file_spec,
444
- name,
445
- with_editable=with_editable,
446
- with_packages=with_packages,
447
- env_vars=env_dict,
448
- ):
449
- logger.info(f"Successfully installed {name} in Claude app")
450
- else:
451
- logger.error(f"Failed to install {name} in Claude app")
452
- sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/exceptions.py DELETED
@@ -1,21 +0,0 @@
1
- """Custom exceptions for FastMCP."""
2
-
3
-
4
- class FastMCPError(Exception):
5
- """Base error for FastMCP."""
6
-
7
-
8
- class ValidationError(FastMCPError):
9
- """Error in validating parameters or return values."""
10
-
11
-
12
- class ResourceError(FastMCPError):
13
- """Error in resource operations."""
14
-
15
-
16
- class ToolError(FastMCPError):
17
- """Error in tool operations."""
18
-
19
-
20
- class InvalidSignature(Exception):
21
- """Invalid signature for use with FastMCP."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/prompts/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- from .base import Prompt
2
- from .manager import PromptManager
3
-
4
- __all__ = ["Prompt", "PromptManager"]
 
 
 
 
 
src/fastmcp/prompts/base.py DELETED
@@ -1,165 +0,0 @@
1
- """Base classes for FastMCP prompts."""
2
-
3
- import json
4
- from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable
5
- import inspect
6
-
7
- from pydantic import BaseModel, Field, TypeAdapter, validate_call
8
- from mcp.types import TextContent, ImageContent, EmbeddedResource
9
- import pydantic_core
10
-
11
- CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
12
-
13
-
14
- class Message(BaseModel):
15
- """Base class for all prompt messages."""
16
-
17
- role: Literal["user", "assistant"]
18
- content: CONTENT_TYPES
19
-
20
- def __init__(self, content: str | CONTENT_TYPES, **kwargs):
21
- if isinstance(content, str):
22
- content = TextContent(type="text", text=content)
23
- super().__init__(content=content, **kwargs)
24
-
25
-
26
- class UserMessage(Message):
27
- """A message from the user."""
28
-
29
- role: Literal["user"] = "user"
30
-
31
- def __init__(self, content: str | CONTENT_TYPES, **kwargs):
32
- super().__init__(content=content, **kwargs)
33
-
34
-
35
- class AssistantMessage(Message):
36
- """A message from the assistant."""
37
-
38
- role: Literal["assistant"] = "assistant"
39
-
40
- def __init__(self, content: str | CONTENT_TYPES, **kwargs):
41
- super().__init__(content=content, **kwargs)
42
-
43
-
44
- message_validator = TypeAdapter(UserMessage | AssistantMessage)
45
-
46
- SyncPromptResult = (
47
- str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
48
- )
49
- PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
50
-
51
-
52
- class PromptArgument(BaseModel):
53
- """An argument that can be passed to a prompt."""
54
-
55
- name: str = Field(description="Name of the argument")
56
- description: str | None = Field(
57
- None, description="Description of what the argument does"
58
- )
59
- required: bool = Field(
60
- default=False, description="Whether the argument is required"
61
- )
62
-
63
-
64
- class Prompt(BaseModel):
65
- """A prompt template that can be rendered with parameters."""
66
-
67
- name: str = Field(description="Name of the prompt")
68
- description: str | None = Field(
69
- None, description="Description of what the prompt does"
70
- )
71
- arguments: list[PromptArgument] | None = Field(
72
- None, description="Arguments that can be passed to the prompt"
73
- )
74
- fn: Callable = Field(exclude=True)
75
-
76
- @classmethod
77
- def from_function(
78
- cls,
79
- fn: Callable[..., PromptResult],
80
- name: Optional[str] = None,
81
- description: Optional[str] = None,
82
- ) -> "Prompt":
83
- """Create a Prompt from a function.
84
-
85
- The function can return:
86
- - A string (converted to a message)
87
- - A Message object
88
- - A dict (converted to a message)
89
- - A sequence of any of the above
90
- """
91
- func_name = name or fn.__name__
92
-
93
- if func_name == "<lambda>":
94
- raise ValueError("You must provide a name for lambda functions")
95
-
96
- # Get schema from TypeAdapter - will fail if function isn't properly typed
97
- parameters = TypeAdapter(fn).json_schema()
98
-
99
- # Convert parameters to PromptArguments
100
- arguments = []
101
- if "properties" in parameters:
102
- for param_name, param in parameters["properties"].items():
103
- required = param_name in parameters.get("required", [])
104
- arguments.append(
105
- PromptArgument(
106
- name=param_name,
107
- description=param.get("description"),
108
- required=required,
109
- )
110
- )
111
-
112
- # ensure the arguments are properly cast
113
- fn = validate_call(fn)
114
-
115
- return cls(
116
- name=func_name,
117
- description=description or fn.__doc__ or "",
118
- arguments=arguments,
119
- fn=fn,
120
- )
121
-
122
- async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]:
123
- """Render the prompt with arguments."""
124
- # Validate required arguments
125
- if self.arguments:
126
- required = {arg.name for arg in self.arguments if arg.required}
127
- provided = set(arguments or {})
128
- missing = required - provided
129
- if missing:
130
- raise ValueError(f"Missing required arguments: {missing}")
131
-
132
- try:
133
- # Call function and check if result is a coroutine
134
- result = self.fn(**(arguments or {}))
135
- if inspect.iscoroutine(result):
136
- result = await result
137
-
138
- # Validate messages
139
- if not isinstance(result, (list, tuple)):
140
- result = [result]
141
-
142
- # Convert result to messages
143
- messages = []
144
- for msg in result:
145
- try:
146
- if isinstance(msg, Message):
147
- messages.append(msg)
148
- elif isinstance(msg, dict):
149
- msg = message_validator.validate_python(msg)
150
- messages.append(msg)
151
- elif isinstance(msg, str):
152
- messages.append(
153
- UserMessage(content=TextContent(type="text", text=msg))
154
- )
155
- else:
156
- msg = json.dumps(pydantic_core.to_jsonable_python(msg))
157
- messages.append(Message(role="user", content=msg))
158
- except Exception:
159
- raise ValueError(
160
- f"Could not convert prompt result to message: {msg}"
161
- )
162
-
163
- return messages
164
- except Exception as e:
165
- raise ValueError(f"Error rendering prompt {self.name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/prompts/manager.py DELETED
@@ -1,50 +0,0 @@
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/prompts/prompt_manager.py DELETED
@@ -1,36 +0,0 @@
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())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/resources/__init__.py DELETED
@@ -1,23 +0,0 @@
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
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/resources/base.py DELETED
@@ -1,48 +0,0 @@
1
- """Base classes and interfaces for FastMCP resources."""
2
-
3
- import abc
4
- from typing import Union, Annotated
5
-
6
- from pydantic import (
7
- AnyUrl,
8
- BaseModel,
9
- ConfigDict,
10
- Field,
11
- UrlConstraints,
12
- ValidationInfo,
13
- field_validator,
14
- )
15
-
16
-
17
- class Resource(BaseModel, abc.ABC):
18
- """Base class for all resources."""
19
-
20
- model_config = ConfigDict(validate_default=True)
21
-
22
- uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
23
- default=..., description="URI of the resource"
24
- )
25
- name: str | None = Field(description="Name of the resource", default=None)
26
- description: str | None = Field(
27
- description="Description of the resource", default=None
28
- )
29
- mime_type: str = Field(
30
- default="text/plain",
31
- description="MIME type of the resource content",
32
- pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
33
- )
34
-
35
- @field_validator("name", mode="before")
36
- @classmethod
37
- def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
38
- """Set default name from URI if not provided."""
39
- if name:
40
- return name
41
- if uri := info.data.get("uri"):
42
- return str(uri)
43
- raise ValueError("Either name or uri must be provided")
44
-
45
- @abc.abstractmethod
46
- async def read(self) -> Union[str, bytes]:
47
- """Read the resource content."""
48
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/resources/resource_manager.py DELETED
@@ -1,94 +0,0 @@
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())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/resources/templates.py DELETED
@@ -1,80 +0,0 @@
1
- """Resource template functionality."""
2
-
3
- import inspect
4
- import re
5
- from typing import Any, Callable, Dict, Optional
6
-
7
- from pydantic import BaseModel, Field, TypeAdapter, validate_call
8
-
9
- from fastmcp.resources.types import FunctionResource, Resource
10
-
11
-
12
- class ResourceTemplate(BaseModel):
13
- """A template for dynamically creating resources."""
14
-
15
- uri_template: str = Field(
16
- description="URI template with parameters (e.g. weather://{city}/current)"
17
- )
18
- name: str = Field(description="Name of the resource")
19
- description: str | None = Field(description="Description of what the resource does")
20
- mime_type: str = Field(
21
- default="text/plain", description="MIME type of the resource content"
22
- )
23
- fn: Callable = Field(exclude=True)
24
- parameters: dict = Field(description="JSON schema for function parameters")
25
-
26
- @classmethod
27
- def from_function(
28
- cls,
29
- fn: Callable,
30
- uri_template: str,
31
- name: Optional[str] = None,
32
- description: Optional[str] = None,
33
- mime_type: Optional[str] = None,
34
- ) -> "ResourceTemplate":
35
- """Create a template from a function."""
36
- func_name = name or fn.__name__
37
- if func_name == "<lambda>":
38
- raise ValueError("You must provide a name for lambda functions")
39
-
40
- # Get schema from TypeAdapter - will fail if function isn't properly typed
41
- parameters = TypeAdapter(fn).json_schema()
42
-
43
- # ensure the arguments are properly cast
44
- fn = validate_call(fn)
45
-
46
- return cls(
47
- uri_template=uri_template,
48
- name=func_name,
49
- description=description or fn.__doc__ or "",
50
- mime_type=mime_type or "text/plain",
51
- fn=fn,
52
- parameters=parameters,
53
- )
54
-
55
- def matches(self, uri: str) -> Optional[Dict[str, Any]]:
56
- """Check if URI matches template and extract parameters."""
57
- # Convert template to regex pattern
58
- pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
59
- match = re.match(f"^{pattern}$", uri)
60
- if match:
61
- return match.groupdict()
62
- return None
63
-
64
- async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
65
- """Create a resource from the template with the given parameters."""
66
- try:
67
- # Call function and check if result is a coroutine
68
- result = self.fn(**params)
69
- if inspect.iscoroutine(result):
70
- result = await result
71
-
72
- return FunctionResource(
73
- uri=uri, # type: ignore
74
- name=self.name,
75
- description=self.description,
76
- mime_type=self.mime_type,
77
- fn=lambda: result, # Capture result in closure
78
- )
79
- except Exception as e:
80
- raise ValueError(f"Error creating resource from template: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/resources/types.py DELETED
@@ -1,180 +0,0 @@
1
- """Concrete resource implementations."""
2
-
3
- import asyncio
4
- import json
5
- from pathlib import Path
6
- from typing import Any, Callable, Union
7
-
8
- import httpx
9
- import pydantic.json
10
- import pydantic_core
11
- from pydantic import Field, ValidationInfo
12
-
13
- from fastmcp.resources.base import Resource
14
-
15
-
16
- class TextResource(Resource):
17
- """A resource that reads from a string."""
18
-
19
- text: str = Field(description="Text content of the resource")
20
-
21
- async def read(self) -> str:
22
- """Read the text content."""
23
- return self.text
24
-
25
-
26
- class BinaryResource(Resource):
27
- """A resource that reads from bytes."""
28
-
29
- data: bytes = Field(description="Binary content of the resource")
30
-
31
- async def read(self) -> bytes:
32
- """Read the binary content."""
33
- return self.data
34
-
35
-
36
- class FunctionResource(Resource):
37
- """A resource that defers data loading by wrapping a function.
38
-
39
- The function is only called when the resource is read, allowing for lazy loading
40
- of potentially expensive data. This is particularly useful when listing resources,
41
- as the function won't be called until the resource is actually accessed.
42
-
43
- The function can return:
44
- - str for text content (default)
45
- - bytes for binary content
46
- - other types will be converted to JSON
47
- """
48
-
49
- fn: Callable[[], Any] = Field(exclude=True)
50
-
51
- async def read(self) -> Union[str, bytes]:
52
- """Read the resource by calling the wrapped function."""
53
- try:
54
- result = self.fn()
55
- if isinstance(result, Resource):
56
- return await result.read()
57
- if isinstance(result, bytes):
58
- return result
59
- if isinstance(result, str):
60
- return result
61
- try:
62
- return json.dumps(pydantic_core.to_jsonable_python(result))
63
- except (TypeError, pydantic_core.PydanticSerializationError):
64
- # If JSON serialization fails, try str()
65
- return str(result)
66
- except Exception as e:
67
- raise ValueError(f"Error reading resource {self.uri}: {e}")
68
-
69
-
70
- class FileResource(Resource):
71
- """A resource that reads from a file.
72
-
73
- Set is_binary=True to read file as binary data instead of text.
74
- """
75
-
76
- path: Path = Field(description="Path to the file")
77
- is_binary: bool = Field(
78
- default=False,
79
- description="Whether to read the file as binary data",
80
- )
81
- mime_type: str = Field(
82
- default="text/plain",
83
- description="MIME type of the resource content",
84
- )
85
-
86
- @pydantic.field_validator("path")
87
- @classmethod
88
- def validate_absolute_path(cls, path: Path) -> Path:
89
- """Ensure path is absolute."""
90
- if not path.is_absolute():
91
- raise ValueError("Path must be absolute")
92
- return path
93
-
94
- @pydantic.field_validator("is_binary")
95
- @classmethod
96
- def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
97
- """Set is_binary based on mime_type if not explicitly set."""
98
- if is_binary:
99
- return True
100
- mime_type = info.data.get("mime_type", "text/plain")
101
- return not mime_type.startswith("text/")
102
-
103
- async def read(self) -> Union[str, bytes]:
104
- """Read the file content."""
105
- try:
106
- if self.is_binary:
107
- return await asyncio.to_thread(self.path.read_bytes)
108
- return await asyncio.to_thread(self.path.read_text)
109
- except Exception as e:
110
- raise ValueError(f"Error reading file {self.path}: {e}")
111
-
112
-
113
- class HttpResource(Resource):
114
- """A resource that reads from an HTTP endpoint."""
115
-
116
- url: str = Field(description="URL to fetch content from")
117
- mime_type: str | None = Field(
118
- default="application/json", description="MIME type of the resource content"
119
- )
120
-
121
- async def read(self) -> Union[str, bytes]:
122
- """Read the HTTP content."""
123
- async with httpx.AsyncClient() as client:
124
- response = await client.get(self.url)
125
- response.raise_for_status()
126
- return response.text
127
-
128
-
129
- class DirectoryResource(Resource):
130
- """A resource that lists files in a directory."""
131
-
132
- path: Path = Field(description="Path to the directory")
133
- recursive: bool = Field(
134
- default=False, description="Whether to list files recursively"
135
- )
136
- pattern: str | None = Field(
137
- default=None, description="Optional glob pattern to filter files"
138
- )
139
- mime_type: str | None = Field(
140
- default="application/json", description="MIME type of the resource content"
141
- )
142
-
143
- @pydantic.field_validator("path")
144
- @classmethod
145
- def validate_absolute_path(cls, path: Path) -> Path:
146
- """Ensure path is absolute."""
147
- if not path.is_absolute():
148
- raise ValueError("Path must be absolute")
149
- return path
150
-
151
- def list_files(self) -> list[Path]:
152
- """List files in the directory."""
153
- if not self.path.exists():
154
- raise FileNotFoundError(f"Directory not found: {self.path}")
155
- if not self.path.is_dir():
156
- raise NotADirectoryError(f"Not a directory: {self.path}")
157
-
158
- try:
159
- if self.pattern:
160
- return (
161
- list(self.path.glob(self.pattern))
162
- if not self.recursive
163
- else list(self.path.rglob(self.pattern))
164
- )
165
- return (
166
- list(self.path.glob("*"))
167
- if not self.recursive
168
- else list(self.path.rglob("*"))
169
- )
170
- except Exception as e:
171
- raise ValueError(f"Error listing directory {self.path}: {e}")
172
-
173
- async def read(self) -> str: # Always returns JSON string
174
- """Read the directory listing."""
175
- try:
176
- files = await asyncio.to_thread(self.list_files)
177
- file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
178
- return json.dumps({"files": file_list}, indent=2)
179
- except Exception as e:
180
- raise ValueError(f"Error reading directory {self.path}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/server.py CHANGED
@@ -1,671 +1,11 @@
1
- """FastMCP - A more ergonomic interface for MCP servers."""
2
 
3
- import asyncio
4
- import functools
5
- import inspect
6
- import json
7
- import re
8
- from itertools import chain
9
- from typing import Any, Callable, Dict, Literal, Sequence, TypeVar, ParamSpec
10
-
11
- import pydantic_core
12
- from pydantic import Field
13
- import uvicorn
14
- from mcp.server import Server as MCPServer
15
- from mcp.server.sse import SseServerTransport
16
- from mcp.server.stdio import stdio_server
17
- from mcp.shared.context import RequestContext
18
- from mcp.types import (
19
- EmbeddedResource,
20
- GetPromptResult,
21
- ImageContent,
22
- TextContent,
23
- )
24
- from mcp.types import (
25
- Prompt as MCPPrompt,
26
- PromptArgument as MCPPromptArgument,
27
- )
28
- from mcp.types import (
29
- Resource as MCPResource,
30
- )
31
- from mcp.types import (
32
- ResourceTemplate as MCPResourceTemplate,
33
- )
34
- from mcp.types import (
35
- Tool as MCPTool,
36
- )
37
- from pydantic import BaseModel
38
- from pydantic.networks import AnyUrl
39
- from pydantic_settings import BaseSettings, SettingsConfigDict
40
-
41
- from fastmcp.exceptions import ResourceError
42
- from fastmcp.prompts import Prompt, PromptManager
43
- from fastmcp.prompts.base import PromptResult
44
- from fastmcp.resources import FunctionResource, Resource, ResourceManager
45
- from fastmcp.tools import ToolManager
46
- from fastmcp.utilities.logging import configure_logging, get_logger
47
- from fastmcp.utilities.types import Image
48
 
49
  logger = get_logger(__name__)
50
 
51
- P = ParamSpec("P")
52
- R = TypeVar("R")
53
- R_PromptResult = TypeVar("R_PromptResult", bound=PromptResult)
54
-
55
-
56
- class Settings(BaseSettings):
57
- """FastMCP server settings.
58
-
59
- All settings can be configured via environment variables with the prefix FASTMCP_.
60
- For example, FASTMCP_DEBUG=true will set debug=True.
61
- """
62
-
63
- model_config: SettingsConfigDict = SettingsConfigDict(
64
- env_prefix="FASTMCP_",
65
- env_file=".env",
66
- extra="ignore",
67
- )
68
-
69
- # Server settings
70
- debug: bool = False
71
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
72
 
73
- # HTTP settings
74
- host: str = "0.0.0.0"
75
- port: int = 8000
76
-
77
- # resource settings
78
- warn_on_duplicate_resources: bool = True
79
-
80
- # tool settings
81
- warn_on_duplicate_tools: bool = True
82
-
83
- # prompt settings
84
- warn_on_duplicate_prompts: bool = True
85
-
86
- dependencies: list[str] = Field(
87
- default_factory=list,
88
- description="List of dependencies to install in the server environment",
89
- )
90
-
91
-
92
- class FastMCP:
93
  def __init__(self, name: str | None = None, **settings: Any):
94
- self.settings = Settings(**settings)
95
- self._mcp_server = MCPServer(name=name or "FastMCP")
96
- self._tool_manager = ToolManager(
97
- warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
98
- )
99
- self._resource_manager = ResourceManager(
100
- warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
101
- )
102
- self._prompt_manager = PromptManager(
103
- warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
104
- )
105
- self.dependencies = self.settings.dependencies
106
-
107
- # Set up MCP protocol handlers
108
- self._setup_handlers()
109
-
110
- # Configure logging
111
- configure_logging(self.settings.log_level)
112
-
113
- @property
114
- def name(self) -> str:
115
- return self._mcp_server.name
116
-
117
- def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
118
- """Run the FastMCP server. Note this is a synchronous function.
119
-
120
- Args:
121
- transport: Transport protocol to use ("stdio" or "sse")
122
- """
123
- TRANSPORTS = Literal["stdio", "sse"]
124
- if transport not in TRANSPORTS.__args__: # type: ignore
125
- raise ValueError(f"Unknown transport: {transport}")
126
-
127
- if transport == "stdio":
128
- asyncio.run(self.run_stdio_async())
129
- else: # transport == "sse"
130
- asyncio.run(self.run_sse_async())
131
-
132
- def _setup_handlers(self) -> None:
133
- """Set up core MCP protocol handlers."""
134
- self._mcp_server.list_tools()(self.list_tools)
135
- self._mcp_server.call_tool()(self.call_tool)
136
- self._mcp_server.list_resources()(self.list_resources)
137
- self._mcp_server.read_resource()(self.read_resource)
138
- self._mcp_server.list_prompts()(self.list_prompts)
139
- self._mcp_server.get_prompt()(self.get_prompt)
140
- # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
141
- # self._mcp_server.list_resource_templates()(self.list_resource_templates)
142
-
143
- async def list_tools(self) -> list[MCPTool]:
144
- """List all available tools."""
145
- tools = self._tool_manager.list_tools()
146
- return [
147
- MCPTool(
148
- name=info.name,
149
- description=info.description,
150
- inputSchema=info.parameters,
151
- )
152
- for info in tools
153
- ]
154
-
155
- def get_context(self) -> "Context":
156
- """
157
- Returns a Context object. Note that the context will only be valid
158
- during a request; outside a request, most methods will error.
159
- """
160
- try:
161
- request_context = self._mcp_server.request_context
162
- except LookupError:
163
- request_context = None
164
- return Context(request_context=request_context, fastmcp=self)
165
-
166
- async def call_tool(
167
- self, name: str, arguments: dict
168
- ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
169
- """Call a tool by name with arguments."""
170
- context = self.get_context()
171
- result = await self._tool_manager.call_tool(name, arguments, context=context)
172
- converted_result = _convert_to_content(result)
173
- return converted_result
174
-
175
- async def list_resources(self) -> list[MCPResource]:
176
- """List all available resources."""
177
-
178
- resources = self._resource_manager.list_resources()
179
- return [
180
- MCPResource(
181
- uri=resource.uri,
182
- name=resource.name or "",
183
- description=resource.description,
184
- mimeType=resource.mime_type,
185
- )
186
- for resource in resources
187
- ]
188
-
189
- async def list_resource_templates(self) -> list[MCPResourceTemplate]:
190
- templates = self._resource_manager.list_templates()
191
- return [
192
- MCPResourceTemplate(
193
- uriTemplate=template.uri_template,
194
- name=template.name,
195
- description=template.description,
196
- )
197
- for template in templates
198
- ]
199
-
200
- async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
201
- """Read a resource by URI."""
202
- resource = await self._resource_manager.get_resource(uri)
203
- if not resource:
204
- raise ResourceError(f"Unknown resource: {uri}")
205
-
206
- try:
207
- return await resource.read()
208
- except Exception as e:
209
- logger.error(f"Error reading resource {uri}: {e}")
210
- raise ResourceError(str(e))
211
-
212
- def add_tool(
213
- self,
214
- fn: Callable,
215
- name: str | None = None,
216
- description: str | None = None,
217
- ) -> None:
218
- """Add a tool to the server.
219
-
220
- The tool function can optionally request a Context object by adding a parameter
221
- with the Context type annotation. See the @tool decorator for examples.
222
-
223
- Args:
224
- fn: The function to register as a tool
225
- name: Optional name for the tool (defaults to function name)
226
- description: Optional description of what the tool does
227
- """
228
- self._tool_manager.add_tool(fn, name=name, description=description)
229
-
230
- def tool(
231
- self, name: str | None = None, description: str | None = None
232
- ) -> Callable[[Callable[P, R]], Callable[P, R]]:
233
- """Decorator to register a tool.
234
-
235
- Tools can optionally request a Context object by adding a parameter with the Context type annotation.
236
- The context provides access to MCP capabilities like logging, progress reporting, and resource access.
237
-
238
- Args:
239
- name: Optional name for the tool (defaults to function name)
240
- description: Optional description of what the tool does
241
-
242
- Example:
243
- @server.tool()
244
- def my_tool(x: int) -> str:
245
- return str(x)
246
-
247
- @server.tool()
248
- def tool_with_context(x: int, ctx: Context) -> str:
249
- ctx.info(f"Processing {x}")
250
- return str(x)
251
-
252
- @server.tool()
253
- async def async_tool(x: int, context: Context) -> str:
254
- await context.report_progress(50, 100)
255
- return str(x)
256
- """
257
- # Check if user passed function directly instead of calling decorator
258
- if callable(name):
259
- raise TypeError(
260
- "The @tool decorator was used incorrectly. "
261
- "Did you forget to call it? Use @tool() instead of @tool"
262
- )
263
-
264
- def decorator(fn: Callable[P, R]) -> Callable[P, R]:
265
- self.add_tool(fn, name=name, description=description)
266
- return fn
267
-
268
- return decorator
269
-
270
- def add_resource(self, resource: Resource) -> None:
271
- """Add a resource to the server.
272
-
273
- Args:
274
- resource: A Resource instance to add
275
- """
276
- self._resource_manager.add_resource(resource)
277
-
278
- def resource(
279
- self,
280
- uri: str,
281
- *,
282
- name: str | None = None,
283
- description: str | None = None,
284
- mime_type: str | None = None,
285
- ) -> Callable[[Callable[P, R]], Callable[P, R]]:
286
- """Decorator to register a function as a resource.
287
-
288
- The function will be called when the resource is read to generate its content.
289
- The function can return:
290
- - str for text content
291
- - bytes for binary content
292
- - other types will be converted to JSON
293
-
294
- If the URI contains parameters (e.g. "resource://{param}") or the function
295
- has parameters, it will be registered as a template resource.
296
-
297
- Args:
298
- uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
299
- name: Optional name for the resource
300
- description: Optional description of the resource
301
- mime_type: Optional MIME type for the resource
302
-
303
- Example:
304
- @server.resource("resource://my-resource")
305
- def get_data() -> str:
306
- return "Hello, world!"
307
-
308
- @server.resource("resource://{city}/weather")
309
- def get_weather(city: str) -> str:
310
- return f"Weather for {city}"
311
- """
312
- # Check if user passed function directly instead of calling decorator
313
- if callable(uri):
314
- raise TypeError(
315
- "The @resource decorator was used incorrectly. "
316
- "Did you forget to call it? Use @resource('uri') instead of @resource"
317
- )
318
-
319
- def decorator(fn: Callable[P, R]) -> Callable[P, R]:
320
- @functools.wraps(fn)
321
- def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
322
- return fn(*args, **kwargs)
323
-
324
- # Check if this should be a template
325
- has_uri_params = "{" in uri and "}" in uri
326
- has_func_params = bool(inspect.signature(fn).parameters)
327
-
328
- if has_uri_params or has_func_params:
329
- # Validate that URI params match function params
330
- uri_params = set(re.findall(r"{(\w+)}", uri))
331
- func_params = set(inspect.signature(fn).parameters.keys())
332
-
333
- if uri_params != func_params:
334
- raise ValueError(
335
- f"Mismatch between URI parameters {uri_params} "
336
- f"and function parameters {func_params}"
337
- )
338
-
339
- # Register as template
340
- self._resource_manager.add_template(
341
- wrapper,
342
- uri_template=uri,
343
- name=name,
344
- description=description,
345
- mime_type=mime_type or "text/plain",
346
- )
347
- else:
348
- # Register as regular resource
349
- resource = FunctionResource(
350
- uri=AnyUrl(uri),
351
- name=name,
352
- description=description,
353
- mime_type=mime_type or "text/plain",
354
- fn=wrapper,
355
- )
356
- self.add_resource(resource)
357
- return wrapper
358
-
359
- return decorator
360
-
361
- def add_prompt(self, prompt: Prompt) -> None:
362
- """Add a prompt to the server.
363
-
364
- Args:
365
- prompt: A Prompt instance to add
366
- """
367
- self._prompt_manager.add_prompt(prompt)
368
-
369
- def prompt(
370
- self, name: str | None = None, description: str | None = None
371
- ) -> Callable[[Callable[P, R_PromptResult]], Callable[P, R_PromptResult]]:
372
- """Decorator to register a prompt.
373
-
374
- Args:
375
- name: Optional name for the prompt (defaults to function name)
376
- description: Optional description of what the prompt does
377
-
378
- Example:
379
- @server.prompt()
380
- def analyze_table(table_name: str) -> list[Message]:
381
- schema = read_table_schema(table_name)
382
- return [
383
- {
384
- "role": "user",
385
- "content": f"Analyze this schema:\n{schema}"
386
- }
387
- ]
388
-
389
- @server.prompt()
390
- async def analyze_file(path: str) -> list[Message]:
391
- content = await read_file(path)
392
- return [
393
- {
394
- "role": "user",
395
- "content": {
396
- "type": "resource",
397
- "resource": {
398
- "uri": f"file://{path}",
399
- "text": content
400
- }
401
- }
402
- }
403
- ]
404
- """
405
- # Check if user passed function directly instead of calling decorator
406
- if callable(name):
407
- raise TypeError(
408
- "The @prompt decorator was used incorrectly. "
409
- "Did you forget to call it? Use @prompt() instead of @prompt"
410
- )
411
-
412
- def decorator(func: Callable[P, R_PromptResult]) -> Callable[P, R_PromptResult]:
413
- prompt = Prompt.from_function(func, name=name, description=description)
414
- self.add_prompt(prompt)
415
- return func
416
-
417
- return decorator
418
-
419
- async def run_stdio_async(self) -> None:
420
- """Run the server using stdio transport."""
421
- async with stdio_server() as (read_stream, write_stream):
422
- await self._mcp_server.run(
423
- read_stream,
424
- write_stream,
425
- self._mcp_server.create_initialization_options(),
426
- )
427
-
428
- async def run_sse_async(self) -> None:
429
- """Run the server using SSE transport."""
430
- from starlette.applications import Starlette
431
- from starlette.routing import Route, Mount
432
-
433
- sse = SseServerTransport("/messages/")
434
-
435
- async def handle_sse(request):
436
- async with sse.connect_sse(
437
- request.scope, request.receive, request._send
438
- ) as streams:
439
- await self._mcp_server.run(
440
- streams[0],
441
- streams[1],
442
- self._mcp_server.create_initialization_options(),
443
- )
444
-
445
- starlette_app = Starlette(
446
- debug=self.settings.debug,
447
- routes=[
448
- Route("/sse", endpoint=handle_sse),
449
- Mount("/messages/", app=sse.handle_post_message),
450
- ],
451
- )
452
-
453
- config = uvicorn.Config(
454
- starlette_app,
455
- host=self.settings.host,
456
- port=self.settings.port,
457
- log_level=self.settings.log_level.lower(),
458
- )
459
- server = uvicorn.Server(config)
460
- await server.serve()
461
-
462
- async def list_prompts(self) -> list[MCPPrompt]:
463
- """List all available prompts."""
464
- prompts = self._prompt_manager.list_prompts()
465
- return [
466
- MCPPrompt(
467
- name=prompt.name,
468
- description=prompt.description,
469
- arguments=[
470
- MCPPromptArgument(
471
- name=arg.name,
472
- description=arg.description,
473
- required=arg.required,
474
- )
475
- for arg in (prompt.arguments or [])
476
- ],
477
- )
478
- for prompt in prompts
479
- ]
480
-
481
- async def get_prompt(
482
- self, name: str, arguments: Dict[str, Any] | None = None
483
- ) -> GetPromptResult:
484
- """Get a prompt by name with arguments."""
485
- try:
486
- messages = await self._prompt_manager.render_prompt(name, arguments)
487
-
488
- return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
489
- except Exception as e:
490
- logger.error(f"Error getting prompt {name}: {e}")
491
- raise ValueError(str(e))
492
-
493
-
494
- def _convert_to_content(
495
- result: Any,
496
- ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
497
- """Convert a result to a sequence of content objects."""
498
- if result is None:
499
- return []
500
-
501
- if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
502
- return [result]
503
-
504
- if isinstance(result, Image):
505
- return [result.to_image_content()]
506
-
507
- if isinstance(result, (list, tuple)):
508
- return list(chain.from_iterable(_convert_to_content(item) for item in result))
509
-
510
- if not isinstance(result, str):
511
- try:
512
- result = json.dumps(pydantic_core.to_jsonable_python(result))
513
- except Exception:
514
- result = str(result)
515
-
516
- return [TextContent(type="text", text=result)]
517
-
518
-
519
- class Context(BaseModel):
520
- """Context object providing access to MCP capabilities.
521
-
522
- This provides a cleaner interface to MCP's RequestContext functionality.
523
- It gets injected into tool and resource functions that request it via type hints.
524
-
525
- To use context in a tool function, add a parameter with the Context type annotation:
526
-
527
- ```python
528
- @server.tool()
529
- def my_tool(x: int, ctx: Context) -> str:
530
- # Log messages to the client
531
- ctx.info(f"Processing {x}")
532
- ctx.debug("Debug info")
533
- ctx.warning("Warning message")
534
- ctx.error("Error message")
535
-
536
- # Report progress
537
- ctx.report_progress(50, 100)
538
-
539
- # Access resources
540
- data = ctx.read_resource("resource://data")
541
-
542
- # Get request info
543
- request_id = ctx.request_id
544
- client_id = ctx.client_id
545
-
546
- return str(x)
547
- ```
548
-
549
- The context parameter name can be anything as long as it's annotated with Context.
550
- The context is optional - tools that don't need it can omit the parameter.
551
- """
552
-
553
- _request_context: RequestContext | None
554
- _fastmcp: FastMCP | None
555
-
556
- def __init__(
557
- self,
558
- *,
559
- request_context: RequestContext | None = None,
560
- fastmcp: FastMCP | None = None,
561
- **kwargs: Any,
562
- ):
563
- super().__init__(**kwargs)
564
- self._request_context = request_context
565
- self._fastmcp = fastmcp
566
-
567
- @property
568
- def fastmcp(self) -> FastMCP:
569
- """Access to the FastMCP server."""
570
- if self._fastmcp is None:
571
- raise ValueError("Context is not available outside of a request")
572
- return self._fastmcp
573
-
574
- @property
575
- def request_context(self) -> RequestContext:
576
- """Access to the underlying request context."""
577
- if self._request_context is None:
578
- raise ValueError("Context is not available outside of a request")
579
- return self._request_context
580
-
581
- async def report_progress(
582
- self, progress: float, total: float | None = None
583
- ) -> None:
584
- """Report progress for the current operation.
585
-
586
- Args:
587
- progress: Current progress value e.g. 24
588
- total: Optional total value e.g. 100
589
- """
590
-
591
- progress_token = (
592
- self.request_context.meta.progressToken
593
- if self.request_context.meta
594
- else None
595
- )
596
-
597
- if not progress_token:
598
- return
599
-
600
- await self.request_context.session.send_progress_notification(
601
- progress_token=progress_token, progress=progress, total=total
602
- )
603
-
604
- async def read_resource(self, uri: str | AnyUrl) -> str | bytes:
605
- """Read a resource by URI.
606
-
607
- Args:
608
- uri: Resource URI to read
609
-
610
- Returns:
611
- The resource content as either text or bytes
612
- """
613
- assert (
614
- self._fastmcp is not None
615
- ), "Context is not available outside of a request"
616
- return await self._fastmcp.read_resource(uri)
617
-
618
- def log(
619
- self,
620
- level: Literal["debug", "info", "warning", "error"],
621
- message: str,
622
- *,
623
- logger_name: str | None = None,
624
- ) -> None:
625
- """Send a log message to the client.
626
-
627
- Args:
628
- level: Log level (debug, info, warning, error)
629
- message: Log message
630
- logger_name: Optional logger name
631
- **extra: Additional structured data to include
632
- """
633
- self.request_context.session.send_log_message(
634
- level=level, data=message, logger=logger_name
635
- )
636
-
637
- @property
638
- def client_id(self) -> str | None:
639
- """Get the client ID if available."""
640
- return (
641
- getattr(self.request_context.meta, "client_id", None)
642
- if self.request_context.meta
643
- else None
644
- )
645
-
646
- @property
647
- def request_id(self) -> str:
648
- """Get the unique ID for this request."""
649
- return str(self.request_context.request_id)
650
-
651
- @property
652
- def session(self):
653
- """Access to the underlying session for advanced usage."""
654
- return self.request_context.session
655
-
656
- # Convenience methods for common log levels
657
- def debug(self, message: str, **extra: Any) -> None:
658
- """Send a debug log message."""
659
- self.log("debug", message, **extra)
660
-
661
- def info(self, message: str, **extra: Any) -> None:
662
- """Send an info log message."""
663
- self.log("info", message, **extra)
664
-
665
- def warning(self, message: str, **extra: Any) -> None:
666
- """Send a warning log message."""
667
- self.log("warning", message, **extra)
668
-
669
- def error(self, message: str, **extra: Any) -> None:
670
- """Send an error log message."""
671
- self.log("error", message, **extra)
 
1
+ from typing import Any
2
 
3
+ import mcp.server.fastmcp
4
+ from fastmcp.utilities.logging import get_logger
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  logger = get_logger(__name__)
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ class FastMCP(mcp.server.fastmcp.FastMCP):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def __init__(self, name: str | None = None, **settings: Any):
11
+ super().__init__(name=name or "FastMCP", **settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/settings.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import Field
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ from typing import Literal
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """FastMCP server settings.
10
+
11
+ All settings can be configured via environment variables with the prefix FASTMCP_.
12
+ For example, FASTMCP_DEBUG=true will set debug=True.
13
+ """
14
+
15
+ model_config: SettingsConfigDict = SettingsConfigDict(
16
+ env_prefix="FASTMCP_",
17
+ env_file=".env",
18
+ extra="ignore",
19
+ )
20
+
21
+ # Server settings
22
+ debug: bool = False
23
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
24
+
25
+ # HTTP settings
26
+ host: str = "0.0.0.0"
27
+ port: int = 8000
28
+
29
+ # resource settings
30
+ warn_on_duplicate_resources: bool = True
31
+
32
+ # tool settings
33
+ warn_on_duplicate_tools: bool = True
34
+
35
+ # prompt settings
36
+ warn_on_duplicate_prompts: bool = True
37
+
38
+ dependencies: list[str] = Field(
39
+ default_factory=list,
40
+ description="List of dependencies to install in the server environment",
41
+ )
src/fastmcp/tools/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- from .base import Tool
2
- from .tool_manager import ToolManager
3
-
4
- __all__ = ["Tool", "ToolManager"]
 
 
 
 
 
src/fastmcp/tools/base.py DELETED
@@ -1,83 +0,0 @@
1
- import fastmcp
2
- from fastmcp.exceptions import ToolError
3
-
4
- from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata
5
- from pydantic import BaseModel, Field
6
-
7
-
8
- import inspect
9
- from typing import TYPE_CHECKING, Any, Callable, Optional
10
-
11
- if TYPE_CHECKING:
12
- from fastmcp.server import Context
13
-
14
-
15
- class Tool(BaseModel):
16
- """Internal tool registration info."""
17
-
18
- fn: Callable = Field(exclude=True)
19
- name: str = Field(description="Name of the tool")
20
- description: str = Field(description="Description of what the tool does")
21
- parameters: dict = Field(description="JSON schema for tool parameters")
22
- fn_metadata: FuncMetadata = Field(
23
- description="Metadata about the function including a pydantic model for tool arguments"
24
- )
25
- is_async: bool = Field(description="Whether the tool is async")
26
- context_kwarg: Optional[str] = Field(
27
- None, description="Name of the kwarg that should receive context"
28
- )
29
-
30
- @classmethod
31
- def from_function(
32
- cls,
33
- fn: Callable,
34
- name: Optional[str] = None,
35
- description: Optional[str] = None,
36
- context_kwarg: Optional[str] = None,
37
- ) -> "Tool":
38
- """Create a Tool from a function."""
39
- func_name = name or fn.__name__
40
-
41
- if func_name == "<lambda>":
42
- raise ValueError("You must provide a name for lambda functions")
43
-
44
- func_doc = description or fn.__doc__ or ""
45
- is_async = inspect.iscoroutinefunction(fn)
46
-
47
- # Find context parameter if it exists
48
- if context_kwarg is None:
49
- sig = inspect.signature(fn)
50
- for param_name, param in sig.parameters.items():
51
- if param.annotation is fastmcp.Context:
52
- context_kwarg = param_name
53
- break
54
-
55
- func_arg_metadata = func_metadata(
56
- fn,
57
- skip_names=[context_kwarg] if context_kwarg is not None else [],
58
- )
59
- parameters = func_arg_metadata.arg_model.model_json_schema()
60
-
61
- return cls(
62
- fn=fn,
63
- name=func_name,
64
- description=func_doc,
65
- parameters=parameters,
66
- fn_metadata=func_arg_metadata,
67
- is_async=is_async,
68
- context_kwarg=context_kwarg,
69
- )
70
-
71
- async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any:
72
- """Run the tool with arguments."""
73
- try:
74
- return await self.fn_metadata.call_fn_with_arg_validation(
75
- self.fn,
76
- self.is_async,
77
- arguments,
78
- {self.context_kwarg: context}
79
- if self.context_kwarg is not None
80
- else None,
81
- )
82
- except Exception as e:
83
- raise ToolError(f"Error executing tool {self.name}: {e}") from e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/tools/tool_manager.py DELETED
@@ -1,55 +0,0 @@
1
- from fastmcp.exceptions import ToolError
2
-
3
- from fastmcp.tools.base import Tool
4
-
5
-
6
- from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
7
-
8
- from fastmcp.utilities.logging import get_logger
9
-
10
- if TYPE_CHECKING:
11
- from fastmcp.server import Context
12
-
13
- logger = get_logger(__name__)
14
-
15
-
16
- class ToolManager:
17
- """Manages FastMCP tools."""
18
-
19
- def __init__(self, warn_on_duplicate_tools: bool = True):
20
- self._tools: Dict[str, Tool] = {}
21
- self.warn_on_duplicate_tools = warn_on_duplicate_tools
22
-
23
- def get_tool(self, name: str) -> Optional[Tool]:
24
- """Get tool by name."""
25
- return self._tools.get(name)
26
-
27
- def list_tools(self) -> list[Tool]:
28
- """List all registered tools."""
29
- return list(self._tools.values())
30
-
31
- def add_tool(
32
- self,
33
- fn: Callable,
34
- name: Optional[str] = None,
35
- description: Optional[str] = None,
36
- ) -> Tool:
37
- """Add a tool to the server."""
38
- tool = Tool.from_function(fn, name=name, description=description)
39
- existing = self._tools.get(tool.name)
40
- if existing:
41
- if self.warn_on_duplicate_tools:
42
- logger.warning(f"Tool already exists: {tool.name}")
43
- return existing
44
- self._tools[tool.name] = tool
45
- return tool
46
-
47
- async def call_tool(
48
- self, name: str, arguments: dict, context: Optional["Context"] = None
49
- ) -> Any:
50
- """Call a tool by name with arguments."""
51
- tool = self.get_tool(name)
52
- if not tool:
53
- raise ToolError(f"Unknown tool: {name}")
54
-
55
- return await tool.run(arguments, context=context)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/utilities/func_metadata.py DELETED
@@ -1,200 +0,0 @@
1
- import inspect
2
- from collections.abc import Callable, Sequence, Awaitable
3
- from typing import (
4
- Annotated,
5
- Any,
6
- Dict,
7
- ForwardRef,
8
- )
9
- from pydantic import Field
10
- from fastmcp.exceptions import InvalidSignature
11
- from pydantic._internal._typing_extra import eval_type_lenient
12
- import json
13
- from pydantic import BaseModel
14
- from pydantic.fields import FieldInfo
15
- from pydantic import ConfigDict, create_model
16
- from pydantic import WithJsonSchema
17
- from pydantic_core import PydanticUndefined
18
- from fastmcp.utilities.logging import get_logger
19
-
20
-
21
- logger = get_logger(__name__)
22
-
23
-
24
- class ArgModelBase(BaseModel):
25
- """A model representing the arguments to a function."""
26
-
27
- def model_dump_one_level(self) -> dict[str, Any]:
28
- """Return a dict of the model's fields, one level deep.
29
-
30
- That is, sub-models etc are not dumped - they are kept as pydantic models.
31
- """
32
- kwargs: dict[str, Any] = {}
33
- for field_name in self.model_fields.keys():
34
- kwargs[field_name] = getattr(self, field_name)
35
- return kwargs
36
-
37
- model_config = ConfigDict(
38
- arbitrary_types_allowed=True,
39
- )
40
-
41
-
42
- class FuncMetadata(BaseModel):
43
- arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
44
- # We can add things in the future like
45
- # - Maybe some args are excluded from attempting to parse from JSON
46
- # - Maybe some args are special (like context) for dependency injection
47
-
48
- async def call_fn_with_arg_validation(
49
- self,
50
- fn: Callable[..., Any] | Awaitable[Any],
51
- fn_is_async: bool,
52
- arguments_to_validate: dict[str, Any],
53
- arguments_to_pass_directly: dict[str, Any] | None,
54
- ) -> Any:
55
- """Call the given function with arguments validated and injected.
56
-
57
- Arguments are first attempted to be parsed from JSON, then validated against
58
- the argument model, before being passed to the function.
59
- """
60
- arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
61
- arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
62
- arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
63
-
64
- arguments_parsed_dict |= arguments_to_pass_directly or {}
65
-
66
- if fn_is_async:
67
- if isinstance(fn, Awaitable):
68
- return await fn
69
- return await fn(**arguments_parsed_dict)
70
- if isinstance(fn, Callable):
71
- return fn(**arguments_parsed_dict)
72
- raise TypeError("fn must be either Callable or Awaitable")
73
-
74
- def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
75
- """Pre-parse data from JSON.
76
-
77
- Return a dict with same keys as input but with values parsed from JSON
78
- if appropriate.
79
-
80
- This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
81
- a string rather than an actual list. Claude desktop is prone to this - in fact
82
- it seems incapable of NOT doing this. For sub-models, it tends to pass
83
- dicts (JSON objects) as JSON strings, which can be pre-parsed here.
84
- """
85
- new_data = data.copy() # Shallow copy
86
- for field_name, field_info in self.arg_model.model_fields.items():
87
- if field_name not in data.keys():
88
- continue
89
- if isinstance(data[field_name], str):
90
- try:
91
- pre_parsed = json.loads(data[field_name])
92
- except json.JSONDecodeError:
93
- continue # Not JSON - skip
94
- if isinstance(pre_parsed, (str, int, float)):
95
- # This is likely that the raw value is e.g. `"hello"` which we
96
- # Should really be parsed as '"hello"' in Python - but if we parse
97
- # it as JSON it'll turn into just 'hello'. So we skip it.
98
- continue
99
- new_data[field_name] = pre_parsed
100
- assert new_data.keys() == data.keys()
101
- return new_data
102
-
103
- model_config = ConfigDict(
104
- arbitrary_types_allowed=True,
105
- )
106
-
107
-
108
- def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata:
109
- """Given a function, return metadata including a pydantic model representing its signature.
110
-
111
- The use case for this is
112
- ```
113
- meta = func_to_pyd(func)
114
- validated_args = meta.arg_model.model_validate(some_raw_data_dict)
115
- return func(**validated_args.model_dump_one_level())
116
- ```
117
-
118
- **critically** it also provides pre-parse helper to attempt to parse things from JSON.
119
-
120
- Args:
121
- func: The function to convert to a pydantic model
122
- skip_names: A list of parameter names to skip. These will not be included in
123
- the model.
124
- Returns:
125
- A pydantic model representing the function's signature.
126
- """
127
- sig = _get_typed_signature(func)
128
- params = sig.parameters
129
- dynamic_pydantic_model_params: dict[str, Any] = {}
130
- globalns = getattr(func, "__globals__", {})
131
- for param in params.values():
132
- if param.name.startswith("_"):
133
- raise InvalidSignature(
134
- f"Parameter {param.name} of {func.__name__} may not start with an underscore"
135
- )
136
- if param.name in skip_names:
137
- continue
138
- annotation = param.annotation
139
-
140
- # `x: None` / `x: None = None`
141
- if annotation is None:
142
- annotation = Annotated[
143
- None,
144
- Field(
145
- default=param.default
146
- if param.default is not inspect.Parameter.empty
147
- else PydanticUndefined
148
- ),
149
- ]
150
-
151
- # Untyped field
152
- if annotation is inspect.Parameter.empty:
153
- annotation = Annotated[
154
- Any,
155
- Field(),
156
- # 🤷
157
- WithJsonSchema({"title": param.name, "type": "string"}),
158
- ]
159
-
160
- field_info = FieldInfo.from_annotated_attribute(
161
- _get_typed_annotation(annotation, globalns),
162
- param.default
163
- if param.default is not inspect.Parameter.empty
164
- else PydanticUndefined,
165
- )
166
- dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
167
- continue
168
-
169
- arguments_model = create_model(
170
- f"{func.__name__}Arguments",
171
- **dynamic_pydantic_model_params,
172
- __base__=ArgModelBase,
173
- )
174
- resp = FuncMetadata(arg_model=arguments_model)
175
- return resp
176
-
177
-
178
- def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
179
- if isinstance(annotation, str):
180
- annotation = ForwardRef(annotation)
181
- annotation = eval_type_lenient(annotation, globalns, globalns)
182
-
183
- return annotation
184
-
185
-
186
- def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
187
- """Get function signature while evaluating forward references"""
188
- signature = inspect.signature(call)
189
- globalns = getattr(call, "__globals__", {})
190
- typed_params = [
191
- inspect.Parameter(
192
- name=param.name,
193
- kind=param.kind,
194
- default=param.default,
195
- annotation=_get_typed_annotation(param.annotation, globalns),
196
- )
197
- for param in signature.parameters.values()
198
- ]
199
- typed_signature = inspect.Signature(typed_params)
200
- return typed_signature
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/prompts/__init__.py DELETED
File without changes
tests/prompts/test_base.py DELETED
@@ -1,194 +0,0 @@
1
- from pydantic import FileUrl
2
- import pytest
3
- from fastmcp.prompts.base import (
4
- Prompt,
5
- UserMessage,
6
- TextContent,
7
- AssistantMessage,
8
- Message,
9
- )
10
- from mcp.types import EmbeddedResource, TextResourceContents
11
-
12
-
13
- class TestRenderPrompt:
14
- async def test_basic_fn(self):
15
- def fn() -> str:
16
- return "Hello, world!"
17
-
18
- prompt = Prompt.from_function(fn)
19
- assert await prompt.render() == [
20
- UserMessage(content=TextContent(type="text", text="Hello, world!"))
21
- ]
22
-
23
- async def test_async_fn(self):
24
- async def fn() -> str:
25
- return "Hello, world!"
26
-
27
- prompt = Prompt.from_function(fn)
28
- assert await prompt.render() == [
29
- UserMessage(content=TextContent(type="text", text="Hello, world!"))
30
- ]
31
-
32
- async def test_fn_with_args(self):
33
- async def fn(name: str, age: int = 30) -> str:
34
- return f"Hello, {name}! You're {age} years old."
35
-
36
- prompt = Prompt.from_function(fn)
37
- assert await prompt.render(arguments=dict(name="World")) == [
38
- UserMessage(
39
- content=TextContent(
40
- type="text", text="Hello, World! You're 30 years old."
41
- )
42
- )
43
- ]
44
-
45
- async def test_fn_with_invalid_kwargs(self):
46
- async def fn(name: str, age: int = 30) -> str:
47
- return f"Hello, {name}! You're {age} years old."
48
-
49
- prompt = Prompt.from_function(fn)
50
- with pytest.raises(ValueError):
51
- await prompt.render(arguments=dict(age=40))
52
-
53
- async def test_fn_returns_message(self):
54
- async def fn() -> UserMessage:
55
- return UserMessage(content="Hello, world!")
56
-
57
- prompt = Prompt.from_function(fn)
58
- assert await prompt.render() == [
59
- UserMessage(content=TextContent(type="text", text="Hello, world!"))
60
- ]
61
-
62
- async def test_fn_returns_assistant_message(self):
63
- async def fn() -> AssistantMessage:
64
- return AssistantMessage(
65
- content=TextContent(type="text", text="Hello, world!")
66
- )
67
-
68
- prompt = Prompt.from_function(fn)
69
- assert await prompt.render() == [
70
- AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
71
- ]
72
-
73
- async def test_fn_returns_multiple_messages(self):
74
- expected = [
75
- UserMessage("Hello, world!"),
76
- AssistantMessage("How can I help you today?"),
77
- UserMessage("I'm looking for a restaurant in the center of town."),
78
- ]
79
-
80
- async def fn() -> list[Message]:
81
- return expected
82
-
83
- prompt = Prompt.from_function(fn)
84
- assert await prompt.render() == expected
85
-
86
- async def test_fn_returns_list_of_strings(self):
87
- expected = [
88
- "Hello, world!",
89
- "I'm looking for a restaurant in the center of town.",
90
- ]
91
-
92
- async def fn() -> list[str]:
93
- return expected
94
-
95
- prompt = Prompt.from_function(fn)
96
- assert await prompt.render() == [UserMessage(t) for t in expected]
97
-
98
- async def test_fn_returns_resource_content(self):
99
- """Test returning a message with resource content."""
100
-
101
- async def fn() -> UserMessage:
102
- return UserMessage(
103
- content=EmbeddedResource(
104
- type="resource",
105
- resource=TextResourceContents(
106
- uri=FileUrl("file://file.txt"),
107
- text="File contents",
108
- mimeType="text/plain",
109
- ),
110
- )
111
- )
112
-
113
- prompt = Prompt.from_function(fn)
114
- assert await prompt.render() == [
115
- UserMessage(
116
- content=EmbeddedResource(
117
- type="resource",
118
- resource=TextResourceContents(
119
- uri=FileUrl("file://file.txt"),
120
- text="File contents",
121
- mimeType="text/plain",
122
- ),
123
- )
124
- )
125
- ]
126
-
127
- async def test_fn_returns_mixed_content(self):
128
- """Test returning messages with mixed content types."""
129
-
130
- async def fn() -> list[Message]:
131
- return [
132
- UserMessage(content="Please analyze this file:"),
133
- UserMessage(
134
- content=EmbeddedResource(
135
- type="resource",
136
- resource=TextResourceContents(
137
- uri=FileUrl("file://file.txt"),
138
- text="File contents",
139
- mimeType="text/plain",
140
- ),
141
- )
142
- ),
143
- AssistantMessage(content="I'll help analyze that file."),
144
- ]
145
-
146
- prompt = Prompt.from_function(fn)
147
- assert await prompt.render() == [
148
- UserMessage(
149
- content=TextContent(type="text", text="Please analyze this file:")
150
- ),
151
- UserMessage(
152
- content=EmbeddedResource(
153
- type="resource",
154
- resource=TextResourceContents(
155
- uri=FileUrl("file://file.txt"),
156
- text="File contents",
157
- mimeType="text/plain",
158
- ),
159
- )
160
- ),
161
- AssistantMessage(
162
- content=TextContent(type="text", text="I'll help analyze that file.")
163
- ),
164
- ]
165
-
166
- async def test_fn_returns_dict_with_resource(self):
167
- """Test returning a dict with resource content."""
168
-
169
- async def fn() -> dict:
170
- return {
171
- "role": "user",
172
- "content": {
173
- "type": "resource",
174
- "resource": {
175
- "uri": FileUrl("file://file.txt"),
176
- "text": "File contents",
177
- "mimeType": "text/plain",
178
- },
179
- },
180
- }
181
-
182
- prompt = Prompt.from_function(fn)
183
- assert await prompt.render() == [
184
- UserMessage(
185
- content=EmbeddedResource(
186
- type="resource",
187
- resource=TextResourceContents(
188
- uri=FileUrl("file://file.txt"),
189
- text="File contents",
190
- mimeType="text/plain",
191
- ),
192
- )
193
- )
194
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/prompts/test_manager.py DELETED
@@ -1,107 +0,0 @@
1
- import pytest
2
- from fastmcp.prompts.base import UserMessage, TextContent, Prompt
3
- from fastmcp.prompts.manager import PromptManager
4
-
5
-
6
- class TestPromptManager:
7
- def test_add_prompt(self):
8
- """Test adding a prompt to the manager."""
9
-
10
- def fn() -> str:
11
- return "Hello, world!"
12
-
13
- manager = PromptManager()
14
- prompt = Prompt.from_function(fn)
15
- added = manager.add_prompt(prompt)
16
- assert added == prompt
17
- assert manager.get_prompt("fn") == prompt
18
-
19
- def test_add_duplicate_prompt(self, caplog):
20
- """Test adding the same prompt twice."""
21
-
22
- def fn() -> str:
23
- return "Hello, world!"
24
-
25
- manager = PromptManager()
26
- prompt = Prompt.from_function(fn)
27
- first = manager.add_prompt(prompt)
28
- second = manager.add_prompt(prompt)
29
- assert first == second
30
- assert "Prompt already exists" in caplog.text
31
-
32
- def test_disable_warn_on_duplicate_prompts(self, caplog):
33
- """Test disabling warning on duplicate prompts."""
34
-
35
- def fn() -> str:
36
- return "Hello, world!"
37
-
38
- manager = PromptManager(warn_on_duplicate_prompts=False)
39
- prompt = Prompt.from_function(fn)
40
- first = manager.add_prompt(prompt)
41
- second = manager.add_prompt(prompt)
42
- assert first == second
43
- assert "Prompt already exists" not in caplog.text
44
-
45
- def test_list_prompts(self):
46
- """Test listing all prompts."""
47
-
48
- def fn1() -> str:
49
- return "Hello, world!"
50
-
51
- def fn2() -> str:
52
- return "Goodbye, world!"
53
-
54
- manager = PromptManager()
55
- prompt1 = Prompt.from_function(fn1)
56
- prompt2 = Prompt.from_function(fn2)
57
- manager.add_prompt(prompt1)
58
- manager.add_prompt(prompt2)
59
- prompts = manager.list_prompts()
60
- assert len(prompts) == 2
61
- assert prompts == [prompt1, prompt2]
62
-
63
- async def test_render_prompt(self):
64
- """Test rendering a prompt."""
65
-
66
- def fn() -> str:
67
- return "Hello, world!"
68
-
69
- manager = PromptManager()
70
- prompt = Prompt.from_function(fn)
71
- manager.add_prompt(prompt)
72
- messages = await manager.render_prompt("fn")
73
- assert messages == [
74
- UserMessage(content=TextContent(type="text", text="Hello, world!"))
75
- ]
76
-
77
- async def test_render_prompt_with_args(self):
78
- """Test rendering a prompt with arguments."""
79
-
80
- def fn(name: str) -> str:
81
- return f"Hello, {name}!"
82
-
83
- manager = PromptManager()
84
- prompt = Prompt.from_function(fn)
85
- manager.add_prompt(prompt)
86
- messages = await manager.render_prompt("fn", arguments={"name": "World"})
87
- assert messages == [
88
- UserMessage(content=TextContent(type="text", text="Hello, World!"))
89
- ]
90
-
91
- async def test_render_unknown_prompt(self):
92
- """Test rendering a non-existent prompt."""
93
- manager = PromptManager()
94
- with pytest.raises(ValueError, match="Unknown prompt: unknown"):
95
- await manager.render_prompt("unknown")
96
-
97
- async def test_render_prompt_with_missing_args(self):
98
- """Test rendering a prompt with missing required arguments."""
99
-
100
- def fn(name: str) -> str:
101
- return f"Hello, {name}!"
102
-
103
- manager = PromptManager()
104
- prompt = Prompt.from_function(fn)
105
- manager.add_prompt(prompt)
106
- with pytest.raises(ValueError, match="Missing required arguments"):
107
- await manager.render_prompt("fn")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/resources/__init__.py DELETED
File without changes
tests/resources/test_file_resources.py DELETED
@@ -1,115 +0,0 @@
1
- import os
2
-
3
- import pytest
4
- from pathlib import Path
5
- from tempfile import NamedTemporaryFile
6
- from pydantic import FileUrl
7
-
8
- from fastmcp.resources import FileResource
9
-
10
-
11
- @pytest.fixture
12
- def temp_file():
13
- """Create a temporary file for testing.
14
-
15
- File is automatically cleaned up after the test if it still exists.
16
- """
17
- content = "test content"
18
- with NamedTemporaryFile(mode="w", delete=False) as f:
19
- f.write(content)
20
- path = Path(f.name).resolve()
21
- yield path
22
- try:
23
- path.unlink()
24
- except FileNotFoundError:
25
- pass # File was already deleted by the test
26
-
27
-
28
- class TestFileResource:
29
- """Test FileResource functionality."""
30
-
31
- def test_file_resource_creation(self, temp_file: Path):
32
- """Test creating a FileResource."""
33
- resource = FileResource(
34
- uri=FileUrl(temp_file.as_uri()),
35
- name="test",
36
- description="test file",
37
- path=temp_file,
38
- )
39
- assert str(resource.uri) == temp_file.as_uri()
40
- assert resource.name == "test"
41
- assert resource.description == "test file"
42
- assert resource.mime_type == "text/plain" # default
43
- assert resource.path == temp_file
44
- assert resource.is_binary is False # default
45
-
46
- def test_file_resource_str_path_conversion(self, temp_file: Path):
47
- """Test FileResource handles string paths."""
48
- resource = FileResource(
49
- uri=FileUrl(f"file://{temp_file}"),
50
- name="test",
51
- path=Path(str(temp_file)),
52
- )
53
- assert isinstance(resource.path, Path)
54
- assert resource.path.is_absolute()
55
-
56
- async def test_read_text_file(self, temp_file: Path):
57
- """Test reading a text file."""
58
- resource = FileResource(
59
- uri=FileUrl(f"file://{temp_file}"),
60
- name="test",
61
- path=temp_file,
62
- )
63
- content = await resource.read()
64
- assert content == "test content"
65
- assert resource.mime_type == "text/plain"
66
-
67
- async def test_read_binary_file(self, temp_file: Path):
68
- """Test reading a file as binary."""
69
- resource = FileResource(
70
- uri=FileUrl(f"file://{temp_file}"),
71
- name="test",
72
- path=temp_file,
73
- is_binary=True,
74
- )
75
- content = await resource.read()
76
- assert isinstance(content, bytes)
77
- assert content == b"test content"
78
-
79
- def test_relative_path_error(self):
80
- """Test error on relative path."""
81
- with pytest.raises(ValueError, match="Path must be absolute"):
82
- FileResource(
83
- uri=FileUrl("file:///test.txt"),
84
- name="test",
85
- path=Path("test.txt"),
86
- )
87
-
88
- async def test_missing_file_error(self, temp_file: Path):
89
- """Test error when file doesn't exist."""
90
- # Create path to non-existent file
91
- missing = temp_file.parent / "missing.txt"
92
- resource = FileResource(
93
- uri=FileUrl("file:///missing.txt"),
94
- name="test",
95
- path=missing,
96
- )
97
- with pytest.raises(ValueError, match="Error reading file"):
98
- await resource.read()
99
-
100
- @pytest.mark.skipif(
101
- os.name == "nt", reason="File permissions behave differently on Windows"
102
- )
103
- async def test_permission_error(self, temp_file: Path):
104
- """Test reading a file without permissions."""
105
- temp_file.chmod(0o000) # Remove all permissions
106
- try:
107
- resource = FileResource(
108
- uri=FileUrl(temp_file.as_uri()),
109
- name="test",
110
- path=temp_file,
111
- )
112
- with pytest.raises(ValueError, match="Error reading file"):
113
- await resource.read()
114
- finally:
115
- temp_file.chmod(0o644) # Restore permissions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/resources/test_function_resources.py DELETED
@@ -1,115 +0,0 @@
1
- from pydantic import BaseModel, AnyUrl
2
- import pytest
3
- from fastmcp.resources import FunctionResource
4
-
5
-
6
- class TestFunctionResource:
7
- """Test FunctionResource functionality."""
8
-
9
- def test_function_resource_creation(self):
10
- """Test creating a FunctionResource."""
11
-
12
- def my_func() -> str:
13
- return "test content"
14
-
15
- resource = FunctionResource(
16
- uri=AnyUrl("fn://test"),
17
- name="test",
18
- description="test function",
19
- fn=my_func,
20
- )
21
- assert str(resource.uri) == "fn://test"
22
- assert resource.name == "test"
23
- assert resource.description == "test function"
24
- assert resource.mime_type == "text/plain" # default
25
- assert resource.fn == my_func
26
-
27
- async def test_read_text(self):
28
- """Test reading text from a FunctionResource."""
29
-
30
- def get_data() -> str:
31
- return "Hello, world!"
32
-
33
- resource = FunctionResource(
34
- uri=AnyUrl("function://test"),
35
- name="test",
36
- fn=get_data,
37
- )
38
- content = await resource.read()
39
- assert content == "Hello, world!"
40
- assert resource.mime_type == "text/plain"
41
-
42
- async def test_read_binary(self):
43
- """Test reading binary data from a FunctionResource."""
44
-
45
- def get_data() -> bytes:
46
- return b"Hello, world!"
47
-
48
- resource = FunctionResource(
49
- uri=AnyUrl("function://test"),
50
- name="test",
51
- fn=get_data,
52
- )
53
- content = await resource.read()
54
- assert content == b"Hello, world!"
55
-
56
- async def test_json_conversion(self):
57
- """Test automatic JSON conversion of non-string results."""
58
-
59
- def get_data() -> dict:
60
- return {"key": "value"}
61
-
62
- resource = FunctionResource(
63
- uri=AnyUrl("function://test"),
64
- name="test",
65
- fn=get_data,
66
- )
67
- content = await resource.read()
68
- assert isinstance(content, str)
69
- assert '"key": "value"' in content
70
-
71
- async def test_error_handling(self):
72
- """Test error handling in FunctionResource."""
73
-
74
- def failing_func() -> str:
75
- raise ValueError("Test error")
76
-
77
- resource = FunctionResource(
78
- uri=AnyUrl("function://test"),
79
- name="test",
80
- fn=failing_func,
81
- )
82
- with pytest.raises(ValueError, match="Error reading resource function://test"):
83
- await resource.read()
84
-
85
- async def test_basemodel_conversion(self):
86
- """Test handling of BaseModel types."""
87
-
88
- class MyModel(BaseModel):
89
- name: str
90
-
91
- resource = FunctionResource(
92
- uri=AnyUrl("function://test"),
93
- name="test",
94
- fn=lambda: MyModel(name="test"),
95
- )
96
- content = await resource.read()
97
- assert content == '{"name": "test"}'
98
-
99
- async def test_custom_type_conversion(self):
100
- """Test handling of custom types."""
101
-
102
- class CustomData:
103
- def __str__(self) -> str:
104
- return "custom data"
105
-
106
- def get_data() -> CustomData:
107
- return CustomData()
108
-
109
- resource = FunctionResource(
110
- uri=AnyUrl("function://test"),
111
- name="test",
112
- fn=get_data,
113
- )
114
- content = await resource.read()
115
- assert isinstance(content, str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/resources/test_resource_manager.py DELETED
@@ -1,137 +0,0 @@
1
- import pytest
2
- from pathlib import Path
3
- from tempfile import NamedTemporaryFile
4
- from pydantic import AnyUrl, FileUrl
5
-
6
- from fastmcp.resources import (
7
- FileResource,
8
- FunctionResource,
9
- ResourceManager,
10
- ResourceTemplate,
11
- )
12
-
13
-
14
- @pytest.fixture
15
- def temp_file():
16
- """Create a temporary file for testing.
17
-
18
- File is automatically cleaned up after the test if it still exists.
19
- """
20
- content = "test content"
21
- with NamedTemporaryFile(mode="w", delete=False) as f:
22
- f.write(content)
23
- path = Path(f.name).resolve()
24
- yield path
25
- try:
26
- path.unlink()
27
- except FileNotFoundError:
28
- pass # File was already deleted by the test
29
-
30
-
31
- class TestResourceManager:
32
- """Test ResourceManager functionality."""
33
-
34
- def test_add_resource(self, temp_file: Path):
35
- """Test adding a resource."""
36
- manager = ResourceManager()
37
- resource = FileResource(
38
- uri=FileUrl(f"file://{temp_file}"),
39
- name="test",
40
- path=temp_file,
41
- )
42
- added = manager.add_resource(resource)
43
- assert added == resource
44
- assert manager.list_resources() == [resource]
45
-
46
- def test_add_duplicate_resource(self, temp_file: Path):
47
- """Test adding the same resource twice."""
48
- manager = ResourceManager()
49
- resource = FileResource(
50
- uri=FileUrl(f"file://{temp_file}"),
51
- name="test",
52
- path=temp_file,
53
- )
54
- first = manager.add_resource(resource)
55
- second = manager.add_resource(resource)
56
- assert first == second
57
- assert manager.list_resources() == [resource]
58
-
59
- def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
60
- """Test warning on duplicate resources."""
61
- manager = ResourceManager()
62
- resource = FileResource(
63
- uri=FileUrl(f"file://{temp_file}"),
64
- name="test",
65
- path=temp_file,
66
- )
67
- manager.add_resource(resource)
68
- manager.add_resource(resource)
69
- assert "Resource already exists" in caplog.text
70
-
71
- def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
72
- """Test disabling warning on duplicate resources."""
73
- manager = ResourceManager(warn_on_duplicate_resources=False)
74
- resource = FileResource(
75
- uri=FileUrl(f"file://{temp_file}"),
76
- name="test",
77
- path=temp_file,
78
- )
79
- manager.add_resource(resource)
80
- manager.add_resource(resource)
81
- assert "Resource already exists" not in caplog.text
82
-
83
- async def test_get_resource(self, temp_file: Path):
84
- """Test getting a resource by URI."""
85
- manager = ResourceManager()
86
- resource = FileResource(
87
- uri=FileUrl(f"file://{temp_file}"),
88
- name="test",
89
- path=temp_file,
90
- )
91
- manager.add_resource(resource)
92
- retrieved = await manager.get_resource(resource.uri)
93
- assert retrieved == resource
94
-
95
- async def test_get_resource_from_template(self):
96
- """Test getting a resource through a template."""
97
- manager = ResourceManager()
98
-
99
- def greet(name: str) -> str:
100
- return f"Hello, {name}!"
101
-
102
- template = ResourceTemplate.from_function(
103
- fn=greet,
104
- uri_template="greet://{name}",
105
- name="greeter",
106
- )
107
- manager._templates[template.uri_template] = template
108
-
109
- resource = await manager.get_resource(AnyUrl("greet://world"))
110
- assert isinstance(resource, FunctionResource)
111
- content = await resource.read()
112
- assert content == "Hello, world!"
113
-
114
- async def test_get_unknown_resource(self):
115
- """Test getting a non-existent resource."""
116
- manager = ResourceManager()
117
- with pytest.raises(ValueError, match="Unknown resource"):
118
- await manager.get_resource(AnyUrl("unknown://test"))
119
-
120
- def test_list_resources(self, temp_file: Path):
121
- """Test listing all resources."""
122
- manager = ResourceManager()
123
- resource1 = FileResource(
124
- uri=FileUrl(f"file://{temp_file}"),
125
- name="test1",
126
- path=temp_file,
127
- )
128
- resource2 = FileResource(
129
- uri=FileUrl(f"file://{temp_file}2"),
130
- name="test2",
131
- path=temp_file,
132
- )
133
- manager.add_resource(resource1)
134
- manager.add_resource(resource2)
135
- resources = manager.list_resources()
136
- assert len(resources) == 2
137
- assert resources == [resource1, resource2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/resources/test_resource_template.py DELETED
@@ -1,181 +0,0 @@
1
- import json
2
- import pytest
3
- from pydantic import BaseModel
4
-
5
- from fastmcp.resources import FunctionResource, ResourceTemplate
6
-
7
-
8
- class TestResourceTemplate:
9
- """Test ResourceTemplate functionality."""
10
-
11
- def test_template_creation(self):
12
- """Test creating a template from a function."""
13
-
14
- def my_func(key: str, value: int) -> dict:
15
- return {"key": key, "value": value}
16
-
17
- template = ResourceTemplate.from_function(
18
- fn=my_func,
19
- uri_template="test://{key}/{value}",
20
- name="test",
21
- )
22
- assert template.uri_template == "test://{key}/{value}"
23
- assert template.name == "test"
24
- assert template.mime_type == "text/plain" # default
25
- test_input = {"key": "test", "value": 42}
26
- assert template.fn(**test_input) == my_func(**test_input)
27
-
28
- def test_template_matches(self):
29
- """Test matching URIs against a template."""
30
-
31
- def my_func(key: str, value: int) -> dict:
32
- return {"key": key, "value": value}
33
-
34
- template = ResourceTemplate.from_function(
35
- fn=my_func,
36
- uri_template="test://{key}/{value}",
37
- name="test",
38
- )
39
-
40
- # Valid match
41
- params = template.matches("test://foo/123")
42
- assert params == {"key": "foo", "value": "123"}
43
-
44
- # No match
45
- assert template.matches("test://foo") is None
46
- assert template.matches("other://foo/123") is None
47
-
48
- async def test_create_resource(self):
49
- """Test creating a resource from a template."""
50
-
51
- def my_func(key: str, value: int) -> dict:
52
- return {"key": key, "value": value}
53
-
54
- template = ResourceTemplate.from_function(
55
- fn=my_func,
56
- uri_template="test://{key}/{value}",
57
- name="test",
58
- )
59
-
60
- resource = await template.create_resource(
61
- "test://foo/123",
62
- {"key": "foo", "value": 123},
63
- )
64
-
65
- assert isinstance(resource, FunctionResource)
66
- content = await resource.read()
67
- assert isinstance(content, str)
68
- data = json.loads(content)
69
- assert data == {"key": "foo", "value": 123}
70
-
71
- async def test_template_error(self):
72
- """Test error handling in template resource creation."""
73
-
74
- def failing_func(x: str) -> str:
75
- raise ValueError("Test error")
76
-
77
- template = ResourceTemplate.from_function(
78
- fn=failing_func,
79
- uri_template="fail://{x}",
80
- name="fail",
81
- )
82
-
83
- with pytest.raises(ValueError, match="Error creating resource from template"):
84
- await template.create_resource("fail://test", {"x": "test"})
85
-
86
- async def test_async_text_resource(self):
87
- """Test creating a text resource from async function."""
88
-
89
- async def greet(name: str) -> str:
90
- return f"Hello, {name}!"
91
-
92
- template = ResourceTemplate.from_function(
93
- fn=greet,
94
- uri_template="greet://{name}",
95
- name="greeter",
96
- )
97
-
98
- resource = await template.create_resource(
99
- "greet://world",
100
- {"name": "world"},
101
- )
102
-
103
- assert isinstance(resource, FunctionResource)
104
- content = await resource.read()
105
- assert content == "Hello, world!"
106
-
107
- async def test_async_binary_resource(self):
108
- """Test creating a binary resource from async function."""
109
-
110
- async def get_bytes(value: str) -> bytes:
111
- return value.encode()
112
-
113
- template = ResourceTemplate.from_function(
114
- fn=get_bytes,
115
- uri_template="bytes://{value}",
116
- name="bytes",
117
- )
118
-
119
- resource = await template.create_resource(
120
- "bytes://test",
121
- {"value": "test"},
122
- )
123
-
124
- assert isinstance(resource, FunctionResource)
125
- content = await resource.read()
126
- assert content == b"test"
127
-
128
- async def test_basemodel_conversion(self):
129
- """Test handling of BaseModel types."""
130
-
131
- class MyModel(BaseModel):
132
- key: str
133
- value: int
134
-
135
- def get_data(key: str, value: int) -> MyModel:
136
- return MyModel(key=key, value=value)
137
-
138
- template = ResourceTemplate.from_function(
139
- fn=get_data,
140
- uri_template="test://{key}/{value}",
141
- name="test",
142
- )
143
-
144
- resource = await template.create_resource(
145
- "test://foo/123",
146
- {"key": "foo", "value": 123},
147
- )
148
-
149
- assert isinstance(resource, FunctionResource)
150
- content = await resource.read()
151
- assert isinstance(content, str)
152
- data = json.loads(content)
153
- assert data == {"key": "foo", "value": 123}
154
-
155
- async def test_custom_type_conversion(self):
156
- """Test handling of custom types."""
157
-
158
- class CustomData:
159
- def __init__(self, value: str):
160
- self.value = value
161
-
162
- def __str__(self) -> str:
163
- return self.value
164
-
165
- def get_data(value: str) -> CustomData:
166
- return CustomData(value)
167
-
168
- template = ResourceTemplate.from_function(
169
- fn=get_data,
170
- uri_template="test://{value}",
171
- name="test",
172
- )
173
-
174
- resource = await template.create_resource(
175
- "test://hello",
176
- {"value": "hello"},
177
- )
178
-
179
- assert isinstance(resource, FunctionResource)
180
- content = await resource.read()
181
- assert content == "hello"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/resources/test_resources.py DELETED
@@ -1,100 +0,0 @@
1
- import pytest
2
- from pydantic import AnyUrl
3
-
4
- from fastmcp.resources import FunctionResource, Resource
5
-
6
-
7
- class TestResourceValidation:
8
- """Test base Resource validation."""
9
-
10
- def test_resource_uri_validation(self):
11
- """Test URI validation."""
12
-
13
- def dummy_func() -> str:
14
- return "data"
15
-
16
- # Valid URI
17
- resource = FunctionResource(
18
- uri=AnyUrl("http://example.com/data"),
19
- name="test",
20
- fn=dummy_func,
21
- )
22
- assert str(resource.uri) == "http://example.com/data"
23
-
24
- # Missing protocol
25
- with pytest.raises(ValueError, match="Input should be a valid URL"):
26
- FunctionResource(
27
- uri=AnyUrl("invalid"),
28
- name="test",
29
- fn=dummy_func,
30
- )
31
-
32
- # Missing host
33
- with pytest.raises(ValueError, match="Input should be a valid URL"):
34
- FunctionResource(
35
- uri=AnyUrl("http://"),
36
- name="test",
37
- fn=dummy_func,
38
- )
39
-
40
- def test_resource_name_from_uri(self):
41
- """Test name is extracted from URI if not provided."""
42
-
43
- def dummy_func() -> str:
44
- return "data"
45
-
46
- resource = FunctionResource(
47
- uri=AnyUrl("resource://my-resource"),
48
- fn=dummy_func,
49
- )
50
- assert resource.name == "resource://my-resource"
51
-
52
- def test_resource_name_validation(self):
53
- """Test name validation."""
54
-
55
- def dummy_func() -> str:
56
- return "data"
57
-
58
- # Must provide either name or URI
59
- with pytest.raises(ValueError, match="Either name or uri must be provided"):
60
- FunctionResource(
61
- fn=dummy_func,
62
- )
63
-
64
- # Explicit name takes precedence over URI
65
- resource = FunctionResource(
66
- uri=AnyUrl("resource://uri-name"),
67
- name="explicit-name",
68
- fn=dummy_func,
69
- )
70
- assert resource.name == "explicit-name"
71
-
72
- def test_resource_mime_type(self):
73
- """Test mime type handling."""
74
-
75
- def dummy_func() -> str:
76
- return "data"
77
-
78
- # Default mime type
79
- resource = FunctionResource(
80
- uri=AnyUrl("resource://test"),
81
- fn=dummy_func,
82
- )
83
- assert resource.mime_type == "text/plain"
84
-
85
- # Custom mime type
86
- resource = FunctionResource(
87
- uri=AnyUrl("resource://test"),
88
- fn=dummy_func,
89
- mime_type="application/json",
90
- )
91
- assert resource.mime_type == "application/json"
92
-
93
- async def test_resource_read_abstract(self):
94
- """Test that Resource.read() is abstract."""
95
-
96
- class ConcreteResource(Resource):
97
- pass
98
-
99
- with pytest.raises(TypeError, match="abstract method"):
100
- ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/servers/__init__.py DELETED
File without changes
tests/servers/test_file_server.py DELETED
@@ -1,114 +0,0 @@
1
- import json
2
- from fastmcp import FastMCP
3
- import pytest
4
- from pathlib import Path
5
-
6
-
7
- @pytest.fixture()
8
- def test_dir(tmp_path_factory) -> Path:
9
- """Create a temporary directory with test files."""
10
- tmp = tmp_path_factory.mktemp("test_files")
11
-
12
- # Create test files
13
- (tmp / "example.py").write_text("print('hello world')")
14
- (tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
15
- (tmp / "config.json").write_text('{"test": true}')
16
-
17
- return tmp
18
-
19
-
20
- @pytest.fixture
21
- def mcp() -> FastMCP:
22
- mcp = FastMCP()
23
-
24
- return mcp
25
-
26
-
27
- @pytest.fixture(autouse=True)
28
- def resources(mcp: FastMCP, test_dir: Path) -> FastMCP:
29
- @mcp.resource("dir://test_dir")
30
- def list_test_dir() -> list[str]:
31
- """List the files in the test directory"""
32
- return [str(f) for f in test_dir.iterdir()]
33
-
34
- @mcp.resource("file://test_dir/example.py")
35
- def read_example_py() -> str:
36
- """Read the example.py file"""
37
- try:
38
- return (test_dir / "example.py").read_text()
39
- except FileNotFoundError:
40
- return "File not found"
41
-
42
- @mcp.resource("file://test_dir/readme.md")
43
- def read_readme_md() -> str:
44
- """Read the readme.md file"""
45
- try:
46
- return (test_dir / "readme.md").read_text()
47
- except FileNotFoundError:
48
- return "File not found"
49
-
50
- @mcp.resource("file://test_dir/config.json")
51
- def read_config_json() -> str:
52
- """Read the config.json file"""
53
- try:
54
- return (test_dir / "config.json").read_text()
55
- except FileNotFoundError:
56
- return "File not found"
57
-
58
- return mcp
59
-
60
-
61
- @pytest.fixture(autouse=True)
62
- def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
63
- @mcp.tool()
64
- def delete_file(path: str) -> bool:
65
- # ensure path is in test_dir
66
- if Path(path).resolve().parent != test_dir:
67
- raise ValueError(f"Path must be in test_dir: {path}")
68
- Path(path).unlink()
69
- return True
70
-
71
- return mcp
72
-
73
-
74
- async def test_list_resources(mcp: FastMCP):
75
- resources = await mcp.list_resources()
76
- assert len(resources) == 4
77
-
78
- assert [str(r.uri) for r in resources] == [
79
- "dir://test_dir",
80
- "file://test_dir/example.py",
81
- "file://test_dir/readme.md",
82
- "file://test_dir/config.json",
83
- ]
84
-
85
-
86
- async def test_read_resource_dir(mcp: FastMCP):
87
- files = await mcp.read_resource("dir://test_dir")
88
- files = json.loads(files)
89
-
90
- assert sorted([Path(f).name for f in files]) == [
91
- "config.json",
92
- "example.py",
93
- "readme.md",
94
- ]
95
-
96
-
97
- async def test_read_resource_file(mcp: FastMCP):
98
- result = await mcp.read_resource("file://test_dir/example.py")
99
- assert result == "print('hello world')"
100
-
101
-
102
- async def test_delete_file(mcp: FastMCP, test_dir: Path):
103
- await mcp.call_tool(
104
- "delete_file", arguments=dict(path=str(test_dir / "example.py"))
105
- )
106
- assert not (test_dir / "example.py").exists()
107
-
108
-
109
- async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
110
- await mcp.call_tool(
111
- "delete_file", arguments=dict(path=str(test_dir / "example.py"))
112
- )
113
- result = await mcp.read_resource("file://test_dir/example.py")
114
- assert result == "File not found"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_cli.py DELETED
@@ -1,376 +0,0 @@
1
- """Tests for the FastMCP CLI."""
2
-
3
- import json
4
- import sys
5
- from pathlib import Path
6
- from unittest.mock import call, patch
7
-
8
- import pytest
9
- from typer.testing import CliRunner
10
-
11
- from fastmcp.cli.cli import _parse_env_var, _parse_file_path, app
12
-
13
-
14
- @pytest.fixture
15
- def mock_config(tmp_path):
16
- """Create a mock Claude config file."""
17
- config = {"mcpServers": {}}
18
- config_file = tmp_path / "claude_desktop_config.json"
19
- config_file.write_text(json.dumps(config))
20
- return config_file
21
-
22
-
23
- @pytest.fixture
24
- def server_file(tmp_path):
25
- """Create a server file."""
26
- server_file = tmp_path / "server.py"
27
- server_file.write_text(
28
- """from fastmcp import FastMCP
29
- mcp = FastMCP("test")
30
- """
31
- )
32
- return server_file
33
-
34
-
35
- @pytest.fixture
36
- def mock_env_file(tmp_path):
37
- """Create a mock .env file."""
38
- env_file = tmp_path / ".env"
39
- env_file.write_text("FOO=bar\nBAZ=123")
40
- return env_file
41
-
42
-
43
- def test_parse_env_var():
44
- """Test parsing environment variables."""
45
- assert _parse_env_var("FOO=bar") == ("FOO", "bar")
46
- assert _parse_env_var("FOO=") == ("FOO", "")
47
- assert _parse_env_var("FOO=bar baz") == ("FOO", "bar baz")
48
- assert _parse_env_var("FOO = bar ") == ("FOO", "bar")
49
-
50
- with pytest.raises(SystemExit):
51
- _parse_env_var("invalid")
52
-
53
-
54
- @pytest.mark.parametrize(
55
- "args,expected_env",
56
- [
57
- # Basic env var
58
- (
59
- ["--env-var", "FOO=bar"],
60
- {"FOO": "bar"},
61
- ),
62
- # Multiple env vars
63
- (
64
- ["--env-var", "FOO=bar", "--env-var", "BAZ=123"],
65
- {"FOO": "bar", "BAZ": "123"},
66
- ),
67
- # Env var with spaces
68
- (
69
- ["--env-var", "FOO=bar baz"],
70
- {"FOO": "bar baz"},
71
- ),
72
- ],
73
- )
74
- def test_install_with_env_vars(mock_config, server_file, args, expected_env):
75
- """Test installing with environment variables."""
76
- runner = CliRunner()
77
-
78
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
79
- mock_config_path.return_value = mock_config.parent
80
-
81
- result = runner.invoke(
82
- app,
83
- ["install", str(server_file)] + args,
84
- )
85
-
86
- assert result.exit_code == 0
87
-
88
- # Read the config file and check env vars
89
- config = json.loads(mock_config.read_text())
90
- assert "mcpServers" in config
91
- assert len(config["mcpServers"]) == 1
92
- server = next(iter(config["mcpServers"].values()))
93
- assert server["env"] == expected_env
94
-
95
-
96
- def test_parse_file_path_windows_drive():
97
- """Test parsing a Windows file path with a drive letter."""
98
- file_spec = r"C:\path\to\file.txt"
99
- with (
100
- patch("pathlib.Path.exists", return_value=True),
101
- patch("pathlib.Path.is_file", return_value=True),
102
- ):
103
- file_path, server_object = _parse_file_path(file_spec)
104
- assert file_path == Path(r"C:\path\to\file.txt").resolve()
105
- assert server_object is None
106
-
107
-
108
- def test_parse_file_path_with_object():
109
- """Test parsing a file path with an object specification."""
110
- file_spec = "/path/to/file.txt:object"
111
- with patch("sys.exit") as mock_exit:
112
- _parse_file_path(file_spec)
113
-
114
- # Check that sys.exit was called twice with code 1
115
- assert mock_exit.call_count == 2
116
- mock_exit.assert_has_calls([call(1), call(1)])
117
-
118
-
119
- def test_parse_file_path_windows_with_object():
120
- """Test parsing a Windows file path with an object specification."""
121
- file_spec = r"C:\path\to\file.txt:object"
122
- with (
123
- patch("pathlib.Path.exists", return_value=True),
124
- patch("pathlib.Path.is_file", return_value=True),
125
- ):
126
- file_path, server_object = _parse_file_path(file_spec)
127
- assert file_path == Path(r"C:\path\to\file.txt").resolve()
128
- assert server_object == "object"
129
-
130
-
131
- def test_install_with_env_file(mock_config, server_file, mock_env_file):
132
- """Test installing with environment variables from a file."""
133
- runner = CliRunner()
134
-
135
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
136
- mock_config_path.return_value = mock_config.parent
137
-
138
- result = runner.invoke(
139
- app,
140
- ["install", str(server_file), "--env-file", str(mock_env_file)],
141
- )
142
-
143
- assert result.exit_code == 0
144
-
145
- # Read the config file and check env vars
146
- config = json.loads(mock_config.read_text())
147
- assert "mcpServers" in config
148
- assert len(config["mcpServers"]) == 1
149
- server = next(iter(config["mcpServers"].values()))
150
- assert server["env"] == {"FOO": "bar", "BAZ": "123"}
151
-
152
-
153
- def test_install_preserves_existing_env_vars(mock_config, server_file):
154
- """Test that installing preserves existing environment variables."""
155
- # Set up initial config with env vars
156
- config = {
157
- "mcpServers": {
158
- "test": {
159
- "command": "uv",
160
- "args": [
161
- "run",
162
- "--with",
163
- "fastmcp",
164
- "fastmcp",
165
- "run",
166
- str(server_file),
167
- ],
168
- "env": {"FOO": "bar", "BAZ": "123"},
169
- }
170
- }
171
- }
172
- mock_config.write_text(json.dumps(config))
173
-
174
- runner = CliRunner()
175
-
176
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
177
- mock_config_path.return_value = mock_config.parent
178
-
179
- # Install with a new env var
180
- result = runner.invoke(
181
- app,
182
- ["install", str(server_file), "--env-var", "NEW=value"],
183
- )
184
-
185
- assert result.exit_code == 0
186
-
187
- # Read the config file and check env vars are preserved
188
- config = json.loads(mock_config.read_text())
189
- server = next(iter(config["mcpServers"].values()))
190
- assert server["env"] == {"FOO": "bar", "BAZ": "123", "NEW": "value"}
191
-
192
-
193
- def test_install_updates_existing_env_vars(mock_config, server_file):
194
- """Test that installing updates existing environment variables."""
195
- # Set up initial config with env vars
196
- config = {
197
- "mcpServers": {
198
- "test": {
199
- "command": "uv",
200
- "args": [
201
- "run",
202
- "--with",
203
- "fastmcp",
204
- "fastmcp",
205
- "run",
206
- str(server_file),
207
- ],
208
- "env": {"FOO": "bar", "BAZ": "123"},
209
- }
210
- }
211
- }
212
- mock_config.write_text(json.dumps(config))
213
-
214
- runner = CliRunner()
215
-
216
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
217
- mock_config_path.return_value = mock_config.parent
218
-
219
- # Update an existing env var
220
- result = runner.invoke(
221
- app,
222
- ["install", str(server_file), "--env-var", "FOO=newvalue"],
223
- )
224
-
225
- assert result.exit_code == 0
226
-
227
- # Read the config file and check env var was updated
228
- config = json.loads(mock_config.read_text())
229
- server = next(iter(config["mcpServers"].values()))
230
- assert server["env"] == {"FOO": "newvalue", "BAZ": "123"}
231
-
232
-
233
- def test_server_dependencies(mock_config, server_file):
234
- """Test that server dependencies are correctly handled."""
235
- # Create a server file with dependencies
236
- server_file = server_file.parent / "server_with_deps.py"
237
- server_file.write_text(
238
- """from fastmcp import FastMCP
239
- mcp = FastMCP("test", dependencies=["pandas", "numpy"])
240
- """
241
- )
242
-
243
- runner = CliRunner()
244
-
245
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
246
- mock_config_path.return_value = mock_config.parent
247
-
248
- result = runner.invoke(app, ["install", str(server_file)])
249
-
250
- assert result.exit_code == 0
251
-
252
- # Read the config file and check dependencies were added as --with args
253
- config = json.loads(mock_config.read_text())
254
- server = next(iter(config["mcpServers"].values()))
255
- assert "--with" in server["args"]
256
- assert "pandas" in server["args"]
257
- assert "numpy" in server["args"]
258
-
259
-
260
- def test_server_dependencies_empty(mock_config, server_file):
261
- """Test that server with no dependencies works correctly."""
262
- runner = CliRunner()
263
-
264
- with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
265
- mock_config_path.return_value = mock_config.parent
266
-
267
- result = runner.invoke(app, ["install", str(server_file)])
268
-
269
- assert result.exit_code == 0
270
-
271
- # Read the config file and check only fastmcp is in --with args
272
- config = json.loads(mock_config.read_text())
273
- server = next(iter(config["mcpServers"].values()))
274
- assert server["args"].count("--with") == 1
275
- assert "fastmcp" in server["args"]
276
-
277
-
278
- def test_dev_with_dependencies(mock_config, server_file):
279
- """Test that dev command handles dependencies correctly."""
280
- server_file = server_file.parent / "server_with_deps.py"
281
- server_file.write_text(
282
- """from fastmcp import FastMCP
283
- mcp = FastMCP("test", dependencies=["pandas", "numpy"])
284
- """
285
- )
286
-
287
- runner = CliRunner()
288
-
289
- with patch("subprocess.run") as mock_run:
290
- mock_run.return_value.returncode = 0
291
- result = runner.invoke(app, ["dev", str(server_file)])
292
- assert result.exit_code == 0
293
-
294
- if sys.platform == "win32":
295
- # On Windows, expect two calls
296
- assert mock_run.call_count == 2
297
- assert mock_run.call_args_list[0] == call(
298
- ["npx.cmd", "--version"], check=True, capture_output=True, shell=True
299
- )
300
-
301
- # get the actual command and expected command without dependencies
302
- actual_cmd = mock_run.call_args_list[1][0][0]
303
- expected_start = [
304
- "npx.cmd",
305
- "@modelcontextprotocol/inspector",
306
- "uv",
307
- "run",
308
- "--with",
309
- "fastmcp",
310
- ]
311
- expected_end = ["fastmcp", "run", str(server_file)]
312
-
313
- # verify start and end of command
314
- assert actual_cmd[: len(expected_start)] == expected_start
315
- assert actual_cmd[-len(expected_end) :] == expected_end
316
-
317
- # verify dependencies are present (order-independent)
318
- deps_section = actual_cmd[len(expected_start) : -len(expected_end)]
319
- assert all(
320
- x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
321
- )
322
-
323
- # Verify subprocess call kwargs, allowing for environment variables
324
- call_kwargs = mock_run.call_args_list[1][1]
325
- assert call_kwargs["check"] is True
326
- assert call_kwargs["shell"] is True
327
- assert isinstance(call_kwargs["env"], dict)
328
- else:
329
- # same verification for unix, just with different command prefix
330
- actual_cmd = mock_run.call_args_list[0][0][0]
331
- expected_start = [
332
- "npx",
333
- "@modelcontextprotocol/inspector",
334
- "uv",
335
- "run",
336
- "--with",
337
- "fastmcp",
338
- ]
339
- expected_end = ["fastmcp", "run", str(server_file)]
340
-
341
- assert actual_cmd[: len(expected_start)] == expected_start
342
- assert actual_cmd[-len(expected_end) :] == expected_end
343
-
344
- deps_section = actual_cmd[len(expected_start) : -len(expected_end)]
345
- assert all(
346
- x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
347
- )
348
-
349
- # Verify subprocess call kwargs, allowing for environment variables
350
- call_kwargs = mock_run.call_args_list[0][1]
351
- assert call_kwargs["check"] is True
352
- assert call_kwargs["shell"] is False
353
- assert isinstance(call_kwargs["env"], dict)
354
-
355
-
356
- def test_run_with_dependencies(mock_config, server_file):
357
- """Test that run command does not handle dependencies."""
358
- # Create a server file with dependencies
359
- server_file = server_file.parent / "server_with_deps.py"
360
- server_file.write_text(
361
- """from fastmcp import FastMCP
362
- mcp = FastMCP("test", dependencies=["pandas", "numpy"])
363
-
364
- if __name__ == "__main__":
365
- mcp.run()
366
- """
367
- )
368
-
369
- runner = CliRunner()
370
-
371
- with patch("subprocess.run") as mock_run:
372
- result = runner.invoke(app, ["run", str(server_file)])
373
- assert result.exit_code == 0
374
-
375
- # Run command should not call subprocess.run
376
- mock_run.assert_not_called()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_func_metadata.py DELETED
@@ -1,376 +0,0 @@
1
- from typing import Annotated
2
-
3
- import annotated_types
4
- import pytest
5
- from pydantic import BaseModel, Field
6
-
7
- from fastmcp.utilities.func_metadata import func_metadata
8
-
9
-
10
- class SomeInputModelA(BaseModel):
11
- pass
12
-
13
-
14
- class SomeInputModelB(BaseModel):
15
- class InnerModel(BaseModel):
16
- x: int
17
-
18
- how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
19
- ok: InnerModel
20
- y: None
21
-
22
-
23
- def complex_arguments_fn(
24
- an_int: int,
25
- must_be_none: None,
26
- must_be_none_dumb_annotation: Annotated[None, "blah"],
27
- list_of_ints: list[int],
28
- # list[str] | str is an interesting case because if it comes in as JSON like
29
- # "[\"a\", \"b\"]" then it will be naively parsed as a string.
30
- list_str_or_str: list[str] | str,
31
- an_int_annotated_with_field: Annotated[
32
- int, Field(description="An int with a field")
33
- ],
34
- an_int_annotated_with_field_and_others: Annotated[
35
- int,
36
- str, # Should be ignored, really
37
- Field(description="An int with a field"),
38
- annotated_types.Gt(1),
39
- ],
40
- an_int_annotated_with_junk: Annotated[
41
- int,
42
- "123",
43
- 456,
44
- ],
45
- field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
46
- int, Field(1)
47
- ],
48
- unannotated,
49
- my_model_a: SomeInputModelA,
50
- my_model_a_forward_ref: "SomeInputModelA",
51
- my_model_b: SomeInputModelB,
52
- an_int_annotated_with_field_default: Annotated[
53
- int,
54
- Field(1, description="An int with a field"),
55
- ],
56
- unannotated_with_default=5,
57
- my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
58
- an_int_with_default: int = 1,
59
- must_be_none_with_default: None = None,
60
- an_int_with_equals_field: int = Field(1, ge=0),
61
- int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
62
- ) -> str:
63
- _ = (
64
- an_int,
65
- must_be_none,
66
- must_be_none_dumb_annotation,
67
- list_of_ints,
68
- list_str_or_str,
69
- an_int_annotated_with_field,
70
- an_int_annotated_with_field_and_others,
71
- an_int_annotated_with_junk,
72
- field_with_default_via_field_annotation_before_nondefault_arg,
73
- unannotated,
74
- an_int_annotated_with_field_default,
75
- unannotated_with_default,
76
- my_model_a,
77
- my_model_a_forward_ref,
78
- my_model_b,
79
- my_model_a_with_default,
80
- an_int_with_default,
81
- must_be_none_with_default,
82
- an_int_with_equals_field,
83
- int_annotated_with_default,
84
- )
85
- return "ok!"
86
-
87
-
88
- async def test_complex_function_runtime_arg_validation_non_json():
89
- """Test that basic non-JSON arguments are validated correctly"""
90
- meta = func_metadata(complex_arguments_fn)
91
-
92
- # Test with minimum required arguments
93
- result = await meta.call_fn_with_arg_validation(
94
- complex_arguments_fn,
95
- fn_is_async=False,
96
- arguments_to_validate={
97
- "an_int": 1,
98
- "must_be_none": None,
99
- "must_be_none_dumb_annotation": None,
100
- "list_of_ints": [1, 2, 3],
101
- "list_str_or_str": "hello",
102
- "an_int_annotated_with_field": 42,
103
- "an_int_annotated_with_field_and_others": 5,
104
- "an_int_annotated_with_junk": 100,
105
- "unannotated": "test",
106
- "my_model_a": {},
107
- "my_model_a_forward_ref": {},
108
- "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
109
- },
110
- arguments_to_pass_directly=None,
111
- )
112
- assert result == "ok!"
113
-
114
- # Test with invalid types
115
- with pytest.raises(ValueError):
116
- await meta.call_fn_with_arg_validation(
117
- complex_arguments_fn,
118
- fn_is_async=False,
119
- arguments_to_validate={"an_int": "not an int"},
120
- arguments_to_pass_directly=None,
121
- )
122
-
123
-
124
- async def test_complex_function_runtime_arg_validation_with_json():
125
- """Test that JSON string arguments are parsed and validated correctly"""
126
- meta = func_metadata(complex_arguments_fn)
127
-
128
- result = await meta.call_fn_with_arg_validation(
129
- complex_arguments_fn,
130
- fn_is_async=False,
131
- arguments_to_validate={
132
- "an_int": 1,
133
- "must_be_none": None,
134
- "must_be_none_dumb_annotation": None,
135
- "list_of_ints": "[1, 2, 3]", # JSON string
136
- "list_str_or_str": '["a", "b", "c"]', # JSON string
137
- "an_int_annotated_with_field": 42,
138
- "an_int_annotated_with_field_and_others": "5", # JSON string
139
- "an_int_annotated_with_junk": 100,
140
- "unannotated": "test",
141
- "my_model_a": "{}", # JSON string
142
- "my_model_a_forward_ref": "{}", # JSON string
143
- "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', # JSON string
144
- },
145
- arguments_to_pass_directly=None,
146
- )
147
- assert result == "ok!"
148
-
149
-
150
- def test_str_vs_list_str():
151
- """Test handling of string vs list[str] type annotations.
152
-
153
- This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
154
- We want to make sure it's kept as a python string.
155
- """
156
-
157
- def func_with_str_types(str_or_list: str | list[str]):
158
- return str_or_list
159
-
160
- meta = func_metadata(func_with_str_types)
161
-
162
- # Test string input for union type
163
- result = meta.pre_parse_json({"str_or_list": "hello"})
164
- assert result["str_or_list"] == "hello"
165
-
166
- # Test string input that contains valid JSON for union type
167
- # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
168
- # kept as a raw string
169
- result = meta.pre_parse_json({"str_or_list": '"hello"'})
170
- assert result["str_or_list"] == '"hello"'
171
-
172
- # Test list input for union type
173
- result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
174
- assert result["str_or_list"] == ["hello", "world"]
175
-
176
-
177
- def test_str_vs_int():
178
- """
179
- Test that string values are kept as strings even when they contain numbers,
180
- while numbers are parsed correctly.
181
- """
182
-
183
- def func_with_str_and_int(a: str, b: int):
184
- return a
185
-
186
- meta = func_metadata(func_with_str_and_int)
187
- result = meta.pre_parse_json({"a": "123", "b": 123})
188
- assert result["a"] == "123"
189
- assert result["b"] == 123
190
-
191
-
192
- def test_skip_names():
193
- """Test that skipped parameters are not included in the model"""
194
-
195
- def func_with_many_params(
196
- keep_this: int, skip_this: str, also_keep: float, also_skip: bool
197
- ):
198
- return keep_this, skip_this, also_keep, also_skip
199
-
200
- # Skip some parameters
201
- meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
202
-
203
- # Check model fields
204
- assert "keep_this" in meta.arg_model.model_fields
205
- assert "also_keep" in meta.arg_model.model_fields
206
- assert "skip_this" not in meta.arg_model.model_fields
207
- assert "also_skip" not in meta.arg_model.model_fields
208
-
209
- # Validate that we can call with only non-skipped parameters
210
- model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
211
- assert model.keep_this == 1 # type: ignore
212
- assert model.also_keep == 2.5 # type: ignore
213
-
214
-
215
- async def test_lambda_function():
216
- """Test lambda function schema and validation"""
217
- fn = lambda x, y=5: x # noqa: E731
218
- meta = func_metadata(lambda x, y=5: x)
219
-
220
- # Test schema
221
- assert meta.arg_model.model_json_schema() == {
222
- "properties": {
223
- "x": {"title": "x", "type": "string"},
224
- "y": {"default": 5, "title": "y", "type": "string"},
225
- },
226
- "required": ["x"],
227
- "title": "<lambda>Arguments",
228
- "type": "object",
229
- }
230
-
231
- async def check_call(args):
232
- return await meta.call_fn_with_arg_validation(
233
- fn,
234
- fn_is_async=False,
235
- arguments_to_validate=args,
236
- arguments_to_pass_directly=None,
237
- )
238
-
239
- # Basic calls
240
- assert await check_call({"x": "hello"}) == "hello"
241
- assert await check_call({"x": "hello", "y": "world"}) == "hello"
242
- assert await check_call({"x": '"hello"'}) == '"hello"'
243
-
244
- # Missing required arg
245
- with pytest.raises(ValueError):
246
- await check_call({"y": "world"})
247
-
248
-
249
- def test_complex_function_json_schema():
250
- meta = func_metadata(complex_arguments_fn)
251
- assert meta.arg_model.model_json_schema() == {
252
- "$defs": {
253
- "InnerModel": {
254
- "properties": {"x": {"title": "X", "type": "integer"}},
255
- "required": ["x"],
256
- "title": "InnerModel",
257
- "type": "object",
258
- },
259
- "SomeInputModelA": {
260
- "properties": {},
261
- "title": "SomeInputModelA",
262
- "type": "object",
263
- },
264
- "SomeInputModelB": {
265
- "properties": {
266
- "how_many_shrimp": {
267
- "description": "How many shrimp in the tank???",
268
- "title": "How Many Shrimp",
269
- "type": "integer",
270
- },
271
- "ok": {"$ref": "#/$defs/InnerModel"},
272
- "y": {"title": "Y", "type": "null"},
273
- },
274
- "required": ["how_many_shrimp", "ok", "y"],
275
- "title": "SomeInputModelB",
276
- "type": "object",
277
- },
278
- },
279
- "properties": {
280
- "an_int": {"title": "An Int", "type": "integer"},
281
- "must_be_none": {"title": "Must Be None", "type": "null"},
282
- "must_be_none_dumb_annotation": {
283
- "title": "Must Be None Dumb Annotation",
284
- "type": "null",
285
- },
286
- "list_of_ints": {
287
- "items": {"type": "integer"},
288
- "title": "List Of Ints",
289
- "type": "array",
290
- },
291
- "list_str_or_str": {
292
- "anyOf": [
293
- {"items": {"type": "string"}, "type": "array"},
294
- {"type": "string"},
295
- ],
296
- "title": "List Str Or Str",
297
- },
298
- "an_int_annotated_with_field": {
299
- "description": "An int with a field",
300
- "title": "An Int Annotated With Field",
301
- "type": "integer",
302
- },
303
- "an_int_annotated_with_field_and_others": {
304
- "description": "An int with a field",
305
- "exclusiveMinimum": 1,
306
- "title": "An Int Annotated With Field And Others",
307
- "type": "integer",
308
- },
309
- "an_int_annotated_with_junk": {
310
- "title": "An Int Annotated With Junk",
311
- "type": "integer",
312
- },
313
- "field_with_default_via_field_annotation_before_nondefault_arg": {
314
- "default": 1,
315
- "title": "Field With Default Via Field Annotation Before Nondefault Arg",
316
- "type": "integer",
317
- },
318
- "unannotated": {"title": "unannotated", "type": "string"},
319
- "my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
320
- "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
321
- "my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
322
- "an_int_annotated_with_field_default": {
323
- "default": 1,
324
- "description": "An int with a field",
325
- "title": "An Int Annotated With Field Default",
326
- "type": "integer",
327
- },
328
- "unannotated_with_default": {
329
- "default": 5,
330
- "title": "unannotated_with_default",
331
- "type": "string",
332
- },
333
- "my_model_a_with_default": {
334
- "$ref": "#/$defs/SomeInputModelA",
335
- "default": {},
336
- },
337
- "an_int_with_default": {
338
- "default": 1,
339
- "title": "An Int With Default",
340
- "type": "integer",
341
- },
342
- "must_be_none_with_default": {
343
- "default": None,
344
- "title": "Must Be None With Default",
345
- "type": "null",
346
- },
347
- "an_int_with_equals_field": {
348
- "default": 1,
349
- "minimum": 0,
350
- "title": "An Int With Equals Field",
351
- "type": "integer",
352
- },
353
- "int_annotated_with_default": {
354
- "default": 5,
355
- "description": "hey",
356
- "title": "Int Annotated With Default",
357
- "type": "integer",
358
- },
359
- },
360
- "required": [
361
- "an_int",
362
- "must_be_none",
363
- "must_be_none_dumb_annotation",
364
- "list_of_ints",
365
- "list_str_or_str",
366
- "an_int_annotated_with_field",
367
- "an_int_annotated_with_field_and_others",
368
- "an_int_annotated_with_junk",
369
- "unannotated",
370
- "my_model_a",
371
- "my_model_a_forward_ref",
372
- "my_model_b",
373
- ],
374
- "title": "complex_arguments_fnArguments",
375
- "type": "object",
376
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_server.py DELETED
@@ -1,656 +0,0 @@
1
- import base64
2
- from pathlib import Path
3
- from typing import TYPE_CHECKING, Union
4
-
5
- import pytest
6
- from mcp.shared.exceptions import McpError
7
- from mcp.shared.memory import (
8
- create_connected_server_and_client_session as client_session,
9
- )
10
- from mcp.types import (
11
- ImageContent,
12
- TextContent,
13
- TextResourceContents,
14
- BlobResourceContents,
15
- )
16
- from pydantic import AnyUrl
17
-
18
- from fastmcp import Context, FastMCP
19
- from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
20
- from fastmcp.resources import FileResource, FunctionResource
21
- from fastmcp.utilities.types import Image
22
-
23
- if TYPE_CHECKING:
24
- from fastmcp import Context
25
-
26
-
27
- class TestServer:
28
- async def test_create_server(self):
29
- mcp = FastMCP()
30
- assert mcp.name == "FastMCP"
31
-
32
- async def test_add_tool_decorator(self):
33
- mcp = FastMCP()
34
-
35
- @mcp.tool()
36
- def add(x: int, y: int) -> int:
37
- return x + y
38
-
39
- assert len(mcp._tool_manager.list_tools()) == 1
40
-
41
- async def test_add_tool_decorator_incorrect_usage(self):
42
- mcp = FastMCP()
43
-
44
- with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
45
-
46
- @mcp.tool # Missing parentheses #type: ignore
47
- def add(x: int, y: int) -> int:
48
- return x + y
49
-
50
- async def test_add_resource_decorator(self):
51
- mcp = FastMCP()
52
-
53
- @mcp.resource("r://{x}")
54
- def get_data(x: str) -> str:
55
- return f"Data: {x}"
56
-
57
- assert len(mcp._resource_manager._templates) == 1
58
-
59
- async def test_add_resource_decorator_incorrect_usage(self):
60
- mcp = FastMCP()
61
-
62
- with pytest.raises(
63
- TypeError, match="The @resource decorator was used incorrectly"
64
- ):
65
-
66
- @mcp.resource # Missing parentheses #type: ignore
67
- def get_data(x: str) -> str:
68
- return f"Data: {x}"
69
-
70
-
71
- def tool_fn(x: int, y: int) -> int:
72
- return x + y
73
-
74
-
75
- def error_tool_fn() -> None:
76
- raise ValueError("Test error")
77
-
78
-
79
- def image_tool_fn(path: str) -> Image:
80
- return Image(path)
81
-
82
-
83
- def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]:
84
- return [
85
- TextContent(type="text", text="Hello"),
86
- ImageContent(type="image", data="abc", mimeType="image/png"),
87
- ]
88
-
89
-
90
- class TestServerTools:
91
- async def test_add_tool(self):
92
- mcp = FastMCP()
93
- mcp.add_tool(tool_fn)
94
- mcp.add_tool(tool_fn)
95
- assert len(mcp._tool_manager.list_tools()) == 1
96
-
97
- async def test_list_tools(self):
98
- mcp = FastMCP()
99
- mcp.add_tool(tool_fn)
100
- async with client_session(mcp._mcp_server) as client:
101
- tools = await client.list_tools()
102
- assert len(tools.tools) == 1
103
-
104
- async def test_call_tool(self):
105
- mcp = FastMCP()
106
- mcp.add_tool(tool_fn)
107
- async with client_session(mcp._mcp_server) as client:
108
- result = await client.call_tool("my_tool", {"arg1": "value"})
109
- assert not hasattr(result, "error")
110
- assert len(result.content) > 0
111
-
112
- async def test_tool_exception_handling(self):
113
- mcp = FastMCP()
114
- mcp.add_tool(error_tool_fn)
115
- async with client_session(mcp._mcp_server) as client:
116
- result = await client.call_tool("error_tool_fn", {})
117
- assert len(result.content) == 1
118
- content = result.content[0]
119
- assert isinstance(content, TextContent)
120
- assert "Test error" in content.text
121
- assert result.isError is True
122
-
123
- async def test_tool_error_handling(self):
124
- mcp = FastMCP()
125
- mcp.add_tool(error_tool_fn)
126
- async with client_session(mcp._mcp_server) as client:
127
- result = await client.call_tool("error_tool_fn", {})
128
- assert len(result.content) == 1
129
- content = result.content[0]
130
- assert isinstance(content, TextContent)
131
- assert "Test error" in content.text
132
- assert result.isError is True
133
-
134
- async def test_tool_error_details(self):
135
- """Test that exception details are properly formatted in the response"""
136
- mcp = FastMCP()
137
- mcp.add_tool(error_tool_fn)
138
- async with client_session(mcp._mcp_server) as client:
139
- result = await client.call_tool("error_tool_fn", {})
140
- content = result.content[0]
141
- assert isinstance(content, TextContent)
142
- assert isinstance(content.text, str)
143
- assert "Test error" in content.text
144
- assert result.isError is True
145
-
146
- async def test_tool_return_value_conversion(self):
147
- mcp = FastMCP()
148
- mcp.add_tool(tool_fn)
149
- async with client_session(mcp._mcp_server) as client:
150
- result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
151
- assert len(result.content) == 1
152
- content = result.content[0]
153
- assert isinstance(content, TextContent)
154
- assert content.text == "3"
155
-
156
- async def test_tool_image_helper(self, tmp_path: Path):
157
- # Create a test image
158
- image_path = tmp_path / "test.png"
159
- image_path.write_bytes(b"fake png data")
160
-
161
- mcp = FastMCP()
162
- mcp.add_tool(image_tool_fn)
163
- async with client_session(mcp._mcp_server) as client:
164
- result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
165
- assert len(result.content) == 1
166
- content = result.content[0]
167
- assert isinstance(content, ImageContent)
168
- assert content.type == "image"
169
- assert content.mimeType == "image/png"
170
- # Verify base64 encoding
171
- decoded = base64.b64decode(content.data)
172
- assert decoded == b"fake png data"
173
-
174
- async def test_tool_mixed_content(self):
175
- mcp = FastMCP()
176
- mcp.add_tool(mixed_content_tool_fn)
177
- async with client_session(mcp._mcp_server) as client:
178
- result = await client.call_tool("mixed_content_tool_fn", {})
179
- assert len(result.content) == 2
180
- content1 = result.content[0]
181
- content2 = result.content[1]
182
- assert isinstance(content1, TextContent)
183
- assert content1.text == "Hello"
184
- assert isinstance(content2, ImageContent)
185
- assert content2.mimeType == "image/png"
186
- assert content2.data == "abc"
187
-
188
- async def test_tool_mixed_list_with_image(self, tmp_path: Path):
189
- """Test that lists containing Image objects and other types are handled correctly"""
190
- # Create a test image
191
- image_path = tmp_path / "test.png"
192
- image_path.write_bytes(b"test image data")
193
-
194
- def mixed_list_fn() -> list:
195
- return [
196
- "text message",
197
- Image(image_path),
198
- {"key": "value"},
199
- TextContent(type="text", text="direct content"),
200
- ]
201
-
202
- mcp = FastMCP()
203
- mcp.add_tool(mixed_list_fn)
204
- async with client_session(mcp._mcp_server) as client:
205
- result = await client.call_tool("mixed_list_fn", {})
206
- assert len(result.content) == 4
207
- # Check text conversion
208
- content1 = result.content[0]
209
- assert isinstance(content1, TextContent)
210
- assert content1.text == "text message"
211
- # Check image conversion
212
- content2 = result.content[1]
213
- assert isinstance(content2, ImageContent)
214
- assert content2.mimeType == "image/png"
215
- assert base64.b64decode(content2.data) == b"test image data"
216
- # Check dict conversion
217
- content3 = result.content[2]
218
- assert isinstance(content3, TextContent)
219
- assert '"key": "value"' in content3.text
220
- # Check direct TextContent
221
- content4 = result.content[3]
222
- assert isinstance(content4, TextContent)
223
- assert content4.text == "direct content"
224
-
225
-
226
- class TestServerResources:
227
- async def test_text_resource(self):
228
- mcp = FastMCP()
229
-
230
- def get_text():
231
- return "Hello, world!"
232
-
233
- resource = FunctionResource(
234
- uri=AnyUrl("resource://test"), name="test", fn=get_text
235
- )
236
- mcp.add_resource(resource)
237
-
238
- async with client_session(mcp._mcp_server) as client:
239
- result = await client.read_resource(AnyUrl("resource://test"))
240
- assert isinstance(result.contents[0], TextResourceContents)
241
- assert result.contents[0].text == "Hello, world!"
242
-
243
- async def test_binary_resource(self):
244
- mcp = FastMCP()
245
-
246
- def get_binary():
247
- return b"Binary data"
248
-
249
- resource = FunctionResource(
250
- uri=AnyUrl("resource://binary"),
251
- name="binary",
252
- fn=get_binary,
253
- mime_type="application/octet-stream",
254
- )
255
- mcp.add_resource(resource)
256
-
257
- async with client_session(mcp._mcp_server) as client:
258
- result = await client.read_resource(AnyUrl("resource://binary"))
259
- assert isinstance(result.contents[0], BlobResourceContents)
260
- assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
261
-
262
- async def test_file_resource_text(self, tmp_path: Path):
263
- mcp = FastMCP()
264
-
265
- # Create a text file
266
- text_file = tmp_path / "test.txt"
267
- text_file.write_text("Hello from file!")
268
-
269
- resource = FileResource(
270
- uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
271
- )
272
- mcp.add_resource(resource)
273
-
274
- async with client_session(mcp._mcp_server) as client:
275
- result = await client.read_resource(AnyUrl("file://test.txt"))
276
- assert isinstance(result.contents[0], TextResourceContents)
277
- assert result.contents[0].text == "Hello from file!"
278
-
279
- async def test_file_resource_binary(self, tmp_path: Path):
280
- mcp = FastMCP()
281
-
282
- # Create a binary file
283
- binary_file = tmp_path / "test.bin"
284
- binary_file.write_bytes(b"Binary file data")
285
-
286
- resource = FileResource(
287
- uri=AnyUrl("file://test.bin"),
288
- name="test.bin",
289
- path=binary_file,
290
- mime_type="application/octet-stream",
291
- )
292
- mcp.add_resource(resource)
293
-
294
- async with client_session(mcp._mcp_server) as client:
295
- result = await client.read_resource(AnyUrl("file://test.bin"))
296
- assert isinstance(result.contents[0], BlobResourceContents)
297
- assert (
298
- result.contents[0].blob
299
- == base64.b64encode(b"Binary file data").decode()
300
- )
301
-
302
-
303
- class TestServerResourceTemplates:
304
- async def test_resource_with_params(self):
305
- """Test that a resource with function parameters raises an error if the URI
306
- parameters don't match"""
307
- mcp = FastMCP()
308
-
309
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
310
-
311
- @mcp.resource("resource://data")
312
- def get_data_fn(param: str) -> str:
313
- return f"Data: {param}"
314
-
315
- async def test_resource_with_uri_params(self):
316
- """Test that a resource with URI parameters is automatically a template"""
317
- mcp = FastMCP()
318
-
319
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
320
-
321
- @mcp.resource("resource://{param}")
322
- def get_data() -> str:
323
- return "Data"
324
-
325
- async def test_resource_with_untyped_params(self):
326
- """Test that a resource with untyped parameters raises an error"""
327
- mcp = FastMCP()
328
-
329
- @mcp.resource("resource://{param}")
330
- def get_data(param) -> str:
331
- return "Data"
332
-
333
- async def test_resource_matching_params(self):
334
- """Test that a resource with matching URI and function parameters works"""
335
- mcp = FastMCP()
336
-
337
- @mcp.resource("resource://{name}/data")
338
- def get_data(name: str) -> str:
339
- return f"Data for {name}"
340
-
341
- async with client_session(mcp._mcp_server) as client:
342
- result = await client.read_resource(AnyUrl("resource://test/data"))
343
- assert isinstance(result.contents[0], TextResourceContents)
344
- assert result.contents[0].text == "Data for test"
345
-
346
- async def test_resource_mismatched_params(self):
347
- """Test that mismatched parameters raise an error"""
348
- mcp = FastMCP()
349
-
350
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
351
-
352
- @mcp.resource("resource://{name}/data")
353
- def get_data(user: str) -> str:
354
- return f"Data for {user}"
355
-
356
- async def test_resource_multiple_params(self):
357
- """Test that multiple parameters work correctly"""
358
- mcp = FastMCP()
359
-
360
- @mcp.resource("resource://{org}/{repo}/data")
361
- def get_data(org: str, repo: str) -> str:
362
- return f"Data for {org}/{repo}"
363
-
364
- async with client_session(mcp._mcp_server) as client:
365
- result = await client.read_resource(
366
- AnyUrl("resource://cursor/fastmcp/data")
367
- )
368
- assert isinstance(result.contents[0], TextResourceContents)
369
- assert result.contents[0].text == "Data for cursor/fastmcp"
370
-
371
- async def test_resource_multiple_mismatched_params(self):
372
- """Test that mismatched parameters raise an error"""
373
- mcp = FastMCP()
374
-
375
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
376
-
377
- @mcp.resource("resource://{org}/{repo}/data")
378
- def get_data_mismatched(org: str, repo_2: str) -> str:
379
- return f"Data for {org}"
380
-
381
- """Test that a resource with no parameters works as a regular resource"""
382
- mcp = FastMCP()
383
-
384
- @mcp.resource("resource://static")
385
- def get_static_data() -> str:
386
- return "Static data"
387
-
388
- async with client_session(mcp._mcp_server) as client:
389
- result = await client.read_resource(AnyUrl("resource://static"))
390
- assert isinstance(result.contents[0], TextResourceContents)
391
- assert result.contents[0].text == "Static data"
392
-
393
- async def test_template_to_resource_conversion(self):
394
- """Test that templates are properly converted to resources when accessed"""
395
- mcp = FastMCP()
396
-
397
- @mcp.resource("resource://{name}/data")
398
- def get_data(name: str) -> str:
399
- return f"Data for {name}"
400
-
401
- # Should be registered as a template
402
- assert len(mcp._resource_manager._templates) == 1
403
- assert len(await mcp.list_resources()) == 0
404
-
405
- # When accessed, should create a concrete resource
406
- resource = await mcp._resource_manager.get_resource("resource://test/data")
407
- assert isinstance(resource, FunctionResource)
408
- result = await resource.read()
409
- assert result == "Data for test"
410
-
411
-
412
- class TestContextInjection:
413
- """Test context injection in tools."""
414
-
415
- async def test_context_detection(self):
416
- """Test that context parameters are properly detected."""
417
- mcp = FastMCP()
418
-
419
- def tool_with_context(x: int, ctx: Context) -> str:
420
- return f"Request {ctx.request_id}: {x}"
421
-
422
- tool = mcp._tool_manager.add_tool(tool_with_context)
423
- assert tool.context_kwarg == "ctx"
424
-
425
- async def test_context_injection(self):
426
- """Test that context is properly injected into tool calls."""
427
- mcp = FastMCP()
428
-
429
- def tool_with_context(x: int, ctx: Context) -> str:
430
- assert ctx.request_id is not None
431
- return f"Request {ctx.request_id}: {x}"
432
-
433
- mcp.add_tool(tool_with_context)
434
- async with client_session(mcp._mcp_server) as client:
435
- result = await client.call_tool("tool_with_context", {"x": 42})
436
- assert len(result.content) == 1
437
- content = result.content[0]
438
- assert isinstance(content, TextContent)
439
- assert "Request" in content.text
440
- assert "42" in content.text
441
-
442
- async def test_async_context(self):
443
- """Test that context works in async functions."""
444
- mcp = FastMCP()
445
-
446
- async def async_tool(x: int, ctx: Context) -> str:
447
- assert ctx.request_id is not None
448
- return f"Async request {ctx.request_id}: {x}"
449
-
450
- mcp.add_tool(async_tool)
451
- async with client_session(mcp._mcp_server) as client:
452
- result = await client.call_tool("async_tool", {"x": 42})
453
- assert len(result.content) == 1
454
- content = result.content[0]
455
- assert isinstance(content, TextContent)
456
- assert "Async request" in content.text
457
- assert "42" in content.text
458
-
459
- async def test_context_logging(self):
460
- """Test that context logging methods work."""
461
- mcp = FastMCP()
462
-
463
- def logging_tool(msg: str, ctx: Context) -> str:
464
- ctx.debug("Debug message")
465
- ctx.info("Info message")
466
- ctx.warning("Warning message")
467
- ctx.error("Error message")
468
- return f"Logged messages for {msg}"
469
-
470
- mcp.add_tool(logging_tool)
471
- async with client_session(mcp._mcp_server) as client:
472
- result = await client.call_tool("logging_tool", {"msg": "test"})
473
- assert len(result.content) == 1
474
- content = result.content[0]
475
- assert isinstance(content, TextContent)
476
- assert "Logged messages for test" in content.text
477
-
478
- async def test_optional_context(self):
479
- """Test that context is optional."""
480
- mcp = FastMCP()
481
-
482
- def no_context(x: int) -> int:
483
- return x * 2
484
-
485
- mcp.add_tool(no_context)
486
- async with client_session(mcp._mcp_server) as client:
487
- result = await client.call_tool("no_context", {"x": 21})
488
- assert len(result.content) == 1
489
- content = result.content[0]
490
- assert isinstance(content, TextContent)
491
- assert content.text == "42"
492
-
493
- async def test_context_resource_access(self):
494
- """Test that context can access resources."""
495
- mcp = FastMCP()
496
-
497
- @mcp.resource("test://data")
498
- def test_resource() -> str:
499
- return "resource data"
500
-
501
- @mcp.tool()
502
- async def tool_with_resource(ctx: Context) -> str:
503
- data = await ctx.read_resource("test://data")
504
- return f"Read resource: {data}"
505
-
506
- async with client_session(mcp._mcp_server) as client:
507
- result = await client.call_tool("tool_with_resource", {})
508
- assert len(result.content) == 1
509
- content = result.content[0]
510
- assert isinstance(content, TextContent)
511
- assert "Read resource: resource data" in content.text
512
-
513
-
514
- class TestServerPrompts:
515
- """Test prompt functionality in FastMCP server."""
516
-
517
- async def test_prompt_decorator(self):
518
- """Test that the prompt decorator registers prompts correctly."""
519
- mcp = FastMCP()
520
-
521
- @mcp.prompt()
522
- def fn() -> str:
523
- return "Hello, world!"
524
-
525
- prompts = mcp._prompt_manager.list_prompts()
526
- assert len(prompts) == 1
527
- assert prompts[0].name == "fn"
528
- # Don't compare functions directly since validate_call wraps them
529
- content = await prompts[0].render()
530
- assert isinstance(content[0].content, TextContent)
531
- assert content[0].content.text == "Hello, world!"
532
-
533
- async def test_prompt_decorator_with_name(self):
534
- """Test prompt decorator with custom name."""
535
- mcp = FastMCP()
536
-
537
- @mcp.prompt(name="custom_name")
538
- def fn() -> str:
539
- return "Hello, world!"
540
-
541
- prompts = mcp._prompt_manager.list_prompts()
542
- assert len(prompts) == 1
543
- assert prompts[0].name == "custom_name"
544
- content = await prompts[0].render()
545
- assert isinstance(content[0].content, TextContent)
546
- assert content[0].content.text == "Hello, world!"
547
-
548
- async def test_prompt_decorator_with_description(self):
549
- """Test prompt decorator with custom description."""
550
- mcp = FastMCP()
551
-
552
- @mcp.prompt(description="A custom description")
553
- def fn() -> str:
554
- return "Hello, world!"
555
-
556
- prompts = mcp._prompt_manager.list_prompts()
557
- assert len(prompts) == 1
558
- assert prompts[0].description == "A custom description"
559
- content = await prompts[0].render()
560
- assert isinstance(content[0].content, TextContent)
561
- assert content[0].content.text == "Hello, world!"
562
-
563
- def test_prompt_decorator_error(self):
564
- """Test error when decorator is used incorrectly."""
565
- mcp = FastMCP()
566
- with pytest.raises(TypeError, match="decorator was used incorrectly"):
567
-
568
- @mcp.prompt # type: ignore
569
- def fn() -> str:
570
- return "Hello, world!"
571
-
572
- async def test_list_prompts(self):
573
- """Test listing prompts through MCP protocol."""
574
- mcp = FastMCP()
575
-
576
- @mcp.prompt()
577
- def fn(name: str, optional: str = "default") -> str:
578
- return f"Hello, {name}!"
579
-
580
- async with client_session(mcp._mcp_server) as client:
581
- result = await client.list_prompts()
582
- assert result.prompts is not None
583
- assert len(result.prompts) == 1
584
- prompt = result.prompts[0]
585
- assert prompt.name == "fn"
586
- assert prompt.arguments is not None
587
- assert len(prompt.arguments) == 2
588
- assert prompt.arguments[0].name == "name"
589
- assert prompt.arguments[0].required is True
590
- assert prompt.arguments[1].name == "optional"
591
- assert prompt.arguments[1].required is False
592
-
593
- async def test_get_prompt(self):
594
- """Test getting a prompt through MCP protocol."""
595
- mcp = FastMCP()
596
-
597
- @mcp.prompt()
598
- def fn(name: str) -> str:
599
- return f"Hello, {name}!"
600
-
601
- async with client_session(mcp._mcp_server) as client:
602
- result = await client.get_prompt("fn", {"name": "World"})
603
- assert len(result.messages) == 1
604
- message = result.messages[0]
605
- assert message.role == "user"
606
- content = message.content
607
- assert isinstance(content, TextContent)
608
- assert content.text == "Hello, World!"
609
-
610
- async def test_get_prompt_with_resource(self):
611
- """Test getting a prompt that returns resource content."""
612
- mcp = FastMCP()
613
-
614
- @mcp.prompt()
615
- def fn() -> Message:
616
- return UserMessage(
617
- content=EmbeddedResource(
618
- type="resource",
619
- resource=TextResourceContents(
620
- uri=AnyUrl("file://file.txt"),
621
- text="File contents",
622
- mimeType="text/plain",
623
- ),
624
- )
625
- )
626
-
627
- async with client_session(mcp._mcp_server) as client:
628
- result = await client.get_prompt("fn")
629
- assert len(result.messages) == 1
630
- message = result.messages[0]
631
- assert message.role == "user"
632
- content = message.content
633
- assert isinstance(content, EmbeddedResource)
634
- resource = content.resource
635
- assert isinstance(resource, TextResourceContents)
636
- assert resource.text == "File contents"
637
- assert resource.mimeType == "text/plain"
638
-
639
- async def test_get_unknown_prompt(self):
640
- """Test error when getting unknown prompt."""
641
- mcp = FastMCP()
642
- async with client_session(mcp._mcp_server) as client:
643
- with pytest.raises(McpError, match="Unknown prompt"):
644
- await client.get_prompt("unknown")
645
-
646
- async def test_get_prompt_missing_args(self):
647
- """Test error when required arguments are missing."""
648
- mcp = FastMCP()
649
-
650
- @mcp.prompt()
651
- def prompt_fn(name: str) -> str:
652
- return f"Hello, {name}!"
653
-
654
- async with client_session(mcp._mcp_server) as client:
655
- with pytest.raises(McpError, match="Missing required arguments"):
656
- await client.get_prompt("prompt_fn")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_tool_manager.py DELETED
@@ -1,306 +0,0 @@
1
- import logging
2
- from typing import Optional
3
-
4
- import pytest
5
- from pydantic import BaseModel
6
- import json
7
- from fastmcp.exceptions import ToolError
8
- from fastmcp.tools import ToolManager
9
-
10
-
11
- class TestAddTools:
12
- def test_basic_function(self):
13
- """Test registering and running a basic function."""
14
-
15
- def add(a: int, b: int) -> int:
16
- """Add two numbers."""
17
- return a + b
18
-
19
- manager = ToolManager()
20
- manager.add_tool(add)
21
-
22
- tool = manager.get_tool("add")
23
- assert tool is not None
24
- assert tool.name == "add"
25
- assert tool.description == "Add two numbers."
26
- assert tool.is_async is False
27
- assert tool.parameters["properties"]["a"]["type"] == "integer"
28
- assert tool.parameters["properties"]["b"]["type"] == "integer"
29
-
30
- async def test_async_function(self):
31
- """Test registering and running an async function."""
32
-
33
- async def fetch_data(url: str) -> str:
34
- """Fetch data from URL."""
35
- return f"Data from {url}"
36
-
37
- manager = ToolManager()
38
- manager.add_tool(fetch_data)
39
-
40
- tool = manager.get_tool("fetch_data")
41
- assert tool is not None
42
- assert tool.name == "fetch_data"
43
- assert tool.description == "Fetch data from URL."
44
- assert tool.is_async is True
45
- assert tool.parameters["properties"]["url"]["type"] == "string"
46
-
47
- def test_pydantic_model_function(self):
48
- """Test registering a function that takes a Pydantic model."""
49
-
50
- class UserInput(BaseModel):
51
- name: str
52
- age: int
53
-
54
- def create_user(user: UserInput, flag: bool) -> dict:
55
- """Create a new user."""
56
- return {"id": 1, **user.model_dump()}
57
-
58
- manager = ToolManager()
59
- manager.add_tool(create_user)
60
-
61
- tool = manager.get_tool("create_user")
62
- assert tool is not None
63
- assert tool.name == "create_user"
64
- assert tool.description == "Create a new user."
65
- assert tool.is_async is False
66
- assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
67
- assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
68
- assert "flag" in tool.parameters["properties"]
69
-
70
- def test_add_invalid_tool(self):
71
- manager = ToolManager()
72
- with pytest.raises(AttributeError):
73
- manager.add_tool(1) # type: ignore
74
-
75
- def test_add_lambda(self):
76
- manager = ToolManager()
77
- tool = manager.add_tool(lambda x: x, name="my_tool")
78
- assert tool.name == "my_tool"
79
-
80
- def test_add_lambda_with_no_name(self):
81
- manager = ToolManager()
82
- with pytest.raises(
83
- ValueError, match="You must provide a name for lambda functions"
84
- ):
85
- manager.add_tool(lambda x: x)
86
-
87
- def test_warn_on_duplicate_tools(self, caplog):
88
- """Test warning on duplicate tools."""
89
-
90
- def f(x: int) -> int:
91
- return x
92
-
93
- manager = ToolManager()
94
- manager.add_tool(f)
95
- with caplog.at_level(logging.WARNING):
96
- manager.add_tool(f)
97
- assert "Tool already exists: f" in caplog.text
98
-
99
- def test_disable_warn_on_duplicate_tools(self, caplog):
100
- """Test disabling warning on duplicate tools."""
101
-
102
- def f(x: int) -> int:
103
- return x
104
-
105
- manager = ToolManager()
106
- manager.add_tool(f)
107
- manager.warn_on_duplicate_tools = False
108
- with caplog.at_level(logging.WARNING):
109
- manager.add_tool(f)
110
- assert "Tool already exists: f" not in caplog.text
111
-
112
-
113
- class TestCallTools:
114
- async def test_call_tool(self):
115
- def add(a: int, b: int) -> int:
116
- """Add two numbers."""
117
- return a + b
118
-
119
- manager = ToolManager()
120
- manager.add_tool(add)
121
- result = await manager.call_tool("add", {"a": 1, "b": 2})
122
- assert result == 3
123
-
124
- async def test_call_async_tool(self):
125
- async def double(n: int) -> int:
126
- """Double a number."""
127
- return n * 2
128
-
129
- manager = ToolManager()
130
- manager.add_tool(double)
131
- result = await manager.call_tool("double", {"n": 5})
132
- assert result == 10
133
-
134
- async def test_call_tool_with_default_args(self):
135
- def add(a: int, b: int = 1) -> int:
136
- """Add two numbers."""
137
- return a + b
138
-
139
- manager = ToolManager()
140
- manager.add_tool(add)
141
- result = await manager.call_tool("add", {"a": 1})
142
- assert result == 2
143
-
144
- async def test_call_tool_with_missing_args(self):
145
- def add(a: int, b: int) -> int:
146
- """Add two numbers."""
147
- return a + b
148
-
149
- manager = ToolManager()
150
- manager.add_tool(add)
151
- with pytest.raises(ToolError):
152
- await manager.call_tool("add", {"a": 1})
153
-
154
- async def test_call_unknown_tool(self):
155
- manager = ToolManager()
156
- with pytest.raises(ToolError):
157
- await manager.call_tool("unknown", {"a": 1})
158
-
159
- async def test_call_tool_with_list_int_input(self):
160
- def sum_vals(vals: list[int]) -> int:
161
- return sum(vals)
162
-
163
- manager = ToolManager()
164
- manager.add_tool(sum_vals)
165
- # Try both with plain list and with JSON list
166
- result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
167
- assert result == 6
168
- result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
169
- assert result == 6
170
-
171
- async def test_call_tool_with_list_str_or_str_input(self):
172
- def concat_strs(vals: list[str] | str) -> str:
173
- return vals if isinstance(vals, str) else "".join(vals)
174
-
175
- manager = ToolManager()
176
- manager.add_tool(concat_strs)
177
- # Try both with plain python object and with JSON list
178
- result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
179
- assert result == "abc"
180
- result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
181
- assert result == "abc"
182
- result = await manager.call_tool("concat_strs", {"vals": "a"})
183
- assert result == "a"
184
- result = await manager.call_tool("concat_strs", {"vals": '"a"'})
185
- assert result == '"a"'
186
-
187
- async def test_call_tool_with_complex_model(self):
188
- from fastmcp import Context
189
-
190
- class MyShrimpTank(BaseModel):
191
- class Shrimp(BaseModel):
192
- name: str
193
-
194
- shrimp: list[Shrimp]
195
- x: None
196
-
197
- def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
198
- return [x.name for x in tank.shrimp]
199
-
200
- manager = ToolManager()
201
- manager.add_tool(name_shrimp)
202
- result = await manager.call_tool(
203
- "name_shrimp",
204
- {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
205
- )
206
- assert result == ["rex", "gertrude"]
207
- result = await manager.call_tool(
208
- "name_shrimp",
209
- {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
210
- )
211
- assert result == ["rex", "gertrude"]
212
-
213
-
214
- class TestToolSchema:
215
- async def test_context_arg_excluded_from_schema(self):
216
- from fastmcp import Context
217
-
218
- def something(a: int, ctx: Context) -> int:
219
- return a
220
-
221
- manager = ToolManager()
222
- tool = manager.add_tool(something)
223
- assert "ctx" not in json.dumps(tool.parameters)
224
- assert "Context" not in json.dumps(tool.parameters)
225
- assert "ctx" not in tool.fn_metadata.arg_model.model_fields
226
-
227
-
228
- class TestContextHandling:
229
- """Test context handling in the tool manager."""
230
-
231
- def test_context_parameter_detection(self):
232
- """Test that context parameters are properly detected in Tool.from_function()."""
233
- from fastmcp import Context
234
-
235
- def tool_with_context(x: int, ctx: Context) -> str:
236
- return str(x)
237
-
238
- manager = ToolManager()
239
- tool = manager.add_tool(tool_with_context)
240
- assert tool.context_kwarg == "ctx"
241
-
242
- def tool_without_context(x: int) -> str:
243
- return str(x)
244
-
245
- tool = manager.add_tool(tool_without_context)
246
- assert tool.context_kwarg is None
247
-
248
- async def test_context_injection(self):
249
- """Test that context is properly injected during tool execution."""
250
- from fastmcp import Context, FastMCP
251
-
252
- def tool_with_context(x: int, ctx: Context) -> str:
253
- assert isinstance(ctx, Context)
254
- return str(x)
255
-
256
- manager = ToolManager()
257
- manager.add_tool(tool_with_context)
258
-
259
- mcp = FastMCP()
260
- ctx = mcp.get_context()
261
- result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
262
- assert result == "42"
263
-
264
- async def test_context_injection_async(self):
265
- """Test that context is properly injected in async tools."""
266
- from fastmcp import Context, FastMCP
267
-
268
- async def async_tool(x: int, ctx: Context) -> str:
269
- assert isinstance(ctx, Context)
270
- return str(x)
271
-
272
- manager = ToolManager()
273
- manager.add_tool(async_tool)
274
-
275
- mcp = FastMCP()
276
- ctx = mcp.get_context()
277
- result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
278
- assert result == "42"
279
-
280
- async def test_context_optional(self):
281
- """Test that context is optional when calling tools."""
282
- from fastmcp import Context
283
-
284
- def tool_with_context(x: int, ctx: Optional[Context] = None) -> str:
285
- return str(x)
286
-
287
- manager = ToolManager()
288
- manager.add_tool(tool_with_context)
289
- # Should not raise an error when context is not provided
290
- result = await manager.call_tool("tool_with_context", {"x": 42})
291
- assert result == "42"
292
-
293
- async def test_context_error_handling(self):
294
- """Test error handling when context injection fails."""
295
- from fastmcp import Context, FastMCP
296
-
297
- def tool_with_context(x: int, ctx: Context) -> str:
298
- raise ValueError("Test error")
299
-
300
- manager = ToolManager()
301
- manager.add_tool(tool_with_context)
302
-
303
- mcp = FastMCP()
304
- ctx = mcp.get_context()
305
- with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
306
- await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
uv.lock CHANGED
@@ -228,18 +228,14 @@ wheels = [
228
 
229
  [[package]]
230
  name = "fastmcp"
231
- version = "0.3.6.dev8+g3b5ae20"
232
  source = { editable = "." }
233
  dependencies = [
234
- { name = "httpx" },
235
  { name = "mcp" },
236
- { name = "pydantic" },
237
- { name = "pydantic-settings" },
238
- { name = "python-dotenv" },
239
- { name = "typer" },
240
  ]
241
 
242
- [package.optional-dependencies]
243
  dev = [
244
  { name = "copychat" },
245
  { name = "ipython" },
@@ -252,41 +248,25 @@ dev = [
252
  { name = "pytest-xdist" },
253
  { name = "ruff" },
254
  ]
255
- tests = [
256
- { name = "pre-commit" },
257
- { name = "pyright" },
258
- { name = "pytest" },
259
- { name = "pytest-asyncio" },
260
- { name = "pytest-flakefinder" },
261
- { name = "pytest-xdist" },
262
- { name = "ruff" },
263
- ]
264
 
265
  [package.metadata]
266
  requires-dist = [
267
- { name = "copychat", marker = "extra == 'dev'", specifier = ">=0.5.2" },
268
- { name = "httpx", specifier = ">=0.26.0" },
269
- { name = "ipython", marker = "extra == 'dev'", specifier = ">=8.12.3" },
270
- { name = "mcp", specifier = ">=1.0.0,<2.0.0" },
271
- { name = "pdbpp", marker = "extra == 'dev'", specifier = ">=0.10.3" },
272
- { name = "pre-commit", marker = "extra == 'dev'" },
273
- { name = "pre-commit", marker = "extra == 'tests'" },
274
- { name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
275
- { name = "pydantic-settings", specifier = ">=2.6.1" },
276
- { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.389" },
277
- { name = "pyright", marker = "extra == 'tests'", specifier = ">=1.1.389" },
278
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
279
- { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
280
- { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
281
- { name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.23.5" },
282
- { name = "pytest-flakefinder", marker = "extra == 'dev'" },
283
- { name = "pytest-flakefinder", marker = "extra == 'tests'" },
284
- { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.1" },
285
- { name = "pytest-xdist", marker = "extra == 'tests'", specifier = ">=3.6.1" },
286
- { name = "python-dotenv", specifier = ">=1.0.1" },
287
- { name = "ruff", marker = "extra == 'dev'" },
288
- { name = "ruff", marker = "extra == 'tests'" },
289
- { name = "typer", specifier = ">=0.9.0" },
290
  ]
291
 
292
  [[package]]
@@ -455,19 +435,21 @@ wheels = [
455
 
456
  [[package]]
457
  name = "mcp"
458
- version = "1.0.0"
459
  source = { registry = "https://pypi.org/simple" }
460
  dependencies = [
461
  { name = "anyio" },
462
  { name = "httpx" },
463
  { name = "httpx-sse" },
464
  { name = "pydantic" },
 
465
  { name = "sse-starlette" },
466
  { name = "starlette" },
 
467
  ]
468
- sdist = { url = "https://files.pythonhosted.org/packages/97/de/a9ec0a1b6439f90ea59f89004bb2e7ec6890dfaeef809751d9e6577dca7e/mcp-1.0.0.tar.gz", hash = "sha256:dba51ce0b5c6a80e25576f606760c49a91ee90210fed805b530ca165d3bbc9b7", size = 82891 }
469
  wheels = [
470
- { url = "https://files.pythonhosted.org/packages/56/89/900c0c8445ec001d3725e475fc553b0feb2e8a51be018f3bb7de51e683db/mcp-1.0.0-py3-none-any.whl", hash = "sha256:bbe70ffa3341cd4da78b5eb504958355c68381fb29971471cea1e642a2af5b8a", size = 36361 },
471
  ]
472
 
473
  [[package]]
 
228
 
229
  [[package]]
230
  name = "fastmcp"
231
+ version = "0.4.2.dev4+g654397b.d20250401"
232
  source = { editable = "." }
233
  dependencies = [
 
234
  { name = "mcp" },
235
+ { name = "rich" },
 
 
 
236
  ]
237
 
238
+ [package.dev-dependencies]
239
  dev = [
240
  { name = "copychat" },
241
  { name = "ipython" },
 
248
  { name = "pytest-xdist" },
249
  { name = "ruff" },
250
  ]
 
 
 
 
 
 
 
 
 
251
 
252
  [package.metadata]
253
  requires-dist = [
254
+ { name = "mcp", specifier = ">=1.6.0,<2.0.0" },
255
+ { name = "rich", specifier = ">=13.9.4" },
256
+ ]
257
+
258
+ [package.metadata.requires-dev]
259
+ dev = [
260
+ { name = "copychat", specifier = ">=0.5.2" },
261
+ { name = "ipython", specifier = ">=8.12.3" },
262
+ { name = "pdbpp", specifier = ">=0.10.3" },
263
+ { name = "pre-commit" },
264
+ { name = "pyright", specifier = ">=1.1.389" },
265
+ { name = "pytest", specifier = ">=8.3.3" },
266
+ { name = "pytest-asyncio", specifier = ">=0.23.5" },
267
+ { name = "pytest-flakefinder" },
268
+ { name = "pytest-xdist", specifier = ">=3.6.1" },
269
+ { name = "ruff" },
 
 
 
 
 
 
 
270
  ]
271
 
272
  [[package]]
 
435
 
436
  [[package]]
437
  name = "mcp"
438
+ version = "1.6.0"
439
  source = { registry = "https://pypi.org/simple" }
440
  dependencies = [
441
  { name = "anyio" },
442
  { name = "httpx" },
443
  { name = "httpx-sse" },
444
  { name = "pydantic" },
445
+ { name = "pydantic-settings" },
446
  { name = "sse-starlette" },
447
  { name = "starlette" },
448
+ { name = "uvicorn" },
449
  ]
450
+ sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 }
451
  wheels = [
452
+ { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 },
453
  ]
454
 
455
  [[package]]