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/core/discovery.py
CHANGED
|
@@ -385,7 +385,7 @@ class ServerCapabilities:
|
|
|
385
385
|
max_batch_size: int = 100
|
|
386
386
|
rate_limit_headers: bool = True
|
|
387
387
|
supported_formats: List[str] = field(default_factory=lambda: ["json"])
|
|
388
|
-
feature_flags_enabled: bool =
|
|
388
|
+
feature_flags_enabled: bool = False
|
|
389
389
|
|
|
390
390
|
def to_dict(self) -> Dict[str, Any]:
|
|
391
391
|
"""
|
foundry_mcp/core/llm_config.py
CHANGED
|
@@ -163,6 +163,14 @@ class ProviderSpec:
|
|
|
163
163
|
"[api]provider/model or [cli]transport[:backend/model|:model]"
|
|
164
164
|
)
|
|
165
165
|
|
|
166
|
+
@classmethod
|
|
167
|
+
def parse_flexible(cls, spec: str) -> "ProviderSpec":
|
|
168
|
+
"""Parse with fallback for simple provider IDs."""
|
|
169
|
+
spec = spec.strip()
|
|
170
|
+
if spec.startswith("["):
|
|
171
|
+
return cls.parse(spec)
|
|
172
|
+
return cls(type="cli", provider=spec.lower(), raw=spec)
|
|
173
|
+
|
|
166
174
|
def validate(self) -> List[str]:
|
|
167
175
|
"""Validate the provider specification.
|
|
168
176
|
|
foundry_mcp/core/naming.py
CHANGED
|
@@ -4,17 +4,34 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import functools
|
|
7
|
+
import json
|
|
7
8
|
import logging
|
|
8
9
|
import time
|
|
9
10
|
from typing import Any, Callable
|
|
10
11
|
|
|
11
12
|
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
from mcp.types import TextContent
|
|
12
14
|
|
|
13
15
|
from foundry_mcp.core.observability import mcp_tool
|
|
14
16
|
|
|
15
17
|
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
def _minify_response(result: dict[str, Any]) -> TextContent:
|
|
21
|
+
"""Convert dict to TextContent with minified JSON.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
result: Dictionary to serialize
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
TextContent with minified JSON string
|
|
28
|
+
"""
|
|
29
|
+
return TextContent(
|
|
30
|
+
type="text",
|
|
31
|
+
text=json.dumps(result, separators=(",", ":"), default=str),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
18
35
|
def canonical_tool(
|
|
19
36
|
mcp: FastMCP,
|
|
20
37
|
*,
|
|
@@ -45,7 +62,10 @@ def canonical_tool(
|
|
|
45
62
|
"""Async wrapper for async underlying functions."""
|
|
46
63
|
start_time = time.perf_counter()
|
|
47
64
|
try:
|
|
48
|
-
|
|
65
|
+
result = await func(*args, **kwargs)
|
|
66
|
+
if isinstance(result, dict):
|
|
67
|
+
return _minify_response(result)
|
|
68
|
+
return result
|
|
49
69
|
except Exception as e:
|
|
50
70
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
51
71
|
_collect_tool_error(
|
|
@@ -64,7 +84,10 @@ def canonical_tool(
|
|
|
64
84
|
"""Sync wrapper for sync underlying functions."""
|
|
65
85
|
start_time = time.perf_counter()
|
|
66
86
|
try:
|
|
67
|
-
|
|
87
|
+
result = func(*args, **kwargs)
|
|
88
|
+
if isinstance(result, dict):
|
|
89
|
+
return _minify_response(result)
|
|
90
|
+
return result
|
|
68
91
|
except Exception as e:
|
|
69
92
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
70
93
|
_collect_tool_error(
|
foundry_mcp/core/prometheus.py
CHANGED
|
@@ -159,7 +159,6 @@ class PrometheusExporter:
|
|
|
159
159
|
# Manifest/discovery metrics
|
|
160
160
|
self._manifest_tokens: Any = None
|
|
161
161
|
self._manifest_tool_count: Any = None
|
|
162
|
-
self._feature_flag_state: Any = None
|
|
163
162
|
|
|
164
163
|
# Health check metrics
|
|
165
164
|
self._health_status: Any = None
|
|
@@ -236,11 +235,6 @@ class PrometheusExporter:
|
|
|
236
235
|
"Tool count for the advertised tool manifest",
|
|
237
236
|
["manifest"], # unified|legacy
|
|
238
237
|
)
|
|
239
|
-
self._feature_flag_state = Gauge(
|
|
240
|
-
f"{ns}_feature_flag_state",
|
|
241
|
-
"Feature flag state (1=enabled, 0=disabled)",
|
|
242
|
-
["flag"],
|
|
243
|
-
)
|
|
244
238
|
|
|
245
239
|
# Health check metrics
|
|
246
240
|
self._health_status = Gauge(
|
|
@@ -396,13 +390,6 @@ class PrometheusExporter:
|
|
|
396
390
|
self._manifest_tokens.labels(manifest=manifest_label).set(int(tokens))
|
|
397
391
|
self._manifest_tool_count.labels(manifest=manifest_label).set(int(tool_count))
|
|
398
392
|
|
|
399
|
-
def record_feature_flag_state(self, flag: str, enabled: bool) -> None:
|
|
400
|
-
"""Record feature flag enabled/disabled state."""
|
|
401
|
-
if not self.is_enabled():
|
|
402
|
-
return
|
|
403
|
-
|
|
404
|
-
self._feature_flag_state.labels(flag=flag).set(1 if enabled else 0)
|
|
405
|
-
|
|
406
393
|
# -------------------------------------------------------------------------
|
|
407
394
|
# Health Check Metrics
|
|
408
395
|
# -------------------------------------------------------------------------
|
|
@@ -56,6 +56,7 @@ from foundry_mcp.core.providers.base import (
|
|
|
56
56
|
ProviderUnavailableError,
|
|
57
57
|
ProviderExecutionError,
|
|
58
58
|
ProviderTimeoutError,
|
|
59
|
+
ContextWindowError,
|
|
59
60
|
# ABC
|
|
60
61
|
ProviderContext,
|
|
61
62
|
)
|
|
@@ -124,6 +125,11 @@ from foundry_mcp.core.providers.validation import (
|
|
|
124
125
|
reset_rate_limiters,
|
|
125
126
|
# Execution wrapper
|
|
126
127
|
with_validation_and_resilience,
|
|
128
|
+
# Context window detection
|
|
129
|
+
CONTEXT_WINDOW_ERROR_PATTERNS,
|
|
130
|
+
is_context_window_error,
|
|
131
|
+
extract_token_counts,
|
|
132
|
+
create_context_window_guidance,
|
|
127
133
|
)
|
|
128
134
|
|
|
129
135
|
# ---------------------------------------------------------------------------
|
|
@@ -160,6 +166,7 @@ __all__ = [
|
|
|
160
166
|
"ProviderUnavailableError",
|
|
161
167
|
"ProviderExecutionError",
|
|
162
168
|
"ProviderTimeoutError",
|
|
169
|
+
"ContextWindowError",
|
|
163
170
|
# ABC
|
|
164
171
|
"ProviderContext",
|
|
165
172
|
# === Detection (detectors.py) ===
|
|
@@ -222,4 +229,9 @@ __all__ = [
|
|
|
222
229
|
"reset_rate_limiters",
|
|
223
230
|
# Execution wrapper
|
|
224
231
|
"with_validation_and_resilience",
|
|
232
|
+
# Context window detection
|
|
233
|
+
"CONTEXT_WINDOW_ERROR_PATTERNS",
|
|
234
|
+
"is_context_window_error",
|
|
235
|
+
"extract_token_counts",
|
|
236
|
+
"create_context_window_guidance",
|
|
225
237
|
]
|
|
@@ -277,6 +277,44 @@ class ProviderTimeoutError(ProviderError):
|
|
|
277
277
|
"""Raised when a provider exceeds its allotted execution time."""
|
|
278
278
|
|
|
279
279
|
|
|
280
|
+
class ContextWindowError(ProviderExecutionError):
|
|
281
|
+
"""Raised when prompt exceeds the model's context window limit.
|
|
282
|
+
|
|
283
|
+
This error indicates the prompt/context size exceeded what the model
|
|
284
|
+
can process. It includes token counts to help with debugging and
|
|
285
|
+
provides actionable guidance for resolution.
|
|
286
|
+
|
|
287
|
+
Attributes:
|
|
288
|
+
prompt_tokens: Estimated tokens in the prompt (if known)
|
|
289
|
+
max_tokens: Maximum context window size (if known)
|
|
290
|
+
provider: Provider that raised the error
|
|
291
|
+
truncation_needed: How many tokens need to be removed
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
message: str,
|
|
297
|
+
*,
|
|
298
|
+
provider: Optional[str] = None,
|
|
299
|
+
prompt_tokens: Optional[int] = None,
|
|
300
|
+
max_tokens: Optional[int] = None,
|
|
301
|
+
):
|
|
302
|
+
"""Initialize context window error.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
message: Error message describing the issue
|
|
306
|
+
provider: Provider ID that raised the error
|
|
307
|
+
prompt_tokens: Number of tokens in the prompt (if known)
|
|
308
|
+
max_tokens: Maximum tokens allowed (if known)
|
|
309
|
+
"""
|
|
310
|
+
super().__init__(message, provider=provider)
|
|
311
|
+
self.prompt_tokens = prompt_tokens
|
|
312
|
+
self.max_tokens = max_tokens
|
|
313
|
+
self.truncation_needed = (
|
|
314
|
+
(prompt_tokens - max_tokens) if prompt_tokens and max_tokens else None
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
280
318
|
# =============================================================================
|
|
281
319
|
# Lifecycle Hooks
|
|
282
320
|
# =============================================================================
|
|
@@ -471,6 +509,7 @@ __all__ = [
|
|
|
471
509
|
"ProviderUnavailableError",
|
|
472
510
|
"ProviderExecutionError",
|
|
473
511
|
"ProviderTimeoutError",
|
|
512
|
+
"ContextWindowError",
|
|
474
513
|
# ABC
|
|
475
514
|
"ProviderContext",
|
|
476
515
|
]
|
|
@@ -314,9 +314,14 @@ class ClaudeProvider(ProviderContext):
|
|
|
314
314
|
)
|
|
315
315
|
|
|
316
316
|
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
317
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
318
|
+
if request.model:
|
|
319
|
+
return str(request.model)
|
|
320
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
317
321
|
model_override = request.metadata.get("model") if request.metadata else None
|
|
318
322
|
if model_override:
|
|
319
323
|
return str(model_override)
|
|
324
|
+
# 3. Fallback to instance default
|
|
320
325
|
return self._model
|
|
321
326
|
|
|
322
327
|
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
@@ -324,6 +329,36 @@ class ClaudeProvider(ProviderContext):
|
|
|
324
329
|
return
|
|
325
330
|
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
326
331
|
|
|
332
|
+
def _extract_error_from_json(self, stdout: str) -> Optional[str]:
|
|
333
|
+
"""
|
|
334
|
+
Extract error message from Claude CLI JSON output.
|
|
335
|
+
|
|
336
|
+
Claude CLI outputs errors as JSON with is_error: true and error in 'result' field.
|
|
337
|
+
Example: {"type":"result","is_error":true,"result":"API Error: 404 {...}"}
|
|
338
|
+
"""
|
|
339
|
+
if not stdout:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
payload = json.loads(stdout.strip())
|
|
344
|
+
except json.JSONDecodeError:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
# Check for error indicator
|
|
348
|
+
if payload.get("is_error"):
|
|
349
|
+
result = payload.get("result", "")
|
|
350
|
+
if result:
|
|
351
|
+
return str(result)
|
|
352
|
+
|
|
353
|
+
# Also check for explicit error field
|
|
354
|
+
error = payload.get("error")
|
|
355
|
+
if error:
|
|
356
|
+
if isinstance(error, dict):
|
|
357
|
+
return error.get("message") or str(error)
|
|
358
|
+
return str(error)
|
|
359
|
+
|
|
360
|
+
return None
|
|
361
|
+
|
|
327
362
|
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
328
363
|
self._validate_request(request)
|
|
329
364
|
model = self._resolve_model(request)
|
|
@@ -334,8 +369,17 @@ class ClaudeProvider(ProviderContext):
|
|
|
334
369
|
if completed.returncode != 0:
|
|
335
370
|
stderr = (completed.stderr or "").strip()
|
|
336
371
|
logger.debug(f"Claude CLI stderr: {stderr or 'no stderr'}")
|
|
372
|
+
|
|
373
|
+
# Extract error from JSON stdout (Claude outputs errors there with is_error: true)
|
|
374
|
+
json_error = self._extract_error_from_json(completed.stdout)
|
|
375
|
+
|
|
376
|
+
error_msg = f"Claude CLI exited with code {completed.returncode}"
|
|
377
|
+
if json_error:
|
|
378
|
+
error_msg += f": {json_error[:500]}"
|
|
379
|
+
elif stderr:
|
|
380
|
+
error_msg += f": {stderr[:500]}"
|
|
337
381
|
raise ProviderExecutionError(
|
|
338
|
-
|
|
382
|
+
error_msg,
|
|
339
383
|
provider=self.metadata.provider_id,
|
|
340
384
|
)
|
|
341
385
|
|
|
@@ -332,7 +332,8 @@ class CodexProvider(ProviderContext):
|
|
|
332
332
|
|
|
333
333
|
def _build_command(self, model: str, prompt: str, attachments: List[str]) -> List[str]:
|
|
334
334
|
# Note: codex CLI requires --json flag for JSONL output (non-interactive mode)
|
|
335
|
-
|
|
335
|
+
# --skip-git-repo-check allows running outside trusted git directories
|
|
336
|
+
command = [self._binary, "exec", "--sandbox", "read-only", "--skip-git-repo-check", "--json"]
|
|
336
337
|
if model:
|
|
337
338
|
command.extend(["-m", model])
|
|
338
339
|
for path in attachments:
|
|
@@ -478,10 +479,61 @@ class CodexProvider(ProviderContext):
|
|
|
478
479
|
|
|
479
480
|
return final_content, usage, metadata, reported_model
|
|
480
481
|
|
|
482
|
+
def _extract_error_from_jsonl(self, stdout: str) -> Optional[str]:
|
|
483
|
+
"""
|
|
484
|
+
Extract error message from Codex JSONL output.
|
|
485
|
+
|
|
486
|
+
Codex CLI outputs errors as JSONL events to stdout, not stderr.
|
|
487
|
+
Look for {"type":"error"} or {"type":"turn.failed"} events.
|
|
488
|
+
"""
|
|
489
|
+
if not stdout:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
errors: List[str] = []
|
|
493
|
+
for line in stdout.strip().splitlines():
|
|
494
|
+
if not line.strip():
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
event = json.loads(line)
|
|
498
|
+
except json.JSONDecodeError:
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
event_type = str(event.get("type", "")).lower()
|
|
502
|
+
|
|
503
|
+
# Extract from {"type":"error","message":"..."}
|
|
504
|
+
if event_type == "error":
|
|
505
|
+
msg = event.get("message", "")
|
|
506
|
+
# Skip reconnection messages, get the final error
|
|
507
|
+
if msg and not msg.startswith("Reconnecting"):
|
|
508
|
+
errors.append(msg)
|
|
509
|
+
|
|
510
|
+
# Extract from {"type":"turn.failed","error":{"message":"..."}}
|
|
511
|
+
elif event_type == "turn.failed":
|
|
512
|
+
error_obj = event.get("error", {})
|
|
513
|
+
if isinstance(error_obj, dict):
|
|
514
|
+
msg = error_obj.get("message", "")
|
|
515
|
+
if msg:
|
|
516
|
+
errors.append(msg)
|
|
517
|
+
|
|
518
|
+
# Return the last (most specific) error, or join if multiple
|
|
519
|
+
if errors:
|
|
520
|
+
# Deduplicate while preserving order
|
|
521
|
+
seen = set()
|
|
522
|
+
unique_errors = []
|
|
523
|
+
for e in errors:
|
|
524
|
+
if e not in seen:
|
|
525
|
+
seen.add(e)
|
|
526
|
+
unique_errors.append(e)
|
|
527
|
+
return "; ".join(unique_errors)
|
|
528
|
+
return None
|
|
529
|
+
|
|
481
530
|
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
482
531
|
self._validate_request(request)
|
|
532
|
+
# Resolve model: request.model takes precedence, then metadata, then instance default
|
|
483
533
|
model = (
|
|
484
|
-
|
|
534
|
+
request.model
|
|
535
|
+
or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
|
|
536
|
+
or self._model
|
|
485
537
|
)
|
|
486
538
|
prompt = self._build_prompt(request)
|
|
487
539
|
attachments = self._normalize_attachment_paths(request)
|
|
@@ -492,8 +544,17 @@ class CodexProvider(ProviderContext):
|
|
|
492
544
|
if completed.returncode != 0:
|
|
493
545
|
stderr = (completed.stderr or "").strip()
|
|
494
546
|
logger.debug(f"Codex CLI stderr: {stderr or 'no stderr'}")
|
|
547
|
+
|
|
548
|
+
# Extract error message from JSONL stdout (Codex outputs errors there, not stderr)
|
|
549
|
+
jsonl_error = self._extract_error_from_jsonl(completed.stdout)
|
|
550
|
+
|
|
551
|
+
error_msg = f"Codex CLI exited with code {completed.returncode}"
|
|
552
|
+
if jsonl_error:
|
|
553
|
+
error_msg += f": {jsonl_error[:500]}"
|
|
554
|
+
elif stderr:
|
|
555
|
+
error_msg += f": {stderr[:500]}"
|
|
495
556
|
raise ProviderExecutionError(
|
|
496
|
-
|
|
557
|
+
error_msg,
|
|
497
558
|
provider=self.metadata.provider_id,
|
|
498
559
|
)
|
|
499
560
|
|
|
@@ -437,16 +437,32 @@ class CursorAgentProvider(ProviderContext):
|
|
|
437
437
|
return retry_process, False
|
|
438
438
|
|
|
439
439
|
stderr_text = (retry_process.stderr or stderr_text).strip()
|
|
440
|
+
# Cursor Agent outputs errors to stdout as plain text, not stderr
|
|
441
|
+
stdout_text = (retry_process.stdout or "").strip()
|
|
440
442
|
logger.debug(f"Cursor Agent CLI stderr (retry): {stderr_text or 'no stderr'}")
|
|
443
|
+
error_msg = f"Cursor Agent CLI exited with code {retry_process.returncode}"
|
|
444
|
+
if stdout_text and not stdout_text.startswith("{"):
|
|
445
|
+
# Plain text error in stdout (not JSON response)
|
|
446
|
+
error_msg += f": {stdout_text[:500]}"
|
|
447
|
+
elif stderr_text:
|
|
448
|
+
error_msg += f": {stderr_text[:500]}"
|
|
441
449
|
raise ProviderExecutionError(
|
|
442
|
-
|
|
450
|
+
error_msg,
|
|
443
451
|
provider=self.metadata.provider_id,
|
|
444
452
|
)
|
|
445
453
|
|
|
446
454
|
stderr_text = (completed.stderr or "").strip()
|
|
455
|
+
# Cursor Agent outputs errors to stdout as plain text, not stderr
|
|
456
|
+
stdout_text = (completed.stdout or "").strip()
|
|
447
457
|
logger.debug(f"Cursor Agent CLI stderr: {stderr_text or 'no stderr'}")
|
|
458
|
+
error_msg = f"Cursor Agent CLI exited with code {completed.returncode}"
|
|
459
|
+
if stdout_text and not stdout_text.startswith("{"):
|
|
460
|
+
# Plain text error in stdout (not JSON response)
|
|
461
|
+
error_msg += f": {stdout_text[:500]}"
|
|
462
|
+
elif stderr_text:
|
|
463
|
+
error_msg += f": {stderr_text[:500]}"
|
|
448
464
|
raise ProviderExecutionError(
|
|
449
|
-
|
|
465
|
+
error_msg,
|
|
450
466
|
provider=self.metadata.provider_id,
|
|
451
467
|
)
|
|
452
468
|
|
|
@@ -492,8 +508,11 @@ class CursorAgentProvider(ProviderContext):
|
|
|
492
508
|
provider=self.metadata.provider_id,
|
|
493
509
|
)
|
|
494
510
|
|
|
511
|
+
# Resolve model: request.model takes precedence, then metadata, then instance default
|
|
495
512
|
model = (
|
|
496
|
-
|
|
513
|
+
request.model
|
|
514
|
+
or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
|
|
515
|
+
or self._model
|
|
497
516
|
)
|
|
498
517
|
|
|
499
518
|
# Backup and replace HOME config with read-only version
|
|
@@ -31,11 +31,23 @@ import logging
|
|
|
31
31
|
import os
|
|
32
32
|
import shutil
|
|
33
33
|
import subprocess
|
|
34
|
+
import time
|
|
34
35
|
from dataclasses import dataclass, field
|
|
35
|
-
from typing import Dict, Iterable, Optional, Sequence
|
|
36
|
+
from typing import Dict, Iterable, Optional, Sequence, Tuple
|
|
36
37
|
|
|
37
38
|
logger = logging.getLogger(__name__)
|
|
38
39
|
|
|
40
|
+
# Cache for provider availability: {provider_id: (is_available, timestamp)}
|
|
41
|
+
_AVAILABILITY_CACHE: Dict[str, Tuple[bool, float]] = {}
|
|
42
|
+
|
|
43
|
+
def _get_cache_ttl() -> float:
|
|
44
|
+
"""Get cache TTL from config or default to 3600s."""
|
|
45
|
+
try:
|
|
46
|
+
from foundry_mcp.config import get_config
|
|
47
|
+
return float(get_config().providers.get("availability_cache_ttl", 3600))
|
|
48
|
+
except Exception:
|
|
49
|
+
return 3600.0
|
|
50
|
+
|
|
39
51
|
# Environment variable for test mode (bypasses real CLI probes)
|
|
40
52
|
_TEST_MODE_ENV = "FOUNDRY_PROVIDER_TEST_MODE"
|
|
41
53
|
|
|
@@ -173,13 +185,14 @@ class ProviderDetector:
|
|
|
173
185
|
|
|
174
186
|
def is_available(self, *, use_probe: bool = True) -> bool:
|
|
175
187
|
"""
|
|
176
|
-
Check whether this provider is available.
|
|
188
|
+
Check whether this provider is available (with caching).
|
|
177
189
|
|
|
178
190
|
Resolution order:
|
|
179
|
-
1. Check override_env (if set, returns its boolean value)
|
|
191
|
+
1. Check override_env (if set, returns its boolean value - takes precedence)
|
|
180
192
|
2. In test mode, return False (no real CLI available)
|
|
181
|
-
3.
|
|
182
|
-
4.
|
|
193
|
+
3. Check cache (if valid)
|
|
194
|
+
4. Resolve binary via PATH
|
|
195
|
+
5. Optionally run health probe
|
|
183
196
|
|
|
184
197
|
Args:
|
|
185
198
|
use_probe: When True, run health probe after finding binary.
|
|
@@ -188,7 +201,7 @@ class ProviderDetector:
|
|
|
188
201
|
Returns:
|
|
189
202
|
True if provider is available, False otherwise
|
|
190
203
|
"""
|
|
191
|
-
# Check environment override first
|
|
204
|
+
# Check environment override first (takes precedence over cache)
|
|
192
205
|
if self.override_env:
|
|
193
206
|
override = _coerce_bool(os.environ.get(self.override_env))
|
|
194
207
|
if override is not None:
|
|
@@ -207,6 +220,14 @@ class ProviderDetector:
|
|
|
207
220
|
)
|
|
208
221
|
return False
|
|
209
222
|
|
|
223
|
+
# Check cache (only for non-overridden, non-test-mode cases)
|
|
224
|
+
cache_key = f"{self.provider_id}:{use_probe}"
|
|
225
|
+
cached = _AVAILABILITY_CACHE.get(cache_key)
|
|
226
|
+
if cached is not None:
|
|
227
|
+
is_avail, cached_time = cached
|
|
228
|
+
if time.time() - cached_time < _get_cache_ttl():
|
|
229
|
+
return is_avail
|
|
230
|
+
|
|
210
231
|
# Resolve binary path
|
|
211
232
|
executable = self.resolve_binary()
|
|
212
233
|
if not executable:
|
|
@@ -214,14 +235,18 @@ class ProviderDetector:
|
|
|
214
235
|
"Provider '%s' unavailable (binary not found in PATH)",
|
|
215
236
|
self.provider_id,
|
|
216
237
|
)
|
|
238
|
+
_AVAILABILITY_CACHE[cache_key] = (False, time.time())
|
|
217
239
|
return False
|
|
218
240
|
|
|
219
241
|
# Skip probe if not requested
|
|
220
242
|
if not use_probe:
|
|
243
|
+
_AVAILABILITY_CACHE[cache_key] = (True, time.time())
|
|
221
244
|
return True
|
|
222
245
|
|
|
223
246
|
# Run health probe
|
|
224
|
-
|
|
247
|
+
result = self._run_probe(executable)
|
|
248
|
+
_AVAILABILITY_CACHE[cache_key] = (result, time.time())
|
|
249
|
+
return result
|
|
225
250
|
|
|
226
251
|
def get_unavailability_reason(self, *, use_probe: bool = True) -> Optional[str]:
|
|
227
252
|
"""
|
|
@@ -468,7 +493,9 @@ def reset_detectors() -> None:
|
|
|
468
493
|
Reset detectors to the default set.
|
|
469
494
|
|
|
470
495
|
Primarily used by tests to restore a clean state.
|
|
496
|
+
Also clears the availability cache to ensure fresh detection.
|
|
471
497
|
"""
|
|
498
|
+
_AVAILABILITY_CACHE.clear()
|
|
472
499
|
_reset_default_detectors()
|
|
473
500
|
|
|
474
501
|
|
|
@@ -252,9 +252,14 @@ class GeminiProvider(ProviderContext):
|
|
|
252
252
|
)
|
|
253
253
|
|
|
254
254
|
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
255
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
256
|
+
if request.model:
|
|
257
|
+
return str(request.model)
|
|
258
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
255
259
|
model_override = request.metadata.get("model") if request.metadata else None
|
|
256
260
|
if model_override:
|
|
257
261
|
return str(model_override)
|
|
262
|
+
# 3. Fallback to instance default
|
|
258
263
|
return self._model
|
|
259
264
|
|
|
260
265
|
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
@@ -262,6 +267,54 @@ class GeminiProvider(ProviderContext):
|
|
|
262
267
|
return
|
|
263
268
|
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
264
269
|
|
|
270
|
+
def _extract_error_from_output(self, stdout: str) -> Optional[str]:
|
|
271
|
+
"""
|
|
272
|
+
Extract error message from Gemini CLI output.
|
|
273
|
+
|
|
274
|
+
Gemini CLI outputs errors as text lines followed by JSON. Example:
|
|
275
|
+
'Error when talking to Gemini API Full report available at: /tmp/...
|
|
276
|
+
{"error": {"type": "Error", "message": "[object Object]", "code": 1}}'
|
|
277
|
+
|
|
278
|
+
The JSON message field is often unhelpful ("[object Object]"), so we
|
|
279
|
+
prefer the text prefix which contains the actual error description.
|
|
280
|
+
"""
|
|
281
|
+
if not stdout:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
lines = stdout.strip().split("\n")
|
|
285
|
+
error_parts: List[str] = []
|
|
286
|
+
|
|
287
|
+
for line in lines:
|
|
288
|
+
line = line.strip()
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Skip "Loaded cached credentials" info line
|
|
293
|
+
if line.startswith("Loaded cached"):
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# Try to parse as JSON
|
|
297
|
+
if line.startswith("{"):
|
|
298
|
+
try:
|
|
299
|
+
payload = json.loads(line)
|
|
300
|
+
error = payload.get("error", {})
|
|
301
|
+
if isinstance(error, dict):
|
|
302
|
+
msg = error.get("message", "")
|
|
303
|
+
# Skip unhelpful "[object Object]" message
|
|
304
|
+
if msg and msg != "[object Object]":
|
|
305
|
+
error_parts.append(msg)
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
pass
|
|
308
|
+
else:
|
|
309
|
+
# Text line - likely contains the actual error message
|
|
310
|
+
# Extract the part before "Full report available at:"
|
|
311
|
+
if "Full report available at:" in line:
|
|
312
|
+
line = line.split("Full report available at:")[0].strip()
|
|
313
|
+
if line:
|
|
314
|
+
error_parts.append(line)
|
|
315
|
+
|
|
316
|
+
return "; ".join(error_parts) if error_parts else None
|
|
317
|
+
|
|
265
318
|
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
266
319
|
self._validate_request(request)
|
|
267
320
|
model = self._resolve_model(request)
|
|
@@ -273,8 +326,17 @@ class GeminiProvider(ProviderContext):
|
|
|
273
326
|
if completed.returncode != 0:
|
|
274
327
|
stderr = (completed.stderr or "").strip()
|
|
275
328
|
logger.debug(f"Gemini CLI stderr: {stderr or 'no stderr'}")
|
|
329
|
+
|
|
330
|
+
# Extract error from stdout (Gemini outputs errors as text + JSON)
|
|
331
|
+
stdout_error = self._extract_error_from_output(completed.stdout)
|
|
332
|
+
|
|
333
|
+
error_msg = f"Gemini CLI exited with code {completed.returncode}"
|
|
334
|
+
if stdout_error:
|
|
335
|
+
error_msg += f": {stdout_error[:500]}"
|
|
336
|
+
elif stderr:
|
|
337
|
+
error_msg += f": {stderr[:500]}"
|
|
276
338
|
raise ProviderExecutionError(
|
|
277
|
-
|
|
339
|
+
error_msg,
|
|
278
340
|
provider=self.metadata.provider_id,
|
|
279
341
|
)
|
|
280
342
|
|