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