foundry-mcp 0.3.3__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 (85) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/__init__.py +0 -13
  3. foundry_mcp/cli/commands/plan.py +10 -3
  4. foundry_mcp/cli/commands/review.py +19 -4
  5. foundry_mcp/cli/commands/session.py +1 -8
  6. foundry_mcp/cli/commands/specs.py +38 -208
  7. foundry_mcp/cli/context.py +39 -0
  8. foundry_mcp/cli/output.py +3 -3
  9. foundry_mcp/config.py +615 -11
  10. foundry_mcp/core/ai_consultation.py +146 -9
  11. foundry_mcp/core/batch_operations.py +1196 -0
  12. foundry_mcp/core/discovery.py +7 -7
  13. foundry_mcp/core/error_store.py +2 -2
  14. foundry_mcp/core/intake.py +933 -0
  15. foundry_mcp/core/llm_config.py +28 -2
  16. foundry_mcp/core/metrics_store.py +2 -2
  17. foundry_mcp/core/naming.py +25 -2
  18. foundry_mcp/core/progress.py +70 -0
  19. foundry_mcp/core/prometheus.py +0 -13
  20. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  21. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  22. foundry_mcp/core/prompts/plan_review.py +5 -1
  23. foundry_mcp/core/providers/__init__.py +12 -0
  24. foundry_mcp/core/providers/base.py +39 -0
  25. foundry_mcp/core/providers/claude.py +51 -48
  26. foundry_mcp/core/providers/codex.py +70 -60
  27. foundry_mcp/core/providers/cursor_agent.py +25 -47
  28. foundry_mcp/core/providers/detectors.py +34 -7
  29. foundry_mcp/core/providers/gemini.py +69 -58
  30. foundry_mcp/core/providers/opencode.py +101 -47
  31. foundry_mcp/core/providers/package-lock.json +4 -4
  32. foundry_mcp/core/providers/package.json +1 -1
  33. foundry_mcp/core/providers/validation.py +128 -0
  34. foundry_mcp/core/research/__init__.py +68 -0
  35. foundry_mcp/core/research/memory.py +528 -0
  36. foundry_mcp/core/research/models.py +1220 -0
  37. foundry_mcp/core/research/providers/__init__.py +40 -0
  38. foundry_mcp/core/research/providers/base.py +242 -0
  39. foundry_mcp/core/research/providers/google.py +507 -0
  40. foundry_mcp/core/research/providers/perplexity.py +442 -0
  41. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  42. foundry_mcp/core/research/providers/tavily.py +383 -0
  43. foundry_mcp/core/research/workflows/__init__.py +25 -0
  44. foundry_mcp/core/research/workflows/base.py +298 -0
  45. foundry_mcp/core/research/workflows/chat.py +271 -0
  46. foundry_mcp/core/research/workflows/consensus.py +539 -0
  47. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  48. foundry_mcp/core/research/workflows/ideate.py +682 -0
  49. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  50. foundry_mcp/core/responses.py +690 -0
  51. foundry_mcp/core/spec.py +2439 -236
  52. foundry_mcp/core/task.py +1205 -31
  53. foundry_mcp/core/testing.py +512 -123
  54. foundry_mcp/core/validation.py +319 -43
  55. foundry_mcp/dashboard/components/charts.py +0 -57
  56. foundry_mcp/dashboard/launcher.py +11 -0
  57. foundry_mcp/dashboard/views/metrics.py +25 -35
  58. foundry_mcp/dashboard/views/overview.py +1 -65
  59. foundry_mcp/resources/specs.py +25 -25
  60. foundry_mcp/schemas/intake-schema.json +89 -0
  61. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  62. foundry_mcp/server.py +0 -14
  63. foundry_mcp/tools/unified/__init__.py +39 -18
  64. foundry_mcp/tools/unified/authoring.py +2371 -248
  65. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  66. foundry_mcp/tools/unified/environment.py +434 -32
  67. foundry_mcp/tools/unified/error.py +18 -1
  68. foundry_mcp/tools/unified/lifecycle.py +8 -0
  69. foundry_mcp/tools/unified/plan.py +133 -2
  70. foundry_mcp/tools/unified/provider.py +0 -40
  71. foundry_mcp/tools/unified/research.py +1283 -0
  72. foundry_mcp/tools/unified/review.py +374 -17
  73. foundry_mcp/tools/unified/review_helpers.py +16 -1
  74. foundry_mcp/tools/unified/server.py +9 -24
  75. foundry_mcp/tools/unified/spec.py +367 -0
  76. foundry_mcp/tools/unified/task.py +1664 -30
  77. foundry_mcp/tools/unified/test.py +69 -8
  78. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
  79. foundry_mcp-0.8.10.dist-info/RECORD +153 -0
  80. foundry_mcp/cli/flags.py +0 -266
  81. foundry_mcp/core/feature_flags.py +0 -592
  82. foundry_mcp-0.3.3.dist-info/RECORD +0 -135
  83. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  84. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  85. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,442 @@
1
+ """Perplexity Search API provider for web search.
2
+
3
+ This module implements PerplexitySearchProvider, which wraps the Perplexity Search API
4
+ to provide web search capabilities for the deep research workflow.
5
+
6
+ Perplexity Search API documentation: https://docs.perplexity.ai/api-reference/search-post
7
+
8
+ Example usage:
9
+ provider = PerplexitySearchProvider(api_key="pplx-...")
10
+ sources = await provider.search("machine learning trends", max_results=5)
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ from datetime import datetime
17
+ from typing import Any, Optional
18
+
19
+ import httpx
20
+
21
+ from foundry_mcp.core.research.models import ResearchSource, SourceType
22
+ from foundry_mcp.core.research.providers.base import (
23
+ AuthenticationError,
24
+ RateLimitError,
25
+ SearchProvider,
26
+ SearchProviderError,
27
+ SearchResult,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Perplexity API constants
33
+ PERPLEXITY_API_BASE_URL = "https://api.perplexity.ai"
34
+ PERPLEXITY_SEARCH_ENDPOINT = "/search"
35
+ DEFAULT_TIMEOUT = 30.0
36
+ DEFAULT_MAX_RETRIES = 3
37
+ DEFAULT_RATE_LIMIT = 1.0 # requests per second
38
+
39
+
40
+ class PerplexitySearchProvider(SearchProvider):
41
+ """Perplexity Search API provider for web search.
42
+
43
+ Wraps the Perplexity Search API to provide web search capabilities.
44
+ Supports domain filtering, recency filtering, and geographic targeting.
45
+
46
+ Pricing: $5 per 1,000 requests
47
+
48
+ Attributes:
49
+ api_key: Perplexity API key (required)
50
+ base_url: API base URL (default: https://api.perplexity.ai)
51
+ timeout: Request timeout in seconds (default: 30.0)
52
+ max_retries: Maximum retry attempts for rate limits (default: 3)
53
+
54
+ Example:
55
+ provider = PerplexitySearchProvider(api_key="pplx-...")
56
+ sources = await provider.search(
57
+ "AI trends 2024",
58
+ max_results=10,
59
+ recency_filter="week",
60
+ )
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ api_key: Optional[str] = None,
66
+ base_url: str = PERPLEXITY_API_BASE_URL,
67
+ timeout: float = DEFAULT_TIMEOUT,
68
+ max_retries: int = DEFAULT_MAX_RETRIES,
69
+ ):
70
+ """Initialize Perplexity search provider.
71
+
72
+ Args:
73
+ api_key: Perplexity API key. If not provided, reads from PERPLEXITY_API_KEY env var.
74
+ base_url: API base URL (default: https://api.perplexity.ai)
75
+ timeout: Request timeout in seconds (default: 30.0)
76
+ max_retries: Maximum retry attempts for rate limits (default: 3)
77
+
78
+ Raises:
79
+ ValueError: If no API key is provided or found in environment
80
+ """
81
+ self._api_key = api_key or os.environ.get("PERPLEXITY_API_KEY")
82
+ if not self._api_key:
83
+ raise ValueError(
84
+ "Perplexity API key required. Provide via api_key parameter "
85
+ "or PERPLEXITY_API_KEY environment variable."
86
+ )
87
+
88
+ self._base_url = base_url.rstrip("/")
89
+ self._timeout = timeout
90
+ self._max_retries = max_retries
91
+ self._rate_limit_value = DEFAULT_RATE_LIMIT
92
+
93
+ def get_provider_name(self) -> str:
94
+ """Return the provider identifier.
95
+
96
+ Returns:
97
+ "perplexity"
98
+ """
99
+ return "perplexity"
100
+
101
+ @property
102
+ def rate_limit(self) -> Optional[float]:
103
+ """Return the rate limit in requests per second.
104
+
105
+ Returns:
106
+ 1.0 (one request per second)
107
+ """
108
+ return self._rate_limit_value
109
+
110
+ async def search(
111
+ self,
112
+ query: str,
113
+ max_results: int = 10,
114
+ **kwargs: Any,
115
+ ) -> list[ResearchSource]:
116
+ """Execute a web search via Perplexity Search API.
117
+
118
+ Args:
119
+ query: The search query string
120
+ max_results: Maximum number of results to return (default: 10, max: 20)
121
+ **kwargs: Additional Perplexity options:
122
+ - recency_filter: Time filter ('day', 'week', 'month', 'year')
123
+ - domain_filter: List of domains to include (max 20)
124
+ - country: Geographic filter ('US', 'GB', etc.)
125
+ - sub_query_id: SubQuery ID for source tracking
126
+
127
+ Returns:
128
+ List of ResearchSource objects
129
+
130
+ Raises:
131
+ AuthenticationError: If API key is invalid
132
+ RateLimitError: If rate limit exceeded after all retries
133
+ SearchProviderError: For other API errors
134
+ """
135
+ # Extract Perplexity-specific options
136
+ recency_filter = kwargs.get("recency_filter")
137
+ domain_filter = kwargs.get("domain_filter", [])
138
+ country = kwargs.get("country")
139
+ sub_query_id = kwargs.get("sub_query_id")
140
+
141
+ # Clamp max_results to Perplexity's limit (1-20)
142
+ max_results = max(1, min(max_results, 20))
143
+
144
+ # Build request payload
145
+ payload: dict[str, Any] = {
146
+ "query": query,
147
+ "max_results": max_results,
148
+ }
149
+
150
+ if recency_filter and recency_filter in ("day", "week", "month", "year"):
151
+ payload["search_recency_filter"] = recency_filter
152
+ if domain_filter:
153
+ # Perplexity allows max 20 domains
154
+ payload["search_domain_filter"] = domain_filter[:20]
155
+ if country:
156
+ payload["country"] = country
157
+
158
+ # Execute with retry logic
159
+ response_data = await self._execute_with_retry(payload)
160
+
161
+ # Parse results
162
+ return self._parse_response(response_data, sub_query_id)
163
+
164
+ async def _execute_with_retry(
165
+ self,
166
+ payload: dict[str, Any],
167
+ ) -> dict[str, Any]:
168
+ """Execute API request with exponential backoff retry.
169
+
170
+ Args:
171
+ payload: Request payload
172
+
173
+ Returns:
174
+ Parsed JSON response
175
+
176
+ Raises:
177
+ AuthenticationError: If API key is invalid
178
+ RateLimitError: If rate limit exceeded after all retries
179
+ SearchProviderError: For other API errors
180
+ """
181
+ url = f"{self._base_url}{PERPLEXITY_SEARCH_ENDPOINT}"
182
+ headers = {
183
+ "Authorization": f"Bearer {self._api_key}",
184
+ "Content-Type": "application/json",
185
+ }
186
+ last_error: Optional[Exception] = None
187
+
188
+ for attempt in range(self._max_retries):
189
+ try:
190
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
191
+ response = await client.post(url, json=payload, headers=headers)
192
+
193
+ # Handle authentication errors (not retryable)
194
+ if response.status_code == 401:
195
+ raise AuthenticationError(
196
+ provider="perplexity",
197
+ message="Invalid API key",
198
+ )
199
+
200
+ # Handle rate limiting
201
+ if response.status_code == 429:
202
+ retry_after = self._parse_retry_after(response)
203
+ if attempt < self._max_retries - 1:
204
+ wait_time = retry_after or (2**attempt)
205
+ logger.warning(
206
+ f"Perplexity rate limit hit, waiting {wait_time}s "
207
+ f"(attempt {attempt + 1}/{self._max_retries})"
208
+ )
209
+ await asyncio.sleep(wait_time)
210
+ continue
211
+ raise RateLimitError(
212
+ provider="perplexity",
213
+ retry_after=retry_after,
214
+ )
215
+
216
+ # Handle server errors (retryable)
217
+ if response.status_code >= 500:
218
+ if attempt < self._max_retries - 1:
219
+ wait_time = 2**attempt
220
+ logger.warning(
221
+ f"Perplexity server error {response.status_code}, "
222
+ f"retrying in {wait_time}s "
223
+ f"(attempt {attempt + 1}/{self._max_retries})"
224
+ )
225
+ await asyncio.sleep(wait_time)
226
+ continue
227
+ error_msg = self._extract_error_message(response)
228
+ raise SearchProviderError(
229
+ provider="perplexity",
230
+ message=f"API error {response.status_code}: {error_msg}",
231
+ retryable=True,
232
+ )
233
+
234
+ # Handle other errors
235
+ if response.status_code >= 400:
236
+ error_msg = self._extract_error_message(response)
237
+ raise SearchProviderError(
238
+ provider="perplexity",
239
+ message=f"API error {response.status_code}: {error_msg}",
240
+ retryable=False,
241
+ )
242
+
243
+ return response.json()
244
+
245
+ except httpx.TimeoutException as e:
246
+ last_error = e
247
+ if attempt < self._max_retries - 1:
248
+ wait_time = 2**attempt
249
+ logger.warning(
250
+ f"Perplexity request timeout, retrying in {wait_time}s "
251
+ f"(attempt {attempt + 1}/{self._max_retries})"
252
+ )
253
+ await asyncio.sleep(wait_time)
254
+ continue
255
+
256
+ except httpx.RequestError as e:
257
+ last_error = e
258
+ if attempt < self._max_retries - 1:
259
+ wait_time = 2**attempt
260
+ logger.warning(
261
+ f"Perplexity request error: {e}, retrying in {wait_time}s "
262
+ f"(attempt {attempt + 1}/{self._max_retries})"
263
+ )
264
+ await asyncio.sleep(wait_time)
265
+ continue
266
+
267
+ except (AuthenticationError, RateLimitError, SearchProviderError):
268
+ raise
269
+
270
+ # All retries exhausted
271
+ raise SearchProviderError(
272
+ provider="perplexity",
273
+ message=f"Request failed after {self._max_retries} attempts",
274
+ retryable=False,
275
+ original_error=last_error,
276
+ )
277
+
278
+ def _parse_retry_after(self, response: httpx.Response) -> Optional[float]:
279
+ """Parse Retry-After header from response.
280
+
281
+ Args:
282
+ response: HTTP response
283
+
284
+ Returns:
285
+ Seconds to wait, or None if not provided
286
+ """
287
+ retry_after = response.headers.get("Retry-After")
288
+ if retry_after:
289
+ try:
290
+ return float(retry_after)
291
+ except ValueError:
292
+ pass
293
+ return None
294
+
295
+ def _extract_error_message(self, response: httpx.Response) -> str:
296
+ """Extract error message from response.
297
+
298
+ Args:
299
+ response: HTTP response
300
+
301
+ Returns:
302
+ Error message string
303
+ """
304
+ try:
305
+ data = response.json()
306
+ return data.get("error", data.get("message", response.text[:200]))
307
+ except Exception:
308
+ return response.text[:200] if response.text else "Unknown error"
309
+
310
+ def _parse_response(
311
+ self,
312
+ data: dict[str, Any],
313
+ sub_query_id: Optional[str] = None,
314
+ ) -> list[ResearchSource]:
315
+ """Parse Perplexity API response into ResearchSource objects.
316
+
317
+ Perplexity Search API response structure:
318
+ {
319
+ "results": [
320
+ {
321
+ "title": "...",
322
+ "url": "...",
323
+ "snippet": "...",
324
+ "date": "...",
325
+ "last_updated": "..."
326
+ }
327
+ ]
328
+ }
329
+
330
+ Args:
331
+ data: Perplexity API response JSON
332
+ sub_query_id: SubQuery ID for source tracking
333
+
334
+ Returns:
335
+ List of ResearchSource objects
336
+ """
337
+ sources: list[ResearchSource] = []
338
+ results = data.get("results", [])
339
+
340
+ for result in results:
341
+ # Parse date - try both 'date' and 'last_updated' fields
342
+ published_date = self._parse_date(
343
+ result.get("date") or result.get("last_updated")
344
+ )
345
+
346
+ # Create SearchResult from Perplexity response
347
+ search_result = SearchResult(
348
+ url=result.get("url", ""),
349
+ title=result.get("title", "Untitled"),
350
+ snippet=result.get("snippet"),
351
+ content=None, # Perplexity doesn't provide full content in search
352
+ score=None, # Perplexity doesn't provide relevance scores
353
+ published_date=published_date,
354
+ source=self._extract_domain(result.get("url", "")),
355
+ metadata={
356
+ "perplexity_date": result.get("date"),
357
+ "perplexity_last_updated": result.get("last_updated"),
358
+ },
359
+ )
360
+
361
+ # Convert to ResearchSource
362
+ research_source = search_result.to_research_source(
363
+ source_type=SourceType.WEB,
364
+ sub_query_id=sub_query_id,
365
+ )
366
+ sources.append(research_source)
367
+
368
+ return sources
369
+
370
+ def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
371
+ """Parse date string from Perplexity response.
372
+
373
+ Args:
374
+ date_str: ISO format date string or other common formats
375
+
376
+ Returns:
377
+ Parsed datetime or None
378
+ """
379
+ if not date_str:
380
+ return None
381
+
382
+ # Try ISO format first
383
+ try:
384
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
385
+ except ValueError:
386
+ pass
387
+
388
+ # Try common date formats
389
+ formats = [
390
+ "%Y-%m-%d",
391
+ "%Y/%m/%d",
392
+ "%d-%m-%Y",
393
+ "%d/%m/%Y",
394
+ "%B %d, %Y",
395
+ "%b %d, %Y",
396
+ ]
397
+
398
+ for fmt in formats:
399
+ try:
400
+ return datetime.strptime(date_str, fmt)
401
+ except ValueError:
402
+ continue
403
+
404
+ return None
405
+
406
+ def _extract_domain(self, url: str) -> Optional[str]:
407
+ """Extract domain from URL.
408
+
409
+ Args:
410
+ url: Full URL
411
+
412
+ Returns:
413
+ Domain name or None
414
+ """
415
+ if not url:
416
+ return None
417
+ try:
418
+ from urllib.parse import urlparse
419
+
420
+ parsed = urlparse(url)
421
+ return parsed.netloc or None
422
+ except Exception:
423
+ return None
424
+
425
+ async def health_check(self) -> bool:
426
+ """Check if Perplexity API is accessible.
427
+
428
+ Performs a lightweight search to verify API key and connectivity.
429
+
430
+ Returns:
431
+ True if provider is healthy, False otherwise
432
+ """
433
+ try:
434
+ # Perform minimal search to verify connectivity
435
+ await self.search("test", max_results=1)
436
+ return True
437
+ except AuthenticationError:
438
+ logger.error("Perplexity health check failed: invalid API key")
439
+ return False
440
+ except Exception as e:
441
+ logger.warning(f"Perplexity health check failed: {e}")
442
+ return False