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.
- foundry_mcp/cli/__init__.py +0 -13
- foundry_mcp/cli/commands/session.py +1 -8
- foundry_mcp/cli/context.py +39 -0
- foundry_mcp/config.py +381 -7
- foundry_mcp/core/batch_operations.py +1196 -0
- foundry_mcp/core/discovery.py +1 -1
- foundry_mcp/core/llm_config.py +8 -0
- foundry_mcp/core/naming.py +25 -2
- foundry_mcp/core/prometheus.py +0 -13
- foundry_mcp/core/providers/__init__.py +12 -0
- foundry_mcp/core/providers/base.py +39 -0
- foundry_mcp/core/providers/claude.py +45 -1
- foundry_mcp/core/providers/codex.py +64 -3
- foundry_mcp/core/providers/cursor_agent.py +22 -3
- foundry_mcp/core/providers/detectors.py +34 -7
- foundry_mcp/core/providers/gemini.py +63 -1
- foundry_mcp/core/providers/opencode.py +95 -71
- foundry_mcp/core/providers/package-lock.json +4 -4
- foundry_mcp/core/providers/package.json +1 -1
- foundry_mcp/core/providers/validation.py +128 -0
- foundry_mcp/core/research/memory.py +103 -0
- foundry_mcp/core/research/models.py +783 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +5 -2
- foundry_mcp/core/research/workflows/base.py +106 -12
- foundry_mcp/core/research/workflows/consensus.py +160 -17
- foundry_mcp/core/research/workflows/deep_research.py +4020 -0
- foundry_mcp/core/responses.py +240 -0
- foundry_mcp/core/spec.py +1 -0
- foundry_mcp/core/task.py +141 -12
- foundry_mcp/core/validation.py +6 -1
- foundry_mcp/server.py +0 -52
- foundry_mcp/tools/unified/__init__.py +37 -18
- foundry_mcp/tools/unified/authoring.py +0 -33
- foundry_mcp/tools/unified/environment.py +202 -29
- foundry_mcp/tools/unified/plan.py +20 -1
- foundry_mcp/tools/unified/provider.py +0 -40
- foundry_mcp/tools/unified/research.py +644 -19
- foundry_mcp/tools/unified/review.py +5 -2
- foundry_mcp/tools/unified/review_helpers.py +16 -1
- foundry_mcp/tools/unified/server.py +9 -24
- foundry_mcp/tools/unified/task.py +528 -9
- {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +2 -1
- {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/RECORD +52 -46
- foundry_mcp/cli/flags.py +0 -266
- foundry_mcp/core/feature_flags.py +0 -592
- {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
- {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
- {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
foundry_mcp/cli/__init__.py
CHANGED
|
@@ -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
|
|
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,
|
foundry_mcp/cli/context.py
CHANGED
|
@@ -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.
|