glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.15b3__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/METADATA +1 -1
  150. glaip_sdk-0.6.15b3.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """Main client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from glaip_sdk.client.agents import AgentClient
14
+ from glaip_sdk.client.base import BaseClient
15
+ from glaip_sdk.client.mcps import MCPClient
16
+ from glaip_sdk.client.shared import build_shared_config
17
+ from glaip_sdk.client.tools import ToolClient
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
24
+
25
+
26
+ class Client(BaseClient):
27
+ """Main client that composes all specialized clients and shares one HTTP session."""
28
+
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
+ """
35
+ super().__init__(**kwargs)
36
+ # Share the single httpx.Client + config with sub-clients
37
+ shared_config = build_shared_config(self)
38
+ self.agents = AgentClient(**shared_config)
39
+ self.tools = ToolClient(**shared_config)
40
+ self.mcps = MCPClient(**shared_config)
41
+
42
+ # ---- Core API Methods (Public Interface) ----
43
+
44
+ # Agents
45
+ def create_agent(self, **kwargs) -> Agent:
46
+ """Create a new agent."""
47
+ return self.agents.create_agent(**kwargs)
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
+
53
+ def list_agents(
54
+ self,
55
+ agent_type: str | None = None,
56
+ framework: str | None = None,
57
+ name: str | None = None,
58
+ version: str | None = None,
59
+ sync_langflow_agents: bool = False,
60
+ ) -> AgentListResult:
61
+ """List agents with optional filtering.
62
+
63
+ Args:
64
+ agent_type: Filter by agent type (config, code, a2a)
65
+ framework: Filter by framework (langchain, langgraph, google_adk)
66
+ name: Filter by partial name match (case-insensitive)
67
+ version: Filter by exact version match
68
+ sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
69
+
70
+ Returns:
71
+ AgentListResult with agents and pagination metadata. Supports iteration and indexing.
72
+ """
73
+ return self.agents.list_agents(
74
+ agent_type=agent_type,
75
+ framework=framework,
76
+ name=name,
77
+ version=version,
78
+ sync_langflow_agents=sync_langflow_agents,
79
+ )
80
+
81
+ def get_agent_by_id(self, agent_id: str) -> Agent | None:
82
+ """Get agent by ID."""
83
+ return self.agents.get_agent_by_id(agent_id)
84
+
85
+ def get_agent(self, agent_id: str) -> Agent | None:
86
+ """Get agent by ID (alias for get_agent_by_id)."""
87
+ return self.get_agent_by_id(agent_id)
88
+
89
+ def find_agents(self, name: str | None = None) -> list[Agent]:
90
+ """Find agents by name."""
91
+ return self.agents.find_agents(name)
92
+
93
+ def update_agent(self, agent_id: str, **kwargs) -> Agent:
94
+ """Update an existing agent."""
95
+ return self.agents.update_agent(agent_id, **kwargs)
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
+
101
+ def delete_agent(self, agent_id: str) -> bool:
102
+ """Delete an agent."""
103
+ return self.agents.delete_agent(agent_id)
104
+
105
+ def run_agent(self, agent_id: str, message: str, **kwargs) -> str:
106
+ """Run an agent with a message."""
107
+ return self.agents.run_agent(agent_id, message, **kwargs)
108
+
109
+ def sync_langflow_agents(
110
+ self,
111
+ base_url: str | None = None,
112
+ api_key: str | None = None,
113
+ ) -> dict[str, Any]:
114
+ """Sync LangFlow agents by fetching flows from the LangFlow server.
115
+
116
+ This method synchronizes agents with LangFlow flows. It fetches all flows
117
+ from the configured LangFlow server and creates/updates corresponding agents.
118
+
119
+ Args:
120
+ base_url: Custom LangFlow server base URL. If not provided, uses LANGFLOW_BASE_URL env var.
121
+ api_key: Custom LangFlow API key. If not provided, uses LANGFLOW_API_KEY env var.
122
+
123
+ Returns:
124
+ Response containing sync results and statistics
125
+ """
126
+ return self.agents.sync_langflow_agents(base_url=base_url, api_key=api_key)
127
+
128
+ # Tools
129
+ def create_tool(self, **kwargs) -> Tool:
130
+ """Create a new tool."""
131
+ return self.tools.create_tool(**kwargs)
132
+
133
+ def create_tool_from_code(self, **kwargs) -> Tool:
134
+ """Create a new tool from code."""
135
+ return self.tools.create_tool_from_code(**kwargs)
136
+
137
+ def list_tools(self, tool_type: str | None = None) -> list[Tool]:
138
+ """List tools with optional type filtering."""
139
+ return self.tools.list_tools(tool_type=tool_type)
140
+
141
+ def get_tool_by_id(self, tool_id: str) -> Tool | None:
142
+ """Get tool by ID."""
143
+ return self.tools.get_tool_by_id(tool_id)
144
+
145
+ def get_tool(self, tool_id: str) -> Tool | None:
146
+ """Backward-compatible alias for get_tool_by_id."""
147
+ return self.get_tool_by_id(tool_id)
148
+
149
+ def find_tools(self, name: str) -> list[Tool]:
150
+ """Find tools by name."""
151
+ return self.tools.find_tools(name)
152
+
153
+ def update_tool(self, tool_id: str, **kwargs) -> Tool:
154
+ """Update an existing tool."""
155
+ return self.tools.update_tool(tool_id, **kwargs)
156
+
157
+ def delete_tool(self, tool_id: str) -> bool:
158
+ """Delete a tool."""
159
+ return self.tools.delete_tool(tool_id)
160
+
161
+ def get_tool_script(self, tool_id: str) -> str:
162
+ """Get tool script content."""
163
+ return self.tools.get_tool_script(tool_id)
164
+
165
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
166
+ """Update tool via file."""
167
+ return self.tools.update_tool_via_file(tool_id, file_path, **kwargs)
168
+
169
+ # MCPs
170
+ def create_mcp(self, **kwargs) -> MCP:
171
+ """Create a new MCP."""
172
+ return self.mcps.create_mcp(**kwargs)
173
+
174
+ def list_mcps(self) -> list[MCP]:
175
+ """List all MCPs."""
176
+ return self.mcps.list_mcps()
177
+
178
+ def get_mcp_by_id(self, mcp_id: str) -> MCP | None:
179
+ """Get MCP by ID."""
180
+ return self.mcps.get_mcp_by_id(mcp_id)
181
+
182
+ def get_mcp(self, mcp_id: str) -> MCP | None:
183
+ """Backward-compatible alias for get_mcp_by_id."""
184
+ return self.get_mcp_by_id(mcp_id)
185
+
186
+ def find_mcps(self, name: str) -> list[MCP]:
187
+ """Find MCPs by name."""
188
+ return self.mcps.find_mcps(name)
189
+
190
+ def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
191
+ """Update an existing MCP."""
192
+ return self.mcps.update_mcp(mcp_id, **kwargs)
193
+
194
+ def delete_mcp(self, mcp_id: str) -> bool:
195
+ """Delete an MCP."""
196
+ return self.mcps.delete_mcp(mcp_id)
197
+
198
+ def test_mcp_connection(self, config: dict) -> dict:
199
+ """Test MCP connection."""
200
+ return self.mcps.test_mcp_connection(config)
201
+
202
+ def test_mcp_connection_from_config(self, config: dict) -> dict:
203
+ """Test MCP connection from config."""
204
+ return self.mcps.test_mcp_connection_from_config(config)
205
+
206
+ def get_mcp_tools_from_config(self, config: dict) -> list[dict]:
207
+ """Get MCP tools from config."""
208
+ return self.mcps.get_mcp_tools_from_config(config)
209
+
210
+ # Language Models
211
+ def list_language_models(self) -> list[dict]:
212
+ """List available language models."""
213
+ data = self._request("GET", "/language-models")
214
+ return data or []
215
+
216
+ # ---- Timeout propagation ----
217
+ @property
218
+ def timeout(self) -> float: # type: ignore[override]
219
+ """Get the client timeout value."""
220
+ return super().timeout
221
+
222
+ @timeout.setter
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
+ """
229
+ # Rebuild the root http client
230
+ BaseClient.timeout.fset(self, value) # call parent setter
231
+ # Propagate the new session to sub-clients so they don't hold a closed client
232
+ try:
233
+ if hasattr(self, "agents"):
234
+ self.agents.http_client = self.http_client
235
+ if hasattr(self, "tools"):
236
+ self.tools.http_client = self.http_client
237
+ if hasattr(self, "mcps"):
238
+ self.mcps.http_client = self.http_client
239
+ except Exception:
240
+ pass
241
+
242
+ # ---- Health Check ----
243
+ def ping(self) -> bool:
244
+ """Check if the API is reachable."""
245
+ try:
246
+ self._request("GET", "/health-check")
247
+ return True
248
+ except Exception:
249
+ return False
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """MCP client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from glaip_sdk.client.base import BaseClient
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,
20
+ )
21
+ from glaip_sdk.utils.resource_refs import is_uuid
22
+
23
+ # API endpoints
24
+ MCPS_ENDPOINT = "/mcps/"
25
+ MCPS_CONNECT_ENDPOINT = "/mcps/connect"
26
+ MCPS_CONNECT_TOOLS_ENDPOINT = "/mcps/connect/tools"
27
+
28
+ # Set up module-level logger
29
+ logger = logging.getLogger("glaip_sdk.mcps")
30
+
31
+
32
+ class MCPClient(BaseClient):
33
+ """Client for MCP operations."""
34
+
35
+ def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
36
+ """Initialize the MCP client.
37
+
38
+ Args:
39
+ parent_client: Parent client to adopt session/config from
40
+ **kwargs: Additional arguments for standalone initialization
41
+ """
42
+ super().__init__(parent_client=parent_client, **kwargs)
43
+
44
+ def list_mcps(self) -> list[MCP]:
45
+ """List all MCPs."""
46
+ data = self._request("GET", MCPS_ENDPOINT)
47
+ return create_model_instances(data, MCP, self)
48
+
49
+ def get_mcp_by_id(self, mcp_id: str) -> MCP:
50
+ """Get MCP by ID."""
51
+ data = self._request("GET", f"{MCPS_ENDPOINT}{mcp_id}")
52
+ response = MCPResponse(**data)
53
+ return MCP.from_response(response, client=self)
54
+
55
+ def find_mcps(self, name: str | None = None) -> list[MCP]:
56
+ """Find MCPs by name."""
57
+ # Backend doesn't support name query parameter, so we fetch all and filter client-side
58
+ data = self._request("GET", MCPS_ENDPOINT)
59
+ mcps = create_model_instances(data, MCP, self)
60
+ return find_by_name(mcps, name, case_sensitive=False)
61
+
62
+ def create_mcp(
63
+ self,
64
+ name: str,
65
+ description: str | None = None,
66
+ config: dict[str, Any] | None = None,
67
+ **kwargs,
68
+ ) -> MCP:
69
+ """Create a new MCP."""
70
+ # Use the helper method to build a properly structured payload
71
+ payload = self._build_create_payload(
72
+ name=name,
73
+ description=description,
74
+ config=config,
75
+ **kwargs,
76
+ )
77
+
78
+ # Create the MCP and fetch full details
79
+ full_mcp_data = self._post_then_fetch(
80
+ id_key="id",
81
+ post_endpoint=MCPS_ENDPOINT,
82
+ get_endpoint_fmt=f"{MCPS_ENDPOINT}{{id}}",
83
+ json=payload,
84
+ )
85
+ response = MCPResponse(**full_mcp_data)
86
+ return MCP.from_response(response, client=self)
87
+
88
+ def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
89
+ """Update an existing MCP.
90
+
91
+ Automatically chooses between PUT (full update) and PATCH (partial update)
92
+ based on the provided fields:
93
+ - Uses PUT if name, config, and transport are all provided (full update)
94
+ - Uses PATCH otherwise (partial update)
95
+ """
96
+ # Check if all required fields for full update are provided
97
+ required_fields = {"name", "config", "transport"}
98
+ provided_fields = set(kwargs.keys())
99
+
100
+ if required_fields.issubset(provided_fields):
101
+ # All required fields provided - use full update (PUT)
102
+ method = "PUT"
103
+ else:
104
+ # Partial update - use PATCH
105
+ method = "PATCH"
106
+
107
+ data = self._request(method, f"{MCPS_ENDPOINT}{mcp_id}", json=kwargs)
108
+ response = MCPResponse(**data)
109
+ return MCP.from_response(response, client=self)
110
+
111
+ def delete_mcp(self, mcp_id: str) -> None:
112
+ """Delete an MCP."""
113
+ self._request("DELETE", f"{MCPS_ENDPOINT}{mcp_id}")
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
+
204
+ def _build_create_payload(
205
+ self,
206
+ name: str,
207
+ description: str | None = None,
208
+ transport: str = DEFAULT_MCP_TRANSPORT,
209
+ config: dict[str, Any] | None = None,
210
+ **kwargs,
211
+ ) -> dict[str, Any]:
212
+ """Build payload for MCP creation with proper metadata handling.
213
+
214
+ CENTRALIZED PAYLOAD BUILDING LOGIC:
215
+ - Sets proper defaults and required fields
216
+ - Handles config serialization consistently
217
+ - Processes transport and other metadata properly
218
+
219
+ Args:
220
+ name: MCP name
221
+ description: MCP description (optional)
222
+ transport: MCP transport protocol (defaults to stdio)
223
+ config: MCP configuration dictionary
224
+ **kwargs: Additional parameters
225
+
226
+ Returns:
227
+ Complete payload dictionary for MCP creation
228
+ """
229
+ # Prepare the creation payload with required fields
230
+ payload: dict[str, Any] = {
231
+ "name": name.strip(),
232
+ "type": DEFAULT_MCP_TYPE, # MCPs are always server type
233
+ "transport": transport,
234
+ }
235
+
236
+ # Add description if provided
237
+ if description:
238
+ payload["description"] = description.strip()
239
+
240
+ # Handle config - ensure it's properly serialized
241
+ if config:
242
+ payload["config"] = config
243
+
244
+ # Add any other kwargs (excluding already handled ones)
245
+ excluded_keys = {"type"} # type is handled above
246
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
247
+
248
+ return payload
249
+
250
+ def _build_update_payload(
251
+ self,
252
+ current_mcp: MCP,
253
+ name: str | None = None,
254
+ description: str | None = None,
255
+ **kwargs,
256
+ ) -> dict[str, Any]:
257
+ """Build payload for MCP update with proper current state preservation.
258
+
259
+ Args:
260
+ current_mcp: Current MCP object to update
261
+ name: New MCP name (None to keep current)
262
+ description: New description (None to keep current)
263
+ **kwargs: Additional parameters (config, transport, etc.)
264
+
265
+ Returns:
266
+ Complete payload dictionary for MCP update
267
+
268
+ Notes:
269
+ - Preserves current values as defaults when new values not provided
270
+ - Handles config updates properly
271
+ """
272
+ # Prepare the update payload with current values as defaults
273
+ update_data = {
274
+ "name": name if name is not None else current_mcp.name,
275
+ "type": DEFAULT_MCP_TYPE, # Required by backend, MCPs are always server type
276
+ "transport": kwargs.get("transport", getattr(current_mcp, "transport", DEFAULT_MCP_TRANSPORT)),
277
+ }
278
+
279
+ # Handle description with proper None handling
280
+ if description is not None:
281
+ update_data["description"] = description.strip()
282
+ elif hasattr(current_mcp, "description") and current_mcp.description:
283
+ update_data["description"] = current_mcp.description
284
+
285
+ # Handle config with proper merging
286
+ if "config" in kwargs:
287
+ update_data["config"] = kwargs["config"]
288
+ elif hasattr(current_mcp, "config") and current_mcp.config:
289
+ # Preserve existing config if present
290
+ update_data["config"] = current_mcp.config
291
+
292
+ # Add any other kwargs (excluding already handled ones)
293
+ excluded_keys = {"transport", "config"}
294
+ for key, value in kwargs.items():
295
+ if key not in excluded_keys:
296
+ update_data[key] = value
297
+
298
+ return update_data
299
+
300
+ def get_mcp_tools(self, mcp_id: str) -> list[dict[str, Any]]:
301
+ """Get tools available from an MCP."""
302
+ data = self._request("GET", f"{MCPS_ENDPOINT}{mcp_id}/tools")
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 []
320
+
321
+ def test_mcp_connection(self, config: dict[str, Any]) -> dict[str, Any]:
322
+ """Test MCP connection using configuration.
323
+
324
+ Args:
325
+ config: MCP configuration dictionary
326
+
327
+ Returns:
328
+ dict: Connection test result
329
+
330
+ Raises:
331
+ Exception: If connection test fails
332
+ """
333
+ try:
334
+ response = self._request("POST", MCPS_CONNECT_ENDPOINT, json=config)
335
+ return response
336
+ except Exception as e:
337
+ logger.error(f"Failed to test MCP connection: {e}")
338
+ raise
339
+
340
+ def test_mcp_connection_from_config(self, config: dict[str, Any]) -> dict[str, Any]:
341
+ """Test MCP connection using configuration (alias for test_mcp_connection).
342
+
343
+ Args:
344
+ config: MCP configuration dictionary
345
+
346
+ Returns:
347
+ dict: Connection test result
348
+ """
349
+ return self.test_mcp_connection(config)
350
+
351
+ def get_mcp_tools_from_config(self, config: dict[str, Any]) -> list[dict[str, Any]]:
352
+ """Fetch tools from MCP configuration without saving.
353
+
354
+ Args:
355
+ config: MCP configuration dictionary
356
+
357
+ Returns:
358
+ list: List of available tools from the MCP
359
+
360
+ Raises:
361
+ Exception: If tool fetching fails
362
+ """
363
+ try:
364
+ response = self._request("POST", MCPS_CONNECT_TOOLS_ENDPOINT, json=config)
365
+ if response is None:
366
+ return []
367
+ return response.get("tools", []) or []
368
+ except Exception as e:
369
+ logger.error(f"Failed to get MCP tools from config: {e}")
370
+ raise