PyStreamMCP 3.1.0__py3-none-any.whl → 3.2.0__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.
- pystreammcp/__init__.py +1 -1
- pystreammcp/_mcp_connector.py +434 -21
- pystreammcp/_mcp_tools.py +160 -339
- pystreammcp/agent.py +48 -6
- pystreammcp/api.py +3 -2
- pystreammcp/cli.py +7 -1
- pystreammcp/integrations/crewai.py +2 -1
- pystreammcp/integrations/haystack.py +2 -1
- pystreammcp/integrations/langchain.py +36 -12
- pystreammcp/integrations/llamaindex.py +97 -17
- pystreammcp/integrations/pydantic_ai.py +2 -1
- pystreammcp/integrations/semantic_kernel.py +2 -1
- pystreammcp/ml/tagging.py +15 -6
- pystreammcp/server.py +102 -8
- pystreammcp-3.2.0.dist-info/METADATA +231 -0
- {pystreammcp-3.1.0.dist-info → pystreammcp-3.2.0.dist-info}/RECORD +19 -19
- {pystreammcp-3.1.0.dist-info → pystreammcp-3.2.0.dist-info}/WHEEL +1 -1
- pystreammcp-3.1.0.dist-info/METADATA +0 -200
- {pystreammcp-3.1.0.dist-info → pystreammcp-3.2.0.dist-info}/licenses/LICENSE +0 -0
- {pystreammcp-3.1.0.dist-info → pystreammcp-3.2.0.dist-info}/top_level.txt +0 -0
pystreammcp/__init__.py
CHANGED
|
@@ -7,7 +7,7 @@ Reduces token usage by 60-75% while maintaining quality.
|
|
|
7
7
|
Includes StatGuardian integration for data quality validation.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "3.
|
|
10
|
+
__version__ = "3.2.0"
|
|
11
11
|
|
|
12
12
|
from .agent import Agent, QueryResult
|
|
13
13
|
from .query import Query, QueryIntent, QueryConstraints
|
pystreammcp/_mcp_connector.py
CHANGED
|
@@ -11,6 +11,9 @@ from dataclasses import dataclass, field
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Dict, List, Optional
|
|
13
13
|
from urllib.parse import urlparse
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from .webhook_router import EventRouter
|
|
14
17
|
|
|
15
18
|
logger = logging.getLogger(__name__)
|
|
16
19
|
|
|
@@ -90,16 +93,23 @@ except ImportError:
|
|
|
90
93
|
self._ready = False
|
|
91
94
|
|
|
92
95
|
def _generate_dab_config(self, tools: Dict[str, Any]) -> Dict:
|
|
96
|
+
# Bind to localhost only, with no cross-origin access and
|
|
97
|
+
# read-only anonymous permissions by default. This launches a
|
|
98
|
+
# local subprocess for MCP tool serving; it isn't meant to be
|
|
99
|
+
# reachable from other hosts or origins out of the box. Callers
|
|
100
|
+
# that need broader access (e.g. a container with its own
|
|
101
|
+
# network boundary) should override these explicitly rather
|
|
102
|
+
# than getting an open-to-everyone server by default.
|
|
93
103
|
return {
|
|
94
104
|
"runtime": {
|
|
95
|
-
"host": "
|
|
105
|
+
"host": "127.0.0.1",
|
|
96
106
|
"port": self.port,
|
|
97
|
-
"cors": {"origins": [
|
|
107
|
+
"cors": {"origins": []},
|
|
98
108
|
},
|
|
99
109
|
"entities": {
|
|
100
110
|
k: {
|
|
101
111
|
"source": k,
|
|
102
|
-
"permissions": [{"actions": ["
|
|
112
|
+
"permissions": [{"actions": ["read"], "roles": ["anonymous"]}],
|
|
103
113
|
}
|
|
104
114
|
for k in tools.keys()
|
|
105
115
|
},
|
|
@@ -142,6 +152,21 @@ class Orchestrator:
|
|
|
142
152
|
self.config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
|
|
143
153
|
self._transport = transport
|
|
144
154
|
|
|
155
|
+
# Real, stateful backing for the routing/webhook/health MCP tools
|
|
156
|
+
# (register_orchestration_webhook, list_service_endpoints,
|
|
157
|
+
# route_tool_invocation, get_tool_routing_info, monitor_mcp_health,
|
|
158
|
+
# orchestrate_cross_mcp_workflow): the same tested ServiceRegistry /
|
|
159
|
+
# ToolChainOrchestrator / FallbackManager infrastructure the HTTP
|
|
160
|
+
# webhook server (server.py) uses, not a second, disconnected copy.
|
|
161
|
+
self.event_router = EventRouter()
|
|
162
|
+
self.webhook_configs: Dict[str, Dict[str, Any]] = {}
|
|
163
|
+
|
|
164
|
+
# Real (if minimal) in-memory cache backing `cache_management`.
|
|
165
|
+
self._cache: Dict[str, Any] = {}
|
|
166
|
+
self._cache_enabled: bool = True
|
|
167
|
+
self._cache_hits = 0
|
|
168
|
+
self._cache_misses = 0
|
|
169
|
+
|
|
145
170
|
def _load_federation_endpoints(self) -> List[str]:
|
|
146
171
|
"""Read [federation].endpoints from the config file.
|
|
147
172
|
|
|
@@ -232,14 +257,115 @@ class Orchestrator:
|
|
|
232
257
|
]
|
|
233
258
|
return {"projects": projects, "total": len(projects)}
|
|
234
259
|
|
|
235
|
-
|
|
236
|
-
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _estimate_tokens(text: str) -> int:
|
|
262
|
+
"""Standard ~4-characters-per-token heuristic, floored at 1."""
|
|
263
|
+
return max(1, len(text) // 4)
|
|
264
|
+
|
|
265
|
+
def plan_query_execution(
|
|
266
|
+
self, query: str, projects_involved: Optional[List[str]] = None
|
|
267
|
+
) -> dict:
|
|
268
|
+
"""Build an execution plan against actually discovered projects.
|
|
269
|
+
|
|
270
|
+
Stages reference the real, currently-known health of each named
|
|
271
|
+
project (discovering first if nothing has been discovered yet)
|
|
272
|
+
rather than a fabricated 3-stage plan with made-up latency numbers.
|
|
273
|
+
Unknown/unrequested projects are reported as such, not silently
|
|
274
|
+
dropped or assumed healthy.
|
|
275
|
+
"""
|
|
276
|
+
if not self.registered_projects:
|
|
277
|
+
self.discover_mcp_projects(refresh=True)
|
|
237
278
|
|
|
238
|
-
|
|
239
|
-
|
|
279
|
+
requested = projects_involved or []
|
|
280
|
+
resolved = []
|
|
281
|
+
for name in requested:
|
|
282
|
+
project = self.registered_projects.get(name)
|
|
283
|
+
resolved.append(
|
|
284
|
+
{
|
|
285
|
+
"project_name": name,
|
|
286
|
+
"status": project.status if project else "unknown",
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"query": query,
|
|
292
|
+
"estimated_tokens": self._estimate_tokens(query),
|
|
293
|
+
"projects": resolved,
|
|
294
|
+
"all_projects_healthy": all(p["status"] == "healthy" for p in resolved)
|
|
295
|
+
if resolved
|
|
296
|
+
else None,
|
|
297
|
+
"plan": [
|
|
298
|
+
{"stage": "validate", "projects": [p["project_name"] for p in resolved]},
|
|
299
|
+
{
|
|
300
|
+
"stage": "execute",
|
|
301
|
+
"projects": [p["project_name"] for p in resolved if p["status"] == "healthy"],
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}
|
|
240
305
|
|
|
241
|
-
def
|
|
242
|
-
|
|
306
|
+
def optimize_cross_project_query(
|
|
307
|
+
self, query: str, strategy: str = "balanced"
|
|
308
|
+
) -> dict:
|
|
309
|
+
"""Compute a real, input-derived token-reduction estimate.
|
|
310
|
+
|
|
311
|
+
Uses the same character-based token heuristic and per-strategy
|
|
312
|
+
reduction target as Agent.query() (agent.py) rather than a
|
|
313
|
+
hardcoded constant unconnected to the actual query text.
|
|
314
|
+
"""
|
|
315
|
+
baseline_tokens = self._estimate_tokens(query)
|
|
316
|
+
reduction_target = {
|
|
317
|
+
"token_efficient": 0.75,
|
|
318
|
+
"quality_first": 0.60,
|
|
319
|
+
}.get(strategy, 0.70)
|
|
320
|
+
optimized_tokens = max(1, int(baseline_tokens * (1 - reduction_target)))
|
|
321
|
+
reduction_percent = (
|
|
322
|
+
(baseline_tokens - optimized_tokens) / baseline_tokens
|
|
323
|
+
) * 100
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
"original_query": query,
|
|
327
|
+
"baseline_tokens": baseline_tokens,
|
|
328
|
+
"optimized_tokens": optimized_tokens,
|
|
329
|
+
"token_reduction_percent": round(reduction_percent, 2),
|
|
330
|
+
"strategy": strategy,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def execute_federated_query(
|
|
334
|
+
self, query: str, projects: List[str], timeout_seconds: int = 30
|
|
335
|
+
) -> dict:
|
|
336
|
+
"""Report which of the requested projects are actually reachable.
|
|
337
|
+
|
|
338
|
+
This does not fabricate row counts or execution results — there is
|
|
339
|
+
no real cross-project query engine backing this yet. It reports
|
|
340
|
+
real discovery/health status for each requested project so callers
|
|
341
|
+
get an honest answer about what's actually available, and marks
|
|
342
|
+
the query itself as not executed.
|
|
343
|
+
"""
|
|
344
|
+
if not self.registered_projects:
|
|
345
|
+
self.discover_mcp_projects(refresh=True)
|
|
346
|
+
|
|
347
|
+
results = []
|
|
348
|
+
for name in projects:
|
|
349
|
+
project = self.registered_projects.get(name)
|
|
350
|
+
results.append(
|
|
351
|
+
{
|
|
352
|
+
"project_name": name,
|
|
353
|
+
"status": project.status if project else "unknown",
|
|
354
|
+
"error": project.error if project else "not discovered",
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
healthy = [r for r in results if r["status"] == "healthy"]
|
|
359
|
+
return {
|
|
360
|
+
"query": query,
|
|
361
|
+
"projects": results,
|
|
362
|
+
"status": "not_implemented",
|
|
363
|
+
"message": (
|
|
364
|
+
"Federated query execution across projects is not yet implemented; "
|
|
365
|
+
f"{len(healthy)}/{len(projects)} requested projects are currently healthy "
|
|
366
|
+
"and reachable."
|
|
367
|
+
),
|
|
368
|
+
}
|
|
243
369
|
|
|
244
370
|
def detect_compatible_projects(self, capability: str) -> dict:
|
|
245
371
|
"""Find discovered, healthy projects exposing a tool relevant to
|
|
@@ -319,19 +445,140 @@ class Orchestrator:
|
|
|
319
445
|
return min(1.0, score)
|
|
320
446
|
|
|
321
447
|
def handle_cross_database_join(self, left: str, right: str, key: str) -> dict:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def cache_management(self, action: str) -> dict:
|
|
325
|
-
return {"cache": {}}
|
|
326
|
-
|
|
327
|
-
def error_recovery_retry(self, query: str, error: str) -> dict:
|
|
328
|
-
return {"status": "retrying"}
|
|
448
|
+
"""Cross-database joins are not implemented.
|
|
329
449
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return {
|
|
450
|
+
Honest "not_implemented" rather than a fabricated row count / join
|
|
451
|
+
plan: there is no real query engine here capable of executing a
|
|
452
|
+
join across heterogeneous databases.
|
|
453
|
+
"""
|
|
454
|
+
return {
|
|
455
|
+
"left_source": left,
|
|
456
|
+
"right_source": right,
|
|
457
|
+
"join_key": key,
|
|
458
|
+
"status": "not_implemented",
|
|
459
|
+
"message": "Cross-database join execution is not yet implemented.",
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
def cache_management(
|
|
463
|
+
self,
|
|
464
|
+
action: str,
|
|
465
|
+
ttl_seconds: Optional[int] = None,
|
|
466
|
+
target: Optional[str] = None,
|
|
467
|
+
) -> dict:
|
|
468
|
+
"""Manage a real (if minimal) in-memory cache.
|
|
469
|
+
|
|
470
|
+
Tracks actual entries and real hit/miss counters rather than
|
|
471
|
+
fabricated size/hit-rate figures.
|
|
472
|
+
"""
|
|
473
|
+
if action == "enable":
|
|
474
|
+
self._cache_enabled = True
|
|
475
|
+
return {"action": action, "status": "success", "cache_enabled": True}
|
|
476
|
+
if action == "disable":
|
|
477
|
+
self._cache_enabled = False
|
|
478
|
+
return {"action": action, "status": "success", "cache_enabled": False}
|
|
479
|
+
if action == "clear":
|
|
480
|
+
if target:
|
|
481
|
+
self._cache.pop(target, None)
|
|
482
|
+
else:
|
|
483
|
+
self._cache.clear()
|
|
484
|
+
return {"action": action, "status": "success", "entries": len(self._cache)}
|
|
485
|
+
if action == "stats":
|
|
486
|
+
total_lookups = self._cache_hits + self._cache_misses
|
|
487
|
+
hit_rate = (
|
|
488
|
+
(self._cache_hits / total_lookups * 100) if total_lookups else 0.0
|
|
489
|
+
)
|
|
490
|
+
return {
|
|
491
|
+
"action": action,
|
|
492
|
+
"status": "success",
|
|
493
|
+
"cache_enabled": self._cache_enabled,
|
|
494
|
+
"entries": len(self._cache),
|
|
495
|
+
"hits": self._cache_hits,
|
|
496
|
+
"misses": self._cache_misses,
|
|
497
|
+
"hit_rate_percent": round(hit_rate, 2),
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
"action": action,
|
|
501
|
+
"status": "error",
|
|
502
|
+
"message": f"Unknown action: {action!r} (expected enable/disable/clear/stats)",
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
def error_recovery_retry(
|
|
506
|
+
self,
|
|
507
|
+
failed_query: str,
|
|
508
|
+
error_type: str,
|
|
509
|
+
retry_strategy: str = "exponential_backoff",
|
|
510
|
+
max_retries: int = 3,
|
|
511
|
+
) -> dict:
|
|
512
|
+
"""Queue a failed query for retry via the real FallbackManager.
|
|
513
|
+
|
|
514
|
+
Reports the real resulting queue size rather than a hardcoded
|
|
515
|
+
"retrying" status disconnected from any actual queue state.
|
|
516
|
+
"""
|
|
517
|
+
self.event_router.fallback_manager._queue_for_retry(
|
|
518
|
+
failed_query, {"error_type": error_type, "max_retries": max_retries}
|
|
519
|
+
)
|
|
520
|
+
queue_status = self.event_router.fallback_manager.get_retry_queue_status()
|
|
521
|
+
return {
|
|
522
|
+
"failed_query": failed_query,
|
|
523
|
+
"error_type": error_type,
|
|
524
|
+
"retry_strategy": retry_strategy,
|
|
525
|
+
"max_retries": max_retries,
|
|
526
|
+
"status": "queued_for_retry",
|
|
527
|
+
"queue_size": queue_status["queue_size"],
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def report_performance_metrics(
|
|
531
|
+
self, metric_type: str = "latency", group_by: str = "project"
|
|
532
|
+
) -> dict:
|
|
533
|
+
"""Report real health-history metrics from the ServiceRegistry.
|
|
534
|
+
|
|
535
|
+
Returns actually recorded health-check history (may legitimately
|
|
536
|
+
be empty if nothing has reported health metrics yet) rather than
|
|
537
|
+
fabricated latency/throughput figures.
|
|
538
|
+
"""
|
|
539
|
+
history = self.event_router.service_registry.health_history
|
|
540
|
+
metrics = []
|
|
541
|
+
for project_name, entries in history.items():
|
|
542
|
+
if not entries:
|
|
543
|
+
continue
|
|
544
|
+
latest = entries[-1]
|
|
545
|
+
value = latest.get("metrics", {}).get(metric_type)
|
|
546
|
+
metrics.append(
|
|
547
|
+
{
|
|
548
|
+
"group": project_name,
|
|
549
|
+
"value": value,
|
|
550
|
+
"status": latest.get("status"),
|
|
551
|
+
"recorded_at": latest.get("timestamp"),
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
return {
|
|
555
|
+
"metric_type": metric_type,
|
|
556
|
+
"group_by": group_by,
|
|
557
|
+
"metrics": metrics,
|
|
558
|
+
"message": None
|
|
559
|
+
if metrics
|
|
560
|
+
else "No health metrics have been recorded for any project yet.",
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
def estimate_query_cost_multi_project(
|
|
564
|
+
self, query: str, projects: Optional[List[str]] = None, cost_model: str = "tokens"
|
|
565
|
+
) -> dict:
|
|
566
|
+
"""Estimate token cost from the actual query text.
|
|
567
|
+
|
|
568
|
+
Uses the same character-based heuristic as optimize_cross_project_query
|
|
569
|
+
rather than a fixed constant unrelated to the query content.
|
|
570
|
+
"""
|
|
571
|
+
tokens = self._estimate_tokens(query)
|
|
572
|
+
projects = projects or []
|
|
573
|
+
per_project_tokens = tokens // len(projects) if projects else tokens
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
"query": query,
|
|
577
|
+
"cost_model": cost_model,
|
|
578
|
+
"projects_involved": projects,
|
|
579
|
+
"estimated_tokens": tokens,
|
|
580
|
+
"cost_breakdown": {p: {"tokens": per_project_tokens} for p in projects},
|
|
581
|
+
}
|
|
335
582
|
|
|
336
583
|
def manage_endpoint_federation(self, action: str) -> dict:
|
|
337
584
|
"""Manage the federation endpoint list.
|
|
@@ -356,6 +603,172 @@ class Orchestrator:
|
|
|
356
603
|
]
|
|
357
604
|
return {"status": "refreshed" if action == "refresh" else "listed", "endpoints": endpoints}
|
|
358
605
|
|
|
606
|
+
# --- Real orchestration webhook / routing surface -------------------
|
|
607
|
+
#
|
|
608
|
+
# Backed by the same tested ServiceRegistry / ToolChainOrchestrator /
|
|
609
|
+
# FallbackManager machinery in webhook_router.py that the HTTP webhook
|
|
610
|
+
# server (server.py) uses — not a second, disconnected implementation.
|
|
611
|
+
|
|
612
|
+
def register_orchestration_webhook(
|
|
613
|
+
self,
|
|
614
|
+
webhook_id: str,
|
|
615
|
+
url: str,
|
|
616
|
+
events: List[str],
|
|
617
|
+
secret_key: Optional[str] = None,
|
|
618
|
+
) -> dict:
|
|
619
|
+
"""Register a webhook subscription in real, queryable state."""
|
|
620
|
+
self.webhook_configs[webhook_id] = {
|
|
621
|
+
"webhook_id": webhook_id,
|
|
622
|
+
"url": url,
|
|
623
|
+
"events": events,
|
|
624
|
+
"secret_key": secret_key,
|
|
625
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
"status": "success",
|
|
629
|
+
"webhook_id": webhook_id,
|
|
630
|
+
"url": url,
|
|
631
|
+
"events": events,
|
|
632
|
+
"signature_method": "HMAC-SHA256" if secret_key else "none",
|
|
633
|
+
"created_at": self.webhook_configs[webhook_id]["created_at"],
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
def list_service_endpoints(
|
|
637
|
+
self, filter_by_status: str = "all", include_metrics: bool = False
|
|
638
|
+
) -> dict:
|
|
639
|
+
"""List MCP endpoints registered with the real ServiceRegistry.
|
|
640
|
+
|
|
641
|
+
This reflects only endpoints that have actually been registered
|
|
642
|
+
(via `mcp.available` events or `register_mcp_endpoint`) — it is
|
|
643
|
+
genuinely empty until something registers, not a fabricated
|
|
644
|
+
7-project fixture list.
|
|
645
|
+
"""
|
|
646
|
+
snapshot = self.event_router.service_registry.get_registry_snapshot()
|
|
647
|
+
endpoints = snapshot["mcps"]
|
|
648
|
+
if filter_by_status != "all":
|
|
649
|
+
endpoints = [e for e in endpoints if e["status"] == filter_by_status]
|
|
650
|
+
if not include_metrics:
|
|
651
|
+
for ep in endpoints:
|
|
652
|
+
ep.pop("health_metrics", None)
|
|
653
|
+
return {
|
|
654
|
+
"status": "success",
|
|
655
|
+
"endpoints": endpoints,
|
|
656
|
+
"total": len(endpoints),
|
|
657
|
+
"healthy_count": len([e for e in endpoints if e["status"] == "healthy"]),
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async def route_tool_invocation(
|
|
661
|
+
self,
|
|
662
|
+
tool_name: str,
|
|
663
|
+
params: Optional[Dict[str, Any]] = None,
|
|
664
|
+
chain_id: Optional[str] = None,
|
|
665
|
+
use_fallback: bool = True,
|
|
666
|
+
timeout_ms: Optional[int] = None,
|
|
667
|
+
) -> dict:
|
|
668
|
+
"""Route a tool invocation via the real ToolChainOrchestrator.
|
|
669
|
+
|
|
670
|
+
Actually looks the tool up in the ServiceRegistry; if it isn't
|
|
671
|
+
found and `use_fallback` is set, falls back through
|
|
672
|
+
FallbackManager.invoke_with_fallback rather than fabricating a
|
|
673
|
+
"routed" response to an endpoint that doesn't exist.
|
|
674
|
+
"""
|
|
675
|
+
chain_context = {"chain_id": chain_id} if chain_id else None
|
|
676
|
+
result = await self.event_router.tool_orchestrator.route_tool_invocation(
|
|
677
|
+
tool_name=tool_name,
|
|
678
|
+
params=params or {},
|
|
679
|
+
chain_context=chain_context,
|
|
680
|
+
)
|
|
681
|
+
if result.get("status") == "error" and use_fallback:
|
|
682
|
+
result = await self.event_router.fallback_manager.invoke_with_fallback(
|
|
683
|
+
tool_name=tool_name, params=params or {}, fallback_enabled=True
|
|
684
|
+
)
|
|
685
|
+
return result
|
|
686
|
+
|
|
687
|
+
def get_tool_routing_info(
|
|
688
|
+
self, tool_name: str, include_fallbacks: bool = False
|
|
689
|
+
) -> dict:
|
|
690
|
+
"""Real routing info for a tool: where the ServiceRegistry says
|
|
691
|
+
it actually lives, not a fabricated 145.5ms/0.999 fixture."""
|
|
692
|
+
result = self.event_router.service_registry.find_tool(tool_name)
|
|
693
|
+
if not result:
|
|
694
|
+
return {
|
|
695
|
+
"status": "not_found",
|
|
696
|
+
"tool_name": tool_name,
|
|
697
|
+
"message": f"Tool {tool_name} not found in any registered MCP",
|
|
698
|
+
}
|
|
699
|
+
project_name, endpoint = result
|
|
700
|
+
routing = {
|
|
701
|
+
"tool_name": tool_name,
|
|
702
|
+
"primary_mcp": project_name,
|
|
703
|
+
"primary_endpoint": f"http://localhost:{endpoint.port}",
|
|
704
|
+
"health_status": endpoint.status,
|
|
705
|
+
}
|
|
706
|
+
if include_fallbacks:
|
|
707
|
+
fallback = self.event_router.tool_orchestrator.find_fallback_tool(tool_name)
|
|
708
|
+
routing["fallback_mcps"] = (
|
|
709
|
+
[{"project": fallback[0], "endpoint": fallback[1]}] if fallback else []
|
|
710
|
+
)
|
|
711
|
+
return {"status": "success", "routing": routing}
|
|
712
|
+
|
|
713
|
+
def monitor_mcp_health(self, project_name: str = "all") -> dict:
|
|
714
|
+
"""Real orchestration health snapshot from the ServiceRegistry."""
|
|
715
|
+
status = self.event_router.get_status()
|
|
716
|
+
registry = status["registry"]
|
|
717
|
+
if project_name != "all":
|
|
718
|
+
endpoint = self.event_router.service_registry.endpoints.get(project_name)
|
|
719
|
+
return {
|
|
720
|
+
"status": "success",
|
|
721
|
+
"project_name": project_name,
|
|
722
|
+
"current_status": endpoint.status if endpoint else "unknown",
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
"status": "success",
|
|
726
|
+
"total_mcps": registry["total_mcps"],
|
|
727
|
+
"healthy_mcps": registry["healthy_mcps"],
|
|
728
|
+
"degraded_or_unavailable": registry["total_mcps"] - registry["healthy_mcps"],
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async def orchestrate_cross_mcp_workflow(
|
|
732
|
+
self,
|
|
733
|
+
workflow_id: str,
|
|
734
|
+
tool_sequence: List[str],
|
|
735
|
+
cascade_on_success: bool = True,
|
|
736
|
+
error_handling: str = "fail_fast",
|
|
737
|
+
) -> dict:
|
|
738
|
+
"""Execute a real sequence of routed tool invocations.
|
|
739
|
+
|
|
740
|
+
Each stage's outcome is the real result of
|
|
741
|
+
ToolChainOrchestrator.route_tool_invocation (found/not-found
|
|
742
|
+
against the ServiceRegistry) — not a fabricated "pending" plan
|
|
743
|
+
that never actually executes.
|
|
744
|
+
"""
|
|
745
|
+
stages = []
|
|
746
|
+
for position, tool_name in enumerate(tool_sequence):
|
|
747
|
+
result = await self.event_router.tool_orchestrator.route_tool_invocation(
|
|
748
|
+
tool_name=tool_name,
|
|
749
|
+
params={},
|
|
750
|
+
chain_context={
|
|
751
|
+
"chain_id": workflow_id,
|
|
752
|
+
"position_in_chain": position,
|
|
753
|
+
"total_chain_length": len(tool_sequence),
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
stages.append({"stage": position + 1, "tool": tool_name, "result": result})
|
|
757
|
+
if result.get("status") == "error" and error_handling == "fail_fast":
|
|
758
|
+
return {
|
|
759
|
+
"status": "failed",
|
|
760
|
+
"workflow_id": workflow_id,
|
|
761
|
+
"stages": stages,
|
|
762
|
+
"failed_at_stage": position + 1,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"status": "completed",
|
|
767
|
+
"workflow_id": workflow_id,
|
|
768
|
+
"stages": stages,
|
|
769
|
+
"total_stages": len(tool_sequence),
|
|
770
|
+
}
|
|
771
|
+
|
|
359
772
|
def start_mcp_connector(self, port: int = 8772) -> str:
|
|
360
773
|
from pystreammcp._mcp_tools import PyStreamMCPHandler, PyStreamMCPTools
|
|
361
774
|
|