Jeremiah Lowin commited on
Commit
a0bed44
·
1 Parent(s): 3dc0bc0

Fix server outputs for resources

Browse files
src/fastmcp/cli/cli.py CHANGED
@@ -157,13 +157,12 @@ def dev(
157
  ),
158
  ] = None,
159
  with_packages: Annotated[
160
- Optional[list[str]],
161
  typer.Option(
162
  "--with",
163
  help="Additional packages to install",
164
- multiple=True,
165
  ),
166
- ] = None,
167
  ) -> None:
168
  """Run a FastMCP server with the MCP Inspector."""
169
  file, server_object = _parse_file_path(file_spec)
 
157
  ),
158
  ] = None,
159
  with_packages: Annotated[
160
+ list[str],
161
  typer.Option(
162
  "--with",
163
  help="Additional packages to install",
 
164
  ),
165
+ ] = [],
166
  ) -> None:
167
  """Run a FastMCP server with the MCP Inspector."""
168
  file, server_object = _parse_file_path(file_spec)
src/fastmcp/resources.py CHANGED
@@ -16,12 +16,18 @@ logger = get_logger(__name__)
16
 
17
 
18
  class Resource(BaseModel):
19
- """Base class for all resources."""
 
 
 
 
 
20
 
21
  uri: _BaseUrl
22
  name: str
23
  description: Optional[str] = None
24
- mime_type: str = "text/plain"
 
25
 
26
  @field_validator("name", mode="before")
27
  @classmethod
@@ -36,15 +42,20 @@ class Resource(BaseModel):
36
  raise ValueError("Either name or uri must be provided")
37
 
38
  @abc.abstractmethod
39
- async def read(self) -> str:
40
- """Read the resource content."""
 
 
 
 
 
41
  return ""
42
 
43
 
44
  class FunctionResource(Resource):
45
  """A resource that is generated by a function call.
46
 
47
- The function can be sync or async and must return a string
48
  or another Resource.
49
  """
50
 
@@ -55,7 +66,7 @@ class FunctionResource(Resource):
55
  super().__init__(**data)
56
  self.is_async = asyncio.iscoroutinefunction(self.func)
57
 
58
- async def read(self) -> str:
59
  """Read the resource content by calling the function."""
60
  try:
61
  result = (
@@ -67,7 +78,7 @@ class FunctionResource(Resource):
67
  if isinstance(result, Resource):
68
  return await result.read()
69
  if isinstance(result, bytes):
70
- return result.decode()
71
  if not isinstance(result, str):
72
  try:
73
  return json.dumps(result, default=pydantic.json.pydantic_encoder)
@@ -91,9 +102,11 @@ class FileResource(Resource):
91
  raise ValueError(f"Path must be absolute: {path}")
92
  return path
93
 
94
- async def read(self) -> str:
95
  """Read the file content."""
96
  try:
 
 
97
  return await asyncio.to_thread(self.path.read_text)
98
  except FileNotFoundError:
99
  raise FileNotFoundError(f"File not found: {self.path}")
@@ -109,13 +122,13 @@ class HttpResource(Resource):
109
  url: str
110
  headers: Optional[Dict[str, str]] = None
111
 
112
- async def read(self) -> str:
113
  """Read the HTTP resource content."""
114
  try:
115
  async with httpx.AsyncClient() as client:
116
  response = await client.get(self.url, headers=self.headers)
117
  response.raise_for_status()
118
- return response.text
119
  except httpx.HTTPStatusError as e:
120
  raise ValueError(f"HTTP error {e.response.status_code}: {e}")
121
  except httpx.RequestError as e:
@@ -128,7 +141,7 @@ class DirectoryResource(Resource):
128
  path: Path
129
  recursive: bool = False
130
  pattern: Optional[str] = None
131
- mime_type: str = "application/json"
132
 
133
  @field_validator("path")
134
  @classmethod
@@ -160,7 +173,7 @@ class DirectoryResource(Resource):
160
  except Exception as e:
161
  raise ValueError(f"Error listing directory {self.path}: {e}")
162
 
163
- async def read(self) -> str:
164
  """Read the directory listing."""
165
  try:
166
  files = await asyncio.to_thread(self.list_files)
 
16
 
17
 
18
  class Resource(BaseModel):
19
+ """Base class for all resources.
20
+
21
+ Resources can contain either text (UTF-8 encoded) or binary data.
22
+ Text resources are suitable for source code, logs, JSON, etc.
23
+ Binary resources are suitable for images, PDFs, audio, etc.
24
+ """
25
 
26
  uri: _BaseUrl
27
  name: str
28
  description: Optional[str] = None
29
+ mime_type: Optional[str] = None
30
+ is_binary: bool = False
31
 
32
  @field_validator("name", mode="before")
33
  @classmethod
 
42
  raise ValueError("Either name or uri must be provided")
43
 
44
  @abc.abstractmethod
45
+ async def read(self) -> Union[str, bytes]:
46
+ """Read the resource content.
47
+
48
+ Returns:
49
+ Union[str, bytes]: Text content as str for text resources,
50
+ binary content as bytes for binary resources
51
+ """
52
  return ""
53
 
54
 
55
  class FunctionResource(Resource):
56
  """A resource that is generated by a function call.
57
 
58
+ The function can be sync or async and must return a string, bytes,
59
  or another Resource.
60
  """
61
 
 
66
  super().__init__(**data)
67
  self.is_async = asyncio.iscoroutinefunction(self.func)
68
 
69
+ async def read(self) -> Union[str, bytes]:
70
  """Read the resource content by calling the function."""
71
  try:
72
  result = (
 
78
  if isinstance(result, Resource):
79
  return await result.read()
80
  if isinstance(result, bytes):
81
+ return result
82
  if not isinstance(result, str):
83
  try:
84
  return json.dumps(result, default=pydantic.json.pydantic_encoder)
 
102
  raise ValueError(f"Path must be absolute: {path}")
103
  return path
104
 
105
+ async def read(self) -> Union[str, bytes]:
106
  """Read the file content."""
107
  try:
108
+ if self.is_binary:
109
+ return await asyncio.to_thread(self.path.read_bytes)
110
  return await asyncio.to_thread(self.path.read_text)
111
  except FileNotFoundError:
112
  raise FileNotFoundError(f"File not found: {self.path}")
 
122
  url: str
123
  headers: Optional[Dict[str, str]] = None
124
 
125
+ async def read(self) -> Union[str, bytes]:
126
  """Read the HTTP resource content."""
127
  try:
128
  async with httpx.AsyncClient() as client:
129
  response = await client.get(self.url, headers=self.headers)
130
  response.raise_for_status()
131
+ return response.content if self.is_binary else response.text
132
  except httpx.HTTPStatusError as e:
133
  raise ValueError(f"HTTP error {e.response.status_code}: {e}")
134
  except httpx.RequestError as e:
 
141
  path: Path
142
  recursive: bool = False
143
  pattern: Optional[str] = None
144
+ mime_type: Optional[str] = "application/json"
145
 
146
  @field_validator("path")
147
  @classmethod
 
173
  except Exception as e:
174
  raise ValueError(f"Error listing directory {self.path}: {e}")
175
 
176
+ async def read(self) -> str: # Always returns JSON string
177
  """Read the directory listing."""
178
  try:
179
  files = await asyncio.to_thread(self.list_files)
src/fastmcp/server.py CHANGED
@@ -1,22 +1,27 @@
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
 
3
  import asyncio
4
- import base64
5
  import functools
6
  import json
 
7
  from typing import Any, Callable, Optional, Sequence, Union, Literal
8
 
 
9
  from mcp.server import Server as MCPServer
10
  from mcp.server.stdio import stdio_server
11
  from mcp.server.sse import SseServerTransport
12
- from mcp.types import Resource as MCPResource
13
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
14
- from pydantic import BaseModel
 
 
 
15
  from pydantic_settings import BaseSettings
16
  from pydantic.networks import _BaseUrl
 
17
  from .exceptions import ResourceError
18
  from .resources import Resource, FunctionResource, ResourceManager
19
- from .tools import ToolManager
20
  from .utilities.logging import get_logger, configure_logging
21
 
22
  logger = get_logger(__name__)
@@ -33,7 +38,9 @@ class Settings(BaseSettings):
33
 
34
  # Server settings
35
  debug: bool = False
36
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
 
 
37
 
38
  # HTTP settings
39
  host: str = "0.0.0.0"
@@ -73,12 +80,14 @@ class FastMCP:
73
  Args:
74
  transport: Transport protocol to use ("stdio" or "sse")
75
  """
 
 
 
 
76
  if transport == "stdio":
77
  asyncio.run(self.run_stdio_async())
78
- elif transport == "sse":
79
  asyncio.run(self.run_sse_async())
80
- else:
81
- raise ValueError(f"Unknown transport: {transport}")
82
 
83
  def _setup_handlers(self) -> None:
84
  """Set up core MCP protocol handlers."""
@@ -101,10 +110,20 @@ class FastMCP:
101
 
102
  async def call_tool(
103
  self, name: str, arguments: dict
104
- ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
105
  """Call a tool by name with arguments."""
106
- result = await self._tool_manager.call_tool(name, arguments)
107
- return [self._convert_to_content(result)]
 
 
 
 
 
 
 
 
 
 
108
 
109
  async def list_resources(self) -> list[MCPResource]:
110
  """List all available resources."""
@@ -134,21 +153,31 @@ class FastMCP:
134
 
135
  def _convert_to_content(
136
  self, value: Any
137
- ) -> Union[TextContent, ImageContent, EmbeddedResource]:
138
- """Convert Python values to MCP content types."""
139
- if isinstance(value, (dict, list)):
140
- return TextContent(type="text", text=json.dumps(value, indent=2))
141
- if isinstance(value, str):
142
- return TextContent(type="text", text=value)
143
- if isinstance(value, bytes):
144
- return ImageContent(
145
- type="image",
146
- data=base64.b64encode(value).decode(),
147
- mimeType="application/octet-stream",
 
 
 
 
 
 
 
 
 
 
 
 
148
  )
149
- if isinstance(value, BaseModel):
150
- return TextContent(type="text", text=value.model_dump_json(indent=2))
151
- return TextContent(type="text", text=str(value))
152
 
153
  def add_tool(
154
  self,
 
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
 
3
  import asyncio
 
4
  import functools
5
  import json
6
+ import logging
7
  from typing import Any, Callable, Optional, Sequence, Union, Literal
8
 
9
+ import pydantic.json
10
  from mcp.server import Server as MCPServer
11
  from mcp.server.stdio import stdio_server
12
  from mcp.server.sse import SseServerTransport
13
+ from mcp.types import (
14
+ Resource as MCPResource,
15
+ Tool,
16
+ TextContent,
17
+ ImageContent,
18
+ )
19
  from pydantic_settings import BaseSettings
20
  from pydantic.networks import _BaseUrl
21
+
22
  from .exceptions import ResourceError
23
  from .resources import Resource, FunctionResource, ResourceManager
24
+ from .tools import ToolManager, Image
25
  from .utilities.logging import get_logger, configure_logging
26
 
27
  logger = get_logger(__name__)
 
38
 
39
  # Server settings
40
  debug: bool = False
41
+ log_level: Literal[
42
+ logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL
43
+ ] = logging.INFO
44
 
45
  # HTTP settings
46
  host: str = "0.0.0.0"
 
80
  Args:
81
  transport: Transport protocol to use ("stdio" or "sse")
82
  """
83
+ TRANSPORTS = Literal["stdio", "sse"]
84
+ if transport not in TRANSPORTS.__args__: # type: ignore
85
+ raise ValueError(f"Unknown transport: {transport}")
86
+
87
  if transport == "stdio":
88
  asyncio.run(self.run_stdio_async())
89
+ else: # transport == "sse"
90
  asyncio.run(self.run_sse_async())
 
 
91
 
92
  def _setup_handlers(self) -> None:
93
  """Set up core MCP protocol handlers."""
 
110
 
111
  async def call_tool(
112
  self, name: str, arguments: dict
113
+ ) -> Sequence[Union[TextContent, ImageContent]]:
114
  """Call a tool by name with arguments."""
115
+ try:
116
+ result = await self._tool_manager.call_tool(name, arguments)
117
+ return self._convert_to_content(result)
118
+ except Exception as e:
119
+ logger.error(f"Error calling tool {name}: {e}")
120
+ return [
121
+ TextContent(
122
+ type="text",
123
+ text=str(e),
124
+ is_error=True,
125
+ )
126
+ ]
127
 
128
  async def list_resources(self) -> list[MCPResource]:
129
  """List all available resources."""
 
153
 
154
  def _convert_to_content(
155
  self, value: Any
156
+ ) -> Sequence[Union[TextContent, ImageContent]]:
157
+ """Convert a tool result to MCP content types."""
158
+
159
+ # Already a sequence of valid content types
160
+ if isinstance(value, (list, tuple)):
161
+ if all(isinstance(x, (TextContent, ImageContent)) for x in value):
162
+ return value
163
+
164
+ # Single content type
165
+ if isinstance(value, (TextContent, ImageContent)):
166
+ return [value]
167
+
168
+ # Image helper
169
+ if isinstance(value, Image):
170
+ return [value.to_image_content()]
171
+
172
+ # All other types - convert to JSON string with pydantic encoder
173
+ return [
174
+ TextContent(
175
+ type="text",
176
+ text=json.dumps(
177
+ value, indent=2, default=pydantic.json.pydantic_encoder
178
+ ),
179
  )
180
+ ]
 
 
181
 
182
  def add_tool(
183
  self,
src/fastmcp/tools.py CHANGED
@@ -1,8 +1,11 @@
1
  """Tool management for FastMCP."""
2
 
 
3
  import inspect
4
- from typing import Any, Callable, Dict, Optional
 
5
 
 
6
  from pydantic import BaseModel, Field, TypeAdapter, validate_call
7
 
8
  from .exceptions import ToolError
@@ -11,6 +14,31 @@ from .utilities.logging import get_logger
11
  logger = get_logger(__name__)
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  class Tool(BaseModel):
15
  """Internal tool registration info."""
16
 
 
1
  """Tool management for FastMCP."""
2
 
3
+ import base64
4
  import inspect
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, Optional, Union
7
 
8
+ from mcp.types import ImageContent
9
  from pydantic import BaseModel, Field, TypeAdapter, validate_call
10
 
11
  from .exceptions import ToolError
 
14
  logger = get_logger(__name__)
15
 
16
 
17
+ class Image:
18
+ """Helper class for returning images from tools."""
19
+
20
+ def __init__(self, path: Union[str, Path], mime_type: Optional[str] = None):
21
+ self.path = Path(path)
22
+ self.mime_type = mime_type or self._guess_mime_type()
23
+
24
+ def _guess_mime_type(self) -> str:
25
+ """Guess MIME type from file extension."""
26
+ suffix = self.path.suffix.lower()
27
+ return {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ }.get(suffix, "application/octet-stream")
34
+
35
+ def to_image_content(self) -> ImageContent:
36
+ """Convert to MCP ImageContent."""
37
+ with open(self.path, "rb") as f:
38
+ data = base64.b64encode(f.read()).decode()
39
+ return ImageContent(type="image", data=data, mimeType=self.mime_type)
40
+
41
+
42
  class Tool(BaseModel):
43
  """Internal tool registration info."""
44
 
tests/test_server.py CHANGED
@@ -2,7 +2,14 @@ from mcp.shared.memory import (
2
  create_connected_server_and_client_session as client_session,
3
  )
4
  from fastmcp import FastMCP
 
 
 
5
  import pytest
 
 
 
 
6
 
7
 
8
  class TestServer:
@@ -53,6 +60,26 @@ def tool_fn(x: int, y: int) -> int:
53
  return x + y
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  class TestServerTools:
57
  async def test_add_tool(self):
58
  mcp = FastMCP()
@@ -74,3 +101,133 @@ class TestServerTools:
74
  result = await client.call_tool("my_tool", {"arg1": "value"})
75
  assert "error" not in result
76
  assert len(result.content) > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  create_connected_server_and_client_session as client_session,
3
  )
4
  from fastmcp import FastMCP
5
+ from fastmcp.resources import FileResource, FunctionResource
6
+ from fastmcp.tools import Image
7
+ from mcp.types import TextContent, ImageContent
8
  import pytest
9
+ from pydantic import BaseModel
10
+ from pathlib import Path
11
+ import base64
12
+ from typing import Union
13
 
14
 
15
  class TestServer:
 
60
  return x + y
61
 
62
 
63
+ def error_tool_fn() -> None:
64
+ raise ValueError("Test error")
65
+
66
+
67
+ class ErrorResponse(BaseModel):
68
+ is_error: bool = True
69
+ message: str
70
+
71
+
72
+ def image_tool_fn(path: str) -> Image:
73
+ return Image(path)
74
+
75
+
76
+ def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]:
77
+ return [
78
+ TextContent(type="text", text="Hello"),
79
+ ImageContent(type="image", data="abc", mimeType="image/png"),
80
+ ]
81
+
82
+
83
  class TestServerTools:
84
  async def test_add_tool(self):
85
  mcp = FastMCP()
 
101
  result = await client.call_tool("my_tool", {"arg1": "value"})
102
  assert "error" not in result
103
  assert len(result.content) > 0
104
+
105
+ async def test_tool_exception_handling(self):
106
+ mcp = FastMCP()
107
+ mcp.add_tool(error_tool_fn)
108
+ async with client_session(mcp._mcp_server) as client:
109
+ result = await client.call_tool("error_tool_fn", {})
110
+ assert len(result.content) == 1
111
+ assert result.content[0].type == "text"
112
+ assert "Test error" in result.content[0].text
113
+ assert result.content[0].is_error is True
114
+
115
+ async def test_tool_exception_content(self):
116
+ """Test that exception details are properly formatted in the response"""
117
+ mcp = FastMCP()
118
+ mcp.add_tool(error_tool_fn)
119
+ async with client_session(mcp._mcp_server) as client:
120
+ result = await client.call_tool("error_tool_fn", {})
121
+ content = result.content[0]
122
+ assert content.type == "text"
123
+ assert isinstance(content.text, str)
124
+ assert "Test error" in content.text
125
+ assert content.is_error is True
126
+
127
+ async def test_tool_text_conversion(self):
128
+ mcp = FastMCP()
129
+ mcp.add_tool(tool_fn)
130
+ async with client_session(mcp._mcp_server) as client:
131
+ result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
132
+ assert len(result.content) == 1
133
+ assert result.content[0].type == "text"
134
+ assert result.content[0].text == "3"
135
+
136
+ async def test_tool_image_helper(self, tmp_path: Path):
137
+ # Create a test image
138
+ image_path = tmp_path / "test.png"
139
+ image_path.write_bytes(b"fake png data")
140
+
141
+ mcp = FastMCP()
142
+ mcp.add_tool(image_tool_fn)
143
+ async with client_session(mcp._mcp_server) as client:
144
+ result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
145
+ assert len(result.content) == 1
146
+ assert result.content[0].type == "image"
147
+ assert result.content[0].mimeType == "image/png"
148
+ # Verify base64 encoding
149
+ decoded = base64.b64decode(result.content[0].data)
150
+ assert decoded == b"fake png data"
151
+
152
+ async def test_tool_mixed_content(self):
153
+ mcp = FastMCP()
154
+ mcp.add_tool(mixed_content_tool_fn)
155
+ async with client_session(mcp._mcp_server) as client:
156
+ result = await client.call_tool("mixed_content_tool_fn", {})
157
+ assert len(result.content) == 2
158
+ assert result.content[0].type == "text"
159
+ assert result.content[0].text == "Hello"
160
+ assert result.content[1].type == "image"
161
+ assert result.content[1].mimeType == "image/png"
162
+ assert result.content[1].data == "abc"
163
+
164
+
165
+ class TestServerResources:
166
+ async def test_text_resource(self):
167
+ mcp = FastMCP()
168
+
169
+ def get_text():
170
+ return "Hello, world!"
171
+
172
+ resource = FunctionResource(uri="resource://test", name="test", func=get_text)
173
+ mcp.add_resource(resource)
174
+
175
+ async with client_session(mcp._mcp_server) as client:
176
+ result = await client.read_resource("resource://test")
177
+ assert result.contents[0].text == "Hello, world!"
178
+
179
+ async def test_binary_resource(self):
180
+ mcp = FastMCP()
181
+
182
+ def get_binary():
183
+ return b"Binary data"
184
+
185
+ resource = FunctionResource(
186
+ uri="resource://binary",
187
+ name="binary",
188
+ func=get_binary,
189
+ is_binary=True,
190
+ mime_type="application/octet-stream",
191
+ )
192
+ mcp.add_resource(resource)
193
+
194
+ async with client_session(mcp._mcp_server) as client:
195
+ result = await client.read_resource("resource://binary")
196
+ assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
197
+
198
+ async def test_file_resource_text(self, tmp_path: Path):
199
+ mcp = FastMCP()
200
+
201
+ # Create a text file
202
+ text_file = tmp_path / "test.txt"
203
+ text_file.write_text("Hello from file!")
204
+
205
+ resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
206
+ mcp.add_resource(resource)
207
+
208
+ async with client_session(mcp._mcp_server) as client:
209
+ result = await client.read_resource("file://test.txt")
210
+ assert result.contents[0].text == "Hello from file!"
211
+
212
+ async def test_file_resource_binary(self, tmp_path: Path):
213
+ mcp = FastMCP()
214
+
215
+ # Create a binary file
216
+ binary_file = tmp_path / "test.bin"
217
+ binary_file.write_bytes(b"Binary file data")
218
+
219
+ resource = FileResource(
220
+ uri="file://test.bin",
221
+ name="test.bin",
222
+ path=binary_file,
223
+ is_binary=True,
224
+ mime_type="application/octet-stream",
225
+ )
226
+ mcp.add_resource(resource)
227
+
228
+ async with client_session(mcp._mcp_server) as client:
229
+ result = await client.read_resource("file://test.bin")
230
+ assert (
231
+ result.contents[0].blob
232
+ == base64.b64encode(b"Binary file data").decode()
233
+ )