glaip-sdk 0.0.11__py3-none-any.whl → 0.0.13__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.
@@ -24,7 +24,13 @@ from glaip_sdk.cli.display import (
24
24
  )
25
25
  from glaip_sdk.cli.io import (
26
26
  fetch_raw_resource_details,
27
+ load_resource_from_file_with_validation,
27
28
  )
29
+ from glaip_sdk.cli.mcp_validators import (
30
+ validate_mcp_auth_structure,
31
+ validate_mcp_config_structure,
32
+ )
33
+ from glaip_sdk.cli.parsers.json_input import parse_json_input
28
34
  from glaip_sdk.cli.resolution import resolve_resource_reference
29
35
  from glaip_sdk.cli.utils import (
30
36
  coerce_to_row,
@@ -36,8 +42,12 @@ from glaip_sdk.cli.utils import (
36
42
  output_result,
37
43
  spinner_context,
38
44
  )
45
+ from glaip_sdk.config.constants import (
46
+ DEFAULT_MCP_TYPE,
47
+ )
39
48
  from glaip_sdk.rich_components import AIPPanel
40
49
  from glaip_sdk.utils import format_datetime
50
+ from glaip_sdk.utils.import_export import convert_export_to_import_format
41
51
  from glaip_sdk.utils.serialization import (
42
52
  build_mcp_export_payload,
43
53
  write_resource_export,
@@ -85,6 +95,180 @@ def _resolve_mcp(
85
95
  )
86
96
 
87
97
 
98
+ def _strip_server_only_fields(import_data: dict[str, Any]) -> dict[str, Any]:
99
+ """Remove fields that should not be forwarded during import-driven creation.
100
+
101
+ Args:
102
+ import_data: Raw import payload loaded from disk.
103
+
104
+ Returns:
105
+ A shallow copy of the data with server-managed fields removed.
106
+ """
107
+ cleaned = dict(import_data)
108
+ for key in (
109
+ "id",
110
+ "type",
111
+ "status",
112
+ "connection_status",
113
+ "created_at",
114
+ "updated_at",
115
+ ):
116
+ cleaned.pop(key, None)
117
+ return cleaned
118
+
119
+
120
+ def _load_import_ready_payload(import_file: str) -> dict[str, Any]:
121
+ """Load and normalise an imported MCP definition for create operations.
122
+
123
+ Args:
124
+ import_file: Path to an MCP export file (JSON or YAML).
125
+
126
+ Returns:
127
+ Normalised import payload ready for CLI/REST usage.
128
+
129
+ Raises:
130
+ click.ClickException: If the file cannot be parsed or validated.
131
+ """
132
+ raw_data = load_resource_from_file_with_validation(Path(import_file), "MCP")
133
+ import_data = convert_export_to_import_format(raw_data)
134
+ import_data = _strip_server_only_fields(import_data)
135
+
136
+ transport = import_data.get("transport")
137
+
138
+ if "config" in import_data:
139
+ import_data["config"] = validate_mcp_config_structure(
140
+ import_data["config"],
141
+ transport=transport,
142
+ source="import file",
143
+ )
144
+
145
+ if "authentication" in import_data:
146
+ import_data["authentication"] = validate_mcp_auth_structure(
147
+ import_data["authentication"],
148
+ source="import file",
149
+ )
150
+
151
+ return import_data
152
+
153
+
154
+ def _coerce_cli_string(value: str | None) -> str | None:
155
+ """Normalise CLI string values so blanks are treated as missing.
156
+
157
+ Args:
158
+ value: User-provided string option.
159
+
160
+ Returns:
161
+ The stripped string, or ``None`` when the value is blank/whitespace-only.
162
+ """
163
+ if value is None:
164
+ return None
165
+ trimmed = value.strip()
166
+ # Treat whitespace-only strings as None
167
+ return trimmed if trimmed else None
168
+
169
+
170
+ def _merge_config_field(
171
+ merged_base: dict[str, Any],
172
+ cli_config: str | None,
173
+ final_transport: str | None,
174
+ ) -> None:
175
+ """Merge config field with validation.
176
+
177
+ Args:
178
+ merged_base: Base payload to update in-place.
179
+ cli_config: Raw CLI JSON string for config.
180
+ final_transport: Transport type for validation.
181
+
182
+ Raises:
183
+ click.ClickException: If config JSON parsing or validation fails.
184
+ """
185
+ if cli_config is not None:
186
+ parsed_config = parse_json_input(cli_config)
187
+ merged_base["config"] = validate_mcp_config_structure(
188
+ parsed_config,
189
+ transport=final_transport,
190
+ source="--config",
191
+ )
192
+ elif "config" not in merged_base or merged_base["config"] is None:
193
+ merged_base["config"] = {}
194
+
195
+
196
+ def _merge_auth_field(
197
+ merged_base: dict[str, Any],
198
+ cli_auth: str | None,
199
+ ) -> None:
200
+ """Merge authentication field with validation.
201
+
202
+ Args:
203
+ merged_base: Base payload to update in-place.
204
+ cli_auth: Raw CLI JSON string for authentication.
205
+
206
+ Raises:
207
+ click.ClickException: If auth JSON parsing or validation fails.
208
+ """
209
+ if cli_auth is not None:
210
+ parsed_auth = parse_json_input(cli_auth)
211
+ merged_base["authentication"] = validate_mcp_auth_structure(
212
+ parsed_auth,
213
+ source="--auth",
214
+ )
215
+ elif "authentication" not in merged_base:
216
+ merged_base["authentication"] = None
217
+
218
+
219
+ def _merge_import_payload(
220
+ import_data: dict[str, Any] | None,
221
+ *,
222
+ cli_name: str | None,
223
+ cli_transport: str | None,
224
+ cli_description: str | None,
225
+ cli_config: str | None,
226
+ cli_auth: str | None,
227
+ ) -> tuple[dict[str, Any], list[str]]:
228
+ """Merge import data with CLI overrides while tracking missing fields.
229
+
230
+ Args:
231
+ import_data: Normalised payload loaded from file (if provided).
232
+ cli_name: Name supplied via CLI option.
233
+ cli_transport: Transport supplied via CLI option.
234
+ cli_description: Description supplied via CLI option.
235
+ cli_config: Raw CLI JSON string for config.
236
+ cli_auth: Raw CLI JSON string for authentication.
237
+
238
+ Returns:
239
+ A tuple of (merged_payload, missing_required_fields).
240
+
241
+ Raises:
242
+ click.ClickException: If config/auth JSON parsing or validation fails.
243
+ """
244
+ merged_base = import_data.copy() if import_data else {}
245
+
246
+ # Merge simple string fields using truthy CLI overrides
247
+ for field, cli_value in (
248
+ ("name", _coerce_cli_string(cli_name)),
249
+ ("transport", _coerce_cli_string(cli_transport)),
250
+ ("description", _coerce_cli_string(cli_description)),
251
+ ):
252
+ if cli_value is not None:
253
+ merged_base[field] = cli_value
254
+
255
+ # Determine final transport before validating config
256
+ final_transport = merged_base.get("transport")
257
+
258
+ # Merge config and authentication with validation
259
+ _merge_config_field(merged_base, cli_config, final_transport)
260
+ _merge_auth_field(merged_base, cli_auth)
261
+
262
+ # Validate required fields
263
+ missing_fields = []
264
+ for required in ("name", "transport"):
265
+ value = merged_base.get(required)
266
+ if not isinstance(value, str) or not value.strip():
267
+ missing_fields.append(required)
268
+
269
+ return merged_base, missing_fields
270
+
271
+
88
272
  @mcps_group.command(name="list")
89
273
  @output_flags()
90
274
  @click.pass_context
@@ -134,50 +318,114 @@ def list_mcps(ctx: Any) -> None:
134
318
 
135
319
 
136
320
  @mcps_group.command()
137
- @click.option("--name", required=True, help="MCP name")
138
- @click.option("--transport", required=True, help="MCP transport protocol")
321
+ @click.option("--name", help="MCP name")
322
+ @click.option("--transport", help="MCP transport protocol")
139
323
  @click.option("--description", help="MCP description")
140
- @click.option("--config", help="JSON configuration string")
324
+ @click.option(
325
+ "--config",
326
+ help="JSON configuration string or @file reference (e.g., @config.json)",
327
+ )
328
+ @click.option(
329
+ "--auth",
330
+ "--authentication",
331
+ "auth",
332
+ help="JSON authentication object or @file reference (e.g., @auth.json)",
333
+ )
334
+ @click.option(
335
+ "--import",
336
+ "import_file",
337
+ type=click.Path(exists=True, dir_okay=False),
338
+ help="Import MCP configuration from JSON or YAML export",
339
+ )
141
340
  @output_flags()
142
341
  @click.pass_context
143
342
  def create(
144
- ctx: Any, name: str, transport: str, description: str | None, config: str | None
343
+ ctx: Any,
344
+ name: str | None,
345
+ transport: str | None,
346
+ description: str | None,
347
+ config: str | None,
348
+ auth: str | None,
349
+ import_file: str | None,
145
350
  ) -> None:
146
351
  """Create a new MCP with specified configuration.
147
352
 
353
+ You can create an MCP by providing all parameters via CLI options, or by
354
+ importing from a file and optionally overriding specific fields.
355
+
148
356
  Args:
149
357
  ctx: Click context containing output format preferences
150
- name: MCP name (required)
151
- transport: MCP transport protocol (required)
358
+ name: MCP name (required unless provided via --import)
359
+ transport: MCP transport protocol (required unless provided via --import)
152
360
  description: Optional MCP description
153
- config: JSON configuration string for MCP settings
361
+ config: JSON configuration string or @file reference
362
+ auth: JSON authentication object or @file reference
363
+ import_file: Optional path to import configuration from export file.
364
+ CLI options override imported values.
154
365
 
155
366
  Raises:
156
367
  ClickException: If JSON parsing fails or API request fails
368
+
369
+ Examples:
370
+ Create from CLI options:
371
+ aip mcps create --name my-mcp --transport http --config '{"url": "https://api.example.com"}'
372
+
373
+ Import from file:
374
+ aip mcps create --import mcp-export.json
375
+
376
+ Import with overrides:
377
+ aip mcps create --import mcp-export.json --name new-name --transport sse
157
378
  """
158
379
  try:
159
380
  client = get_client(ctx)
160
381
 
161
- # Parse config if provided
162
- mcp_config = {}
163
- if config:
164
- try:
165
- mcp_config = json.loads(config)
166
- except json.JSONDecodeError:
167
- raise click.ClickException("Invalid JSON in --config")
382
+ import_payload = (
383
+ _load_import_ready_payload(import_file) if import_file is not None else None
384
+ )
385
+
386
+ merged_payload, missing_fields = _merge_import_payload(
387
+ import_payload,
388
+ cli_name=name,
389
+ cli_transport=transport,
390
+ cli_description=description,
391
+ cli_config=config,
392
+ cli_auth=auth,
393
+ )
394
+
395
+ if missing_fields:
396
+ raise click.ClickException(
397
+ "Missing required fields after combining import and CLI values: "
398
+ + ", ".join(missing_fields)
399
+ )
400
+
401
+ effective_name = merged_payload["name"]
402
+ effective_transport = merged_payload["transport"]
403
+ effective_description = merged_payload.get("description")
404
+ effective_config = merged_payload.get("config") or {}
405
+ effective_auth = merged_payload.get("authentication")
168
406
 
169
407
  with spinner_context(
170
408
  ctx,
171
409
  "[bold blue]Creating MCP…[/bold blue]",
172
410
  console_override=console,
173
411
  ):
174
- mcp = client.mcps.create_mcp(
175
- name=name,
176
- type="server", # MCPs are always server type
177
- transport=transport,
178
- description=description,
179
- config=mcp_config,
180
- )
412
+ create_kwargs: dict[str, Any] = {
413
+ "name": effective_name,
414
+ "config": effective_config,
415
+ "transport": effective_transport,
416
+ }
417
+
418
+ if effective_description is not None:
419
+ create_kwargs["description"] = effective_description
420
+
421
+ if effective_auth:
422
+ create_kwargs["authentication"] = effective_auth
423
+
424
+ mcp_metadata = merged_payload.get("mcp_metadata")
425
+ if mcp_metadata is not None:
426
+ create_kwargs["mcp_metadata"] = mcp_metadata
427
+
428
+ mcp = client.mcps.create_mcp(**create_kwargs)
181
429
 
182
430
  # Handle JSON output
183
431
  handle_json_output(ctx, mcp.model_dump())
@@ -187,9 +435,9 @@ def create(
187
435
  "MCP",
188
436
  mcp.name,
189
437
  mcp.id,
190
- Type="server",
191
- Transport=getattr(mcp, "transport", transport),
192
- Description=description or "No description",
438
+ Type=getattr(mcp, "type", DEFAULT_MCP_TYPE),
439
+ Transport=getattr(mcp, "transport", effective_transport),
440
+ Description=effective_description or "No description",
193
441
  )
194
442
  handle_rich_output(ctx, rich_panel)
195
443
 
@@ -437,7 +685,6 @@ def list_tools(ctx: Any, mcp_ref: str) -> None:
437
685
  columns = [
438
686
  ("name", "Name", "cyan", None),
439
687
  ("description", "Description", "green", 50),
440
- ("type", "Type", "yellow", None),
441
688
  ]
442
689
 
443
690
  # Transform function for safe dictionary access
@@ -447,7 +694,6 @@ def list_tools(ctx: Any, mcp_ref: str) -> None:
447
694
  "description": tool.get("description", "N/A")[:47] + "..."
448
695
  if len(tool.get("description", "")) > 47
449
696
  else tool.get("description", "N/A"),
450
- "type": tool.get("type", "N/A"),
451
697
  }
452
698
 
453
699
  output_list(
@@ -525,7 +771,16 @@ def connect(ctx: Any, config_file: str) -> None:
525
771
  @click.argument("mcp_ref")
526
772
  @click.option("--name", help="New MCP name")
527
773
  @click.option("--description", help="New description")
528
- @click.option("--config", help="JSON configuration string")
774
+ @click.option(
775
+ "--config",
776
+ help="JSON configuration string or @file reference (e.g., @config.json)",
777
+ )
778
+ @click.option(
779
+ "--auth",
780
+ "--authentication",
781
+ "auth",
782
+ help="JSON authentication object or @file reference (e.g., @auth.json)",
783
+ )
529
784
  @output_flags()
530
785
  @click.pass_context
531
786
  def update(
@@ -534,6 +789,7 @@ def update(
534
789
  name: str | None,
535
790
  description: str | None,
536
791
  config: str | None,
792
+ auth: str | None,
537
793
  ) -> None:
538
794
  """Update an existing MCP with new configuration values.
539
795
 
@@ -542,7 +798,8 @@ def update(
542
798
  mcp_ref: MCP reference (ID or name)
543
799
  name: New MCP name (optional)
544
800
  description: New description (optional)
545
- config: New JSON configuration string (optional)
801
+ config: New JSON configuration string or @file reference (optional)
802
+ auth: New JSON authentication object or @file reference (optional)
546
803
 
547
804
  Raises:
548
805
  ClickException: If MCP not found, JSON invalid, or no fields specified
@@ -564,10 +821,17 @@ def update(
564
821
  if description is not None:
565
822
  update_data["description"] = description
566
823
  if config is not None:
567
- try:
568
- update_data["config"] = json.loads(config)
569
- except json.JSONDecodeError:
570
- raise click.ClickException("Invalid JSON in --config")
824
+ parsed_config = parse_json_input(config)
825
+ update_data["config"] = validate_mcp_config_structure(
826
+ parsed_config,
827
+ transport=getattr(mcp, "transport", None),
828
+ source="--config",
829
+ )
830
+ if auth is not None:
831
+ parsed_auth = parse_json_input(auth)
832
+ update_data["authentication"] = validate_mcp_auth_structure(
833
+ parsed_auth, source="--auth"
834
+ )
571
835
 
572
836
  if not update_data:
573
837
  raise click.ClickException("No update fields specified")
@@ -0,0 +1,297 @@
1
+ """MCP configuration and authentication validation for CLI.
2
+
3
+ This module provides validation functions for MCP config and auth structures
4
+ that are used in CLI commands. It ensures data conforms to the MCP schema
5
+ documented in docs/reference/schemas/mcps.md.
6
+
7
+ Authors:
8
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
9
+ """
10
+
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+
14
+ import click
15
+
16
+
17
+ def format_validation_error(prefix: str, detail: str | None = None) -> str:
18
+ """Format a validation error message with optional detail.
19
+
20
+ Args:
21
+ prefix: Main error message
22
+ detail: Optional additional detail to append
23
+
24
+ Returns:
25
+ Formatted error message string
26
+
27
+ Examples:
28
+ >>> format_validation_error("Invalid config", "Missing 'url' field")
29
+ "Invalid config\\nMissing 'url' field"
30
+ """
31
+ parts = [prefix]
32
+ if detail:
33
+ parts.append(detail)
34
+ return "\n".join(parts)
35
+
36
+
37
+ def validate_mcp_config_structure(
38
+ config: Any, *, transport: str | None = None, source: str = "--config"
39
+ ) -> dict[str, Any]:
40
+ """Validate MCP configuration structure for CLI commands.
41
+
42
+ Validates that the config is a dictionary with a valid 'url' field.
43
+ The 'url' must be an absolute HTTP/HTTPS URL as required by the MCP schema.
44
+
45
+ Args:
46
+ config: Configuration value to validate (expected to be a dict)
47
+ transport: Optional transport type ('http' or 'sse') for context in errors
48
+ source: Source parameter name for error messages (default: "--config")
49
+
50
+ Returns:
51
+ Validated configuration dictionary
52
+
53
+ Raises:
54
+ click.ClickException: If config is not a dict, missing 'url', or URL is invalid
55
+
56
+ Examples:
57
+ >>> validate_mcp_config_structure({"url": "https://api.example.com"})
58
+ {'url': 'https://api.example.com'}
59
+
60
+ >>> validate_mcp_config_structure([1, 2, 3]) # doctest: +SKIP
61
+ ClickException: Invalid --config value
62
+ Expected a JSON object representing MCP configuration.
63
+
64
+ Schema Reference:
65
+ See docs/reference/schemas/mcps.md - Config Object Structure
66
+ - Required field: 'url' (string, must be valid HTTP/HTTPS URL)
67
+ - Additional fields allowed and passed through
68
+ """
69
+ if not isinstance(config, dict):
70
+ raise click.ClickException(
71
+ format_validation_error(
72
+ f"Invalid {source} value",
73
+ "Expected a JSON object representing MCP configuration.",
74
+ )
75
+ )
76
+
77
+ url_value = config.get("url")
78
+ if not isinstance(url_value, str) or not url_value.strip():
79
+ requirement = "Missing required 'url' field with a non-empty string value."
80
+ if transport:
81
+ requirement += f" Required for transport '{transport}'."
82
+ raise click.ClickException(
83
+ format_validation_error(f"Invalid {source} value", requirement)
84
+ )
85
+
86
+ parsed_url = urlparse(url_value)
87
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
88
+ raise click.ClickException(
89
+ format_validation_error(
90
+ f"Invalid {source} value",
91
+ "'url' must be an absolute HTTP or HTTPS URL.",
92
+ )
93
+ )
94
+
95
+ return config
96
+
97
+
98
+ def _validate_headers_mapping(
99
+ headers: Any, *, source: str, context: str
100
+ ) -> dict[str, str]:
101
+ """Validate headers mapping for authentication.
102
+
103
+ Args:
104
+ headers: Headers value to validate (expected to be a non-empty dict)
105
+ source: Source parameter name for error messages
106
+ context: Context description for error messages (e.g., "bearer-token authentication")
107
+
108
+ Returns:
109
+ Validated headers dictionary with string keys and values
110
+
111
+ Raises:
112
+ click.ClickException: If headers is not a dict, empty, or contains invalid entries
113
+ """
114
+ if not isinstance(headers, dict) or not headers:
115
+ raise click.ClickException(
116
+ format_validation_error(
117
+ f"Invalid {source} value",
118
+ f"{context} must provide a non-empty 'headers' object with string keys and values.",
119
+ )
120
+ )
121
+
122
+ normalized: dict[str, str] = {}
123
+ for key, value in headers.items():
124
+ if not isinstance(key, str) or not key.strip():
125
+ raise click.ClickException(
126
+ format_validation_error(
127
+ f"Invalid {source} value",
128
+ "Header names must be non-empty strings.",
129
+ )
130
+ )
131
+ if not isinstance(value, str) or not value.strip():
132
+ raise click.ClickException(
133
+ format_validation_error(
134
+ f"Invalid {source} value",
135
+ f"Header '{key}' must have a non-empty string value.",
136
+ )
137
+ )
138
+ normalized[key] = value
139
+ return normalized
140
+
141
+
142
+ def _validate_bearer_token_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
143
+ """Validate bearer-token authentication.
144
+
145
+ Args:
146
+ auth: Authentication dictionary
147
+ source: Source parameter name for error messages
148
+
149
+ Returns:
150
+ Validated bearer-token authentication dictionary
151
+
152
+ Raises:
153
+ click.ClickException: If bearer-token structure is invalid
154
+ """
155
+ token = auth.get("token")
156
+ if isinstance(token, str) and token.strip():
157
+ return {"type": "bearer-token", "token": token}
158
+ headers = auth.get("headers")
159
+ normalized_headers = _validate_headers_mapping(
160
+ headers, source=source, context="bearer-token authentication"
161
+ )
162
+ return {"type": "bearer-token", "headers": normalized_headers}
163
+
164
+
165
+ def _validate_api_key_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
166
+ """Validate api-key authentication.
167
+
168
+ Args:
169
+ auth: Authentication dictionary
170
+ source: Source parameter name for error messages
171
+
172
+ Returns:
173
+ Validated api-key authentication dictionary
174
+
175
+ Raises:
176
+ click.ClickException: If api-key structure is invalid
177
+ """
178
+ headers = auth.get("headers")
179
+ if headers is not None:
180
+ normalized_headers = _validate_headers_mapping(
181
+ headers, source=source, context="api-key authentication"
182
+ )
183
+ return {"type": "api-key", "headers": normalized_headers}
184
+
185
+ key = auth.get("key")
186
+ value = auth.get("value")
187
+ if not isinstance(key, str) or not key.strip():
188
+ raise click.ClickException(
189
+ format_validation_error(
190
+ f"Invalid {source} value",
191
+ "api-key authentication requires a non-empty 'key'.",
192
+ )
193
+ )
194
+ if not isinstance(value, str) or not value.strip():
195
+ raise click.ClickException(
196
+ format_validation_error(
197
+ f"Invalid {source} value",
198
+ "api-key authentication requires a non-empty 'value'.",
199
+ )
200
+ )
201
+ return {"type": "api-key", "key": key, "value": value}
202
+
203
+
204
+ def _validate_custom_header_auth(auth: dict[str, Any], source: str) -> dict[str, Any]:
205
+ """Validate custom-header authentication.
206
+
207
+ Args:
208
+ auth: Authentication dictionary
209
+ source: Source parameter name for error messages
210
+
211
+ Returns:
212
+ Validated custom-header authentication dictionary
213
+
214
+ Raises:
215
+ click.ClickException: If custom-header structure is invalid
216
+ """
217
+ headers = auth.get("headers")
218
+ normalized_headers = _validate_headers_mapping(
219
+ headers, source=source, context="custom-header authentication"
220
+ )
221
+ return {"type": "custom-header", "headers": normalized_headers}
222
+
223
+
224
+ def validate_mcp_auth_structure(auth: Any, *, source: str = "--auth") -> dict[str, Any]:
225
+ """Validate MCP authentication structure for CLI commands.
226
+
227
+ Validates authentication objects according to the MCP schema, supporting:
228
+ - no-auth: No authentication required
229
+ - bearer-token: Bearer token via 'token' field or 'headers'
230
+ - api-key: API key via 'key'/'value' fields or 'headers'
231
+ - custom-header: Custom headers via 'headers' object
232
+
233
+ Args:
234
+ auth: Authentication value to validate (expected to be a dict or None)
235
+ source: Source parameter name for error messages (default: "--auth")
236
+
237
+ Returns:
238
+ Validated authentication dictionary, or empty dict if auth is None
239
+
240
+ Raises:
241
+ click.ClickException: If auth structure is invalid or type is unsupported
242
+
243
+ Examples:
244
+ >>> validate_mcp_auth_structure(None)
245
+ {}
246
+
247
+ >>> validate_mcp_auth_structure({"type": "no-auth"})
248
+ {'type': 'no-auth'}
249
+
250
+ >>> validate_mcp_auth_structure({"type": "bearer-token", "token": "abc123"})
251
+ {'type': 'bearer-token', 'token': 'abc123'}
252
+
253
+ Schema Reference:
254
+ See docs/reference/schemas/mcps.md - Authentication Types
255
+ - Required field: 'type' (string, one of: no-auth, bearer-token, api-key, custom-header)
256
+ - Additional fields depend on type
257
+ """
258
+ if auth is None:
259
+ return {}
260
+
261
+ if not isinstance(auth, dict):
262
+ raise click.ClickException(
263
+ format_validation_error(
264
+ f"Invalid {source} value",
265
+ "Expected a JSON object representing MCP authentication.",
266
+ )
267
+ )
268
+
269
+ raw_type = auth.get("type")
270
+ if not isinstance(raw_type, str) or not raw_type.strip():
271
+ raise click.ClickException(
272
+ format_validation_error(
273
+ f"Invalid {source} value",
274
+ "Authentication objects must include a non-empty 'type' field.",
275
+ )
276
+ )
277
+
278
+ auth_type = raw_type.strip()
279
+
280
+ # Dispatch to type-specific validators
281
+ if auth_type == "no-auth":
282
+ return {"type": "no-auth"}
283
+ if auth_type == "bearer-token":
284
+ return _validate_bearer_token_auth(auth, source)
285
+ if auth_type == "api-key":
286
+ return _validate_api_key_auth(auth, source)
287
+ if auth_type == "custom-header":
288
+ return _validate_custom_header_auth(auth, source)
289
+
290
+ # Unknown type
291
+ raise click.ClickException(
292
+ format_validation_error(
293
+ f"Invalid {source} value",
294
+ f"Unsupported authentication type '{auth_type}'. "
295
+ f"Supported types: no-auth, bearer-token, api-key, custom-header",
296
+ )
297
+ )
@@ -0,0 +1,9 @@
1
+ """CLI input parsers.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from glaip_sdk.cli.parsers.json_input import parse_json_input
8
+
9
+ __all__ = ["parse_json_input"]
@@ -0,0 +1,140 @@
1
+ """JSON input parser for CLI options.
2
+
3
+ Handles both inline JSON strings and @file references.
4
+
5
+ Authors:
6
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+
17
+ def _format_file_error(
18
+ prefix: str, file_path_str: str, resolved_path: Path, *, detail: str | None = None
19
+ ) -> str:
20
+ """Format a file-related error message with path context.
21
+
22
+ Args:
23
+ prefix: Main error message
24
+ file_path_str: Original file path string provided by user
25
+ resolved_path: Resolved absolute path
26
+ detail: Optional additional detail to append
27
+
28
+ Returns:
29
+ Formatted error message string with file path context
30
+
31
+ Examples:
32
+ >>> from pathlib import Path
33
+ >>> _format_file_error("File not found", "config.json", Path("/abs/config.json"))
34
+ 'File not found: config.json\\nResolved path: /abs/config.json'
35
+ """
36
+ parts = [f"{prefix}: {file_path_str}", f"Resolved path: {resolved_path}"]
37
+ if detail:
38
+ parts.append(detail)
39
+ return "\n".join(parts)
40
+
41
+
42
+ def _parse_json_from_file(file_path_str: str) -> Any:
43
+ """Parse JSON from a file path.
44
+
45
+ Args:
46
+ file_path_str: Path to the JSON file (without @ prefix).
47
+
48
+ Returns:
49
+ Parsed dictionary from file.
50
+
51
+ Raises:
52
+ click.ClickException: If file not found, not readable, empty, or invalid JSON.
53
+ """
54
+ # Resolve relative paths against CWD
55
+ file_path = Path(file_path_str)
56
+ if not file_path.is_absolute():
57
+ file_path = Path.cwd() / file_path
58
+
59
+ # Check if file exists and is a regular file
60
+ if not file_path.is_file():
61
+ raise click.ClickException(
62
+ _format_file_error("File not found or not a file", file_path_str, file_path)
63
+ )
64
+
65
+ # Check if file is readable
66
+ if not os.access(file_path, os.R_OK):
67
+ raise click.ClickException(
68
+ _format_file_error(
69
+ "File not readable (permission denied)", file_path_str, file_path
70
+ )
71
+ )
72
+
73
+ # Read file content
74
+ try:
75
+ content = file_path.read_text(encoding="utf-8")
76
+ except Exception as e:
77
+ raise click.ClickException(
78
+ _format_file_error(
79
+ "Error reading file", file_path_str, file_path, detail=f"Error: {e}"
80
+ )
81
+ )
82
+
83
+ # Check for empty content
84
+ if not content.strip():
85
+ raise click.ClickException(
86
+ _format_file_error("File is empty", file_path_str, file_path)
87
+ )
88
+
89
+ # Parse JSON from file content
90
+ try:
91
+ return json.loads(content)
92
+ except json.JSONDecodeError as e:
93
+ raise click.ClickException(
94
+ _format_file_error(
95
+ "Invalid JSON in file",
96
+ file_path_str,
97
+ file_path,
98
+ detail=f"Error: {e.msg} at line {e.lineno}, column {e.colno}",
99
+ )
100
+ )
101
+
102
+
103
+ def parse_json_input(value: str | None) -> Any:
104
+ """Parse JSON input from inline string or file reference.
105
+
106
+ Args:
107
+ value: JSON string or @file reference. If None, returns None.
108
+
109
+ Returns:
110
+ Parsed JSON value (dict, list, str, int, float, bool, None) or None if value is None.
111
+
112
+ Raises:
113
+ click.ClickException: If file not found, not readable, empty, or invalid JSON.
114
+
115
+ Examples:
116
+ >>> parse_json_input('{"key": "value"}')
117
+ {'key': 'value'}
118
+
119
+ >>> parse_json_input('@/path/to/config.json')
120
+ # Returns content of config.json parsed as JSON
121
+
122
+ >>> parse_json_input(None)
123
+ None
124
+ """
125
+ if value is None:
126
+ return None
127
+
128
+ # Check if value is a file reference (strip whitespace first)
129
+ trimmed = value.strip()
130
+ if trimmed.startswith("@"):
131
+ return _parse_json_from_file(trimmed[1:])
132
+
133
+ # Parse inline JSON
134
+ try:
135
+ return json.loads(value)
136
+ except json.JSONDecodeError as e:
137
+ raise click.ClickException(
138
+ f"Invalid JSON in inline value\n"
139
+ f"Error: {e.msg} at line {e.lineno}, column {e.colno}"
140
+ )
glaip_sdk/client/mcps.py CHANGED
@@ -57,7 +57,7 @@ class MCPClient(BaseClient):
57
57
  def create_mcp(
58
58
  self,
59
59
  name: str,
60
- description: str,
60
+ description: str | None = None,
61
61
  config: dict[str, Any] | None = None,
62
62
  **kwargs,
63
63
  ) -> MCP:
@@ -108,7 +108,7 @@ class MCPClient(BaseClient):
108
108
  def _build_create_payload(
109
109
  self,
110
110
  name: str,
111
- description: str,
111
+ description: str | None = None,
112
112
  transport: str = DEFAULT_MCP_TRANSPORT,
113
113
  config: dict[str, Any] | None = None,
114
114
  **kwargs,
@@ -122,7 +122,7 @@ class MCPClient(BaseClient):
122
122
 
123
123
  Args:
124
124
  name: MCP name
125
- description: MCP description
125
+ description: MCP description (optional)
126
126
  transport: MCP transport protocol (defaults to stdio)
127
127
  config: MCP configuration dictionary
128
128
  **kwargs: Additional parameters
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: glaip-sdk
3
- Version: 0.0.11
3
+ Version: 0.0.13
4
4
  Summary: Python SDK for GL AIP (GDP Labs AI Agent Package) - Simplified CLI Design
5
5
  License: MIT
6
6
  Author: Raymond Christopher
@@ -7,12 +7,15 @@ glaip_sdk/cli/auth.py,sha256=eYdtGmJ3XgiO96hq_69GF6b3W-aRWZrDQ-6bHuaRX4M,13517
7
7
  glaip_sdk/cli/commands/__init__.py,sha256=x0CZlZbZHoHvuzfoTWIyEch6WmNnbPzxajrox6riYp0,173
8
8
  glaip_sdk/cli/commands/agents.py,sha256=97dzowjHgk5knyHuI-0z2ojvqNlkebNN1-ikGEoS5sc,40623
9
9
  glaip_sdk/cli/commands/configure.py,sha256=eRDzsaKV4fl2lJt8ieS4g2-xRnaa02eAAPW8xBf-tqA,7507
10
- glaip_sdk/cli/commands/mcps.py,sha256=a2p8KfPYv4YaC9qiC7LEyh8oPsbLAwXv4dsnwxZ_juI,19752
10
+ glaip_sdk/cli/commands/mcps.py,sha256=ENhasfSupmCSKs-Ycg-M9Gy-58Y55SMIQzeg3fBJj48,28186
11
11
  glaip_sdk/cli/commands/models.py,sha256=Ra3-50BPScNs0Q-j4b7U4iK0hNooucEyVgHpQ11-pt8,1700
12
12
  glaip_sdk/cli/commands/tools.py,sha256=MOM9Db3HGL1stF-WvL5cZXjw-iZo2qc-oyKQHy6VwIM,18690
13
13
  glaip_sdk/cli/display.py,sha256=jE20swoRKzpYUmc0jgbeonaXKeE9x95hfjWAEdnBYRc,8727
14
14
  glaip_sdk/cli/io.py,sha256=GPkw3pQMLBGoD5GH-KlbKpNRlVWFZOXHE17F7V3kQsI,3343
15
15
  glaip_sdk/cli/main.py,sha256=3Bl8u9t1MekzaNrAZqsx4TukbzzFdi6Wss6jvTDos00,12930
16
+ glaip_sdk/cli/mcp_validators.py,sha256=PEJRzb7ogRkwNJwJK9k5Xmb8hvoQ58L2Qywqd_3Wayo,10125
17
+ glaip_sdk/cli/parsers/__init__.py,sha256=Ycd4HDfYmA7GUGFt0ndBPBo5uTbv15XsXnYUj-a89ug,183
18
+ glaip_sdk/cli/parsers/json_input.py,sha256=iISa31ZsDNYWfCVRy0cifRIg2gjnhI-XtdDLB-UOshg,4039
16
19
  glaip_sdk/cli/resolution.py,sha256=BOw2NchReLKewAwBAZLWw_3_bI7u3tfzQEO7kQbIiGE,2067
17
20
  glaip_sdk/cli/slash/__init__.py,sha256=Vdv6Y8bu-pA8dxDlyP4XrhudBPivztUozhLAz9vaLig,682
18
21
  glaip_sdk/cli/slash/agent_session.py,sha256=pDOwGXNHuyJIulrGYu1pacyF3oxHWeDQY-Uv92h2qVg,6859
@@ -25,7 +28,7 @@ glaip_sdk/client/__init__.py,sha256=nYLXfBVTTWwKjP0e63iumPYO4k5FifwWaELQPaPIKIg,
25
28
  glaip_sdk/client/agents.py,sha256=FSKubF40wptMNIheC3_iawiX2CRbhTcNLFiz4qkPC6k,34659
26
29
  glaip_sdk/client/base.py,sha256=O3dv3I7PqY91gH1FehBMRZcSXjwimfeBcJuiXidDmqw,13700
27
30
  glaip_sdk/client/main.py,sha256=LlvYHP7-Hy7Eq1ep1kfk337K-Oue5SdKWJpqYfX9eXY,7993
28
- glaip_sdk/client/mcps.py,sha256=yxwrAtztElYDEGhp2EHRpeYUxNsOlTLTqtw9jSKJmcI,8936
31
+ glaip_sdk/client/mcps.py,sha256=-O-I15qjbwfSA69mouHY6g5_qgPWC4rM98VJLpOkh1A,8975
29
32
  glaip_sdk/client/tools.py,sha256=n8DIiOOf1YU_j9JK3Bx2-rDnkpckPi0MI9Ok2s1kwa4,16634
30
33
  glaip_sdk/client/validators.py,sha256=NtPsWjQLjj25LiUnmR-WuS8lL5p4MVRaYT9UVRmj9bo,8809
31
34
  glaip_sdk/config/constants.py,sha256=ysEobMiXlLZGIOEaqTdHpPF8kmg5nbLn7BIcBvTCuHM,819
@@ -55,7 +58,7 @@ glaip_sdk/utils/rich_utils.py,sha256=-Ij-1bIJvnVAi6DrfftchIlMcvOTjVmSE0Qqax0EY_s
55
58
  glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDad0,1365
56
59
  glaip_sdk/utils/serialization.py,sha256=T1yt_8G2DCFpcxx7XnqFl5slksRXfBCUuLQJTreGYEQ,11806
57
60
  glaip_sdk/utils/validation.py,sha256=QNORcdyvuliEs4EH2_mkDgmoyT9utgl7YNhaf45SEf8,6992
58
- glaip_sdk-0.0.11.dist-info/METADATA,sha256=Z8hxLQAn8fWQtisQyZAi6QgOCSxDpEWw4gLbTBMDrxU,4984
59
- glaip_sdk-0.0.11.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
60
- glaip_sdk-0.0.11.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
61
- glaip_sdk-0.0.11.dist-info/RECORD,,
61
+ glaip_sdk-0.0.13.dist-info/METADATA,sha256=Kv1zs8YO3gQigogChDeITrmnA7UggqkM_qBFnkAAi-k,4984
62
+ glaip_sdk-0.0.13.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
63
+ glaip_sdk-0.0.13.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
64
+ glaip_sdk-0.0.13.dist-info/RECORD,,