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 +4 -2
- pystreammcp/api.py +47 -4
- pystreammcp/discovery.py +139 -5
- pystreammcp/integrations/langchain.py +15 -11
- pystreammcp/mcp_server.py +157 -110
- pystreammcp/testing.py +123 -0
- {pystreammcp-3.2.0.dist-info → pystreammcp-3.3.0.dist-info}/METADATA +76 -2
- {pystreammcp-3.2.0.dist-info → pystreammcp-3.3.0.dist-info}/RECORD +11 -10
- {pystreammcp-3.2.0.dist-info → pystreammcp-3.3.0.dist-info}/WHEEL +0 -0
- {pystreammcp-3.2.0.dist-info → pystreammcp-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {pystreammcp-3.2.0.dist-info → pystreammcp-3.3.0.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
|
266
|
-
agent = self._get_agent(request.agent_id)
|
|
306
|
+
"""Discover data sources relevant to `context`.
|
|
267
307
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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": "
|
|
207
|
+
"message": f"Tool '{name}' failed during execution: {e}",
|
|
208
|
+
"error_type": type(e).__name__,
|
|
163
209
|
}
|
|
164
210
|
|
|
165
|
-
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
return {
|
|
216
|
-
"status": "error",
|
|
217
|
-
"message": "text parameter required",
|
|
218
|
-
}
|
|
254
|
+
return {"status": "success", "name": args.name}
|
|
219
255
|
|
|
220
|
-
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
13
|
-
pystreammcp/mcp_server.py,sha256=
|
|
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=
|
|
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.
|
|
51
|
-
pystreammcp-3.
|
|
52
|
-
pystreammcp-3.
|
|
53
|
-
pystreammcp-3.
|
|
54
|
-
pystreammcp-3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|