foundry-mcp 0.7.0__py3-none-any.whl → 0.8.10__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 (54) hide show
  1. foundry_mcp/cli/__init__.py +0 -13
  2. foundry_mcp/cli/commands/session.py +1 -8
  3. foundry_mcp/cli/context.py +39 -0
  4. foundry_mcp/config.py +381 -7
  5. foundry_mcp/core/batch_operations.py +1196 -0
  6. foundry_mcp/core/discovery.py +1 -1
  7. foundry_mcp/core/llm_config.py +8 -0
  8. foundry_mcp/core/naming.py +25 -2
  9. foundry_mcp/core/prometheus.py +0 -13
  10. foundry_mcp/core/providers/__init__.py +12 -0
  11. foundry_mcp/core/providers/base.py +39 -0
  12. foundry_mcp/core/providers/claude.py +45 -1
  13. foundry_mcp/core/providers/codex.py +64 -3
  14. foundry_mcp/core/providers/cursor_agent.py +22 -3
  15. foundry_mcp/core/providers/detectors.py +34 -7
  16. foundry_mcp/core/providers/gemini.py +63 -1
  17. foundry_mcp/core/providers/opencode.py +95 -71
  18. foundry_mcp/core/providers/package-lock.json +4 -4
  19. foundry_mcp/core/providers/package.json +1 -1
  20. foundry_mcp/core/providers/validation.py +128 -0
  21. foundry_mcp/core/research/memory.py +103 -0
  22. foundry_mcp/core/research/models.py +783 -0
  23. foundry_mcp/core/research/providers/__init__.py +40 -0
  24. foundry_mcp/core/research/providers/base.py +242 -0
  25. foundry_mcp/core/research/providers/google.py +507 -0
  26. foundry_mcp/core/research/providers/perplexity.py +442 -0
  27. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  28. foundry_mcp/core/research/providers/tavily.py +383 -0
  29. foundry_mcp/core/research/workflows/__init__.py +5 -2
  30. foundry_mcp/core/research/workflows/base.py +106 -12
  31. foundry_mcp/core/research/workflows/consensus.py +160 -17
  32. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  33. foundry_mcp/core/responses.py +240 -0
  34. foundry_mcp/core/spec.py +1 -0
  35. foundry_mcp/core/task.py +141 -12
  36. foundry_mcp/core/validation.py +6 -1
  37. foundry_mcp/server.py +0 -52
  38. foundry_mcp/tools/unified/__init__.py +37 -18
  39. foundry_mcp/tools/unified/authoring.py +0 -33
  40. foundry_mcp/tools/unified/environment.py +202 -29
  41. foundry_mcp/tools/unified/plan.py +20 -1
  42. foundry_mcp/tools/unified/provider.py +0 -40
  43. foundry_mcp/tools/unified/research.py +644 -19
  44. foundry_mcp/tools/unified/review.py +5 -2
  45. foundry_mcp/tools/unified/review_helpers.py +16 -1
  46. foundry_mcp/tools/unified/server.py +9 -24
  47. foundry_mcp/tools/unified/task.py +528 -9
  48. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +2 -1
  49. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/RECORD +52 -46
  50. foundry_mcp/cli/flags.py +0 -266
  51. foundry_mcp/core/feature_flags.py +0 -592
  52. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  53. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  54. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -5,13 +5,6 @@ All commands emit structured JSON to stdout for reliable parsing.
5
5
  """
6
6
 
7
7
  from foundry_mcp.cli.config import CLIContext, create_context
8
- from foundry_mcp.cli.flags import (
9
- CLIFlagRegistry,
10
- apply_cli_flag_overrides,
11
- flags_for_discovery,
12
- get_cli_flags,
13
- with_flag_options,
14
- )
15
8
  from foundry_mcp.cli.logging import (
16
9
  CLILogContext,
17
10
  cli_command,
@@ -51,12 +44,6 @@ __all__ = [
51
44
  "emit",
52
45
  "emit_error",
53
46
  "emit_success",
54
- # Feature flags
55
- "CLIFlagRegistry",
56
- "apply_cli_flag_overrides",
57
- "flags_for_discovery",
58
- "get_cli_flags",
59
- "with_flag_options",
60
47
  # Logging
61
48
  "CLILogContext",
62
49
  "cli_command",
@@ -183,12 +183,11 @@ def show_limits_cmd(ctx: click.Context) -> None:
183
183
  @handle_keyboard_interrupt()
184
184
  @with_sync_timeout(FAST_TIMEOUT, "Capabilities lookup timed out")
185
185
  def session_capabilities_cmd(ctx: click.Context) -> None:
186
- """Show CLI capabilities and feature flags.
186
+ """Show CLI capabilities.
187
187
 
188
188
  Returns a manifest of available features, commands, and their status
189
189
  for AI coding assistants to understand available functionality.
190
190
  """
191
- from foundry_mcp.cli.flags import flags_for_discovery, get_cli_flags
192
191
  from foundry_mcp.cli.main import cli
193
192
 
194
193
  cli_ctx = get_context(ctx)
@@ -204,15 +203,10 @@ def session_capabilities_cmd(ctx: click.Context) -> None:
204
203
  else:
205
204
  command_groups[name] = {"type": "command"}
206
205
 
207
- # Get feature flags
208
- get_cli_flags()
209
- flags = flags_for_discovery()
210
-
211
206
  # Known CLI capabilities
212
207
  capabilities = {
213
208
  "json_output": True, # All output is JSON
214
209
  "spec_driven": True, # SDD methodology supported
215
- "feature_flags": True, # Feature flag system available
216
210
  "session_tracking": True, # Session/context tracking
217
211
  "rate_limiting": True, # Rate limiting built-in
218
212
  }
@@ -222,7 +216,6 @@ def session_capabilities_cmd(ctx: click.Context) -> None:
222
216
  "version": "0.1.0",
223
217
  "name": "foundry-cli",
224
218
  "capabilities": capabilities,
225
- "feature_flags": flags,
226
219
  "command_groups": list(command_groups.keys()),
227
220
  "command_count": len(cli.commands),
228
221
  "specs_dir": str(cli_ctx.specs_dir) if cli_ctx.specs_dir else None,
@@ -29,6 +29,43 @@ class SessionStats:
29
29
  last_activity: Optional[str] = None
30
30
 
31
31
 
32
+ @dataclass
33
+ class AutonomousSession:
34
+ """
35
+ Ephemeral state for autonomous task execution mode.
36
+
37
+ This tracks whether the agent is running in autonomous mode where
38
+ it continues to the next task without explicit user confirmation.
39
+
40
+ NOTE: This state is EPHEMERAL - it exists only in memory and does
41
+ not persist across CLI restarts. Each new CLI session starts with
42
+ autonomous mode disabled.
43
+ """
44
+ enabled: bool = False
45
+ tasks_completed: int = 0
46
+ pause_reason: Optional[str] = None # Why auto-mode paused: "limit", "error", "user", "context"
47
+ started_at: Optional[str] = None # ISO timestamp when auto-mode was enabled
48
+
49
+ def to_dict(self) -> Dict[str, Any]:
50
+ """Convert to dictionary for JSON output."""
51
+ return {
52
+ "enabled": self.enabled,
53
+ "tasks_completed": self.tasks_completed,
54
+ "pause_reason": self.pause_reason,
55
+ "started_at": self.started_at,
56
+ }
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: Dict[str, Any]) -> "AutonomousSession":
60
+ """Create from dictionary (for in-session use only)."""
61
+ return cls(
62
+ enabled=data.get("enabled", False),
63
+ tasks_completed=data.get("tasks_completed", 0),
64
+ pause_reason=data.get("pause_reason"),
65
+ started_at=data.get("started_at"),
66
+ )
67
+
68
+
32
69
  @dataclass
33
70
  class ContextSession:
34
71
  """
@@ -44,6 +81,7 @@ class ContextSession:
44
81
  limits: SessionLimits = field(default_factory=SessionLimits)
45
82
  stats: SessionStats = field(default_factory=SessionStats)
46
83
  metadata: Dict[str, Any] = field(default_factory=dict)
84
+ autonomous: Optional[AutonomousSession] = None
47
85
 
48
86
  @property
49
87
  def consultations_remaining(self) -> int:
@@ -111,6 +149,7 @@ class ContextSession:
111
149
  "at_limit": self.at_limit,
112
150
  },
113
151
  "metadata": self.metadata,
152
+ "autonomous": self.autonomous.to_dict() if self.autonomous else None,
114
153
  }
115
154
 
116
155
 
foundry_mcp/config.py CHANGED
@@ -16,6 +16,13 @@ Environment variables:
16
16
  - FOUNDRY_MCP_REQUIRE_AUTH: Whether to require API key authentication (true/false)
17
17
  - FOUNDRY_MCP_CONFIG_FILE: Path to TOML config file
18
18
 
19
+ Search Provider API Keys (for deep research workflow):
20
+ - TAVILY_API_KEY: API key for Tavily web search (https://tavily.com/)
21
+ - PERPLEXITY_API_KEY: API key for Perplexity Search (https://docs.perplexity.ai/)
22
+ - GOOGLE_API_KEY: API key for Google Custom Search (https://console.cloud.google.com/)
23
+ - GOOGLE_CSE_ID: Google Custom Search Engine ID (https://cse.google.com/)
24
+ - SEMANTIC_SCHOLAR_API_KEY: API key for Semantic Scholar academic search (optional for basic tier)
25
+
19
26
  API Key Security:
20
27
  - Keys should be rotated regularly (recommended: every 90 days)
21
28
  - To revoke a key: remove it from FOUNDRY_MCP_API_KEYS and restart server
@@ -30,7 +37,7 @@ import time
30
37
  from dataclasses import dataclass, field
31
38
  from importlib.metadata import version as get_package_version, PackageNotFoundError
32
39
  from pathlib import Path
33
- from typing import Optional, List, Dict, Any, Callable, TypeVar
40
+ from typing import Optional, List, Dict, Any, Callable, TypeVar, Tuple
34
41
 
35
42
  try:
36
43
  import tomllib
@@ -59,7 +66,6 @@ class GitSettings:
59
66
  """Git workflow preferences for CLI + MCP surfaces."""
60
67
 
61
68
  enabled: bool = False
62
- auto_branch: bool = False
63
69
  auto_commit: bool = False
64
70
  auto_push: bool = False
65
71
  auto_pr: bool = False
@@ -433,7 +439,7 @@ class TestConfig:
433
439
 
434
440
  @dataclass
435
441
  class ResearchConfig:
436
- """Configuration for research workflows (CHAT, CONSENSUS, THINKDEEP, IDEATE).
442
+ """Configuration for research workflows (CHAT, CONSENSUS, THINKDEEP, IDEATE, DEEP_RESEARCH).
437
443
 
438
444
  Attributes:
439
445
  enabled: Master switch for research tools
@@ -445,6 +451,23 @@ class ResearchConfig:
445
451
  consensus_providers: List of provider IDs for CONSENSUS workflow
446
452
  thinkdeep_max_depth: Maximum investigation depth for THINKDEEP workflow
447
453
  ideate_perspectives: List of perspectives for IDEATE brainstorming
454
+ default_timeout: Default timeout in seconds for provider calls (thinkdeep uses 2x)
455
+ deep_research_max_iterations: Maximum refinement iterations for DEEP_RESEARCH
456
+ deep_research_max_sub_queries: Maximum sub-queries for query decomposition
457
+ deep_research_max_sources: Maximum sources per sub-query
458
+ deep_research_follow_links: Whether to follow and extract content from links
459
+ deep_research_timeout: Default timeout per operation in seconds
460
+ deep_research_max_concurrent: Maximum concurrent operations
461
+ deep_research_providers: Ordered list of search providers for deep research
462
+ deep_research_audit_artifacts: Whether to write per-run audit artifacts
463
+ search_rate_limit: Global rate limit for search APIs (requests per minute)
464
+ max_concurrent_searches: Maximum concurrent search requests (for asyncio.Semaphore)
465
+ per_provider_rate_limits: Per-provider rate limits in requests per minute
466
+ tavily_api_key: API key for Tavily search provider (optional, reads from TAVILY_API_KEY env var)
467
+ perplexity_api_key: API key for Perplexity Search (optional, reads from PERPLEXITY_API_KEY env var)
468
+ google_api_key: API key for Google Custom Search (optional, reads from GOOGLE_API_KEY env var)
469
+ google_cse_id: Google Custom Search Engine ID (optional, reads from GOOGLE_CSE_ID env var)
470
+ semantic_scholar_api_key: API key for Semantic Scholar (optional, reads from SEMANTIC_SCHOLAR_API_KEY env var)
448
471
  """
449
472
 
450
473
  enabled: bool = True
@@ -460,6 +483,47 @@ class ResearchConfig:
460
483
  ideate_perspectives: List[str] = field(
461
484
  default_factory=lambda: ["technical", "creative", "practical", "visionary"]
462
485
  )
486
+ default_timeout: float = 60.0 # 60 seconds default, configurable
487
+ # Deep research configuration
488
+ deep_research_max_iterations: int = 3
489
+ deep_research_max_sub_queries: int = 5
490
+ deep_research_max_sources: int = 5
491
+ deep_research_follow_links: bool = True
492
+ deep_research_timeout: float = 120.0
493
+ deep_research_max_concurrent: int = 3
494
+ # Per-phase timeout overrides (seconds) - uses deep_research_timeout if not set
495
+ deep_research_planning_timeout: float = 60.0
496
+ deep_research_analysis_timeout: float = 90.0
497
+ deep_research_synthesis_timeout: float = 180.0
498
+ deep_research_refinement_timeout: float = 60.0
499
+ # Per-phase provider overrides - uses default_provider if not set
500
+ deep_research_planning_provider: Optional[str] = None
501
+ deep_research_analysis_provider: Optional[str] = None
502
+ deep_research_synthesis_provider: Optional[str] = None
503
+ deep_research_refinement_provider: Optional[str] = None
504
+ deep_research_providers: List[str] = field(
505
+ default_factory=lambda: ["tavily", "google", "semantic_scholar"]
506
+ )
507
+ deep_research_audit_artifacts: bool = True
508
+ # Research mode: "general" | "academic" | "technical"
509
+ deep_research_mode: str = "general"
510
+ # Search rate limiting configuration
511
+ search_rate_limit: int = 60 # requests per minute (global default)
512
+ max_concurrent_searches: int = 3 # for asyncio.Semaphore in gathering phase
513
+ per_provider_rate_limits: Dict[str, int] = field(
514
+ default_factory=lambda: {
515
+ "tavily": 60, # Tavily free tier: ~1 req/sec
516
+ "perplexity": 60, # Perplexity: ~1 req/sec (pricing: $5/1k requests)
517
+ "google": 100, # Google CSE: 100 queries/day free, ~100/min paid
518
+ "semantic_scholar": 100, # Semantic Scholar: 100 req/5min unauthenticated
519
+ }
520
+ )
521
+ # Search provider API keys (all optional, read from env vars if not set)
522
+ tavily_api_key: Optional[str] = None
523
+ perplexity_api_key: Optional[str] = None
524
+ google_api_key: Optional[str] = None
525
+ google_cse_id: Optional[str] = None
526
+ semantic_scholar_api_key: Optional[str] = None
463
527
 
464
528
  @classmethod
465
529
  def from_toml_dict(cls, data: Dict[str, Any]) -> "ResearchConfig":
@@ -483,6 +547,28 @@ class ResearchConfig:
483
547
  if isinstance(ideate_perspectives, str):
484
548
  ideate_perspectives = [p.strip() for p in ideate_perspectives.split(",")]
485
549
 
550
+ # Parse deep_research_providers - handle both string and list
551
+ deep_research_providers = data.get(
552
+ "deep_research_providers", ["tavily", "google", "semantic_scholar"]
553
+ )
554
+ if isinstance(deep_research_providers, str):
555
+ deep_research_providers = [
556
+ p.strip() for p in deep_research_providers.split(",") if p.strip()
557
+ ]
558
+
559
+ # Parse per_provider_rate_limits - handle dict from TOML
560
+ per_provider_rate_limits = data.get("per_provider_rate_limits", {
561
+ "tavily": 60,
562
+ "perplexity": 60,
563
+ "google": 100,
564
+ "semantic_scholar": 100,
565
+ })
566
+ if isinstance(per_provider_rate_limits, dict):
567
+ # Convert values to int
568
+ per_provider_rate_limits = {
569
+ k: int(v) for k, v in per_provider_rate_limits.items()
570
+ }
571
+
486
572
  return cls(
487
573
  enabled=_parse_bool(data.get("enabled", True)),
488
574
  storage_path=str(data.get("storage_path", "")),
@@ -493,6 +579,40 @@ class ResearchConfig:
493
579
  consensus_providers=consensus_providers,
494
580
  thinkdeep_max_depth=int(data.get("thinkdeep_max_depth", 5)),
495
581
  ideate_perspectives=ideate_perspectives,
582
+ default_timeout=float(data.get("default_timeout", 60.0)),
583
+ # Deep research configuration
584
+ deep_research_max_iterations=int(data.get("deep_research_max_iterations", 3)),
585
+ deep_research_max_sub_queries=int(data.get("deep_research_max_sub_queries", 5)),
586
+ deep_research_max_sources=int(data.get("deep_research_max_sources", 5)),
587
+ deep_research_follow_links=_parse_bool(data.get("deep_research_follow_links", True)),
588
+ deep_research_timeout=float(data.get("deep_research_timeout", 120.0)),
589
+ deep_research_max_concurrent=int(data.get("deep_research_max_concurrent", 3)),
590
+ # Per-phase timeout overrides
591
+ deep_research_planning_timeout=float(data.get("deep_research_planning_timeout", 60.0)),
592
+ deep_research_analysis_timeout=float(data.get("deep_research_analysis_timeout", 90.0)),
593
+ deep_research_synthesis_timeout=float(data.get("deep_research_synthesis_timeout", 180.0)),
594
+ deep_research_refinement_timeout=float(data.get("deep_research_refinement_timeout", 60.0)),
595
+ # Per-phase provider overrides
596
+ deep_research_planning_provider=data.get("deep_research_planning_provider"),
597
+ deep_research_analysis_provider=data.get("deep_research_analysis_provider"),
598
+ deep_research_synthesis_provider=data.get("deep_research_synthesis_provider"),
599
+ deep_research_refinement_provider=data.get("deep_research_refinement_provider"),
600
+ deep_research_providers=deep_research_providers,
601
+ deep_research_audit_artifacts=_parse_bool(
602
+ data.get("deep_research_audit_artifacts", True)
603
+ ),
604
+ # Research mode
605
+ deep_research_mode=str(data.get("deep_research_mode", "general")),
606
+ # Search rate limiting configuration
607
+ search_rate_limit=int(data.get("search_rate_limit", 60)),
608
+ max_concurrent_searches=int(data.get("max_concurrent_searches", 3)),
609
+ per_provider_rate_limits=per_provider_rate_limits,
610
+ # Search provider API keys (None means not set in TOML, will check env vars)
611
+ tavily_api_key=data.get("tavily_api_key"),
612
+ perplexity_api_key=data.get("perplexity_api_key"),
613
+ google_api_key=data.get("google_api_key"),
614
+ google_cse_id=data.get("google_cse_id"),
615
+ semantic_scholar_api_key=data.get("semantic_scholar_api_key"),
496
616
  )
497
617
 
498
618
  def get_storage_path(self) -> Path:
@@ -505,6 +625,196 @@ class ResearchConfig:
505
625
  return Path(self.storage_path).expanduser()
506
626
  return Path.home() / ".foundry-mcp" / "research"
507
627
 
628
+ def get_provider_rate_limit(self, provider: str) -> int:
629
+ """Get rate limit for a specific provider.
630
+
631
+ Returns the provider-specific rate limit if configured,
632
+ otherwise falls back to the global search_rate_limit.
633
+
634
+ Args:
635
+ provider: Provider name (e.g., "tavily", "google", "semantic_scholar")
636
+
637
+ Returns:
638
+ Rate limit in requests per minute
639
+ """
640
+ return self.per_provider_rate_limits.get(provider, self.search_rate_limit)
641
+
642
+ def get_phase_timeout(self, phase: str) -> float:
643
+ """Get timeout for a specific deep research phase.
644
+
645
+ Returns the phase-specific timeout if configured, otherwise
646
+ falls back to deep_research_timeout.
647
+
648
+ Args:
649
+ phase: Phase name ("planning", "analysis", "synthesis", "refinement", "gathering")
650
+
651
+ Returns:
652
+ Timeout in seconds for the phase
653
+ """
654
+ phase_timeouts = {
655
+ "planning": self.deep_research_planning_timeout,
656
+ "analysis": self.deep_research_analysis_timeout,
657
+ "synthesis": self.deep_research_synthesis_timeout,
658
+ "refinement": self.deep_research_refinement_timeout,
659
+ "gathering": self.deep_research_timeout, # Gathering uses default
660
+ }
661
+ return phase_timeouts.get(phase.lower(), self.deep_research_timeout)
662
+
663
+ def get_phase_provider(self, phase: str) -> str:
664
+ """Get LLM provider ID for a specific deep research phase.
665
+
666
+ Returns the phase-specific provider if configured, otherwise
667
+ falls back to default_provider. Supports both simple names ("gemini")
668
+ and ProviderSpec format ("[cli]gemini:pro").
669
+
670
+ Args:
671
+ phase: Phase name ("planning", "analysis", "synthesis", "refinement")
672
+
673
+ Returns:
674
+ Provider ID for the phase (e.g., "gemini", "opencode")
675
+ """
676
+ provider_id, _ = self.resolve_phase_provider(phase)
677
+ return provider_id
678
+
679
+ def resolve_phase_provider(self, phase: str) -> Tuple[str, Optional[str]]:
680
+ """Resolve provider ID and model for a deep research phase.
681
+
682
+ Parses ProviderSpec format ("[cli]gemini:pro") or simple names ("gemini").
683
+ Returns (provider_id, model) tuple for use with the provider registry.
684
+
685
+ Args:
686
+ phase: Phase name ("planning", "analysis", "synthesis", "refinement")
687
+
688
+ Returns:
689
+ Tuple of (provider_id, model) where model may be None
690
+ """
691
+ phase_providers = {
692
+ "planning": self.deep_research_planning_provider,
693
+ "analysis": self.deep_research_analysis_provider,
694
+ "synthesis": self.deep_research_synthesis_provider,
695
+ "refinement": self.deep_research_refinement_provider,
696
+ }
697
+ spec_str = phase_providers.get(phase.lower()) or self.default_provider
698
+ return _parse_provider_spec(spec_str)
699
+
700
+ def get_search_provider_api_key(
701
+ self,
702
+ provider: str,
703
+ required: bool = True,
704
+ ) -> Optional[str]:
705
+ """Get API key for a search provider with fallback to environment variables.
706
+
707
+ Checks config value first, then falls back to environment variable.
708
+ Raises ValueError with clear error message if required and not found.
709
+
710
+ Args:
711
+ provider: Provider name ("tavily", "google", "semantic_scholar")
712
+ required: If True, raises ValueError when key is missing (default: True)
713
+
714
+ Returns:
715
+ API key string, or None if not required and not found
716
+
717
+ Raises:
718
+ ValueError: If required=True and no API key is found
719
+
720
+ Example:
721
+ # Get Tavily API key (will raise if missing)
722
+ api_key = config.research.get_search_provider_api_key("tavily")
723
+
724
+ # Get Semantic Scholar API key (optional, returns None if missing)
725
+ api_key = config.research.get_search_provider_api_key(
726
+ "semantic_scholar", required=False
727
+ )
728
+ """
729
+ # Map provider names to config attributes and env vars
730
+ provider_config = {
731
+ "tavily": {
732
+ "config_key": "tavily_api_key",
733
+ "env_var": "TAVILY_API_KEY",
734
+ "setup_url": "https://tavily.com/",
735
+ },
736
+ "perplexity": {
737
+ "config_key": "perplexity_api_key",
738
+ "env_var": "PERPLEXITY_API_KEY",
739
+ "setup_url": "https://docs.perplexity.ai/",
740
+ },
741
+ "google": {
742
+ "config_key": "google_api_key",
743
+ "env_var": "GOOGLE_API_KEY",
744
+ "setup_url": "https://console.cloud.google.com/apis/credentials",
745
+ },
746
+ "google_cse": {
747
+ "config_key": "google_cse_id",
748
+ "env_var": "GOOGLE_CSE_ID",
749
+ "setup_url": "https://cse.google.com/",
750
+ },
751
+ "semantic_scholar": {
752
+ "config_key": "semantic_scholar_api_key",
753
+ "env_var": "SEMANTIC_SCHOLAR_API_KEY",
754
+ "setup_url": "https://www.semanticscholar.org/product/api",
755
+ },
756
+ }
757
+
758
+ provider_lower = provider.lower()
759
+ if provider_lower not in provider_config:
760
+ raise ValueError(
761
+ f"Unknown search provider: '{provider}'. "
762
+ f"Valid providers: {', '.join(provider_config.keys())}"
763
+ )
764
+
765
+ config_info = provider_config[provider_lower]
766
+ config_key = config_info["config_key"]
767
+ env_var = config_info["env_var"]
768
+
769
+ # Check config value first
770
+ api_key = getattr(self, config_key, None)
771
+
772
+ # Fall back to environment variable
773
+ if not api_key:
774
+ api_key = os.environ.get(env_var)
775
+
776
+ # Handle missing key
777
+ if not api_key:
778
+ if required:
779
+ raise ValueError(
780
+ f"{provider.title()} API key not configured. "
781
+ f"Set via {env_var} environment variable or "
782
+ f"'research.{config_key}' in foundry-mcp.toml. "
783
+ f"Get an API key at: {config_info['setup_url']}"
784
+ )
785
+ return None
786
+
787
+ return api_key
788
+
789
+ def get_google_credentials(self, required: bool = True) -> tuple[Optional[str], Optional[str]]:
790
+ """Get both Google API key and CSE ID for Google Custom Search.
791
+
792
+ Convenience method that retrieves both required credentials for
793
+ Google Custom Search API.
794
+
795
+ Args:
796
+ required: If True, raises ValueError when either credential is missing
797
+
798
+ Returns:
799
+ Tuple of (api_key, cse_id)
800
+
801
+ Raises:
802
+ ValueError: If required=True and either credential is missing
803
+ """
804
+ api_key = self.get_search_provider_api_key("google", required=required)
805
+ cse_id = self.get_search_provider_api_key("google_cse", required=required)
806
+ return api_key, cse_id
807
+
808
+ def get_default_provider_spec(self) -> "ProviderSpec":
809
+ """Parse default_provider into a ProviderSpec."""
810
+ from foundry_mcp.core.llm_config import ProviderSpec
811
+ return ProviderSpec.parse_flexible(self.default_provider)
812
+
813
+ def get_consensus_provider_specs(self) -> List["ProviderSpec"]:
814
+ """Parse consensus_providers into ProviderSpec list."""
815
+ from foundry_mcp.core.llm_config import ProviderSpec
816
+ return [ProviderSpec.parse_flexible(p) for p in self.consensus_providers]
817
+
508
818
 
509
819
  _VALID_COMMIT_CADENCE = {"manual", "task", "phase"}
510
820
 
@@ -521,6 +831,45 @@ def _normalize_commit_cadence(value: str) -> str:
521
831
  return normalized
522
832
 
523
833
 
834
+ def _parse_provider_spec(spec: str) -> Tuple[str, Optional[str]]:
835
+ """Parse a provider specification into (provider_id, model).
836
+
837
+ Supports both simple names and ProviderSpec bracket notation:
838
+ - "gemini" -> ("gemini", None)
839
+ - "[cli]gemini:pro" -> ("gemini", "pro")
840
+ - "[cli]opencode:openai/gpt-5.2" -> ("opencode", "openai/gpt-5.2")
841
+ - "[api]openai/gpt-4.1" -> ("openai", "gpt-4.1")
842
+
843
+ Args:
844
+ spec: Provider specification string
845
+
846
+ Returns:
847
+ Tuple of (provider_id, model) where model may be None
848
+ """
849
+ spec = spec.strip()
850
+
851
+ # Simple name (no brackets) - backward compatible
852
+ if not spec.startswith("["):
853
+ return (spec, None)
854
+
855
+ # Try to parse with ProviderSpec
856
+ try:
857
+ from foundry_mcp.core.llm_config import ProviderSpec
858
+
859
+ parsed = ProviderSpec.parse(spec)
860
+ # Build model string with backend routing if present
861
+ model = None
862
+ if parsed.backend and parsed.model:
863
+ model = f"{parsed.backend}/{parsed.model}"
864
+ elif parsed.model:
865
+ model = parsed.model
866
+ return (parsed.provider, model)
867
+ except (ValueError, ImportError) as e:
868
+ logger.warning("Failed to parse provider spec '%s': %s", spec, e)
869
+ # Fall back to treating as simple name (strip brackets)
870
+ return (spec.split("]")[-1].split(":")[0], None)
871
+
872
+
524
873
  def _parse_bool(value: Any) -> bool:
525
874
  if isinstance(value, bool):
526
875
  return value
@@ -573,6 +922,9 @@ class ServerConfig:
573
922
  # Research workflows configuration
574
923
  research: ResearchConfig = field(default_factory=ResearchConfig)
575
924
 
925
+ # Tool registration control
926
+ disabled_tools: List[str] = field(default_factory=list)
927
+
576
928
  @classmethod
577
929
  def from_env(cls, config_file: Optional[str] = None) -> "ServerConfig":
578
930
  """
@@ -646,14 +998,21 @@ class ServerConfig:
646
998
  self.server_name = srv["name"]
647
999
  if "version" in srv:
648
1000
  self.server_version = srv["version"]
1001
+ # Legacy: disabled_tools under [server] (deprecated)
1002
+ if "disabled_tools" in srv:
1003
+ self.disabled_tools = srv["disabled_tools"]
1004
+
1005
+ # Tools configuration (preferred location for disabled_tools)
1006
+ if "tools" in data:
1007
+ tools_cfg = data["tools"]
1008
+ if "disabled_tools" in tools_cfg:
1009
+ self.disabled_tools = tools_cfg["disabled_tools"]
649
1010
 
650
1011
  # Git workflow settings
651
1012
  if "git" in data:
652
1013
  git_cfg = data["git"]
653
1014
  if "enabled" in git_cfg:
654
1015
  self.git.enabled = _parse_bool(git_cfg["enabled"])
655
- if "auto_branch" in git_cfg:
656
- self.git.auto_branch = _parse_bool(git_cfg["auto_branch"])
657
1016
  if "auto_commit" in git_cfg:
658
1017
  self.git.auto_commit = _parse_bool(git_cfg["auto_commit"])
659
1018
  if "auto_push" in git_cfg:
@@ -739,8 +1098,6 @@ class ServerConfig:
739
1098
  # Git settings
740
1099
  if git_enabled := os.environ.get("FOUNDRY_MCP_GIT_ENABLED"):
741
1100
  self.git.enabled = _parse_bool(git_enabled)
742
- if git_auto_branch := os.environ.get("FOUNDRY_MCP_GIT_AUTO_BRANCH"):
743
- self.git.auto_branch = _parse_bool(git_auto_branch)
744
1101
  if git_auto_commit := os.environ.get("FOUNDRY_MCP_GIT_AUTO_COMMIT"):
745
1102
  self.git.auto_commit = _parse_bool(git_auto_commit)
746
1103
  if git_auto_push := os.environ.get("FOUNDRY_MCP_GIT_AUTO_PUSH"):
@@ -879,6 +1236,23 @@ class ServerConfig:
879
1236
  except ValueError:
880
1237
  pass
881
1238
 
1239
+ # Search provider API keys (direct env vars, no FOUNDRY_MCP_ prefix)
1240
+ # These use standard env var names that match provider documentation
1241
+ if tavily_key := os.environ.get("TAVILY_API_KEY"):
1242
+ self.research.tavily_api_key = tavily_key
1243
+ if perplexity_key := os.environ.get("PERPLEXITY_API_KEY"):
1244
+ self.research.perplexity_api_key = perplexity_key
1245
+ if google_key := os.environ.get("GOOGLE_API_KEY"):
1246
+ self.research.google_api_key = google_key
1247
+ if google_cse := os.environ.get("GOOGLE_CSE_ID"):
1248
+ self.research.google_cse_id = google_cse
1249
+ if semantic_scholar_key := os.environ.get("SEMANTIC_SCHOLAR_API_KEY"):
1250
+ self.research.semantic_scholar_api_key = semantic_scholar_key
1251
+
1252
+ # Disabled tools (comma-separated list)
1253
+ if disabled := os.environ.get("FOUNDRY_MCP_DISABLED_TOOLS"):
1254
+ self.disabled_tools = [t.strip() for t in disabled.split(",") if t.strip()]
1255
+
882
1256
  def validate_api_key(self, key: Optional[str]) -> bool:
883
1257
  """
884
1258
  Validate an API key.