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
@@ -2,16 +2,25 @@
2
2
 
3
3
  Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
6
  """
6
7
 
7
8
  import json
9
+ import sys
8
10
  from pathlib import Path
9
11
  from typing import Any
10
12
 
11
13
  import click
12
14
  from rich.console import Console
13
- from rich.text import Text
14
15
 
16
+ from glaip_sdk.branding import (
17
+ ACCENT_STYLE,
18
+ INFO,
19
+ SUCCESS,
20
+ SUCCESS_STYLE,
21
+ WARNING_STYLE,
22
+ )
23
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
15
24
  from glaip_sdk.cli.display import (
16
25
  display_api_error,
17
26
  display_confirmation_prompt,
@@ -21,116 +30,649 @@ from glaip_sdk.cli.display import (
21
30
  handle_json_output,
22
31
  handle_rich_output,
23
32
  )
24
- from glaip_sdk.cli.io import (
25
- export_resource_to_file_with_validation as export_resource_to_file,
26
- )
27
33
  from glaip_sdk.cli.io import (
28
34
  fetch_raw_resource_details,
35
+ load_resource_from_file_with_validation,
29
36
  )
37
+ from glaip_sdk.cli.mcp_validators import (
38
+ validate_mcp_auth_structure,
39
+ validate_mcp_config_structure,
40
+ )
41
+ from glaip_sdk.cli.parsers.json_input import parse_json_input
30
42
  from glaip_sdk.cli.resolution import resolve_resource_reference
43
+ from glaip_sdk.cli.rich_helpers import print_markup
31
44
  from glaip_sdk.cli.utils import (
32
45
  coerce_to_row,
33
- detect_export_format,
46
+ fetch_resource_for_export,
47
+ format_datetime_fields,
34
48
  get_client,
35
- get_ctx_value,
36
- output_flags,
37
49
  output_list,
38
50
  output_result,
51
+ spinner_context,
52
+ with_client_and_spinner,
53
+ )
54
+ from glaip_sdk.config.constants import (
55
+ DEFAULT_MCP_TYPE,
39
56
  )
57
+ from glaip_sdk.icons import ICON_TOOL
40
58
  from glaip_sdk.rich_components import AIPPanel
41
- from glaip_sdk.utils import format_datetime
59
+ from glaip_sdk.utils.import_export import convert_export_to_import_format
60
+ from glaip_sdk.utils.serialization import (
61
+ build_mcp_export_payload,
62
+ write_resource_export,
63
+ )
42
64
 
43
65
  console = Console()
66
+ MAX_DESCRIPTION_LEN = 50
67
+
68
+
69
+ def _is_sensitive_data(val: Any) -> bool:
70
+ """Check if value contains sensitive authentication data.
71
+
72
+ Args:
73
+ val: Value to check for sensitive information
74
+
75
+ Returns:
76
+ True if the value appears to contain sensitive data
77
+ """
78
+ if not isinstance(val, dict):
79
+ return False
80
+
81
+ sensitive_patterns = {"token", "password", "secret", "key", "credential"}
82
+ return any(pattern in str(k).lower() for k in val.keys() for pattern in sensitive_patterns)
83
+
84
+
85
+ def _redact_sensitive_dict(val: dict[str, Any]) -> dict[str, Any]:
86
+ """Redact sensitive fields from a dictionary.
87
+
88
+ Args:
89
+ val: Dictionary to redact
90
+
91
+ Returns:
92
+ Redacted dictionary
93
+ """
94
+ redacted = val.copy()
95
+ sensitive_patterns = {"token", "password", "secret", "key", "credential"}
96
+ for k in redacted.keys():
97
+ if any(pattern in k.lower() for pattern in sensitive_patterns):
98
+ redacted[k] = "<REDACTED>"
99
+ return redacted
100
+
101
+
102
+ def _format_dict_value(val: dict[str, Any]) -> str:
103
+ """Format a dictionary value for display.
104
+
105
+ Args:
106
+ val: Dictionary to format
107
+
108
+ Returns:
109
+ Formatted string representation
110
+ """
111
+ if _is_sensitive_data(val):
112
+ redacted = _redact_sensitive_dict(val)
113
+ return json.dumps(redacted, indent=2)
114
+ return json.dumps(val, indent=2)
115
+
116
+
117
+ def _format_preview_value(val: Any) -> str:
118
+ """Format a value for display in update preview with sensitive data redaction.
119
+
120
+ Args:
121
+ val: Value to format
122
+
123
+ Returns:
124
+ Formatted string representation of the value
125
+ """
126
+ if val is None:
127
+ return "[dim]None[/dim]"
128
+ if isinstance(val, dict):
129
+ return _format_dict_value(val)
130
+ if isinstance(val, str):
131
+ return f'"{val}"' if val else '""'
132
+ return str(val)
133
+
134
+
135
+ def _build_empty_override_warnings(empty_fields: list[str]) -> list[str]:
136
+ """Build warning lines for empty CLI overrides.
137
+
138
+ Args:
139
+ empty_fields: List of field names with empty string overrides
140
+
141
+ Returns:
142
+ List of formatted warning lines
143
+ """
144
+ if not empty_fields:
145
+ return []
146
+
147
+ warnings = ["\n[yellow]⚠️ Warning: Empty values provided via CLI will override import values[/yellow]"]
148
+ warnings.extend(f"- [yellow]{field}: will be set to empty string[/yellow]" for field in empty_fields)
149
+ return warnings
150
+
151
+
152
+ def _validate_import_payload_fields(import_payload: dict[str, Any]) -> bool:
153
+ """Validate that import payload contains updatable fields.
154
+
155
+ Args:
156
+ import_payload: Import payload to validate
157
+
158
+ Returns:
159
+ True if payload has updatable fields, False otherwise
160
+ """
161
+ updatable_fields = {"name", "transport", "description", "config", "authentication"}
162
+ has_updatable = any(field in import_payload for field in updatable_fields)
163
+
164
+ if not has_updatable:
165
+ available_fields = set(import_payload.keys())
166
+ print_markup(
167
+ "[yellow]⚠️ No updatable fields found in import file.[/yellow]\n"
168
+ f"[dim]Found fields: {', '.join(sorted(available_fields))}[/dim]\n"
169
+ f"[dim]Updatable fields: {', '.join(sorted(updatable_fields))}[/dim]"
170
+ )
171
+ return has_updatable
172
+
173
+
174
+ def _get_config_transport(
175
+ transport: str | None,
176
+ import_payload: dict[str, Any] | None,
177
+ mcp: Any,
178
+ ) -> str | None:
179
+ """Get the transport value for config validation.
180
+
181
+ Args:
182
+ transport: CLI transport flag
183
+ import_payload: Optional import payload
184
+ mcp: Current MCP object
185
+
186
+ Returns:
187
+ Transport value or None
188
+ """
189
+ if import_payload:
190
+ return transport or import_payload.get("transport")
191
+ return transport or getattr(mcp, "transport", None)
192
+
193
+
194
+ def _build_update_data_from_sources(
195
+ import_payload: dict[str, Any] | None,
196
+ mcp: Any,
197
+ name: str | None,
198
+ transport: str | None,
199
+ description: str | None,
200
+ config: str | None,
201
+ auth: str | None,
202
+ ) -> dict[str, Any]:
203
+ """Build update data from import payload and CLI flags.
204
+
205
+ Args:
206
+ import_payload: Optional import payload
207
+ mcp: Current MCP object
208
+ name: CLI name flag
209
+ transport: CLI transport flag
210
+ description: CLI description flag
211
+ config: CLI config flag
212
+ auth: CLI auth flag
213
+
214
+ Returns:
215
+ Dictionary with update data
216
+ """
217
+ update_data = {}
218
+
219
+ # Start with import data if available
220
+ if import_payload:
221
+ updatable_fields = [
222
+ "name",
223
+ "transport",
224
+ "description",
225
+ "config",
226
+ "authentication",
227
+ ]
228
+ for field in updatable_fields:
229
+ if field in import_payload:
230
+ update_data[field] = import_payload[field]
231
+
232
+ # CLI flags override import values
233
+ if name is not None:
234
+ update_data["name"] = name
235
+ if transport is not None:
236
+ update_data["transport"] = transport
237
+ if description is not None:
238
+ update_data["description"] = description
239
+ if config is not None:
240
+ parsed_config = parse_json_input(config)
241
+ config_transport = _get_config_transport(transport, import_payload, mcp)
242
+ update_data["config"] = validate_mcp_config_structure(
243
+ parsed_config,
244
+ transport=config_transport,
245
+ source="--config",
246
+ )
247
+ if auth is not None:
248
+ parsed_auth = parse_json_input(auth)
249
+ update_data["authentication"] = validate_mcp_auth_structure(parsed_auth, source="--auth")
250
+
251
+ return update_data
252
+
253
+
254
+ def _collect_cli_overrides(
255
+ name: str | None,
256
+ transport: str | None,
257
+ description: str | None,
258
+ config: str | None,
259
+ auth: str | None,
260
+ ) -> dict[str, Any]:
261
+ """Collect CLI flags that were explicitly provided.
262
+
263
+ Args:
264
+ name: CLI name flag
265
+ transport: CLI transport flag
266
+ description: CLI description flag
267
+ config: CLI config flag
268
+ auth: CLI auth flag
269
+
270
+ Returns:
271
+ Dictionary of provided CLI overrides
272
+ """
273
+ cli_overrides = {}
274
+ if name is not None:
275
+ cli_overrides["name"] = name
276
+ if transport is not None:
277
+ cli_overrides["transport"] = transport
278
+ if description is not None:
279
+ cli_overrides["description"] = description
280
+ if config is not None:
281
+ cli_overrides["config"] = config
282
+ if auth is not None:
283
+ cli_overrides["auth"] = auth
284
+ return cli_overrides
285
+
286
+
287
+ def _handle_cli_error(ctx: Any, error: Exception, operation: str) -> None:
288
+ """Render CLI error once and exit with non-zero status."""
289
+ handle_json_output(ctx, error=error)
290
+ if get_ctx_value(ctx, "view") != "json":
291
+ display_api_error(error, operation)
292
+ ctx.exit(1)
44
293
 
45
294
 
46
295
  @click.group(name="mcps", no_args_is_help=True)
47
296
  def mcps_group() -> None:
48
- """MCP management operations."""
297
+ """MCP management operations.
298
+
299
+ Provides commands for creating, listing, updating, deleting, and managing
300
+ Model Context Protocol (MCP) configurations.
301
+ """
49
302
  pass
50
303
 
51
304
 
52
- def _resolve_mcp(
53
- ctx: Any, client: Any, ref: str, select: int | None = None
54
- ) -> Any | None:
55
- """Resolve MCP reference (ID or name) with ambiguity handling."""
305
+ def _resolve_mcp(ctx: Any, client: Any, ref: str, select: int | None = None) -> Any | None:
306
+ """Resolve an MCP server by ID or name, with interactive selection support.
307
+
308
+ This function provides MCP-specific resolution logic. It delegates to
309
+ resolve_resource_reference for MCP-specific resolution, supporting UUID
310
+ lookups and name-based fuzzy matching.
311
+
312
+ Args:
313
+ ctx: Click context for command execution.
314
+ client: API client for backend operations.
315
+ ref: MCP identifier (UUID or name string).
316
+ select: Optional selection index when multiple MCPs match (1-based).
317
+
318
+ Returns:
319
+ MCP instance if resolution succeeds, None if not found.
320
+
321
+ Raises:
322
+ click.ClickException: When resolution fails or selection is invalid.
323
+ """
324
+ # Configure MCP-specific resolution functions
325
+ mcp_client = client.mcps
326
+ get_by_id_func = mcp_client.get_mcp_by_id
327
+ find_by_name_func = mcp_client.find_mcps
328
+ # Use MCP-specific resolution with standard fuzzy matching
56
329
  return resolve_resource_reference(
57
330
  ctx,
58
331
  client,
59
332
  ref,
60
333
  "mcp",
61
- client.mcps.get_mcp_by_id,
62
- client.mcps.find_mcps,
334
+ get_by_id_func,
335
+ find_by_name_func,
63
336
  "MCP",
64
337
  select=select,
65
338
  )
66
339
 
67
340
 
341
+ def _strip_server_only_fields(import_data: dict[str, Any]) -> dict[str, Any]:
342
+ """Remove fields that should not be forwarded during import-driven creation.
343
+
344
+ Args:
345
+ import_data: Raw import payload loaded from disk.
346
+
347
+ Returns:
348
+ A shallow copy of the data with server-managed fields removed.
349
+ """
350
+ cleaned = dict(import_data)
351
+ for key in (
352
+ "id",
353
+ "type",
354
+ "status",
355
+ "connection_status",
356
+ "created_at",
357
+ "updated_at",
358
+ ):
359
+ cleaned.pop(key, None)
360
+ return cleaned
361
+
362
+
363
+ def _load_import_ready_payload(import_file: str) -> dict[str, Any]:
364
+ """Load and normalise an imported MCP definition for create operations.
365
+
366
+ Args:
367
+ import_file: Path to an MCP export file (JSON or YAML).
368
+
369
+ Returns:
370
+ Normalised import payload ready for CLI/REST usage.
371
+
372
+ Raises:
373
+ click.ClickException: If the file cannot be parsed or validated.
374
+ """
375
+ raw_data = load_resource_from_file_with_validation(Path(import_file), "MCP")
376
+ import_data = convert_export_to_import_format(raw_data)
377
+ import_data = _strip_server_only_fields(import_data)
378
+
379
+ transport = import_data.get("transport")
380
+
381
+ if "config" in import_data:
382
+ import_data["config"] = validate_mcp_config_structure(
383
+ import_data["config"],
384
+ transport=transport,
385
+ source="import file",
386
+ )
387
+
388
+ if "authentication" in import_data:
389
+ import_data["authentication"] = validate_mcp_auth_structure(
390
+ import_data["authentication"],
391
+ source="import file",
392
+ )
393
+
394
+ return import_data
395
+
396
+
397
+ def _coerce_cli_string(value: str | None) -> str | None:
398
+ """Normalise CLI string values so blanks are treated as missing.
399
+
400
+ Args:
401
+ value: User-provided string option.
402
+
403
+ Returns:
404
+ The stripped string, or ``None`` when the value is blank/whitespace-only.
405
+ """
406
+ if value is None:
407
+ return None
408
+ trimmed = value.strip()
409
+ # Treat whitespace-only strings as None
410
+ return trimmed if trimmed else None
411
+
412
+
413
+ def _merge_config_field(
414
+ merged_base: dict[str, Any],
415
+ cli_config: str | None,
416
+ final_transport: str | None,
417
+ ) -> None:
418
+ """Merge config field with validation.
419
+
420
+ Args:
421
+ merged_base: Base payload to update in-place.
422
+ cli_config: Raw CLI JSON string for config.
423
+ final_transport: Transport type for validation.
424
+
425
+ Raises:
426
+ click.ClickException: If config JSON parsing or validation fails.
427
+ """
428
+ if cli_config is not None:
429
+ parsed_config = parse_json_input(cli_config)
430
+ merged_base["config"] = validate_mcp_config_structure(
431
+ parsed_config,
432
+ transport=final_transport,
433
+ source="--config",
434
+ )
435
+ elif "config" not in merged_base or merged_base["config"] is None:
436
+ merged_base["config"] = {}
437
+
438
+
439
+ def _merge_auth_field(
440
+ merged_base: dict[str, Any],
441
+ cli_auth: str | None,
442
+ ) -> None:
443
+ """Merge authentication field with validation.
444
+
445
+ Args:
446
+ merged_base: Base payload to update in-place.
447
+ cli_auth: Raw CLI JSON string for authentication.
448
+
449
+ Raises:
450
+ click.ClickException: If auth JSON parsing or validation fails.
451
+ """
452
+ if cli_auth is not None:
453
+ parsed_auth = parse_json_input(cli_auth)
454
+ merged_base["authentication"] = validate_mcp_auth_structure(
455
+ parsed_auth,
456
+ source="--auth",
457
+ )
458
+ elif "authentication" not in merged_base:
459
+ merged_base["authentication"] = None
460
+
461
+
462
+ def _merge_import_payload(
463
+ import_data: dict[str, Any] | None,
464
+ *,
465
+ cli_name: str | None,
466
+ cli_transport: str | None,
467
+ cli_description: str | None,
468
+ cli_config: str | None,
469
+ cli_auth: str | None,
470
+ ) -> tuple[dict[str, Any], list[str]]:
471
+ """Merge import data with CLI overrides while tracking missing fields.
472
+
473
+ Args:
474
+ import_data: Normalised payload loaded from file (if provided).
475
+ cli_name: Name supplied via CLI option.
476
+ cli_transport: Transport supplied via CLI option.
477
+ cli_description: Description supplied via CLI option.
478
+ cli_config: Raw CLI JSON string for config.
479
+ cli_auth: Raw CLI JSON string for authentication.
480
+
481
+ Returns:
482
+ A tuple of (merged_payload, missing_required_fields).
483
+
484
+ Raises:
485
+ click.ClickException: If config/auth JSON parsing or validation fails.
486
+ """
487
+ merged_base = import_data.copy() if import_data else {}
488
+
489
+ # Merge simple string fields using truthy CLI overrides
490
+ for field, cli_value in (
491
+ ("name", _coerce_cli_string(cli_name)),
492
+ ("transport", _coerce_cli_string(cli_transport)),
493
+ ("description", _coerce_cli_string(cli_description)),
494
+ ):
495
+ if cli_value is not None:
496
+ merged_base[field] = cli_value
497
+
498
+ # Determine final transport before validating config
499
+ final_transport = merged_base.get("transport")
500
+
501
+ # Merge config and authentication with validation
502
+ _merge_config_field(merged_base, cli_config, final_transport)
503
+ _merge_auth_field(merged_base, cli_auth)
504
+
505
+ # Validate required fields
506
+ missing_fields = []
507
+ for required in ("name", "transport"):
508
+ value = merged_base.get(required)
509
+ if not isinstance(value, str) or not value.strip():
510
+ missing_fields.append(required)
511
+
512
+ return merged_base, missing_fields
513
+
514
+
68
515
  @mcps_group.command(name="list")
69
516
  @output_flags()
70
517
  @click.pass_context
71
518
  def list_mcps(ctx: Any) -> None:
72
- """List all MCPs."""
519
+ """List all MCPs in a formatted table.
520
+
521
+ Args:
522
+ ctx: Click context containing output format preferences
523
+
524
+ Raises:
525
+ ClickException: If API request fails
526
+ """
73
527
  try:
74
- client = get_client(ctx)
75
- mcps = client.mcps.list_mcps()
528
+ with with_client_and_spinner(
529
+ ctx,
530
+ "[bold blue]Fetching MCPs…[/bold blue]",
531
+ console_override=console,
532
+ ) as client:
533
+ mcps = client.mcps.list_mcps()
76
534
 
77
535
  # Define table columns: (data_key, header, style, width)
78
536
  columns = [
79
537
  ("id", "ID", "dim", 36),
80
- ("name", "Name", "cyan", None),
81
- ("config", "Config", "blue", None),
538
+ ("name", "Name", ACCENT_STYLE, None),
539
+ ("config", "Config", INFO, None),
82
540
  ]
83
541
 
84
542
  # Transform function for safe dictionary access
85
543
  def transform_mcp(mcp: Any) -> dict[str, Any]:
544
+ """Transform an MCP object to a display row dictionary.
545
+
546
+ Args:
547
+ mcp: MCP object to transform.
548
+
549
+ Returns:
550
+ Dictionary with id, name, and config fields.
551
+ """
86
552
  row = coerce_to_row(mcp, ["id", "name", "config"])
87
553
  # Ensure id is always a string
88
554
  row["id"] = str(row["id"])
89
555
  # Truncate config field for display
90
556
  if row["config"] != "N/A":
91
- row["config"] = (
92
- str(row["config"])[:50] + "..."
93
- if len(str(row["config"])) > 50
94
- else str(row["config"])
95
- )
557
+ row["config"] = str(row["config"])[:50] + "..." if len(str(row["config"])) > 50 else str(row["config"])
96
558
  return row
97
559
 
98
560
  output_list(ctx, mcps, "🔌 Available MCPs", columns, transform_mcp)
99
561
 
100
562
  except Exception as e:
101
- raise click.ClickException(str(e))
563
+ raise click.ClickException(str(e)) from e
102
564
 
103
565
 
104
566
  @mcps_group.command()
105
- @click.option("--name", required=True, help="MCP name")
106
- @click.option("--transport", required=True, help="MCP transport protocol")
567
+ @click.option("--name", help="MCP name")
568
+ @click.option("--transport", help="MCP transport protocol")
107
569
  @click.option("--description", help="MCP description")
108
- @click.option("--config", help="JSON configuration string")
570
+ @click.option(
571
+ "--config",
572
+ help="JSON configuration string or @file reference (e.g., @config.json)",
573
+ )
574
+ @click.option(
575
+ "--auth",
576
+ "--authentication",
577
+ "auth",
578
+ help="JSON authentication object or @file reference (e.g., @auth.json)",
579
+ )
580
+ @click.option(
581
+ "--import",
582
+ "import_file",
583
+ type=click.Path(exists=True, dir_okay=False),
584
+ help="Import MCP configuration from JSON or YAML export",
585
+ )
109
586
  @output_flags()
110
587
  @click.pass_context
111
588
  def create(
112
- ctx: Any, name: str, transport: str, description: str | None, config: str | None
589
+ ctx: Any,
590
+ name: str | None,
591
+ transport: str | None,
592
+ description: str | None,
593
+ config: str | None,
594
+ auth: str | None,
595
+ import_file: str | None,
113
596
  ) -> None:
114
- """Create a new MCP."""
115
- try:
116
- client = get_client(ctx)
597
+ r"""Create a new MCP with specified configuration.
117
598
 
118
- # Parse config if provided
119
- mcp_config = {}
120
- if config:
121
- try:
122
- mcp_config = json.loads(config)
123
- except json.JSONDecodeError:
124
- raise click.ClickException("Invalid JSON in --config")
125
-
126
- mcp = client.mcps.create_mcp(
127
- name=name,
128
- type="server", # MCPs are always server type
129
- transport=transport,
130
- description=description,
131
- config=mcp_config,
599
+ You can create an MCP by providing all parameters via CLI options, or by
600
+ importing from a file and optionally overriding specific fields.
601
+
602
+ Args:
603
+ ctx: Click context containing output format preferences
604
+ name: MCP name (required unless provided via --import)
605
+ transport: MCP transport protocol (required unless provided via --import)
606
+ description: Optional MCP description
607
+ config: JSON configuration string or @file reference
608
+ auth: JSON authentication object or @file reference
609
+ import_file: Optional path to import configuration from export file.
610
+ CLI options override imported values.
611
+
612
+ Raises:
613
+ ClickException: If JSON parsing fails or API request fails
614
+
615
+ \b
616
+ Examples:
617
+ Create from CLI options:
618
+ aip mcps create --name my-mcp --transport http --config '{"url": "https://api.example.com"}'
619
+
620
+ Import from file:
621
+ aip mcps create --import mcp-export.json
622
+
623
+ Import with overrides:
624
+ aip mcps create --import mcp-export.json --name new-name --transport sse
625
+ """
626
+ try:
627
+ # Get API client instance for MCP operations
628
+ api_client = get_client(ctx)
629
+
630
+ # Process import file if specified, otherwise use None
631
+ import_payload = _load_import_ready_payload(import_file) if import_file is not None else None
632
+
633
+ merged_payload, missing_fields = _merge_import_payload(
634
+ import_payload,
635
+ cli_name=name,
636
+ cli_transport=transport,
637
+ cli_description=description,
638
+ cli_config=config,
639
+ cli_auth=auth,
132
640
  )
133
641
 
642
+ if missing_fields:
643
+ raise click.ClickException(
644
+ "Missing required fields after combining import and CLI values: " + ", ".join(missing_fields)
645
+ )
646
+
647
+ effective_name = merged_payload["name"]
648
+ effective_transport = merged_payload["transport"]
649
+ effective_description = merged_payload.get("description")
650
+ effective_config = merged_payload.get("config") or {}
651
+ effective_auth = merged_payload.get("authentication")
652
+
653
+ with spinner_context(
654
+ ctx,
655
+ "[bold blue]Creating MCP…[/bold blue]",
656
+ console_override=console,
657
+ ):
658
+ create_kwargs: dict[str, Any] = {
659
+ "name": effective_name,
660
+ "config": effective_config,
661
+ "transport": effective_transport,
662
+ }
663
+
664
+ if effective_description is not None:
665
+ create_kwargs["description"] = effective_description
666
+
667
+ if effective_auth:
668
+ create_kwargs["authentication"] = effective_auth
669
+
670
+ mcp_metadata = merged_payload.get("mcp_metadata")
671
+ if mcp_metadata is not None:
672
+ create_kwargs["mcp_metadata"] = mcp_metadata
673
+
674
+ mcp = api_client.mcps.create_mcp(**create_kwargs)
675
+
134
676
  # Handle JSON output
135
677
  handle_json_output(ctx, mcp.model_dump())
136
678
 
@@ -139,17 +681,141 @@ def create(
139
681
  "MCP",
140
682
  mcp.name,
141
683
  mcp.id,
142
- Type="server",
143
- Transport=getattr(mcp, "transport", transport),
144
- Description=description or "No description",
684
+ Type=getattr(mcp, "type", DEFAULT_MCP_TYPE),
685
+ Transport=getattr(mcp, "transport", effective_transport),
686
+ Description=effective_description or "No description",
145
687
  )
146
688
  handle_rich_output(ctx, rich_panel)
147
689
 
148
690
  except Exception as e:
149
- handle_json_output(ctx, error=e)
150
- if get_ctx_value(ctx, "view") != "json":
151
- display_api_error(e, "MCP creation")
152
- raise click.ClickException(str(e))
691
+ _handle_cli_error(ctx, e, "MCP creation")
692
+
693
+
694
+ def _handle_mcp_export(
695
+ ctx: Any,
696
+ client: Any,
697
+ mcp: Any,
698
+ export_path: Path,
699
+ no_auth_prompt: bool,
700
+ auth_placeholder: str,
701
+ ) -> None:
702
+ """Handle MCP export to file with format detection and auth handling.
703
+
704
+ Args:
705
+ ctx: Click context for spinner management
706
+ client: API client for fetching MCP details
707
+ mcp: MCP object to export
708
+ export_path: Target file path (format detected from extension)
709
+ no_auth_prompt: Skip interactive secret prompts if True
710
+ auth_placeholder: Placeholder text for missing secrets
711
+
712
+ Note:
713
+ Supports JSON (.json) and YAML (.yaml/.yml) export formats.
714
+ In interactive mode, prompts for secret values.
715
+ In non-interactive mode, uses placeholder values.
716
+ """
717
+ # Auto-detect format from file extension
718
+ detected_format = detect_export_format(export_path)
719
+
720
+ # Always export comprehensive data - re-fetch with full details
721
+
722
+ mcp = fetch_resource_for_export(
723
+ ctx,
724
+ mcp,
725
+ resource_type="MCP",
726
+ get_by_id_func=client.mcps.get_mcp_by_id,
727
+ console_override=console,
728
+ )
729
+
730
+ # Determine if we should prompt for secrets
731
+ prompt_for_secrets = not no_auth_prompt and sys.stdin.isatty()
732
+
733
+ # Warn user if non-interactive mode forces placeholder usage
734
+ if not no_auth_prompt and not sys.stdin.isatty():
735
+ print_markup(
736
+ f"[{WARNING_STYLE}]⚠️ Non-interactive mode detected. Using placeholder values for secrets.[/]",
737
+ console=console,
738
+ )
739
+
740
+ # Build and write export payload
741
+ if prompt_for_secrets:
742
+ # Interactive mode: no spinner during prompts
743
+ export_payload = build_mcp_export_payload(
744
+ mcp,
745
+ prompt_for_secrets=prompt_for_secrets,
746
+ placeholder=auth_placeholder,
747
+ console=console,
748
+ )
749
+ with spinner_context(
750
+ ctx,
751
+ "[bold blue]Writing export file…[/bold blue]",
752
+ console_override=console,
753
+ ):
754
+ write_resource_export(export_path, export_payload, detected_format)
755
+ else:
756
+ # Non-interactive mode: spinner for entire export process
757
+ with spinner_context(
758
+ ctx,
759
+ "[bold blue]Exporting MCP configuration…[/bold blue]",
760
+ console_override=console,
761
+ ):
762
+ export_payload = build_mcp_export_payload(
763
+ mcp,
764
+ prompt_for_secrets=prompt_for_secrets,
765
+ placeholder=auth_placeholder,
766
+ console=console,
767
+ )
768
+ write_resource_export(export_path, export_payload, detected_format)
769
+
770
+ print_markup(
771
+ f"[{SUCCESS_STYLE}]✅ Complete MCP configuration exported to: {export_path} (format: {detected_format})[/]",
772
+ console=console,
773
+ )
774
+
775
+
776
+ def _display_mcp_details(ctx: Any, client: Any, mcp: Any) -> None:
777
+ """Display MCP details using raw API data or fallback to Pydantic model.
778
+
779
+ Args:
780
+ ctx: Click context containing output format preferences
781
+ client: API client for fetching raw MCP data
782
+ mcp: MCP object to display details for
783
+
784
+ Note:
785
+ Attempts to fetch raw API data first to preserve all fields.
786
+ Falls back to Pydantic model data if raw data unavailable.
787
+ Formats datetime fields for better readability.
788
+ """
789
+ # Try to fetch raw API data first to preserve ALL fields
790
+ with spinner_context(
791
+ ctx,
792
+ "[bold blue]Fetching detailed MCP data…[/bold blue]",
793
+ console_override=console,
794
+ ):
795
+ raw_mcp_data = fetch_raw_resource_details(client, mcp, "mcps")
796
+
797
+ if raw_mcp_data:
798
+ # Use raw API data - this preserves ALL fields
799
+ formatted_data = format_datetime_fields(raw_mcp_data)
800
+
801
+ output_result(
802
+ ctx,
803
+ formatted_data,
804
+ title="MCP Details",
805
+ panel_title=f"🔌 {raw_mcp_data.get('name', 'Unknown')}",
806
+ )
807
+ else:
808
+ # Fall back to Pydantic model data
809
+ console.print(f"[{WARNING_STYLE}]Falling back to Pydantic model data[/]")
810
+ result_data = {
811
+ "id": str(getattr(mcp, "id", "N/A")),
812
+ "name": getattr(mcp, "name", "N/A"),
813
+ "type": getattr(mcp, "type", "N/A"),
814
+ "config": getattr(mcp, "config", "N/A"),
815
+ "status": getattr(mcp, "status", "N/A"),
816
+ "connection_status": getattr(mcp, "connection_status", "N/A"),
817
+ }
818
+ output_result(ctx, result_data, title=f"🔌 {mcp.name}")
153
819
 
154
820
 
155
821
  @mcps_group.command()
@@ -159,15 +825,43 @@ def create(
159
825
  type=click.Path(dir_okay=False, writable=True),
160
826
  help="Export complete MCP configuration to file (format auto-detected from .json/.yaml extension)",
161
827
  )
828
+ @click.option(
829
+ "--no-auth-prompt",
830
+ is_flag=True,
831
+ help="Skip interactive secret prompts and use placeholder values.",
832
+ )
833
+ @click.option(
834
+ "--auth-placeholder",
835
+ default="<INSERT VALUE>",
836
+ show_default=True,
837
+ help="Placeholder text used when secrets are unavailable.",
838
+ )
162
839
  @output_flags()
163
840
  @click.pass_context
164
- def get(ctx: Any, mcp_ref: str, export: str | None) -> None:
165
- """Get MCP details.
841
+ def get(
842
+ ctx: Any,
843
+ mcp_ref: str,
844
+ export: str | None,
845
+ no_auth_prompt: bool,
846
+ auth_placeholder: str,
847
+ ) -> None:
848
+ r"""Get MCP details and optionally export configuration to file.
849
+
850
+ Args:
851
+ ctx: Click context containing output format preferences
852
+ mcp_ref: MCP reference (ID or name)
853
+ export: Optional file path to export MCP configuration
854
+ no_auth_prompt: Skip interactive secret prompts if True
855
+ auth_placeholder: Placeholder text for missing secrets
166
856
 
857
+ Raises:
858
+ ClickException: If MCP not found or export fails
859
+
860
+ \b
167
861
  Examples:
168
862
  aip mcps get my-mcp
169
- aip mcps get my-mcp --export mcp.json # Exports complete configuration as JSON
170
- aip mcps get my-mcp --export mcp.yaml # Exports complete configuration as YAML
863
+ aip mcps get my-mcp --export mcp.json # Export as JSON
864
+ aip mcps get my-mcp --export mcp.yaml # Export as YAML
171
865
  """
172
866
  try:
173
867
  client = get_client(ctx)
@@ -177,111 +871,222 @@ def get(ctx: Any, mcp_ref: str, export: str | None) -> None:
177
871
 
178
872
  # Handle export option
179
873
  if export:
180
- export_path = Path(export)
181
- # Auto-detect format from file extension
182
- detected_format = detect_export_format(export_path)
183
-
184
- # Always export comprehensive data - re-fetch MCP with full details if needed
185
- try:
186
- mcp = client.mcps.get_mcp_by_id(mcp.id)
187
- except Exception as e:
188
- console.print(
189
- Text(f"[yellow]⚠️ Could not fetch full MCP details: {e}[/yellow]")
190
- )
191
- console.print(
192
- Text("[yellow]⚠️ Proceeding with available data[/yellow]")
193
- )
194
-
195
- export_resource_to_file(mcp, export_path, detected_format)
196
- console.print(
197
- Text(
198
- f"[green]✅ Complete MCP configuration exported to: {export_path} (format: {detected_format})[/green]"
199
- )
200
- )
874
+ _handle_mcp_export(ctx, client, mcp, Path(export), no_auth_prompt, auth_placeholder)
201
875
 
202
- # Try to fetch raw API data first to preserve ALL fields
203
- raw_mcp_data = fetch_raw_resource_details(client, mcp, "mcps")
876
+ # Display MCP details
877
+ _display_mcp_details(ctx, client, mcp)
204
878
 
205
- if raw_mcp_data:
206
- # Use raw API data - this preserves ALL fields
207
- # Format dates for better display (minimal postprocessing)
208
- formatted_data = raw_mcp_data.copy()
209
- if "created_at" in formatted_data:
210
- formatted_data["created_at"] = format_datetime(
211
- formatted_data["created_at"]
212
- )
213
- if "updated_at" in formatted_data:
214
- formatted_data["updated_at"] = format_datetime(
215
- formatted_data["updated_at"]
216
- )
217
-
218
- # Display using output_result with raw data
219
- output_result(
220
- ctx,
221
- formatted_data,
222
- title="MCP Details",
223
- panel_title=f"🔌 {raw_mcp_data.get('name', 'Unknown')}",
224
- )
879
+ except Exception as e:
880
+ raise click.ClickException(str(e)) from e
881
+
882
+
883
+ def _get_tools_from_config(ctx: Any, client: Any, config_file: str) -> tuple[list[dict[str, Any]], str]:
884
+ """Get tools from MCP config file.
885
+
886
+ Args:
887
+ ctx: Click context
888
+ client: GlaIP client instance
889
+ config_file: Path to config file
890
+
891
+ Returns:
892
+ Tuple of (tools list, title string)
893
+ """
894
+ config_data = load_resource_from_file_with_validation(Path(config_file), "MCP config")
895
+
896
+ # Validate config structure
897
+ transport = config_data.get("transport")
898
+ if "config" not in config_data:
899
+ raise click.ClickException("Invalid MCP config: missing 'config' section in the file.")
900
+ config_data["config"] = validate_mcp_config_structure(
901
+ config_data["config"],
902
+ transport=transport,
903
+ source=config_file,
904
+ )
905
+
906
+ # Get tools from config without saving
907
+ with spinner_context(
908
+ ctx,
909
+ "[bold blue]Fetching tools from config…[/bold blue]",
910
+ console_override=console,
911
+ ):
912
+ tools = client.mcps.get_mcp_tools_from_config(config_data)
913
+
914
+ title = f"{ICON_TOOL} Tools from config: {Path(config_file).name}"
915
+ return tools, title
916
+
917
+
918
+ def _get_tools_from_mcp(ctx: Any, client: Any, mcp_ref: str | None) -> tuple[list[dict[str, Any]], str]:
919
+ """Get tools from saved MCP.
920
+
921
+ Args:
922
+ ctx: Click context
923
+ client: GlaIP client instance
924
+ mcp_ref: MCP reference (ID or name)
925
+
926
+ Returns:
927
+ Tuple of (tools list, title string)
928
+ """
929
+ mcp = _resolve_mcp(ctx, client, mcp_ref)
930
+
931
+ with spinner_context(
932
+ ctx,
933
+ "[bold blue]Fetching MCP tools…[/bold blue]",
934
+ console_override=console,
935
+ ):
936
+ tools = client.mcps.get_mcp_tools(mcp.id)
937
+
938
+ title = f"{ICON_TOOL} Tools from MCP: {mcp.name}"
939
+ return tools, title
940
+
941
+
942
+ def _output_tool_names(ctx: Any, tools: list[dict[str, Any]]) -> None:
943
+ """Output only tool names.
944
+
945
+ Args:
946
+ ctx: Click context
947
+ tools: List of tool dictionaries
948
+ """
949
+ view = get_ctx_value(ctx, "view", "rich")
950
+ tool_names = [tool.get("name", "N/A") for tool in tools]
951
+
952
+ if view == "json":
953
+ handle_json_output(ctx, tool_names)
954
+ elif view == "plain":
955
+ if tool_names:
956
+ for name in tool_names:
957
+ console.print(name, markup=False)
958
+ console.print(f"Total: {len(tool_names)} tools", markup=False)
225
959
  else:
226
- # Fall back to original method if raw fetch fails
227
- console.print("[yellow]Falling back to Pydantic model data[/yellow]")
228
-
229
- # Create result data with actual available fields
230
- result_data = {
231
- "id": str(getattr(mcp, "id", "N/A")),
232
- "name": getattr(mcp, "name", "N/A"),
233
- "type": getattr(mcp, "type", "N/A"),
234
- "config": getattr(mcp, "config", "N/A"),
235
- "status": getattr(mcp, "status", "N/A"),
236
- "connection_status": getattr(mcp, "connection_status", "N/A"),
237
- }
960
+ console.print("No tools found", markup=False)
961
+ else:
962
+ if tool_names:
963
+ for name in tool_names:
964
+ console.print(name)
965
+ console.print(f"[dim]Total: {len(tool_names)} tools[/dim]")
966
+ else:
967
+ console.print("[yellow]No tools found[/yellow]")
238
968
 
239
- output_result(
240
- ctx, result_data, title="MCP Details", panel_title=f"🔌 {mcp.name}"
241
- )
242
969
 
243
- except Exception as e:
244
- raise click.ClickException(str(e))
970
+ def _transform_tool(tool: dict[str, Any]) -> dict[str, Any]:
971
+ """Transform a tool dictionary to a display row dictionary.
972
+
973
+ Args:
974
+ tool: Tool dictionary to transform.
975
+
976
+ Returns:
977
+ Dictionary with name and description fields.
978
+ """
979
+ description = tool.get("description", "N/A")
980
+ if len(description) > MAX_DESCRIPTION_LEN:
981
+ description = description[: MAX_DESCRIPTION_LEN - 3] + "..."
982
+ return {
983
+ "name": tool.get("name", "N/A"),
984
+ "description": description,
985
+ }
986
+
987
+
988
+ def _output_tools_table(ctx: Any, tools: list[dict[str, Any]], title: str) -> None:
989
+ """Output tools in table format.
990
+
991
+ Args:
992
+ ctx: Click context
993
+ tools: List of tool dictionaries
994
+ title: Table title
995
+ """
996
+ columns = [
997
+ ("name", "Name", ACCENT_STYLE, None),
998
+ ("description", "Description", INFO, 50),
999
+ ]
1000
+
1001
+ output_list(
1002
+ ctx,
1003
+ tools,
1004
+ title,
1005
+ columns,
1006
+ _transform_tool,
1007
+ )
1008
+
1009
+
1010
+ def _validate_tool_command_args(mcp_ref: str | None, config_file: str | None) -> None:
1011
+ """Validate that exactly one of mcp_ref or config_file is provided.
1012
+
1013
+ Args:
1014
+ mcp_ref: MCP reference (ID or name)
1015
+ config_file: Path to config file
1016
+
1017
+ Raises:
1018
+ ClickException: If validation fails
1019
+ """
1020
+ if not mcp_ref and not config_file:
1021
+ raise click.ClickException(
1022
+ "Either MCP_REF or --from-config must be provided.\n"
1023
+ "Examples:\n"
1024
+ " aip mcps tools <MCP_ID>\n"
1025
+ " aip mcps tools --from-config mcp-config.json"
1026
+ )
1027
+ if mcp_ref and config_file:
1028
+ raise click.ClickException(
1029
+ "Cannot use both MCP_REF and --from-config at the same time.\n"
1030
+ "Use either:\n"
1031
+ " aip mcps tools <MCP_ID>\n"
1032
+ " aip mcps tools --from-config mcp-config.json"
1033
+ )
245
1034
 
246
1035
 
247
1036
  @mcps_group.command("tools")
248
- @click.argument("mcp_ref")
1037
+ @click.argument("mcp_ref", required=False)
1038
+ @click.option(
1039
+ "--from-config",
1040
+ "--config",
1041
+ "config_file",
1042
+ type=click.Path(exists=True, dir_okay=False),
1043
+ help="Get tools from MCP config file without saving to DB (JSON or YAML)",
1044
+ )
1045
+ @click.option(
1046
+ "--names-only",
1047
+ is_flag=True,
1048
+ help="Show only tool names (useful for allowed_tools config)",
1049
+ )
249
1050
  @output_flags()
250
1051
  @click.pass_context
251
- def list_tools(ctx: Any, mcp_ref: str) -> None:
252
- """List tools from MCP."""
253
- try:
254
- client = get_client(ctx)
1052
+ def list_tools(ctx: Any, mcp_ref: str | None, config_file: str | None, names_only: bool) -> None:
1053
+ """List tools available from a specific MCP or config file.
255
1054
 
256
- # Resolve MCP using helper function
257
- mcp = _resolve_mcp(ctx, client, mcp_ref)
1055
+ Args:
1056
+ ctx: Click context containing output format preferences
1057
+ mcp_ref: MCP reference (ID or name) - required if --from-config not used
1058
+ config_file: Path to MCP config file - alternative to mcp_ref
1059
+ names_only: Show only tool names instead of full table
258
1060
 
259
- # Get tools from MCP
260
- tools = client.mcps.get_mcp_tools(mcp.id)
1061
+ Raises:
1062
+ ClickException: If MCP not found or tools fetch fails
261
1063
 
262
- # Define table columns: (data_key, header, style, width)
263
- columns = [
264
- ("name", "Name", "cyan", None),
265
- ("description", "Description", "green", 50),
266
- ("type", "Type", "yellow", None),
267
- ]
1064
+ Examples:
1065
+ Get tools from saved MCP:
1066
+ aip mcps tools <MCP_ID>
268
1067
 
269
- # Transform function for safe dictionary access
270
- def transform_tool(tool: dict[str, Any]) -> dict[str, Any]:
271
- return {
272
- "name": tool.get("name", "N/A"),
273
- "description": tool.get("description", "N/A")[:47] + "..."
274
- if len(tool.get("description", "")) > 47
275
- else tool.get("description", "N/A"),
276
- "type": tool.get("type", "N/A"),
277
- }
1068
+ Get tools from config file (without saving to DB):
1069
+ aip mcps tools --from-config mcp-config.json
278
1070
 
279
- output_list(
280
- ctx, tools, f"🔧 Tools from MCP: {mcp.name}", columns, transform_tool
281
- )
1071
+ Get just tool names for allowed_tools config:
1072
+ aip mcps tools <MCP_ID> --names-only
1073
+ """
1074
+ try:
1075
+ _validate_tool_command_args(mcp_ref, config_file)
1076
+ client = get_client(ctx)
1077
+
1078
+ if config_file:
1079
+ tools, title = _get_tools_from_config(ctx, client, config_file)
1080
+ else:
1081
+ tools, title = _get_tools_from_mcp(ctx, client, mcp_ref)
1082
+
1083
+ if names_only:
1084
+ _output_tool_names(ctx, tools)
1085
+ else:
1086
+ _output_tools_table(ctx, tools, title)
282
1087
 
283
1088
  except Exception as e:
284
- raise click.ClickException(str(e))
1089
+ raise click.ClickException(str(e)) from e
285
1090
 
286
1091
 
287
1092
  @mcps_group.command("connect")
@@ -294,7 +1099,19 @@ def list_tools(ctx: Any, mcp_ref: str) -> None:
294
1099
  @output_flags()
295
1100
  @click.pass_context
296
1101
  def connect(ctx: Any, config_file: str) -> None:
297
- """Connect to MCP using config file."""
1102
+ """Test MCP connection using a configuration file.
1103
+
1104
+ Args:
1105
+ ctx: Click context containing output format preferences
1106
+ config_file: Path to MCP configuration JSON file
1107
+
1108
+ Raises:
1109
+ ClickException: If config file invalid or connection test fails
1110
+
1111
+ Note:
1112
+ Loads MCP configuration from JSON file and tests connectivity.
1113
+ Displays success or failure with connection details.
1114
+ """
298
1115
  try:
299
1116
  client = get_client(ctx)
300
1117
 
@@ -304,78 +1121,189 @@ def connect(ctx: Any, config_file: str) -> None:
304
1121
 
305
1122
  view = get_ctx_value(ctx, "view", "rich")
306
1123
  if view != "json":
307
- console.print(
308
- Text(
309
- f"[yellow]Connecting to MCP with config from {config_file}...[/yellow]"
310
- )
1124
+ print_markup(
1125
+ f"[{WARNING_STYLE}]Connecting to MCP with config from {config_file}...[/]",
1126
+ console=console,
311
1127
  )
312
1128
 
313
1129
  # Test connection using config
314
- result = client.mcps.test_mcp_connection_from_config(config)
1130
+ with spinner_context(
1131
+ ctx,
1132
+ "[bold blue]Connecting to MCP…[/bold blue]",
1133
+ console_override=console,
1134
+ ):
1135
+ result = client.mcps.test_mcp_connection_from_config(config)
315
1136
 
316
1137
  view = get_ctx_value(ctx, "view", "rich")
317
1138
  if view == "json":
318
1139
  handle_json_output(ctx, result)
319
1140
  else:
320
1141
  success_panel = AIPPanel(
321
- f"[green]✓[/green] MCP connection successful!\n\n"
322
- f"[bold]Result:[/bold] {result}",
1142
+ f"[{SUCCESS_STYLE}]✓[/] MCP connection successful!\n\n[bold]Result:[/bold] {result}",
323
1143
  title="🔌 Connection",
324
- border_style="green",
1144
+ border_style=SUCCESS,
325
1145
  )
326
1146
  console.print(success_panel)
327
1147
 
328
1148
  except Exception as e:
329
- raise click.ClickException(str(e))
1149
+ raise click.ClickException(str(e)) from e
1150
+
1151
+
1152
+ def _generate_update_preview(mcp: Any, update_data: dict[str, Any], cli_overrides: dict[str, Any]) -> str:
1153
+ """Generate formatted preview of changes for user confirmation.
1154
+
1155
+ Args:
1156
+ mcp: Current MCP object
1157
+ update_data: Data that will be sent in update request
1158
+ cli_overrides: CLI flags that were explicitly provided
1159
+
1160
+ Returns:
1161
+ Formatted preview string showing old→new values
1162
+ """
1163
+ lines = [f"\n[bold]The following fields will be updated for MCP '{mcp.name}':[/bold]\n"]
1164
+
1165
+ empty_overrides = []
1166
+
1167
+ # Show each field that will be updated
1168
+ for field, new_value in update_data.items():
1169
+ old_value = getattr(mcp, field, None)
1170
+
1171
+ # Track empty CLI overrides
1172
+ if field in cli_overrides and cli_overrides[field] == "":
1173
+ empty_overrides.append(field)
1174
+
1175
+ old_display = _format_preview_value(old_value)
1176
+ new_display = _format_preview_value(new_value)
1177
+
1178
+ lines.append(f"- [cyan]{field}[/cyan]: {old_display} → {new_display}")
1179
+
1180
+ # Add warnings for empty CLI overrides
1181
+ lines.extend(_build_empty_override_warnings(empty_overrides))
1182
+
1183
+ return "\n".join(lines)
330
1184
 
331
1185
 
332
1186
  @mcps_group.command()
333
1187
  @click.argument("mcp_ref")
334
1188
  @click.option("--name", help="New MCP name")
1189
+ @click.option("--transport", type=click.Choice(["http", "sse"]), help="New transport protocol")
335
1190
  @click.option("--description", help="New description")
336
- @click.option("--config", help="JSON configuration string")
1191
+ @click.option(
1192
+ "--config",
1193
+ help="JSON configuration string or @file reference (e.g., @config.json)",
1194
+ )
1195
+ @click.option(
1196
+ "--auth",
1197
+ "--authentication",
1198
+ "auth",
1199
+ help="JSON authentication object or @file reference (e.g., @auth.json)",
1200
+ )
1201
+ @click.option(
1202
+ "--import",
1203
+ "import_file",
1204
+ type=click.Path(exists=True, dir_okay=False, readable=True),
1205
+ help="Import MCP configuration from JSON or YAML export",
1206
+ )
1207
+ @click.option("-y", is_flag=True, help="Skip confirmation prompt when using --import")
337
1208
  @output_flags()
338
1209
  @click.pass_context
339
1210
  def update(
340
1211
  ctx: Any,
341
1212
  mcp_ref: str,
342
1213
  name: str | None,
1214
+ transport: str | None,
343
1215
  description: str | None,
344
1216
  config: str | None,
1217
+ auth: str | None,
1218
+ import_file: str | None,
1219
+ y: bool,
345
1220
  ) -> None:
346
- """Update an existing MCP."""
1221
+ r"""Update an existing MCP with new configuration values.
1222
+
1223
+ You can update an MCP by providing individual fields via CLI options, or by
1224
+ importing from a file and optionally overriding specific fields.
1225
+
1226
+ Args:
1227
+ ctx: Click context containing output format preferences
1228
+ mcp_ref: MCP reference (ID or name)
1229
+ name: New MCP name (optional)
1230
+ transport: New transport protocol (optional)
1231
+ description: New description (optional)
1232
+ config: New JSON configuration string or @file reference (optional)
1233
+ auth: New JSON authentication object or @file reference (optional)
1234
+ import_file: Optional path to import configuration from export file.
1235
+ CLI options override imported values.
1236
+ y: Skip confirmation prompt when using --import
1237
+
1238
+ Raises:
1239
+ ClickException: If MCP not found, JSON invalid, or no fields specified
1240
+
1241
+ Note:
1242
+ Must specify either --import OR at least one CLI field.
1243
+ CLI options override imported values when both are specified.
1244
+ Uses PATCH for import-based updates, PUT/PATCH for CLI-only updates.
1245
+
1246
+ \b
1247
+ Examples:
1248
+ Update with CLI options:
1249
+ aip mcps update my-mcp --name new-name --transport sse
1250
+
1251
+ Import from file:
1252
+ aip mcps update my-mcp --import mcp-export.json
1253
+
1254
+ Import with overrides:
1255
+ aip mcps update my-mcp --import mcp-export.json --name new-name -y
1256
+ """
347
1257
  try:
348
1258
  client = get_client(ctx)
349
1259
 
1260
+ # Validate that at least one update method is provided
1261
+ cli_flags_provided = any(v is not None for v in [name, transport, description, config, auth])
1262
+ if not import_file and not cli_flags_provided:
1263
+ raise click.ClickException(
1264
+ "No update fields specified. Use --import or one of: "
1265
+ "--name, --transport, --description, --config, --auth"
1266
+ )
1267
+
350
1268
  # Resolve MCP using helper function
351
1269
  mcp = _resolve_mcp(ctx, client, mcp_ref)
352
1270
 
353
- # Build update data
354
- update_data = {}
355
- if name is not None:
356
- update_data["name"] = name
357
- if description is not None:
358
- update_data["description"] = description
359
- if config is not None:
360
- try:
361
- update_data["config"] = json.loads(config)
362
- except json.JSONDecodeError:
363
- raise click.ClickException("Invalid JSON in --config")
1271
+ # Load and validate import data if provided
1272
+ import_payload = None
1273
+ if import_file:
1274
+ import_payload = _load_import_ready_payload(import_file)
1275
+ if not _validate_import_payload_fields(import_payload):
1276
+ return
1277
+
1278
+ # Build update data from import and CLI flags
1279
+ update_data = _build_update_data_from_sources(import_payload, mcp, name, transport, description, config, auth)
364
1280
 
365
1281
  if not update_data:
366
1282
  raise click.ClickException("No update fields specified")
367
1283
 
368
- # Update MCP (automatically chooses PUT or PATCH based on provided fields)
369
- updated_mcp = client.mcps.update_mcp(mcp.id, **update_data)
1284
+ # Show confirmation preview for import-based updates (unless -y flag)
1285
+ if import_payload and not y:
1286
+ cli_overrides = _collect_cli_overrides(name, transport, description, config, auth)
1287
+ preview = _generate_update_preview(mcp, update_data, cli_overrides)
1288
+ print_markup(preview)
1289
+
1290
+ if not click.confirm("\nContinue with update?", default=False):
1291
+ print_markup("[yellow]Update cancelled.[/yellow]")
1292
+ return
1293
+
1294
+ # Update MCP
1295
+ with spinner_context(
1296
+ ctx,
1297
+ "[bold blue]Updating MCP…[/bold blue]",
1298
+ console_override=console,
1299
+ ):
1300
+ updated_mcp = client.mcps.update_mcp(mcp.id, **update_data)
370
1301
 
371
1302
  handle_json_output(ctx, updated_mcp.model_dump())
372
1303
  handle_rich_output(ctx, display_update_success("MCP", updated_mcp.name))
373
1304
 
374
1305
  except Exception as e:
375
- handle_json_output(ctx, error=e)
376
- if get_ctx_value(ctx, "view") != "json":
377
- display_api_error(e, "MCP update")
378
- raise click.ClickException(str(e))
1306
+ _handle_cli_error(ctx, e, "MCP update")
379
1307
 
380
1308
 
381
1309
  @mcps_group.command()
@@ -384,7 +1312,20 @@ def update(
384
1312
  @output_flags()
385
1313
  @click.pass_context
386
1314
  def delete(ctx: Any, mcp_ref: str, yes: bool) -> None:
387
- """Delete an MCP."""
1315
+ """Delete an MCP after confirmation.
1316
+
1317
+ Args:
1318
+ ctx: Click context containing output format preferences
1319
+ mcp_ref: MCP reference (ID or name)
1320
+ yes: Skip confirmation prompt if True
1321
+
1322
+ Raises:
1323
+ ClickException: If MCP not found or deletion fails
1324
+
1325
+ Note:
1326
+ Requires confirmation unless --yes flag is provided.
1327
+ Deletion is permanent and cannot be undone.
1328
+ """
388
1329
  try:
389
1330
  client = get_client(ctx)
390
1331
 
@@ -395,7 +1336,12 @@ def delete(ctx: Any, mcp_ref: str, yes: bool) -> None:
395
1336
  if not yes and not display_confirmation_prompt("MCP", mcp.name):
396
1337
  return
397
1338
 
398
- client.mcps.delete_mcp(mcp.id)
1339
+ with spinner_context(
1340
+ ctx,
1341
+ "[bold blue]Deleting MCP…[/bold blue]",
1342
+ console_override=console,
1343
+ ):
1344
+ client.mcps.delete_mcp(mcp.id)
399
1345
 
400
1346
  handle_json_output(
401
1347
  ctx,
@@ -407,7 +1353,4 @@ def delete(ctx: Any, mcp_ref: str, yes: bool) -> None:
407
1353
  handle_rich_output(ctx, display_deletion_success("MCP", mcp.name))
408
1354
 
409
1355
  except Exception as e:
410
- handle_json_output(ctx, error=e)
411
- if get_ctx_value(ctx, "view") != "json":
412
- display_api_error(e, "MCP deletion")
413
- raise click.ClickException(str(e))
1356
+ _handle_cli_error(ctx, e, "MCP deletion")