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.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -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 +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Unified server discovery tool with action routing.
|
|
2
|
+
|
|
3
|
+
Consolidates discovery/context helpers into a single `server(action=...)` tool.
|
|
4
|
+
Only the unified tool surface is exposed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from foundry_mcp.config import ServerConfig
|
|
19
|
+
from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
|
|
20
|
+
from foundry_mcp.core.discovery import get_capabilities, get_tool_registry
|
|
21
|
+
from foundry_mcp.core.naming import canonical_tool
|
|
22
|
+
from foundry_mcp.core.observability import (
|
|
23
|
+
get_metrics,
|
|
24
|
+
get_observability_manager,
|
|
25
|
+
mcp_tool,
|
|
26
|
+
)
|
|
27
|
+
from foundry_mcp.core.pagination import (
|
|
28
|
+
DEFAULT_PAGE_SIZE,
|
|
29
|
+
MAX_PAGE_SIZE,
|
|
30
|
+
CursorError,
|
|
31
|
+
decode_cursor,
|
|
32
|
+
encode_cursor,
|
|
33
|
+
paginated_response,
|
|
34
|
+
)
|
|
35
|
+
from foundry_mcp.core.responses import (
|
|
36
|
+
ErrorCode,
|
|
37
|
+
ErrorType,
|
|
38
|
+
error_response,
|
|
39
|
+
success_response,
|
|
40
|
+
)
|
|
41
|
+
from foundry_mcp.tools.unified.context_helpers import (
|
|
42
|
+
build_llm_status_response,
|
|
43
|
+
build_server_context_response,
|
|
44
|
+
)
|
|
45
|
+
from foundry_mcp.tools.unified.router import (
|
|
46
|
+
ActionDefinition,
|
|
47
|
+
ActionRouter,
|
|
48
|
+
ActionRouterError,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
_metrics = get_metrics()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _request_id() -> str:
|
|
56
|
+
return get_correlation_id() or generate_correlation_id(prefix="server")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _metric(action: str) -> str:
|
|
60
|
+
return f"unified_tools.server.{action.replace('-', '_')}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
MANIFEST_TOKEN_BUDGET = 16_000
|
|
64
|
+
MANIFEST_TOKEN_BUDGET_MAX = 18_000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=1)
|
|
68
|
+
def _get_tokenizer() -> Any | None:
|
|
69
|
+
try:
|
|
70
|
+
import tiktoken
|
|
71
|
+
|
|
72
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _estimate_tokens(text: str) -> int:
|
|
78
|
+
"""Estimate token usage for manifest budget dashboards.
|
|
79
|
+
|
|
80
|
+
Uses `tiktoken` when available, otherwise falls back to a conservative
|
|
81
|
+
~4-chars-per-token heuristic.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
tokenizer = _get_tokenizer()
|
|
85
|
+
if tokenizer is not None:
|
|
86
|
+
return len(tokenizer.encode(text))
|
|
87
|
+
|
|
88
|
+
return max(1, len(text) // 4)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validation_error(
|
|
92
|
+
*, message: str, request_id: str, remediation: Optional[str] = None
|
|
93
|
+
) -> dict:
|
|
94
|
+
return asdict(
|
|
95
|
+
error_response(
|
|
96
|
+
message,
|
|
97
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
98
|
+
error_type=ErrorType.VALIDATION,
|
|
99
|
+
remediation=remediation,
|
|
100
|
+
request_id=request_id,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_unified_manifest_tools() -> list[Dict[str, Any]]:
|
|
106
|
+
"""Return compact tool entries for the 16-tool unified manifest."""
|
|
107
|
+
|
|
108
|
+
from foundry_mcp.tools.unified.authoring import _AUTHORING_ROUTER
|
|
109
|
+
from foundry_mcp.tools.unified.environment import _ENVIRONMENT_ROUTER
|
|
110
|
+
from foundry_mcp.tools.unified.error import _ERROR_ROUTER
|
|
111
|
+
from foundry_mcp.tools.unified.health import _HEALTH_ROUTER
|
|
112
|
+
from foundry_mcp.tools.unified.journal import _JOURNAL_ROUTER
|
|
113
|
+
from foundry_mcp.tools.unified.lifecycle import _LIFECYCLE_ROUTER
|
|
114
|
+
from foundry_mcp.tools.unified.metrics import _METRICS_ROUTER
|
|
115
|
+
from foundry_mcp.tools.unified.plan import _PLAN_ROUTER
|
|
116
|
+
from foundry_mcp.tools.unified.pr import _PR_ROUTER
|
|
117
|
+
from foundry_mcp.tools.unified.provider import _PROVIDER_ROUTER
|
|
118
|
+
from foundry_mcp.tools.unified.review import _REVIEW_ROUTER
|
|
119
|
+
from foundry_mcp.tools.unified.spec import _SPEC_ROUTER
|
|
120
|
+
from foundry_mcp.tools.unified.task import _TASK_ROUTER
|
|
121
|
+
from foundry_mcp.tools.unified.test import _TEST_ROUTER
|
|
122
|
+
from foundry_mcp.tools.unified.verification import _VERIFICATION_ROUTER
|
|
123
|
+
|
|
124
|
+
routers = {
|
|
125
|
+
"health": _HEALTH_ROUTER,
|
|
126
|
+
"plan": _PLAN_ROUTER,
|
|
127
|
+
"pr": _PR_ROUTER,
|
|
128
|
+
"error": _ERROR_ROUTER,
|
|
129
|
+
"metrics": _METRICS_ROUTER,
|
|
130
|
+
"journal": _JOURNAL_ROUTER,
|
|
131
|
+
"authoring": _AUTHORING_ROUTER,
|
|
132
|
+
"provider": _PROVIDER_ROUTER,
|
|
133
|
+
"environment": _ENVIRONMENT_ROUTER,
|
|
134
|
+
"lifecycle": _LIFECYCLE_ROUTER,
|
|
135
|
+
"verification": _VERIFICATION_ROUTER,
|
|
136
|
+
"task": _TASK_ROUTER,
|
|
137
|
+
"spec": _SPEC_ROUTER,
|
|
138
|
+
"review": _REVIEW_ROUTER,
|
|
139
|
+
"server": _SERVER_ROUTER,
|
|
140
|
+
"test": _TEST_ROUTER,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
categories = {
|
|
144
|
+
"health": "health",
|
|
145
|
+
"plan": "planning",
|
|
146
|
+
"pr": "workflow",
|
|
147
|
+
"error": "observability",
|
|
148
|
+
"metrics": "observability",
|
|
149
|
+
"journal": "journal",
|
|
150
|
+
"authoring": "specs",
|
|
151
|
+
"provider": "providers",
|
|
152
|
+
"environment": "environment",
|
|
153
|
+
"lifecycle": "lifecycle",
|
|
154
|
+
"verification": "verification",
|
|
155
|
+
"task": "tasks",
|
|
156
|
+
"spec": "specs",
|
|
157
|
+
"review": "review",
|
|
158
|
+
"server": "server",
|
|
159
|
+
"test": "testing",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
descriptions = {
|
|
163
|
+
"health": "Health checks and diagnostics.",
|
|
164
|
+
"plan": "Planning helpers (create/list/review plans).",
|
|
165
|
+
"pr": "PR workflows with spec context.",
|
|
166
|
+
"error": "Error collection query and cleanup.",
|
|
167
|
+
"metrics": "Metrics query, stats, and cleanup.",
|
|
168
|
+
"journal": "Journaling add/list helpers.",
|
|
169
|
+
"authoring": "Spec authoring mutations (phases, assumptions, revisions).",
|
|
170
|
+
"provider": "LLM provider discovery and execution.",
|
|
171
|
+
"environment": "Workspace init + environment verification.",
|
|
172
|
+
"lifecycle": "Spec lifecycle transitions.",
|
|
173
|
+
"verification": "Verification definition + execution.",
|
|
174
|
+
"task": "Task preparation, mutation, and listing.",
|
|
175
|
+
"spec": "Spec discovery, validation, and analysis.",
|
|
176
|
+
"review": "LLM-assisted review workflows.",
|
|
177
|
+
"server": "Tool discovery, schemas, context, and capabilities.",
|
|
178
|
+
"test": "Pytest discovery and execution.",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
tools: list[Dict[str, Any]] = []
|
|
182
|
+
for name, router in routers.items():
|
|
183
|
+
summaries = router.describe()
|
|
184
|
+
actions = [
|
|
185
|
+
{"name": action, "summary": summaries.get(action)}
|
|
186
|
+
for action in router.allowed_actions()
|
|
187
|
+
]
|
|
188
|
+
tools.append(
|
|
189
|
+
{
|
|
190
|
+
"name": name,
|
|
191
|
+
"description": descriptions.get(name, ""),
|
|
192
|
+
"category": categories.get(name, "general"),
|
|
193
|
+
"version": "1.0.0",
|
|
194
|
+
"deprecated": False,
|
|
195
|
+
"tags": ["unified"],
|
|
196
|
+
"actions": actions,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return tools
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _handle_tools(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
|
|
204
|
+
request_id = _request_id()
|
|
205
|
+
|
|
206
|
+
category = payload.get("category")
|
|
207
|
+
if category is not None and not isinstance(category, str):
|
|
208
|
+
return _validation_error(
|
|
209
|
+
message="category must be a string",
|
|
210
|
+
request_id=request_id,
|
|
211
|
+
remediation="Provide a category name like 'specs'",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
tag = payload.get("tag")
|
|
215
|
+
if tag is not None and not isinstance(tag, str):
|
|
216
|
+
return _validation_error(
|
|
217
|
+
message="tag must be a string",
|
|
218
|
+
request_id=request_id,
|
|
219
|
+
remediation="Provide a tag name like 'read'",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
include_deprecated_value = payload.get("include_deprecated", False)
|
|
223
|
+
if include_deprecated_value is not None and not isinstance(
|
|
224
|
+
include_deprecated_value, bool
|
|
225
|
+
):
|
|
226
|
+
return _validation_error(
|
|
227
|
+
message="include_deprecated must be a boolean",
|
|
228
|
+
request_id=request_id,
|
|
229
|
+
remediation="Provide include_deprecated=true|false",
|
|
230
|
+
)
|
|
231
|
+
include_deprecated = (
|
|
232
|
+
include_deprecated_value
|
|
233
|
+
if isinstance(include_deprecated_value, bool)
|
|
234
|
+
else False
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
cursor = payload.get("cursor")
|
|
238
|
+
if cursor is not None and not isinstance(cursor, str):
|
|
239
|
+
return _validation_error(
|
|
240
|
+
message="cursor must be a string",
|
|
241
|
+
request_id=request_id,
|
|
242
|
+
remediation="Use the cursor provided in meta.pagination",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
limit = payload.get("limit", DEFAULT_PAGE_SIZE)
|
|
246
|
+
try:
|
|
247
|
+
limit_int = int(limit)
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
return _validation_error(
|
|
250
|
+
message="limit must be an integer",
|
|
251
|
+
request_id=request_id,
|
|
252
|
+
remediation=f"Provide an integer between 1 and {MAX_PAGE_SIZE}",
|
|
253
|
+
)
|
|
254
|
+
limit_int = min(max(1, limit_int), MAX_PAGE_SIZE)
|
|
255
|
+
|
|
256
|
+
start_time = time.perf_counter()
|
|
257
|
+
|
|
258
|
+
# Always use unified manifest (feature flags removed)
|
|
259
|
+
all_tools = _build_unified_manifest_tools()
|
|
260
|
+
if category:
|
|
261
|
+
all_tools = [tool for tool in all_tools if tool.get("category") == category]
|
|
262
|
+
if tag:
|
|
263
|
+
all_tools = [tool for tool in all_tools if tag in (tool.get("tags") or [])]
|
|
264
|
+
categories_list = sorted(
|
|
265
|
+
{tool.get("category", "general") for tool in all_tools}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
start_idx = 0
|
|
269
|
+
if cursor:
|
|
270
|
+
try:
|
|
271
|
+
cursor_data = decode_cursor(cursor)
|
|
272
|
+
start_idx = int(cursor_data.get("offset", 0))
|
|
273
|
+
except (CursorError, ValueError, TypeError) as exc:
|
|
274
|
+
return asdict(
|
|
275
|
+
error_response(
|
|
276
|
+
f"Invalid cursor: {exc}",
|
|
277
|
+
error_code=ErrorCode.INVALID_FORMAT,
|
|
278
|
+
error_type=ErrorType.VALIDATION,
|
|
279
|
+
remediation="Use the cursor returned by server(action=tools)",
|
|
280
|
+
request_id=request_id,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
end_idx = start_idx + limit_int
|
|
285
|
+
paginated_tools = all_tools[start_idx:end_idx]
|
|
286
|
+
has_more = end_idx < len(all_tools)
|
|
287
|
+
next_cursor = encode_cursor({"offset": end_idx}) if has_more else None
|
|
288
|
+
|
|
289
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
290
|
+
_metrics.timer(_metric("tools") + ".duration_ms", elapsed_ms)
|
|
291
|
+
|
|
292
|
+
response = paginated_response(
|
|
293
|
+
data={
|
|
294
|
+
"tools": paginated_tools,
|
|
295
|
+
"categories": categories_list,
|
|
296
|
+
"filters_applied": {
|
|
297
|
+
"category": category,
|
|
298
|
+
"tag": tag,
|
|
299
|
+
"include_deprecated": include_deprecated,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
cursor=next_cursor,
|
|
303
|
+
has_more=has_more,
|
|
304
|
+
page_size=limit_int,
|
|
305
|
+
total_count=len(all_tools),
|
|
306
|
+
)
|
|
307
|
+
telemetry = response.setdefault("meta", {}).setdefault("telemetry", {})
|
|
308
|
+
telemetry["duration_ms"] = round(elapsed_ms, 2)
|
|
309
|
+
|
|
310
|
+
manifest_label = "unified"
|
|
311
|
+
manifest_tokens = _estimate_tokens(
|
|
312
|
+
json.dumps(all_tools, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
313
|
+
)
|
|
314
|
+
telemetry["manifest_tokens"] = manifest_tokens
|
|
315
|
+
telemetry["manifest_tool_count"] = len(all_tools)
|
|
316
|
+
telemetry["manifest_token_budget"] = MANIFEST_TOKEN_BUDGET
|
|
317
|
+
telemetry["manifest_token_budget_max"] = MANIFEST_TOKEN_BUDGET_MAX
|
|
318
|
+
|
|
319
|
+
warning_message: str | None = None
|
|
320
|
+
if manifest_tokens > MANIFEST_TOKEN_BUDGET_MAX:
|
|
321
|
+
warning_message = (
|
|
322
|
+
"Manifest token estimate "
|
|
323
|
+
f"{manifest_tokens} exceeds maximum budget {MANIFEST_TOKEN_BUDGET_MAX}; "
|
|
324
|
+
"clients may fail to load the manifest."
|
|
325
|
+
)
|
|
326
|
+
elif manifest_tokens > MANIFEST_TOKEN_BUDGET:
|
|
327
|
+
warning_message = (
|
|
328
|
+
"Manifest token estimate "
|
|
329
|
+
f"{manifest_tokens} exceeds budget {MANIFEST_TOKEN_BUDGET}; "
|
|
330
|
+
"trim tool/action metadata to reduce token load."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if warning_message:
|
|
334
|
+
meta = response.setdefault("meta", {})
|
|
335
|
+
warnings = meta.get("warnings")
|
|
336
|
+
if warnings is None:
|
|
337
|
+
warnings = []
|
|
338
|
+
elif not isinstance(warnings, list):
|
|
339
|
+
warnings = [str(warnings)]
|
|
340
|
+
warnings.append(warning_message)
|
|
341
|
+
meta["warnings"] = warnings
|
|
342
|
+
|
|
343
|
+
manager = get_observability_manager()
|
|
344
|
+
if manager.is_metrics_enabled():
|
|
345
|
+
exporter = manager.get_prometheus_exporter()
|
|
346
|
+
exporter.record_manifest_snapshot(
|
|
347
|
+
manifest=manifest_label,
|
|
348
|
+
tokens=manifest_tokens,
|
|
349
|
+
tool_count=len(all_tools),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
response["meta"]["request_id"] = request_id
|
|
353
|
+
return response
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _handle_schema(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
|
|
357
|
+
request_id = _request_id()
|
|
358
|
+
tool_name = payload.get("tool_name")
|
|
359
|
+
if not isinstance(tool_name, str) or not tool_name.strip():
|
|
360
|
+
return _validation_error(
|
|
361
|
+
message="tool_name is required",
|
|
362
|
+
request_id=request_id,
|
|
363
|
+
remediation="Provide a tool name like 'spec'",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
registry = get_tool_registry()
|
|
367
|
+
schema = registry.get_tool_schema(tool_name.strip())
|
|
368
|
+
if schema is None:
|
|
369
|
+
return asdict(
|
|
370
|
+
error_response(
|
|
371
|
+
f"Tool '{tool_name}' not found",
|
|
372
|
+
error_code=ErrorCode.NOT_FOUND,
|
|
373
|
+
error_type=ErrorType.NOT_FOUND,
|
|
374
|
+
remediation="Use server(action=tools) to list available tools",
|
|
375
|
+
request_id=request_id,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return asdict(success_response(data=schema, request_id=request_id))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _handle_capabilities(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
|
|
383
|
+
request_id = _request_id()
|
|
384
|
+
try:
|
|
385
|
+
caps = get_capabilities()
|
|
386
|
+
return asdict(success_response(data=caps, request_id=request_id))
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
logger.exception("Error getting capabilities")
|
|
389
|
+
return asdict(
|
|
390
|
+
error_response(
|
|
391
|
+
f"Failed to get capabilities: {exc}",
|
|
392
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
393
|
+
error_type=ErrorType.INTERNAL,
|
|
394
|
+
remediation="Check server logs",
|
|
395
|
+
request_id=request_id,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _handle_context(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
|
|
401
|
+
request_id = _request_id()
|
|
402
|
+
|
|
403
|
+
include_llm_value = payload.get("include_llm", True)
|
|
404
|
+
if include_llm_value is not None and not isinstance(include_llm_value, bool):
|
|
405
|
+
return _validation_error(
|
|
406
|
+
message="include_llm must be a boolean",
|
|
407
|
+
request_id=request_id,
|
|
408
|
+
remediation="Provide include_llm=true|false",
|
|
409
|
+
)
|
|
410
|
+
include_llm = include_llm_value if isinstance(include_llm_value, bool) else True
|
|
411
|
+
|
|
412
|
+
include_workflow_value = payload.get("include_workflow", True)
|
|
413
|
+
if include_workflow_value is not None and not isinstance(
|
|
414
|
+
include_workflow_value, bool
|
|
415
|
+
):
|
|
416
|
+
return _validation_error(
|
|
417
|
+
message="include_workflow must be a boolean",
|
|
418
|
+
request_id=request_id,
|
|
419
|
+
remediation="Provide include_workflow=true|false",
|
|
420
|
+
)
|
|
421
|
+
include_workflow = (
|
|
422
|
+
include_workflow_value if isinstance(include_workflow_value, bool) else True
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
include_workspace_value = payload.get("include_workspace", True)
|
|
426
|
+
if include_workspace_value is not None and not isinstance(
|
|
427
|
+
include_workspace_value, bool
|
|
428
|
+
):
|
|
429
|
+
return _validation_error(
|
|
430
|
+
message="include_workspace must be a boolean",
|
|
431
|
+
request_id=request_id,
|
|
432
|
+
remediation="Provide include_workspace=true|false",
|
|
433
|
+
)
|
|
434
|
+
include_workspace = (
|
|
435
|
+
include_workspace_value if isinstance(include_workspace_value, bool) else True
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
include_capabilities_value = payload.get("include_capabilities", True)
|
|
439
|
+
if include_capabilities_value is not None and not isinstance(
|
|
440
|
+
include_capabilities_value, bool
|
|
441
|
+
):
|
|
442
|
+
return _validation_error(
|
|
443
|
+
message="include_capabilities must be a boolean",
|
|
444
|
+
request_id=request_id,
|
|
445
|
+
remediation="Provide include_capabilities=true|false",
|
|
446
|
+
)
|
|
447
|
+
include_capabilities = (
|
|
448
|
+
include_capabilities_value
|
|
449
|
+
if isinstance(include_capabilities_value, bool)
|
|
450
|
+
else True
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return build_server_context_response(
|
|
454
|
+
config,
|
|
455
|
+
include_llm=include_llm,
|
|
456
|
+
include_workflow=include_workflow,
|
|
457
|
+
include_workspace=include_workspace,
|
|
458
|
+
include_capabilities=include_capabilities,
|
|
459
|
+
request_id=_request_id(),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _handle_llm_status(*, config: ServerConfig, payload: Dict[str, Any]) -> dict:
|
|
464
|
+
return build_llm_status_response(request_id=_request_id())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
_ACTION_SUMMARY = {
|
|
468
|
+
"tools": "List available tools with filters and pagination.",
|
|
469
|
+
"schema": "Return schema metadata for a tool.",
|
|
470
|
+
"capabilities": "Return server capability negotiation metadata.",
|
|
471
|
+
"context": "Return server context (paths, config, capabilities).",
|
|
472
|
+
"llm-status": "Return downstream LLM provider configuration health.",
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _build_router() -> ActionRouter:
|
|
477
|
+
return ActionRouter(
|
|
478
|
+
tool_name="server",
|
|
479
|
+
actions=[
|
|
480
|
+
ActionDefinition(
|
|
481
|
+
name="tools", handler=_handle_tools, summary=_ACTION_SUMMARY["tools"]
|
|
482
|
+
),
|
|
483
|
+
ActionDefinition(
|
|
484
|
+
name="schema", handler=_handle_schema, summary=_ACTION_SUMMARY["schema"]
|
|
485
|
+
),
|
|
486
|
+
ActionDefinition(
|
|
487
|
+
name="capabilities",
|
|
488
|
+
handler=_handle_capabilities,
|
|
489
|
+
summary=_ACTION_SUMMARY["capabilities"],
|
|
490
|
+
),
|
|
491
|
+
ActionDefinition(
|
|
492
|
+
name="context",
|
|
493
|
+
handler=_handle_context,
|
|
494
|
+
summary=_ACTION_SUMMARY["context"],
|
|
495
|
+
),
|
|
496
|
+
ActionDefinition(
|
|
497
|
+
name="llm-status",
|
|
498
|
+
handler=_handle_llm_status,
|
|
499
|
+
summary=_ACTION_SUMMARY["llm-status"],
|
|
500
|
+
aliases=("llm_status",),
|
|
501
|
+
),
|
|
502
|
+
],
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
_SERVER_ROUTER = _build_router()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _dispatch_server_action(
|
|
510
|
+
*, action: str, payload: Dict[str, Any], config: ServerConfig
|
|
511
|
+
) -> dict:
|
|
512
|
+
try:
|
|
513
|
+
return _SERVER_ROUTER.dispatch(action, config=config, payload=payload)
|
|
514
|
+
except ActionRouterError as exc:
|
|
515
|
+
allowed = ", ".join(exc.allowed_actions)
|
|
516
|
+
request_id = _request_id()
|
|
517
|
+
return asdict(
|
|
518
|
+
error_response(
|
|
519
|
+
f"Unsupported server action '{action}'. Allowed actions: {allowed}",
|
|
520
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
521
|
+
error_type=ErrorType.VALIDATION,
|
|
522
|
+
remediation=f"Use one of: {allowed}",
|
|
523
|
+
request_id=request_id,
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def register_unified_server_tool(mcp: FastMCP, config: ServerConfig) -> None:
|
|
529
|
+
"""Register the consolidated server tool."""
|
|
530
|
+
|
|
531
|
+
@canonical_tool(mcp, canonical_name="server")
|
|
532
|
+
@mcp_tool(tool_name="server", emit_metrics=True, audit=False)
|
|
533
|
+
def server(
|
|
534
|
+
action: str,
|
|
535
|
+
tool_name: Optional[str] = None,
|
|
536
|
+
category: Optional[str] = None,
|
|
537
|
+
tag: Optional[str] = None,
|
|
538
|
+
include_deprecated: bool = False,
|
|
539
|
+
cursor: Optional[str] = None,
|
|
540
|
+
limit: int = DEFAULT_PAGE_SIZE,
|
|
541
|
+
include_llm: bool = True,
|
|
542
|
+
include_workflow: bool = True,
|
|
543
|
+
include_workspace: bool = True,
|
|
544
|
+
include_capabilities: bool = True,
|
|
545
|
+
) -> dict:
|
|
546
|
+
payload: Dict[str, Any] = {
|
|
547
|
+
"tool_name": tool_name,
|
|
548
|
+
"category": category,
|
|
549
|
+
"tag": tag,
|
|
550
|
+
"include_deprecated": include_deprecated,
|
|
551
|
+
"cursor": cursor,
|
|
552
|
+
"limit": limit,
|
|
553
|
+
"include_llm": include_llm,
|
|
554
|
+
"include_workflow": include_workflow,
|
|
555
|
+
"include_workspace": include_workspace,
|
|
556
|
+
"include_capabilities": include_capabilities,
|
|
557
|
+
}
|
|
558
|
+
return _dispatch_server_action(action=action, payload=payload, config=config)
|
|
559
|
+
|
|
560
|
+
logger.debug("Registered unified server tool")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
__all__ = [
|
|
564
|
+
"register_unified_server_tool",
|
|
565
|
+
]
|