PyStreamMCP 3.2.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.2.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 []),
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/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]
@@ -21,6 +21,7 @@ from pystreammcp import (
21
21
  )
22
22
 
23
23
  from pystreammcp.adapters import QueryResult as AdapterQueryResult
24
+ from pystreammcp.discovery import SourceRegistry
24
25
 
25
26
 
26
27
  class LangchainAdapter(AgentFrameworkAdapter):
@@ -30,11 +31,15 @@ class LangchainAdapter(AgentFrameworkAdapter):
30
31
  Supports both sync and async execution.
31
32
  """
32
33
 
33
- def __init__(self, config: AdapterConfig):
34
+ def __init__(self, config: AdapterConfig, registry: Optional[SourceRegistry] = None):
34
35
  """Initialize Langchain adapter.
35
36
 
36
37
  Args:
37
38
  config: Adapter configuration
39
+ registry: Optional shared SourceRegistry (e.g. the same one used
40
+ by a PyStreamMCPAPI or PyStreamMCPServer instance, so sources
41
+ registered on one surface are discoverable from another). A
42
+ new, empty registry is created if not given.
38
43
  """
39
44
  super().__init__(config)
40
45
  self.agent = Agent(
@@ -43,6 +48,7 @@ class LangchainAdapter(AgentFrameworkAdapter):
43
48
  optimization_strategy=config.optimization_strategy,
44
49
  max_tokens=config.max_tokens,
45
50
  )
51
+ self.registry = registry if registry is not None else SourceRegistry()
46
52
 
47
53
  def query(
48
54
  self, text: str, intent: str = "retrieve", **kwargs
@@ -92,6 +98,11 @@ class LangchainAdapter(AgentFrameworkAdapter):
92
98
  def discover(self, context: str, **kwargs) -> Dict[str, Any]:
93
99
  """Discover relevant data sources for context.
94
100
 
101
+ Ranks sources registered on `self.registry` (see
102
+ `SourceRegistry.register` / pass a shared registry to `__init__`)
103
+ by real token overlap against `context`. Returns an empty list if
104
+ nothing is registered or nothing overlaps — never fabricated data.
105
+
95
106
  Args:
96
107
  context: Context for discovery
97
108
  **kwargs: Additional arguments
@@ -99,17 +110,10 @@ class LangchainAdapter(AgentFrameworkAdapter):
99
110
  Returns:
100
111
  Dictionary with discovered sources
101
112
  """
102
- # TODO: Integrate with discovery module
113
+ sources = self.registry.discover(context, limit=kwargs.get("limit", 10))
103
114
  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,
115
+ "sources": sources,
116
+ "total_sources": len(sources),
113
117
  "search_context": context,
114
118
  }
115
119
 
pystreammcp/mcp_server.py CHANGED
@@ -4,17 +4,86 @@ Implements the Model Context Protocol standard to expose
4
4
  query planning, discovery, and optimization as MCP tools.
5
5
  """
6
6
 
7
- import json
8
7
  import logging
9
- from typing import Any, Dict, List, Optional
8
+ from typing import Any, Dict, List, Literal, Optional, Type
10
9
  from dataclasses import dataclass
11
10
 
11
+ from pydantic import BaseModel, Field, ValidationError
12
+
12
13
  from .agent import Agent
14
+ from .discovery import SourceRegistry
13
15
 
14
16
 
15
17
  logger = logging.getLogger(__name__)
16
18
 
17
19
 
20
+ # ── MCP tool argument schemas ────────────────────────────────────────────────
21
+ #
22
+ # Previously `call_tool`/`_tool_*` only did manual `if not text: return error`
23
+ # checks per field -- no type checking (a `max_tokens` of "ten" would pass
24
+ # straight through to `int()` arithmetic downstream), no enum validation (an
25
+ # `intent`/`strategy` of anything at all was accepted despite the JSON schema
26
+ # below advertising a fixed enum), and no consistent required-field handling
27
+ # across the four tools. These models are now both the single source of
28
+ # truth for `input_schema` (via `model_json_schema()`) and the actual runtime
29
+ # validation, so the two can no longer silently drift apart the way a
30
+ # hand-duplicated dict schema could.
31
+
32
+
33
+ class QueryToolArgs(BaseModel):
34
+ text: str = Field(..., min_length=1, description="The query text")
35
+ intent: Literal["retrieve", "discover", "aggregate", "synthesize", "analyze"] = Field(
36
+ "retrieve", description="Query intent - what the agent is trying to do"
37
+ )
38
+ max_tokens: Optional[int] = Field(
39
+ None, gt=0, description="Maximum tokens for context (overrides default)"
40
+ )
41
+
42
+
43
+ class DiscoverToolArgs(BaseModel):
44
+ context: str = Field(..., min_length=1, description="Context for discovery")
45
+ limit: int = Field(10, gt=0, le=1000, description="Maximum sources to return")
46
+
47
+
48
+ class RegisterSourceToolArgs(BaseModel):
49
+ name: str = Field(..., min_length=1, description="Unique source name")
50
+ description: str = Field(
51
+ ..., min_length=1, description="What this source contains, in plain text"
52
+ )
53
+ type: str = Field("unknown", description="Source type, e.g. database, api, docs")
54
+ tags: List[str] = Field(default_factory=list, description="Optional keyword tags")
55
+
56
+
57
+ class OptimizeToolArgs(BaseModel):
58
+ text: str = Field(..., min_length=1, description="Query to optimize")
59
+ strategy: Literal["balanced", "token_efficient", "quality_first"] = Field(
60
+ "balanced", description="Optimization strategy"
61
+ )
62
+
63
+
64
+ _TOOL_ARG_MODELS: Dict[str, Type[BaseModel]] = {
65
+ "pystreammcp_query": QueryToolArgs,
66
+ "pystreammcp_discover": DiscoverToolArgs,
67
+ "pystreammcp_register_source": RegisterSourceToolArgs,
68
+ "pystreammcp_optimize": OptimizeToolArgs,
69
+ }
70
+
71
+
72
+ def _validation_error_response(name: str, error: ValidationError) -> Dict[str, Any]:
73
+ return {
74
+ "status": "error",
75
+ "message": f"Invalid arguments for tool '{name}'",
76
+ "validation_errors": [
77
+ {
78
+ "field": ".".join(str(p) for p in e["loc"]),
79
+ "issue": e["msg"],
80
+ "type": e["type"],
81
+ }
82
+ for e in error.errors()
83
+ ],
84
+ }
85
+
86
+
18
87
  @dataclass
19
88
  class MCPTool:
20
89
  """MCP tool definition."""
@@ -27,89 +96,51 @@ class MCPTool:
27
96
  class PyStreamMCPServer:
28
97
  """MCP server implementing PyStreamMCP protocol."""
29
98
 
30
- def __init__(self, agent_id: str = "mcp_agent"):
99
+ def __init__(self, agent_id: str = "mcp_agent", agent: Optional[Agent] = None):
31
100
  """Initialize MCP server.
32
101
 
33
102
  Args:
34
- agent_id: Agent ID for this server
103
+ agent_id: Agent ID for this server (ignored if `agent` is given)
104
+ agent: Optional pre-built `Agent` (or any object exposing a
105
+ compatible `.query(text)` method) to use instead of
106
+ constructing a default one. Mainly for tests that need to
107
+ inject controlled failure behavior (rate limits, malformed
108
+ responses, etc.) into the tool-execution path.
35
109
  """
36
110
  self.agent_id = agent_id
37
- self.agent = Agent(
111
+ self.agent = agent or Agent(
38
112
  agent_id=agent_id,
39
113
  name=f"MCP Agent: {agent_id}",
40
114
  optimization_strategy="balanced",
41
115
  max_tokens=2000,
42
116
  )
117
+ self.registry = SourceRegistry()
43
118
  self.tools = self._define_tools()
44
119
 
45
120
  def _define_tools(self) -> Dict[str, MCPTool]:
46
- """Define MCP tools."""
121
+ """Define MCP tools. `input_schema` is generated from the Pydantic
122
+ arg models above, so the advertised schema and the schema actually
123
+ enforced at call time can't drift apart."""
47
124
  return {
48
125
  "query": MCPTool(
49
126
  name="pystreammcp_query",
50
127
  description="Execute a query with PyStreamMCP optimization and discover relevant context",
51
- input_schema={
52
- "type": "object",
53
- "properties": {
54
- "text": {
55
- "type": "string",
56
- "description": "The query text",
57
- },
58
- "intent": {
59
- "type": "string",
60
- "enum": [
61
- "retrieve",
62
- "discover",
63
- "aggregate",
64
- "synthesize",
65
- "analyze",
66
- ],
67
- "description": "Query intent - what the agent is trying to do",
68
- },
69
- "max_tokens": {
70
- "type": "integer",
71
- "description": "Maximum tokens for context (overrides default)",
72
- },
73
- },
74
- "required": ["text"],
75
- },
128
+ input_schema=QueryToolArgs.model_json_schema(),
76
129
  ),
77
130
  "discover": MCPTool(
78
131
  name="pystreammcp_discover",
79
132
  description="Discover relevant data sources and context for a query",
80
- input_schema={
81
- "type": "object",
82
- "properties": {
83
- "context": {
84
- "type": "string",
85
- "description": "Context for discovery",
86
- },
87
- "limit": {
88
- "type": "integer",
89
- "description": "Maximum sources to return",
90
- },
91
- },
92
- "required": ["context"],
93
- },
133
+ input_schema=DiscoverToolArgs.model_json_schema(),
134
+ ),
135
+ "register_source": MCPTool(
136
+ name="pystreammcp_register_source",
137
+ description="Register a data source (name, description, tags) so pystreammcp_discover can find it",
138
+ input_schema=RegisterSourceToolArgs.model_json_schema(),
94
139
  ),
95
140
  "optimize": MCPTool(
96
141
  name="pystreammcp_optimize",
97
142
  description="Optimize a query for cost reduction while maintaining quality",
98
- input_schema={
99
- "type": "object",
100
- "properties": {
101
- "text": {
102
- "type": "string",
103
- "description": "Query to optimize",
104
- },
105
- "strategy": {
106
- "type": "string",
107
- "enum": ["balanced", "token_efficient", "quality_first"],
108
- "description": "Optimization strategy",
109
- },
110
- },
111
- "required": ["text"],
112
- },
143
+ input_schema=OptimizeToolArgs.model_json_schema(),
113
144
  ),
114
145
  }
115
146
 
@@ -131,6 +162,16 @@ class PyStreamMCPServer:
131
162
  def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
132
163
  """Call a tool by name.
133
164
 
165
+ Validates `arguments` against the tool's Pydantic arg model before
166
+ dispatch (returning a structured `validation_errors` response on
167
+ failure, rather than letting a wrong type or invalid enum value
168
+ flow through to the handler unchecked), and isolates the handler
169
+ call in a try/except so a failure during execution -- a bug, or a
170
+ downstream dependency (e.g. a real LLM/framework adapter wired in
171
+ via `agent=`) hitting a rate limit, context-window overrun, or
172
+ returning malformed data -- produces a structured error response
173
+ instead of propagating out of `call_tool` uncaught.
174
+
134
175
  Args:
135
176
  name: Tool name
136
177
  arguments: Tool arguments
@@ -141,64 +182,59 @@ class PyStreamMCPServer:
141
182
  Raises:
142
183
  ValueError: If tool not found
143
184
  """
144
- if name == "pystreammcp_query":
145
- return self._tool_query(arguments)
146
- elif name == "pystreammcp_discover":
147
- return self._tool_discover(arguments)
148
- elif name == "pystreammcp_optimize":
149
- return self._tool_optimize(arguments)
150
- else:
185
+ model = _TOOL_ARG_MODELS.get(name)
186
+ if model is None:
151
187
  raise ValueError(f"Unknown tool: {name}")
152
188
 
153
- def _tool_query(self, args: Dict[str, Any]) -> Dict[str, Any]:
154
- """Execute query tool."""
155
- text = args.get("text")
156
- intent = args.get("intent", "retrieve")
157
- max_tokens = args.get("max_tokens")
158
-
159
- if not text:
189
+ try:
190
+ validated = model(**arguments)
191
+ except ValidationError as e:
192
+ return _validation_error_response(name, e)
193
+
194
+ handler = {
195
+ "pystreammcp_query": self._tool_query,
196
+ "pystreammcp_discover": self._tool_discover,
197
+ "pystreammcp_register_source": self._tool_register_source,
198
+ "pystreammcp_optimize": self._tool_optimize,
199
+ }[name]
200
+
201
+ try:
202
+ return handler(validated)
203
+ except Exception as e:
204
+ logger.exception(f"Tool '{name}' failed during execution")
160
205
  return {
161
206
  "status": "error",
162
- "message": "text parameter required",
207
+ "message": f"Tool '{name}' failed during execution: {e}",
208
+ "error_type": type(e).__name__,
163
209
  }
164
210
 
165
- result = self.agent.query(text)
211
+ def _tool_query(self, args: QueryToolArgs) -> Dict[str, Any]:
212
+ """Execute query tool."""
213
+ result = self.agent.query(args.text)
166
214
 
167
215
  return {
168
216
  "status": "success",
169
217
  "query_id": result.query_id,
170
- "text": text,
171
- "intent": intent,
218
+ "text": args.text,
219
+ "intent": args.intent,
172
220
  "baseline_tokens": result.baseline_tokens,
173
221
  "optimized_tokens": result.optimized_tokens,
174
222
  "cost_reduction_percent": result.cost_reduction_percent,
175
223
  "execution_time_ms": result.execution_time_ms,
176
224
  "context": {
177
225
  "description": "Context optimized for the query",
178
- "recommendation": f"Use this context for {intent} queries",
226
+ "recommendation": f"Use this context for {args.intent} queries",
179
227
  },
180
228
  }
181
229
 
182
- def _tool_discover(self, args: Dict[str, Any]) -> Dict[str, Any]:
183
- """Execute discovery tool."""
184
- context = args.get("context")
185
- limit = args.get("limit", 10)
230
+ def _tool_discover(self, args: DiscoverToolArgs) -> Dict[str, Any]:
231
+ """Execute discovery tool.
186
232
 
187
- if not context:
188
- return {
189
- "status": "error",
190
- "message": "context parameter required",
191
- }
192
-
193
- # TODO: Implement actual discovery logic
194
- sources = [
195
- {
196
- "name": f"source_{i}",
197
- "relevance": 0.95 - (i * 0.05),
198
- "type": "database",
199
- }
200
- for i in range(min(limit, 5))
201
- ]
233
+ Ranks *registered* sources (see pystreammcp_register_source) by
234
+ real token overlap against `context`. Returns an empty list if
235
+ nothing is registered or nothing overlaps — never fabricated data.
236
+ """
237
+ sources = self.registry.discover(args.context, limit=args.limit)
202
238
 
203
239
  return {
204
240
  "status": "success",
@@ -206,18 +242,20 @@ class PyStreamMCPServer:
206
242
  "total_sources": len(sources),
207
243
  }
208
244
 
209
- def _tool_optimize(self, args: Dict[str, Any]) -> Dict[str, Any]:
210
- """Execute optimization tool."""
211
- text = args.get("text")
212
- strategy = args.get("strategy", "balanced")
245
+ def _tool_register_source(self, args: RegisterSourceToolArgs) -> Dict[str, Any]:
246
+ """Register a data source for discovery."""
247
+ self.registry.register(
248
+ name=args.name,
249
+ description=args.description,
250
+ type=args.type,
251
+ tags=args.tags,
252
+ )
213
253
 
214
- if not text:
215
- return {
216
- "status": "error",
217
- "message": "text parameter required",
218
- }
254
+ return {"status": "success", "name": args.name}
219
255
 
220
- result = self.agent.query(text)
256
+ def _tool_optimize(self, args: OptimizeToolArgs) -> Dict[str, Any]:
257
+ """Execute optimization tool."""
258
+ result = self.agent.query(args.text)
221
259
 
222
260
  return {
223
261
  "status": "success",
@@ -225,7 +263,7 @@ class PyStreamMCPServer:
225
263
  "baseline_tokens": result.baseline_tokens,
226
264
  "optimized_tokens": result.optimized_tokens,
227
265
  "cost_reduction_percent": result.cost_reduction_percent,
228
- "strategy_used": strategy,
266
+ "strategy_used": args.strategy,
229
267
  "techniques": [
230
268
  "pruning",
231
269
  "summarization",
@@ -248,7 +286,16 @@ class PyStreamMCPServer:
248
286
  if message_type == "call_tool":
249
287
  tool_name = message.get("name")
250
288
  tool_args = message.get("arguments", {})
251
- return self.call_tool(tool_name, tool_args)
289
+ try:
290
+ return self.call_tool(tool_name, tool_args)
291
+ except ValueError as e:
292
+ # Unknown tool name -- call_tool's own validation/execution
293
+ # error paths already return structured dicts; this is the
294
+ # one case it still raises (see its docstring), and
295
+ # process_message is the actual message-transport entry
296
+ # point, so an unrecognized tool name shouldn't crash the
297
+ # whole call.
298
+ return {"status": "error", "message": str(e)}
252
299
 
253
300
  elif message_type == "list_tools":
254
301
  return {
pystreammcp/testing.py ADDED
@@ -0,0 +1,123 @@
1
+ """Reusable test doubles for PyStreamMCP, exported for downstream use.
2
+
3
+ Previously the only mock (`MockAdapter` in `tests/test_sprint1_foundation.py`)
4
+ was test-local -- not importable by a downstream project's own test suite,
5
+ and not usable for simulating real LLM/framework failure states (rate
6
+ limits, context-window overruns, malformed model responses) at all, since it
7
+ only ever returned a fixed happy-path result.
8
+
9
+ This module provides:
10
+ - `MockAdapter`: the same happy-path mock, promoted here so it's a real,
11
+ importable, reusable fixture instead of copy-pasted per test file.
12
+ - `RateLimitError`, `ContextWindowExceededError`,
13
+ `MalformedModelResponseError`: the three LLM-failure classes named in
14
+ the "no end-to-end tests for LLM failure states" gap. PyStreamMCP's own
15
+ code never calls a model provider directly (see README "Known Issues"),
16
+ so these aren't wrapping any provider-specific SDK exception -- they
17
+ exist to let tests exercise how PyStreamMCP's *own* code (the MCP tool
18
+ dispatcher, adapter query paths) behaves when something it's
19
+ orchestrating (a real `Agent`/`AgentFrameworkAdapter` a caller plugs in)
20
+ fails in one of these ways, without needing network access or a real
21
+ API key.
22
+ - `FailingAgent` / `FailingAdapter`: drop-in stand-ins for `Agent` /
23
+ `AgentFrameworkAdapter` that raise a configured failure on `query()`
24
+ instead of computing a result.
25
+ """
26
+
27
+ from typing import Any, Dict, Optional
28
+
29
+ from .adapters import AdapterConfig, AgentFrameworkAdapter, QueryResult
30
+
31
+
32
+ class RateLimitError(Exception):
33
+ """Raised by a test double to simulate a model provider rate limit."""
34
+
35
+
36
+ class ContextWindowExceededError(Exception):
37
+ """Raised by a test double to simulate exceeding the model's context window."""
38
+
39
+
40
+ class MalformedModelResponseError(Exception):
41
+ """Raised by a test double to simulate a model returning invalid/unparseable JSON."""
42
+
43
+
44
+ class MockAdapter(AgentFrameworkAdapter):
45
+ """Happy-path mock adapter: always returns a fixed, valid result.
46
+
47
+ Useful for tests that need *an* adapter wired in but don't care about
48
+ its output -- for failure-injection tests, use `FailingAdapter` instead.
49
+ """
50
+
51
+ def query(self, text: str, intent: str = "retrieve", **kwargs) -> QueryResult:
52
+ return QueryResult(
53
+ query_id="mock-query-1",
54
+ text=text,
55
+ intent=intent,
56
+ baseline_tokens=1000,
57
+ optimized_tokens=400,
58
+ cost_reduction_percent=60.0,
59
+ execution_time_ms=150.5,
60
+ context={"result": "mock"},
61
+ )
62
+
63
+ async def query_async(self, text: str, intent: str = "retrieve", **kwargs) -> QueryResult:
64
+ return self.query(text, intent, **kwargs)
65
+
66
+ def discover(self, context: str, **kwargs) -> Dict[str, Any]:
67
+ return {
68
+ "sources": [{"name": "source_1", "relevance": 0.95}],
69
+ "total": 1,
70
+ }
71
+
72
+ async def discover_async(self, context: str, **kwargs) -> Dict[str, Any]:
73
+ return self.discover(context, **kwargs)
74
+
75
+ def optimize(self, query_text: str, strategy: Optional[str] = None, **kwargs) -> QueryResult:
76
+ return self.query(query_text)
77
+
78
+ async def optimize_async(
79
+ self, query_text: str, strategy: Optional[str] = None, **kwargs
80
+ ) -> QueryResult:
81
+ return self.optimize(query_text, strategy, **kwargs)
82
+
83
+
84
+ class FailingAgent:
85
+ """Drop-in stand-in for `pystreammcp.agent.Agent`: raises `failure` from
86
+ `query()` instead of computing a result. Pass to
87
+ `PyStreamMCPServer(agent=FailingAgent(RateLimitError(...)))` to test how
88
+ the MCP tool-call path handles a failure during execution."""
89
+
90
+ def __init__(self, failure: Exception):
91
+ self.failure = failure
92
+
93
+ def query(self, text: str, *args, **kwargs):
94
+ raise self.failure
95
+
96
+
97
+ class FailingAdapter(AgentFrameworkAdapter):
98
+ """Drop-in `AgentFrameworkAdapter` that raises `failure` from every
99
+ operation, for framework-integration failure-path tests."""
100
+
101
+ def __init__(self, config: AdapterConfig, failure: Exception):
102
+ super().__init__(config)
103
+ self.failure = failure
104
+
105
+ def query(self, text: str, intent: str = "retrieve", **kwargs) -> QueryResult:
106
+ raise self.failure
107
+
108
+ async def query_async(self, text: str, intent: str = "retrieve", **kwargs) -> QueryResult:
109
+ raise self.failure
110
+
111
+ def discover(self, context: str, **kwargs) -> Dict[str, Any]:
112
+ raise self.failure
113
+
114
+ async def discover_async(self, context: str, **kwargs) -> Dict[str, Any]:
115
+ raise self.failure
116
+
117
+ def optimize(self, query_text: str, strategy: Optional[str] = None, **kwargs) -> QueryResult:
118
+ raise self.failure
119
+
120
+ async def optimize_async(
121
+ self, query_text: str, strategy: Optional[str] = None, **kwargs
122
+ ) -> QueryResult:
123
+ raise self.failure
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyStreamMCP
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Intelligent MCP orchestration hub - Intent understanding, capability matching, tool ranking with cohesive foundation
5
5
  Author-email: Georgi Mammen Mullassery <mullassery@gmail.com>
6
6
  License: Proprietary License — Free to use with explicit attribution
@@ -91,7 +91,11 @@ pip install "PyStreamMCP[semantic-kernel]" # Semantic Kernel adapter
91
91
  pip install "PyStreamMCP[all-integrations]" # every framework adapter
92
92
  ```
93
93
 
94
- Requires Python 3.9+.
94
+ Requires Python 3.9+ for the core SDK (`Agent`, `SourceRegistry`,
95
+ `Orchestrator`). The `mcp` and `semantic-kernel` extras require Python
96
+ 3.10+ — their upstream packages don't publish Python 3.9-compatible
97
+ releases, so `pip install` for those extras will fail on 3.9. CI tests
98
+ 3.10, 3.11, and 3.12 only; 3.9 is not covered by CI.
95
99
 
96
100
  ## Quick start
97
101
 
@@ -120,6 +124,33 @@ for a full runnable walkthrough, including framework adapters for
120
124
  LangChain, LlamaIndex, CrewAI, Semantic Kernel, PydanticAI, and Haystack
121
125
  under [`pystreammcp.integrations`](python/pystreammcp/integrations/).
122
126
 
127
+ ## Data source discovery
128
+
129
+ `SourceRegistry` (`pystreammcp.SourceRegistry`) ranks *registered* data
130
+ sources by real word-overlap relevance to a query — not a fabricated or
131
+ hardcoded result:
132
+
133
+ ```python
134
+ from pystreammcp import SourceRegistry
135
+
136
+ registry = SourceRegistry()
137
+ registry.register(
138
+ "orders_db",
139
+ description="customer order history and revenue by quarter",
140
+ type="database",
141
+ tags=["orders", "revenue"],
142
+ )
143
+
144
+ results = registry.discover("customer revenue by quarter")
145
+ # [{"name": "orders_db", "type": "database", "relevance": 0.4444, "matched_terms": [...]}]
146
+ ```
147
+
148
+ The same registry backs `POST /discover` (REST), the `pystreammcp_discover`
149
+ MCP tool, and `LangchainAdapter.discover()` — register sources once via
150
+ `POST /sources` / `pystreammcp_register_source` / `registry.register()`,
151
+ then discover against them from any of the three surfaces. This is a
152
+ simple Jaccard token-overlap heuristic, not semantic/embedding search.
153
+
123
154
  ## MCP orchestration & federation
124
155
 
125
156
  `Orchestrator` (`pystreammcp.Orchestrator`) discovers and routes across
@@ -217,6 +248,49 @@ pip install -e ".[dev,api,mcp,langchain,llamaindex,semantic-kernel]"
217
248
  pytest tests/ -v
218
249
  ```
219
250
 
251
+ Note: this exact command needs Python 3.10+ (see [Install](#install)); on
252
+ 3.9 the `mcp` and `semantic-kernel` extras fail to resolve, so drop them
253
+ from the extras list if you're on 3.9.
254
+
255
+ ## Known Issues
256
+
257
+ - ~~No end-to-end tests for LLM failure states~~ **Fixed.** PyStreamMCP's
258
+ own code never calls a model provider directly (no code path here to add
259
+ `except RateLimitError`/`APIError` handling *to* — see `AgentFrameworkAdapter`
260
+ below), so what's now tested is the part this repo actually owns: that the
261
+ MCP tool-call path doesn't crash when whatever it's orchestrating fails.
262
+ `pystreammcp.testing` exports `RateLimitError`, `ContextWindowExceededError`,
263
+ `MalformedModelResponseError`, and `FailingAgent`/`FailingAdapter` stand-ins
264
+ that raise them; `mcp_server.py`'s `call_tool`/`process_message` now catch
265
+ exceptions raised during tool execution and return a structured
266
+ `{"status": "error", "error_type": ..., "message": ...}` response instead
267
+ of propagating uncaught. See `tests/test_mcp_server_resilience.py`.
268
+ - ~~No Pydantic validation for the MCP tool protocol itself~~ **Fixed.**
269
+ Each of the four MCP tools now has a Pydantic arg model
270
+ (`QueryToolArgs`/`DiscoverToolArgs`/`RegisterSourceToolArgs`/`OptimizeToolArgs`
271
+ in `mcp_server.py`) validated in `call_tool` before dispatch (wrong types,
272
+ invalid enum values, and missing required fields all now return a
273
+ structured `validation_errors` response instead of reaching the handler
274
+ unchecked), and `MCPTool.input_schema` is generated from those same models
275
+ via `model_json_schema()` — the advertised schema and the schema actually
276
+ enforced can no longer drift apart the way a hand-duplicated dict could.
277
+ - ~~No shipped mock MCP server / mock LLM fixture~~ **Fixed.** Added
278
+ `tests/conftest.py` (centralizing the `sys.path` setup 5 test files were
279
+ duplicating, plus `mock_adapter`/`failing_agent`/`failing_adapter`/
280
+ `llm_failure` pytest fixtures) and `pystreammcp/testing.py`, exporting
281
+ `MockAdapter` (promoted from the old test-local copy) and the
282
+ failure-injection stand-ins above for downstream projects' own test
283
+ suites to import directly.
284
+ - **Per-query context discovery** now has a real implementation
285
+ (`pystreammcp.discovery.SourceRegistry`): register data sources (name,
286
+ description, type, tags), and `/discover` (REST), `pystreammcp_discover`
287
+ (MCP), and `LangchainAdapter.discover()` all rank *registered* sources
288
+ by real Jaccard token-overlap against the query — an empty registry or
289
+ a non-matching context returns an empty list, not fabricated data. This
290
+ is a simple word-overlap heuristic, not semantic/embedding search — if
291
+ you need that, wrap `SourceRegistry` with your own similarity scoring.
292
+ - No open GitHub issues as of this pass.
293
+
220
294
  ## Rust workspace (not shipped)
221
295
 
222
296
  `core/` and `python/src/lib.rs` are an in-progress Rust workspace intended
@@ -1,16 +1,16 @@
1
- pystreammcp/__init__.py,sha256=XBZOos263gyAHVhxNXsaeXIDt9hcS77iVdAGoFZ02to,2813
1
+ pystreammcp/__init__.py,sha256=paZxqfxmLptS1tkZkL5LBU_MDKMTxCirHp8vGiOguS4,2881
2
2
  pystreammcp/_mcp_connector.py,sha256=CTPdQHT64aVxmolwG0l0irCj2uQshwJvhbdbGgyBEu0,31208
3
3
  pystreammcp/_mcp_tools.py,sha256=7s3DtzIbJxHVFMmISdd9EcFbmtKrSMIDXH27j0onhMY,32540
4
4
  pystreammcp/adapters.py,sha256=uB0zGKJU6jcTyRsUDIiiOgGg6K-8dbJw1YLt0ZiAiao,6801
5
5
  pystreammcp/agent.py,sha256=LJUPxnvcrT01rG8l3pqVXQ58OZyeqBn41GYM-hyHIWo,6385
6
- pystreammcp/api.py,sha256=Qk90v7NwxsKp21bfwuzFv_tPWeRevda-nOhpmSYSe0I,11004
6
+ pystreammcp/api.py,sha256=4_9i86lQFKxzHdPB1TXzYVBJS6jna9Q2_s7Ea3-v6vg,12860
7
7
  pystreammcp/cli.py,sha256=Tk_vlwZT5Rv6OB4QXOaWUhv_JxlqijiNK5fL2EU1O2s,8569
8
8
  pystreammcp/cli_daemon.py,sha256=_ZSNtifMGOIkcR2mZaycdDDYYes4jtv0cdeMdMKU0Jw,1999
9
9
  pystreammcp/cli_dashboard.py,sha256=J5pnUUpkvQGjltUOFN6SPbNF1yyXsjHQR0VCI14ty6M,10562
10
10
  pystreammcp/context.py,sha256=WIgwbb0MAZTnPfQiZW0PLunEtxJatsHmwTnCRsvSqLU,2213
11
11
  pystreammcp/decomposition.py,sha256=peRR1MZbH29DCWMSvtL4Btzp1QhpXsFuVo5LOXPnoIs,6532
12
- pystreammcp/discovery.py,sha256=dEXhMA6AEHy2Ipo5LyL_dJMvHwqvcqTj4nNOyEMwbI4,2472
13
- pystreammcp/mcp_server.py,sha256=pv_l20kjZTW9KMSGU4izADGlsfF0ggrU7Papta0fGro,8808
12
+ pystreammcp/discovery.py,sha256=FWfuYraXjBBkSReU-kxcKg0MJvnfmV3TH1wf5mvedH8,7312
13
+ pystreammcp/mcp_server.py,sha256=hL5z9op-X62kdtFXLCM213pJP_9LTr1GsEYSt5d4Zrs,11972
14
14
  pystreammcp/ml.py,sha256=SZMX0DGak4SHLoXbVw2n85OrbrHvnqqSzHIkvgEjS80,4005
15
15
  pystreammcp/multi_agent.py,sha256=oUoyjix6WqiE7yX2limQAwWEQkMbwcrFV-vSKK-iDYc,4641
16
16
  pystreammcp/okf_core.py,sha256=JtyZ-KoMwoM7fSzOt9lEA-7uSvUT-fN7qggE7KtGNgQ,10039
@@ -21,12 +21,13 @@ pystreammcp/quality.py,sha256=l9_iW5YTWLtYlA7UbX915Z_pJSQf7dJUXk_MtFDUfpc,12832
21
21
  pystreammcp/query.py,sha256=rtWYmoOM6hbZ1W2MJpiBOoQ6Y-x0vyr3fZqSHagZzuI,3441
22
22
  pystreammcp/server.py,sha256=3BplCTjmguETK3fk1ObGayM-wvET1e-Uujs5wwwfJxY,20473
23
23
  pystreammcp/streaming.py,sha256=yKurMNPgPpF4D_jZIXk_TZaIt4nHFcV1ODrHXylna_g,3283
24
+ pystreammcp/testing.py,sha256=b4E1JXfBYNqGPEVB5275lLPbfNDMqwutnnSoXLLnjis,4841
24
25
  pystreammcp/webhook_handlers.py,sha256=iawVSJJBj-DzDz7bd4ik4TnykdF2tU0tyYkCQhUwYq4,28084
25
26
  pystreammcp/webhook_router.py,sha256=a1lFKSluQA-y46O2wDAxwtZdg27GL7YgUYWBgySl1sY,20583
26
27
  pystreammcp/integrations/__init__.py,sha256=QOEpm_EcA7bLr7w0NXAWO-NLN5i37b_Q3vjA6-WDXxQ,101
27
28
  pystreammcp/integrations/crewai.py,sha256=v7qYQ4xo6FAaIUgOuNJYainF_7XkWGY9vX1kTO7EtrM,5724
28
29
  pystreammcp/integrations/haystack.py,sha256=bFVvzNeKmR7NVc4__8Z_aQBnWqMpHP8dzZms5so-NS4,7025
29
- pystreammcp/integrations/langchain.py,sha256=ngx9V5jDhigRzMzvOnqFs3vTkWC0OdP_OIiruPkXMAI,13966
30
+ pystreammcp/integrations/langchain.py,sha256=v5obvQdM06KHOpy7-npABvTb0hdAMxYKAgDM9av9RVM,14542
30
31
  pystreammcp/integrations/langsmith.py,sha256=ePQpQG_Zs3xCG-PbOeDY2CYDclMYFLEPNbQ-He-Ll5A,8642
31
32
  pystreammcp/integrations/llamaindex.py,sha256=Ra6p6ptABPiIJQ2E88Ta7FY1ALig-XXIuJxaijqwyuw,12329
32
33
  pystreammcp/integrations/pydantic_ai.py,sha256=NY0qFw6aL4ygUnt5bLfkJgFzeIPc5Yycp0SVhwARr2U,4675
@@ -47,8 +48,8 @@ pystreammcp/qa/__init__.py,sha256=NvUUjyWNIyMfckW4J34Cn1xKYX5CnDlM22mXZF3pfnY,17
47
48
  pystreammcp/qa/validator.py,sha256=qTpdkFE7Z7l16fE1emwqfbr8W2_zh1m4Zvzz3hjnGt8,10694
48
49
  pystreammcp/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
50
  pystreammcp/scripts/post_install.py,sha256=lnxiXH8Owq_F4M4KFHNL-GJ5JMYVevUcVo3M5JXOpRo,2719
50
- pystreammcp-3.2.0.dist-info/licenses/LICENSE,sha256=0SAYRjt9bcQPGaEkH6VFFLo9JWVIZ-cNcOZM89kJNhQ,970
51
- pystreammcp-3.2.0.dist-info/METADATA,sha256=_sVkccAmfYu165c0MRwSHgYKcZhhYzk5vUTPIHLotnc,8643
52
- pystreammcp-3.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
53
- pystreammcp-3.2.0.dist-info/top_level.txt,sha256=SHNryMw9AAzKhuqpS2tCkxIfTpx4z623tlW8BazwTNM,12
54
- pystreammcp-3.2.0.dist-info/RECORD,,
51
+ pystreammcp-3.3.0.dist-info/licenses/LICENSE,sha256=0SAYRjt9bcQPGaEkH6VFFLo9JWVIZ-cNcOZM89kJNhQ,970
52
+ pystreammcp-3.3.0.dist-info/METADATA,sha256=JtwjENWHTPTcsBRud2CJTeHaSJIVhGMKZKXsnxA1xR4,12669
53
+ pystreammcp-3.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
54
+ pystreammcp-3.3.0.dist-info/top_level.txt,sha256=SHNryMw9AAzKhuqpS2tCkxIfTpx4z623tlW8BazwTNM,12
55
+ pystreammcp-3.3.0.dist-info/RECORD,,