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.
- agentforge_graph/__init__.py +6 -0
- agentforge_graph/chunking/__init__.py +12 -0
- agentforge_graph/chunking/cast.py +159 -0
- agentforge_graph/chunking/chunk.py +19 -0
- agentforge_graph/chunking/tokens.py +15 -0
- agentforge_graph/cli.py +607 -0
- agentforge_graph/config.py +259 -0
- agentforge_graph/core/__init__.py +54 -0
- agentforge_graph/core/conformance.py +270 -0
- agentforge_graph/core/contracts.py +163 -0
- agentforge_graph/core/kinds.py +68 -0
- agentforge_graph/core/models.py +134 -0
- agentforge_graph/core/provenance.py +62 -0
- agentforge_graph/core/symbols.py +116 -0
- agentforge_graph/embed/__init__.py +28 -0
- agentforge_graph/embed/base.py +22 -0
- agentforge_graph/embed/bedrock.py +85 -0
- agentforge_graph/embed/fake.py +34 -0
- agentforge_graph/embed/openai.py +67 -0
- agentforge_graph/embed/pipeline.py +184 -0
- agentforge_graph/embed/registry.py +66 -0
- agentforge_graph/embed/report.py +15 -0
- agentforge_graph/enrich/__init__.py +70 -0
- agentforge_graph/enrich/anthropic.py +38 -0
- agentforge_graph/enrich/anthropic_client.py +109 -0
- agentforge_graph/enrich/bedrock.py +24 -0
- agentforge_graph/enrich/bedrock_client.py +115 -0
- agentforge_graph/enrich/bedrock_summarizer.py +23 -0
- agentforge_graph/enrich/claude.py +172 -0
- agentforge_graph/enrich/enricher.py +108 -0
- agentforge_graph/enrich/governs.py +173 -0
- agentforge_graph/enrich/governs_enricher.py +152 -0
- agentforge_graph/enrich/heuristics.py +224 -0
- agentforge_graph/enrich/judge.py +63 -0
- agentforge_graph/enrich/registry.py +133 -0
- agentforge_graph/enrich/report.py +60 -0
- agentforge_graph/enrich/summarizer.py +62 -0
- agentforge_graph/enrich/summary_enricher.py +211 -0
- agentforge_graph/enrich/taxonomy.py +38 -0
- agentforge_graph/frameworks/__init__.py +29 -0
- agentforge_graph/frameworks/base.py +75 -0
- agentforge_graph/frameworks/detect.py +124 -0
- agentforge_graph/frameworks/extractor.py +63 -0
- agentforge_graph/frameworks/orm.py +93 -0
- agentforge_graph/frameworks/packs/_js_ast.py +56 -0
- agentforge_graph/frameworks/packs/_python_ast.py +157 -0
- agentforge_graph/frameworks/packs/django/__init__.py +240 -0
- agentforge_graph/frameworks/packs/django/models.scm +7 -0
- agentforge_graph/frameworks/packs/express/__init__.py +133 -0
- agentforge_graph/frameworks/packs/express/routes.scm +8 -0
- agentforge_graph/frameworks/packs/fastapi/__init__.py +210 -0
- agentforge_graph/frameworks/packs/fastapi/depends.scm +6 -0
- agentforge_graph/frameworks/packs/fastapi/routes.scm +10 -0
- agentforge_graph/frameworks/packs/flask/__init__.py +143 -0
- agentforge_graph/frameworks/packs/flask/routes.scm +11 -0
- agentforge_graph/frameworks/packs/nestjs/__init__.py +205 -0
- agentforge_graph/frameworks/packs/nestjs/routes.scm +6 -0
- agentforge_graph/frameworks/packs/spring/__init__.py +267 -0
- agentforge_graph/frameworks/packs/spring/routes.scm +6 -0
- agentforge_graph/frameworks/packs/sqlalchemy/__init__.py +250 -0
- agentforge_graph/frameworks/packs/sqlalchemy/models.scm +7 -0
- agentforge_graph/frameworks/registry.py +44 -0
- agentforge_graph/ingest/__init__.py +30 -0
- agentforge_graph/ingest/codegraph.py +847 -0
- agentforge_graph/ingest/extractor.py +353 -0
- agentforge_graph/ingest/incremental/__init__.py +25 -0
- agentforge_graph/ingest/incremental/detect.py +118 -0
- agentforge_graph/ingest/incremental/dirty.py +61 -0
- agentforge_graph/ingest/incremental/indexer.py +218 -0
- agentforge_graph/ingest/incremental/meta.py +72 -0
- agentforge_graph/ingest/incremental/ports.py +39 -0
- agentforge_graph/ingest/pack.py +160 -0
- agentforge_graph/ingest/packs/__init__.py +34 -0
- agentforge_graph/ingest/packs/cpp/__init__.py +35 -0
- agentforge_graph/ingest/packs/cpp/references.scm +15 -0
- agentforge_graph/ingest/packs/cpp/structure.scm +49 -0
- agentforge_graph/ingest/packs/csharp/__init__.py +35 -0
- agentforge_graph/ingest/packs/csharp/references.scm +12 -0
- agentforge_graph/ingest/packs/csharp/structure.scm +45 -0
- agentforge_graph/ingest/packs/go/__init__.py +38 -0
- agentforge_graph/ingest/packs/go/references.scm +12 -0
- agentforge_graph/ingest/packs/go/structure.scm +64 -0
- agentforge_graph/ingest/packs/java/__init__.py +35 -0
- agentforge_graph/ingest/packs/java/references.scm +12 -0
- agentforge_graph/ingest/packs/java/structure.scm +38 -0
- agentforge_graph/ingest/packs/javascript/__init__.py +34 -0
- agentforge_graph/ingest/packs/javascript/references.scm +11 -0
- agentforge_graph/ingest/packs/javascript/structure.scm +166 -0
- agentforge_graph/ingest/packs/php/__init__.py +35 -0
- agentforge_graph/ingest/packs/php/references.scm +15 -0
- agentforge_graph/ingest/packs/php/structure.scm +44 -0
- agentforge_graph/ingest/packs/python/__init__.py +25 -0
- agentforge_graph/ingest/packs/python/references.scm +14 -0
- agentforge_graph/ingest/packs/python/structure.scm +57 -0
- agentforge_graph/ingest/packs/ruby/__init__.py +37 -0
- agentforge_graph/ingest/packs/ruby/references.scm +12 -0
- agentforge_graph/ingest/packs/ruby/structure.scm +37 -0
- agentforge_graph/ingest/packs/rust/__init__.py +39 -0
- agentforge_graph/ingest/packs/rust/references.scm +12 -0
- agentforge_graph/ingest/packs/rust/structure.scm +46 -0
- agentforge_graph/ingest/packs/typescript/__init__.py +31 -0
- agentforge_graph/ingest/packs/typescript/references.scm +11 -0
- agentforge_graph/ingest/packs/typescript/structure.scm +99 -0
- agentforge_graph/ingest/pipeline.py +134 -0
- agentforge_graph/ingest/report.py +84 -0
- agentforge_graph/ingest/resolver.py +467 -0
- agentforge_graph/ingest/source.py +79 -0
- agentforge_graph/knowledge/__init__.py +28 -0
- agentforge_graph/knowledge/adr.py +136 -0
- agentforge_graph/knowledge/commits.py +152 -0
- agentforge_graph/knowledge/ingest.py +312 -0
- agentforge_graph/knowledge/mentions.py +71 -0
- agentforge_graph/knowledge/report.py +32 -0
- agentforge_graph/main.py +21 -0
- agentforge_graph/providers.py +36 -0
- agentforge_graph/repomap/__init__.py +14 -0
- agentforge_graph/repomap/rank.py +161 -0
- agentforge_graph/repomap/render.py +55 -0
- agentforge_graph/repomap/repomap.py +66 -0
- agentforge_graph/retrieve/__init__.py +21 -0
- agentforge_graph/retrieve/pack.py +76 -0
- agentforge_graph/retrieve/rerank.py +251 -0
- agentforge_graph/retrieve/retriever.py +286 -0
- agentforge_graph/retrieve/scoring.py +36 -0
- agentforge_graph/serve/__init__.py +19 -0
- agentforge_graph/serve/engine.py +204 -0
- agentforge_graph/serve/http_runner.py +133 -0
- agentforge_graph/serve/server.py +110 -0
- agentforge_graph/serve/tools.py +307 -0
- agentforge_graph/store/__init__.py +32 -0
- agentforge_graph/store/_rowmap.py +102 -0
- agentforge_graph/store/errors.py +22 -0
- agentforge_graph/store/facade.py +89 -0
- agentforge_graph/store/kuzu_store.py +380 -0
- agentforge_graph/store/lance_store.py +146 -0
- agentforge_graph/store/neo4j_store.py +294 -0
- agentforge_graph/store/pgvector_store.py +170 -0
- agentforge_graph/store/registry.py +45 -0
- agentforge_graph/temporal/__init__.py +36 -0
- agentforge_graph/temporal/backfill.py +338 -0
- agentforge_graph/temporal/events.py +82 -0
- agentforge_graph/temporal/index.py +190 -0
- agentforge_graph/temporal/mining.py +190 -0
- agentforge_graph/temporal/recorder.py +114 -0
- agentforge_graph/temporal/store.py +282 -0
- agentforge_graph-0.3.2.dist-info/METADATA +291 -0
- agentforge_graph-0.3.2.dist-info/RECORD +151 -0
- agentforge_graph-0.3.2.dist-info/WHEEL +4 -0
- agentforge_graph-0.3.2.dist-info/entry_points.txt +3 -0
- agentforge_graph-0.3.2.dist-info/licenses/LICENSE +202 -0
- 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()
|