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.
- {gumloop-0.3.0 → gumloop-0.3.1}/PKG-INFO +1 -1
- {gumloop-0.3.0 → gumloop-0.3.1}/pyproject.toml +1 -1
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/__init__.py +1 -1
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/_client.py +6 -4
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/mcp.py +176 -3
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/mcp.py +78 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/types.py +108 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_mcp.py +98 -0
- gumloop-0.3.1/tests/sdk/test_mcp.py +264 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/uv.lock +1 -1
- gumloop-0.3.0/tests/sdk/test_mcp.py +0 -139
- {gumloop-0.3.0 → gumloop-0.3.1}/.env.example +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/.gitignore +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/.pre-commit-config.yaml +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/.python-version +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/LICENSE +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/README.md +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/scripts/check +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/scripts/fmt +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/scripts/sdk_examples.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/_http.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/agents.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/main.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/errors.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/oauth.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/py.typed +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/agents.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/skills.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/conftest.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_agents.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_chat.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_context.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_login.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_main.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/cli/test_skills.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/conftest.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/integration/conftest.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/integration/test_live.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/__init__.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/conftest.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/helpers.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_agents.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_client.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.3.0 → gumloop-0.3.1}/tests/test_client.py +0 -0
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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())
|
|
@@ -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
|
|
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
|
|
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
|