mcp-project-context-server 0.0.7__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 (26) hide show
  1. mcp_project_context_server/__init__.py +6 -0
  2. mcp_project_context_server/__main__.py +3 -0
  3. mcp_project_context_server/helpers/__init__.py +0 -0
  4. mcp_project_context_server/helpers/context.py +32 -0
  5. mcp_project_context_server/indexing/__init__.py +0 -0
  6. mcp_project_context_server/indexing/chroma/__init__.py +0 -0
  7. mcp_project_context_server/indexing/chroma/indexer.py +72 -0
  8. mcp_project_context_server/indexing/ollama/__init__.py +0 -0
  9. mcp_project_context_server/indexing/ollama/embedder.py +18 -0
  10. mcp_project_context_server/integrations/__init__.py +0 -0
  11. mcp_project_context_server/integrations/chroma/__init__.py +0 -0
  12. mcp_project_context_server/integrations/chroma/client.py +18 -0
  13. mcp_project_context_server/integrations/ollama/__init__.py +0 -0
  14. mcp_project_context_server/integrations/ollama/client.py +28 -0
  15. mcp_project_context_server/server.py +119 -0
  16. mcp_project_context_server/tools/__init__.py +0 -0
  17. mcp_project_context_server/tools/index_context.py +13 -0
  18. mcp_project_context_server/tools/load_context.py +43 -0
  19. mcp_project_context_server/tools/save_session.py +43 -0
  20. mcp_project_context_server/tools/search_context.py +52 -0
  21. mcp_project_context_server-0.0.7.dist-info/METADATA +1318 -0
  22. mcp_project_context_server-0.0.7.dist-info/RECORD +26 -0
  23. mcp_project_context_server-0.0.7.dist-info/WHEEL +5 -0
  24. mcp_project_context_server-0.0.7.dist-info/entry_points.txt +2 -0
  25. mcp_project_context_server-0.0.7.dist-info/licenses/LICENSE +661 -0
  26. mcp_project_context_server-0.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """mcp-project-context-server — MCP server for persistent project context."""
2
+
3
+ try:
4
+ from mcp_project_context_server._version import __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,3 @@
1
+ from mcp_project_context_server.server import run
2
+
3
+ run()
File without changes
@@ -0,0 +1,32 @@
1
+ """Shared helpers for .context/ directory resolution and file reading."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def find_context_dir(project_path: str | Path) -> Path | None:
7
+ """Walk up from project_path to find a .context/ directory."""
8
+ p = Path(project_path).resolve()
9
+ for candidate in [p, *p.parents]:
10
+ ctx = candidate / ".context"
11
+ if ctx.is_dir():
12
+ return ctx
13
+ return None
14
+
15
+
16
+ def collection_name_for(context_dir: Path) -> str:
17
+ """Derive a stable ChromaDB collection name from the project root.
18
+
19
+ Always based on context_dir.parent so it is consistent regardless of
20
+ whether the caller passed a project root, a subdirectory, or a file path.
21
+ """
22
+ project_name = context_dir.parent.name
23
+ return f"ctx_{project_name}".replace("-", "_").replace(" ", "_")[:63]
24
+
25
+
26
+ def read_context_files(context_dir: Path) -> dict[str, str]:
27
+ """Read all markdown files from .context/ into a dict.
28
+
29
+ Keys use POSIX-style forward slashes (Path.as_posix()) so that ChromaDB
30
+ document IDs and metadata are identical on Windows and Linux.
31
+ """
32
+ return {md_file.relative_to(context_dir).as_posix(): md_file.read_text(encoding="utf-8") for md_file in context_dir.rglob("*.md")}
File without changes
File without changes
@@ -0,0 +1,72 @@
1
+ """Indexes .context/ files into ChromaDB for semantic search."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from mcp_project_context_server.helpers.context import (
9
+ collection_name_for,
10
+ find_context_dir,
11
+ read_context_files,
12
+ )
13
+ from mcp_project_context_server.indexing.ollama.embedder import embed_chunk_async
14
+ from mcp_project_context_server.integrations.chroma.client import chroma_client
15
+ from mcp_project_context_server.integrations.ollama.client import get_async_client
16
+
17
+ _EMBED_CONCURRENCY: int = int(os.getenv("EMBED_CONCURRENCY", "4"))
18
+
19
+
20
+ async def index_project_context(project_path: str | Path) -> str:
21
+ """Chunk, embed concurrently, and batch-store all .context/ markdown files."""
22
+ context_dir = find_context_dir(project_path)
23
+ if not context_dir:
24
+ return f"No .context/ directory found at or above {project_path}"
25
+
26
+ col_name = collection_name_for(context_dir)
27
+
28
+ # Drop and recreate for a clean re-index
29
+ try:
30
+ chroma_client.delete_collection(col_name)
31
+ except Exception:
32
+ pass
33
+ collection = chroma_client.create_collection(col_name)
34
+
35
+ files = read_context_files(context_dir)
36
+
37
+ # Build flat list of (doc_id, chunk_text, filename, chunk_index)
38
+ all_chunks: list[tuple[str, str, str, int]] = []
39
+ for filename, file_content in files.items():
40
+ for i, chunk in enumerate(file_content[j : j + 1000] for j in range(0, len(file_content), 1000)):
41
+ if chunk.strip():
42
+ all_chunks.append((f"{filename}::{i}", chunk, filename, i))
43
+
44
+ if not all_chunks:
45
+ return f"Indexed 0 chunks from {len(files)} files into collection '{col_name}'"
46
+
47
+ # Embed all chunks concurrently, bounded by semaphore
48
+ async_client = get_async_client()
49
+ semaphore = asyncio.Semaphore(_EMBED_CONCURRENCY)
50
+
51
+ async def _embed(doc_id: str, chunk: str, filename: str, chunk_idx: int):
52
+ async with semaphore:
53
+ try:
54
+ embedding = await embed_chunk_async(chunk, async_client)
55
+ return (doc_id, chunk, embedding, filename, chunk_idx)
56
+ except Exception as e:
57
+ print(f"Warning: failed to embed {doc_id}: {e}", file=sys.stderr)
58
+ return None
59
+
60
+ results = await asyncio.gather(*[_embed(*c) for c in all_chunks])
61
+
62
+ # Filter failures, then batch-add everything to ChromaDB in one call
63
+ valid = [r for r in results if r is not None]
64
+ if valid:
65
+ collection.add(
66
+ ids=[r[0] for r in valid],
67
+ embeddings=[r[2] for r in valid],
68
+ documents=[r[1] for r in valid],
69
+ metadatas=[{"file": r[3], "chunk": r[4]} for r in valid],
70
+ )
71
+
72
+ return f"Indexed {len(valid)} chunks from {len(files)} files into collection '{col_name}'"
File without changes
@@ -0,0 +1,18 @@
1
+ """Embedding generation for the indexing pipeline (sync + async)."""
2
+
3
+ import ollama
4
+
5
+ from mcp_project_context_server.integrations.ollama.client import (
6
+ get_embedding,
7
+ get_embedding_async,
8
+ )
9
+
10
+
11
+ def embed_chunk(text: str) -> list[float]:
12
+ """Generate an embedding vector for a single text chunk."""
13
+ return get_embedding(text)
14
+
15
+
16
+ async def embed_chunk_async(text: str, client: ollama.AsyncClient) -> list[float]:
17
+ """Async: Generate an embedding vector for a single text chunk."""
18
+ return await get_embedding_async(text, client)
File without changes
@@ -0,0 +1,18 @@
1
+ """ChromaDB client configuration and singleton."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import chromadb
7
+ from chromadb.config import Settings
8
+
9
+ _chroma_default: Path = Path.home() / ".mcp-data" / "chroma"
10
+ CHROMA_DIR: Path = Path(os.getenv("CHROMA_DIR", str(_chroma_default)))
11
+ CHROMA_DIR.mkdir(parents=True, exist_ok=True)
12
+
13
+ # chromadb.PersistentClient requires a str — this is the only place a Path is
14
+ # explicitly converted to str, at the external API boundary.
15
+ chroma_client: chromadb.ClientAPI = chromadb.PersistentClient(
16
+ path=str(CHROMA_DIR),
17
+ settings=Settings(anonymized_telemetry=False),
18
+ )
@@ -0,0 +1,28 @@
1
+ """Ollama client configuration and raw embedding API calls (sync + async)."""
2
+
3
+ import os
4
+
5
+ import ollama
6
+
7
+ OLLAMA_BASE_URL: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
8
+ EMBED_MODEL: str = os.getenv("EMBED_MODEL", "nomic-embed-text")
9
+
10
+
11
+ def get_client() -> ollama.Client:
12
+ return ollama.Client(host=OLLAMA_BASE_URL)
13
+
14
+
15
+ def get_async_client() -> ollama.AsyncClient:
16
+ return ollama.AsyncClient(host=OLLAMA_BASE_URL)
17
+
18
+
19
+ def get_embedding(text: str) -> list[float]:
20
+ """Call the Ollama embed endpoint and return the embedding vector."""
21
+ response = get_client().embed(model=EMBED_MODEL, input=text)
22
+ return list(response.embeddings[0])
23
+
24
+
25
+ async def get_embedding_async(text: str, client: ollama.AsyncClient) -> list[float]:
26
+ """Async: Call the Ollama embed endpoint and return the embedding vector."""
27
+ response = await client.embed(model=EMBED_MODEL, input=text)
28
+ return list(response.embeddings[0])
@@ -0,0 +1,119 @@
1
+ """MCP server setup, tool registry, and entry point."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from mcp import types
9
+ from mcp.server import Server
10
+ from mcp.server.stdio import stdio_server
11
+
12
+ from mcp_project_context_server.tools import (
13
+ index_context,
14
+ load_context,
15
+ save_session,
16
+ search_context,
17
+ )
18
+
19
+ _LOG_PATH = Path(r"C:\Users\drahk\.mcp-data\logs\project-context-server.log")
20
+ _LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ logging.basicConfig(
23
+ filename=_LOG_PATH,
24
+ filemode="a",
25
+ level=logging.DEBUG,
26
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
27
+ )
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ server = Server("project-context")
32
+
33
+ _TOOL_DEFINITIONS: list[types.Tool] = [
34
+ types.Tool(
35
+ name="load_project_context",
36
+ description=("Load the full project context for the given project path. " "Returns project.md, all ADRs, and the latest session summary. " "You MUST call this at the start of every session."),
37
+ inputSchema={
38
+ "type": "object",
39
+ "properties": {
40
+ "project_path": {
41
+ "type": "string",
42
+ "description": "Absolute path to the project root or any file within it.",
43
+ }
44
+ },
45
+ "required": ["project_path"],
46
+ },
47
+ ),
48
+ types.Tool(
49
+ name="search_project_context",
50
+ description=("Semantically search the indexed project context. " "Use this to find relevant past decisions, architecture notes, " "or code summaries related to your current task."),
51
+ inputSchema={
52
+ "type": "object",
53
+ "properties": {
54
+ "project_path": {"type": "string"},
55
+ "query": {"type": "string", "description": "Natural language search query"},
56
+ "n_results": {"type": "integer", "default": 5},
57
+ },
58
+ "required": ["project_path", "query"],
59
+ },
60
+ ),
61
+ types.Tool(
62
+ name="save_session_summary",
63
+ description=("Save a summary of the current session to .context/sessions/YYYY-MM-DD.md. " "Call this at the end of a session with a concise summary of what was done."),
64
+ inputSchema={
65
+ "type": "object",
66
+ "properties": {
67
+ "project_path": {"type": "string"},
68
+ "summary": {
69
+ "type": "string",
70
+ "description": "Markdown summary: what was worked on, decisions made, next steps.",
71
+ },
72
+ },
73
+ "required": ["project_path", "summary"],
74
+ },
75
+ ),
76
+ types.Tool(
77
+ name="index_project_context",
78
+ description=("Re-index the .context/ directory into the vector store. " "Run this after updating project.md, adding ADRs, or refreshing BUNDLE.md."),
79
+ inputSchema={
80
+ "type": "object",
81
+ "properties": {"project_path": {"type": "string"}},
82
+ "required": ["project_path"],
83
+ },
84
+ ),
85
+ ]
86
+
87
+ _TOOL_HANDLERS = {
88
+ "load_project_context": load_context.handle,
89
+ "search_project_context": search_context.handle,
90
+ "save_session_summary": save_session.handle,
91
+ "index_project_context": index_context.handle,
92
+ }
93
+
94
+
95
+ @server.list_tools()
96
+ async def list_tools() -> list[types.Tool]:
97
+ return _TOOL_DEFINITIONS
98
+
99
+
100
+ @server.call_tool()
101
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
102
+ handler = _TOOL_HANDLERS.get(name)
103
+ if not handler:
104
+ return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
105
+ return await handler(arguments)
106
+
107
+
108
+ async def _main() -> None:
109
+ async with stdio_server() as (read_stream, write_stream):
110
+ await server.run(read_stream, write_stream, server.create_initialization_options())
111
+
112
+
113
+ def run() -> None:
114
+ logger.info("project-context-server starting")
115
+ try:
116
+ asyncio.run(_main())
117
+ except Exception:
118
+ logger.exception("Server crashed at top level")
119
+ raise
File without changes
@@ -0,0 +1,13 @@
1
+ """Tool: index_project_context — re-indexes .context/ into ChromaDB."""
2
+
3
+ import os
4
+
5
+ from mcp import types
6
+
7
+ from mcp_project_context_server.indexing.chroma.indexer import index_project_context
8
+
9
+
10
+ async def handle(arguments: dict) -> list[types.TextContent]:
11
+ _project_path = os.getenv("PROJECT_PATH", arguments["project_path"])
12
+ result = await index_project_context(_project_path)
13
+ return [types.TextContent(type="text", text=result)]
@@ -0,0 +1,43 @@
1
+ """Tool: load_project_context — loads project.md, ADRs, and last session."""
2
+
3
+ import os
4
+
5
+ from mcp import types
6
+
7
+ from mcp_project_context_server.helpers.context import find_context_dir
8
+
9
+
10
+ async def handle(arguments: dict) -> list[types.TextContent]:
11
+ _project_path = os.getenv("PROJECT_PATH", arguments["project_path"])
12
+ context_dir = find_context_dir(_project_path)
13
+ if not context_dir:
14
+ return [
15
+ types.TextContent(
16
+ type="text",
17
+ text=f"No .context/ directory found near {arguments['project_path']}",
18
+ )
19
+ ]
20
+
21
+ parts: list[str] = []
22
+
23
+ project_md = context_dir / "project.md"
24
+ if project_md.exists():
25
+ parts.append(f"## project.md\n\n{project_md.read_text(encoding='utf-8')}")
26
+
27
+ decisions_dir = context_dir / "decisions"
28
+ if decisions_dir.exists():
29
+ adrs = sorted(decisions_dir.glob("*.md"))
30
+ if adrs:
31
+ parts.append("## Architecture Decisions\n")
32
+ for adr in adrs:
33
+ parts.append(f"### {adr.name}\n{adr.read_text(encoding='utf-8')}")
34
+
35
+ sessions_dir = context_dir / "sessions"
36
+ if sessions_dir.exists():
37
+ session_files = sorted(sessions_dir.glob("*.md"))
38
+ if session_files:
39
+ latest = session_files[-1]
40
+ parts.append(f"## Last Session ({latest.stem})\n\n{latest.read_text(encoding='utf-8')}")
41
+
42
+ result = "\n\n---\n\n".join(parts)
43
+ return [types.TextContent(type="text", text=result or "No context files found.")]
@@ -0,0 +1,43 @@
1
+ """Tool: save_session_summary — writes a session summary to .context/sessions/."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+
6
+ from mcp import types
7
+
8
+ from mcp_project_context_server.helpers.context import find_context_dir
9
+
10
+
11
+ async def handle(arguments: dict) -> list[types.TextContent]:
12
+ summary: str = arguments["summary"]
13
+
14
+ _project_path = os.getenv("PROJECT_PATH", arguments["project_path"])
15
+ context_dir = find_context_dir(_project_path)
16
+ if not context_dir:
17
+ return [
18
+ types.TextContent(
19
+ type="text",
20
+ text=f"No .context/ directory found near {arguments['project_path']}",
21
+ )
22
+ ]
23
+
24
+ sessions_dir = context_dir / "sessions"
25
+ sessions_dir.mkdir(exist_ok=True)
26
+
27
+ today = datetime.now().strftime("%Y-%m-%d")
28
+ session_file = sessions_dir / f"{today}.md"
29
+
30
+ if session_file.exists():
31
+ timestamp = datetime.now().strftime("%H:%M")
32
+ file_content = f"{session_file.read_text(encoding='utf-8')}" f"\n\n### Session at {timestamp}\n\n{summary}"
33
+ else:
34
+ file_content = f"# Session: {today}\n\n{summary}"
35
+
36
+ session_file.write_text(file_content, encoding="utf-8")
37
+ # as_posix() gives a consistent forward-slash path regardless of platform.
38
+ return [
39
+ types.TextContent(
40
+ type="text",
41
+ text=f"Session summary saved to {session_file.as_posix()}",
42
+ )
43
+ ]
@@ -0,0 +1,52 @@
1
+ """Tool: search_project_context — semantic search over indexed context."""
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+ from typing import cast
6
+
7
+ from mcp import types
8
+
9
+ from mcp_project_context_server.helpers.context import collection_name_for, find_context_dir
10
+ from mcp_project_context_server.integrations.chroma.client import chroma_client
11
+ from mcp_project_context_server.integrations.ollama.client import get_embedding
12
+
13
+
14
+ async def handle(arguments: dict) -> list[types.TextContent]:
15
+ query: str = arguments["query"]
16
+ n_results: int = arguments.get("n_results", 5)
17
+
18
+ _project_path = os.getenv("PROJECT_PATH", arguments["project_path"])
19
+ context_dir = find_context_dir(_project_path)
20
+ if not context_dir:
21
+ return [
22
+ types.TextContent(
23
+ type="text",
24
+ text=f"No .context/ directory found near {arguments['project_path']}",
25
+ )
26
+ ]
27
+
28
+ col_name = collection_name_for(context_dir)
29
+
30
+ try:
31
+ collection = chroma_client.get_collection(col_name)
32
+ except Exception:
33
+ return [
34
+ types.TextContent(
35
+ type="text",
36
+ text=f"Collection '{col_name}' not found. Run index_project_context first.",
37
+ )
38
+ ]
39
+
40
+ query_embedding = get_embedding(query)
41
+ results = collection.query(
42
+ query_embeddings=cast(list[Sequence[float]], [query_embedding]),
43
+ n_results=min(n_results, collection.count()),
44
+ )
45
+
46
+ docs = results["documents"]
47
+ metas = results["metadatas"]
48
+ if docs is None or metas is None or not docs[0]:
49
+ return [types.TextContent(type="text", text="No results found.")]
50
+
51
+ output_parts = [f"**[{meta['file']}]**\n{doc}" for doc, meta in zip(docs[0], metas[0])]
52
+ return [types.TextContent(type="text", text="\n\n---\n\n".join(output_parts))]