sqlserver-semantic-mcp 0.5.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.
Files changed (74) hide show
  1. sqlserver_semantic_mcp/__init__.py +1 -0
  2. sqlserver_semantic_mcp/config.py +78 -0
  3. sqlserver_semantic_mcp/domain/__init__.py +0 -0
  4. sqlserver_semantic_mcp/domain/enums.py +48 -0
  5. sqlserver_semantic_mcp/domain/models/__init__.py +0 -0
  6. sqlserver_semantic_mcp/domain/models/column.py +14 -0
  7. sqlserver_semantic_mcp/domain/models/object.py +13 -0
  8. sqlserver_semantic_mcp/domain/models/relationship.py +11 -0
  9. sqlserver_semantic_mcp/domain/models/table.py +29 -0
  10. sqlserver_semantic_mcp/infrastructure/__init__.py +0 -0
  11. sqlserver_semantic_mcp/infrastructure/background.py +59 -0
  12. sqlserver_semantic_mcp/infrastructure/cache/__init__.py +0 -0
  13. sqlserver_semantic_mcp/infrastructure/cache/semantic.py +132 -0
  14. sqlserver_semantic_mcp/infrastructure/cache/store.py +152 -0
  15. sqlserver_semantic_mcp/infrastructure/cache/structural.py +203 -0
  16. sqlserver_semantic_mcp/infrastructure/connection.py +78 -0
  17. sqlserver_semantic_mcp/infrastructure/queries/__init__.py +0 -0
  18. sqlserver_semantic_mcp/infrastructure/queries/comment_queries.py +18 -0
  19. sqlserver_semantic_mcp/infrastructure/queries/metadata_queries.py +70 -0
  20. sqlserver_semantic_mcp/infrastructure/queries/object_queries.py +15 -0
  21. sqlserver_semantic_mcp/main.py +90 -0
  22. sqlserver_semantic_mcp/policy/__init__.py +0 -0
  23. sqlserver_semantic_mcp/policy/analyzer.py +194 -0
  24. sqlserver_semantic_mcp/policy/enforcer.py +104 -0
  25. sqlserver_semantic_mcp/policy/intents/__init__.py +16 -0
  26. sqlserver_semantic_mcp/policy/intents/ast_analyzer.py +24 -0
  27. sqlserver_semantic_mcp/policy/intents/base.py +17 -0
  28. sqlserver_semantic_mcp/policy/intents/regex_analyzer.py +11 -0
  29. sqlserver_semantic_mcp/policy/intents/router.py +21 -0
  30. sqlserver_semantic_mcp/policy/loader.py +90 -0
  31. sqlserver_semantic_mcp/policy/models.py +43 -0
  32. sqlserver_semantic_mcp/server/__init__.py +0 -0
  33. sqlserver_semantic_mcp/server/app.py +125 -0
  34. sqlserver_semantic_mcp/server/compact.py +74 -0
  35. sqlserver_semantic_mcp/server/prompts/__init__.py +5 -0
  36. sqlserver_semantic_mcp/server/prompts/analysis.py +56 -0
  37. sqlserver_semantic_mcp/server/prompts/discovery.py +55 -0
  38. sqlserver_semantic_mcp/server/prompts/execution.py +64 -0
  39. sqlserver_semantic_mcp/server/prompts/registry.py +41 -0
  40. sqlserver_semantic_mcp/server/resources/__init__.py +1 -0
  41. sqlserver_semantic_mcp/server/resources/schema.py +144 -0
  42. sqlserver_semantic_mcp/server/tools/__init__.py +42 -0
  43. sqlserver_semantic_mcp/server/tools/cache.py +24 -0
  44. sqlserver_semantic_mcp/server/tools/metadata.py +167 -0
  45. sqlserver_semantic_mcp/server/tools/metrics.py +44 -0
  46. sqlserver_semantic_mcp/server/tools/object_tool.py +113 -0
  47. sqlserver_semantic_mcp/server/tools/policy.py +48 -0
  48. sqlserver_semantic_mcp/server/tools/query.py +159 -0
  49. sqlserver_semantic_mcp/server/tools/relationship.py +104 -0
  50. sqlserver_semantic_mcp/server/tools/semantic.py +112 -0
  51. sqlserver_semantic_mcp/server/tools/shape.py +204 -0
  52. sqlserver_semantic_mcp/server/tools/workflow.py +307 -0
  53. sqlserver_semantic_mcp/services/__init__.py +0 -0
  54. sqlserver_semantic_mcp/services/metadata_service.py +173 -0
  55. sqlserver_semantic_mcp/services/metrics_service.py +124 -0
  56. sqlserver_semantic_mcp/services/object_service.py +187 -0
  57. sqlserver_semantic_mcp/services/policy_service.py +59 -0
  58. sqlserver_semantic_mcp/services/query_service.py +321 -0
  59. sqlserver_semantic_mcp/services/relationship_service.py +160 -0
  60. sqlserver_semantic_mcp/services/semantic_service.py +277 -0
  61. sqlserver_semantic_mcp/workflows/__init__.py +26 -0
  62. sqlserver_semantic_mcp/workflows/bundle.py +157 -0
  63. sqlserver_semantic_mcp/workflows/contracts.py +64 -0
  64. sqlserver_semantic_mcp/workflows/discovery_flow.py +116 -0
  65. sqlserver_semantic_mcp/workflows/facade.py +117 -0
  66. sqlserver_semantic_mcp/workflows/query_flow.py +120 -0
  67. sqlserver_semantic_mcp/workflows/recommendations.py +161 -0
  68. sqlserver_semantic_mcp/workflows/router.py +59 -0
  69. sqlserver_semantic_mcp-0.5.0.dist-info/METADATA +679 -0
  70. sqlserver_semantic_mcp-0.5.0.dist-info/RECORD +74 -0
  71. sqlserver_semantic_mcp-0.5.0.dist-info/WHEEL +5 -0
  72. sqlserver_semantic_mcp-0.5.0.dist-info/entry_points.txt +2 -0
  73. sqlserver_semantic_mcp-0.5.0.dist-info/licenses/LICENSE +21 -0
  74. sqlserver_semantic_mcp-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,307 @@
1
+ """Workflow-layer MCP tools (v0.5)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from mcp.types import Tool
7
+
8
+ from ...services import relationship_service, semantic_service, object_service
9
+ from ..app import get_context, register_tool
10
+
11
+
12
+ _DETAIL_PROP = {
13
+ "type": "string", "enum": ["brief", "standard", "full"], "default": "brief",
14
+ }
15
+
16
+
17
+ def register() -> None:
18
+ register_tool(
19
+ Tool(
20
+ name="discover_relevant_tables",
21
+ description=(
22
+ "Return a small, ranked candidate set for a natural-language "
23
+ "goal. Use before describe_table / find_join_path when the "
24
+ "target tables are not yet known."
25
+ ),
26
+ inputSchema={
27
+ "type": "object",
28
+ "properties": {
29
+ "goal": {"type": "string"},
30
+ "schemas": {"type": "array",
31
+ "items": {"type": "string"}},
32
+ "keyword": {"type": "string"},
33
+ "limit": {"type": "integer", "minimum": 1, "default": 10},
34
+ "classify": {"type": "boolean", "default": False},
35
+ },
36
+ "required": ["goal"],
37
+ },
38
+ ),
39
+ _discover,
40
+ )
41
+ register_tool(
42
+ Tool(
43
+ name="suggest_next_tool",
44
+ description=(
45
+ "Given the agent's current state (optional query, goal, or "
46
+ "discovered context), return the recommended next tool call. "
47
+ "Runs no DB queries."
48
+ ),
49
+ inputSchema={
50
+ "type": "object",
51
+ "properties": {
52
+ "query": {"type": "string"},
53
+ "goal": {"type": "string"},
54
+ "have_candidates": {"type": "boolean", "default": False},
55
+ "have_join_path": {"type": "boolean", "default": False},
56
+ "have_object": {"type": "string"},
57
+ },
58
+ },
59
+ ),
60
+ _suggest,
61
+ )
62
+ register_tool(
63
+ Tool(
64
+ name="bundle_context_for_next_step",
65
+ description=(
66
+ "Compress prior tool results into the minimum context the "
67
+ "next tool needs. goal=joining expects items [{kind:table, "
68
+ "schema, table}]; goal=object_impact expects [{kind:object, "
69
+ "schema, object_name, object_type}]."
70
+ ),
71
+ inputSchema={
72
+ "type": "object",
73
+ "properties": {
74
+ "items": {
75
+ "type": "array",
76
+ "items": {"type": "object"},
77
+ },
78
+ "goal": {"type": "string",
79
+ "enum": ["joining", "object_impact"],
80
+ "default": "joining"},
81
+ "detail": _DETAIL_PROP,
82
+ },
83
+ "required": ["items"],
84
+ },
85
+ ),
86
+ _bundle,
87
+ )
88
+ register_tool(
89
+ Tool(
90
+ name="score_join_candidate",
91
+ description=(
92
+ "Compute a usability score for a join path candidate. "
93
+ "Penalises excess hops and bridge/audit/lookup hops."
94
+ ),
95
+ inputSchema={
96
+ "type": "object",
97
+ "properties": {
98
+ "from_schema": {"type": "string"},
99
+ "from_table": {"type": "string"},
100
+ "to_schema": {"type": "string"},
101
+ "to_table": {"type": "string"},
102
+ "max_hops": {"type": "integer", "minimum": 1, "default": 5},
103
+ },
104
+ "required": ["from_schema", "from_table",
105
+ "to_schema", "to_table"],
106
+ },
107
+ ),
108
+ _score_join,
109
+ )
110
+ register_tool(
111
+ Tool(
112
+ name="summarize_table_for_joining",
113
+ description=(
114
+ "Return a compact join-ready summary for a single table — "
115
+ "classification, PK, important columns, FK edges."
116
+ ),
117
+ inputSchema={
118
+ "type": "object",
119
+ "properties": {
120
+ "schema": {"type": "string"},
121
+ "table": {"type": "string"},
122
+ },
123
+ "required": ["schema", "table"],
124
+ },
125
+ ),
126
+ _summarize_table,
127
+ )
128
+ register_tool(
129
+ Tool(
130
+ name="summarize_object_for_impact",
131
+ description=(
132
+ "Return a compact impact summary for a VIEW/PROCEDURE/FUNCTION "
133
+ "— reads, writes, depends_on."
134
+ ),
135
+ inputSchema={
136
+ "type": "object",
137
+ "properties": {
138
+ "schema": {"type": "string"},
139
+ "name": {"type": "string"},
140
+ "type": {"type": "string",
141
+ "enum": ["VIEW", "PROCEDURE", "FUNCTION"]},
142
+ },
143
+ "required": ["schema", "name", "type"],
144
+ },
145
+ ),
146
+ _summarize_object,
147
+ )
148
+
149
+
150
+ # ---- handlers ---------------------------------------------------------------
151
+
152
+
153
+ async def _discover(args: dict) -> dict:
154
+ ctx = get_context()
155
+ schemas = args.get("schemas") or None
156
+ return await ctx.workflow.discover_relevant_tables(
157
+ args["goal"],
158
+ schemas=schemas,
159
+ keyword=args.get("keyword"),
160
+ limit=int(args.get("limit", 10)),
161
+ classify=bool(args.get("classify", False)),
162
+ )
163
+
164
+
165
+ async def _suggest(args: dict) -> dict:
166
+ ctx = get_context()
167
+ return ctx.workflow.suggest_next_tool(
168
+ query=args.get("query"),
169
+ goal=args.get("goal"),
170
+ have_candidates=bool(args.get("have_candidates", False)),
171
+ have_join_path=bool(args.get("have_join_path", False)),
172
+ have_object=args.get("have_object"),
173
+ )
174
+
175
+
176
+ async def _bundle(args: dict) -> dict:
177
+ ctx = get_context()
178
+ return await ctx.workflow.bundle_context_for_next_step(
179
+ args["items"],
180
+ goal=args.get("goal", "joining"),
181
+ detail=args.get("detail", "brief"),
182
+ )
183
+
184
+
185
+ # ---- reasoning helpers ------------------------------------------------------
186
+
187
+
188
+ _CLASSIFICATION_PENALTY = {
189
+ "bridge": 0.25,
190
+ "audit": 0.2,
191
+ "lookup": 0.1,
192
+ }
193
+
194
+
195
+ async def _score_join(args: dict) -> dict:
196
+ ctx = get_context()
197
+ path = await relationship_service.find_join_path(
198
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
199
+ args["from_schema"], args["from_table"],
200
+ args["to_schema"], args["to_table"],
201
+ max_hops=args.get("max_hops", 5),
202
+ )
203
+
204
+ if path is None:
205
+ return {
206
+ "kind": "score_join_candidate",
207
+ "detail": "brief",
208
+ "found": False,
209
+ "next_action": "broaden_or_pick_different_start",
210
+ "recommended_tool": "discover_relevant_tables",
211
+ "data": {"score": 0.0, "path": [], "penalties": []},
212
+ }
213
+
214
+ hops = len(path)
215
+ score = 1.0 - (0.15 * max(hops - 1, 0))
216
+ penalties: list[dict] = []
217
+
218
+ for edge in path:
219
+ schema = edge.get("to_schema")
220
+ table = edge.get("to_table")
221
+ if not schema or not table:
222
+ continue
223
+ cls = await semantic_service.classify_table(
224
+ ctx.cfg.cache_path, ctx.cfg.mssql_database, schema, table,
225
+ )
226
+ penalty = _CLASSIFICATION_PENALTY.get(cls.get("type"), 0.0)
227
+ if penalty:
228
+ score -= penalty
229
+ penalties.append({
230
+ "at": f"{schema}.{table}",
231
+ "classification": cls.get("type"),
232
+ "penalty": penalty,
233
+ })
234
+
235
+ score = max(0.0, min(1.0, score))
236
+
237
+ return {
238
+ "kind": "score_join_candidate",
239
+ "detail": "brief",
240
+ "found": True,
241
+ "confidence": score,
242
+ "next_action": "execute" if score >= 0.5 else "consider_alternatives",
243
+ "recommended_tool": (
244
+ "plan_or_execute_query" if score >= 0.5 else "find_join_path"
245
+ ),
246
+ "data": {
247
+ "score": round(score, 3),
248
+ "hops": hops,
249
+ "path": path,
250
+ "penalties": penalties,
251
+ },
252
+ }
253
+
254
+
255
+ async def _summarize_table(args: dict) -> Any:
256
+ ctx = get_context()
257
+ summary = await semantic_service.summarize_for_joining(
258
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
259
+ args["schema"], args["table"],
260
+ )
261
+ if summary is None:
262
+ return {
263
+ "kind": "summarize_table_for_joining",
264
+ "detail": "brief",
265
+ "next_action": "broaden_search",
266
+ "recommended_tool": "get_tables",
267
+ "data": {"error": "table not found"},
268
+ }
269
+ return {
270
+ "kind": "summarize_table_for_joining",
271
+ "detail": "brief",
272
+ "next_action": "find_or_score_join",
273
+ "recommended_tool": "find_join_path",
274
+ "data": summary,
275
+ }
276
+
277
+
278
+ async def _summarize_object(args: dict) -> dict:
279
+ ctx = get_context()
280
+ obj = await object_service.describe_object(
281
+ args["schema"], args["name"], args["type"], ctx.cfg,
282
+ )
283
+ if not obj or obj.get("status") == "error":
284
+ return {
285
+ "kind": "summarize_object_for_impact",
286
+ "detail": "brief",
287
+ "next_action": "revise",
288
+ "recommended_tool": "describe_view",
289
+ "data": {
290
+ "object": f"{args['schema']}.{args['name']}",
291
+ "type": args["type"],
292
+ "error": obj.get("error_message") if obj else "not found",
293
+ },
294
+ }
295
+ return {
296
+ "kind": "summarize_object_for_impact",
297
+ "detail": "brief",
298
+ "next_action": "trace_impact",
299
+ "recommended_tool": "trace_object_dependencies",
300
+ "data": {
301
+ "object": f"{args['schema']}.{args['name']}",
302
+ "type": args["type"],
303
+ "reads": list(obj.get("read_tables", []) or []),
304
+ "writes": list(obj.get("write_tables", []) or []),
305
+ "depends_on": list(obj.get("dependencies", []) or []),
306
+ },
307
+ }
File without changes
@@ -0,0 +1,173 @@
1
+ from typing import Optional
2
+ import aiosqlite
3
+
4
+
5
+ async def _list_columns_from_db(
6
+ db: aiosqlite.Connection,
7
+ database: str,
8
+ schema: str,
9
+ table: str,
10
+ ) -> list[dict]:
11
+ cur = await db.execute(
12
+ "SELECT column_name, data_type, max_length, is_nullable, "
13
+ "column_default, ordinal_position "
14
+ "FROM sc_columns "
15
+ "WHERE database_name=? AND schema_name=? AND table_name=? "
16
+ "ORDER BY ordinal_position",
17
+ (database, schema, table),
18
+ )
19
+ cols = [dict(r) for r in await cur.fetchall()]
20
+
21
+ cur = await db.execute(
22
+ "SELECT column_name, description FROM sc_comments "
23
+ "WHERE database_name=? AND schema_name=? AND object_name=? "
24
+ "AND column_name<>''",
25
+ (database, schema, table),
26
+ )
27
+ comments = {r["column_name"]: r["description"]
28
+ for r in await cur.fetchall()}
29
+
30
+ for c in cols:
31
+ c["is_nullable"] = bool(c["is_nullable"])
32
+ c["default_value"] = c.pop("column_default", None)
33
+ c["description"] = comments.get(c["column_name"])
34
+ return cols
35
+
36
+
37
+ async def list_tables(
38
+ db_path: str, database: str, *,
39
+ schemas: Optional[list[str]] = None,
40
+ keyword: Optional[str] = None,
41
+ ) -> list[dict]:
42
+ sql = ("SELECT schema_name, table_name FROM sc_tables "
43
+ "WHERE database_name = ?")
44
+ params: list = [database]
45
+
46
+ if schemas:
47
+ placeholders = ",".join("?" for _ in schemas)
48
+ sql += f" AND schema_name IN ({placeholders})"
49
+ params.extend(schemas)
50
+
51
+ if keyword:
52
+ sql += (" AND (LOWER(schema_name || '.' || table_name) "
53
+ "LIKE ? ESCAPE '\\')")
54
+ kw = keyword.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
55
+ params.append(f"%{kw}%")
56
+
57
+ sql += " ORDER BY schema_name, table_name"
58
+
59
+ async with aiosqlite.connect(db_path) as db:
60
+ db.row_factory = aiosqlite.Row
61
+ cur = await db.execute(sql, tuple(params))
62
+ return [dict(r) for r in await cur.fetchall()]
63
+
64
+
65
+ async def list_columns(
66
+ db_path: str, database: str, schema: str, table: str,
67
+ ) -> list[dict]:
68
+ async with aiosqlite.connect(db_path) as db:
69
+ db.row_factory = aiosqlite.Row
70
+ return await _list_columns_from_db(db, database, schema, table)
71
+
72
+
73
+ async def describe_table(
74
+ db_path: str, database: str, schema: str, table: str,
75
+ ) -> Optional[dict]:
76
+ async with aiosqlite.connect(db_path) as db:
77
+ db.row_factory = aiosqlite.Row
78
+ cur = await db.execute(
79
+ "SELECT 1 FROM sc_tables WHERE database_name=? "
80
+ "AND schema_name=? AND table_name=?",
81
+ (database, schema, table),
82
+ )
83
+ if not await cur.fetchone():
84
+ return None
85
+
86
+ columns = await _list_columns_from_db(db, database, schema, table)
87
+
88
+ cur = await db.execute(
89
+ "SELECT column_name FROM sc_primary_keys "
90
+ "WHERE database_name=? AND schema_name=? AND table_name=?",
91
+ (database, schema, table),
92
+ )
93
+ pk = [r["column_name"] for r in await cur.fetchall()]
94
+
95
+ cur = await db.execute(
96
+ "SELECT column_name, ref_schema, ref_table, ref_column "
97
+ "FROM sc_foreign_keys "
98
+ "WHERE database_name=? AND schema_name=? AND table_name=?",
99
+ (database, schema, table),
100
+ )
101
+ fks = [dict(r) for r in await cur.fetchall()]
102
+
103
+ cur = await db.execute(
104
+ "SELECT index_name, is_unique, is_primary_key, columns "
105
+ "FROM sc_indexes "
106
+ "WHERE database_name=? AND schema_name=? AND table_name=?",
107
+ (database, schema, table),
108
+ )
109
+ indexes = []
110
+ for r in await cur.fetchall():
111
+ indexes.append({
112
+ "index_name": r["index_name"],
113
+ "is_unique": bool(r["is_unique"]),
114
+ "is_primary_key": bool(r["is_primary_key"]),
115
+ "columns": r["columns"].split(",") if r["columns"] else [],
116
+ })
117
+
118
+ cur = await db.execute(
119
+ "SELECT description FROM sc_comments "
120
+ "WHERE database_name=? AND schema_name=? AND object_name=? "
121
+ "AND column_name=''",
122
+ (database, schema, table),
123
+ )
124
+ row = await cur.fetchone()
125
+ description = row["description"] if row else None
126
+
127
+ return {
128
+ "schema_name": schema,
129
+ "table_name": table,
130
+ "columns": columns,
131
+ "primary_key": pk,
132
+ "foreign_keys": fks,
133
+ "indexes": indexes,
134
+ "description": description,
135
+ }
136
+
137
+
138
+ async def database_summary(db_path: str, database: str) -> dict:
139
+ async with aiosqlite.connect(db_path) as db:
140
+ db.row_factory = aiosqlite.Row
141
+ counts = {}
142
+ for tbl in ["sc_tables", "sc_columns", "sc_foreign_keys",
143
+ "sc_indexes", "sc_objects"]:
144
+ cur = await db.execute(
145
+ f"SELECT COUNT(*) AS n FROM {tbl} WHERE database_name=?",
146
+ (database,),
147
+ )
148
+ counts[tbl] = (await cur.fetchone())["n"]
149
+
150
+ cur = await db.execute(
151
+ "SELECT object_type, COUNT(*) AS n FROM sc_objects "
152
+ "WHERE database_name=? GROUP BY object_type",
153
+ (database,),
154
+ )
155
+ object_counts = {r["object_type"]: r["n"]
156
+ for r in await cur.fetchall()}
157
+
158
+ cur = await db.execute(
159
+ "SELECT * FROM schema_version WHERE database_name=?",
160
+ (database,),
161
+ )
162
+ ver = await cur.fetchone()
163
+
164
+ return {
165
+ "database_name": database,
166
+ "table_count": counts["sc_tables"],
167
+ "column_count": counts["sc_columns"],
168
+ "foreign_key_count": counts["sc_foreign_keys"],
169
+ "index_count": counts["sc_indexes"],
170
+ "object_count": counts["sc_objects"],
171
+ "objects_by_type": object_counts,
172
+ "schema_version": dict(ver) if ver else None,
173
+ }
@@ -0,0 +1,124 @@
1
+ """Per-tool response metrics recording and query.
2
+
3
+ See docs/superpowers/specs/2026-04-19-p4-measurement-design.md for the
4
+ original design. v0.5 extends the record with workflow-aware fields so
5
+ the team can measure how many tasks complete via the direct-execute
6
+ fast path and which tools still dominate the token budget.
7
+ """
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Optional
10
+
11
+ import aiosqlite
12
+
13
+
14
+ _EXTRA_FIELDS = (
15
+ "route_type",
16
+ "detail",
17
+ "response_mode",
18
+ "token_budget_hint",
19
+ "was_direct_execute",
20
+ "bundle_used",
21
+ "next_action",
22
+ )
23
+
24
+
25
+ async def _ensure_extra_columns(db: aiosqlite.Connection) -> None:
26
+ """Best-effort migration for in-place stores created before v0.5."""
27
+ cur = await db.execute("PRAGMA table_info(tool_metrics)")
28
+ existing = {row[1] for row in await cur.fetchall()}
29
+ for col in _EXTRA_FIELDS:
30
+ if col not in existing:
31
+ coltype = "INTEGER" if col in ("was_direct_execute", "bundle_used") else "TEXT"
32
+ await db.execute(
33
+ f"ALTER TABLE tool_metrics ADD COLUMN {col} {coltype}"
34
+ )
35
+ await db.commit()
36
+
37
+
38
+ async def record_metric(
39
+ db_path: str, tool_name: str, *,
40
+ response_bytes: int,
41
+ array_length: Optional[int] = None,
42
+ fields_returned: Optional[int] = None,
43
+ route_type: Optional[str] = None,
44
+ detail: Optional[str] = None,
45
+ response_mode: Optional[str] = None,
46
+ token_budget_hint: Optional[str] = None,
47
+ was_direct_execute: Optional[bool] = None,
48
+ bundle_used: Optional[bool] = None,
49
+ next_action: Optional[str] = None,
50
+ ) -> None:
51
+ async with aiosqlite.connect(db_path) as db:
52
+ await _ensure_extra_columns(db)
53
+ await db.execute(
54
+ "INSERT INTO tool_metrics "
55
+ "(tool_name, response_bytes, array_length, fields_returned, "
56
+ " route_type, detail, response_mode, token_budget_hint, "
57
+ " was_direct_execute, bundle_used, next_action, recorded_at) "
58
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
59
+ (
60
+ tool_name, response_bytes, array_length, fields_returned,
61
+ route_type, detail, response_mode, token_budget_hint,
62
+ 1 if was_direct_execute else (0 if was_direct_execute is False else None),
63
+ 1 if bundle_used else (0 if bundle_used is False else None),
64
+ next_action,
65
+ datetime.now(timezone.utc).isoformat(),
66
+ ),
67
+ )
68
+ await db.commit()
69
+
70
+
71
+ def _p95(values: list[int]) -> int:
72
+ if not values:
73
+ return 0
74
+ vs = sorted(values)
75
+ idx = max(0, int(0.95 * len(vs)) - 1)
76
+ return vs[idx]
77
+
78
+
79
+ async def query_top_tools(db_path: str, *, limit: int = 10) -> list[dict]:
80
+ """Return tool metrics aggregated, ordered by total_bytes desc."""
81
+ async with aiosqlite.connect(db_path) as db:
82
+ db.row_factory = aiosqlite.Row
83
+ await _ensure_extra_columns(db)
84
+ cur = await db.execute(
85
+ "SELECT tool_name, COUNT(*) AS call_count, "
86
+ " SUM(response_bytes) AS total_bytes, "
87
+ " AVG(response_bytes) AS avg_bytes, "
88
+ " MAX(response_bytes) AS max_bytes, "
89
+ " SUM(CASE WHEN was_direct_execute=1 THEN 1 ELSE 0 END) "
90
+ " AS direct_execute_count, "
91
+ " SUM(CASE WHEN bundle_used=1 THEN 1 ELSE 0 END) "
92
+ " AS bundle_count "
93
+ "FROM tool_metrics "
94
+ "GROUP BY tool_name "
95
+ "ORDER BY total_bytes DESC "
96
+ "LIMIT ?",
97
+ (limit,),
98
+ )
99
+ aggregated = [dict(r) for r in await cur.fetchall()]
100
+
101
+ for row in aggregated:
102
+ cur = await db.execute(
103
+ "SELECT response_bytes FROM tool_metrics "
104
+ "WHERE tool_name=? ORDER BY response_bytes",
105
+ (row["tool_name"],),
106
+ )
107
+ values = [r[0] for r in await cur.fetchall()]
108
+ row["p95_bytes"] = _p95(values)
109
+ row["avg_bytes"] = int(row["avg_bytes"] or 0)
110
+ return aggregated
111
+
112
+
113
+ async def clear_metrics(db_path: str) -> int:
114
+ async with aiosqlite.connect(db_path) as db:
115
+ cur = await db.execute("DELETE FROM tool_metrics")
116
+ await db.commit()
117
+ return cur.rowcount
118
+
119
+
120
+ __all__: list[str] = [
121
+ "record_metric",
122
+ "query_top_tools",
123
+ "clear_metrics",
124
+ ]