PyStreamMCP 0.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.
@@ -0,0 +1,276 @@
1
+ """SQLite persistence layer for PyStreamMCP metadata."""
2
+
3
+ import sqlite3
4
+ import json
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+ from datetime import datetime, timezone
8
+ from pystreammcp.core import Metadata, Capability, CapabilityClassification
9
+
10
+
11
+ class SqlitePersistence:
12
+ """SQLite-based persistence for metadata indexing."""
13
+
14
+ def __init__(self, path: str = ":memory:"):
15
+ """Initialize SQLite store."""
16
+ self.path = path
17
+ self.conn = sqlite3.connect(path, check_same_thread=False, timeout=30.0)
18
+ self.conn.row_factory = sqlite3.Row
19
+ self._init_schema()
20
+
21
+ def _init_schema(self) -> None:
22
+ """Initialize database schema."""
23
+ self.conn.executescript(
24
+ """
25
+ PRAGMA journal_mode = WAL;
26
+ PRAGMA synchronous = NORMAL;
27
+ PRAGMA temp_store = MEMORY;
28
+ PRAGMA foreign_keys = ON;
29
+
30
+ CREATE TABLE IF NOT EXISTS metadata (
31
+ id TEXT PRIMARY KEY,
32
+ name TEXT NOT NULL,
33
+ description TEXT,
34
+ server_id TEXT NOT NULL,
35
+ tool_id TEXT NOT NULL,
36
+ input_schema TEXT,
37
+ output_schema TEXT,
38
+ tags TEXT,
39
+ created_at TEXT,
40
+ updated_at TEXT
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS capabilities (
44
+ id TEXT PRIMARY KEY,
45
+ metadata_id TEXT NOT NULL,
46
+ classification TEXT NOT NULL,
47
+ inputs TEXT,
48
+ outputs TEXT,
49
+ cost_estimate REAL,
50
+ latency_estimate_ms INTEGER,
51
+ reliability_score REAL,
52
+ FOREIGN KEY(metadata_id) REFERENCES metadata(id)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS schemas (
56
+ id TEXT PRIMARY KEY,
57
+ metadata_id TEXT NOT NULL,
58
+ base_type TEXT NOT NULL,
59
+ properties TEXT,
60
+ required_fields TEXT,
61
+ is_array BOOLEAN,
62
+ extraction_confidence REAL,
63
+ FOREIGN KEY(metadata_id) REFERENCES metadata(id)
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS search_index (
67
+ metadata_id TEXT PRIMARY KEY,
68
+ name_lower TEXT,
69
+ description_lower TEXT,
70
+ all_text TEXT,
71
+ FOREIGN KEY(metadata_id) REFERENCES metadata(id)
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_server_id ON metadata(server_id);
75
+ CREATE INDEX IF NOT EXISTS idx_tool_id ON metadata(tool_id);
76
+ CREATE INDEX IF NOT EXISTS idx_capability_classification ON capabilities(classification);
77
+ CREATE INDEX IF NOT EXISTS idx_capability_metadata ON capabilities(metadata_id);
78
+ CREATE INDEX IF NOT EXISTS idx_schema_metadata ON schemas(metadata_id);
79
+ """
80
+ )
81
+ self.conn.commit()
82
+
83
+ def insert_metadata(self, metadata: Metadata) -> None:
84
+ """Insert or update metadata."""
85
+ cursor = self.conn.cursor()
86
+ cursor.execute(
87
+ """INSERT OR REPLACE INTO metadata
88
+ (id, name, description, server_id, tool_id, input_schema, output_schema, tags, created_at, updated_at)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
90
+ (
91
+ metadata.id,
92
+ metadata.name,
93
+ metadata.description,
94
+ metadata.server_id,
95
+ metadata.tool_id,
96
+ json.dumps(metadata.input_schema),
97
+ json.dumps(metadata.output_schema),
98
+ ",".join(metadata.tags),
99
+ metadata.created_at,
100
+ metadata.updated_at,
101
+ ),
102
+ )
103
+
104
+ # Update search index
105
+ cursor.execute(
106
+ """INSERT OR REPLACE INTO search_index
107
+ (metadata_id, name_lower, description_lower, all_text)
108
+ VALUES (?, ?, ?, ?)""",
109
+ (
110
+ metadata.id,
111
+ metadata.name.lower(),
112
+ metadata.description.lower(),
113
+ f"{metadata.name} {metadata.description} {' '.join(metadata.tags)}",
114
+ ),
115
+ )
116
+ self.conn.commit()
117
+
118
+ def get_metadata(self, id: str) -> Optional[Metadata]:
119
+ """Get metadata by ID."""
120
+ cursor = self.conn.cursor()
121
+ cursor.execute(
122
+ """SELECT id, name, description, server_id, tool_id, input_schema, output_schema, tags, created_at, updated_at
123
+ FROM metadata WHERE id = ?""",
124
+ (id,),
125
+ )
126
+ row = cursor.fetchone()
127
+ if row:
128
+ return Metadata(
129
+ id=row["id"],
130
+ name=row["name"],
131
+ description=row["description"],
132
+ server_id=row["server_id"],
133
+ tool_id=row["tool_id"],
134
+ input_schema=json.loads(row["input_schema"]),
135
+ output_schema=json.loads(row["output_schema"]),
136
+ tags=row["tags"].split(",") if row["tags"] else [],
137
+ created_at=row["created_at"],
138
+ updated_at=row["updated_at"],
139
+ )
140
+ return None
141
+
142
+ def search_metadata(self, query: str, limit: int = 100) -> List[Metadata]:
143
+ """Search metadata by name/description."""
144
+ cursor = self.conn.cursor()
145
+ pattern = f"%{query.lower()}%"
146
+ cursor.execute(
147
+ """SELECT m.id, m.name, m.description, m.server_id, m.tool_id,
148
+ m.input_schema, m.output_schema, m.tags, m.created_at, m.updated_at
149
+ FROM metadata m
150
+ INNER JOIN search_index s ON m.id = s.metadata_id
151
+ WHERE s.name_lower LIKE ? OR s.description_lower LIKE ?
152
+ LIMIT ?""",
153
+ (pattern, pattern, limit),
154
+ )
155
+
156
+ results = []
157
+ for row in cursor.fetchall():
158
+ results.append(
159
+ Metadata(
160
+ id=row["id"],
161
+ name=row["name"],
162
+ description=row["description"],
163
+ server_id=row["server_id"],
164
+ tool_id=row["tool_id"],
165
+ input_schema=json.loads(row["input_schema"]),
166
+ output_schema=json.loads(row["output_schema"]),
167
+ tags=row["tags"].split(",") if row["tags"] else [],
168
+ created_at=row["created_at"],
169
+ updated_at=row["updated_at"],
170
+ )
171
+ )
172
+ return results
173
+
174
+ def get_by_server(self, server_id: str) -> List[Metadata]:
175
+ """Get all metadata for a server."""
176
+ cursor = self.conn.cursor()
177
+ cursor.execute(
178
+ """SELECT id, name, description, server_id, tool_id, input_schema, output_schema, tags, created_at, updated_at
179
+ FROM metadata WHERE server_id = ?""",
180
+ (server_id,),
181
+ )
182
+
183
+ results = []
184
+ for row in cursor.fetchall():
185
+ results.append(
186
+ Metadata(
187
+ id=row["id"],
188
+ name=row["name"],
189
+ description=row["description"],
190
+ server_id=row["server_id"],
191
+ tool_id=row["tool_id"],
192
+ input_schema=json.loads(row["input_schema"]),
193
+ output_schema=json.loads(row["output_schema"]),
194
+ tags=row["tags"].split(",") if row["tags"] else [],
195
+ created_at=row["created_at"],
196
+ updated_at=row["updated_at"],
197
+ )
198
+ )
199
+ return results
200
+
201
+ def insert_capability(self, capability: Capability) -> None:
202
+ """Insert or update capability."""
203
+ cursor = self.conn.cursor()
204
+ cursor.execute(
205
+ """INSERT OR REPLACE INTO capabilities
206
+ (id, metadata_id, classification, inputs, outputs, cost_estimate, latency_estimate_ms, reliability_score)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
208
+ (
209
+ capability.id,
210
+ capability.metadata_id,
211
+ capability.classification.value,
212
+ ",".join(capability.inputs),
213
+ ",".join(capability.outputs),
214
+ capability.cost_estimate,
215
+ capability.latency_estimate_ms,
216
+ capability.reliability_score,
217
+ ),
218
+ )
219
+ self.conn.commit()
220
+
221
+ def get_capabilities_by_classification(
222
+ self, classification: CapabilityClassification
223
+ ) -> List[Capability]:
224
+ """Get capabilities by classification."""
225
+ cursor = self.conn.cursor()
226
+ cursor.execute(
227
+ """SELECT id, metadata_id, classification, inputs, outputs, cost_estimate, latency_estimate_ms, reliability_score
228
+ FROM capabilities WHERE classification = ?""",
229
+ (classification.value,),
230
+ )
231
+
232
+ results = []
233
+ for row in cursor.fetchall():
234
+ results.append(
235
+ Capability(
236
+ id=row["id"],
237
+ metadata_id=row["metadata_id"],
238
+ classification=CapabilityClassification(row["classification"]),
239
+ inputs=[s for s in row["inputs"].split(",") if s],
240
+ outputs=[s for s in row["outputs"].split(",") if s],
241
+ cost_estimate=row["cost_estimate"],
242
+ latency_estimate_ms=row["latency_estimate_ms"],
243
+ reliability_score=row["reliability_score"],
244
+ )
245
+ )
246
+ return results
247
+
248
+ def count_metadata(self) -> int:
249
+ """Get count of metadata entries."""
250
+ cursor = self.conn.cursor()
251
+ cursor.execute("SELECT COUNT(*) as count FROM metadata")
252
+ return cursor.fetchone()["count"]
253
+
254
+ def clear(self) -> None:
255
+ """Clear all data."""
256
+ self.conn.executescript(
257
+ """
258
+ DELETE FROM capabilities;
259
+ DELETE FROM schemas;
260
+ DELETE FROM search_index;
261
+ DELETE FROM metadata;
262
+ """
263
+ )
264
+ self.conn.commit()
265
+
266
+ def close(self) -> None:
267
+ """Close database connection."""
268
+ self.conn.close()
269
+
270
+ def __enter__(self):
271
+ """Context manager entry."""
272
+ return self
273
+
274
+ def __exit__(self, exc_type, exc_val, exc_tb):
275
+ """Context manager exit."""
276
+ self.close()
pystreammcp/routing.py ADDED
@@ -0,0 +1,239 @@
1
+ """Semantic routing engine for intelligent tool selection."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List
5
+ from pystreammcp.core import Metadata, Capability, CapabilityClassification
6
+
7
+
8
+ @dataclass
9
+ class RoutingScore:
10
+ """Score for routing a query to a tool."""
11
+
12
+ metadata_id: str
13
+ tool_name: str
14
+ server_id: str
15
+ relevance_score: float
16
+ capability_match: float
17
+ schema_match: float
18
+ cost_efficiency: float
19
+ overall_score: float
20
+
21
+ def __lt__(self, other):
22
+ """Sort by overall_score descending."""
23
+ return self.overall_score > other.overall_score
24
+
25
+
26
+ class SemanticRouter:
27
+ """Route queries to the most relevant tools."""
28
+
29
+ @staticmethod
30
+ def score_tool(
31
+ query: str, metadata: Metadata, capabilities: List[Capability]
32
+ ) -> RoutingScore:
33
+ """Score a tool for a given query."""
34
+ relevance = SemanticRouter._calculate_relevance(query, metadata)
35
+ capability_match = SemanticRouter._calculate_capability_match(query, capabilities)
36
+ schema_match = SemanticRouter._calculate_schema_match(query, metadata)
37
+ cost_efficiency = SemanticRouter._calculate_cost_efficiency(capabilities)
38
+
39
+ overall_score = (
40
+ (relevance * 0.4)
41
+ + (capability_match * 0.35)
42
+ + (schema_match * 0.15)
43
+ + (cost_efficiency * 0.1)
44
+ )
45
+
46
+ return RoutingScore(
47
+ metadata_id=metadata.id,
48
+ tool_name=metadata.name,
49
+ server_id=metadata.server_id,
50
+ relevance_score=relevance,
51
+ capability_match=capability_match,
52
+ schema_match=schema_match,
53
+ cost_efficiency=cost_efficiency,
54
+ overall_score=min(max(overall_score, 0.0), 1.0),
55
+ )
56
+
57
+ @staticmethod
58
+ def rank_tools(
59
+ query: str, metadata_list: List[Metadata], capabilities_map: dict
60
+ ) -> List[RoutingScore]:
61
+ """Rank all tools for a query."""
62
+ scores = []
63
+ for metadata in metadata_list:
64
+ caps = capabilities_map.get(metadata.id, [])
65
+ score = SemanticRouter.score_tool(query, metadata, caps)
66
+ scores.append(score)
67
+
68
+ scores.sort()
69
+ return scores
70
+
71
+ @staticmethod
72
+ def select_best_tools(
73
+ scores: List[RoutingScore], threshold: float = 0.7, max_count: int = 5
74
+ ) -> List[RoutingScore]:
75
+ """Select best tools above threshold."""
76
+ return [s for s in scores if s.overall_score >= threshold][:max_count]
77
+
78
+ @staticmethod
79
+ def _calculate_relevance(query: str, metadata: Metadata) -> float:
80
+ """Calculate relevance score."""
81
+ query_lower = query.lower()
82
+ name_lower = metadata.name.lower()
83
+ desc_lower = metadata.description.lower()
84
+
85
+ relevance = 0.0
86
+
87
+ # Exact name match
88
+ if query_lower in name_lower:
89
+ relevance += 0.8
90
+
91
+ # Word-level matches in name
92
+ for word in query_lower.split():
93
+ if len(word) > 2 and word in name_lower:
94
+ relevance += 0.3
95
+
96
+ # Description matches
97
+ desc_matches = sum(
98
+ 1 for word in query_lower.split() if len(word) > 2 and word in desc_lower
99
+ )
100
+ relevance += desc_matches * 0.15
101
+
102
+ # Tag matches
103
+ tag_matches = sum(
104
+ 1 for tag in metadata.tags if query_lower in tag.lower()
105
+ )
106
+ relevance += tag_matches * 0.2
107
+
108
+ return min(relevance, 1.0)
109
+
110
+ @staticmethod
111
+ def _calculate_capability_match(
112
+ query: str, capabilities: List[Capability]
113
+ ) -> float:
114
+ """Calculate capability match score."""
115
+ query_lower = query.lower()
116
+ match_score = 0.0
117
+
118
+ classifications = [
119
+ (CapabilityClassification.Search, "search"),
120
+ (CapabilityClassification.Read, "read"),
121
+ (CapabilityClassification.Write, "write"),
122
+ (CapabilityClassification.Compute, "compute"),
123
+ (CapabilityClassification.Transform, "transform"),
124
+ (CapabilityClassification.Aggregate, "aggregate"),
125
+ ]
126
+
127
+ for cap in capabilities:
128
+ for class_type, keyword in classifications:
129
+ if cap.classification == class_type and keyword in query_lower:
130
+ match_score += 0.6
131
+
132
+ # Check for input/output parameter matches
133
+ for input_param in cap.inputs:
134
+ if input_param.lower() in query_lower:
135
+ match_score += 0.2
136
+
137
+ return min(match_score, 1.0)
138
+
139
+ @staticmethod
140
+ def _calculate_schema_match(query: str, metadata: Metadata) -> float:
141
+ """Calculate schema match score."""
142
+ query_lower = query.lower()
143
+ schema_score = 0.0
144
+
145
+ # Check input schema
146
+ if "properties" in metadata.input_schema:
147
+ props = metadata.input_schema["properties"]
148
+ for key in props.keys():
149
+ if key.lower() in query_lower:
150
+ schema_score += 0.25
151
+
152
+ # Check output schema
153
+ if "properties" in metadata.output_schema:
154
+ props = metadata.output_schema["properties"]
155
+ for key in props.keys():
156
+ if key.lower() in query_lower:
157
+ schema_score += 0.15
158
+
159
+ return min(schema_score, 1.0)
160
+
161
+ @staticmethod
162
+ def _calculate_cost_efficiency(capabilities: List[Capability]) -> float:
163
+ """Calculate cost efficiency score."""
164
+ if not capabilities:
165
+ return 0.5
166
+
167
+ avg_cost = sum(c.cost_estimate for c in capabilities) / len(capabilities)
168
+ avg_reliability = sum(c.reliability_score for c in capabilities) / len(
169
+ capabilities
170
+ )
171
+
172
+ cost_score = 1.0 - min(avg_cost, 1.0)
173
+ reliability_score = avg_reliability
174
+
175
+ return min(
176
+ max((cost_score * 0.5 + reliability_score * 0.5), 0.0), 1.0
177
+ )
178
+
179
+
180
+ class RoutingPlan:
181
+ """Execution plan for a query."""
182
+
183
+ def __init__(
184
+ self,
185
+ query: str,
186
+ primary_tools: List[RoutingScore],
187
+ secondary_tools: List[RoutingScore],
188
+ ):
189
+ self.query = query
190
+ self.primary_tools = primary_tools
191
+ self.secondary_tools = secondary_tools
192
+ self.estimated_cost = len(primary_tools) * 0.1
193
+ self.estimated_latency_ms = min(len(primary_tools) * 50, 1000)
194
+ self.confidence = (
195
+ sum(t.overall_score for t in primary_tools) / len(primary_tools)
196
+ if primary_tools
197
+ else 0.0
198
+ )
199
+
200
+ def to_dict(self):
201
+ """Convert to dict."""
202
+ return {
203
+ "query": self.query,
204
+ "primary_tools": [
205
+ {
206
+ "tool_name": t.tool_name,
207
+ "server_id": t.server_id,
208
+ "overall_score": t.overall_score,
209
+ "relevance": t.relevance_score,
210
+ "capability_match": t.capability_match,
211
+ }
212
+ for t in self.primary_tools
213
+ ],
214
+ "secondary_tools": [
215
+ {
216
+ "tool_name": t.tool_name,
217
+ "server_id": t.server_id,
218
+ "overall_score": t.overall_score,
219
+ }
220
+ for t in self.secondary_tools[:3]
221
+ ],
222
+ "estimated_cost": self.estimated_cost,
223
+ "estimated_latency_ms": self.estimated_latency_ms,
224
+ "confidence": self.confidence,
225
+ }
226
+
227
+
228
+ class RoutingPlanner:
229
+ """Plan routing for queries."""
230
+
231
+ @staticmethod
232
+ def plan(
233
+ query: str, all_scores: List[RoutingScore], max_primary: int = 3
234
+ ) -> RoutingPlan:
235
+ """Create routing plan."""
236
+ primary = SemanticRouter.select_best_tools(all_scores, 0.7, max_primary)
237
+ secondary = SemanticRouter.select_best_tools(all_scores, 0.4, 5)
238
+
239
+ return RoutingPlan(query, primary, secondary)