PyStreamMCP 3.1.0__py3-none-any.whl → 3.3.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 CHANGED
@@ -7,12 +7,12 @@ 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.0.0"
10
+ __version__ = "3.3.0"
11
11
 
12
12
  from .agent import Agent, QueryResult
13
13
  from .query import Query, QueryIntent, QueryConstraints
14
14
  from .context import Context, ContextType, ContextWindow
15
- from .discovery import Discovery, DiscoveredSource, SourceType
15
+ from .discovery import Discovery, DiscoveredSource, SourceType, SourceRegistry, DataSource
16
16
 
17
17
  # Optimization imports
18
18
  try:
@@ -79,6 +79,8 @@ __all__ = [
79
79
  "Discovery",
80
80
  "DiscoveredSource",
81
81
  "SourceType",
82
+ "SourceRegistry",
83
+ "DataSource",
82
84
  # Legacy optimization (optional, phase 1-2)
83
85
  *((["OptimizationStrategy"]) if OptimizationStrategy else []),
84
86
  *((["StrategyType"]) if StrategyType else []),
@@ -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": "0.0.0.0",
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": ["*"], "roles": ["*"]}],
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
- def plan_query_execution(self, query: str) -> dict:
236
- return {"plan": []}
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
- def optimize_cross_project_query(self, query: str) -> dict:
239
- return {"optimized": query}
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 execute_federated_query(self, query: str, projects: list) -> dict:
242
- return {"results": []}
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
- return {"joined": {}}
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
- def report_performance_metrics(self, metric_type: str) -> dict:
331
- return {"metrics": []}
332
-
333
- def estimate_query_cost_multi_project(self, query: str) -> dict:
334
- return {"cost": 0}
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