devscontext 0.1.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.
devscontext/server.py ADDED
@@ -0,0 +1,374 @@
1
+ """MCP server for DevsContext.
2
+
3
+ This module provides the Model Context Protocol (MCP) server implementation
4
+ that exposes DevsContext functionality as MCP tools.
5
+
6
+ The server runs over stdio transport and provides the following tools:
7
+ - get_task_context: Get synthesized context for a task
8
+ - search_context: Search across all configured sources
9
+ - get_standards: Get coding standards documentation
10
+
11
+ Example:
12
+ Run as MCP server:
13
+ devscontext serve
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import time
20
+ from typing import Any
21
+
22
+ from mcp.server import Server
23
+ from mcp.server.stdio import stdio_server
24
+ from mcp.types import TextContent, Tool
25
+
26
+ from devscontext.config import load_devscontext_config
27
+ from devscontext.core import DevsContextCore
28
+ from devscontext.logging import get_logger
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ # Initialize the MCP server
33
+ server = Server("devscontext")
34
+
35
+ # Global core instance (initialized on startup)
36
+ _core: DevsContextCore | None = None
37
+
38
+
39
+ def get_core() -> DevsContextCore:
40
+ """Get or create the global DevsContextCore instance.
41
+
42
+ Lazily initializes the core on first access using
43
+ the configuration loaded from the config file.
44
+
45
+ Returns:
46
+ The singleton DevsContextCore instance.
47
+ """
48
+ global _core
49
+ if _core is None:
50
+ config = load_devscontext_config()
51
+ _core = DevsContextCore(config)
52
+ logger.info("DevsContextCore initialized")
53
+ return _core
54
+
55
+
56
+ @server.list_tools() # type: ignore[no-untyped-call, untyped-decorator]
57
+ async def list_tools() -> list[Tool]:
58
+ """List available MCP tools.
59
+
60
+ Returns:
61
+ List of Tool definitions for get_task_context, search_context,
62
+ and get_standards.
63
+ """
64
+ return [
65
+ Tool(
66
+ name="get_task_context",
67
+ description=(
68
+ "Use this when starting work on a Jira ticket. "
69
+ "Fetches and synthesizes everything you need: ticket requirements, "
70
+ "acceptance criteria, discussion comments, related meeting transcripts, "
71
+ "architecture docs, ADRs, and applicable coding standards. "
72
+ "Call this FIRST when the user says 'work on PROJ-123' or 'start TICKET-456'."
73
+ ),
74
+ inputSchema={
75
+ "type": "object",
76
+ "properties": {
77
+ "task_id": {
78
+ "type": "string",
79
+ "description": "Jira ticket ID (e.g., 'PROJ-123', 'TICKET-456')",
80
+ },
81
+ "refresh": {
82
+ "type": "boolean",
83
+ "description": "Force refresh, bypassing cache",
84
+ "default": False,
85
+ },
86
+ },
87
+ "required": ["task_id"],
88
+ },
89
+ ),
90
+ Tool(
91
+ name="search_context",
92
+ description=(
93
+ "Use this for freeform questions about the codebase, architecture, "
94
+ "or past decisions. Searches across Jira tickets, meeting transcripts, "
95
+ "and documentation. Use when the user asks questions like "
96
+ "'how do we handle errors?', 'what was decided about webhooks?', "
97
+ "or 'why did we choose SQS?'. Input is a natural language question."
98
+ ),
99
+ inputSchema={
100
+ "type": "object",
101
+ "properties": {
102
+ "query": {
103
+ "type": "string",
104
+ "description": "Natural language question or search terms",
105
+ },
106
+ },
107
+ "required": ["query"],
108
+ },
109
+ ),
110
+ Tool(
111
+ name="get_standards",
112
+ description=(
113
+ "Use this when checking coding conventions before or during implementation. "
114
+ "Returns coding standards, style guides, and best practices from local docs. "
115
+ "Filter by area: 'testing', 'error-handling', 'typescript', 'api', etc. "
116
+ "Use when the user asks 'what are our testing conventions?' or "
117
+ "'how should I handle errors?' or before writing significant code."
118
+ ),
119
+ inputSchema={
120
+ "type": "object",
121
+ "properties": {
122
+ "area": {
123
+ "type": "string",
124
+ "description": (
125
+ "Filter by area: 'testing', 'typescript', 'error-handling', etc. "
126
+ "Omit to get all standards."
127
+ ),
128
+ },
129
+ },
130
+ },
131
+ ),
132
+ ]
133
+
134
+
135
+ @server.call_tool() # type: ignore[untyped-decorator]
136
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
137
+ """Handle MCP tool calls.
138
+
139
+ Routes tool calls to the appropriate core method and formats
140
+ the response for the MCP client.
141
+
142
+ Args:
143
+ name: The name of the tool to call.
144
+ arguments: The tool arguments as a dictionary.
145
+
146
+ Returns:
147
+ List containing a single TextContent with the tool result.
148
+ """
149
+ start_time = time.monotonic()
150
+ core = get_core()
151
+
152
+ logger.info(
153
+ "Tool call received",
154
+ extra={"tool": name, "arguments": arguments},
155
+ )
156
+
157
+ try:
158
+ if name == "get_task_context":
159
+ return await _handle_get_task_context(core, arguments, start_time)
160
+
161
+ elif name == "search_context":
162
+ return await _handle_search_context(core, arguments, start_time)
163
+
164
+ elif name == "get_standards":
165
+ return await _handle_get_standards(core, arguments, start_time)
166
+
167
+ else:
168
+ logger.warning("Unknown tool called", extra={"tool": name})
169
+ return [TextContent(type="text", text=f"Error: Unknown tool '{name}'")]
170
+
171
+ except Exception as e:
172
+ duration_ms = int((time.monotonic() - start_time) * 1000)
173
+ logger.exception(
174
+ "Tool call failed",
175
+ extra={"tool": name, "error": str(e), "duration_ms": duration_ms},
176
+ )
177
+ return [
178
+ TextContent(
179
+ type="text",
180
+ text=f"Error: An unexpected error occurred while processing '{name}': {e}",
181
+ )
182
+ ]
183
+
184
+
185
+ async def _handle_get_task_context(
186
+ core: DevsContextCore,
187
+ arguments: dict[str, Any],
188
+ start_time: float,
189
+ ) -> list[TextContent]:
190
+ """Handle the get_task_context tool call.
191
+
192
+ Args:
193
+ core: The DevsContextCore instance.
194
+ arguments: Tool arguments.
195
+ start_time: When the request started for duration logging.
196
+
197
+ Returns:
198
+ List containing TextContent with the synthesized context.
199
+ """
200
+ task_id = arguments.get("task_id", "")
201
+ refresh = arguments.get("refresh", False)
202
+
203
+ if not task_id:
204
+ logger.warning("get_task_context called without task_id")
205
+ return [TextContent(type="text", text="Error: task_id is required")]
206
+
207
+ try:
208
+ result = await core.get_task_context(
209
+ task_id=task_id,
210
+ use_cache=not refresh,
211
+ )
212
+
213
+ duration_ms = int((time.monotonic() - start_time) * 1000)
214
+ logger.info(
215
+ "get_task_context completed",
216
+ extra={
217
+ "task_id": task_id,
218
+ "source_count": len(result.sources_used),
219
+ "cached": result.cached,
220
+ "duration_ms": duration_ms,
221
+ },
222
+ )
223
+
224
+ # Return the synthesized markdown directly
225
+ return [TextContent(type="text", text=result.synthesized)]
226
+
227
+ except Exception as e:
228
+ duration_ms = int((time.monotonic() - start_time) * 1000)
229
+ logger.exception(
230
+ "get_task_context failed",
231
+ extra={"task_id": task_id, "error": str(e), "duration_ms": duration_ms},
232
+ )
233
+ return [
234
+ TextContent(
235
+ type="text",
236
+ text=f"Error fetching context for {task_id}: {e}\n\n"
237
+ f"Please check that your Jira and Fireflies credentials are configured correctly.",
238
+ )
239
+ ]
240
+
241
+
242
+ async def _handle_search_context(
243
+ core: DevsContextCore,
244
+ arguments: dict[str, Any],
245
+ start_time: float,
246
+ ) -> list[TextContent]:
247
+ """Handle the search_context tool call.
248
+
249
+ Args:
250
+ core: The DevsContextCore instance.
251
+ arguments: Tool arguments.
252
+ start_time: When the request started for duration logging.
253
+
254
+ Returns:
255
+ List containing TextContent with search results.
256
+ """
257
+ query = arguments.get("query", "")
258
+
259
+ if not query:
260
+ logger.warning("search_context called without query")
261
+ return [TextContent(type="text", text="Error: query is required")]
262
+
263
+ try:
264
+ result = await core.search_context(query=query)
265
+
266
+ duration_ms = int((time.monotonic() - start_time) * 1000)
267
+ logger.info(
268
+ "search_context completed",
269
+ extra={
270
+ "query": query,
271
+ "result_count": result["result_count"],
272
+ "duration_ms": duration_ms,
273
+ },
274
+ )
275
+
276
+ sources = result.get("sources", [])
277
+ sources_str = ", ".join(sources) if isinstance(sources, list) else str(sources)
278
+
279
+ response_text = f"""# Search Results for "{query}"
280
+
281
+ **Sources searched:** {sources_str}
282
+ **Results found:** {result["result_count"]}
283
+
284
+ ---
285
+
286
+ {result["results"]}
287
+ """
288
+ return [TextContent(type="text", text=response_text)]
289
+
290
+ except Exception as e:
291
+ duration_ms = int((time.monotonic() - start_time) * 1000)
292
+ logger.exception(
293
+ "search_context failed",
294
+ extra={"query": query, "error": str(e), "duration_ms": duration_ms},
295
+ )
296
+ return [
297
+ TextContent(
298
+ type="text",
299
+ text=f"Error searching for '{query}': {e}",
300
+ )
301
+ ]
302
+
303
+
304
+ async def _handle_get_standards(
305
+ core: DevsContextCore,
306
+ arguments: dict[str, Any],
307
+ start_time: float,
308
+ ) -> list[TextContent]:
309
+ """Handle the get_standards tool call.
310
+
311
+ Args:
312
+ core: The DevsContextCore instance.
313
+ arguments: Tool arguments.
314
+ start_time: When the request started for duration logging.
315
+
316
+ Returns:
317
+ List containing TextContent with standards content.
318
+ """
319
+ area = arguments.get("area")
320
+
321
+ try:
322
+ result = await core.get_standards(area=area)
323
+
324
+ duration_ms = int((time.monotonic() - start_time) * 1000)
325
+ logger.info(
326
+ "get_standards completed",
327
+ extra={"area": area, "duration_ms": duration_ms},
328
+ )
329
+
330
+ area_text = f" ({area})" if area else ""
331
+ response_text = f"""# Coding Standards{area_text}
332
+
333
+ {result["content"]}
334
+ """
335
+ return [TextContent(type="text", text=response_text)]
336
+
337
+ except Exception as e:
338
+ duration_ms = int((time.monotonic() - start_time) * 1000)
339
+ logger.exception(
340
+ "get_standards failed",
341
+ extra={"area": area, "error": str(e), "duration_ms": duration_ms},
342
+ )
343
+ return [
344
+ TextContent(
345
+ type="text",
346
+ text=f"Error fetching standards: {e}",
347
+ )
348
+ ]
349
+
350
+
351
+ async def run_server() -> None:
352
+ """Run the MCP server over stdio transport.
353
+
354
+ Sets up the stdio transport and runs the server until interrupted.
355
+ """
356
+ logger.info("Starting MCP server")
357
+ async with stdio_server() as (read_stream, write_stream):
358
+ await server.run(
359
+ read_stream,
360
+ write_stream,
361
+ server.create_initialization_options(),
362
+ )
363
+
364
+
365
+ def main() -> None:
366
+ """Entry point for the MCP server.
367
+
368
+ Configures logging and runs the async server.
369
+ """
370
+ asyncio.run(run_server())
371
+
372
+
373
+ if __name__ == "__main__":
374
+ main()
devscontext/storage.py ADDED
@@ -0,0 +1,321 @@
1
+ """SQLite storage for pre-built context.
2
+
3
+ This module provides persistent storage for pre-built context that has been
4
+ processed by the background agent. The MCP server can then retrieve this
5
+ context instantly instead of fetching on-demand.
6
+
7
+ Uses aiosqlite for async SQLite access.
8
+
9
+ Example:
10
+ storage = PrebuiltContextStorage(".devscontext/cache.db")
11
+ await storage.initialize()
12
+
13
+ # Store pre-built context
14
+ await storage.store(context)
15
+
16
+ # Retrieve context
17
+ context = await storage.get("PROJ-123")
18
+ if context and not context.is_expired():
19
+ # Use the pre-built context
20
+ ...
21
+
22
+ await storage.close()
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import aiosqlite
33
+
34
+ from devscontext.logging import get_logger
35
+ from devscontext.models import PrebuiltContext
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ class PrebuiltContextStorage:
41
+ """SQLite storage for pre-built context.
42
+
43
+ Provides async storage and retrieval of pre-built context that was
44
+ created by the background preprocessing agent.
45
+
46
+ The storage uses a single SQLite table with the following schema:
47
+ - task_id: TEXT PRIMARY KEY
48
+ - synthesized: TEXT (markdown content)
49
+ - sources_used: TEXT (JSON array)
50
+ - context_quality_score: REAL (0-1)
51
+ - gaps: TEXT (JSON array)
52
+ - built_at: TEXT (ISO timestamp)
53
+ - expires_at: TEXT (ISO timestamp)
54
+ - source_data_hash: TEXT (for staleness detection)
55
+ """
56
+
57
+ def __init__(self, db_path: str = ".devscontext/cache.db") -> None:
58
+ """Initialize storage with database path.
59
+
60
+ Args:
61
+ db_path: Path to SQLite database file.
62
+ """
63
+ self._db_path = Path(db_path)
64
+ self._conn: aiosqlite.Connection | None = None
65
+
66
+ async def initialize(self) -> None:
67
+ """Create database and table if needed.
68
+
69
+ Creates the parent directory if it doesn't exist.
70
+ """
71
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
72
+ self._conn = await aiosqlite.connect(self._db_path)
73
+
74
+ await self._conn.execute("""
75
+ CREATE TABLE IF NOT EXISTS prebuilt_context (
76
+ task_id TEXT PRIMARY KEY,
77
+ synthesized TEXT NOT NULL,
78
+ sources_used TEXT NOT NULL,
79
+ context_quality_score REAL NOT NULL,
80
+ gaps TEXT NOT NULL,
81
+ built_at TEXT NOT NULL,
82
+ expires_at TEXT NOT NULL,
83
+ source_data_hash TEXT NOT NULL
84
+ )
85
+ """)
86
+ await self._conn.commit()
87
+
88
+ logger.info(
89
+ "Storage initialized",
90
+ extra={"db_path": str(self._db_path)},
91
+ )
92
+
93
+ async def store(self, context: PrebuiltContext) -> None:
94
+ """Store pre-built context, replacing if exists.
95
+
96
+ Args:
97
+ context: PrebuiltContext to store.
98
+ """
99
+ if self._conn is None:
100
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
101
+
102
+ await self._conn.execute(
103
+ """
104
+ INSERT OR REPLACE INTO prebuilt_context
105
+ (task_id, synthesized, sources_used, context_quality_score,
106
+ gaps, built_at, expires_at, source_data_hash)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
108
+ """,
109
+ (
110
+ context.task_id,
111
+ context.synthesized,
112
+ json.dumps(context.sources_used),
113
+ context.context_quality_score,
114
+ json.dumps(context.gaps),
115
+ context.built_at.isoformat(),
116
+ context.expires_at.isoformat(),
117
+ context.source_data_hash,
118
+ ),
119
+ )
120
+ await self._conn.commit()
121
+
122
+ logger.info(
123
+ "Stored pre-built context",
124
+ extra={
125
+ "task_id": context.task_id,
126
+ "quality_score": context.context_quality_score,
127
+ "gaps_count": len(context.gaps),
128
+ },
129
+ )
130
+
131
+ async def get(self, task_id: str) -> PrebuiltContext | None:
132
+ """Get pre-built context if exists.
133
+
134
+ Note: This returns the context even if expired. Use is_expired()
135
+ to check if it should be refreshed.
136
+
137
+ Args:
138
+ task_id: Task identifier to retrieve.
139
+
140
+ Returns:
141
+ PrebuiltContext if found, None otherwise.
142
+ """
143
+ if self._conn is None:
144
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
145
+
146
+ cursor = await self._conn.execute(
147
+ """
148
+ SELECT task_id, synthesized, sources_used, context_quality_score,
149
+ gaps, built_at, expires_at, source_data_hash
150
+ FROM prebuilt_context
151
+ WHERE task_id = ?
152
+ """,
153
+ (task_id,),
154
+ )
155
+ row = await cursor.fetchone()
156
+
157
+ if row is None:
158
+ return None
159
+
160
+ return PrebuiltContext(
161
+ task_id=row[0],
162
+ synthesized=row[1],
163
+ sources_used=json.loads(row[2]),
164
+ context_quality_score=row[3],
165
+ gaps=json.loads(row[4]),
166
+ built_at=datetime.fromisoformat(row[5]),
167
+ expires_at=datetime.fromisoformat(row[6]),
168
+ source_data_hash=row[7],
169
+ )
170
+
171
+ async def is_stale(self, task_id: str, current_hash: str) -> bool:
172
+ """Check if stored context is stale.
173
+
174
+ Context is stale if the source data has changed (Jira ticket updated).
175
+
176
+ Args:
177
+ task_id: Task identifier.
178
+ current_hash: Current hash of source data (ticket.updated).
179
+
180
+ Returns:
181
+ True if stale or not found, False if fresh.
182
+ """
183
+ if self._conn is None:
184
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
185
+
186
+ cursor = await self._conn.execute(
187
+ "SELECT source_data_hash FROM prebuilt_context WHERE task_id = ?",
188
+ (task_id,),
189
+ )
190
+ row = await cursor.fetchone()
191
+
192
+ if row is None:
193
+ return True # Not found = stale
194
+
195
+ return bool(row[0] != current_hash)
196
+
197
+ async def delete(self, task_id: str) -> bool:
198
+ """Delete pre-built context for a task.
199
+
200
+ Args:
201
+ task_id: Task identifier to delete.
202
+
203
+ Returns:
204
+ True if deleted, False if not found.
205
+ """
206
+ if self._conn is None:
207
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
208
+
209
+ cursor = await self._conn.execute(
210
+ "DELETE FROM prebuilt_context WHERE task_id = ?",
211
+ (task_id,),
212
+ )
213
+ await self._conn.commit()
214
+
215
+ deleted = cursor.rowcount > 0
216
+ if deleted:
217
+ logger.info("Deleted pre-built context", extra={"task_id": task_id})
218
+
219
+ return deleted
220
+
221
+ async def delete_expired(self) -> int:
222
+ """Delete all expired entries.
223
+
224
+ Returns:
225
+ Number of entries deleted.
226
+ """
227
+ if self._conn is None:
228
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
229
+
230
+ now = datetime.now(UTC).isoformat()
231
+ cursor = await self._conn.execute(
232
+ "DELETE FROM prebuilt_context WHERE expires_at < ?",
233
+ (now,),
234
+ )
235
+ await self._conn.commit()
236
+
237
+ count = cursor.rowcount
238
+ if count > 0:
239
+ logger.info("Deleted expired contexts", extra={"count": count})
240
+
241
+ return count
242
+
243
+ async def list_all(self) -> list[dict[str, Any]]:
244
+ """List all stored contexts (summary only).
245
+
246
+ Returns:
247
+ List of dicts with task_id, quality_score, built_at, expires_at.
248
+ """
249
+ if self._conn is None:
250
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
251
+
252
+ cursor = await self._conn.execute(
253
+ """
254
+ SELECT task_id, context_quality_score, built_at, expires_at, gaps
255
+ FROM prebuilt_context
256
+ ORDER BY built_at DESC
257
+ """
258
+ )
259
+ rows = await cursor.fetchall()
260
+
261
+ return [
262
+ {
263
+ "task_id": row[0],
264
+ "quality_score": row[1],
265
+ "built_at": row[2],
266
+ "expires_at": row[3],
267
+ "gaps_count": len(json.loads(row[4])),
268
+ }
269
+ for row in rows
270
+ ]
271
+
272
+ async def get_stats(self) -> dict[str, Any]:
273
+ """Get storage statistics for CLI status command.
274
+
275
+ Returns:
276
+ Dict with total, active, expired counts and average quality.
277
+ """
278
+ if self._conn is None:
279
+ raise RuntimeError("Storage not initialized. Call initialize() first.")
280
+
281
+ now = datetime.now(UTC).isoformat()
282
+
283
+ # Total count
284
+ cursor = await self._conn.execute("SELECT COUNT(*) FROM prebuilt_context")
285
+ total_row = await cursor.fetchone()
286
+ total: int = total_row[0] if total_row else 0
287
+
288
+ # Active (not expired) count
289
+ cursor = await self._conn.execute(
290
+ "SELECT COUNT(*) FROM prebuilt_context WHERE expires_at >= ?",
291
+ (now,),
292
+ )
293
+ active_row = await cursor.fetchone()
294
+ active: int = active_row[0] if active_row else 0
295
+
296
+ # Average quality score
297
+ cursor = await self._conn.execute("SELECT AVG(context_quality_score) FROM prebuilt_context")
298
+ avg_quality_row = await cursor.fetchone()
299
+ avg_quality: float = (
300
+ avg_quality_row[0] if avg_quality_row and avg_quality_row[0] is not None else 0.0
301
+ )
302
+
303
+ # Last build time
304
+ cursor = await self._conn.execute("SELECT MAX(built_at) FROM prebuilt_context")
305
+ last_build_row = await cursor.fetchone()
306
+ last_build: str | None = last_build_row[0] if last_build_row and last_build_row[0] else None
307
+
308
+ return {
309
+ "total": total,
310
+ "active": active,
311
+ "expired": total - active,
312
+ "avg_quality": avg_quality,
313
+ "last_build": last_build,
314
+ }
315
+
316
+ async def close(self) -> None:
317
+ """Close database connection."""
318
+ if self._conn is not None:
319
+ await self._conn.close()
320
+ self._conn = None
321
+ logger.debug("Storage connection closed")