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.
- contextforge_mcp/__init__.py +3 -0
- contextforge_mcp/cbm_client.py +132 -0
- contextforge_mcp/cli.py +127 -0
- contextforge_mcp/compressor.py +138 -0
- contextforge_mcp/server.py +203 -0
- contextforge_mcp/speckit.py +125 -0
- contextforge_mcp-0.1.0.dist-info/METADATA +119 -0
- contextforge_mcp-0.1.0.dist-info/RECORD +11 -0
- contextforge_mcp-0.1.0.dist-info/WHEEL +4 -0
- contextforge_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge_mcp-0.1.0.dist-info/licenses/LICENSE +21 -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
|
contextforge_mcp/cli.py
ADDED
|
@@ -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,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.
|