PyStreamMCP 3.2.0__py3-none-any.whl → 3.3.1__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.2.0"
10
+ __version__ = "3.3.1"
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 []),
pystreammcp/api.py CHANGED
@@ -13,6 +13,7 @@ import logging
13
13
 
14
14
  from .adapters import AdapterRegistry, AdapterConfig, FrameworkType, QueryResult
15
15
  from .agent import Agent
16
+ from .discovery import SourceRegistry
16
17
 
17
18
 
18
19
  logger = logging.getLogger(__name__)
@@ -59,6 +60,15 @@ class DiscoveryResponse(BaseModel):
59
60
  total_sources: int
60
61
 
61
62
 
63
+ class SourceRegistration(BaseModel):
64
+ """Register a data source for discovery."""
65
+
66
+ name: str = Field(..., description="Unique source name")
67
+ description: str = Field(..., description="What this source contains, in plain text")
68
+ type: str = Field("unknown", description="Source type, e.g. database, api, docs")
69
+ tags: List[str] = Field(default_factory=list, description="Optional keyword tags")
70
+
71
+
62
72
  class OptimizationRequest(BaseModel):
63
73
  """Query optimization request."""
64
74
 
@@ -117,6 +127,7 @@ class PyStreamMCPAPI:
117
127
  self.title = title
118
128
  self.version = version
119
129
  self.agents: Dict[str, Agent] = {}
130
+ self.registry = SourceRegistry()
120
131
  self.app = self._create_app()
121
132
 
122
133
  def _create_app(self) -> FastAPI:
@@ -260,13 +271,45 @@ class PyStreamMCPAPI:
260
271
  }
261
272
 
262
273
  # Discovery Endpoints
274
+ @app.post("/sources", response_model=Dict[str, Any])
275
+ async def register_source(source: SourceRegistration):
276
+ """Register a data source so /discover can find it."""
277
+ self.registry.register(
278
+ name=source.name,
279
+ description=source.description,
280
+ type=source.type,
281
+ tags=source.tags,
282
+ )
283
+ return {"status": "success", "name": source.name}
284
+
285
+ @app.delete("/sources/{name}")
286
+ async def unregister_source(name: str):
287
+ """Remove a registered data source."""
288
+ removed = self.registry.unregister(name)
289
+ if not removed:
290
+ raise HTTPException(status_code=404, detail=f"Source {name} not found")
291
+ return {"status": "success", "name": name}
292
+
293
+ @app.get("/sources")
294
+ async def list_sources():
295
+ """List all registered data sources."""
296
+ return {
297
+ "status": "success",
298
+ "sources": [
299
+ {"name": s.name, "type": s.type, "tags": s.tags}
300
+ for s in self.registry.list_sources()
301
+ ],
302
+ }
303
+
263
304
  @app.post("/discover", response_model=DiscoveryResponse)
264
305
  async def discover(request: DiscoveryRequest):
265
- """Discover relevant data sources."""
266
- agent = self._get_agent(request.agent_id)
306
+ """Discover data sources relevant to `context`.
267
307
 
268
- # TODO: Implement discovery logic
269
- sources = []
308
+ Ranks *registered* sources (see POST /sources) by real token
309
+ overlap against `context`. Returns an empty list if nothing is
310
+ registered or nothing overlaps — never a fabricated result.
311
+ """
312
+ sources = self.registry.discover(request.context)
270
313
 
271
314
  return {
272
315
  "status": "success",
pystreammcp/cli.py CHANGED
@@ -2,11 +2,9 @@
2
2
 
3
3
  import json
4
4
  import sys
5
- from typing import Optional
6
5
  import click
7
6
 
8
7
  from pystreammcp import Agent, __version__
9
- from pystreammcp.api import PyStreamMCPAPI
10
8
  from pystreammcp.cli_dashboard import PyStreamMCPDashboard
11
9
 
12
10
 
@@ -83,6 +81,16 @@ def query(
83
81
  @click.option("--reload", is_flag=True)
84
82
  def server(host: str, port: int, reload: bool):
85
83
  """Start PyStreamMCP API server."""
84
+ try:
85
+ from pystreammcp.api import PyStreamMCPAPI
86
+ except ImportError:
87
+ click.echo(
88
+ "Error: the 'server' command requires the 'api' extra. "
89
+ "Install with: pip install 'pystreammcp[api]'",
90
+ err=True,
91
+ )
92
+ raise SystemExit(1)
93
+
86
94
  click.echo(f"Starting server on {host}:{port}...")
87
95
  api = PyStreamMCPAPI()
88
96
  api.run(host=host, port=port, reload=reload)
@@ -124,162 +132,5 @@ def dashboard(
124
132
  sys.exit(1)
125
133
 
126
134
 
127
- # Legacy CLI interface for compatibility
128
- class CLIInterface:
129
- """Command-line interface for PyStreamMCP (legacy)."""
130
-
131
- def __init__(self):
132
- self.agent = None
133
-
134
- def create_agent(
135
- self, agent_id: str, strategy: str = "balanced", max_tokens: int = 2000
136
- ):
137
- """Create an agent."""
138
- self.agent = Agent(
139
- agent_id=agent_id,
140
- optimization_strategy=strategy,
141
- max_tokens=max_tokens,
142
- )
143
- return {"status": "success", "agent_id": agent_id}
144
-
145
- def query(
146
- self, text: str, intent: str = "retrieve", agent_id: Optional[str] = None
147
- ) -> dict:
148
- """Execute a query."""
149
- if agent_id:
150
- self.create_agent(agent_id)
151
-
152
- if not self.agent:
153
- return {"status": "error", "message": "No agent created"}
154
-
155
- result = self.agent.query(text)
156
-
157
- return {
158
- "status": "success",
159
- "query_id": result.query_id,
160
- "query_text": text,
161
- "baseline_tokens": result.baseline_tokens,
162
- "optimized_tokens": result.optimized_tokens,
163
- "cost_reduction_percent": result.cost_reduction_percent,
164
- "cost_saved": (result.baseline_tokens - result.optimized_tokens) * 0.00001,
165
- "execution_time_ms": result.execution_time_ms,
166
- "meets_target": 60 <= result.cost_reduction_percent <= 75,
167
- }
168
-
169
- def get_metrics(self) -> dict:
170
- """Get agent metrics."""
171
- if not self.agent:
172
- return {"status": "error", "message": "No agent created"}
173
-
174
- metrics = self.agent.get_metrics()
175
- return {
176
- "status": "success",
177
- "metrics": metrics,
178
- }
179
-
180
-
181
- def main():
182
- """Main CLI entry point."""
183
- cli = CLIInterface()
184
-
185
- if len(sys.argv) < 2:
186
- print_help()
187
- sys.exit(1)
188
-
189
- command = sys.argv[1]
190
-
191
- try:
192
- if command == "query":
193
- if len(sys.argv) < 3:
194
- print(json.dumps({"error": "Missing query text"}))
195
- sys.exit(1)
196
-
197
- text = sys.argv[2]
198
- intent = sys.argv[3] if len(sys.argv) > 3 else "retrieve"
199
- agent_id = sys.argv[4] if len(sys.argv) > 4 else "cli_agent"
200
-
201
- result = cli.query(text, intent=intent, agent_id=agent_id)
202
- print(json.dumps(result))
203
-
204
- elif command == "create-agent":
205
- if len(sys.argv) < 3:
206
- print(json.dumps({"error": "Missing agent_id"}))
207
- sys.exit(1)
208
-
209
- agent_id = sys.argv[2]
210
- strategy = sys.argv[3] if len(sys.argv) > 3 else "balanced"
211
- max_tokens = int(sys.argv[4]) if len(sys.argv) > 4 else 2000
212
-
213
- result = cli.create_agent(agent_id, strategy, max_tokens)
214
- print(json.dumps(result))
215
-
216
- elif command == "metrics":
217
- result = cli.get_metrics()
218
- print(json.dumps(result))
219
-
220
- elif command == "help":
221
- print_help()
222
-
223
- else:
224
- print(json.dumps({"error": f"Unknown command: {command}"}))
225
- sys.exit(1)
226
-
227
- except Exception as e:
228
- print(json.dumps({"error": str(e), "status": "error"}))
229
- sys.exit(1)
230
-
231
-
232
- def print_help():
233
- """Print help message."""
234
- help_text = """
235
- PyStreamMCP CLI - Intelligence Layer for Workflows
236
-
237
- USAGE:
238
- pystreammcp <command> [options]
239
-
240
- COMMANDS:
241
- query <text> [intent] [agent_id]
242
- Execute a query with optimization
243
- - text: The query text (required)
244
- - intent: Query type (retrieve|discover|aggregate) (default: retrieve)
245
- - agent_id: Agent identifier (default: cli_agent)
246
-
247
- Example:
248
- pystreammcp query "Top 10 customers by LTV" retrieve my_agent
249
-
250
- create-agent <agent_id> [strategy] [max_tokens]
251
- Create an agent with specific configuration
252
- - agent_id: Agent identifier (required)
253
- - strategy: Optimization strategy (balanced|token_efficient|quality_first) (default: balanced)
254
- - max_tokens: Token budget (default: 2000)
255
-
256
- Example:
257
- pystreammcp create-agent workflow_agent token_efficient 1500
258
-
259
- metrics
260
- Get current agent metrics
261
-
262
- Example:
263
- pystreammcp metrics
264
-
265
- help
266
- Show this help message
267
-
268
- OUTPUT FORMAT:
269
- All commands return JSON output for easy parsing in workflows
270
-
271
- EXAMPLES:
272
- # Query with cost tracking
273
- pystreammcp query "Find churn risk customers"
274
-
275
- # Efficient query for token-constrained environments
276
- pystreammcp query "List segments" discover automation_agent
277
-
278
- # Get metrics for reporting
279
- pystreammcp metrics | jq '.metrics.total_cost_saved'
280
- """
281
- print(help_text)
282
-
283
-
284
135
  if __name__ == "__main__":
285
- main()
136
+ cli()
pystreammcp/discovery.py CHANGED
@@ -1,9 +1,33 @@
1
- """Discovery types for PyStreamMCP."""
2
-
3
- from typing import Optional, List
4
- from enum import Enum
1
+ """Discovery types and logic for PyStreamMCP.
2
+
3
+ `SourceRegistry` is real discovery logic: register data sources (name,
4
+ description, type, tags), then find the ones most relevant to a query
5
+ context using word-overlap relevance scoring. This is deliberately a
6
+ simple, honest heuristic — Jaccard token overlap between the query context
7
+ and each source's registered description/tags/name — not a semantic or
8
+ embedding-based search. Sources with zero token overlap are excluded from
9
+ results rather than padded in with a fabricated score, so an empty
10
+ registry or a context that matches nothing returns no sources, not
11
+ placeholder data.
12
+
13
+ `Discovery` / `DiscoveredSource` / `SourceType` are the result types
14
+ `SourceRegistry.discover()` returns results as, so callers get real,
15
+ structured discovery results rather than a bare dict.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
5
21
  from dataclasses import dataclass, field
6
22
  from datetime import datetime
23
+ from enum import Enum
24
+ from typing import Any, Dict, List, Optional, Set
25
+
26
+ _WORD_RE = re.compile(r"[a-z0-9]+")
27
+
28
+
29
+ def _tokenize(text: str) -> Set[str]:
30
+ return set(_WORD_RE.findall(text.lower()))
7
31
 
8
32
 
9
33
  class SourceType(str, Enum):
@@ -14,11 +38,12 @@ class SourceType(str, Enum):
14
38
  CACHE = "cache"
15
39
  EXTERNAL = "external"
16
40
  COMPUTED = "computed"
41
+ UNKNOWN = "unknown"
17
42
 
18
43
 
19
44
  @dataclass
20
45
  class DiscoveredSource:
21
- """A discovered data source."""
46
+ """A discovered data source, returned by SourceRegistry.discover()."""
22
47
 
23
48
  name: str
24
49
  source_type: SourceType
@@ -80,3 +105,112 @@ class Discovery:
80
105
  def high_relevance_sources(self, threshold: float = 0.8) -> List[DiscoveredSource]:
81
106
  """Get high-relevance sources."""
82
107
  return [s for s in self.discovered_sources if s.relevance_score >= threshold]
108
+
109
+
110
+ @dataclass
111
+ class DataSource:
112
+ """A data source registered with a SourceRegistry."""
113
+
114
+ name: str
115
+ description: str
116
+ type: str = "unknown"
117
+ tags: List[str] = field(default_factory=list)
118
+ metadata: Dict[str, Any] = field(default_factory=dict)
119
+ estimated_tokens: int = 0
120
+
121
+ def _text(self) -> str:
122
+ return " ".join([self.name, self.description, *self.tags])
123
+
124
+
125
+ class SourceRegistry:
126
+ """In-memory registry of data sources, with real relevance scoring."""
127
+
128
+ def __init__(self) -> None:
129
+ self._sources: Dict[str, DataSource] = {}
130
+
131
+ def register(
132
+ self,
133
+ name: str,
134
+ description: str,
135
+ type: str = "unknown",
136
+ tags: Optional[List[str]] = None,
137
+ metadata: Optional[Dict[str, Any]] = None,
138
+ estimated_tokens: int = 0,
139
+ ) -> DataSource:
140
+ """Register (or replace) a data source by name."""
141
+ source = DataSource(
142
+ name=name,
143
+ description=description,
144
+ type=type,
145
+ tags=list(tags) if tags else [],
146
+ metadata=dict(metadata) if metadata else {},
147
+ estimated_tokens=estimated_tokens,
148
+ )
149
+ self._sources[name] = source
150
+ return source
151
+
152
+ def unregister(self, name: str) -> bool:
153
+ """Remove a registered source. Returns True if it existed."""
154
+ return self._sources.pop(name, None) is not None
155
+
156
+ def list_sources(self) -> List[DataSource]:
157
+ return list(self._sources.values())
158
+
159
+ def discover(self, context: str, limit: int = 10) -> List[Dict[str, Any]]:
160
+ """Return registered sources ranked by real relevance to `context`.
161
+
162
+ Relevance is Jaccard token overlap: |context tokens ∩ source
163
+ tokens| / |context tokens ∪ source tokens|. Sources that share no
164
+ tokens with `context` are excluded, not returned with a made-up
165
+ low score. Returns plain dicts (the wire-format shape used by the
166
+ REST/MCP surfaces) — use `discover_typed` for `DiscoveredSource`
167
+ objects.
168
+ """
169
+ return [
170
+ {
171
+ "name": s.name,
172
+ "type": s.type,
173
+ "relevance": round(score, 4),
174
+ "matched_terms": matched,
175
+ }
176
+ for score, matched, s in self._score(context, limit)
177
+ ]
178
+
179
+ def discover_typed(self, query_id: str, context: str, limit: int = 10) -> Discovery:
180
+ """Same ranking as `discover`, returned as a `Discovery` result of
181
+ real `DiscoveredSource` objects instead of plain dicts."""
182
+ result = Discovery(query_id=query_id)
183
+ for score, _matched, s in self._score(context, limit):
184
+ try:
185
+ source_type = SourceType(s.type)
186
+ except ValueError:
187
+ source_type = SourceType.UNKNOWN
188
+ result.add_source(
189
+ DiscoveredSource(
190
+ name=s.name,
191
+ source_type=source_type,
192
+ relevance_score=score,
193
+ estimated_tokens=s.estimated_tokens,
194
+ )
195
+ )
196
+ return result
197
+
198
+ def _score(self, context: str, limit: int):
199
+ context_tokens = _tokenize(context)
200
+ if not context_tokens or not self._sources:
201
+ return []
202
+
203
+ scored = []
204
+ for source in self._sources.values():
205
+ source_tokens = _tokenize(source._text())
206
+ if not source_tokens:
207
+ continue
208
+ overlap = context_tokens & source_tokens
209
+ if not overlap:
210
+ continue
211
+ union = context_tokens | source_tokens
212
+ relevance = len(overlap) / len(union)
213
+ scored.append((relevance, sorted(overlap), source))
214
+
215
+ scored.sort(key=lambda item: item[0], reverse=True)
216
+ return scored[:limit]
@@ -8,7 +8,7 @@ Provides both:
8
8
  2. Legacy PyStreamMCPTool & PyStreamMCPRetriever (backward compatible)
9
9
  """
10
10
 
11
- from typing import Optional, Dict, Any, List, Type
11
+ from typing import Optional, Dict, Any, List, Type, TYPE_CHECKING
12
12
  import asyncio
13
13
  from pystreammcp import (
14
14
  Agent,
@@ -21,6 +21,11 @@ from pystreammcp import (
21
21
  )
22
22
 
23
23
  from pystreammcp.adapters import QueryResult as AdapterQueryResult
24
+ from pystreammcp.discovery import SourceRegistry
25
+
26
+ if TYPE_CHECKING:
27
+ from langchain_core.tools import Tool as LangchainTool
28
+ from langchain_core.retrievers import BaseRetriever
24
29
 
25
30
 
26
31
  class LangchainAdapter(AgentFrameworkAdapter):
@@ -30,11 +35,15 @@ class LangchainAdapter(AgentFrameworkAdapter):
30
35
  Supports both sync and async execution.
31
36
  """
32
37
 
33
- def __init__(self, config: AdapterConfig):
38
+ def __init__(self, config: AdapterConfig, registry: Optional[SourceRegistry] = None):
34
39
  """Initialize Langchain adapter.
35
40
 
36
41
  Args:
37
42
  config: Adapter configuration
43
+ registry: Optional shared SourceRegistry (e.g. the same one used
44
+ by a PyStreamMCPAPI or PyStreamMCPServer instance, so sources
45
+ registered on one surface are discoverable from another). A
46
+ new, empty registry is created if not given.
38
47
  """
39
48
  super().__init__(config)
40
49
  self.agent = Agent(
@@ -43,6 +52,7 @@ class LangchainAdapter(AgentFrameworkAdapter):
43
52
  optimization_strategy=config.optimization_strategy,
44
53
  max_tokens=config.max_tokens,
45
54
  )
55
+ self.registry = registry if registry is not None else SourceRegistry()
46
56
 
47
57
  def query(
48
58
  self, text: str, intent: str = "retrieve", **kwargs
@@ -92,6 +102,11 @@ class LangchainAdapter(AgentFrameworkAdapter):
92
102
  def discover(self, context: str, **kwargs) -> Dict[str, Any]:
93
103
  """Discover relevant data sources for context.
94
104
 
105
+ Ranks sources registered on `self.registry` (see
106
+ `SourceRegistry.register` / pass a shared registry to `__init__`)
107
+ by real token overlap against `context`. Returns an empty list if
108
+ nothing is registered or nothing overlaps — never fabricated data.
109
+
95
110
  Args:
96
111
  context: Context for discovery
97
112
  **kwargs: Additional arguments
@@ -99,17 +114,10 @@ class LangchainAdapter(AgentFrameworkAdapter):
99
114
  Returns:
100
115
  Dictionary with discovered sources
101
116
  """
102
- # TODO: Integrate with discovery module
117
+ sources = self.registry.discover(context, limit=kwargs.get("limit", 10))
103
118
  return {
104
- "sources": [
105
- {
106
- "name": "data_warehouse",
107
- "relevance": 0.95,
108
- "type": "database",
109
- "estimated_tokens": 500,
110
- }
111
- ],
112
- "total_sources": 1,
119
+ "sources": sources,
120
+ "total_sources": len(sources),
113
121
  "search_context": context,
114
122
  }
115
123
 
@@ -8,7 +8,7 @@ Provides both:
8
8
  2. Legacy StreamMCPRetriever (backward compatible)
9
9
  """
10
10
 
11
- from typing import Any, List, Optional, Dict
11
+ from typing import Any, List, Optional, Dict, TYPE_CHECKING
12
12
  import asyncio
13
13
  from pystreammcp import (
14
14
  Agent,
@@ -22,6 +22,9 @@ from pystreammcp import (
22
22
 
23
23
  from pystreammcp.adapters import QueryResult as AdapterQueryResult
24
24
 
25
+ if TYPE_CHECKING:
26
+ from llama_index.core.retrievers import BaseRetriever
27
+
25
28
 
26
29
  class LlamaIndexAdapter(AgentFrameworkAdapter):
27
30
  """LlamaIndex adapter using new AgentFrameworkAdapter base class.
@@ -8,7 +8,7 @@ Provides both:
8
8
  2. Legacy PyStreamMCPPlugin (backward compatible)
9
9
  """
10
10
 
11
- from typing import Optional, Dict, Any
11
+ from typing import Optional, Dict, Any, TYPE_CHECKING
12
12
  import asyncio
13
13
  from pystreammcp import (
14
14
  Agent,
@@ -22,6 +22,9 @@ from pystreammcp import (
22
22
 
23
23
  from pystreammcp.adapters import QueryResult as AdapterQueryResult
24
24
 
25
+ if TYPE_CHECKING:
26
+ from semantic_kernel.functions import KernelPlugin
27
+
25
28
 
26
29
  class SemanticKernelAdapter(AgentFrameworkAdapter):
27
30
  """Semantic Kernel adapter using new AdapterFrameworkAdapter base class.