glaip-sdk 0.0.7__py3-none-any.whl → 0.6.5b6__py3-none-any.whl

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 (161) hide show
  1. glaip_sdk/__init__.py +6 -3
  2. glaip_sdk/_version.py +12 -5
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1126 -0
  5. glaip_sdk/branding.py +79 -15
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +699 -0
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +503 -183
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +774 -137
  14. glaip_sdk/cli/commands/mcps.py +1124 -181
  15. glaip_sdk/cli/commands/models.py +25 -10
  16. glaip_sdk/cli/commands/tools.py +144 -92
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +61 -0
  19. glaip_sdk/cli/config.py +95 -0
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +150 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +143 -53
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +24 -18
  30. glaip_sdk/cli/main.py +420 -145
  31. glaip_sdk/cli/masking.py +136 -0
  32. glaip_sdk/cli/mcp_validators.py +287 -0
  33. glaip_sdk/cli/pager.py +266 -0
  34. glaip_sdk/cli/parsers/__init__.py +7 -0
  35. glaip_sdk/cli/parsers/json_input.py +177 -0
  36. glaip_sdk/cli/resolution.py +28 -21
  37. glaip_sdk/cli/rich_helpers.py +27 -0
  38. glaip_sdk/cli/slash/__init__.py +15 -0
  39. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +282 -0
  42. glaip_sdk/cli/slash/prompt.py +245 -0
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +1679 -0
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +872 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +31 -0
  52. glaip_sdk/cli/transcript/cache.py +536 -0
  53. glaip_sdk/cli/transcript/capture.py +329 -0
  54. glaip_sdk/cli/transcript/export.py +38 -0
  55. glaip_sdk/cli/transcript/history.py +815 -0
  56. glaip_sdk/cli/transcript/launcher.py +77 -0
  57. glaip_sdk/cli/transcript/viewer.py +372 -0
  58. glaip_sdk/cli/update_notifier.py +290 -0
  59. glaip_sdk/cli/utils.py +247 -1238
  60. glaip_sdk/cli/validators.py +16 -18
  61. glaip_sdk/client/__init__.py +2 -1
  62. glaip_sdk/client/_agent_payloads.py +520 -0
  63. glaip_sdk/client/agent_runs.py +147 -0
  64. glaip_sdk/client/agents.py +940 -574
  65. glaip_sdk/client/base.py +163 -48
  66. glaip_sdk/client/main.py +35 -12
  67. glaip_sdk/client/mcps.py +126 -18
  68. glaip_sdk/client/run_rendering.py +415 -0
  69. glaip_sdk/client/shared.py +21 -0
  70. glaip_sdk/client/tools.py +195 -37
  71. glaip_sdk/client/validators.py +20 -48
  72. glaip_sdk/config/constants.py +15 -5
  73. glaip_sdk/exceptions.py +16 -9
  74. glaip_sdk/icons.py +25 -0
  75. glaip_sdk/mcps/__init__.py +21 -0
  76. glaip_sdk/mcps/base.py +345 -0
  77. glaip_sdk/models/__init__.py +90 -0
  78. glaip_sdk/models/agent.py +47 -0
  79. glaip_sdk/models/agent_runs.py +116 -0
  80. glaip_sdk/models/common.py +42 -0
  81. glaip_sdk/models/mcp.py +33 -0
  82. glaip_sdk/models/tool.py +33 -0
  83. glaip_sdk/payload_schemas/__init__.py +7 -0
  84. glaip_sdk/payload_schemas/agent.py +85 -0
  85. glaip_sdk/registry/__init__.py +55 -0
  86. glaip_sdk/registry/agent.py +164 -0
  87. glaip_sdk/registry/base.py +139 -0
  88. glaip_sdk/registry/mcp.py +253 -0
  89. glaip_sdk/registry/tool.py +231 -0
  90. glaip_sdk/rich_components.py +98 -2
  91. glaip_sdk/runner/__init__.py +59 -0
  92. glaip_sdk/runner/base.py +84 -0
  93. glaip_sdk/runner/deps.py +115 -0
  94. glaip_sdk/runner/langgraph.py +597 -0
  95. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +158 -0
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  99. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +177 -0
  102. glaip_sdk/tools/__init__.py +22 -0
  103. glaip_sdk/tools/base.py +435 -0
  104. glaip_sdk/utils/__init__.py +59 -13
  105. glaip_sdk/utils/a2a/__init__.py +34 -0
  106. glaip_sdk/utils/a2a/event_processor.py +188 -0
  107. glaip_sdk/utils/agent_config.py +53 -40
  108. glaip_sdk/utils/bundler.py +267 -0
  109. glaip_sdk/utils/client.py +111 -0
  110. glaip_sdk/utils/client_utils.py +58 -26
  111. glaip_sdk/utils/datetime_helpers.py +58 -0
  112. glaip_sdk/utils/discovery.py +78 -0
  113. glaip_sdk/utils/display.py +65 -32
  114. glaip_sdk/utils/export.py +143 -0
  115. glaip_sdk/utils/general.py +1 -36
  116. glaip_sdk/utils/import_export.py +20 -25
  117. glaip_sdk/utils/import_resolver.py +492 -0
  118. glaip_sdk/utils/instructions.py +101 -0
  119. glaip_sdk/utils/rendering/__init__.py +115 -1
  120. glaip_sdk/utils/rendering/formatting.py +85 -43
  121. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  122. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +51 -19
  123. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  124. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  125. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  126. glaip_sdk/utils/rendering/models.py +39 -7
  127. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  128. glaip_sdk/utils/rendering/renderer/base.py +672 -759
  129. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  130. glaip_sdk/utils/rendering/renderer/debug.py +75 -22
  131. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  132. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  133. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  134. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  135. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  136. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  137. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  138. glaip_sdk/utils/rendering/state.py +204 -0
  139. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  140. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  141. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  142. glaip_sdk/utils/rendering/steps/format.py +176 -0
  143. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  144. glaip_sdk/utils/rendering/timing.py +36 -0
  145. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  146. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  147. glaip_sdk/utils/resource_refs.py +29 -26
  148. glaip_sdk/utils/runtime_config.py +422 -0
  149. glaip_sdk/utils/serialization.py +184 -51
  150. glaip_sdk/utils/sync.py +142 -0
  151. glaip_sdk/utils/tool_detection.py +33 -0
  152. glaip_sdk/utils/validation.py +21 -30
  153. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/METADATA +58 -12
  154. glaip_sdk-0.6.5b6.dist-info/RECORD +159 -0
  155. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/WHEEL +1 -1
  156. glaip_sdk/models.py +0 -250
  157. glaip_sdk/utils/rendering/renderer/progress.py +0 -118
  158. glaip_sdk/utils/rendering/steps.py +0 -232
  159. glaip_sdk/utils/rich_utils.py +0 -29
  160. glaip_sdk-0.0.7.dist-info/RECORD +0 -55
  161. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/entry_points.txt +0 -0
glaip_sdk/client/base.py CHANGED
@@ -3,10 +3,12 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
9
  import logging
9
10
  import os
11
+ from collections.abc import Iterable, Mapping
10
12
  from typing import Any, NoReturn, Union
11
13
 
12
14
  import httpx
@@ -14,7 +16,7 @@ from dotenv import load_dotenv
14
16
 
15
17
  import glaip_sdk
16
18
  from glaip_sdk._version import __version__ as SDK_VERSION
17
- from glaip_sdk.config.constants import SDK_NAME
19
+ from glaip_sdk.config.constants import DEFAULT_ERROR_MESSAGE, SDK_NAME
18
20
  from glaip_sdk.exceptions import (
19
21
  AuthenticationError,
20
22
  ConflictError,
@@ -151,12 +153,7 @@ class BaseClient:
151
153
  def timeout(self, value: float) -> None:
152
154
  """Set timeout and rebuild client."""
153
155
  self._timeout = value
154
- if (
155
- hasattr(self, "http_client")
156
- and self.http_client
157
- and not self._session_scoped
158
- and not self._parent_client
159
- ):
156
+ if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
160
157
  self.http_client.close()
161
158
  self.http_client = self._build_client(value)
162
159
 
@@ -230,8 +227,13 @@ class BaseClient:
230
227
  else:
231
228
  raise
232
229
 
233
- def _request(self, method: str, endpoint: str, **kwargs) -> Any:
234
- """Make HTTP request with error handling."""
230
+ def _perform_request(
231
+ self,
232
+ method: str,
233
+ endpoint: str,
234
+ **kwargs: Any,
235
+ ) -> httpx.Response:
236
+ """Execute a raw HTTP request with retry handling."""
235
237
  # Ensure client is alive before making request
236
238
  self._ensure_client_alive()
237
239
 
@@ -239,20 +241,31 @@ class BaseClient:
239
241
  try:
240
242
  response = self.http_client.request(method, endpoint, **kwargs)
241
243
  client_log.debug(f"Response status: {response.status_code}")
242
- return self._handle_response(response)
244
+ return response
243
245
  except httpx.ConnectError as e:
244
- client_log.warning(
245
- f"Connection error on {method} {endpoint}, retrying once: {e}"
246
- )
246
+ client_log.warning(f"Connection error on {method} {endpoint}, retrying once: {e}")
247
247
  try:
248
248
  response = self.http_client.request(method, endpoint, **kwargs)
249
- client_log.debug(
250
- f"Retry successful, response status: {response.status_code}"
251
- )
252
- return self._handle_response(response)
249
+ client_log.debug(f"Retry successful, response status: {response.status_code}")
250
+ return response
253
251
  except httpx.ConnectError:
254
252
  client_log.error(f"Retry failed for {method} {endpoint}: {e}")
255
- raise e
253
+ raise
254
+
255
+ def _request(self, method: str, endpoint: str, **kwargs) -> Any:
256
+ """Make HTTP request with error handling and unwrap success envelopes."""
257
+ response = self._perform_request(method, endpoint, **kwargs)
258
+ return self._handle_response(response, unwrap=True)
259
+
260
+ def _request_with_envelope(
261
+ self,
262
+ method: str,
263
+ endpoint: str,
264
+ **kwargs: Any,
265
+ ) -> Any:
266
+ """Make HTTP request but return the full success envelope."""
267
+ response = self._perform_request(method, endpoint, **kwargs)
268
+ return self._handle_response(response, unwrap=False)
256
269
 
257
270
  def _parse_response_content(self, response: httpx.Response) -> Any | None:
258
271
  """Parse response content based on content type."""
@@ -271,14 +284,14 @@ class BaseClient:
271
284
  else:
272
285
  return None # Let _handle_response deal with error status codes
273
286
 
274
- def _handle_success_response(self, parsed: Any) -> Any:
287
+ def _handle_success_response(self, parsed: Any, *, unwrap: bool) -> Any:
275
288
  """Handle successful response with success flag."""
276
289
  if isinstance(parsed, dict) and "success" in parsed:
277
290
  if parsed.get("success"):
278
- return parsed.get("data", parsed)
291
+ return parsed.get("data", parsed) if unwrap else parsed
279
292
  else:
280
293
  error_type = parsed.get("error", "UnknownError")
281
- message = parsed.get("message", "Unknown error")
294
+ message = self._format_error_dict({key: value for key, value in parsed.items() if key != "success"})
282
295
  self._raise_api_error(
283
296
  400,
284
297
  message,
@@ -290,35 +303,142 @@ class BaseClient:
290
303
 
291
304
  def _get_error_message(self, response: httpx.Response) -> str:
292
305
  """Extract error message from response, preferring parsed content."""
293
- # Try to get error message from parsed content if available
294
- error_message = response.text
306
+ parsed = self._parse_error_json(response)
307
+ if parsed is None:
308
+ return response.text
309
+
310
+ formatted = self._format_parsed_error(parsed)
311
+ return formatted if formatted is not None else response.text
312
+
313
+ def _parse_error_json(self, response: httpx.Response) -> Any | None:
314
+ """Safely parse JSON from an error response."""
295
315
  try:
296
- parsed = response.json()
297
- if isinstance(parsed, dict) and "message" in parsed:
298
- error_message = parsed["message"]
299
- elif isinstance(parsed, str):
300
- error_message = parsed
316
+ return response.json()
301
317
  except (ValueError, TypeError):
302
- pass # Use response.text as fallback
303
- return error_message
318
+ return None
304
319
 
305
- def _handle_response(self, response: httpx.Response) -> Any:
306
- """Handle HTTP response with proper error handling."""
307
- # Handle no-content success before general error handling
308
- if response.status_code == 204:
320
+ def _format_parsed_error(self, parsed: Any) -> str | None:
321
+ """Build a readable error message from parsed JSON payloads."""
322
+ if isinstance(parsed, dict):
323
+ return self._format_error_dict(parsed)
324
+ if isinstance(parsed, str):
325
+ return parsed
326
+ return str(parsed) if parsed else None
327
+
328
+ def _format_error_dict(self, parsed: dict[str, Any]) -> str:
329
+ """Format structured API error payloads."""
330
+ detail = parsed.get("detail")
331
+ if isinstance(detail, list):
332
+ validation_message = self._format_validation_errors(detail)
333
+ if validation_message:
334
+ return validation_message
335
+ return f"Validation error: {parsed}"
336
+
337
+ formatted_details = None
338
+ if "details" in parsed:
339
+ formatted_details = self._format_error_details(parsed["details"])
340
+
341
+ message = parsed.get("message")
342
+ if message:
343
+ if formatted_details:
344
+ return f"{message}\n{formatted_details}"
345
+ return message
346
+
347
+ if formatted_details:
348
+ return formatted_details
349
+
350
+ return str(parsed) if parsed else DEFAULT_ERROR_MESSAGE
351
+
352
+ def _format_error_details(self, details: Any) -> str | None:
353
+ """Render generic error details into a human-readable string."""
354
+ if details is None:
309
355
  return None
310
356
 
311
- # Handle error status codes
312
- if not (200 <= response.status_code < 300):
313
- error_message = self._get_error_message(response)
314
- self._raise_api_error(response.status_code, error_message)
315
- return None # Won't be reached but helps with type checking
357
+ if isinstance(details, dict):
358
+ return self._format_detail_mapping(details)
316
359
 
317
- parsed = self._parse_response_content(response)
318
- if parsed is None:
360
+ if isinstance(details, (list, tuple, set)):
361
+ return self._format_detail_iterable(details)
362
+
363
+ return f"Details: {details}"
364
+
365
+ @staticmethod
366
+ def _format_detail_mapping(details: Mapping[str, Any]) -> str | None:
367
+ """Format details provided as a mapping."""
368
+ entries = [f" {key}: {value}" for key, value in details.items()]
369
+ if not entries:
370
+ return None
371
+ return "Details:\n" + "\n".join(entries)
372
+
373
+ @staticmethod
374
+ def _format_detail_iterable(details: Iterable[Any]) -> str | None:
375
+ """Format details provided as an iterable collection."""
376
+ entries: list[str] = []
377
+ for item in details:
378
+ if isinstance(item, Mapping):
379
+ inner = ", ".join(f"{k}={v}" for k, v in item.items())
380
+ entries.append(f" - {inner if inner else '{}'}")
381
+ else:
382
+ entries.append(f" - {item}")
383
+
384
+ if not entries:
385
+ return None
386
+
387
+ return "Details:\n" + "\n".join(entries)
388
+
389
+ def _format_validation_errors(self, errors: list[Any]) -> str | None:
390
+ """Render validation errors into a human-readable string."""
391
+ entries: list[str] = []
392
+ for error in errors:
393
+ if isinstance(error, dict):
394
+ loc = " -> ".join(str(x) for x in error.get("loc", []))
395
+ msg = error.get("msg", DEFAULT_ERROR_MESSAGE)
396
+ error_type = error.get("type", "unknown")
397
+ prefix = loc if loc else "Field"
398
+ entries.append(f" {prefix}: {msg} ({error_type})")
399
+ else:
400
+ entries.append(f" {error}")
401
+
402
+ if not entries:
403
+ return None
404
+
405
+ return "Validation errors:\n" + "\n".join(entries)
406
+
407
+ @staticmethod
408
+ def _is_no_content_response(response: httpx.Response) -> bool:
409
+ """Return True when the response contains no content."""
410
+ return response.status_code == 204
411
+
412
+ @staticmethod
413
+ def _is_success_status(response: httpx.Response) -> bool:
414
+ """Return True for successful HTTP status codes."""
415
+ return 200 <= response.status_code < 300
416
+
417
+ def _handle_error_response(self, response: httpx.Response) -> None:
418
+ """Raise an API error for non-success responses."""
419
+ error_message = self._get_error_message(response)
420
+ parsed_content = self._parse_response_content(response)
421
+ self._raise_api_error(response.status_code, error_message, payload=parsed_content)
422
+
423
+ def _handle_response(
424
+ self,
425
+ response: httpx.Response,
426
+ *,
427
+ unwrap: bool = True,
428
+ ) -> Any:
429
+ """Handle HTTP response with proper error handling."""
430
+ # Handle no-content success before general error handling
431
+ if self._is_no_content_response(response):
319
432
  return None
320
433
 
321
- return self._handle_success_response(parsed)
434
+ if self._is_success_status(response):
435
+ parsed = self._parse_response_content(response)
436
+ if parsed is None:
437
+ return None
438
+ return self._handle_success_response(parsed, unwrap=unwrap)
439
+
440
+ self._handle_error_response(response)
441
+ return None
322
442
 
323
443
  def _raise_api_error(
324
444
  self,
@@ -363,12 +483,7 @@ class BaseClient:
363
483
 
364
484
  def close(self) -> None:
365
485
  """Close the HTTP client."""
366
- if (
367
- hasattr(self, "http_client")
368
- and self.http_client
369
- and not self._session_scoped
370
- and not self._parent_client
371
- ):
486
+ if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
372
487
  self.http_client.close()
373
488
 
374
489
  def __enter__(self) -> "BaseClient":
glaip_sdk/client/main.py CHANGED
@@ -3,29 +3,38 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
- from typing import Any
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
9
12
 
10
13
  from glaip_sdk.client.agents import AgentClient
11
14
  from glaip_sdk.client.base import BaseClient
12
15
  from glaip_sdk.client.mcps import MCPClient
16
+ from glaip_sdk.client.shared import build_shared_config
13
17
  from glaip_sdk.client.tools import ToolClient
14
- from glaip_sdk.models import MCP, Agent, Tool
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from glaip_sdk.agents import Agent
21
+ from glaip_sdk.client._agent_payloads import AgentListResult
22
+ from glaip_sdk.mcps import MCP
23
+ from glaip_sdk.tools import Tool
15
24
 
16
25
 
17
26
  class Client(BaseClient):
18
27
  """Main client that composes all specialized clients and shares one HTTP session."""
19
28
 
20
29
  def __init__(self, **kwargs):
30
+ """Initialize the main client.
31
+
32
+ Args:
33
+ **kwargs: Client configuration arguments (api_url, api_key, timeout, etc.)
34
+ """
21
35
  super().__init__(**kwargs)
22
36
  # Share the single httpx.Client + config with sub-clients
23
- shared_config = {
24
- "parent_client": self,
25
- "api_url": self.api_url,
26
- "api_key": self.api_key,
27
- "timeout": self._timeout,
28
- }
37
+ shared_config = build_shared_config(self)
29
38
  self.agents = AgentClient(**shared_config)
30
39
  self.tools = ToolClient(**shared_config)
31
40
  self.mcps = MCPClient(**shared_config)
@@ -37,6 +46,10 @@ class Client(BaseClient):
37
46
  """Create a new agent."""
38
47
  return self.agents.create_agent(**kwargs)
39
48
 
49
+ def create_agent_from_file(self, *args, **kwargs) -> Agent:
50
+ """Create a new agent from a JSON or YAML configuration file."""
51
+ return self.agents.create_agent_from_file(*args, **kwargs)
52
+
40
53
  def list_agents(
41
54
  self,
42
55
  agent_type: str | None = None,
@@ -44,7 +57,7 @@ class Client(BaseClient):
44
57
  name: str | None = None,
45
58
  version: str | None = None,
46
59
  sync_langflow_agents: bool = False,
47
- ) -> list[Agent]:
60
+ ) -> AgentListResult:
48
61
  """List agents with optional filtering.
49
62
 
50
63
  Args:
@@ -55,7 +68,7 @@ class Client(BaseClient):
55
68
  sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
56
69
 
57
70
  Returns:
58
- List of agents matching the filters
71
+ AgentListResult with agents and pagination metadata. Supports iteration and indexing.
59
72
  """
60
73
  return self.agents.list_agents(
61
74
  agent_type=agent_type,
@@ -81,6 +94,10 @@ class Client(BaseClient):
81
94
  """Update an existing agent."""
82
95
  return self.agents.update_agent(agent_id, **kwargs)
83
96
 
97
+ def update_agent_from_file(self, agent_id: str, *args, **kwargs) -> Agent:
98
+ """Update an existing agent using a JSON or YAML configuration file."""
99
+ return self.agents.update_agent_from_file(agent_id, *args, **kwargs)
100
+
84
101
  def delete_agent(self, agent_id: str) -> bool:
85
102
  """Delete an agent."""
86
103
  return self.agents.delete_agent(agent_id)
@@ -145,9 +162,9 @@ class Client(BaseClient):
145
162
  """Get tool script content."""
146
163
  return self.tools.get_tool_script(tool_id)
147
164
 
148
- def update_tool_via_file(self, tool_id: str, file_path: str) -> Tool:
165
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
149
166
  """Update tool via file."""
150
- return self.tools.update_tool_via_file(tool_id, file_path)
167
+ return self.tools.update_tool_via_file(tool_id, file_path, **kwargs)
151
168
 
152
169
  # MCPs
153
170
  def create_mcp(self, **kwargs) -> MCP:
@@ -199,10 +216,16 @@ class Client(BaseClient):
199
216
  # ---- Timeout propagation ----
200
217
  @property
201
218
  def timeout(self) -> float: # type: ignore[override]
219
+ """Get the client timeout value."""
202
220
  return super().timeout
203
221
 
204
222
  @timeout.setter
205
223
  def timeout(self, value: float) -> None: # type: ignore[override]
224
+ """Set the client timeout and propagate to sub-clients.
225
+
226
+ Args:
227
+ value: Timeout value in seconds.
228
+ """
206
229
  # Rebuild the root http client
207
230
  BaseClient.timeout.fset(self, value) # call parent setter
208
231
  # Propagate the new session to sub-clients so they don't hold a closed client
glaip_sdk/client/mcps.py CHANGED
@@ -3,18 +3,22 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
9
  import logging
9
10
  from typing import Any
10
11
 
11
12
  from glaip_sdk.client.base import BaseClient
12
- from glaip_sdk.config.constants import (
13
- DEFAULT_MCP_TRANSPORT,
14
- DEFAULT_MCP_TYPE,
13
+ from glaip_sdk.config.constants import DEFAULT_MCP_TRANSPORT, DEFAULT_MCP_TYPE
14
+ from glaip_sdk.mcps import MCP
15
+ from glaip_sdk.models import MCPResponse
16
+ from glaip_sdk.utils.client_utils import (
17
+ add_kwargs_to_payload,
18
+ create_model_instances,
19
+ find_by_name,
15
20
  )
16
- from glaip_sdk.models import MCP
17
- from glaip_sdk.utils.client_utils import create_model_instances, find_by_name
21
+ from glaip_sdk.utils.resource_refs import is_uuid
18
22
 
19
23
  # API endpoints
20
24
  MCPS_ENDPOINT = "/mcps/"
@@ -45,7 +49,8 @@ class MCPClient(BaseClient):
45
49
  def get_mcp_by_id(self, mcp_id: str) -> MCP:
46
50
  """Get MCP by ID."""
47
51
  data = self._request("GET", f"{MCPS_ENDPOINT}{mcp_id}")
48
- return MCP(**data)._set_client(self)
52
+ response = MCPResponse(**data)
53
+ return MCP.from_response(response, client=self)
49
54
 
50
55
  def find_mcps(self, name: str | None = None) -> list[MCP]:
51
56
  """Find MCPs by name."""
@@ -57,7 +62,7 @@ class MCPClient(BaseClient):
57
62
  def create_mcp(
58
63
  self,
59
64
  name: str,
60
- description: str,
65
+ description: str | None = None,
61
66
  config: dict[str, Any] | None = None,
62
67
  **kwargs,
63
68
  ) -> MCP:
@@ -77,7 +82,8 @@ class MCPClient(BaseClient):
77
82
  get_endpoint_fmt=f"{MCPS_ENDPOINT}{{id}}",
78
83
  json=payload,
79
84
  )
80
- return MCP(**full_mcp_data)._set_client(self)
85
+ response = MCPResponse(**full_mcp_data)
86
+ return MCP.from_response(response, client=self)
81
87
 
82
88
  def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
83
89
  """Update an existing MCP.
@@ -99,16 +105,106 @@ class MCPClient(BaseClient):
99
105
  method = "PATCH"
100
106
 
101
107
  data = self._request(method, f"{MCPS_ENDPOINT}{mcp_id}", json=kwargs)
102
- return MCP(**data)._set_client(self)
108
+ response = MCPResponse(**data)
109
+ return MCP.from_response(response, client=self)
103
110
 
104
111
  def delete_mcp(self, mcp_id: str) -> None:
105
112
  """Delete an MCP."""
106
113
  self._request("DELETE", f"{MCPS_ENDPOINT}{mcp_id}")
107
114
 
115
+ def upsert_mcp(
116
+ self,
117
+ identifier: str | MCP,
118
+ description: str | None = None,
119
+ config: dict[str, Any] | None = None,
120
+ **kwargs,
121
+ ) -> MCP:
122
+ """Create or update an MCP by instance, ID, or name.
123
+
124
+ Args:
125
+ identifier: MCP instance, ID (UUID string), or name
126
+ description: MCP description
127
+ config: MCP configuration dictionary
128
+ **kwargs: Additional parameters (transport, metadata, etc.)
129
+
130
+ Returns:
131
+ The created or updated MCP.
132
+
133
+ Example:
134
+ >>> # By name (creates if not exists)
135
+ >>> mcp = client.mcps.upsert_mcp(
136
+ ... "deepwiki",
137
+ ... transport="sse",
138
+ ... config={"url": "https://mcp.deepwiki.com/sse"},
139
+ ... )
140
+ >>> # By instance
141
+ >>> mcp = client.mcps.upsert_mcp(existing_mcp, description="Updated")
142
+ >>> # By ID
143
+ >>> mcp = client.mcps.upsert_mcp("uuid-here", description="Updated")
144
+ """
145
+ # Handle MCP instance
146
+ if isinstance(identifier, MCP):
147
+ if identifier.id:
148
+ logger.info("Updating MCP by instance: %s", identifier.name)
149
+ return self._do_upsert_update(identifier.id, identifier.name, description, config, **kwargs)
150
+ # MCP without ID - treat name as identifier
151
+ identifier = identifier.name
152
+
153
+ # Handle string (ID or name)
154
+ if isinstance(identifier, str):
155
+ if is_uuid(identifier):
156
+ logger.info("Updating MCP by ID: %s", identifier)
157
+ existing = self.get_mcp_by_id(identifier)
158
+ return self._do_upsert_update(identifier, existing.name, description, config, **kwargs)
159
+
160
+ # It's a name - find or create
161
+ return self._upsert_by_name(identifier, description, config, **kwargs)
162
+
163
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
164
+
165
+ def _do_upsert_update(
166
+ self,
167
+ mcp_id: str,
168
+ name: str | None,
169
+ description: str | None,
170
+ config: dict[str, Any] | None,
171
+ **kwargs,
172
+ ) -> MCP:
173
+ """Perform the update part of upsert."""
174
+ update_kwargs = {**kwargs}
175
+ if name is not None:
176
+ update_kwargs["name"] = name
177
+ if description is not None:
178
+ update_kwargs["description"] = description
179
+ if config is not None:
180
+ update_kwargs["config"] = config
181
+ return self.update_mcp(mcp_id, **update_kwargs)
182
+
183
+ def _upsert_by_name(
184
+ self,
185
+ name: str,
186
+ description: str | None,
187
+ config: dict[str, Any] | None,
188
+ **kwargs,
189
+ ) -> MCP:
190
+ """Find by name and update, or create if not found."""
191
+ existing = self.find_mcps(name)
192
+
193
+ if len(existing) == 1:
194
+ logger.info("Updating existing MCP: %s", name)
195
+ return self._do_upsert_update(existing[0].id, name, description, config, **kwargs)
196
+
197
+ if len(existing) > 1:
198
+ raise ValueError(f"Multiple MCPs found with name '{name}'")
199
+
200
+ # Create new MCP
201
+ logger.info("Creating new MCP: %s", name)
202
+ return self.create_mcp(name=name, description=description, config=config, **kwargs)
203
+
108
204
  def _build_create_payload(
109
205
  self,
110
206
  name: str,
111
- description: str,
207
+ description: str | None = None,
112
208
  transport: str = DEFAULT_MCP_TRANSPORT,
113
209
  config: dict[str, Any] | None = None,
114
210
  **kwargs,
@@ -122,7 +218,7 @@ class MCPClient(BaseClient):
122
218
 
123
219
  Args:
124
220
  name: MCP name
125
- description: MCP description
221
+ description: MCP description (optional)
126
222
  transport: MCP transport protocol (defaults to stdio)
127
223
  config: MCP configuration dictionary
128
224
  **kwargs: Additional parameters
@@ -147,9 +243,7 @@ class MCPClient(BaseClient):
147
243
 
148
244
  # Add any other kwargs (excluding already handled ones)
149
245
  excluded_keys = {"type"} # type is handled above
150
- for key, value in kwargs.items():
151
- if key not in excluded_keys:
152
- payload[key] = value
246
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
153
247
 
154
248
  return payload
155
249
 
@@ -179,9 +273,7 @@ class MCPClient(BaseClient):
179
273
  update_data = {
180
274
  "name": name if name is not None else current_mcp.name,
181
275
  "type": DEFAULT_MCP_TYPE, # Required by backend, MCPs are always server type
182
- "transport": kwargs.get(
183
- "transport", getattr(current_mcp, "transport", DEFAULT_MCP_TRANSPORT)
184
- ),
276
+ "transport": kwargs.get("transport", getattr(current_mcp, "transport", DEFAULT_MCP_TRANSPORT)),
185
277
  }
186
278
 
187
279
  # Handle description with proper None handling
@@ -208,7 +300,23 @@ class MCPClient(BaseClient):
208
300
  def get_mcp_tools(self, mcp_id: str) -> list[dict[str, Any]]:
209
301
  """Get tools available from an MCP."""
210
302
  data = self._request("GET", f"{MCPS_ENDPOINT}{mcp_id}/tools")
211
- return data or []
303
+ if data is None:
304
+ return []
305
+ if isinstance(data, list):
306
+ return data
307
+ if isinstance(data, dict):
308
+ if "tools" in data:
309
+ return data.get("tools", []) or []
310
+ logger.warning(
311
+ "Unexpected MCP tools response keys %s; returning empty list",
312
+ list(data.keys()),
313
+ )
314
+ return []
315
+ logger.warning(
316
+ "Unexpected MCP tools response type %s; returning empty list",
317
+ type(data).__name__,
318
+ )
319
+ return []
212
320
 
213
321
  def test_mcp_connection(self, config: dict[str, Any]) -> dict[str, Any]:
214
322
  """Test MCP connection using configuration.