glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__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 (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +265 -45
  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 +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -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 +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  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 +876 -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 +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.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
@@ -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
 
@@ -246,18 +243,14 @@ class BaseClient:
246
243
  client_log.debug(f"Response status: {response.status_code}")
247
244
  return response
248
245
  except httpx.ConnectError as e:
249
- client_log.warning(
250
- f"Connection error on {method} {endpoint}, retrying once: {e}"
251
- )
246
+ client_log.warning(f"Connection error on {method} {endpoint}, retrying once: {e}")
252
247
  try:
253
248
  response = self.http_client.request(method, endpoint, **kwargs)
254
- client_log.debug(
255
- f"Retry successful, response status: {response.status_code}"
256
- )
249
+ client_log.debug(f"Retry successful, response status: {response.status_code}")
257
250
  return response
258
251
  except httpx.ConnectError:
259
252
  client_log.error(f"Retry failed for {method} {endpoint}: {e}")
260
- raise e
253
+ raise
261
254
 
262
255
  def _request(self, method: str, endpoint: str, **kwargs) -> Any:
263
256
  """Make HTTP request with error handling and unwrap success envelopes."""
@@ -298,7 +291,7 @@ class BaseClient:
298
291
  return parsed.get("data", parsed) if unwrap else parsed
299
292
  else:
300
293
  error_type = parsed.get("error", "UnknownError")
301
- message = parsed.get("message", DEFAULT_ERROR_MESSAGE)
294
+ message = self._format_error_dict({key: value for key, value in parsed.items() if key != "success"})
302
295
  self._raise_api_error(
303
296
  400,
304
297
  message,
@@ -341,12 +334,58 @@ class BaseClient:
341
334
  return validation_message
342
335
  return f"Validation error: {parsed}"
343
336
 
337
+ formatted_details = None
338
+ if "details" in parsed:
339
+ formatted_details = self._format_error_details(parsed["details"])
340
+
344
341
  message = parsed.get("message")
345
342
  if message:
343
+ if formatted_details:
344
+ return f"{message}\n{formatted_details}"
346
345
  return message
347
346
 
347
+ if formatted_details:
348
+ return formatted_details
349
+
348
350
  return str(parsed) if parsed else DEFAULT_ERROR_MESSAGE
349
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:
355
+ return None
356
+
357
+ if isinstance(details, dict):
358
+ return self._format_detail_mapping(details)
359
+
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
+
350
389
  def _format_validation_errors(self, errors: list[Any]) -> str | None:
351
390
  """Render validation errors into a human-readable string."""
352
391
  entries: list[str] = []
@@ -365,6 +404,22 @@ class BaseClient:
365
404
 
366
405
  return "Validation errors:\n" + "\n".join(entries)
367
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
+
368
423
  def _handle_response(
369
424
  self,
370
425
  response: httpx.Response,
@@ -373,24 +428,17 @@ class BaseClient:
373
428
  ) -> Any:
374
429
  """Handle HTTP response with proper error handling."""
375
430
  # Handle no-content success before general error handling
376
- if response.status_code == 204:
431
+ if self._is_no_content_response(response):
377
432
  return None
378
433
 
379
- # Handle error status codes
380
- if not (200 <= response.status_code < 300):
381
- error_message = self._get_error_message(response)
382
- # Try to parse response content for payload
383
- parsed_content = self._parse_response_content(response)
384
- self._raise_api_error(
385
- response.status_code, error_message, payload=parsed_content
386
- )
387
- return None # Won't be reached but helps with type checking
388
-
389
- parsed = self._parse_response_content(response)
390
- if parsed is None:
391
- return None
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)
392
439
 
393
- return self._handle_success_response(parsed, unwrap=unwrap)
440
+ self._handle_error_response(response)
441
+ return None
394
442
 
395
443
  def _raise_api_error(
396
444
  self,
@@ -435,12 +483,7 @@ class BaseClient:
435
483
 
436
484
  def close(self) -> None:
437
485
  """Close the HTTP client."""
438
- if (
439
- hasattr(self, "http_client")
440
- and self.http_client
441
- and not self._session_scoped
442
- and not self._parent_client
443
- ):
486
+ if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
444
487
  self.http_client.close()
445
488
 
446
489
  def __enter__(self) -> "BaseClient":
glaip_sdk/client/main.py CHANGED
@@ -3,15 +3,24 @@
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):
@@ -25,12 +34,7 @@ class Client(BaseClient):
25
34
  """
26
35
  super().__init__(**kwargs)
27
36
  # Share the single httpx.Client + config with sub-clients
28
- shared_config = {
29
- "parent_client": self,
30
- "api_url": self.api_url,
31
- "api_key": self.api_key,
32
- "timeout": self._timeout,
33
- }
37
+ shared_config = build_shared_config(self)
34
38
  self.agents = AgentClient(**shared_config)
35
39
  self.tools = ToolClient(**shared_config)
36
40
  self.mcps = MCPClient(**shared_config)
@@ -53,7 +57,7 @@ class Client(BaseClient):
53
57
  name: str | None = None,
54
58
  version: str | None = None,
55
59
  sync_langflow_agents: bool = False,
56
- ) -> list[Agent]:
60
+ ) -> AgentListResult:
57
61
  """List agents with optional filtering.
58
62
 
59
63
  Args:
@@ -64,7 +68,7 @@ class Client(BaseClient):
64
68
  sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
65
69
 
66
70
  Returns:
67
- List of agents matching the filters
71
+ AgentListResult with agents and pagination metadata. Supports iteration and indexing.
68
72
  """
69
73
  return self.agents.list_agents(
70
74
  agent_type=agent_type,
@@ -217,6 +221,11 @@ class Client(BaseClient):
217
221
 
218
222
  @timeout.setter
219
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
+ """
220
229
  # Rebuild the root http client
221
230
  BaseClient.timeout.fset(self, value) # call parent setter
222
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."""
@@ -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,12 +105,102 @@ 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,
@@ -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.