contextforge-mcp 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.
@@ -0,0 +1,3 @@
1
+ """contextforge-mcp — MCP orchestrator for codebase-memory-mcp + headroom + Spec Kit."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,132 @@
1
+ """Client for codebase-memory-mcp binary over stdio JSON-RPC 2.0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ CBM_TOOLS: list[str] = [
15
+ "index_repository", "search_graph", "search_code", "trace_path",
16
+ "trace_call_path", "get_architecture", "get_node_details", "find_dead_code",
17
+ "find_similar_code", "get_impact", "cypher_query", "manage_adr",
18
+ "get_cross_service_links", "get_indexing_status",
19
+ ]
20
+
21
+ HIGH_TOKEN_TOOLS: set[str] = {
22
+ "search_graph", "search_code", "get_architecture",
23
+ "find_dead_code", "find_similar_code", "cypher_query",
24
+ }
25
+
26
+
27
+ class CBMClient:
28
+ def __init__(self, binary_path: str | None = None):
29
+ self.binary_path = binary_path or self._find_binary()
30
+ self._process: asyncio.subprocess.Process | None = None
31
+ self._request_id = 0
32
+ self._lock = asyncio.Lock()
33
+
34
+ async def start(self) -> None:
35
+ if not self.binary_path:
36
+ raise RuntimeError(
37
+ "codebase-memory-mcp binary not found. "
38
+ "Install with: npm install -g codebase-memory-mcp"
39
+ )
40
+ self._process = await asyncio.create_subprocess_exec(
41
+ self.binary_path,
42
+ stdin=asyncio.subprocess.PIPE,
43
+ stdout=asyncio.subprocess.PIPE,
44
+ stderr=asyncio.subprocess.PIPE,
45
+ )
46
+ await self._initialize()
47
+
48
+ async def stop(self) -> None:
49
+ if self._process and self._process.returncode is None:
50
+ self._process.terminate()
51
+ try:
52
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
53
+ except asyncio.TimeoutError:
54
+ self._process.kill()
55
+
56
+ async def __aenter__(self) -> "CBMClient":
57
+ await self.start()
58
+ return self
59
+
60
+ async def __aexit__(self, *_: Any) -> None:
61
+ await self.stop()
62
+
63
+ async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
64
+ if tool_name not in CBM_TOOLS:
65
+ raise ValueError(f"Unknown CBM tool: {tool_name!r}")
66
+ async with self._lock:
67
+ return await self._send({
68
+ "jsonrpc": "2.0",
69
+ "id": self._next_id(),
70
+ "method": "tools/call",
71
+ "params": {"name": tool_name, "arguments": arguments},
72
+ })
73
+
74
+ def is_high_token_tool(self, tool_name: str) -> bool:
75
+ return tool_name in HIGH_TOKEN_TOOLS
76
+
77
+ @property
78
+ def available(self) -> bool:
79
+ return self.binary_path is not None
80
+
81
+ async def _initialize(self) -> None:
82
+ await self._send({
83
+ "jsonrpc": "2.0", "id": self._next_id(), "method": "initialize",
84
+ "params": {
85
+ "protocolVersion": "2024-11-05", "capabilities": {},
86
+ "clientInfo": {"name": "contextforge-mcp", "version": "0.1.0"},
87
+ },
88
+ })
89
+ await self._write({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
90
+
91
+ async def _send(self, payload: dict[str, Any]) -> Any:
92
+ assert self._process and self._process.stdin and self._process.stdout
93
+ await self._write(payload)
94
+ target_id = payload.get("id")
95
+ while True:
96
+ line = await asyncio.wait_for(self._process.stdout.readline(), timeout=30.0)
97
+ if not line:
98
+ raise ConnectionError("codebase-memory-mcp subprocess closed")
99
+ line = line.decode().strip()
100
+ if not line:
101
+ continue
102
+ try:
103
+ response = json.loads(line)
104
+ except json.JSONDecodeError:
105
+ continue
106
+ if response.get("id") == target_id:
107
+ if "error" in response:
108
+ raise RuntimeError(f"CBM error: {response['error']}")
109
+ return response.get("result")
110
+
111
+ async def _write(self, payload: dict[str, Any]) -> None:
112
+ assert self._process and self._process.stdin
113
+ self._process.stdin.write((json.dumps(payload) + "\n").encode())
114
+ await self._process.stdin.drain()
115
+
116
+ def _next_id(self) -> int:
117
+ self._request_id += 1
118
+ return self._request_id
119
+
120
+ @staticmethod
121
+ def _find_binary() -> str | None:
122
+ for name in ["codebase-memory-mcp", "codebase-memory-mcp.exe"]:
123
+ found = shutil.which(name)
124
+ if found:
125
+ return found
126
+ for p in [
127
+ Path.home() / ".local" / "bin" / "codebase-memory-mcp",
128
+ Path("/usr/local/bin/codebase-memory-mcp"),
129
+ ]:
130
+ if p.exists():
131
+ return str(p)
132
+ return None
@@ -0,0 +1,127 @@
1
+ """ContextForge MCP CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ app = typer.Typer(name="contextforge-mcp", help="MCP orchestrator: codebase-memory-mcp + headroom + Spec Kit", no_args_is_help=True)
14
+ console = Console()
15
+
16
+
17
+ @app.command()
18
+ def run() -> None:
19
+ """Start ContextForge as a stdio MCP server."""
20
+ from .server import main
21
+ main()
22
+
23
+
24
+ @app.command()
25
+ def doctor() -> None:
26
+ """Check all dependencies."""
27
+ console.rule("[bold]ContextForge MCP — Doctor[/bold]")
28
+ ok = True
29
+
30
+ major, minor = sys.version_info[:2]
31
+ if major == 3 and minor >= 10:
32
+ console.print(f" ✅ Python {major}.{minor}")
33
+ else:
34
+ console.print(f" ❌ Python {major}.{minor} — need 3.10+"); ok = False
35
+
36
+ try:
37
+ import mcp; console.print(" ✅ mcp")
38
+ except ImportError:
39
+ console.print(" ❌ mcp — pip install mcp"); ok = False
40
+
41
+ try:
42
+ import headroom; console.print(" ✅ headroom-ai")
43
+ except ImportError:
44
+ console.print(" ⚠️ headroom-ai not installed — pip install 'headroom-ai[all]'")
45
+
46
+ cbm = shutil.which("codebase-memory-mcp") or shutil.which("codebase-memory-mcp.exe")
47
+ if cbm:
48
+ console.print(f" ✅ codebase-memory-mcp → {cbm}")
49
+ else:
50
+ console.print(" ❌ codebase-memory-mcp — npm install -g codebase-memory-mcp"); ok = False
51
+
52
+ specify = shutil.which("specify")
53
+ if specify:
54
+ console.print(f" ✅ specify (Spec Kit) → {specify}")
55
+ else:
56
+ console.print(" ⚠️ specify not found — pip install spec-kit (optional)")
57
+
58
+ console.print()
59
+ if ok:
60
+ console.print("[green]✅ Everything looks good![/green]")
61
+ else:
62
+ console.print("[yellow]⚠️ Some dependencies missing. See above.[/yellow]")
63
+ raise typer.Exit(code=1)
64
+
65
+
66
+ @app.command()
67
+ def install(
68
+ target: str = typer.Option("claude", help="claude | speckit | all"),
69
+ project_dir: Path = typer.Option(Path("."), help="Project directory"),
70
+ ) -> None:
71
+ """Configure ContextForge MCP for Claude Code and/or Spec Kit."""
72
+
73
+ if target in ("claude", "all"):
74
+ mcp_path = project_dir / ".mcp.json"
75
+ config: dict = {}
76
+ if mcp_path.exists():
77
+ try:
78
+ config = json.loads(mcp_path.read_text())
79
+ except json.JSONDecodeError:
80
+ pass
81
+ config.setdefault("mcpServers", {})
82
+ config["mcpServers"]["contextforge-mcp"] = {
83
+ "command": "contextforge-mcp",
84
+ "args": ["run"],
85
+ }
86
+ mcp_path.write_text(json.dumps(config, indent=2))
87
+ console.print(f"✅ Updated {mcp_path}")
88
+
89
+ if target in ("speckit", "all"):
90
+ _install_speckit(project_dir)
91
+
92
+ if target not in ("claude", "speckit", "all"):
93
+ console.print(f"[yellow]Unknown target: {target!r}[/yellow]")
94
+
95
+
96
+ def _install_speckit(project_dir: Path) -> None:
97
+ import shutil as sh
98
+ pkg_dir = Path(__file__).parent.parent.parent
99
+ ext_src = pkg_dir / "extensions" / "speckit"
100
+ if not ext_src.exists():
101
+ console.print("[yellow]Extension source not found — clone the repo first.[/yellow]")
102
+ return
103
+ ext_dst = project_dir / ".specify" / "extensions" / "contextforge-mcp"
104
+ ext_dst.mkdir(parents=True, exist_ok=True)
105
+ sh.copytree(ext_src, ext_dst, dirs_exist_ok=True)
106
+ console.print(f"✅ Spec Kit extension → {ext_dst}")
107
+ claude_commands = project_dir / ".claude" / "commands"
108
+ if (project_dir / ".claude").exists():
109
+ claude_commands.mkdir(parents=True, exist_ok=True)
110
+ for f in (ext_src / "commands").glob("*.md"):
111
+ sh.copy2(f, claude_commands / f.name)
112
+ console.print(f"✅ Slash commands → {claude_commands}")
113
+
114
+
115
+ @app.command()
116
+ def info() -> None:
117
+ """Show version and info."""
118
+ from . import __version__
119
+ console.print(f"[bold]contextforge-mcp[/bold] v{__version__}")
120
+ console.print(" codebase-memory-mcp: github.com/DeusData/codebase-memory-mcp")
121
+ console.print(" headroom: github.com/headroomlabs-ai/headroom")
122
+ console.print(" spec-kit: github.com/github/spec-kit")
123
+ console.print(" repo: github.com/capatinore/contextforge-mcp")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ app()
@@ -0,0 +1,138 @@
1
+ """Headroom compression layer with stats tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SKIP_TOOLS: set[str] = {"index_repository", "get_indexing_status"}
14
+
15
+
16
+ @dataclass
17
+ class CompressionResult:
18
+ original_tokens: int
19
+ compressed_tokens: int
20
+ tokens_saved: int
21
+ ratio: float
22
+ content: str
23
+ elapsed_ms: float
24
+ skipped: bool = False
25
+
26
+
27
+ @dataclass
28
+ class SessionStats:
29
+ total_calls: int = 0
30
+ total_original_tokens: int = 0
31
+ total_compressed_tokens: int = 0
32
+ total_elapsed_ms: float = 0.0
33
+ errors: int = 0
34
+
35
+ @property
36
+ def tokens_saved(self) -> int:
37
+ return self.total_original_tokens - self.total_compressed_tokens
38
+
39
+ @property
40
+ def overall_ratio(self) -> float:
41
+ if self.total_original_tokens == 0:
42
+ return 0.0
43
+ return 1.0 - (self.total_compressed_tokens / self.total_original_tokens)
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return {
47
+ "total_calls": self.total_calls,
48
+ "total_original_tokens": self.total_original_tokens,
49
+ "total_compressed_tokens": self.total_compressed_tokens,
50
+ "tokens_saved": self.tokens_saved,
51
+ "overall_compression_ratio": f"{self.overall_ratio:.1%}",
52
+ "total_elapsed_ms": round(self.total_elapsed_ms, 1),
53
+ "errors": self.errors,
54
+ }
55
+
56
+
57
+ class HeadroomCompressor:
58
+ def __init__(self, model: str = "claude-sonnet-4-6"):
59
+ self.model = model
60
+ self.stats = SessionStats()
61
+ self._available = self._check_availability()
62
+
63
+ async def compress_tool_result(
64
+ self, tool_name: str, result: Any, *, force: bool = False
65
+ ) -> CompressionResult:
66
+ t0 = time.perf_counter()
67
+ self.stats.total_calls += 1
68
+ content_str = result if isinstance(result, str) else json.dumps(result, indent=2)
69
+
70
+ if (tool_name in SKIP_TOOLS and not force) or not self._available:
71
+ elapsed = (time.perf_counter() - t0) * 1000
72
+ token_est = len(content_str.split())
73
+ self.stats.total_original_tokens += token_est
74
+ self.stats.total_compressed_tokens += token_est
75
+ return CompressionResult(
76
+ original_tokens=token_est, compressed_tokens=token_est,
77
+ tokens_saved=0, ratio=0.0, content=content_str,
78
+ elapsed_ms=elapsed, skipped=True,
79
+ )
80
+
81
+ try:
82
+ return await self._compress(content_str, t0)
83
+ except Exception as exc:
84
+ logger.warning("Headroom compression failed for %s: %s", tool_name, exc)
85
+ self.stats.errors += 1
86
+ elapsed = (time.perf_counter() - t0) * 1000
87
+ token_est = len(content_str.split())
88
+ return CompressionResult(
89
+ original_tokens=token_est, compressed_tokens=token_est,
90
+ tokens_saved=0, ratio=0.0, content=content_str,
91
+ elapsed_ms=elapsed, skipped=True,
92
+ )
93
+
94
+ def get_stats(self) -> dict[str, Any]:
95
+ return {"headroom_available": self._available, "model": self.model, **self.stats.to_dict()}
96
+
97
+ def reset_stats(self) -> None:
98
+ self.stats = SessionStats()
99
+
100
+ async def _compress(self, content: str, t0: float) -> CompressionResult:
101
+ import asyncio
102
+ from headroom import compress # type: ignore[import]
103
+
104
+ messages = [{"role": "tool", "tool_call_id": "cf", "content": content}]
105
+ loop = asyncio.get_event_loop()
106
+ result = await loop.run_in_executor(
107
+ None, lambda: compress(messages, model=self.model)
108
+ )
109
+ elapsed = (time.perf_counter() - t0) * 1000
110
+ compressed_content = content
111
+ if result.messages:
112
+ msg = result.messages[0]
113
+ if isinstance(msg, dict):
114
+ compressed_content = msg.get("content", content)
115
+
116
+ orig = getattr(result, "tokens_before", len(content.split()))
117
+ comp = getattr(result, "tokens_after", len(compressed_content.split()))
118
+ saved = orig - comp
119
+ ratio = saved / orig if orig > 0 else 0.0
120
+
121
+ self.stats.total_original_tokens += orig
122
+ self.stats.total_compressed_tokens += comp
123
+ self.stats.total_elapsed_ms += elapsed
124
+
125
+ return CompressionResult(
126
+ original_tokens=orig, compressed_tokens=comp,
127
+ tokens_saved=saved, ratio=ratio,
128
+ content=compressed_content, elapsed_ms=elapsed,
129
+ )
130
+
131
+ @staticmethod
132
+ def _check_availability() -> bool:
133
+ try:
134
+ import headroom # noqa: F401
135
+ return True
136
+ except ImportError:
137
+ logger.warning("headroom-ai not installed — compression disabled.")
138
+ return False
@@ -0,0 +1,203 @@
1
+ """ContextForge MCP Server — codebase-memory-mcp + headroom + Spec Kit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from .cbm_client import CBMClient, CBM_TOOLS
13
+ from .compressor import HeadroomCompressor
14
+ from .speckit import read_and_compress, implement_context, status as speckit_status
15
+
16
+ logging.basicConfig(level=os.environ.get("CF_LOG_LEVEL", "WARNING").upper())
17
+ logger = logging.getLogger("contextforge-mcp")
18
+
19
+ mcp = FastMCP("contextforge-mcp")
20
+
21
+ _cbm = CBMClient(binary_path=os.environ.get("CBM_BINARY_PATH"))
22
+ _compressor = HeadroomCompressor(
23
+ model=os.environ.get("CF_MODEL", "claude-sonnet-4-6")
24
+ )
25
+
26
+
27
+ async def _cbm_call(tool_name: str, arguments: dict[str, Any]) -> str:
28
+ if not _cbm.available:
29
+ return json.dumps({"error": "codebase-memory-mcp not found", "hint": "npm install -g codebase-memory-mcp"})
30
+ if _cbm._process is None:
31
+ await _cbm.start()
32
+ try:
33
+ raw = await _cbm.call_tool(tool_name, arguments)
34
+ except Exception as exc:
35
+ return json.dumps({"error": str(exc), "tool": tool_name})
36
+ compressed = await _compressor.compress_tool_result(tool_name, raw)
37
+ if compressed.skipped:
38
+ return compressed.content
39
+ return (
40
+ f"[ContextForge: {compressed.original_tokens}→{compressed.compressed_tokens} "
41
+ f"tokens ({compressed.ratio:.0%} saved)]\n\n{compressed.content}"
42
+ )
43
+
44
+
45
+ # ── CBM Tools ────────────────────────────────────────────────────────────────
46
+
47
+ @mcp.tool()
48
+ async def cbm_index_repository(repo_path: str, incremental: bool = True) -> str:
49
+ """Index a repository into the knowledge graph. Run before any other cbm_* tool."""
50
+ return await _cbm_call("index_repository", {"repo_path": repo_path, "incremental": incremental})
51
+
52
+ @mcp.tool()
53
+ async def cbm_search_graph(name_pattern: str, label: str = "Function", limit: int = 50, file_pattern: str | None = None) -> str:
54
+ """Search the knowledge graph by name pattern (regex). label: Function|Class|Method|Interface."""
55
+ args: dict[str, Any] = {"name_pattern": name_pattern, "label": label, "limit": limit}
56
+ if file_pattern:
57
+ args["file_pattern"] = file_pattern
58
+ return await _cbm_call("search_graph", args)
59
+
60
+ @mcp.tool()
61
+ async def cbm_search_code(query: str, mode: str = "compact", limit: int = 30) -> str:
62
+ """Full-text + graph-ranked code search. mode: compact|full|files."""
63
+ return await _cbm_call("search_code", {"query": query, "mode": mode, "limit": limit})
64
+
65
+ @mcp.tool()
66
+ async def cbm_trace_path(function_name: str, direction: str = "both", depth: int = 5) -> str:
67
+ """Trace call paths for a function. direction: inbound|outbound|both."""
68
+ return await _cbm_call("trace_path", {"function_name": function_name, "direction": direction, "depth": depth})
69
+
70
+ @mcp.tool()
71
+ async def cbm_trace_call_path(function_name: str, direction: str = "both") -> str:
72
+ """Trace the full call chain for a function across the knowledge graph."""
73
+ return await _cbm_call("trace_call_path", {"function_name": function_name, "direction": direction})
74
+
75
+ @mcp.tool()
76
+ async def cbm_get_architecture(include_external: bool = False, service_filter: str | None = None) -> str:
77
+ """Get high-level architecture view: modules, services, HTTP routes."""
78
+ args: dict[str, Any] = {"include_external": include_external}
79
+ if service_filter:
80
+ args["service_filter"] = service_filter
81
+ return await _cbm_call("get_architecture", args)
82
+
83
+ @mcp.tool()
84
+ async def cbm_get_node_details(node_id: str) -> str:
85
+ """Get detailed information about a specific graph node."""
86
+ return await _cbm_call("get_node_details", {"node_id": node_id})
87
+
88
+ @mcp.tool()
89
+ async def cbm_find_dead_code(confidence: str = "high", label: str | None = None) -> str:
90
+ """Detect unreachable/unused code. confidence: high|medium|low."""
91
+ args: dict[str, Any] = {"confidence": confidence}
92
+ if label:
93
+ args["label"] = label
94
+ return await _cbm_call("find_dead_code", args)
95
+
96
+ @mcp.tool()
97
+ async def cbm_find_similar_code(node_id: str, threshold: float = 0.8, limit: int = 20) -> str:
98
+ """Find code clones and similar implementations in the codebase."""
99
+ return await _cbm_call("find_similar_code", {"node_id": node_id, "threshold": threshold, "limit": limit})
100
+
101
+ @mcp.tool()
102
+ async def cbm_get_impact(node_id: str, depth: int = 3) -> str:
103
+ """Analyze what would break if this function/class changes."""
104
+ return await _cbm_call("get_impact", {"node_id": node_id, "depth": depth})
105
+
106
+ @mcp.tool()
107
+ async def cbm_cypher_query(query: str, limit: int = 100) -> str:
108
+ """Run a raw Cypher-like query against the knowledge graph."""
109
+ return await _cbm_call("cypher_query", {"query": query, "limit": limit})
110
+
111
+ @mcp.tool()
112
+ async def cbm_manage_adr(action: str, title: str | None = None, content: str | None = None, adr_id: str | None = None) -> str:
113
+ """Manage Architecture Decision Records. action: create|list|get|update."""
114
+ args: dict[str, Any] = {"action": action}
115
+ if title: args["title"] = title
116
+ if content: args["content"] = content
117
+ if adr_id: args["adr_id"] = adr_id
118
+ return await _cbm_call("manage_adr", args)
119
+
120
+ @mcp.tool()
121
+ async def cbm_get_cross_service_links(service_name: str | None = None, protocol: str | None = None) -> str:
122
+ """Get cross-service HTTP/gRPC/GraphQL links detected in the codebase."""
123
+ args: dict[str, Any] = {}
124
+ if service_name: args["service_name"] = service_name
125
+ if protocol: args["protocol"] = protocol
126
+ return await _cbm_call("get_cross_service_links", args)
127
+
128
+ @mcp.tool()
129
+ async def cbm_get_indexing_status() -> str:
130
+ """Get the current indexing status."""
131
+ return await _cbm_call("get_indexing_status", {})
132
+
133
+
134
+ # ── ContextForge Meta Tools ───────────────────────────────────────────────────
135
+
136
+ @mcp.tool()
137
+ async def cf_stats() -> str:
138
+ """Show ContextForge session compression statistics and token savings."""
139
+ return json.dumps({
140
+ "contextforge_mcp_version": "0.1.0",
141
+ "cbm_available": _cbm.available,
142
+ "cbm_binary": _cbm.binary_path,
143
+ "compression": _compressor.get_stats(),
144
+ }, indent=2)
145
+
146
+ @mcp.tool()
147
+ async def cf_compress(text: str, hint: str = "text") -> str:
148
+ """Compress arbitrary text through headroom. hint: text|code|json|logs."""
149
+ compressed = await _compressor.compress_tool_result(f"cf_{hint}", text, force=True)
150
+ if compressed.skipped:
151
+ return f"[headroom unavailable]\n\n{text}"
152
+ return (
153
+ f"[ContextForge: {compressed.original_tokens}→{compressed.compressed_tokens} "
154
+ f"tokens ({compressed.ratio:.0%} saved)]\n\n{compressed.content}"
155
+ )
156
+
157
+ @mcp.tool()
158
+ async def cf_reset_stats() -> str:
159
+ """Reset ContextForge session compression counters."""
160
+ _compressor.reset_stats()
161
+ return json.dumps({"status": "reset"})
162
+
163
+
164
+ # ── Spec Kit Tools ────────────────────────────────────────────────────────────
165
+
166
+ @mcp.tool()
167
+ async def cf_read_spec(feature_id: str | None = None) -> str:
168
+ """Read and compress spec.md for a Spec Kit feature."""
169
+ return await read_and_compress(_compressor, feature_id, "spec.md")
170
+
171
+ @mcp.tool()
172
+ async def cf_read_plan(feature_id: str | None = None) -> str:
173
+ """Read and compress plan.md for a Spec Kit feature."""
174
+ return await read_and_compress(_compressor, feature_id, "plan.md")
175
+
176
+ @mcp.tool()
177
+ async def cf_read_tasks(feature_id: str | None = None) -> str:
178
+ """Read and compress tasks.md for a Spec Kit feature."""
179
+ return await read_and_compress(_compressor, feature_id, "tasks.md")
180
+
181
+ @mcp.tool()
182
+ async def cf_read_artifact(artifact: str, feature_id: str | None = None) -> str:
183
+ """Read and compress any Spec Kit artifact. artifact: filename like data-model.md."""
184
+ return await read_and_compress(_compressor, feature_id, artifact)
185
+
186
+ @mcp.tool()
187
+ async def cf_implement_context(feature_id: str | None = None) -> str:
188
+ """Load compressed spec+plan+tasks+context bundle for /speckit.cf-implement."""
189
+ return await implement_context(_compressor, feature_id)
190
+
191
+ @mcp.tool()
192
+ async def cf_speckit_status() -> str:
193
+ """List all Spec Kit features and their current phase."""
194
+ return json.dumps(await speckit_status(), indent=2)
195
+
196
+
197
+ # ── Entry point ───────────────────────────────────────────────────────────────
198
+
199
+ def main() -> None:
200
+ mcp.run(transport="stdio")
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -0,0 +1,125 @@
1
+ """Spec Kit integration — read and compress SDD artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def _specs_dir(base: str = ".") -> Path:
11
+ for parent in [Path(base), *Path(base).parents[:3]]:
12
+ candidate = parent / "specs"
13
+ if candidate.is_dir():
14
+ return candidate
15
+ return Path(base) / "specs"
16
+
17
+
18
+ def _feature_dir(feature_id: str | None, specs: Path) -> Path | None:
19
+ if not specs.exists():
20
+ return None
21
+ if feature_id:
22
+ exact = specs / feature_id
23
+ if exact.is_dir():
24
+ return exact
25
+ matches = [d for d in specs.iterdir() if d.is_dir() and d.name.startswith(feature_id)]
26
+ return matches[0] if matches else None
27
+ dirs = [d for d in specs.iterdir() if d.is_dir()]
28
+ return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None
29
+
30
+
31
+ def _read(feature_dir: Path, filename: str) -> str | None:
32
+ path = feature_dir / filename
33
+ return path.read_text(encoding="utf-8") if path.exists() else None
34
+
35
+
36
+ async def read_and_compress(compressor: Any, feature_id: str | None, artifact: str, base: str = ".") -> str:
37
+ specs = _specs_dir(base)
38
+ feat = _feature_dir(feature_id, specs)
39
+
40
+ if feat is None:
41
+ return json.dumps({
42
+ "error": f"No feature found in {specs}",
43
+ "hint": "Run /speckit.specify first.",
44
+ "feature_id": feature_id,
45
+ })
46
+
47
+ content = _read(feat, artifact)
48
+ if content is None:
49
+ return json.dumps({
50
+ "error": f"{artifact} not found in {feat.name}",
51
+ "available": [f.name for f in feat.iterdir() if f.is_file()],
52
+ })
53
+
54
+ compressed = await compressor.compress_tool_result(f"speckit_{artifact}", content, force=True)
55
+ header = (
56
+ f"[ContextForge: {artifact} — "
57
+ f"{compressed.original_tokens}→{compressed.compressed_tokens} tokens "
58
+ f"({compressed.ratio:.0%} saved) | {feat.name}]\n\n"
59
+ )
60
+ return header + compressed.content
61
+
62
+
63
+ async def implement_context(compressor: Any, feature_id: str | None, base: str = ".") -> str:
64
+ specs = _specs_dir(base)
65
+ feat = _feature_dir(feature_id, specs)
66
+
67
+ if feat is None:
68
+ return json.dumps({"error": "Feature not found", "specs_dir": str(specs)})
69
+
70
+ artifacts = ["spec.md", "plan.md", "tasks.md", "context.md", "data-model.md"]
71
+ sections = []
72
+ total_orig = total_comp = 0
73
+
74
+ for artifact in artifacts:
75
+ content = _read(feat, artifact)
76
+ if content is None:
77
+ continue
78
+ r = await compressor.compress_tool_result(f"speckit_{artifact}", content, force=True)
79
+ total_orig += r.original_tokens
80
+ total_comp += r.compressed_tokens
81
+ sections.append(f"## {artifact}\n\n{r.content}")
82
+
83
+ if not sections:
84
+ return json.dumps({"error": "No Spec Kit artifacts found", "feature_dir": str(feat)})
85
+
86
+ ratio = 1 - (total_comp / total_orig) if total_orig > 0 else 0
87
+ header = (
88
+ f"[ContextForge: implement context — {feat.name} — "
89
+ f"{total_orig}→{total_comp} tokens ({ratio:.0%} saved)]\n\n"
90
+ )
91
+ return header + "\n\n---\n\n".join(sections)
92
+
93
+
94
+ async def status(base: str = ".") -> dict[str, Any]:
95
+ specs = _specs_dir(base)
96
+ if not specs.exists():
97
+ return {"exists": False, "specs_dir": str(specs), "features": []}
98
+
99
+ features = []
100
+ for feat in sorted(specs.iterdir()):
101
+ if not feat.is_dir():
102
+ continue
103
+ files = {f.name for f in feat.iterdir() if f.is_file()}
104
+ phase = "empty"
105
+ if "spec.md" in files:
106
+ phase = "specified"
107
+ if "plan.md" in files:
108
+ phase = "planned"
109
+ if "tasks.md" in files:
110
+ phase = "tasked"
111
+
112
+ tasks_done = tasks_total = 0
113
+ tasks_content = _read(feat, "tasks.md")
114
+ if tasks_content:
115
+ lines = tasks_content.splitlines()
116
+ tasks_total = sum(1 for l in lines if l.strip().startswith("- ["))
117
+ tasks_done = sum(1 for l in lines if l.strip().startswith("- [x]"))
118
+
119
+ features.append({
120
+ "id": feat.name, "phase": phase,
121
+ "cf_analyzed": "context.md" in files,
122
+ "tasks": {"done": tasks_done, "total": tasks_total},
123
+ })
124
+
125
+ return {"exists": True, "feature_count": len(features), "features": features}
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: contextforge-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP orchestrator: codebase-memory-mcp + headroom + Spec Kit
5
+ Project-URL: Repository, https://github.com/capatinore/contextforge-mcp
6
+ Author-email: Camilo Patiño <capatinore@github.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,code-intelligence,compression,context,llm,mcp,spec-kit
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: anyio>=4.0.0
12
+ Requires-Dist: headroom-ai>=0.9.0
13
+ Requires-Dist: httpx>=0.27.0
14
+ Requires-Dist: mcp>=1.0.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: rich>=13.0.0
17
+ Requires-Dist: typer>=0.12.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # contextforge-mcp
24
+
25
+ **MCP orchestrator combining [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) + [headroom](https://github.com/headroomlabs-ai/headroom) + [Spec Kit](https://github.com/github/spec-kit) into a single pipeline.**
26
+
27
+ ```
28
+ Agent (Claude Code / Cursor / Codex)
29
+
30
+
31
+ contextforge-mcp ←── single MCP server
32
+
33
+ ├── codebase-memory-mcp (knowledge graph: 99% fewer retrieval tokens)
34
+ ├── headroom (compression: 60–95% fewer prompt tokens)
35
+ └── Spec Kit (SDD workflow: spec → plan → tasks → implement)
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ # Prerequisites
44
+ npm install -g codebase-memory-mcp
45
+ pip install "headroom-ai[all]"
46
+ pip install spec-kit
47
+
48
+ # Install contextforge-mcp
49
+ pip install contextforge-mcp
50
+ ```
51
+
52
+ ## Setup
53
+
54
+ ```bash
55
+ # Health check
56
+ contextforge-mcp doctor
57
+
58
+ # Configure Claude Code (writes .mcp.json)
59
+ contextforge-mcp install --target claude
60
+
61
+ # Configure Spec Kit extension
62
+ contextforge-mcp install --target speckit
63
+
64
+ # Both at once
65
+ contextforge-mcp install --target all
66
+ ```
67
+
68
+ ## Usage in Claude Code
69
+
70
+ ```
71
+ # 1. Index the codebase (once per session)
72
+ cbm_index_repository(repo_path=".")
73
+
74
+ # 2. Query the graph (instead of grep/read)
75
+ cbm_search_graph(name_pattern=".*Payment.*")
76
+ cbm_trace_path(function_name="processPayment")
77
+ cbm_get_architecture()
78
+
79
+ # 3. Check token savings
80
+ cf_stats()
81
+ ```
82
+
83
+ ## Spec Kit workflow
84
+
85
+ ```
86
+ /speckit.constitution
87
+ /speckit.specify
88
+ /speckit.cf-analyze ← analyze codebase before planning
89
+ /speckit.plan
90
+ /speckit.tasks
91
+ /speckit.cf-index ← index before implementing
92
+ /speckit.cf-implement ← graph-aware implementation
93
+ /speckit.cf-stats ← token savings report
94
+ ```
95
+
96
+ ## Tools (23 total)
97
+
98
+ | Prefix | Count | Description |
99
+ |--------|-------|-------------|
100
+ | `cbm_*` | 14 | codebase-memory-mcp graph tools |
101
+ | `cf_*` | 9 | ContextForge meta + Spec Kit tools |
102
+
103
+ ## Environment variables
104
+
105
+ | Variable | Default | Description |
106
+ |----------|---------|-------------|
107
+ | `CBM_BINARY_PATH` | auto | Path to codebase-memory-mcp binary |
108
+ | `CF_MODEL` | `claude-sonnet-4-6` | Model hint for headroom |
109
+ | `CF_LOG_LEVEL` | `WARNING` | Log level |
110
+
111
+ ## Credits
112
+
113
+ - [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) — MIT
114
+ - [headroom](https://github.com/headroomlabs-ai/headroom) — Apache 2.0
115
+ - [spec-kit](https://github.com/github/spec-kit) — MIT
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,11 @@
1
+ contextforge_mcp/__init__.py,sha256=dM-AYU2hQF_EQp_C4om1s4FFFTX2lCkwdaCzXwRopEo,114
2
+ contextforge_mcp/cbm_client.py,sha256=ben6fRaQwumZIN5t_FXN8RGeuTUSIRU5tZC_GO8DQcc,4661
3
+ contextforge_mcp/cli.py,sha256=mHbPSOj-Wpu2t3oRHs5kfsORvJDhMldKNNcFMVWSAf8,4328
4
+ contextforge_mcp/compressor.py,sha256=gcBpmEQPc-oqpmt2IjsohV1hX0K2-JyDQDeBlB-dv1w,4795
5
+ contextforge_mcp/server.py,sha256=nITVgmKHVJTvGIAlEbaACrn6HyHt-2HVcsX4Hn80Zv0,8843
6
+ contextforge_mcp/speckit.py,sha256=hKASEM90NxPR3f9pDjBho2Q1AZz5UnuJHKkIyviDdbw,4332
7
+ contextforge_mcp-0.1.0.dist-info/METADATA,sha256=YuHjXVT1qEIo_S9WX9jH1e3N5iRHeC8Go9-izY5aZ-g,3161
8
+ contextforge_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ contextforge_mcp-0.1.0.dist-info/entry_points.txt,sha256=E8S63Mm75wGZZSq48-SmkR4B8JATfan9fsj-VtJNZWY,62
10
+ contextforge_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=b6yZCptmfb19UpXurEv9CJsqCk5JZEAOIdys2z5KA20,1071
11
+ contextforge_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ contextforge-mcp = contextforge_mcp.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Camilo Patiño
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.