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,314 @@
1
+ """Shared review helpers for the unified review tool.
2
+
3
+ This module centralizes the reusable building blocks used by
4
+ `foundry_mcp.tools.unified.review` so the main router stays focused on
5
+ input validation and action dispatch.
6
+
7
+ The implementation is adapted from the legacy CLI review command helpers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import time
15
+ from dataclasses import asdict
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Optional
18
+
19
+ from foundry_mcp.core.responses import (
20
+ ErrorCode,
21
+ ErrorType,
22
+ ai_no_provider_error,
23
+ ai_provider_error,
24
+ ai_provider_timeout_error,
25
+ error_response,
26
+ success_response,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # Default AI consultation timeout
33
+ DEFAULT_AI_TIMEOUT = 360.0
34
+
35
+ # Review types supported by the unified `review(action="spec")` entrypoint.
36
+ REVIEW_TYPES = ["quick", "full", "security", "feasibility"]
37
+
38
+ # Map review types to PLAN_REVIEW templates
39
+ _REVIEW_TYPE_TO_TEMPLATE = {
40
+ "full": "PLAN_REVIEW_FULL_V1",
41
+ "security": "PLAN_REVIEW_SECURITY_V1",
42
+ "feasibility": "PLAN_REVIEW_FEASIBILITY_V1",
43
+ }
44
+
45
+
46
+ def _get_llm_status() -> Dict[str, Any]:
47
+ """Get LLM configuration status.
48
+
49
+ This is a lightweight wrapper around `foundry_mcp.core.review.get_llm_status`
50
+ that normalizes exception handling for tool surfaces.
51
+ """
52
+
53
+ try:
54
+ from foundry_mcp.core.review import get_llm_status
55
+
56
+ return get_llm_status()
57
+ except Exception as exc:
58
+ logger.debug("Failed to get LLM status: %s", exc)
59
+ return {"configured": False, "error": "Failed to load LLM configuration"}
60
+
61
+
62
+ def _run_quick_review(
63
+ *,
64
+ spec_id: str,
65
+ specs_dir: Optional[Path],
66
+ dry_run: bool,
67
+ llm_status: Dict[str, Any],
68
+ start_time: float,
69
+ ) -> dict:
70
+ if dry_run:
71
+ duration_ms = (time.perf_counter() - start_time) * 1000
72
+ return asdict(
73
+ success_response(
74
+ spec_id=spec_id,
75
+ review_type="quick",
76
+ dry_run=True,
77
+ llm_status=llm_status,
78
+ message="Dry run - quick review skipped",
79
+ telemetry={"duration_ms": round(duration_ms, 2)},
80
+ )
81
+ )
82
+
83
+ try:
84
+ from foundry_mcp.core.review import quick_review
85
+
86
+ result = quick_review(spec_id=spec_id, specs_dir=specs_dir)
87
+ except Exception as exc:
88
+ logger.exception("Quick review failed")
89
+ return asdict(
90
+ error_response(
91
+ f"Quick review failed: {exc}",
92
+ error_code=ErrorCode.INTERNAL_ERROR,
93
+ error_type=ErrorType.INTERNAL,
94
+ remediation="Check logs for details and retry.",
95
+ )
96
+ )
97
+
98
+ duration_ms = (time.perf_counter() - start_time) * 1000
99
+
100
+ payload = asdict(result)
101
+ payload["llm_status"] = llm_status
102
+
103
+ return asdict(
104
+ success_response(
105
+ **payload,
106
+ telemetry={"duration_ms": round(duration_ms, 2)},
107
+ )
108
+ )
109
+
110
+
111
+ def _run_ai_review(
112
+ *,
113
+ spec_id: str,
114
+ specs_dir: Optional[Path],
115
+ review_type: str,
116
+ ai_provider: Optional[str],
117
+ model: Optional[str],
118
+ ai_timeout: float,
119
+ consultation_cache: bool,
120
+ dry_run: bool,
121
+ llm_status: Dict[str, Any],
122
+ start_time: float,
123
+ ) -> dict:
124
+ template_id = _REVIEW_TYPE_TO_TEMPLATE.get(review_type)
125
+ if template_id is None:
126
+ return asdict(
127
+ error_response(
128
+ f"Unknown review type: {review_type}",
129
+ error_code=ErrorCode.VALIDATION_ERROR,
130
+ error_type=ErrorType.VALIDATION,
131
+ remediation=f"Use one of: {', '.join(_REVIEW_TYPE_TO_TEMPLATE.keys())}",
132
+ data={"review_type": review_type},
133
+ )
134
+ )
135
+
136
+ try:
137
+ from foundry_mcp.core.review import prepare_review_context
138
+
139
+ context = prepare_review_context(
140
+ spec_id=spec_id,
141
+ specs_dir=specs_dir,
142
+ include_tasks=True,
143
+ include_journals=True,
144
+ )
145
+ except Exception as exc:
146
+ logger.exception("Failed preparing review context")
147
+ return asdict(
148
+ error_response(
149
+ f"Failed preparing review context: {exc}",
150
+ error_code=ErrorCode.INTERNAL_ERROR,
151
+ error_type=ErrorType.INTERNAL,
152
+ remediation="Check logs for details and retry.",
153
+ )
154
+ )
155
+
156
+ if context is None:
157
+ return asdict(
158
+ error_response(
159
+ f"Specification '{spec_id}' not found",
160
+ error_code=ErrorCode.SPEC_NOT_FOUND,
161
+ error_type=ErrorType.NOT_FOUND,
162
+ remediation="Verify the spec_id and that the spec exists.",
163
+ data={"spec_id": spec_id},
164
+ )
165
+ )
166
+
167
+ if dry_run:
168
+ duration_ms = (time.perf_counter() - start_time) * 1000
169
+ return asdict(
170
+ success_response(
171
+ spec_id=spec_id,
172
+ title=context.title,
173
+ review_type=review_type,
174
+ template_id=template_id,
175
+ dry_run=True,
176
+ llm_status=llm_status,
177
+ ai_provider=ai_provider,
178
+ model=model,
179
+ consultation_cache=consultation_cache,
180
+ message=f"Dry run - {review_type} review would use template {template_id}",
181
+ stats={
182
+ "total_tasks": context.stats.totals.get("tasks", 0)
183
+ if context.stats
184
+ else 0,
185
+ },
186
+ telemetry={"duration_ms": round(duration_ms, 2)},
187
+ )
188
+ )
189
+
190
+ try:
191
+ from foundry_mcp.core.ai_consultation import (
192
+ ConsultationOrchestrator,
193
+ ConsultationRequest,
194
+ ConsultationWorkflow,
195
+ )
196
+ from foundry_mcp.core.llm_config import load_consultation_config
197
+ except ImportError:
198
+ return asdict(
199
+ error_response(
200
+ "AI consultation layer not available",
201
+ error_code=ErrorCode.UNAVAILABLE,
202
+ error_type=ErrorType.UNAVAILABLE,
203
+ remediation="Ensure foundry_mcp.core.ai_consultation is installed.",
204
+ )
205
+ )
206
+
207
+ # Load consultation config from workspace (fixes config discovery issue)
208
+ # Derive workspace from specs_dir - check parent directories for config
209
+ config_file = None
210
+ if specs_dir:
211
+ ws_path = specs_dir.parent if specs_dir.name == "specs" else specs_dir
212
+ for _ in range(5): # Search up to 5 levels for foundry-mcp.toml
213
+ candidate = ws_path / "foundry-mcp.toml"
214
+ if candidate.exists():
215
+ config_file = candidate
216
+ break
217
+ if ws_path.parent == ws_path: # Reached root
218
+ break
219
+ ws_path = ws_path.parent
220
+ consultation_config = load_consultation_config(config_file=config_file)
221
+ orchestrator = ConsultationOrchestrator(config=consultation_config, default_timeout=ai_timeout)
222
+
223
+ if not orchestrator.is_available(provider_id=ai_provider):
224
+ return asdict(
225
+ ai_no_provider_error(
226
+ "AI-enhanced review requested but no providers available",
227
+ required_providers=[ai_provider] if ai_provider else None,
228
+ )
229
+ )
230
+
231
+ spec_content = json.dumps(context.spec_data, indent=2)
232
+
233
+ request = ConsultationRequest(
234
+ workflow=ConsultationWorkflow.PLAN_REVIEW,
235
+ prompt_id=template_id,
236
+ context={
237
+ "spec_content": spec_content,
238
+ "spec_id": spec_id,
239
+ "title": context.title,
240
+ "review_type": review_type,
241
+ },
242
+ provider_id=ai_provider,
243
+ model=model,
244
+ timeout=ai_timeout,
245
+ )
246
+
247
+ try:
248
+ result = orchestrator.consult(request, use_cache=consultation_cache)
249
+ except Exception as exc:
250
+ error_lower = str(exc).lower()
251
+ if "timeout" in error_lower or "timed out" in error_lower:
252
+ return asdict(
253
+ ai_provider_timeout_error(
254
+ ai_provider or "unknown",
255
+ int(ai_timeout),
256
+ )
257
+ )
258
+
259
+ return asdict(ai_provider_error(ai_provider or "unknown", str(exc)))
260
+
261
+ from foundry_mcp.core.ai_consultation import ConsensusResult
262
+
263
+ duration_ms = (time.perf_counter() - start_time) * 1000
264
+
265
+ is_consensus = isinstance(result, ConsensusResult)
266
+ if is_consensus:
267
+ primary = (
268
+ result.successful_responses[0] if result.successful_responses else None
269
+ )
270
+ provider_id = primary.provider_id if primary else None
271
+ model_used = primary.model_used if primary else None
272
+ cached = bool(primary.cache_hit) if primary else False
273
+ content = result.primary_content
274
+ consensus = {
275
+ "mode": "multi_model",
276
+ "total_providers": len(result.responses),
277
+ "successful_providers": len(result.successful_responses),
278
+ "failed_providers": len(result.failed_responses),
279
+ }
280
+ else:
281
+ provider_id = getattr(result, "provider_id", ai_provider)
282
+ model_used = getattr(result, "model_used", None)
283
+ cached = bool(getattr(result, "cache_hit", False))
284
+ content = getattr(result, "content", None)
285
+ consensus = {"mode": "single_model"}
286
+
287
+ total_tasks = context.stats.totals.get("tasks", 0) if context.stats else 0
288
+ completed_tasks = (
289
+ context.stats.status_counts.get("completed", 0) if context.stats else 0
290
+ )
291
+
292
+ return asdict(
293
+ success_response(
294
+ spec_id=spec_id,
295
+ title=context.title,
296
+ review_type=review_type,
297
+ template_id=template_id,
298
+ llm_status=llm_status,
299
+ ai_provider=provider_id,
300
+ model=model_used,
301
+ consultation_cache=consultation_cache,
302
+ response=content,
303
+ cached=cached,
304
+ consensus=consensus,
305
+ stats={
306
+ "total_tasks": total_tasks,
307
+ "completed_tasks": completed_tasks,
308
+ "progress_percentage": context.progress.get("percentage", 0)
309
+ if context.progress
310
+ else 0,
311
+ },
312
+ telemetry={"duration_ms": round(duration_ms, 2)},
313
+ )
314
+ )
@@ -0,0 +1,102 @@
1
+ """Action routing utilities for unified MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Dict, cast
8
+
9
+
10
+ class ActionRouterError(ValueError):
11
+ """Raised when an unsupported action is requested."""
12
+
13
+ def __init__(self, message: str, *, allowed_actions: Sequence[str]) -> None:
14
+ super().__init__(message)
15
+ self.allowed_actions = tuple(allowed_actions)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ActionDefinition:
20
+ """Describe an action handler for a unified tool."""
21
+
22
+ name: str
23
+ handler: Callable[..., dict]
24
+ summary: str | None = None
25
+ aliases: Sequence[str] = ()
26
+
27
+
28
+ class ActionRouter:
29
+ """Route action requests to the correct handler."""
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ tool_name: str,
35
+ actions: Iterable[ActionDefinition] | Mapping[str, Callable[..., dict]],
36
+ case_sensitive: bool = False,
37
+ ) -> None:
38
+ if isinstance(actions, Mapping):
39
+ mapping_actions = cast(Mapping[str, Callable[..., dict]], actions)
40
+ normalized_actions = [
41
+ ActionDefinition(name=name, handler=handler)
42
+ for name, handler in mapping_actions.items()
43
+ ]
44
+ else:
45
+ iterable_actions = cast(Iterable[ActionDefinition], actions)
46
+ normalized_actions = list(iterable_actions)
47
+
48
+ if not normalized_actions:
49
+ raise ValueError("ActionRouter requires at least one action")
50
+
51
+ self._tool_name = tool_name
52
+ self._case_sensitive = case_sensitive
53
+ self._handlers: Dict[str, Callable[..., dict]] = {}
54
+ self._canonical: Dict[str, str] = {}
55
+ self._summaries: Dict[str, str | None] = {}
56
+
57
+ for action_def in normalized_actions:
58
+ canonical_name = action_def.name
59
+ if not canonical_name:
60
+ raise ValueError("Action names must be non-empty strings")
61
+
62
+ names = [canonical_name, *(action_def.aliases or ())]
63
+ for name in names:
64
+ if not name:
65
+ continue
66
+ key = name if case_sensitive else name.lower()
67
+ if key in self._handlers:
68
+ raise ValueError(
69
+ f"Duplicate action alias '{name}' for tool '{tool_name}'"
70
+ )
71
+ self._handlers[key] = action_def.handler
72
+ self._canonical[key] = canonical_name
73
+ self._summaries[canonical_name] = action_def.summary
74
+
75
+ @property
76
+ def tool_name(self) -> str:
77
+ return self._tool_name
78
+
79
+ def allowed_actions(self) -> tuple[str, ...]:
80
+ return tuple(sorted(set(self._canonical.values())))
81
+
82
+ def dispatch(self, action: str | None, **kwargs) -> dict:
83
+ if not action:
84
+ raise ActionRouterError(
85
+ f"{self._tool_name} requires an action",
86
+ allowed_actions=self.allowed_actions(),
87
+ )
88
+
89
+ key = action if self._case_sensitive else action.lower()
90
+ handler = self._handlers.get(key)
91
+ if handler is None:
92
+ raise ActionRouterError(
93
+ f"Unsupported action '{action}' for {self._tool_name}",
94
+ allowed_actions=self.allowed_actions(),
95
+ )
96
+
97
+ return handler(**kwargs)
98
+
99
+ def describe(self) -> Dict[str, str | None]:
100
+ """Return summaries for the canonical actions."""
101
+
102
+ return dict(self._summaries)