gumloop 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {gumloop-0.3.0 → gumloop-0.3.1}/PKG-INFO +1 -1
  2. {gumloop-0.3.0 → gumloop-0.3.1}/pyproject.toml +1 -1
  3. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/__init__.py +1 -1
  4. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/_client.py +6 -4
  5. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/mcp.py +176 -3
  6. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/mcp.py +78 -0
  7. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/types.py +108 -0
  8. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_mcp.py +98 -0
  9. gumloop-0.3.1/tests/sdk/test_mcp.py +264 -0
  10. {gumloop-0.3.0 → gumloop-0.3.1}/uv.lock +1 -1
  11. gumloop-0.3.0/tests/sdk/test_mcp.py +0 -139
  12. {gumloop-0.3.0 → gumloop-0.3.1}/.env.example +0 -0
  13. {gumloop-0.3.0 → gumloop-0.3.1}/.gitignore +0 -0
  14. {gumloop-0.3.0 → gumloop-0.3.1}/.pre-commit-config.yaml +0 -0
  15. {gumloop-0.3.0 → gumloop-0.3.1}/.python-version +0 -0
  16. {gumloop-0.3.0 → gumloop-0.3.1}/LICENSE +0 -0
  17. {gumloop-0.3.0 → gumloop-0.3.1}/README.md +0 -0
  18. {gumloop-0.3.0 → gumloop-0.3.1}/scripts/check +0 -0
  19. {gumloop-0.3.0 → gumloop-0.3.1}/scripts/fmt +0 -0
  20. {gumloop-0.3.0 → gumloop-0.3.1}/scripts/sdk_examples.py +0 -0
  21. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/_http.py +0 -0
  22. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/__init__.py +0 -0
  23. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/__init__.py +0 -0
  24. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_args_input.py +0 -0
  25. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_downloads.py +0 -0
  26. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/agents.py +0 -0
  27. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/artifacts.py +0 -0
  28. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/auth.py +0 -0
  29. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/chat.py +0 -0
  30. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/sessions.py +0 -0
  31. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/skills.py +0 -0
  32. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/console.py +0 -0
  33. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/context.py +0 -0
  34. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/credentials.py +0 -0
  35. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/errors.py +0 -0
  36. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/main.py +0 -0
  37. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/oauth.py +0 -0
  38. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/errors.py +0 -0
  39. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/oauth.py +0 -0
  40. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/py.typed +0 -0
  41. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/__init__.py +0 -0
  42. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/agents.py +0 -0
  43. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/artifacts.py +0 -0
  44. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/chat.py +0 -0
  45. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/sessions.py +0 -0
  46. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/skills.py +0 -0
  47. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/teams.py +0 -0
  48. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/__init__.py +0 -0
  49. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/_compat.py +0 -0
  50. {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/_extensions.py +0 -0
  51. {gumloop-0.3.0 → gumloop-0.3.1}/tests/__init__.py +0 -0
  52. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/__init__.py +0 -0
  53. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/conftest.py +0 -0
  54. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_agents.py +0 -0
  55. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_args_input.py +0 -0
  56. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_artifacts.py +0 -0
  57. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_chat.py +0 -0
  58. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_context.py +0 -0
  59. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_credentials.py +0 -0
  60. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_login.py +0 -0
  61. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_main.py +0 -0
  62. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_sessions.py +0 -0
  63. {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_skills.py +0 -0
  64. {gumloop-0.3.0 → gumloop-0.3.1}/tests/conftest.py +0 -0
  65. {gumloop-0.3.0 → gumloop-0.3.1}/tests/integration/conftest.py +0 -0
  66. {gumloop-0.3.0 → gumloop-0.3.1}/tests/integration/test_live.py +0 -0
  67. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/__init__.py +0 -0
  68. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/conftest.py +0 -0
  69. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/helpers.py +0 -0
  70. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_agents.py +0 -0
  71. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_artifacts.py +0 -0
  72. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_chat.py +0 -0
  73. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_client.py +0 -0
  74. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_oauth.py +0 -0
  75. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_sessions.py +0 -0
  76. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_skills.py +0 -0
  77. {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_teams.py +0 -0
  78. {gumloop-0.3.0 → gumloop-0.3.1}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python client for the Gumloop API
5
5
  Project-URL: Homepage, https://github.com/gumloop/gumloop-py
6
6
  Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ from gumloop.errors import AuthenticationError
6
6
  from gumloop.errors import GumloopError
7
7
  from gumloop.oauth import OAuth
8
8
 
9
- __version__ = "0.3.0"
9
+ __version__ = "0.3.1"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -51,7 +51,7 @@ class Gumloop:
51
51
  *,
52
52
  access_token: str | None = None,
53
53
  user_id: str | None = None,
54
- base_url: str = DEFAULT_BASE_URL,
54
+ base_url: str | None = None,
55
55
  stream_base_url: str | None = None,
56
56
  timeout: float = DEFAULT_TIMEOUT,
57
57
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
@@ -62,7 +62,8 @@ class Gumloop:
62
62
  # x-auth-key header to look up the owner's stored secret. OAuth
63
63
  # callers omit it because the bearer token already identifies them.
64
64
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
65
- self.base_url = base_url.rstrip("/")
65
+ # Defaults to production; GUMLOOP_BASE_URL is an optional override.
66
+ self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
66
67
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
67
68
  self.timeout = timeout
68
69
  self.stream_timeout = stream_timeout
@@ -105,7 +106,7 @@ class AsyncGumloop:
105
106
  *,
106
107
  access_token: str | None = None,
107
108
  user_id: str | None = None,
108
- base_url: str = DEFAULT_BASE_URL,
109
+ base_url: str | None = None,
109
110
  stream_base_url: str | None = None,
110
111
  timeout: float = DEFAULT_TIMEOUT,
111
112
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
@@ -113,7 +114,8 @@ class AsyncGumloop:
113
114
  self.api_key = api_key
114
115
  self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
115
116
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
116
- self.base_url = base_url.rstrip("/")
117
+ # Defaults to production; GUMLOOP_BASE_URL is an optional override.
118
+ self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
117
119
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
118
120
  self.timeout = timeout
119
121
  self.stream_timeout = stream_timeout
@@ -140,7 +140,7 @@ def call_tool(
140
140
  ] = None,
141
141
  json_output: Annotated[
142
142
  bool,
143
- typer.Option("--json", help="Print the raw SDK response as JSON."),
143
+ typer.Option("--json", help="Print the tool response as JSON, decoding JSON text content when possible."),
144
144
  ] = False,
145
145
  ) -> None:
146
146
  """Execute an MCP tool on a server."""
@@ -160,7 +160,7 @@ def call_tool(
160
160
  exit_with_error(error, json_output=json_output)
161
161
 
162
162
  if json_output:
163
- print_json(response)
163
+ print_json(response.model_dump_decoded_content())
164
164
  return
165
165
 
166
166
  # markup=False on remote fields stops MCP server output from rendering
@@ -176,10 +176,183 @@ def call_tool(
176
176
  console.print(f" status: {result.status}", markup=False, highlight=False)
177
177
  if result.error:
178
178
  console.print(f" error: {result.error}", markup=False, highlight=False)
179
- content = result.content
179
+ content = result.decoded_content
180
180
  if not content:
181
181
  continue
182
182
  if isinstance(content, str):
183
183
  console.print(content, markup=False, highlight=False)
184
184
  else:
185
185
  print_json(content)
186
+
187
+
188
+ @mcp_app.command("resources", epilog="Example:\n gumloop mcp resources gmail")
189
+ def list_resources(
190
+ ctx: typer.Context,
191
+ server: Annotated[str, typer.Argument(help="MCP server id whose resources should be listed.")],
192
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor from a previous page.")] = None,
193
+ json_output: Annotated[bool, typer.Option("--json", help="Print the raw SDK response as JSON.")] = False,
194
+ ) -> None:
195
+ """List the resources a connected MCP server exposes."""
196
+ cli: CliContext = ctx.obj
197
+ try:
198
+ response = cli.call_with_refresh(
199
+ lambda client: client.mcp.list_resources(server, cursor=cursor, team_id=cli.effective_team_id)
200
+ )
201
+ except GumloopError as error:
202
+ exit_with_error(error, json_output=json_output)
203
+
204
+ if json_output:
205
+ print_json(response)
206
+ return
207
+
208
+ if response.status and response.status != "connected":
209
+ status_text = escape_markup(response.status)
210
+ console.print(f"Server [bold]{escape_markup(server)}[/bold] is not connected ({status_text}).")
211
+ if response.gumloop_auth_url:
212
+ console.print(f"Connect here: {response.gumloop_auth_url}", markup=False, highlight=False)
213
+ return
214
+
215
+ if not response.resources:
216
+ console.print("No resources found.")
217
+ return
218
+
219
+ console.print("URI", "NAME", "MIME_TYPE", "DESCRIPTION", sep="\t", soft_wrap=True)
220
+ for resource in response.resources:
221
+ console.print(
222
+ resource.uri,
223
+ resource.name or "",
224
+ resource.mime_type or "",
225
+ resource.description or "",
226
+ sep="\t",
227
+ soft_wrap=True,
228
+ )
229
+ if response.next_cursor:
230
+ console.print(
231
+ f"\nMore results — next page: --cursor {response.next_cursor}",
232
+ markup=False,
233
+ highlight=False,
234
+ )
235
+
236
+
237
+ @mcp_app.command("resource", epilog='Example:\n gumloop mcp resource gmail "gmail://label/INBOX"')
238
+ def read_resource(
239
+ ctx: typer.Context,
240
+ server: Annotated[str, typer.Argument(help="MCP server id (for example 'gmail').")],
241
+ uri: Annotated[str, typer.Argument(help="Resource URI to read (for example 'gmail://label/INBOX').")],
242
+ json_output: Annotated[bool, typer.Option("--json", help="Print the raw SDK response as JSON.")] = False,
243
+ ) -> None:
244
+ """Read a single MCP resource by URI."""
245
+ cli: CliContext = ctx.obj
246
+ try:
247
+ response = cli.call_with_refresh(
248
+ lambda client: client.mcp.get_resource(server, uri, team_id=cli.effective_team_id)
249
+ )
250
+ except GumloopError as error:
251
+ exit_with_error(error, json_output=json_output)
252
+
253
+ if json_output:
254
+ print_json(response)
255
+ return
256
+
257
+ text = response.text
258
+ if text is not None:
259
+ console.print(text, markup=False, highlight=False)
260
+ return
261
+ # Binary or empty contents: fall back to the structured payload.
262
+ if not response.contents:
263
+ console.print("(no content)")
264
+ return
265
+ print_json(response)
266
+
267
+
268
+ @mcp_app.command("prompts", epilog="Example:\n gumloop mcp prompts gmail")
269
+ def list_prompts(
270
+ ctx: typer.Context,
271
+ server: Annotated[str, typer.Argument(help="MCP server id whose prompts should be listed.")],
272
+ json_output: Annotated[bool, typer.Option("--json", help="Print the raw SDK response as JSON.")] = False,
273
+ ) -> None:
274
+ """List the prompts a connected MCP server exposes."""
275
+ cli: CliContext = ctx.obj
276
+ try:
277
+ response = cli.call_with_refresh(lambda client: client.mcp.list_prompts(server, team_id=cli.effective_team_id))
278
+ except GumloopError as error:
279
+ exit_with_error(error, json_output=json_output)
280
+
281
+ if json_output:
282
+ print_json(response)
283
+ return
284
+
285
+ if response.status and response.status != "connected":
286
+ status_text = escape_markup(response.status)
287
+ console.print(f"Server [bold]{escape_markup(server)}[/bold] is not connected ({status_text}).")
288
+ if response.gumloop_auth_url:
289
+ console.print(f"Connect here: {response.gumloop_auth_url}", markup=False, highlight=False)
290
+ return
291
+
292
+ if not response.prompts:
293
+ console.print("No prompts found.")
294
+ return
295
+
296
+ console.print("NAME", "ARGUMENTS", "DESCRIPTION", sep="\t", soft_wrap=True)
297
+ for prompt in response.prompts:
298
+ # A trailing * marks a required argument.
299
+ args = ", ".join(f"{arg.name}*" if arg.required else arg.name for arg in prompt.arguments)
300
+ console.print(prompt.name, args, prompt.description or "", sep="\t", soft_wrap=True)
301
+
302
+
303
+ @mcp_app.command(
304
+ "prompt",
305
+ epilog=(
306
+ "Examples:\n"
307
+ ' gumloop mcp prompt gmail summarize_thread --args-json \'{"thread_id": "abc"}\'\n'
308
+ " gumloop mcp prompt gmail summarize_thread --args-file ./args.json"
309
+ ),
310
+ )
311
+ def get_prompt(
312
+ ctx: typer.Context,
313
+ server: Annotated[str, typer.Argument(help="MCP server id (for example 'gmail').")],
314
+ name: Annotated[str, typer.Argument(help="Prompt name on that server.")],
315
+ args_json: Annotated[
316
+ str | None,
317
+ typer.Option("--args-json", help="Inline JSON object of prompt arguments."),
318
+ ] = None,
319
+ args_file: Annotated[
320
+ str | None,
321
+ typer.Option("--args-file", help="Path to a file containing the JSON arguments."),
322
+ ] = None,
323
+ args_stdin: Annotated[
324
+ str | None,
325
+ typer.Option("--args", help="Use '-' to read JSON arguments from stdin.", metavar="-"),
326
+ ] = None,
327
+ json_output: Annotated[
328
+ bool,
329
+ typer.Option("--json", help="Print the prompt response as JSON."),
330
+ ] = False,
331
+ ) -> None:
332
+ """Get (render) an MCP prompt with arguments."""
333
+ cli: CliContext = ctx.obj
334
+ try:
335
+ arguments = resolve_json_args(inline=args_json, file_path=args_file, stdin_marker=args_stdin)
336
+ response = cli.call_with_refresh(
337
+ lambda client: client.mcp.get_prompt(server, name, arguments, team_id=cli.effective_team_id)
338
+ )
339
+ except GumloopError as error:
340
+ exit_with_error(error, json_output=json_output)
341
+
342
+ if json_output:
343
+ print_json(response)
344
+ return
345
+
346
+ if response.description:
347
+ console.print(response.description, markup=False, highlight=False)
348
+ if not response.messages:
349
+ console.print("(no messages)")
350
+ return
351
+ for message in response.messages:
352
+ role = message.role or ""
353
+ console.print(f"[bold]{escape_markup(role)}[/bold]", markup=True, highlight=False)
354
+ text = message.content.get("text") if isinstance(message.content, dict) else None
355
+ if text:
356
+ console.print(text, markup=False, highlight=False)
357
+ else:
358
+ print_json(message.content)
@@ -6,6 +6,10 @@ from typing import Any
6
6
  from gumloop._http import AsyncHttpClient
7
7
  from gumloop._http import HttpClient
8
8
  from gumloop.types import McpExecuteResponse
9
+ from gumloop.types import McpPromptResponse
10
+ from gumloop.types import McpPromptsResponse
11
+ from gumloop.types import McpResourceReadResponse
12
+ from gumloop.types import McpResourcesResponse
9
13
  from gumloop.types import McpServerResponse
10
14
  from gumloop.types import McpServersResponse
11
15
  from gumloop.types import McpToolCallRequest
@@ -42,6 +46,44 @@ class MCP:
42
46
  self._client.get(f"mcp/servers/{server_id}/tools", params={"team_id": team_id, **kwargs})
43
47
  )
44
48
 
49
+ def list_resources(
50
+ self, server_id: str, *, cursor: str | None = None, team_id: str | None = None, **kwargs: Any
51
+ ) -> McpResourcesResponse:
52
+ return McpResourcesResponse.model_validate(
53
+ self._client.get(
54
+ f"mcp/servers/{server_id}/resources",
55
+ params={"cursor": cursor, "team_id": team_id, **kwargs},
56
+ )
57
+ )
58
+
59
+ def get_resource(
60
+ self, server_id: str, uri: str, *, team_id: str | None = None, **kwargs: Any
61
+ ) -> McpResourceReadResponse:
62
+ return McpResourceReadResponse.model_validate(
63
+ self._client.get(
64
+ f"mcp/servers/{server_id}/resources/read",
65
+ params={"uri": uri, "team_id": team_id, **kwargs},
66
+ )
67
+ )
68
+
69
+ def list_prompts(self, server_id: str, *, team_id: str | None = None, **kwargs: Any) -> McpPromptsResponse:
70
+ return McpPromptsResponse.model_validate(
71
+ self._client.get(f"mcp/servers/{server_id}/prompts", params={"team_id": team_id, **kwargs})
72
+ )
73
+
74
+ def get_prompt(
75
+ self,
76
+ server_id: str,
77
+ name: str,
78
+ arguments: dict[str, Any] | None = None,
79
+ *,
80
+ team_id: str | None = None,
81
+ ) -> McpPromptResponse:
82
+ body: dict[str, Any] = {"name": name, "arguments": arguments or {}}
83
+ if team_id is not None:
84
+ body["team_id"] = team_id
85
+ return McpPromptResponse.model_validate(self._client.post(f"mcp/servers/{server_id}/prompts/get", json=body))
86
+
45
87
  def execute(
46
88
  self,
47
89
  server_id: str,
@@ -84,6 +126,42 @@ class AsyncMCP:
84
126
  data = await self._client.get(f"mcp/servers/{server_id}/tools", params={"team_id": team_id, **kwargs})
85
127
  return McpToolsResponse.model_validate(data)
86
128
 
129
+ async def list_resources(
130
+ self, server_id: str, *, cursor: str | None = None, team_id: str | None = None, **kwargs: Any
131
+ ) -> McpResourcesResponse:
132
+ data = await self._client.get(
133
+ f"mcp/servers/{server_id}/resources",
134
+ params={"cursor": cursor, "team_id": team_id, **kwargs},
135
+ )
136
+ return McpResourcesResponse.model_validate(data)
137
+
138
+ async def get_resource(
139
+ self, server_id: str, uri: str, *, team_id: str | None = None, **kwargs: Any
140
+ ) -> McpResourceReadResponse:
141
+ data = await self._client.get(
142
+ f"mcp/servers/{server_id}/resources/read",
143
+ params={"uri": uri, "team_id": team_id, **kwargs},
144
+ )
145
+ return McpResourceReadResponse.model_validate(data)
146
+
147
+ async def list_prompts(self, server_id: str, *, team_id: str | None = None, **kwargs: Any) -> McpPromptsResponse:
148
+ data = await self._client.get(f"mcp/servers/{server_id}/prompts", params={"team_id": team_id, **kwargs})
149
+ return McpPromptsResponse.model_validate(data)
150
+
151
+ async def get_prompt(
152
+ self,
153
+ server_id: str,
154
+ name: str,
155
+ arguments: dict[str, Any] | None = None,
156
+ *,
157
+ team_id: str | None = None,
158
+ ) -> McpPromptResponse:
159
+ body: dict[str, Any] = {"name": name, "arguments": arguments or {}}
160
+ if team_id is not None:
161
+ body["team_id"] = team_id
162
+ data = await self._client.post(f"mcp/servers/{server_id}/prompts/get", json=body)
163
+ return McpPromptResponse.model_validate(data)
164
+
87
165
  async def execute(
88
166
  self,
89
167
  server_id: str,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any
4
5
  from typing import Literal
5
6
 
@@ -300,6 +301,20 @@ class McpToolCallResult(_Model):
300
301
  content: list[Any] | None = None
301
302
  error: dict[str, Any] | None = None
302
303
 
304
+ @property
305
+ def decoded_content(self) -> list[Any] | None:
306
+ """Each content string JSON-decoded; plain text passes through. guMCP returns
307
+ one string per TextContent, usually a JSON dump but sometimes plain text."""
308
+ if self.content is None:
309
+ return None
310
+ decoded: list[Any] = []
311
+ for item in self.content:
312
+ try:
313
+ decoded.append(json.loads(item) if isinstance(item, str) else item)
314
+ except json.JSONDecodeError:
315
+ decoded.append(item)
316
+ return decoded
317
+
303
318
 
304
319
  class McpServersResponse(_Model):
305
320
  servers: list[McpServer] = Field(default_factory=list)
@@ -318,3 +333,96 @@ class McpToolsResponse(_Model):
318
333
 
319
334
  class McpExecuteResponse(_Model):
320
335
  results: list[McpToolCallResult] = Field(default_factory=list)
336
+
337
+ def model_dump_decoded_content(self) -> dict[str, Any]:
338
+ payload = self.model_dump(mode="json")
339
+ results = payload.get("results")
340
+ if not isinstance(results, list):
341
+ return payload
342
+ for index, result in enumerate(self.results):
343
+ if index < len(results) and isinstance(results[index], dict):
344
+ results[index]["content"] = result.decoded_content
345
+ return payload
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # MCP resource types
350
+ # ---------------------------------------------------------------------------
351
+
352
+
353
+ class McpResource(_Model):
354
+ uri: str
355
+ name: str | None = None
356
+ title: str | None = None
357
+ description: str | None = None
358
+ mime_type: str | None = None
359
+ size: int | None = None
360
+ server_id: str | None = None
361
+
362
+
363
+ class McpResourcesResponse(_Model):
364
+ resources: list[McpResource] = Field(default_factory=list)
365
+ server_id: str | None = None
366
+ status: str | None = None
367
+ gumloop_auth_url: str | None = None
368
+ next_cursor: str | None = None
369
+
370
+
371
+ class McpResourceContent(_Model):
372
+ uri: str | None = None
373
+ mime_type: str | None = None
374
+ # A content item is either text or a base64 blob.
375
+ text: str | None = None
376
+ blob: str | None = None
377
+
378
+
379
+ class McpResourceReadResponse(_Model):
380
+ server_id: str | None = None
381
+ uri: str | None = None
382
+ contents: list[McpResourceContent] = Field(default_factory=list)
383
+
384
+ @property
385
+ def text(self) -> str | None:
386
+ """All text contents joined with newlines, or None if the resource is
387
+ binary/empty. Most resources are a single text blob, so this is the value
388
+ callers usually want — parallels ``McpToolCallResult.decoded_content``."""
389
+ parts = [content.text for content in self.contents if content.text]
390
+ return "\n".join(parts) if parts else None
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # MCP prompt types
395
+ # ---------------------------------------------------------------------------
396
+
397
+
398
+ class McpPromptArgument(_Model):
399
+ name: str
400
+ description: str | None = None
401
+ required: bool | None = None
402
+
403
+
404
+ class McpPrompt(_Model):
405
+ name: str
406
+ description: str | None = None
407
+ arguments: list[McpPromptArgument] = Field(default_factory=list)
408
+ server_id: str | None = None
409
+
410
+
411
+ class McpPromptsResponse(_Model):
412
+ prompts: list[McpPrompt] = Field(default_factory=list)
413
+ server_id: str | None = None
414
+ status: str | None = None
415
+ gumloop_auth_url: str | None = None
416
+
417
+
418
+ class McpPromptMessage(_Model):
419
+ role: str | None = None
420
+ # content is {type, text, resource?, ...} — kept open across MCP content variants.
421
+ content: dict[str, Any] = Field(default_factory=dict)
422
+
423
+
424
+ class McpPromptResponse(_Model):
425
+ server_id: str | None = None
426
+ name: str | None = None
427
+ description: str | None = None
428
+ messages: list[McpPromptMessage] = Field(default_factory=list)
@@ -163,3 +163,101 @@ def test_mcp_call_rejects_multiple_args_input_modes(cli_runner: CliRunner, tmp_p
163
163
  )
164
164
 
165
165
  assert result.exit_code == 1
166
+
167
+
168
+ @respx.mock
169
+ def test_mcp_resources_lists_rich_fields(cli_runner: CliRunner) -> None:
170
+ payload = {
171
+ "server_id": "gmail",
172
+ "status": "connected",
173
+ "resources": [
174
+ {"uri": "gmail://label/INBOX", "name": "Inbox", "mime_type": "application/json", "description": "Primary"}
175
+ ],
176
+ }
177
+ respx.get(f"{API_BASE}/mcp/servers/gmail/resources").mock(return_value=httpx.Response(200, json=payload))
178
+ save_credentials(Credentials(api_key="key"))
179
+
180
+ result = cli_runner.invoke(app, ["mcp", "resources", "gmail"])
181
+
182
+ assert result.exit_code == 0
183
+ assert "gmail://label/INBOX" in result.output
184
+ assert "application/json" in result.output
185
+
186
+
187
+ @respx.mock
188
+ def test_mcp_resources_shows_auth_url_when_not_connected(cli_runner: CliRunner) -> None:
189
+ payload = {
190
+ "server_id": "gmail",
191
+ "status": "unauthenticated",
192
+ "gumloop_auth_url": "https://example.com/settings/profile/apps?server=gmail",
193
+ "resources": [],
194
+ }
195
+ respx.get(f"{API_BASE}/mcp/servers/gmail/resources").mock(return_value=httpx.Response(200, json=payload))
196
+ save_credentials(Credentials(api_key="key"))
197
+
198
+ result = cli_runner.invoke(app, ["mcp", "resources", "gmail"])
199
+
200
+ assert result.exit_code == 0
201
+ assert "not connected" in result.output
202
+ assert "https://example.com/settings/profile/apps?server=gmail" in result.output
203
+
204
+
205
+ @respx.mock
206
+ def test_mcp_resource_prints_text_content(cli_runner: CliRunner) -> None:
207
+ payload = {
208
+ "server_id": "gmail",
209
+ "uri": "gmail://label/INBOX",
210
+ "contents": [{"mime_type": "text/plain", "text": "hello world"}],
211
+ }
212
+ route = respx.get(f"{API_BASE}/mcp/servers/gmail/resources/read").mock(
213
+ return_value=httpx.Response(200, json=payload)
214
+ )
215
+ save_credentials(Credentials(api_key="key"))
216
+
217
+ result = cli_runner.invoke(app, ["mcp", "resource", "gmail", "gmail://label/INBOX"])
218
+
219
+ assert result.exit_code == 0
220
+ assert "hello world" in result.output
221
+ assert route.calls[0].request.url.params["uri"] == "gmail://label/INBOX"
222
+
223
+
224
+ @respx.mock
225
+ def test_mcp_prompts_lists_arguments(cli_runner: CliRunner) -> None:
226
+ payload = {
227
+ "server_id": "gmail",
228
+ "status": "connected",
229
+ "prompts": [
230
+ {
231
+ "name": "summarize",
232
+ "description": "Summarize a thread",
233
+ "arguments": [{"name": "thread_id", "required": True}],
234
+ }
235
+ ],
236
+ }
237
+ respx.get(f"{API_BASE}/mcp/servers/gmail/prompts").mock(return_value=httpx.Response(200, json=payload))
238
+ save_credentials(Credentials(api_key="key"))
239
+
240
+ result = cli_runner.invoke(app, ["mcp", "prompts", "gmail"])
241
+
242
+ assert result.exit_code == 0
243
+ assert "summarize" in result.output
244
+ assert "thread_id*" in result.output # trailing * marks a required argument
245
+
246
+
247
+ @respx.mock
248
+ def test_mcp_prompt_posts_args_and_prints_messages(cli_runner: CliRunner) -> None:
249
+ payload = {
250
+ "server_id": "gmail",
251
+ "name": "summarize",
252
+ "description": "Summarize a thread",
253
+ "messages": [{"role": "user", "content": {"type": "text", "text": "Please summarize"}}],
254
+ }
255
+ route = respx.post(f"{API_BASE}/mcp/servers/gmail/prompts/get").mock(return_value=httpx.Response(200, json=payload))
256
+ save_credentials(Credentials(api_key="key"))
257
+
258
+ result = cli_runner.invoke(app, ["mcp", "prompt", "gmail", "summarize", "--args-json", '{"thread_id": "abc"}'])
259
+
260
+ assert result.exit_code == 0
261
+ assert "Please summarize" in result.output
262
+ sent = route.calls[0].request.content
263
+ assert b'"thread_id": "abc"' in sent or b'"thread_id":"abc"' in sent
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from gumloop import AsyncGumloop
10
+ from gumloop import Gumloop
11
+ from tests.sdk.helpers import API_BASE
12
+ from tests.sdk.helpers import request_json
13
+
14
+
15
+ @respx.mock
16
+ def test_mcp_list_servers_and_get_server_routes(client: Gumloop) -> None:
17
+ list_route = respx.get(f"{API_BASE}/mcp/servers").mock(return_value=httpx.Response(200, json={"servers": []}))
18
+ get_route = respx.get(f"{API_BASE}/mcp/servers/gmail").mock(
19
+ return_value=httpx.Response(
20
+ 200,
21
+ json={
22
+ "server": {"server_id": "gmail", "type": "gumcp", "status": "ok", "gumloop_auth_url": "u"},
23
+ },
24
+ )
25
+ )
26
+
27
+ assert client.mcp.list_servers(team_id="team_123").servers == []
28
+ assert client.mcp.get_server("gmail", team_id="team_123").server.server_id == "gmail"
29
+ assert list_route.calls[0].request.url.params["team_id"] == "team_123"
30
+ assert get_route.calls[0].request.url.params["team_id"] == "team_123"
31
+
32
+
33
+ @respx.mock
34
+ def test_mcp_list_tools_uses_server_path(client: Gumloop) -> None:
35
+ route = respx.get(f"{API_BASE}/mcp/servers/gmail/tools").mock(
36
+ return_value=httpx.Response(200, json={"tools": [], "server_id": "gmail", "status": "connected"})
37
+ )
38
+
39
+ result = client.mcp.list_tools("gmail", team_id="team_123")
40
+
41
+ assert result.tools == []
42
+ assert result.server_id == "gmail"
43
+ assert result.status == "connected"
44
+ params = route.calls[0].request.url.params
45
+ assert params["team_id"] == "team_123"
46
+
47
+
48
+ @respx.mock
49
+ def test_mcp_execute_single_and_many_bodies(client: Gumloop) -> None:
50
+ route = respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
51
+
52
+ assert (
53
+ client.mcp.execute(
54
+ "gmail",
55
+ "read_emails",
56
+ {"max_results": 5},
57
+ team_id="team_123",
58
+ ).results
59
+ == []
60
+ )
61
+ assert request_json(route.calls[0].request) == {
62
+ "calls": [{"server_id": "gmail", "tool_name": "read_emails", "arguments": {"max_results": 5}}],
63
+ "team_id": "team_123",
64
+ }
65
+
66
+ calls = [
67
+ {
68
+ "ref": "emails",
69
+ "server_id": "gmail",
70
+ "tool_name": "read_emails",
71
+ "arguments": {"max_results": 5},
72
+ }
73
+ ]
74
+ assert client.mcp.execute_many(calls, team_id="team_123").results == []
75
+ assert request_json(route.calls[1].request) == {
76
+ "team_id": "team_123",
77
+ "calls": [
78
+ {
79
+ "ref": "emails",
80
+ "server_id": "gmail",
81
+ "tool_name": "read_emails",
82
+ "arguments": {"max_results": 5},
83
+ }
84
+ ],
85
+ }
86
+
87
+
88
+ @respx.mock
89
+ def test_mcp_execute_with_ref(client: Gumloop) -> None:
90
+ route = respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
91
+
92
+ client.mcp.execute("gmail", "read_emails", ref="my_ref")
93
+
94
+ call_body = request_json(route.calls[0].request)
95
+ assert call_body["calls"][0]["ref"] == "my_ref"
96
+
97
+
98
+ def test_mcp_execute_many_rejects_empty_calls(client: Gumloop) -> None:
99
+ with pytest.raises(ValueError, match="empty"):
100
+ client.mcp.execute_many([])
101
+
102
+
103
+ @respx.mock
104
+ def test_async_mcp_methods() -> None:
105
+ respx.get(f"{API_BASE}/mcp/servers").mock(return_value=httpx.Response(200, json={"servers": []}))
106
+ respx.get(f"{API_BASE}/mcp/servers/gmail").mock(
107
+ return_value=httpx.Response(
108
+ 200,
109
+ json={
110
+ "server": {"server_id": "gmail", "type": "gumcp", "status": "ok", "gumloop_auth_url": "u"},
111
+ },
112
+ )
113
+ )
114
+ respx.get(f"{API_BASE}/mcp/servers/gmail/tools").mock(
115
+ return_value=httpx.Response(200, json={"tools": [], "server_id": "gmail", "status": "connected"})
116
+ )
117
+ respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
118
+
119
+ async def run() -> None:
120
+ async with AsyncGumloop(access_token="token") as client:
121
+ assert (await client.mcp.list_servers(team_id="team_123")).servers == []
122
+ assert (await client.mcp.get_server("gmail", team_id="team_123")).server.server_id == "gmail"
123
+ tools_resp = await client.mcp.list_tools("gmail", team_id="team_123")
124
+ assert tools_resp.tools == []
125
+ assert tools_resp.server_id == "gmail"
126
+ single = await client.mcp.execute(
127
+ "gmail",
128
+ "read_emails",
129
+ {"max_results": 5},
130
+ team_id="team_123",
131
+ )
132
+ assert single.results == []
133
+ batch = await client.mcp.execute_many(
134
+ [{"server_id": "gmail", "tool_name": "read_emails", "arguments": {}}],
135
+ team_id="team_123",
136
+ )
137
+ assert batch.results == []
138
+
139
+ asyncio.run(run())
140
+
141
+
142
+ @respx.mock
143
+ def test_mcp_list_and_read_resources(client: Gumloop) -> None:
144
+ list_route = respx.get(f"{API_BASE}/mcp/servers/gmail/resources").mock(
145
+ return_value=httpx.Response(
146
+ 200,
147
+ json={
148
+ "resources": [
149
+ {
150
+ "uri": "gmail://label/INBOX",
151
+ "name": "Inbox",
152
+ "description": "Primary",
153
+ "mime_type": "application/json",
154
+ "server_id": "gmail",
155
+ }
156
+ ],
157
+ "server_id": "gmail",
158
+ "status": "connected",
159
+ "next_cursor": "c2",
160
+ },
161
+ )
162
+ )
163
+ read_route = respx.get(f"{API_BASE}/mcp/servers/gmail/resources/read").mock(
164
+ return_value=httpx.Response(
165
+ 200,
166
+ json={
167
+ "server_id": "gmail",
168
+ "uri": "gmail://label/INBOX",
169
+ "contents": [{"mime_type": "text/plain", "text": "a"}, {"text": "b"}],
170
+ },
171
+ )
172
+ )
173
+
174
+ listed = client.mcp.list_resources("gmail", cursor="c1", team_id="team_123")
175
+ assert listed.resources[0].uri == "gmail://label/INBOX"
176
+ assert listed.resources[0].mime_type == "application/json"
177
+ assert listed.next_cursor == "c2"
178
+ assert list_route.calls[0].request.url.params["cursor"] == "c1"
179
+ assert list_route.calls[0].request.url.params["team_id"] == "team_123"
180
+
181
+ read = client.mcp.get_resource("gmail", "gmail://label/INBOX", team_id="team_123")
182
+ assert read.text == "a\nb" # the .text convenience joins text contents
183
+ assert read_route.calls[0].request.url.params["uri"] == "gmail://label/INBOX"
184
+
185
+
186
+ @respx.mock
187
+ def test_mcp_list_and_get_prompts(client: Gumloop) -> None:
188
+ list_route = respx.get(f"{API_BASE}/mcp/servers/gmail/prompts").mock(
189
+ return_value=httpx.Response(
190
+ 200,
191
+ json={
192
+ "prompts": [
193
+ {
194
+ "name": "summarize",
195
+ "description": "d",
196
+ "arguments": [{"name": "thread_id", "required": True}],
197
+ "server_id": "gmail",
198
+ }
199
+ ],
200
+ "server_id": "gmail",
201
+ "status": "connected",
202
+ },
203
+ )
204
+ )
205
+ get_route = respx.post(f"{API_BASE}/mcp/servers/gmail/prompts/get").mock(
206
+ return_value=httpx.Response(
207
+ 200,
208
+ json={
209
+ "server_id": "gmail",
210
+ "name": "summarize",
211
+ "description": "d",
212
+ "messages": [{"role": "user", "content": {"type": "text", "text": "hello"}}],
213
+ },
214
+ )
215
+ )
216
+
217
+ listed = client.mcp.list_prompts("gmail", team_id="team_123")
218
+ assert listed.prompts[0].name == "summarize"
219
+ assert listed.prompts[0].arguments[0].required is True
220
+ assert list_route.calls[0].request.url.params["team_id"] == "team_123"
221
+
222
+ got = client.mcp.get_prompt("gmail", "summarize", {"thread_id": "abc"}, team_id="team_123")
223
+ assert got.messages[0].content["text"] == "hello"
224
+ assert request_json(get_route.calls[0].request) == {
225
+ "name": "summarize",
226
+ "arguments": {"thread_id": "abc"},
227
+ "team_id": "team_123",
228
+ }
229
+
230
+
231
+ @respx.mock
232
+ def test_mcp_get_prompt_omits_team_id_when_absent(client: Gumloop) -> None:
233
+ route = respx.post(f"{API_BASE}/mcp/servers/gmail/prompts/get").mock(
234
+ return_value=httpx.Response(200, json={"server_id": "gmail", "name": "summarize", "messages": []})
235
+ )
236
+
237
+ client.mcp.get_prompt("gmail", "summarize")
238
+
239
+ assert request_json(route.calls[0].request) == {"name": "summarize", "arguments": {}}
240
+
241
+
242
+ @respx.mock
243
+ def test_async_mcp_resource_and_prompt_methods() -> None:
244
+ respx.get(f"{API_BASE}/mcp/servers/gmail/resources").mock(
245
+ return_value=httpx.Response(200, json={"resources": [], "server_id": "gmail", "status": "connected"})
246
+ )
247
+ respx.get(f"{API_BASE}/mcp/servers/gmail/resources/read").mock(
248
+ return_value=httpx.Response(200, json={"server_id": "gmail", "uri": "u", "contents": [{"text": "x"}]})
249
+ )
250
+ respx.get(f"{API_BASE}/mcp/servers/gmail/prompts").mock(
251
+ return_value=httpx.Response(200, json={"prompts": [], "server_id": "gmail", "status": "connected"})
252
+ )
253
+ respx.post(f"{API_BASE}/mcp/servers/gmail/prompts/get").mock(
254
+ return_value=httpx.Response(200, json={"server_id": "gmail", "name": "p", "messages": []})
255
+ )
256
+
257
+ async def run() -> None:
258
+ async with AsyncGumloop(access_token="token") as client:
259
+ assert (await client.mcp.list_resources("gmail", team_id="t")).resources == []
260
+ assert (await client.mcp.get_resource("gmail", "u", team_id="t")).text == "x"
261
+ assert (await client.mcp.list_prompts("gmail", team_id="t")).prompts == []
262
+ assert (await client.mcp.get_prompt("gmail", "p", {"a": 1}, team_id="t")).name == "p"
263
+
264
+ asyncio.run(run())
@@ -355,7 +355,7 @@ wheels = [
355
355
 
356
356
  [[package]]
357
357
  name = "gumloop"
358
- version = "0.3.0"
358
+ version = "0.3.1"
359
359
  source = { editable = "." }
360
360
  dependencies = [
361
361
  { name = "httpx" },
@@ -1,139 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
-
5
- import httpx
6
- import pytest
7
- import respx
8
-
9
- from gumloop import AsyncGumloop
10
- from gumloop import Gumloop
11
- from tests.sdk.helpers import API_BASE
12
- from tests.sdk.helpers import request_json
13
-
14
-
15
- @respx.mock
16
- def test_mcp_list_servers_and_get_server_routes(client: Gumloop) -> None:
17
- list_route = respx.get(f"{API_BASE}/mcp/servers").mock(return_value=httpx.Response(200, json={"servers": []}))
18
- get_route = respx.get(f"{API_BASE}/mcp/servers/gmail").mock(
19
- return_value=httpx.Response(
20
- 200,
21
- json={
22
- "server": {"server_id": "gmail", "type": "gumcp", "status": "ok", "gumloop_auth_url": "u"},
23
- },
24
- )
25
- )
26
-
27
- assert client.mcp.list_servers(team_id="team_123").servers == []
28
- assert client.mcp.get_server("gmail", team_id="team_123").server.server_id == "gmail"
29
- assert list_route.calls[0].request.url.params["team_id"] == "team_123"
30
- assert get_route.calls[0].request.url.params["team_id"] == "team_123"
31
-
32
-
33
- @respx.mock
34
- def test_mcp_list_tools_uses_server_path(client: Gumloop) -> None:
35
- route = respx.get(f"{API_BASE}/mcp/servers/gmail/tools").mock(
36
- return_value=httpx.Response(200, json={"tools": [], "server_id": "gmail", "status": "connected"})
37
- )
38
-
39
- result = client.mcp.list_tools("gmail", team_id="team_123")
40
-
41
- assert result.tools == []
42
- assert result.server_id == "gmail"
43
- assert result.status == "connected"
44
- params = route.calls[0].request.url.params
45
- assert params["team_id"] == "team_123"
46
-
47
-
48
- @respx.mock
49
- def test_mcp_execute_single_and_many_bodies(client: Gumloop) -> None:
50
- route = respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
51
-
52
- assert (
53
- client.mcp.execute(
54
- "gmail",
55
- "read_emails",
56
- {"max_results": 5},
57
- team_id="team_123",
58
- ).results
59
- == []
60
- )
61
- assert request_json(route.calls[0].request) == {
62
- "calls": [{"server_id": "gmail", "tool_name": "read_emails", "arguments": {"max_results": 5}}],
63
- "team_id": "team_123",
64
- }
65
-
66
- calls = [
67
- {
68
- "ref": "emails",
69
- "server_id": "gmail",
70
- "tool_name": "read_emails",
71
- "arguments": {"max_results": 5},
72
- }
73
- ]
74
- assert client.mcp.execute_many(calls, team_id="team_123").results == []
75
- assert request_json(route.calls[1].request) == {
76
- "team_id": "team_123",
77
- "calls": [
78
- {
79
- "ref": "emails",
80
- "server_id": "gmail",
81
- "tool_name": "read_emails",
82
- "arguments": {"max_results": 5},
83
- }
84
- ],
85
- }
86
-
87
-
88
- @respx.mock
89
- def test_mcp_execute_with_ref(client: Gumloop) -> None:
90
- route = respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
91
-
92
- client.mcp.execute("gmail", "read_emails", ref="my_ref")
93
-
94
- call_body = request_json(route.calls[0].request)
95
- assert call_body["calls"][0]["ref"] == "my_ref"
96
-
97
-
98
- def test_mcp_execute_many_rejects_empty_calls(client: Gumloop) -> None:
99
- with pytest.raises(ValueError, match="empty"):
100
- client.mcp.execute_many([])
101
-
102
-
103
- @respx.mock
104
- def test_async_mcp_methods() -> None:
105
- respx.get(f"{API_BASE}/mcp/servers").mock(return_value=httpx.Response(200, json={"servers": []}))
106
- respx.get(f"{API_BASE}/mcp/servers/gmail").mock(
107
- return_value=httpx.Response(
108
- 200,
109
- json={
110
- "server": {"server_id": "gmail", "type": "gumcp", "status": "ok", "gumloop_auth_url": "u"},
111
- },
112
- )
113
- )
114
- respx.get(f"{API_BASE}/mcp/servers/gmail/tools").mock(
115
- return_value=httpx.Response(200, json={"tools": [], "server_id": "gmail", "status": "connected"})
116
- )
117
- respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
118
-
119
- async def run() -> None:
120
- async with AsyncGumloop(access_token="token") as client:
121
- assert (await client.mcp.list_servers(team_id="team_123")).servers == []
122
- assert (await client.mcp.get_server("gmail", team_id="team_123")).server.server_id == "gmail"
123
- tools_resp = await client.mcp.list_tools("gmail", team_id="team_123")
124
- assert tools_resp.tools == []
125
- assert tools_resp.server_id == "gmail"
126
- single = await client.mcp.execute(
127
- "gmail",
128
- "read_emails",
129
- {"max_results": 5},
130
- team_id="team_123",
131
- )
132
- assert single.results == []
133
- batch = await client.mcp.execute_many(
134
- [{"server_id": "gmail", "tool_name": "read_emails", "arguments": {}}],
135
- team_id="team_123",
136
- )
137
- assert batch.results == []
138
-
139
- asyncio.run(run())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes