Jeremiah Lowin commited on
Commit
c4ac623
·
1 Parent(s): fcb2602

Improve client return types

Browse files
src/fastmcp/client/client.py CHANGED
@@ -1,7 +1,7 @@
1
  import datetime
2
  from contextlib import AbstractAsyncContextManager
3
  from pathlib import Path
4
- from typing import Any
5
 
6
  import mcp.types
7
  from mcp import ClientSession
@@ -24,6 +24,10 @@ from .transports import ClientTransport, SessionKwargs, infer_transport
24
  __all__ = ["Client", "RootsHandler", "RootsList"]
25
 
26
 
 
 
 
 
27
  class Client:
28
  """
29
  MCP client that delegates connection management to a Transport instance.
@@ -122,60 +126,101 @@ class Client:
122
  """Send a logging/setLevel request."""
123
  await self.session.set_logging_level(level)
124
 
125
- async def list_resources(self) -> mcp.types.ListResourcesResult:
 
 
 
 
126
  """Send a resources/list request."""
127
- return await self.session.list_resources()
 
128
 
129
- async def list_resource_templates(self) -> mcp.types.ListResourceTemplatesResult:
130
  """Send a resources/listResourceTemplates request."""
131
- return await self.session.list_resource_templates()
 
132
 
133
- async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
 
 
134
  """Send a resources/read request."""
135
  if isinstance(uri, str):
136
  uri = AnyUrl(uri) # Ensure AnyUrl
137
- return await self.session.read_resource(uri)
138
-
139
- async def subscribe_resource(self, uri: AnyUrl | str) -> None:
140
- """Send a resources/subscribe request."""
141
- if isinstance(uri, str):
142
- uri = AnyUrl(uri)
143
- await self.session.subscribe_resource(uri)
144
-
145
- async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
146
- """Send a resources/unsubscribe request."""
147
- if isinstance(uri, str):
148
- uri = AnyUrl(uri)
149
- await self.session.unsubscribe_resource(uri)
150
-
151
- async def list_prompts(self) -> mcp.types.ListPromptsResult:
 
152
  """Send a prompts/list request."""
153
- return await self.session.list_prompts()
 
154
 
155
  async def get_prompt(
156
  self, name: str, arguments: dict[str, str] | None = None
157
  ) -> mcp.types.GetPromptResult:
158
  """Send a prompts/get request."""
159
- return await self.session.get_prompt(name, arguments)
 
160
 
161
  async def complete(
162
  self,
163
  ref: mcp.types.ResourceReference | mcp.types.PromptReference,
164
  argument: dict[str, str],
165
- ) -> mcp.types.CompleteResult:
166
- """Send a completion/complete request."""
167
- return await self.session.complete(ref, argument)
 
168
 
169
- async def list_tools(self) -> mcp.types.ListToolsResult:
170
  """Send a tools/list request."""
171
- return await self.session.list_tools()
 
172
 
 
173
  async def call_tool(
174
- self, name: str, arguments: dict[str, Any] | None = None
175
- ) -> mcp.types.CallToolResult:
176
- """Send a tools/call request."""
177
- return await self.session.call_tool(name, arguments)
 
 
 
 
 
 
 
 
 
 
 
178
 
179
- async def send_roots_list_changed(self) -> None:
180
- """Send a roots/list_changed notification."""
181
- await self.session.send_roots_list_changed()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import datetime
2
  from contextlib import AbstractAsyncContextManager
3
  from pathlib import Path
4
+ from typing import Any, Literal, cast, overload
5
 
6
  import mcp.types
7
  from mcp import ClientSession
 
24
  __all__ = ["Client", "RootsHandler", "RootsList"]
25
 
26
 
27
+ class ClientError(ValueError):
28
+ """Base class for errors raised by the client."""
29
+
30
+
31
  class Client:
32
  """
33
  MCP client that delegates connection management to a Transport instance.
 
126
  """Send a logging/setLevel request."""
127
  await self.session.set_logging_level(level)
128
 
129
+ async def send_roots_list_changed(self) -> None:
130
+ """Send a roots/list_changed notification."""
131
+ await self.session.send_roots_list_changed()
132
+
133
+ async def list_resources(self) -> list[mcp.types.Resource]:
134
  """Send a resources/list request."""
135
+ result = await self.session.list_resources()
136
+ return result.resources
137
 
138
+ async def list_resource_templates(self) -> list[mcp.types.ResourceTemplate]:
139
  """Send a resources/listResourceTemplates request."""
140
+ result = await self.session.list_resource_templates()
141
+ return result.resourceTemplates
142
 
143
+ async def read_resource(
144
+ self, uri: AnyUrl | str
145
+ ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
146
  """Send a resources/read request."""
147
  if isinstance(uri, str):
148
  uri = AnyUrl(uri) # Ensure AnyUrl
149
+ result = await self.session.read_resource(uri)
150
+ return result.contents
151
+
152
+ # async def subscribe_resource(self, uri: AnyUrl | str) -> None:
153
+ # """Send a resources/subscribe request."""
154
+ # if isinstance(uri, str):
155
+ # uri = AnyUrl(uri)
156
+ # await self.session.subscribe_resource(uri)
157
+
158
+ # async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
159
+ # """Send a resources/unsubscribe request."""
160
+ # if isinstance(uri, str):
161
+ # uri = AnyUrl(uri)
162
+ # await self.session.unsubscribe_resource(uri)
163
+
164
+ async def list_prompts(self) -> list[mcp.types.Prompt]:
165
  """Send a prompts/list request."""
166
+ result = await self.session.list_prompts()
167
+ return result.prompts
168
 
169
  async def get_prompt(
170
  self, name: str, arguments: dict[str, str] | None = None
171
  ) -> mcp.types.GetPromptResult:
172
  """Send a prompts/get request."""
173
+ result = await self.session.get_prompt(name, arguments)
174
+ return result
175
 
176
  async def complete(
177
  self,
178
  ref: mcp.types.ResourceReference | mcp.types.PromptReference,
179
  argument: dict[str, str],
180
+ ) -> mcp.types.Completion:
181
+ """Send a completion request."""
182
+ result = await self.session.complete(ref, argument)
183
+ return result.completion
184
 
185
+ async def list_tools(self) -> list[mcp.types.Tool]:
186
  """Send a tools/list request."""
187
+ result = await self.session.list_tools()
188
+ return result.tools
189
 
190
+ @overload
191
  async def call_tool(
192
+ self,
193
+ name: str,
194
+ arguments: dict[str, Any] | None = None,
195
+ _return_raw_result: Literal[False] = False,
196
+ ) -> list[
197
+ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
198
+ ]: ...
199
+
200
+ @overload
201
+ async def call_tool(
202
+ self,
203
+ name: str,
204
+ arguments: dict[str, Any] | None = None,
205
+ _return_raw_result: Literal[True] = True,
206
+ ) -> mcp.types.CallToolResult: ...
207
 
208
+ async def call_tool(
209
+ self,
210
+ name: str,
211
+ arguments: dict[str, Any] | None = None,
212
+ _return_raw_result: bool = False,
213
+ ) -> (
214
+ list[
215
+ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
216
+ ]
217
+ | mcp.types.CallToolResult
218
+ ):
219
+ """Send a tools/call request."""
220
+ result = await self.session.call_tool(name, arguments)
221
+ if _return_raw_result:
222
+ return result
223
+ elif result.isError:
224
+ msg = cast(mcp.types.TextContent, result.content[0]).text
225
+ raise ClientError(msg)
226
+ return result.content
src/fastmcp/server/proxy.py CHANGED
@@ -40,11 +40,15 @@ class ProxyTool(Tool):
40
  async def run(
41
  self, arguments: dict[str, Any], context: Context | None = None
42
  ) -> Any:
 
 
43
  async with self._client:
44
- result = await self._client.call_tool(self.name, arguments)
 
 
45
  if result.isError:
46
  raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
47
- return result.content[0]
48
 
49
 
50
  class ProxyResource(Resource):
@@ -73,12 +77,12 @@ class ProxyResource(Resource):
73
 
74
  async with self._client:
75
  result = await self._client.read_resource(self.uri)
76
- if isinstance(result.contents[0], TextResourceContents):
77
- return result.contents[0].text
78
- elif isinstance(result.contents[0], BlobResourceContents):
79
- return result.contents[0].blob
80
  else:
81
- raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
82
 
83
 
84
  class ProxyTemplate(ResourceTemplate):
@@ -103,20 +107,20 @@ class ProxyTemplate(ResourceTemplate):
103
  async with self._client:
104
  result = await self._client.read_resource(uri)
105
 
106
- if isinstance(result.contents[0], TextResourceContents):
107
- value = result.contents[0].text
108
- elif isinstance(result.contents[0], BlobResourceContents):
109
- value = result.contents[0].blob
110
  else:
111
- raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
112
 
113
  return ProxyResource(
114
  client=self._client,
115
  uri=uri,
116
  name=self.name,
117
  description=self.description,
118
- mime_type=result.contents[0].mimeType,
119
- contents=result.contents,
120
  _value=value,
121
  )
122
 
@@ -177,15 +181,15 @@ class FastMCPProxy(FastMCP):
177
 
178
  async with client:
179
  # Register proxies for client tools
180
- tools_result = await client.list_tools()
181
- for tool in tools_result.tools:
182
  tool_proxy = await ProxyTool.from_client(client, tool)
183
  server._tool_manager._tools[tool_proxy.name] = tool_proxy
184
  logger.debug(f"Created proxy for tool: {tool_proxy.name}")
185
 
186
  # Register proxies for client resources
187
- resources_result = await client.list_resources()
188
- for resource in resources_result.resources:
189
  resource_proxy = await ProxyResource.from_client(client, resource)
190
  server._resource_manager._resources[str(resource_proxy.uri)] = (
191
  resource_proxy
@@ -193,8 +197,8 @@ class FastMCPProxy(FastMCP):
193
  logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
194
 
195
  # Register proxies for client resource templates
196
- templates_result = await client.list_resource_templates()
197
- for template in templates_result.resourceTemplates:
198
  template_proxy = await ProxyTemplate.from_client(client, template)
199
  server._resource_manager._templates[template_proxy.uri_template] = (
200
  template_proxy
@@ -204,8 +208,8 @@ class FastMCPProxy(FastMCP):
204
  )
205
 
206
  # Register proxies for client prompts
207
- prompts_result = await client.list_prompts()
208
- for prompt in prompts_result.prompts:
209
  prompt_proxy = await ProxyPrompt.from_client(client, prompt)
210
  server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
211
  logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
 
40
  async def run(
41
  self, arguments: dict[str, Any], context: Context | None = None
42
  ) -> Any:
43
+ # the client context manager will swallow any exceptions inside a TaskGroup
44
+ # so we return the raw result and raise an exception ourselves
45
  async with self._client:
46
+ result = await self._client.call_tool(
47
+ self.name, arguments, _return_raw_result=True
48
+ )
49
  if result.isError:
50
  raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
51
+ return result.content
52
 
53
 
54
  class ProxyResource(Resource):
 
77
 
78
  async with self._client:
79
  result = await self._client.read_resource(self.uri)
80
+ if isinstance(result[0], TextResourceContents):
81
+ return result[0].text
82
+ elif isinstance(result[0], BlobResourceContents):
83
+ return result[0].blob
84
  else:
85
+ raise ValueError(f"Unsupported content type: {type(result[0])}")
86
 
87
 
88
  class ProxyTemplate(ResourceTemplate):
 
107
  async with self._client:
108
  result = await self._client.read_resource(uri)
109
 
110
+ if isinstance(result[0], TextResourceContents):
111
+ value = result[0].text
112
+ elif isinstance(result[0], BlobResourceContents):
113
+ value = result[0].blob
114
  else:
115
+ raise ValueError(f"Unsupported content type: {type(result[0])}")
116
 
117
  return ProxyResource(
118
  client=self._client,
119
  uri=uri,
120
  name=self.name,
121
  description=self.description,
122
+ mime_type=result[0].mimeType,
123
+ contents=result,
124
  _value=value,
125
  )
126
 
 
181
 
182
  async with client:
183
  # Register proxies for client tools
184
+ tools = await client.list_tools()
185
+ for tool in tools:
186
  tool_proxy = await ProxyTool.from_client(client, tool)
187
  server._tool_manager._tools[tool_proxy.name] = tool_proxy
188
  logger.debug(f"Created proxy for tool: {tool_proxy.name}")
189
 
190
  # Register proxies for client resources
191
+ resources = await client.list_resources()
192
+ for resource in resources:
193
  resource_proxy = await ProxyResource.from_client(client, resource)
194
  server._resource_manager._resources[str(resource_proxy.uri)] = (
195
  resource_proxy
 
197
  logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
198
 
199
  # Register proxies for client resource templates
200
+ templates = await client.list_resource_templates()
201
+ for template in templates:
202
  template_proxy = await ProxyTemplate.from_client(client, template)
203
  server._resource_manager._templates[template_proxy.uri_template] = (
204
  template_proxy
 
208
  )
209
 
210
  # Register proxies for client prompts
211
+ prompts = await client.list_prompts()
212
+ for prompt in prompts:
213
  prompt_proxy = await ProxyPrompt.from_client(client, prompt)
214
  server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
215
  logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
tests/client/{test_fastmcp_transport.py → test_client.py} RENAMED
@@ -51,8 +51,8 @@ async def test_list_tools(fastmcp_server):
51
  result = await client.list_tools()
52
 
53
  # Check that our tools are available
54
- assert len(result.tools) == 2
55
- assert set(tool.name for tool in result.tools) == {"greet", "add"}
56
 
57
 
58
  async def test_call_tool(fastmcp_server):
@@ -63,7 +63,7 @@ async def test_call_tool(fastmcp_server):
63
  result = await client.call_tool("greet", {"name": "World"})
64
 
65
  # The result content should contain our greeting
66
- content_str = str(result.content[0])
67
  assert "Hello, World!" in content_str
68
 
69
 
@@ -75,8 +75,8 @@ async def test_list_resources(fastmcp_server):
75
  result = await client.list_resources()
76
 
77
  # Check that our resource is available
78
- assert len(result.resources) == 1
79
- assert str(result.resources[0].uri) == "data://users"
80
 
81
 
82
  async def test_list_prompts(fastmcp_server):
@@ -87,8 +87,8 @@ async def test_list_prompts(fastmcp_server):
87
  result = await client.list_prompts()
88
 
89
  # Check that our prompt is available
90
- assert len(result.prompts) == 1
91
- assert result.prompts[0].name == "welcome"
92
 
93
 
94
  async def test_get_prompt(fastmcp_server):
@@ -115,7 +115,7 @@ async def test_read_resource(fastmcp_server):
115
  result = await client.read_resource(uri)
116
 
117
  # The contents should include our user list
118
- contents_str = str(result.contents[0])
119
  assert "Alice" in contents_str
120
  assert "Bob" in contents_str
121
  assert "Charlie" in contents_str
@@ -145,15 +145,15 @@ async def test_resource_template(fastmcp_server):
145
  result = await client.list_resource_templates()
146
 
147
  # Check that our template is available
148
- assert len(result.resourceTemplates) == 1
149
- assert "data://user/{user_id}" in result.resourceTemplates[0].uriTemplate
150
 
151
  # Now use the template with a specific user_id
152
  uri = cast(AnyUrl, "data://user/123")
153
  result = await client.read_resource(uri)
154
 
155
  # Check the content matches what we expect for the provided user_id
156
- content_str = str(result.contents[0])
157
  assert '"id": "123"' in content_str
158
  assert '"name": "User 123"' in content_str
159
  assert '"active": true' in content_str
 
51
  result = await client.list_tools()
52
 
53
  # Check that our tools are available
54
+ assert len(result) == 2
55
+ assert set(tool.name for tool in result) == {"greet", "add"}
56
 
57
 
58
  async def test_call_tool(fastmcp_server):
 
63
  result = await client.call_tool("greet", {"name": "World"})
64
 
65
  # The result content should contain our greeting
66
+ content_str = str(result[0])
67
  assert "Hello, World!" in content_str
68
 
69
 
 
75
  result = await client.list_resources()
76
 
77
  # Check that our resource is available
78
+ assert len(result) == 1
79
+ assert str(result[0].uri) == "data://users"
80
 
81
 
82
  async def test_list_prompts(fastmcp_server):
 
87
  result = await client.list_prompts()
88
 
89
  # Check that our prompt is available
90
+ assert len(result) == 1
91
+ assert result[0].name == "welcome"
92
 
93
 
94
  async def test_get_prompt(fastmcp_server):
 
115
  result = await client.read_resource(uri)
116
 
117
  # The contents should include our user list
118
+ contents_str = str(result[0])
119
  assert "Alice" in contents_str
120
  assert "Bob" in contents_str
121
  assert "Charlie" in contents_str
 
145
  result = await client.list_resource_templates()
146
 
147
  # Check that our template is available
148
+ assert len(result) == 1
149
+ assert "data://user/{user_id}" in result[0].uriTemplate
150
 
151
  # Now use the template with a specific user_id
152
  uri = cast(AnyUrl, "data://user/123")
153
  result = await client.read_resource(uri)
154
 
155
  # Check the content matches what we expect for the provided user_id
156
+ content_str = str(result[0])
157
  assert '"id": "123"' in content_str
158
  assert '"name": "User 123"' in content_str
159
  assert '"active": true' in content_str
tests/client/test_roots.py CHANGED
@@ -41,8 +41,8 @@ class TestClientRoots:
41
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
42
  async with Client(fastmcp_server, roots=roots) as client:
43
  result = await client.call_tool("list_roots", {})
44
- assert isinstance(result.content[0], TextContent)
45
- assert json.loads(result.content[0].text) == [
46
  "file://x/y/z",
47
  "file://x/y/z",
48
  ]
 
41
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
42
  async with Client(fastmcp_server, roots=roots) as client:
43
  result = await client.call_tool("list_roots", {})
44
+ assert isinstance(result[0], TextContent)
45
+ assert json.loads(result[0].text) == [
46
  "file://x/y/z",
47
  "file://x/y/z",
48
  ]
tests/client/test_sampling.py CHANGED
@@ -47,7 +47,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
- reply = cast(TextContent, result.content[0])
51
  assert reply.text == "This is the sample message!"
52
 
53
 
@@ -62,7 +62,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
62
  result = await client.call_tool(
63
  "sample_with_system_prompt", {"message": "Hello, world!"}
64
  )
65
- reply = cast(TextContent, result.content[0])
66
  assert reply.text == "You love FastMCP"
67
 
68
 
@@ -81,5 +81,5 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
81
  result = await client.call_tool(
82
  "sample_with_messages", {"message": "Hello, world!"}
83
  )
84
- reply = cast(TextContent, result.content[0])
85
  assert reply.text == "I need to think."
 
47
 
48
  async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
49
  result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
50
+ reply = cast(TextContent, result[0])
51
  assert reply.text == "This is the sample message!"
52
 
53
 
 
62
  result = await client.call_tool(
63
  "sample_with_system_prompt", {"message": "Hello, world!"}
64
  )
65
+ reply = cast(TextContent, result[0])
66
  assert reply.text == "You love FastMCP"
67
 
68
 
 
81
  result = await client.call_tool(
82
  "sample_with_messages", {"message": "Hello, world!"}
83
  )
84
+ reply = cast(TextContent, result[0])
85
  assert reply.text == "I need to think."