glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.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.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1356 @@
1
+ """MCP management commands.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+ from rich.console import Console
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
24
+ from glaip_sdk.cli.display import (
25
+ display_api_error,
26
+ display_confirmation_prompt,
27
+ display_creation_success,
28
+ display_deletion_success,
29
+ display_update_success,
30
+ handle_json_output,
31
+ handle_rich_output,
32
+ )
33
+ from glaip_sdk.cli.io import (
34
+ fetch_raw_resource_details,
35
+ load_resource_from_file_with_validation,
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
42
+ from glaip_sdk.cli.resolution import resolve_resource_reference
43
+ from glaip_sdk.cli.rich_helpers import print_markup
44
+ from glaip_sdk.cli.utils import (
45
+ coerce_to_row,
46
+ fetch_resource_for_export,
47
+ format_datetime_fields,
48
+ get_client,
49
+ output_list,
50
+ output_result,
51
+ spinner_context,
52
+ with_client_and_spinner,
53
+ )
54
+ from glaip_sdk.config.constants import (
55
+ DEFAULT_MCP_TYPE,
56
+ )
57
+ from glaip_sdk.icons import ICON_TOOL
58
+ from glaip_sdk.rich_components import AIPPanel
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
+ )
64
+
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)
293
+
294
+
295
+ @click.group(name="mcps", no_args_is_help=True)
296
+ def mcps_group() -> None:
297
+ """MCP management operations.
298
+
299
+ Provides commands for creating, listing, updating, deleting, and managing
300
+ Model Context Protocol (MCP) configurations.
301
+ """
302
+ pass
303
+
304
+
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
329
+ return resolve_resource_reference(
330
+ ctx,
331
+ client,
332
+ ref,
333
+ "mcp",
334
+ get_by_id_func,
335
+ find_by_name_func,
336
+ "MCP",
337
+ select=select,
338
+ )
339
+
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
+
515
+ @mcps_group.command(name="list")
516
+ @output_flags()
517
+ @click.pass_context
518
+ def list_mcps(ctx: Any) -> None:
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
+ """
527
+ try:
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()
534
+
535
+ # Define table columns: (data_key, header, style, width)
536
+ columns = [
537
+ ("id", "ID", "dim", 36),
538
+ ("name", "Name", ACCENT_STYLE, None),
539
+ ("config", "Config", INFO, None),
540
+ ]
541
+
542
+ # Transform function for safe dictionary access
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
+ """
552
+ row = coerce_to_row(mcp, ["id", "name", "config"])
553
+ # Ensure id is always a string
554
+ row["id"] = str(row["id"])
555
+ # Truncate config field for display
556
+ if row["config"] != "N/A":
557
+ row["config"] = str(row["config"])[:50] + "..." if len(str(row["config"])) > 50 else str(row["config"])
558
+ return row
559
+
560
+ output_list(ctx, mcps, "🔌 Available MCPs", columns, transform_mcp)
561
+
562
+ except Exception as e:
563
+ raise click.ClickException(str(e)) from e
564
+
565
+
566
+ @mcps_group.command()
567
+ @click.option("--name", help="MCP name")
568
+ @click.option("--transport", help="MCP transport protocol")
569
+ @click.option("--description", help="MCP description")
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
+ )
586
+ @output_flags()
587
+ @click.pass_context
588
+ def create(
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,
596
+ ) -> None:
597
+ r"""Create a new MCP with specified configuration.
598
+
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,
640
+ )
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
+
676
+ # Handle JSON output
677
+ handle_json_output(ctx, mcp.model_dump())
678
+
679
+ # Handle Rich output
680
+ rich_panel = display_creation_success(
681
+ "MCP",
682
+ mcp.name,
683
+ mcp.id,
684
+ Type=getattr(mcp, "type", DEFAULT_MCP_TYPE),
685
+ Transport=getattr(mcp, "transport", effective_transport),
686
+ Description=effective_description or "No description",
687
+ )
688
+ handle_rich_output(ctx, rich_panel)
689
+
690
+ except Exception as 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}")
819
+
820
+
821
+ @mcps_group.command()
822
+ @click.argument("mcp_ref")
823
+ @click.option(
824
+ "--export",
825
+ type=click.Path(dir_okay=False, writable=True),
826
+ help="Export complete MCP configuration to file (format auto-detected from .json/.yaml extension)",
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
+ )
839
+ @output_flags()
840
+ @click.pass_context
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
856
+
857
+ Raises:
858
+ ClickException: If MCP not found or export fails
859
+
860
+ \b
861
+ Examples:
862
+ aip mcps get my-mcp
863
+ aip mcps get my-mcp --export mcp.json # Export as JSON
864
+ aip mcps get my-mcp --export mcp.yaml # Export as YAML
865
+ """
866
+ try:
867
+ client = get_client(ctx)
868
+
869
+ # Resolve MCP using helper function
870
+ mcp = _resolve_mcp(ctx, client, mcp_ref)
871
+
872
+ # Handle export option
873
+ if export:
874
+ _handle_mcp_export(ctx, client, mcp, Path(export), no_auth_prompt, auth_placeholder)
875
+
876
+ # Display MCP details
877
+ _display_mcp_details(ctx, client, mcp)
878
+
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)
959
+ else:
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]")
968
+
969
+
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
+ )
1034
+
1035
+
1036
+ @mcps_group.command("tools")
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
+ )
1050
+ @output_flags()
1051
+ @click.pass_context
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.
1054
+
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
1060
+
1061
+ Raises:
1062
+ ClickException: If MCP not found or tools fetch fails
1063
+
1064
+ Examples:
1065
+ Get tools from saved MCP:
1066
+ aip mcps tools <MCP_ID>
1067
+
1068
+ Get tools from config file (without saving to DB):
1069
+ aip mcps tools --from-config mcp-config.json
1070
+
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)
1087
+
1088
+ except Exception as e:
1089
+ raise click.ClickException(str(e)) from e
1090
+
1091
+
1092
+ @mcps_group.command("connect")
1093
+ @click.option(
1094
+ "--from-file",
1095
+ "config_file",
1096
+ required=True,
1097
+ help="MCP config JSON file",
1098
+ )
1099
+ @output_flags()
1100
+ @click.pass_context
1101
+ def connect(ctx: Any, config_file: str) -> None:
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
+ """
1115
+ try:
1116
+ client = get_client(ctx)
1117
+
1118
+ # Load MCP config from file
1119
+ with open(config_file) as f:
1120
+ config = json.load(f)
1121
+
1122
+ view = get_ctx_value(ctx, "view", "rich")
1123
+ if view != "json":
1124
+ print_markup(
1125
+ f"[{WARNING_STYLE}]Connecting to MCP with config from {config_file}...[/]",
1126
+ console=console,
1127
+ )
1128
+
1129
+ # Test connection using 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)
1136
+
1137
+ view = get_ctx_value(ctx, "view", "rich")
1138
+ if view == "json":
1139
+ handle_json_output(ctx, result)
1140
+ else:
1141
+ success_panel = AIPPanel(
1142
+ f"[{SUCCESS_STYLE}]✓[/] MCP connection successful!\n\n[bold]Result:[/bold] {result}",
1143
+ title="🔌 Connection",
1144
+ border_style=SUCCESS,
1145
+ )
1146
+ console.print(success_panel)
1147
+
1148
+ except Exception as 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)
1184
+
1185
+
1186
+ @mcps_group.command()
1187
+ @click.argument("mcp_ref")
1188
+ @click.option("--name", help="New MCP name")
1189
+ @click.option("--transport", type=click.Choice(["http", "sse"]), help="New transport protocol")
1190
+ @click.option("--description", help="New description")
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")
1208
+ @output_flags()
1209
+ @click.pass_context
1210
+ def update(
1211
+ ctx: Any,
1212
+ mcp_ref: str,
1213
+ name: str | None,
1214
+ transport: str | None,
1215
+ description: str | None,
1216
+ config: str | None,
1217
+ auth: str | None,
1218
+ import_file: str | None,
1219
+ y: bool,
1220
+ ) -> None:
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
+ """
1257
+ try:
1258
+ client = get_client(ctx)
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
+
1268
+ # Resolve MCP using helper function
1269
+ mcp = _resolve_mcp(ctx, client, mcp_ref)
1270
+
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)
1280
+
1281
+ if not update_data:
1282
+ raise click.ClickException("No update fields specified")
1283
+
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)
1301
+
1302
+ handle_json_output(ctx, updated_mcp.model_dump())
1303
+ handle_rich_output(ctx, display_update_success("MCP", updated_mcp.name))
1304
+
1305
+ except Exception as e:
1306
+ _handle_cli_error(ctx, e, "MCP update")
1307
+
1308
+
1309
+ @mcps_group.command()
1310
+ @click.argument("mcp_ref")
1311
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation")
1312
+ @output_flags()
1313
+ @click.pass_context
1314
+ def delete(ctx: Any, mcp_ref: str, yes: bool) -> None:
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
+ """
1329
+ try:
1330
+ client = get_client(ctx)
1331
+
1332
+ # Resolve MCP using helper function
1333
+ mcp = _resolve_mcp(ctx, client, mcp_ref)
1334
+
1335
+ # Confirm deletion
1336
+ if not yes and not display_confirmation_prompt("MCP", mcp.name):
1337
+ return
1338
+
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)
1345
+
1346
+ handle_json_output(
1347
+ ctx,
1348
+ {
1349
+ "success": True,
1350
+ "message": f"MCP '{mcp.name}' deleted",
1351
+ },
1352
+ )
1353
+ handle_rich_output(ctx, display_deletion_success("MCP", mcp.name))
1354
+
1355
+ except Exception as e:
1356
+ _handle_cli_error(ctx, e, "MCP deletion")