foundry-mcp 0.8.22__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.

Potentially problematic release.


This version of foundry-mcp might be problematic. Click here for more details.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,589 @@
1
+ """Unified provider tool backed by ActionRouter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from dataclasses import asdict
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from foundry_mcp.config import ServerConfig
13
+ from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
14
+ from foundry_mcp.core.llm_provider import RateLimitError
15
+ from foundry_mcp.core.naming import canonical_tool
16
+ from foundry_mcp.core.observability import get_metrics, mcp_tool
17
+ from foundry_mcp.core.providers import (
18
+ ProviderExecutionError,
19
+ ProviderHooks,
20
+ ProviderRequest,
21
+ ProviderTimeoutError,
22
+ ProviderUnavailableError,
23
+ check_provider_available,
24
+ describe_providers,
25
+ get_provider_metadata,
26
+ get_provider_statuses,
27
+ resolve_provider,
28
+ )
29
+ from foundry_mcp.core.responses import (
30
+ ErrorCode,
31
+ ErrorType,
32
+ error_response,
33
+ sanitize_error_message,
34
+ success_response,
35
+ )
36
+ from foundry_mcp.tools.unified.router import (
37
+ ActionDefinition,
38
+ ActionRouter,
39
+ ActionRouterError,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+ _metrics = get_metrics()
44
+
45
+ _ACTION_SUMMARY = {
46
+ "list": "List registered providers with optional unavailable entries",
47
+ "status": "Fetch metadata and health for a provider",
48
+ "execute": "Run prompts through providers with validation and telemetry",
49
+ }
50
+
51
+
52
+ def _metric_name(action: str) -> str:
53
+ return f"provider.{action}"
54
+
55
+
56
+ def _request_id() -> str:
57
+ return get_correlation_id() or generate_correlation_id(prefix="provider")
58
+
59
+
60
+ def _validation_error(
61
+ *,
62
+ action: str,
63
+ field: str,
64
+ message: str,
65
+ request_id: str,
66
+ remediation: Optional[str] = None,
67
+ code: ErrorCode = ErrorCode.VALIDATION_ERROR,
68
+ ) -> dict:
69
+ return asdict(
70
+ error_response(
71
+ f"Invalid field '{field}' for provider.{action}: {message}",
72
+ error_code=code,
73
+ error_type=ErrorType.VALIDATION,
74
+ remediation=remediation,
75
+ details={"field": field, "action": f"provider.{action}"},
76
+ request_id=request_id,
77
+ )
78
+ )
79
+
80
+
81
+ def _handle_list(
82
+ *,
83
+ config: ServerConfig, # noqa: ARG001 - reserved for future hooks
84
+ include_unavailable: Optional[bool] = False,
85
+ **_: Any,
86
+ ) -> dict:
87
+ request_id = _request_id()
88
+
89
+ include = include_unavailable if isinstance(include_unavailable, bool) else False
90
+ if include_unavailable is not None and not isinstance(include_unavailable, bool):
91
+ return _validation_error(
92
+ action="list",
93
+ field="include_unavailable",
94
+ message="Expected a boolean value",
95
+ request_id=request_id,
96
+ code=ErrorCode.INVALID_FORMAT,
97
+ )
98
+
99
+ try:
100
+ providers = describe_providers()
101
+ except Exception:
102
+ logger.exception("Failed to describe providers")
103
+ _metrics.counter(_metric_name("list"), labels={"status": "error"})
104
+ return asdict(
105
+ error_response(
106
+ "Failed to list providers",
107
+ error_code=ErrorCode.INTERNAL_ERROR,
108
+ error_type=ErrorType.INTERNAL,
109
+ remediation="Inspect provider registry configuration",
110
+ request_id=request_id,
111
+ )
112
+ )
113
+
114
+ total_count = len(providers)
115
+ available_count = sum(
116
+ 1 for provider in providers if provider.get("available", False)
117
+ )
118
+ visible = (
119
+ providers
120
+ if include
121
+ else [provider for provider in providers if provider.get("available", False)]
122
+ )
123
+
124
+ warnings: List[str] = []
125
+ if not include and available_count < total_count:
126
+ missing = total_count - available_count
127
+ warnings.append(
128
+ f"{missing} provider(s) filtered out because they are unavailable"
129
+ )
130
+
131
+ _metrics.counter(_metric_name("list"), labels={"status": "success"})
132
+ return asdict(
133
+ success_response(
134
+ data={
135
+ "providers": visible,
136
+ "available_count": available_count,
137
+ "total_count": total_count,
138
+ },
139
+ warnings=warnings or None,
140
+ request_id=request_id,
141
+ )
142
+ )
143
+
144
+
145
+ def _handle_status(
146
+ *,
147
+ config: ServerConfig, # noqa: ARG001 - reserved for future hooks
148
+ provider_id: Optional[str] = None,
149
+ **_: Any,
150
+ ) -> dict:
151
+ request_id = _request_id()
152
+
153
+ if not isinstance(provider_id, str) or not provider_id.strip():
154
+ return _validation_error(
155
+ action="status",
156
+ field="provider_id",
157
+ message="Provide a non-empty provider_id",
158
+ request_id=request_id,
159
+ remediation="Call provider(action=list) to discover valid providers",
160
+ code=ErrorCode.MISSING_REQUIRED,
161
+ )
162
+ provider_id = provider_id.strip()
163
+
164
+ try:
165
+ availability = check_provider_available(provider_id)
166
+ metadata = get_provider_metadata(provider_id)
167
+ statuses = get_provider_statuses()
168
+ except Exception:
169
+ logger.exception(
170
+ "Failed to load provider status", extra={"provider_id": provider_id}
171
+ )
172
+ _metrics.counter(_metric_name("status"), labels={"status": "error"})
173
+ return asdict(
174
+ error_response(
175
+ f"Failed to retrieve status for provider '{provider_id}'",
176
+ error_code=ErrorCode.INTERNAL_ERROR,
177
+ error_type=ErrorType.INTERNAL,
178
+ remediation="Inspect provider registry configuration",
179
+ request_id=request_id,
180
+ )
181
+ )
182
+
183
+ metadata_dict: Optional[Dict[str, Any]] = None
184
+ capabilities: Optional[List[str]] = None
185
+ if metadata:
186
+ metadata_dict = {
187
+ "name": metadata.display_name or metadata.provider_id,
188
+ "version": metadata.extra.get("version") if metadata.extra else None,
189
+ "default_model": metadata.default_model,
190
+ "supported_models": [
191
+ {
192
+ "id": model.id,
193
+ "name": model.display_name or model.id,
194
+ "context_window": model.routing_hints.get("context_window")
195
+ if model.routing_hints
196
+ else None,
197
+ "is_default": model.id == metadata.default_model,
198
+ }
199
+ for model in (metadata.models or [])
200
+ ],
201
+ "documentation_url": metadata.extra.get("documentation_url")
202
+ if metadata.extra
203
+ else None,
204
+ "tags": metadata.extra.get("tags", []) if metadata.extra else [],
205
+ }
206
+ capabilities = [cap.value for cap in (metadata.capabilities or [])]
207
+
208
+ health = statuses.get(provider_id)
209
+ health_dict = None
210
+ if health is not None:
211
+ health_dict = {
212
+ "status": "available" if health else "unavailable",
213
+ "available": health,
214
+ }
215
+
216
+ if not availability and not metadata_dict and health_dict is None:
217
+ _metrics.counter(_metric_name("status"), labels={"status": "not_found"})
218
+ return asdict(
219
+ error_response(
220
+ f"Provider '{provider_id}' not found",
221
+ error_code=ErrorCode.NOT_FOUND,
222
+ error_type=ErrorType.NOT_FOUND,
223
+ remediation="Use provider(action=list) to see registered providers",
224
+ request_id=request_id,
225
+ )
226
+ )
227
+
228
+ _metrics.counter(_metric_name("status"), labels={"status": "success"})
229
+ return asdict(
230
+ success_response(
231
+ data={
232
+ "provider_id": provider_id,
233
+ "available": availability,
234
+ "metadata": metadata_dict,
235
+ "capabilities": capabilities,
236
+ "health": health_dict,
237
+ },
238
+ request_id=request_id,
239
+ )
240
+ )
241
+
242
+
243
+ def _handle_execute(
244
+ *,
245
+ config: ServerConfig, # noqa: ARG001 - reserved for future hooks
246
+ provider_id: Optional[str] = None,
247
+ prompt: Optional[str] = None,
248
+ model: Optional[str] = None,
249
+ max_tokens: Optional[int] = None,
250
+ temperature: Optional[float] = None,
251
+ timeout: Optional[int] = None,
252
+ **_: Any,
253
+ ) -> dict:
254
+ request_id = _request_id()
255
+ action = "execute"
256
+
257
+ if not isinstance(provider_id, str) or not provider_id.strip():
258
+ return _validation_error(
259
+ action=action,
260
+ field="provider_id",
261
+ message="Provide a non-empty provider_id",
262
+ request_id=request_id,
263
+ remediation="Call provider(action=list) to discover valid providers",
264
+ code=ErrorCode.MISSING_REQUIRED,
265
+ )
266
+ provider_id = provider_id.strip()
267
+
268
+ if not isinstance(prompt, str) or not prompt.strip():
269
+ return _validation_error(
270
+ action=action,
271
+ field="prompt",
272
+ message="Provide a non-empty prompt",
273
+ request_id=request_id,
274
+ remediation="Supply the text you want to send to the provider",
275
+ code=ErrorCode.MISSING_REQUIRED,
276
+ )
277
+ prompt_text = prompt.strip()
278
+
279
+ model_name = None
280
+ if model is not None:
281
+ if not isinstance(model, str) or not model.strip():
282
+ return _validation_error(
283
+ action=action,
284
+ field="model",
285
+ message="Model overrides must be a non-empty string",
286
+ request_id=request_id,
287
+ )
288
+ model_name = model.strip()
289
+
290
+ if max_tokens is not None:
291
+ if isinstance(max_tokens, bool) or not isinstance(max_tokens, int):
292
+ return _validation_error(
293
+ action=action,
294
+ field="max_tokens",
295
+ message="max_tokens must be an integer",
296
+ request_id=request_id,
297
+ code=ErrorCode.INVALID_FORMAT,
298
+ )
299
+ if max_tokens <= 0:
300
+ return _validation_error(
301
+ action=action,
302
+ field="max_tokens",
303
+ message="max_tokens must be greater than zero",
304
+ request_id=request_id,
305
+ )
306
+
307
+ temp_value: Optional[float] = None
308
+ if temperature is not None:
309
+ if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
310
+ return _validation_error(
311
+ action=action,
312
+ field="temperature",
313
+ message="temperature must be a numeric value",
314
+ request_id=request_id,
315
+ code=ErrorCode.INVALID_FORMAT,
316
+ )
317
+ temp_value = float(temperature)
318
+ if temp_value < 0 or temp_value > 2:
319
+ return _validation_error(
320
+ action=action,
321
+ field="temperature",
322
+ message="temperature must be between 0.0 and 2.0",
323
+ request_id=request_id,
324
+ remediation="Choose a temperature in the inclusive range 0.0-2.0",
325
+ )
326
+
327
+ timeout_value: Optional[int] = None
328
+ if timeout is not None:
329
+ if isinstance(timeout, bool) or not isinstance(timeout, int):
330
+ return _validation_error(
331
+ action=action,
332
+ field="timeout",
333
+ message="timeout must be an integer representing seconds",
334
+ request_id=request_id,
335
+ code=ErrorCode.INVALID_FORMAT,
336
+ )
337
+ if timeout <= 0:
338
+ return _validation_error(
339
+ action=action,
340
+ field="timeout",
341
+ message="timeout must be greater than zero",
342
+ request_id=request_id,
343
+ )
344
+ timeout_value = timeout
345
+
346
+ try:
347
+ provider_summaries = describe_providers()
348
+ except Exception:
349
+ logger.exception("Failed to describe providers before execution")
350
+ _metrics.counter(_metric_name(action), labels={"status": "error"})
351
+ return asdict(
352
+ error_response(
353
+ "Failed to resolve provider registry",
354
+ error_code=ErrorCode.INTERNAL_ERROR,
355
+ error_type=ErrorType.INTERNAL,
356
+ remediation="Inspect provider registry configuration",
357
+ request_id=request_id,
358
+ )
359
+ )
360
+
361
+ known_providers = {
362
+ entry.get("id") for entry in provider_summaries if entry.get("id")
363
+ }
364
+ if provider_id not in known_providers:
365
+ _metrics.counter(_metric_name(action), labels={"status": "not_found"})
366
+ return asdict(
367
+ error_response(
368
+ f"Provider '{provider_id}' not found",
369
+ error_code=ErrorCode.NOT_FOUND,
370
+ error_type=ErrorType.NOT_FOUND,
371
+ remediation="Use provider(action=list) to see available providers",
372
+ request_id=request_id,
373
+ )
374
+ )
375
+
376
+ try:
377
+ if not check_provider_available(provider_id):
378
+ _metrics.counter(_metric_name(action), labels={"status": "unavailable"})
379
+ return asdict(
380
+ error_response(
381
+ f"Provider '{provider_id}' is not available",
382
+ error_code=ErrorCode.UNAVAILABLE,
383
+ error_type=ErrorType.UNAVAILABLE,
384
+ data={"provider_id": provider_id},
385
+ remediation="Verify provider credentials and availability",
386
+ request_id=request_id,
387
+ )
388
+ )
389
+ except Exception:
390
+ logger.exception(
391
+ "Failed to check provider availability", extra={"provider_id": provider_id}
392
+ )
393
+ _metrics.counter(_metric_name(action), labels={"status": "error"})
394
+ return asdict(
395
+ error_response(
396
+ "Failed to validate provider availability",
397
+ error_code=ErrorCode.INTERNAL_ERROR,
398
+ error_type=ErrorType.INTERNAL,
399
+ remediation="Inspect provider detector configuration",
400
+ request_id=request_id,
401
+ )
402
+ )
403
+
404
+ hooks = ProviderHooks()
405
+ try:
406
+ provider_ctx = resolve_provider(provider_id, hooks=hooks, model=model_name)
407
+ except ProviderUnavailableError as exc:
408
+ _metrics.counter(_metric_name(action), labels={"status": "unavailable"})
409
+ return asdict(
410
+ error_response(
411
+ sanitize_error_message(exc, context="providers"),
412
+ error_code=ErrorCode.UNAVAILABLE,
413
+ error_type=ErrorType.UNAVAILABLE,
414
+ data={"provider_id": provider_id},
415
+ remediation="Verify provider configuration and retry",
416
+ request_id=request_id,
417
+ )
418
+ )
419
+
420
+ request = ProviderRequest(
421
+ prompt=prompt_text,
422
+ model=model_name,
423
+ max_tokens=max_tokens,
424
+ temperature=temp_value,
425
+ timeout=timeout_value or 300,
426
+ stream=False,
427
+ )
428
+
429
+ metric_key = _metric_name(action)
430
+ start_time = time.perf_counter()
431
+ try:
432
+ result = provider_ctx.generate(request)
433
+ except RateLimitError as exc:
434
+ _metrics.counter(metric_key, labels={"status": "rate_limited"})
435
+ retry_after = exc.retry_after if exc.retry_after is not None else 0
436
+ return asdict(
437
+ error_response(
438
+ f"Provider '{provider_id}' rate limited the request",
439
+ error_code=ErrorCode.RATE_LIMIT_EXCEEDED,
440
+ error_type=ErrorType.RATE_LIMIT,
441
+ data={"provider_id": provider_id, "retry_after_seconds": retry_after},
442
+ remediation="Wait before retrying or reduce concurrent executions",
443
+ request_id=request_id,
444
+ rate_limit={
445
+ "status": "rate_limited",
446
+ "retry_after_seconds": retry_after,
447
+ "provider": provider_id,
448
+ },
449
+ )
450
+ )
451
+ except ProviderTimeoutError:
452
+ _metrics.counter(metric_key, labels={"status": "timeout"})
453
+ return asdict(
454
+ error_response(
455
+ f"Provider '{provider_id}' timed out",
456
+ error_code=ErrorCode.AI_PROVIDER_TIMEOUT,
457
+ error_type=ErrorType.UNAVAILABLE,
458
+ data={"provider_id": provider_id},
459
+ remediation="Increase timeout or simplify the prompt",
460
+ request_id=request_id,
461
+ )
462
+ )
463
+ except ProviderExecutionError:
464
+ _metrics.counter(metric_key, labels={"status": "provider_error"})
465
+ return asdict(
466
+ error_response(
467
+ f"Provider '{provider_id}' execution failed",
468
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
469
+ error_type=ErrorType.AI_PROVIDER,
470
+ data={"provider_id": provider_id},
471
+ remediation="Inspect provider logs and retry after resolving the issue",
472
+ request_id=request_id,
473
+ )
474
+ )
475
+ except Exception as exc:
476
+ logger.exception(
477
+ "Unexpected provider execution failure", extra={"provider_id": provider_id}
478
+ )
479
+ _metrics.counter(metric_key, labels={"status": "error"})
480
+ return asdict(
481
+ error_response(
482
+ sanitize_error_message(exc, context="providers"),
483
+ error_code=ErrorCode.INTERNAL_ERROR,
484
+ error_type=ErrorType.INTERNAL,
485
+ remediation="Check provider configuration and retry",
486
+ request_id=request_id,
487
+ )
488
+ )
489
+
490
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
491
+ response_data: Dict[str, Any] = {
492
+ "provider_id": provider_id,
493
+ "model": result.model_used or model_name or "default",
494
+ "content": result.content,
495
+ "finish_reason": result.status.value if result.status else None,
496
+ }
497
+ if result.tokens and result.tokens.total_tokens > 0:
498
+ response_data["token_usage"] = {
499
+ "prompt_tokens": result.tokens.input_tokens,
500
+ "completion_tokens": result.tokens.output_tokens,
501
+ "total_tokens": result.tokens.total_tokens,
502
+ }
503
+
504
+ _metrics.counter(metric_key, labels={"status": "success"})
505
+ return asdict(
506
+ success_response(
507
+ data=response_data,
508
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
509
+ request_id=request_id,
510
+ )
511
+ )
512
+
513
+
514
+ _PROVIDER_ROUTER = ActionRouter(
515
+ tool_name="provider",
516
+ actions=[
517
+ ActionDefinition(
518
+ name="list",
519
+ handler=_handle_list,
520
+ summary=_ACTION_SUMMARY["list"],
521
+ aliases=("provider_list",),
522
+ ),
523
+ ActionDefinition(
524
+ name="status",
525
+ handler=_handle_status,
526
+ summary=_ACTION_SUMMARY["status"],
527
+ aliases=("provider_status",),
528
+ ),
529
+ ActionDefinition(
530
+ name="execute",
531
+ handler=_handle_execute,
532
+ summary=_ACTION_SUMMARY["execute"],
533
+ aliases=("provider_execute",),
534
+ ),
535
+ ],
536
+ )
537
+
538
+
539
+ def _dispatch_provider_action(
540
+ *, action: str, payload: Dict[str, Any], config: ServerConfig
541
+ ) -> dict:
542
+ try:
543
+ return _PROVIDER_ROUTER.dispatch(action=action, config=config, **payload)
544
+ except ActionRouterError as exc:
545
+ request_id = _request_id()
546
+ allowed = ", ".join(exc.allowed_actions)
547
+ return asdict(
548
+ error_response(
549
+ f"Unsupported provider action '{action}'. Allowed actions: {allowed}",
550
+ error_code=ErrorCode.VALIDATION_ERROR,
551
+ error_type=ErrorType.VALIDATION,
552
+ remediation=f"Use one of: {allowed}",
553
+ request_id=request_id,
554
+ )
555
+ )
556
+
557
+
558
+ def register_unified_provider_tool(mcp: FastMCP, config: ServerConfig) -> None:
559
+ """Register the consolidated provider tool."""
560
+
561
+ @canonical_tool(mcp, canonical_name="provider")
562
+ @mcp_tool(tool_name="provider", emit_metrics=True, audit=True)
563
+ def provider( # noqa: PLR0913 - unified signature spans multiple actions
564
+ action: str,
565
+ include_unavailable: Optional[bool] = False,
566
+ provider_id: Optional[str] = None,
567
+ prompt: Optional[str] = None,
568
+ model: Optional[str] = None,
569
+ max_tokens: Optional[int] = None,
570
+ temperature: Optional[float] = None,
571
+ timeout: Optional[int] = None,
572
+ ) -> dict:
573
+ payload = {
574
+ "include_unavailable": include_unavailable,
575
+ "provider_id": provider_id,
576
+ "prompt": prompt,
577
+ "model": model,
578
+ "max_tokens": max_tokens,
579
+ "temperature": temperature,
580
+ "timeout": timeout,
581
+ }
582
+ return _dispatch_provider_action(action=action, payload=payload, config=config)
583
+
584
+ logger.debug("Registered unified provider tool")
585
+
586
+
587
+ __all__ = [
588
+ "register_unified_provider_tool",
589
+ ]