Skip to content

Commit d5f6da9

Browse files
Merge pull request #41 from CrackingShells/feat/mcp-host-partial-config
MCP server configuration improvements
2 parents 462895d + fd83cd9 commit d5f6da9

11 files changed

+1288
-86
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# For this project
22
envs/
3+
.augment/
4+
.github/instructions/
5+
Laghari/
36

47
# vvvvvvv Default Python Ignore vvvvvvvv
58
# Byte-compiled / optimized / DLL files

docs/articles/users/CLIReference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ Configure an MCP server on a specific host platform.
346346

347347
Syntax:
348348

349-
`hatch mcp configure <server-name> --host <host> (--command CMD | --url URL) [--args ARGS] [--env ENV] [--headers HEADERS] [--dry-run] [--auto-approve] [--no-backup]`
349+
`hatch mcp configure <server-name> --host <host> (--command CMD | --url URL) [--args ARGS] [--env ENV] [--header HEADER] [--dry-run] [--auto-approve] [--no-backup]`
350350

351351
| Argument / Flag | Type | Description | Default |
352352
|---:|---|---|---|
@@ -356,7 +356,7 @@ Syntax:
356356
| `--url` | string | URL for remote MCP servers (mutually exclusive with --command) | none |
357357
| `--args` | multiple | Arguments for MCP server command (only with --command) | none |
358358
| `--env` | string | Environment variables format: KEY=VALUE (can be used multiple times) | none |
359-
| `--headers` | string | HTTP headers format: KEY=VALUE (only with --url) | none |
359+
| `--header` | string | HTTP headers format: KEY=VALUE (only with --url) | none |
360360
| `--dry-run` | flag | Preview configuration without applying changes | false |
361361
| `--auto-approve` | flag | Skip confirmation prompts | false |
362362
| `--no-backup` | flag | Skip backup creation before configuration | false |
@@ -389,7 +389,7 @@ Configure MCP server 'my-server' on host 'claude-desktop'? [y/N]: y
389389
**Example - Remote Server Configuration**:
390390

391391
```bash
392-
$ hatch mcp configure api-server --host claude-desktop --url https://api.example.com --headers Auth=token
392+
$ hatch mcp configure api-server --host claude-desktop --url https://api.example.com --header Auth=token
393393

394394
Server 'api-server' created for host 'claude-desktop':
395395
name: UPDATED None --> 'api-server'

docs/articles/users/MCPHostConfiguration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ hatch mcp remove host claude-desktop
8787
**Important**: Each server must be configured as either local (using `--command`) or remote (using `--url`), but not both. These options are mutually exclusive:
8888

8989
- **Local servers**: Use `--command` and optionally `--args` and `--env`
90-
- **Remote servers**: Use `--url` and optionally `--headers`
90+
- **Remote servers**: Use `--url` and optionally `--header`
9191

9292
Attempting to use both `--command` and `--url` will result in an error.
9393

hatch/cli_hatch.py

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import argparse
1111
import json
1212
import logging
13+
import shlex
1314
import sys
1415
from pathlib import Path
1516
from typing import Optional, List
@@ -567,13 +568,13 @@ def parse_env_vars(env_list: Optional[list]) -> dict:
567568

568569
return env_dict
569570

570-
def parse_headers(headers_list: Optional[list]) -> dict:
571+
def parse_header(header_list: Optional[list]) -> dict:
571572
"""Parse HTTP headers from command line format."""
572-
if not headers_list:
573+
if not header_list:
573574
return {}
574575

575576
headers_dict = {}
576-
for header in headers_list:
577+
for header in header_list:
577578
if '=' not in header:
578579
print(f"Warning: Invalid header format '{header}'. Expected KEY=VALUE")
579580
continue
@@ -582,7 +583,7 @@ def parse_headers(headers_list: Optional[list]) -> dict:
582583

583584
return headers_dict
584585

585-
def parse_inputs(inputs_list: Optional[list]) -> Optional[list]:
586+
def parse_input(input_list: Optional[list]) -> Optional[list]:
586587
"""Parse VS Code input variable definitions from command line format.
587588
588589
Format: type,id,description[,password=true]
@@ -591,11 +592,11 @@ def parse_inputs(inputs_list: Optional[list]) -> Optional[list]:
591592
Returns:
592593
List of input variable definition dictionaries, or None if no inputs provided.
593594
"""
594-
if not inputs_list:
595+
if not input_list:
595596
return None
596597

597598
parsed_inputs = []
598-
for input_str in inputs_list:
599+
for input_str in input_list:
599600
parts = [p.strip() for p in input_str.split(',')]
600601
if len(parts) < 3:
601602
print(f"Warning: Invalid input format '{input_str}'. Expected: type,id,description[,password=true]")
@@ -617,11 +618,11 @@ def parse_inputs(inputs_list: Optional[list]) -> Optional[list]:
617618

618619
def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
619620
env: Optional[list] = None, url: Optional[str] = None,
620-
headers: Optional[list] = None, timeout: Optional[int] = None,
621+
header: Optional[list] = None, timeout: Optional[int] = None,
621622
trust: bool = False, cwd: Optional[str] = None,
622623
env_file: Optional[str] = None, http_url: Optional[str] = None,
623624
include_tools: Optional[list] = None, exclude_tools: Optional[list] = None,
624-
inputs: Optional[list] = None, no_backup: bool = False,
625+
input: Optional[list] = None, no_backup: bool = False,
625626
dry_run: bool = False, auto_approve: bool = False):
626627
"""Handle 'hatch mcp configure' command with ALL host-specific arguments.
627628
@@ -637,23 +638,41 @@ def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
637638
print(f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}")
638639
return 1
639640

641+
# Validate Claude Desktop/Code transport restrictions (Issue 2)
642+
if host_type in (MCPHostType.CLAUDE_DESKTOP, MCPHostType.CLAUDE_CODE):
643+
if url is not None:
644+
print(f"Error: {host} does not support remote servers (--url). Only local servers with --command are supported.")
645+
return 1
646+
640647
# Validate argument dependencies
641-
if command and headers:
642-
print("Error: --headers can only be used with --url (remote servers), not with --command (local servers)")
648+
if command and header:
649+
print("Error: --header can only be used with --url or --http-url (remote servers), not with --command (local servers)")
643650
return 1
644651

645-
if url and args:
646-
print("Error: --args can only be used with --command (local servers), not with --url (remote servers)")
652+
if (url or http_url) and args:
653+
print("Error: --args can only be used with --command (local servers), not with --url or --http-url (remote servers)")
647654
return 1
648655

649656
# NOTE: We do NOT validate host-specific arguments here.
650657
# The reporting system will show unsupported fields as "UNSUPPORTED" in the conversion report.
651658
# This allows users to see which fields are not supported by their target host without blocking the operation.
652659

660+
# Check if server exists (for partial update support)
661+
manager = MCPHostConfigurationManager()
662+
existing_config = manager.get_server_config(host, server_name)
663+
is_update = existing_config is not None
664+
665+
# Conditional validation: Create requires command OR url OR http_url, update does not
666+
if not is_update:
667+
# Create operation: require command, url, or http_url
668+
if not command and not url and not http_url:
669+
print(f"Error: When creating a new server, you must provide either --command (for local servers), --url (for SSE remote servers), or --http-url (for HTTP remote servers, Gemini only)")
670+
return 1
671+
653672
# Parse environment variables, headers, and inputs
654673
env_dict = parse_env_vars(env)
655-
headers_dict = parse_headers(headers)
656-
inputs_list = parse_inputs(inputs)
674+
headers_dict = parse_header(header)
675+
inputs_list = parse_input(input)
657676

658677
# Create Omni configuration (universal model)
659678
# Only include fields that have actual values to ensure model_dump(exclude_unset=True) works correctly
@@ -662,12 +681,24 @@ def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
662681
if command is not None:
663682
omni_config_data['command'] = command
664683
if args is not None:
665-
omni_config_data['args'] = args
684+
# Process args with shlex.split() to handle quoted strings (Issue 4)
685+
processed_args = []
686+
for arg in args:
687+
if arg: # Skip empty strings
688+
try:
689+
# Split quoted strings into individual arguments
690+
split_args = shlex.split(arg)
691+
processed_args.extend(split_args)
692+
except ValueError as e:
693+
# Handle invalid quotes gracefully
694+
print(f"Warning: Invalid quote in argument '{arg}': {e}")
695+
processed_args.append(arg)
696+
omni_config_data['args'] = processed_args if processed_args else None
666697
if env_dict:
667698
omni_config_data['env'] = env_dict
668699
if url is not None:
669700
omni_config_data['url'] = url
670-
if url and headers_dict:
701+
if headers_dict:
671702
omni_config_data['headers'] = headers_dict
672703

673704
# Host-specific fields (Gemini)
@@ -692,6 +723,29 @@ def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
692723
if inputs_list is not None:
693724
omni_config_data['inputs'] = inputs_list
694725

726+
# Partial update merge logic
727+
if is_update:
728+
# Merge with existing configuration
729+
existing_data = existing_config.model_dump(exclude_unset=True, exclude={'name'})
730+
731+
# Handle command/URL/httpUrl switching behavior
732+
# If switching from command to URL or httpUrl: clear command-based fields
733+
if (url is not None or http_url is not None) and existing_config.command is not None:
734+
existing_data.pop('command', None)
735+
existing_data.pop('args', None)
736+
existing_data.pop('type', None) # Clear type field when switching transports (Issue 1)
737+
738+
# If switching from URL/httpUrl to command: clear URL-based fields
739+
if command is not None and (existing_config.url is not None or getattr(existing_config, 'httpUrl', None) is not None):
740+
existing_data.pop('url', None)
741+
existing_data.pop('httpUrl', None)
742+
existing_data.pop('headers', None)
743+
existing_data.pop('type', None) # Clear type field when switching transports (Issue 1)
744+
745+
# Merge: new values override existing values
746+
merged_data = {**existing_data, **omni_config_data}
747+
omni_config_data = merged_data
748+
695749
# Create Omni model
696750
omni_config = MCPServerConfigOmni(**omni_config_data)
697751

@@ -706,10 +760,11 @@ def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
706760

707761
# Generate conversion report
708762
report = generate_conversion_report(
709-
operation='create',
763+
operation='update' if is_update else 'create',
710764
server_name=server_name,
711765
target_host=host_type,
712766
omni=omni_config,
767+
old_config=existing_config if is_update else None,
713768
dry_run=dry_run
714769
)
715770

@@ -1208,25 +1263,25 @@ def main():
12081263
# Create mutually exclusive group for server type
12091264
server_type_group = mcp_configure_parser.add_mutually_exclusive_group(required=True)
12101265
server_type_group.add_argument("--command", dest="server_command", help="Command to execute the MCP server (for local servers)")
1211-
server_type_group.add_argument("--url", help="Server URL for remote MCP servers")
1266+
server_type_group.add_argument("--url", help="Server URL for remote MCP servers (SSE transport)")
1267+
server_type_group.add_argument("--http-url", help="HTTP streaming endpoint URL (Gemini only)")
12121268

12131269
mcp_configure_parser.add_argument("--args", nargs="*", help="Arguments for the MCP server command (only with --command)")
12141270
mcp_configure_parser.add_argument("--env-var", action="append", help="Environment variables (format: KEY=VALUE)")
1215-
mcp_configure_parser.add_argument("--headers", action="append", help="HTTP headers for remote servers (format: KEY=VALUE, only with --url)")
1271+
mcp_configure_parser.add_argument("--header", action="append", help="HTTP headers for remote servers (format: KEY=VALUE, only with --url)")
12161272

12171273
# Host-specific arguments (Gemini)
12181274
mcp_configure_parser.add_argument("--timeout", type=int, help="Request timeout in milliseconds (Gemini)")
12191275
mcp_configure_parser.add_argument("--trust", action="store_true", help="Bypass tool call confirmations (Gemini)")
12201276
mcp_configure_parser.add_argument("--cwd", help="Working directory for stdio transport (Gemini)")
1221-
mcp_configure_parser.add_argument("--http-url", help="HTTP streaming endpoint URL (Gemini)")
12221277
mcp_configure_parser.add_argument("--include-tools", nargs="*", help="Tool allowlist - only these tools will be available (Gemini)")
12231278
mcp_configure_parser.add_argument("--exclude-tools", nargs="*", help="Tool blocklist - these tools will be excluded (Gemini)")
12241279

12251280
# Host-specific arguments (Cursor/VS Code/LM Studio)
12261281
mcp_configure_parser.add_argument("--env-file", help="Path to environment file (Cursor, VS Code, LM Studio)")
12271282

12281283
# Host-specific arguments (VS Code)
1229-
mcp_configure_parser.add_argument("--inputs", action="append", help="Input variable definitions in format: type,id,description[,password=true] (VS Code)")
1284+
mcp_configure_parser.add_argument("--input", action="append", help="Input variable definitions in format: type,id,description[,password=true] (VS Code)")
12301285

12311286
mcp_configure_parser.add_argument("--no-backup", action="store_true", help="Skip backup creation before configuration")
12321287
mcp_configure_parser.add_argument("--dry-run", action="store_true", help="Preview configuration without execution")
@@ -2022,11 +2077,11 @@ def main():
20222077
elif args.mcp_command == "configure":
20232078
return handle_mcp_configure(
20242079
args.host, args.server_name, args.server_command, args.args,
2025-
getattr(args, 'env_var', None), args.url, args.headers,
2080+
getattr(args, 'env_var', None), args.url, args.header,
20262081
getattr(args, 'timeout', None), getattr(args, 'trust', False),
20272082
getattr(args, 'cwd', None), getattr(args, 'env_file', None),
20282083
getattr(args, 'http_url', None), getattr(args, 'include_tools', None),
2029-
getattr(args, 'exclude_tools', None), getattr(args, 'inputs', None),
2084+
getattr(args, 'exclude_tools', None), getattr(args, 'input', None),
20302085
args.no_backup, args.dry_run, args.auto_approve
20312086
)
20322087

hatch/mcp_host_config/host_management.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,32 @@ def configure_server(self, server_config: MCPServerConfig,
180180
hostname=hostname,
181181
error_message=str(e)
182182
)
183-
184-
def remove_server(self, server_name: str, hostname: str,
183+
184+
def get_server_config(self, hostname: str, server_name: str) -> Optional[MCPServerConfig]:
185+
"""
186+
Get existing server configuration from host.
187+
188+
Args:
189+
hostname: The MCP host to query (e.g., 'claude-desktop', 'cursor')
190+
server_name: Name of the server to retrieve
191+
192+
Returns:
193+
MCPServerConfig if server exists, None otherwise
194+
"""
195+
try:
196+
host_type = MCPHostType(hostname)
197+
strategy = self.host_registry.get_strategy(host_type)
198+
current_config = strategy.read_configuration()
199+
200+
if server_name in current_config.servers:
201+
return current_config.servers[server_name]
202+
return None
203+
204+
except Exception as e:
205+
logger.debug(f"Failed to retrieve server config for {server_name} on {hostname}: {e}")
206+
return None
207+
208+
def remove_server(self, server_name: str, hostname: str,
185209
no_backup: bool = False) -> ConfigurationResult:
186210
"""Remove MCP server from specified host."""
187211
try:

hatch/mcp_host_config/models.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,14 @@ class MCPServerConfigBase(BaseModel):
360360

361361
@model_validator(mode='after')
362362
def validate_transport(self) -> 'MCPServerConfigBase':
363-
"""Validate transport configuration using type field."""
363+
"""Validate transport configuration using type field.
364+
365+
Note: Gemini subclass overrides this with dual-transport support.
366+
"""
367+
# Skip validation for Gemini which has its own dual-transport validator
368+
if self.__class__.__name__ == 'MCPServerConfigGemini':
369+
return self
370+
364371
# Check mutual exclusion - command and url cannot both be set
365372
if self.command is not None and self.url is not None:
366373
raise ValueError(
@@ -413,6 +420,45 @@ class MCPServerConfigGemini(MCPServerConfigBase):
413420
oauth_audiences: Optional[List[str]] = Field(None, description="OAuth audiences")
414421
authProviderType: Optional[str] = Field(None, description="Authentication provider type")
415422

423+
@model_validator(mode='after')
424+
def validate_gemini_dual_transport(self):
425+
"""Override transport validation to support Gemini's dual-transport capability.
426+
427+
Gemini supports both:
428+
- SSE transport with 'url' field
429+
- HTTP transport with 'httpUrl' field
430+
431+
Validates that:
432+
1. Either url or httpUrl is provided (not both)
433+
2. Type field matches the transport being used
434+
"""
435+
# Check if both url and httpUrl are provided
436+
if self.url is not None and self.httpUrl is not None:
437+
raise ValueError("Cannot specify both 'url' and 'httpUrl' - choose one transport")
438+
439+
# Validate based on type
440+
if self.type == "stdio":
441+
if not self.command:
442+
raise ValueError("'command' is required for stdio transport")
443+
elif self.type == "sse":
444+
if not self.url:
445+
raise ValueError("'url' is required for sse transport")
446+
elif self.type == "http":
447+
if not self.httpUrl:
448+
raise ValueError("'httpUrl' is required for http transport")
449+
elif self.type is None:
450+
# Infer type from fields if not specified
451+
if self.command:
452+
self.type = "stdio"
453+
elif self.url:
454+
self.type = "sse" # default to sse for url
455+
elif self.httpUrl:
456+
self.type = "http" # http for httpUrl
457+
else:
458+
raise ValueError("Either 'command', 'url', or 'httpUrl' must be provided")
459+
460+
return self
461+
416462
@classmethod
417463
def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigGemini':
418464
"""Convert Omni model to Gemini-specific model using Pydantic APIs."""

0 commit comments

Comments
 (0)