agentforge-graph 0.3.2__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 (151) hide show
  1. agentforge_graph/__init__.py +6 -0
  2. agentforge_graph/chunking/__init__.py +12 -0
  3. agentforge_graph/chunking/cast.py +159 -0
  4. agentforge_graph/chunking/chunk.py +19 -0
  5. agentforge_graph/chunking/tokens.py +15 -0
  6. agentforge_graph/cli.py +607 -0
  7. agentforge_graph/config.py +259 -0
  8. agentforge_graph/core/__init__.py +54 -0
  9. agentforge_graph/core/conformance.py +270 -0
  10. agentforge_graph/core/contracts.py +163 -0
  11. agentforge_graph/core/kinds.py +68 -0
  12. agentforge_graph/core/models.py +134 -0
  13. agentforge_graph/core/provenance.py +62 -0
  14. agentforge_graph/core/symbols.py +116 -0
  15. agentforge_graph/embed/__init__.py +28 -0
  16. agentforge_graph/embed/base.py +22 -0
  17. agentforge_graph/embed/bedrock.py +85 -0
  18. agentforge_graph/embed/fake.py +34 -0
  19. agentforge_graph/embed/openai.py +67 -0
  20. agentforge_graph/embed/pipeline.py +184 -0
  21. agentforge_graph/embed/registry.py +66 -0
  22. agentforge_graph/embed/report.py +15 -0
  23. agentforge_graph/enrich/__init__.py +70 -0
  24. agentforge_graph/enrich/anthropic.py +38 -0
  25. agentforge_graph/enrich/anthropic_client.py +109 -0
  26. agentforge_graph/enrich/bedrock.py +24 -0
  27. agentforge_graph/enrich/bedrock_client.py +115 -0
  28. agentforge_graph/enrich/bedrock_summarizer.py +23 -0
  29. agentforge_graph/enrich/claude.py +172 -0
  30. agentforge_graph/enrich/enricher.py +108 -0
  31. agentforge_graph/enrich/governs.py +173 -0
  32. agentforge_graph/enrich/governs_enricher.py +152 -0
  33. agentforge_graph/enrich/heuristics.py +224 -0
  34. agentforge_graph/enrich/judge.py +63 -0
  35. agentforge_graph/enrich/registry.py +133 -0
  36. agentforge_graph/enrich/report.py +60 -0
  37. agentforge_graph/enrich/summarizer.py +62 -0
  38. agentforge_graph/enrich/summary_enricher.py +211 -0
  39. agentforge_graph/enrich/taxonomy.py +38 -0
  40. agentforge_graph/frameworks/__init__.py +29 -0
  41. agentforge_graph/frameworks/base.py +75 -0
  42. agentforge_graph/frameworks/detect.py +124 -0
  43. agentforge_graph/frameworks/extractor.py +63 -0
  44. agentforge_graph/frameworks/orm.py +93 -0
  45. agentforge_graph/frameworks/packs/_js_ast.py +56 -0
  46. agentforge_graph/frameworks/packs/_python_ast.py +157 -0
  47. agentforge_graph/frameworks/packs/django/__init__.py +240 -0
  48. agentforge_graph/frameworks/packs/django/models.scm +7 -0
  49. agentforge_graph/frameworks/packs/express/__init__.py +133 -0
  50. agentforge_graph/frameworks/packs/express/routes.scm +8 -0
  51. agentforge_graph/frameworks/packs/fastapi/__init__.py +210 -0
  52. agentforge_graph/frameworks/packs/fastapi/depends.scm +6 -0
  53. agentforge_graph/frameworks/packs/fastapi/routes.scm +10 -0
  54. agentforge_graph/frameworks/packs/flask/__init__.py +143 -0
  55. agentforge_graph/frameworks/packs/flask/routes.scm +11 -0
  56. agentforge_graph/frameworks/packs/nestjs/__init__.py +205 -0
  57. agentforge_graph/frameworks/packs/nestjs/routes.scm +6 -0
  58. agentforge_graph/frameworks/packs/spring/__init__.py +267 -0
  59. agentforge_graph/frameworks/packs/spring/routes.scm +6 -0
  60. agentforge_graph/frameworks/packs/sqlalchemy/__init__.py +250 -0
  61. agentforge_graph/frameworks/packs/sqlalchemy/models.scm +7 -0
  62. agentforge_graph/frameworks/registry.py +44 -0
  63. agentforge_graph/ingest/__init__.py +30 -0
  64. agentforge_graph/ingest/codegraph.py +847 -0
  65. agentforge_graph/ingest/extractor.py +353 -0
  66. agentforge_graph/ingest/incremental/__init__.py +25 -0
  67. agentforge_graph/ingest/incremental/detect.py +118 -0
  68. agentforge_graph/ingest/incremental/dirty.py +61 -0
  69. agentforge_graph/ingest/incremental/indexer.py +218 -0
  70. agentforge_graph/ingest/incremental/meta.py +72 -0
  71. agentforge_graph/ingest/incremental/ports.py +39 -0
  72. agentforge_graph/ingest/pack.py +160 -0
  73. agentforge_graph/ingest/packs/__init__.py +34 -0
  74. agentforge_graph/ingest/packs/cpp/__init__.py +35 -0
  75. agentforge_graph/ingest/packs/cpp/references.scm +15 -0
  76. agentforge_graph/ingest/packs/cpp/structure.scm +49 -0
  77. agentforge_graph/ingest/packs/csharp/__init__.py +35 -0
  78. agentforge_graph/ingest/packs/csharp/references.scm +12 -0
  79. agentforge_graph/ingest/packs/csharp/structure.scm +45 -0
  80. agentforge_graph/ingest/packs/go/__init__.py +38 -0
  81. agentforge_graph/ingest/packs/go/references.scm +12 -0
  82. agentforge_graph/ingest/packs/go/structure.scm +64 -0
  83. agentforge_graph/ingest/packs/java/__init__.py +35 -0
  84. agentforge_graph/ingest/packs/java/references.scm +12 -0
  85. agentforge_graph/ingest/packs/java/structure.scm +38 -0
  86. agentforge_graph/ingest/packs/javascript/__init__.py +34 -0
  87. agentforge_graph/ingest/packs/javascript/references.scm +11 -0
  88. agentforge_graph/ingest/packs/javascript/structure.scm +166 -0
  89. agentforge_graph/ingest/packs/php/__init__.py +35 -0
  90. agentforge_graph/ingest/packs/php/references.scm +15 -0
  91. agentforge_graph/ingest/packs/php/structure.scm +44 -0
  92. agentforge_graph/ingest/packs/python/__init__.py +25 -0
  93. agentforge_graph/ingest/packs/python/references.scm +14 -0
  94. agentforge_graph/ingest/packs/python/structure.scm +57 -0
  95. agentforge_graph/ingest/packs/ruby/__init__.py +37 -0
  96. agentforge_graph/ingest/packs/ruby/references.scm +12 -0
  97. agentforge_graph/ingest/packs/ruby/structure.scm +37 -0
  98. agentforge_graph/ingest/packs/rust/__init__.py +39 -0
  99. agentforge_graph/ingest/packs/rust/references.scm +12 -0
  100. agentforge_graph/ingest/packs/rust/structure.scm +46 -0
  101. agentforge_graph/ingest/packs/typescript/__init__.py +31 -0
  102. agentforge_graph/ingest/packs/typescript/references.scm +11 -0
  103. agentforge_graph/ingest/packs/typescript/structure.scm +99 -0
  104. agentforge_graph/ingest/pipeline.py +134 -0
  105. agentforge_graph/ingest/report.py +84 -0
  106. agentforge_graph/ingest/resolver.py +467 -0
  107. agentforge_graph/ingest/source.py +79 -0
  108. agentforge_graph/knowledge/__init__.py +28 -0
  109. agentforge_graph/knowledge/adr.py +136 -0
  110. agentforge_graph/knowledge/commits.py +152 -0
  111. agentforge_graph/knowledge/ingest.py +312 -0
  112. agentforge_graph/knowledge/mentions.py +71 -0
  113. agentforge_graph/knowledge/report.py +32 -0
  114. agentforge_graph/main.py +21 -0
  115. agentforge_graph/providers.py +36 -0
  116. agentforge_graph/repomap/__init__.py +14 -0
  117. agentforge_graph/repomap/rank.py +161 -0
  118. agentforge_graph/repomap/render.py +55 -0
  119. agentforge_graph/repomap/repomap.py +66 -0
  120. agentforge_graph/retrieve/__init__.py +21 -0
  121. agentforge_graph/retrieve/pack.py +76 -0
  122. agentforge_graph/retrieve/rerank.py +251 -0
  123. agentforge_graph/retrieve/retriever.py +286 -0
  124. agentforge_graph/retrieve/scoring.py +36 -0
  125. agentforge_graph/serve/__init__.py +19 -0
  126. agentforge_graph/serve/engine.py +204 -0
  127. agentforge_graph/serve/http_runner.py +133 -0
  128. agentforge_graph/serve/server.py +110 -0
  129. agentforge_graph/serve/tools.py +307 -0
  130. agentforge_graph/store/__init__.py +32 -0
  131. agentforge_graph/store/_rowmap.py +102 -0
  132. agentforge_graph/store/errors.py +22 -0
  133. agentforge_graph/store/facade.py +89 -0
  134. agentforge_graph/store/kuzu_store.py +380 -0
  135. agentforge_graph/store/lance_store.py +146 -0
  136. agentforge_graph/store/neo4j_store.py +294 -0
  137. agentforge_graph/store/pgvector_store.py +170 -0
  138. agentforge_graph/store/registry.py +45 -0
  139. agentforge_graph/temporal/__init__.py +36 -0
  140. agentforge_graph/temporal/backfill.py +338 -0
  141. agentforge_graph/temporal/events.py +82 -0
  142. agentforge_graph/temporal/index.py +190 -0
  143. agentforge_graph/temporal/mining.py +190 -0
  144. agentforge_graph/temporal/recorder.py +114 -0
  145. agentforge_graph/temporal/store.py +282 -0
  146. agentforge_graph-0.3.2.dist-info/METADATA +291 -0
  147. agentforge_graph-0.3.2.dist-info/RECORD +151 -0
  148. agentforge_graph-0.3.2.dist-info/WHEEL +4 -0
  149. agentforge_graph-0.3.2.dist-info/entry_points.txt +3 -0
  150. agentforge_graph-0.3.2.dist-info/licenses/LICENSE +202 -0
  151. agentforge_graph-0.3.2.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,204 @@
1
+ """Process-lifetime engine holder behind the MCP tools.
2
+
3
+ Opened lazily on first tool call so ``code_graph_tools(".")`` can be built
4
+ synchronously and passed straight to ``Agent(tools=…)`` / the MCP server.
5
+ This module (and the whole ``serve`` package) is the framework-facing layer —
6
+ it may import ``agentforge`` (ADR-0001 exception); the engine packages do not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agentforge_graph.config import (
16
+ EmbedConfig,
17
+ RepoMapConfig,
18
+ RetrieveConfig,
19
+ ServeConfig,
20
+ StoreConfig,
21
+ )
22
+ from agentforge_graph.core import GraphQuery
23
+ from agentforge_graph.embed import embedder_from_config
24
+ from agentforge_graph.ingest import CodeGraph
25
+ from agentforge_graph.repomap import RepoMap
26
+ from agentforge_graph.retrieve import Retriever
27
+
28
+ TOOL_API_VERSION = "1.0"
29
+ _ALL = 10_000_000
30
+
31
+
32
+ def _git_head(repo_path: str | Path) -> str:
33
+ try:
34
+ out = subprocess.run(
35
+ ["git", "-C", str(repo_path), "rev-parse", "HEAD"],
36
+ capture_output=True,
37
+ text=True,
38
+ check=True,
39
+ )
40
+ return out.stdout.strip()
41
+ except (subprocess.SubprocessError, OSError):
42
+ return ""
43
+
44
+
45
+ class _Engine:
46
+ def __init__(self, repo_path: str | Path = ".", config: str | Path | None = None) -> None:
47
+ self.repo_path = repo_path
48
+ self.config = config
49
+ self.serve = ServeConfig.load(config)
50
+ self._cg: CodeGraph | None = None
51
+ self._retriever: Retriever | None = None
52
+ self._repomap: RepoMap | None = None
53
+
54
+ async def code_graph(self) -> CodeGraph:
55
+ if self._cg is None:
56
+ self._cg = await CodeGraph.open(self.repo_path, self.config)
57
+ return self._cg
58
+
59
+ async def retriever(self) -> Retriever:
60
+ if self._retriever is None:
61
+ from agentforge_graph.retrieve.rerank import reranker_from_config
62
+
63
+ cg = await self.code_graph()
64
+ embedder = embedder_from_config(EmbedConfig.load(self.config))
65
+ rcfg = RetrieveConfig.load(self.config)
66
+ # ENH-009: honor retrieve.rerank over MCP too (previously ignored here).
67
+ self._retriever = Retriever(
68
+ cg.store,
69
+ embedder,
70
+ rcfg,
71
+ reranker=reranker_from_config(rcfg.rerank, rcfg.rerank_weight, rcfg.rerank_model),
72
+ )
73
+ return self._retriever
74
+
75
+ async def repomap(self) -> RepoMap:
76
+ if self._repomap is None:
77
+ cg = await self.code_graph()
78
+ self._repomap = RepoMap(cg.store, RepoMapConfig.load(self.config))
79
+ return self._repomap
80
+
81
+ def _meta(self) -> Any:
82
+ from agentforge_graph.ingest.incremental import IndexMeta
83
+
84
+ root = Path(self.repo_path) / StoreConfig.load(self.config).path
85
+ return IndexMeta.load(root)
86
+
87
+ async def staleness(self) -> dict[str, Any]:
88
+ """Cheap envelope: the indexed commit + dirty flag, read from the
89
+ persisted manifest (feat-004) rather than probed from a node."""
90
+ meta = self._meta()
91
+ head = _git_head(self.repo_path)
92
+ return {
93
+ "indexed_commit": meta.indexed_commit,
94
+ "dirty": bool(head) and bool(meta.indexed_commit) and head != meta.indexed_commit,
95
+ }
96
+
97
+ async def routes(self, method: str = "", path: str = "") -> dict[str, Any]:
98
+ """Extracted endpoints (feat-011), optionally filtered by HTTP method
99
+ and/or path prefix, wrapped in the staleness envelope."""
100
+ cg = await self.code_graph()
101
+ items = [r.to_dict() for r in await cg.routes()]
102
+ if method:
103
+ items = [r for r in items if str(r["method"]).upper() == method.upper()]
104
+ if path:
105
+ items = [r for r in items if str(r["path"]).startswith(path)]
106
+ return {
107
+ "routes": items,
108
+ "count": len(items),
109
+ **(await self.staleness()),
110
+ "tool_api_version": TOOL_API_VERSION,
111
+ }
112
+
113
+ async def decisions(self, scope: str = "", status: str = "") -> dict[str, Any]:
114
+ """Architecture decisions (feat-010), optionally filtered by governed
115
+ path ``scope`` and ``status``, wrapped in the staleness envelope."""
116
+ cg = await self.code_graph()
117
+ items = [
118
+ d.to_dict() for d in await cg.decisions(scope=scope or None, status=status or None)
119
+ ]
120
+ return {
121
+ "decisions": items,
122
+ "count": len(items),
123
+ **(await self.staleness()),
124
+ "tool_api_version": TOOL_API_VERSION,
125
+ }
126
+
127
+ async def explain(self, symbol_id: str) -> dict[str, Any]:
128
+ """A symbol's LLM summary + design-pattern tags (feat-012) + its 1-hop
129
+ typed facts — the reserved ckg_explain."""
130
+ from agentforge_graph.core import EdgeKind, NodeKind, SymbolID
131
+
132
+ cg = await self.code_graph()
133
+ node = await cg.store.graph.get(symbol_id)
134
+ tags: list[dict[str, Any]] = []
135
+ facts: list[dict[str, str]] = []
136
+ if node is not None:
137
+ for e in await cg.store.graph.adjacent(symbol_id, [EdgeKind.TAGGED], "out"):
138
+ target = await cg.store.graph.get(e.dst)
139
+ tags.append(
140
+ {
141
+ "pattern": target.name if target else "",
142
+ "confidence": e.attrs.get("confidence", 0.0),
143
+ "rationale": e.attrs.get("rationale", ""),
144
+ }
145
+ )
146
+ for e in await cg.store.graph.adjacent(symbol_id, None, "both"):
147
+ if e.kind is not EdgeKind.TAGGED:
148
+ facts.append({"src": e.src, "dst": e.dst, "kind": e.kind.value})
149
+ # the owning file's summary, if one exists (feat-012)
150
+ summary = ""
151
+ if node is not None:
152
+ path = SymbolID.parse(symbol_id).path
153
+ for n in (
154
+ await cg.store.graph.query(GraphQuery(kinds=[NodeKind.SUMMARY], limit=10**9))
155
+ ).nodes:
156
+ if str(n.attrs.get("level")) == "file" and str(n.attrs.get("path")) == path:
157
+ summary = str(n.attrs.get("text", ""))
158
+ break
159
+ return {
160
+ "symbol_id": symbol_id,
161
+ "name": node.name if node else "",
162
+ "kind": node.kind.value if node else "",
163
+ "summary": summary,
164
+ "tags": tags,
165
+ "facts": facts,
166
+ **(await self.staleness()),
167
+ "tool_api_version": TOOL_API_VERSION,
168
+ }
169
+
170
+ async def history(self, symbol_id: str) -> dict[str, Any]:
171
+ """A symbol's git evolution (feat-009): introduced / last-changed /
172
+ churn / authors / lifecycle events, wrapped in the staleness envelope."""
173
+ cg = await self.code_graph()
174
+ hist = await cg.history(symbol_id)
175
+ body: dict[str, Any] = (
176
+ hist.model_dump() if hist is not None else {"symbol_id": symbol_id, "available": False}
177
+ )
178
+ return {**body, **(await self.staleness()), "tool_api_version": TOOL_API_VERSION}
179
+
180
+ async def status(self) -> dict[str, Any]:
181
+ meta = self._meta()
182
+ cg = await self.code_graph()
183
+ nodes = (await cg.store.graph.query(GraphQuery(limit=_ALL))).nodes
184
+ head = _git_head(self.repo_path)
185
+ dirty = bool(head) and bool(meta.indexed_commit) and head != meta.indexed_commit
186
+ by_kind: dict[str, int] = {}
187
+ for n in nodes:
188
+ by_kind[n.kind.value] = by_kind.get(n.kind.value, 0) + 1
189
+ store_root = Path(self.repo_path) / StoreConfig.load(self.config).path
190
+ return {
191
+ "indexed_commit": meta.indexed_commit,
192
+ "head_commit": head,
193
+ "dirty": dirty,
194
+ "files_indexed": len(meta.files),
195
+ "nodes": len(nodes),
196
+ "by_kind": by_kind,
197
+ "temporal": await cg.temporal_status(),
198
+ "store_path": str(store_root),
199
+ "tool_api_version": TOOL_API_VERSION,
200
+ }
201
+
202
+ async def close(self) -> None:
203
+ if self._cg is not None:
204
+ await self._cg.close()
@@ -0,0 +1,133 @@
1
+ """Bearer-token auth for the HTTP MCP transport (ENH-005).
2
+
3
+ The framework's ``MCPServer.from_http`` serves streamable-HTTP with **no auth** —
4
+ fine for the localhost/trusted-container default, but a wide-open surface on any
5
+ exposed port. ``agentforge-mcp`` exposes no auth hook (see the framework wishlist
6
+ note), but ``from_http(runner=…)`` lets us inject a custom ``MCPServerRunner``.
7
+
8
+ So: the **no-auth** path stays 100% framework (unchanged default). When a token is
9
+ configured, ``serve`` uses :class:`CkgHttpRunner` here — it wires the MCP tools
10
+ exactly like the framework runner, but wraps the Starlette app in
11
+ :class:`BearerAuthMiddleware`, which rejects any request lacking a matching
12
+ ``Authorization: Bearer …`` with ``401`` (constant-time compare; the token is
13
+ never logged). Drop this once the framework grows an auth hook.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hmac
19
+ from typing import Any
20
+
21
+ # Hosts that don't need auth-by-default (the client owns the loopback surface).
22
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"})
23
+
24
+
25
+ def is_loopback(host: str) -> bool:
26
+ return host in LOOPBACK_HOSTS
27
+
28
+
29
+ class BearerAuthMiddleware:
30
+ """Pure-ASGI middleware: require ``Authorization: Bearer <token>`` on every
31
+ HTTP request, else ``401``. Non-HTTP scopes (lifespan) pass through."""
32
+
33
+ def __init__(self, app: Any, token: str) -> None:
34
+ self._app = app
35
+ self._expected = f"Bearer {token}"
36
+
37
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
38
+ if scope["type"] != "http":
39
+ await self._app(scope, receive, send)
40
+ return
41
+ provided = ""
42
+ for name, value in scope.get("headers", []):
43
+ if name == b"authorization":
44
+ provided = value.decode("latin-1")
45
+ break
46
+ # constant-time compare so a wrong token can't be timed out char by char.
47
+ if not (provided and hmac.compare_digest(provided, self._expected)):
48
+ await _send_401(send)
49
+ return
50
+ await self._app(scope, receive, send)
51
+
52
+
53
+ async def _send_401(send: Any) -> None:
54
+ body = b'{"error":"unauthorized"}'
55
+ await send(
56
+ {
57
+ "type": "http.response.start",
58
+ "status": 401,
59
+ "headers": [
60
+ (b"content-type", b"application/json"),
61
+ (b"www-authenticate", b"Bearer"),
62
+ (b"content-length", str(len(body)).encode()),
63
+ ],
64
+ }
65
+ )
66
+ await send({"type": "http.response.body", "body": body})
67
+
68
+
69
+ class CkgHttpRunner:
70
+ """An ``MCPServerRunner`` that serves the streamable-HTTP MCP transport with
71
+ a bearer-token gate. Mirrors the framework's HTTP runner wiring (list/call
72
+ tool dispatch + ``StreamableHTTPSessionManager`` under uvicorn) and wraps the
73
+ app in :class:`BearerAuthMiddleware`."""
74
+
75
+ def __init__(self, *, host: str, port: int, token: str, server_name: str = "ckg") -> None:
76
+ from mcp.server import Server
77
+
78
+ self._server = Server(server_name)
79
+ self._host = host
80
+ self._port = port
81
+ self._token = token
82
+ self._tools: dict[str, tuple[str, str, dict[str, Any], Any]] = {}
83
+ self._uv_server: Any = None
84
+
85
+ def register_tool(
86
+ self, name: str, description: str, input_schema: dict[str, Any], handler: Any
87
+ ) -> None:
88
+ self._tools[name] = (name, description, dict(input_schema or {}), handler)
89
+
90
+ async def serve(self) -> None:
91
+ import contextlib
92
+
93
+ import uvicorn
94
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
95
+ from mcp.types import TextContent
96
+ from mcp.types import Tool as MCPTool
97
+ from starlette.applications import Starlette
98
+ from starlette.routing import Mount
99
+
100
+ registered = self._tools
101
+
102
+ @self._server.list_tools() # type: ignore[no-untyped-call, untyped-decorator]
103
+ async def _list() -> list[MCPTool]:
104
+ return [
105
+ MCPTool(name=n, description=d, inputSchema=s) for n, d, s, _ in registered.values()
106
+ ]
107
+
108
+ @self._server.call_tool() # type: ignore[untyped-decorator]
109
+ async def _call(name: str, arguments: dict[str, Any]) -> list[TextContent]:
110
+ tool = registered.get(name)
111
+ if tool is None:
112
+ raise ValueError(f"MCP server: unknown tool {name!r}")
113
+ return [TextContent(type="text", text=await tool[3](arguments))]
114
+
115
+ manager = StreamableHTTPSessionManager(app=self._server, json_response=True, stateless=True)
116
+
117
+ async def _asgi(scope: Any, receive: Any, send: Any) -> None:
118
+ await manager.handle_request(scope, receive, send)
119
+
120
+ @contextlib.asynccontextmanager
121
+ async def _lifespan(_app: Any) -> Any:
122
+ async with manager.run():
123
+ yield
124
+
125
+ app = Starlette(routes=[Mount("/mcp", app=_asgi)], lifespan=_lifespan)
126
+ guarded = BearerAuthMiddleware(app, self._token)
127
+ config = uvicorn.Config(guarded, host=self._host, port=self._port, log_level="warning")
128
+ self._uv_server = uvicorn.Server(config)
129
+ await self._uv_server.serve()
130
+
131
+ async def stop(self) -> None:
132
+ if self._uv_server is not None:
133
+ self._uv_server.should_exit = True
@@ -0,0 +1,110 @@
1
+ """Dual binding: the same nine ``Tool`` instances power both
2
+ ``code_graph_tools`` (for ``Agent(tools=…)``) and ``serve_mcp`` (an MCP server).
3
+ One definition, every call site — no toolset drift.
4
+
5
+ The MCP server runs over two transports (feat-008):
6
+
7
+ - **stdio** (default) — the client launches ``ckg serve-mcp`` as a subprocess
8
+ (``command``/``args`` in an ``mcpServers`` block; ``claude mcp add``).
9
+ - **http** — a long-running streamable-HTTP server (mounted at ``/mcp`` under
10
+ uvicorn) that clients connect to by ``url``. Same tools, same guardrails.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ from agentforge_core.contracts.tool import Tool
20
+ from agentforge_mcp import MCPServer
21
+
22
+ from .engine import _Engine
23
+ from .http_runner import CkgHttpRunner, is_loopback
24
+ from .tools import ALL_TOOLS
25
+
26
+ Transport = Literal["stdio", "http"]
27
+
28
+
29
+ def code_graph_tools(repo_path: str | Path = ".", config: str | Path | None = None) -> list[Tool]:
30
+ """The CKG toolset as native AgentForge ``Tool`` instances, sharing one
31
+ lazily-opened engine. Pass straight to ``Agent(tools=code_graph_tools("."))``."""
32
+ engine = _Engine(repo_path, config)
33
+ # ALL_TOOLS holds concrete Tool subclasses; mypy joins them to the abstract
34
+ # base and can't see that, hence the abstract ignore.
35
+ return [tool_cls(engine) for tool_cls in ALL_TOOLS] # type: ignore[abstract]
36
+
37
+
38
+ def build_mcp_server(
39
+ repo_path: str | Path = ".",
40
+ config: str | Path | None = None,
41
+ *,
42
+ transport: Transport = "stdio",
43
+ host: str = "127.0.0.1",
44
+ port: int = 8765,
45
+ refresh_on_call: bool = False,
46
+ auth_token: str = "",
47
+ allow_unauthenticated: bool = False,
48
+ ) -> MCPServer:
49
+ """Build (but don't serve) the MCP server with the CKG tools, over the
50
+ chosen ``transport`` (``stdio`` default, or ``http`` at ``host:port``).
51
+
52
+ HTTP auth (ENH-005): ``auth_token`` (or ``$CKG_HTTP_AUTH_TOKEN``) requires a
53
+ matching ``Authorization: Bearer …`` on every request — off by default to
54
+ preserve the localhost loop. Binding a **non-loopback** host with no token is
55
+ refused unless ``allow_unauthenticated`` (a loud, deliberate opt-in), so an
56
+ exposed port is never silently wide open. ``refresh_on_call`` is a no-op at
57
+ 0.1 (tools are read-only; cheap refresh is feat-004)."""
58
+ if transport not in ("stdio", "http"):
59
+ msg = f"unknown MCP transport {transport!r}; use 'stdio' or 'http'"
60
+ raise ValueError(msg)
61
+ # Resolve + validate auth before opening the engine, so a misconfigured bind
62
+ # fails fast (not after the index is loaded).
63
+ token = auth_token or os.environ.get("CKG_HTTP_AUTH_TOKEN", "")
64
+ if transport == "http" and not token and not is_loopback(host) and not allow_unauthenticated:
65
+ msg = (
66
+ f"refusing to serve the HTTP MCP transport on non-loopback host {host!r} "
67
+ "with no auth: set serve.http_auth_token / $CKG_HTTP_AUTH_TOKEN, or pass "
68
+ "--allow-unauthenticated to bind it open on purpose"
69
+ )
70
+ raise ValueError(msg)
71
+ tools = code_graph_tools(repo_path, config)
72
+ allowed = tuple(t.name for t in tools)
73
+ if transport == "stdio":
74
+ # no auth: the client owns the subprocess (stdin/stdout).
75
+ return MCPServer.from_stdio(tools=tools, allowed=allowed, server_name="ckg")
76
+ if token:
77
+ runner = CkgHttpRunner(host=host, port=port, token=token)
78
+ return MCPServer.from_http(
79
+ tools=tools, host=host, port=port, allowed=allowed, server_name="ckg", runner=runner
80
+ )
81
+ return MCPServer.from_http(
82
+ tools=tools, host=host, port=port, allowed=allowed, server_name="ckg"
83
+ )
84
+
85
+
86
+ async def serve_mcp(
87
+ repo_path: str | Path = ".",
88
+ config: str | Path | None = None,
89
+ *,
90
+ transport: Transport = "stdio",
91
+ host: str = "127.0.0.1",
92
+ port: int = 8765,
93
+ refresh_on_call: bool = False,
94
+ auth_token: str = "",
95
+ allow_unauthenticated: bool = False,
96
+ ) -> None:
97
+ """Run the CKG MCP server (blocks until stopped). ``transport='http'`` serves
98
+ streamable-HTTP at ``http://{host}:{port}/mcp``; the default ``stdio`` serves
99
+ over stdin/stdout for a subprocess client. See ``build_mcp_server`` for the
100
+ ``auth_token`` / ``allow_unauthenticated`` HTTP-auth semantics (ENH-005)."""
101
+ await build_mcp_server(
102
+ repo_path,
103
+ config,
104
+ transport=transport,
105
+ host=host,
106
+ port=port,
107
+ refresh_on_call=refresh_on_call,
108
+ auth_token=auth_token,
109
+ allow_unauthenticated=allow_unauthenticated,
110
+ ).serve()