graphify-mesh 0.0.22__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.
- graphify_mesh/__init__.py +20 -0
- graphify_mesh/_version.py +24 -0
- graphify_mesh/server/__init__.py +61 -0
- graphify_mesh/server/anchors.py +223 -0
- graphify_mesh/server/citation.py +17 -0
- graphify_mesh/server/config.py +195 -0
- graphify_mesh/server/context_pack.py +212 -0
- graphify_mesh/server/embed_query.py +206 -0
- graphify_mesh/server/frames.py +93 -0
- graphify_mesh/server/http_app.py +367 -0
- graphify_mesh/server/lexical_read.py +208 -0
- graphify_mesh/server/project_map.py +92 -0
- graphify_mesh/server/ranking.py +209 -0
- graphify_mesh/server/retrieval.py +343 -0
- graphify_mesh/server/rwlock.py +61 -0
- graphify_mesh/server/scope.py +207 -0
- graphify_mesh/server/sdk_app.py +108 -0
- graphify_mesh/server/server.py +813 -0
- graphify_mesh/server/similar.py +284 -0
- graphify_mesh/server/stdio_guard.py +287 -0
- graphify_mesh/server/store.py +733 -0
- graphify_mesh/server/traverse.py +305 -0
- graphify_mesh/sync/__init__.py +26 -0
- graphify_mesh/sync/backend.py +80 -0
- graphify_mesh/sync/cli.py +249 -0
- graphify_mesh/sync/clustering.py +182 -0
- graphify_mesh/sync/config.py +815 -0
- graphify_mesh/sync/discovery.py +668 -0
- graphify_mesh/sync/embed_similarity.py +516 -0
- graphify_mesh/sync/embedding.py +1372 -0
- graphify_mesh/sync/graphify_cli.py +941 -0
- graphify_mesh/sync/lexical_index.py +293 -0
- graphify_mesh/sync/locking.py +30 -0
- graphify_mesh/sync/naming.py +563 -0
- graphify_mesh/sync/naming_state.py +143 -0
- graphify_mesh/sync/overlay.py +146 -0
- graphify_mesh/sync/overlay_api.py +312 -0
- graphify_mesh/sync/overlay_depends.py +324 -0
- graphify_mesh/sync/overlay_refs.py +143 -0
- graphify_mesh/sync/overlay_similar.py +357 -0
- graphify_mesh/sync/perms.py +116 -0
- graphify_mesh/sync/pipeline.py +1891 -0
- graphify_mesh/sync/progress.py +41 -0
- graphify_mesh/sync/publish.py +374 -0
- graphify_mesh/sync/reaper.py +201 -0
- graphify_mesh/sync/reaper_cli.py +62 -0
- graphify_mesh/sync/registry.py +184 -0
- graphify_mesh/sync/repo_tags.py +299 -0
- graphify_mesh/sync/rss.py +68 -0
- graphify_mesh/sync/source_cache.py +149 -0
- graphify_mesh/sync/state.py +268 -0
- graphify_mesh/sync/sync_project.py +793 -0
- graphify_mesh/sync/tls.py +119 -0
- graphify_mesh/sync/validate.py +277 -0
- graphify_mesh/sync/vectors.py +234 -0
- graphify_mesh-0.0.22.dist-info/METADATA +219 -0
- graphify_mesh-0.0.22.dist-info/RECORD +60 -0
- graphify_mesh-0.0.22.dist-info/WHEEL +4 -0
- graphify_mesh-0.0.22.dist-info/entry_points.txt +4 -0
- graphify_mesh-0.0.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""graphify-mesh — a scheduled sync engine + companion MCP server that merges
|
|
2
|
+
many per-repo `graphify` knowledge graphs into one safe, repo-attributed,
|
|
3
|
+
human-named global graph with a cross-project dependency/similarity/API overlay.
|
|
4
|
+
|
|
5
|
+
See the `sync` subpackage for the merge/naming/embed/overlay/publish pipeline
|
|
6
|
+
and the `server` subpackage for the stdio MCP query server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
# Generated by hatch-vcs at build time from the git tag (see
|
|
13
|
+
# [tool.hatch.build.hooks.vcs] in pyproject.toml) — never hand-edit
|
|
14
|
+
# _version.py, and never hand-edit a version string here either.
|
|
15
|
+
from graphify_mesh._version import __version__
|
|
16
|
+
except ImportError:
|
|
17
|
+
# No git tags reachable and no build has run yet (e.g. a shallow clone
|
|
18
|
+
# with no tags, or source copied without git metadata) — better than a
|
|
19
|
+
# crash on import, but callers should not trust this as a real version.
|
|
20
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.22'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 22)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""graphify-mesh: companion stdio MCP server for the merged global graph.
|
|
2
|
+
|
|
3
|
+
Two constraints are the most load-bearing for this package: the `project_map`
|
|
4
|
+
input must resolve a registered `repo_id` (never an arbitrary on-disk path),
|
|
5
|
+
and a generation is only ever served whole once its manifest passes a
|
|
6
|
+
consistency check.
|
|
7
|
+
|
|
8
|
+
## Dependency decision
|
|
9
|
+
|
|
10
|
+
This server is implemented with the Python STANDARD LIBRARY ONLY — no `mcp`
|
|
11
|
+
SDK package, no third-party JSON-RPC/transport library, and therefore no
|
|
12
|
+
dedicated virtualenv is required. It runs under the same interpreter as
|
|
13
|
+
`graphify_mesh.sync`.
|
|
14
|
+
|
|
15
|
+
This section is historical: the server has since moved onto the official
|
|
16
|
+
`mcp` SDK (`server/sdk_app.py`, `server/stdio_guard.py`) for its stdio
|
|
17
|
+
transport, because a second transport (streamable HTTP, for the shared
|
|
18
|
+
daemon) needed a real MCP session/protocol implementation rather than a
|
|
19
|
+
hand-rolled newline-delimited JSON-RPC loop. The size-cap guard the old
|
|
20
|
+
hand-rolled reader provided (`MAX_LINE_BYTES`) is carried forward in
|
|
21
|
+
`stdio_guard.capped_stdin`, since the SDK's own stdio reader has no such
|
|
22
|
+
bound.
|
|
23
|
+
|
|
24
|
+
## Two MCP servers: this one vs graphify's own
|
|
25
|
+
|
|
26
|
+
graphify's own MCP server (`graphify.serve`) can be repointed to read the
|
|
27
|
+
PUBLISHED `<mesh_root>/graphify/global/current/global-graph.json` for
|
|
28
|
+
STRUCTURAL/PR tools only: `god_nodes`, `shortest_path`, `get_pr_impact`,
|
|
29
|
+
`get_community`, `get_neighbors`, `get_node`, `graph_stats`, `list_prs`,
|
|
30
|
+
`triage_prs`. Those tools operate purely on the structural graph shape and
|
|
31
|
+
gain nothing from this package's scope/fusion/overlay logic — repointing them
|
|
32
|
+
is a config change (point at the new published path instead of
|
|
33
|
+
`~/.graphify/global-graph.json`), not a rewrite.
|
|
34
|
+
|
|
35
|
+
graphify-mesh (this package) owns everything hybrid/cross-project/evidence-
|
|
36
|
+
oriented: `search`, `cross_project`, `find_similar`, `project_map`,
|
|
37
|
+
`context_pack`. These need scope resolution, the lexical index, the embedding
|
|
38
|
+
index, and the cross-project overlay — none of which graphify's own server
|
|
39
|
+
has access to.
|
|
40
|
+
|
|
41
|
+
**By design, the two servers can show different community names** for the
|
|
42
|
+
same node: graphify's server reads the published graph's `community_name`
|
|
43
|
+
attribute directly, but graphify-mesh's naming stage strips and relabels
|
|
44
|
+
community names during the sync pipeline (see
|
|
45
|
+
`graphify_mesh.sync.naming.strip_project_community_attrs`). Historical caches
|
|
46
|
+
or a not-yet-repointed upstream instance may still show an old per-project
|
|
47
|
+
name for a transitional period. This is not a bug to reconcile; it is a known
|
|
48
|
+
consequence of the relabeling running only in this pipeline. Route
|
|
49
|
+
accordingly: local per-project structural questions and PR tools -> graphify's
|
|
50
|
+
server; concept/cross-project/evidence-pack questions -> graphify-mesh.
|
|
51
|
+
|
|
52
|
+
## No arbitrary-path caching
|
|
53
|
+
|
|
54
|
+
This server never exposes a "give me any path on disk and I'll load/cache it"
|
|
55
|
+
parameter. `project_map(repo)` takes a `repo_id` that MUST resolve against the
|
|
56
|
+
registry (see `scope.py`) — there is no arbitrary-path acceptance and
|
|
57
|
+
therefore no unbounded per-path cache: the only thing ever cached is the
|
|
58
|
+
single current *generation* (`store.py`'s `GenerationStore`), reloaded
|
|
59
|
+
wholesale (never partially) on hot-reload and not keyed by caller-supplied
|
|
60
|
+
paths at all.
|
|
61
|
+
"""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Symbol-anchored ranking for `search`.
|
|
2
|
+
|
|
3
|
+
A multi-token query often names one symbol (`export`, `AbstractCronJob`) plus
|
|
4
|
+
words describing its context. Each query token that resolves through the
|
|
5
|
+
exact-alias table to a RARE alias (few members) yields anchor candidates. A
|
|
6
|
+
candidate is scored by the idf of the OTHER query tokens found in its own
|
|
7
|
+
lexical fields and in its depth-1 EXTRACTED neighbours' fields. Only a
|
|
8
|
+
candidate that clears an absolute floor AND dominates the next one is pinned
|
|
9
|
+
right after the exact-alias hits; otherwise ranking is left untouched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import weakref
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from graphify_mesh.server import lexical_read, ranking
|
|
19
|
+
from graphify_mesh.server.store import Generation
|
|
20
|
+
from graphify_mesh.sync.lexical_index import normalize_alias_query, tokenize_text
|
|
21
|
+
|
|
22
|
+
# An alias form yields candidates only with 1..N members after the repo
|
|
23
|
+
# filter. Rarity is the alias member count, NOT the token's document
|
|
24
|
+
# frequency: a symbol name such as `export` can have the highest df in the
|
|
25
|
+
# query while its `.export()` alias still names only a handful of methods.
|
|
26
|
+
ANCHOR_MAX_ALIAS_MEMBERS = 10
|
|
27
|
+
|
|
28
|
+
# Absolute score floor a pinned anchor must reach, in idf units.
|
|
29
|
+
ANCHOR_MIN_SCORE = 10.0
|
|
30
|
+
|
|
31
|
+
# A pinned anchor must score at least this multiple of the next candidate.
|
|
32
|
+
ANCHOR_DOMINANCE = 2.0
|
|
33
|
+
|
|
34
|
+
ANCHOR_MAX_PINS = 2
|
|
35
|
+
|
|
36
|
+
# Score carried by anchor hits: below `retrieval.EXACT_MATCH_SCORE` so exact
|
|
37
|
+
# hits stay first, above every fused score (RRF sums stay below 1.0).
|
|
38
|
+
ANCHOR_MATCH_SCORE = 500_000.0
|
|
39
|
+
|
|
40
|
+
# Query tokens are caller-chosen, so the per-generation df memo is bounded;
|
|
41
|
+
# past the cap it is cleared and refilled.
|
|
42
|
+
_DOC_FREQ_CACHE_MAX = 4096
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _GenerationCache:
|
|
47
|
+
generation_id: str
|
|
48
|
+
# Identity check on top of the id: a weakref, so a replaced generation is
|
|
49
|
+
# never kept alive by this cache.
|
|
50
|
+
generation: weakref.ref[Generation]
|
|
51
|
+
doc_ids_by_key: dict[str, list[int]] | None
|
|
52
|
+
doc_freq: dict[str, int] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Only the most recent generation's cache is kept. Concurrent readers may
|
|
56
|
+
# race on this assignment; the loser just rebuilds, the content is the same,
|
|
57
|
+
# so no lock is needed.
|
|
58
|
+
_cache: _GenerationCache | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _generation_cache(generation: Generation) -> _GenerationCache:
|
|
62
|
+
global _cache
|
|
63
|
+
cache = _cache
|
|
64
|
+
if (
|
|
65
|
+
cache is None
|
|
66
|
+
or cache.generation_id != generation.generation_id
|
|
67
|
+
or cache.generation() is not generation
|
|
68
|
+
):
|
|
69
|
+
cache = _GenerationCache(
|
|
70
|
+
generation_id=generation.generation_id,
|
|
71
|
+
generation=weakref.ref(generation),
|
|
72
|
+
doc_ids_by_key=lexical_read.doc_ids_by_key(generation.lexical),
|
|
73
|
+
)
|
|
74
|
+
_cache = cache
|
|
75
|
+
return cache
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _doc_freq(token: str, generation: Generation, cache: _GenerationCache) -> int:
|
|
79
|
+
df = cache.doc_freq.get(token)
|
|
80
|
+
if df is None:
|
|
81
|
+
df = lexical_read.term_doc_freq(generation.lexical, token)
|
|
82
|
+
if len(cache.doc_freq) >= _DOC_FREQ_CACHE_MAX:
|
|
83
|
+
cache.doc_freq.clear()
|
|
84
|
+
cache.doc_freq[token] = df
|
|
85
|
+
return df
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _alias_members(
|
|
89
|
+
form: str, generation: Generation, repo_filter: frozenset[str] | None
|
|
90
|
+
) -> set[str]:
|
|
91
|
+
refs = lexical_read.alias_refs(generation.lexical, normalize_alias_query(form))
|
|
92
|
+
return {key for repo, key in refs if repo_filter is None or repo in repo_filter}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _neighbor_keys(
|
|
96
|
+
key: str, generation: Generation, repo_filter: frozenset[str] | None
|
|
97
|
+
) -> set[str]:
|
|
98
|
+
node_id = generation.node_id_by_key.get(key)
|
|
99
|
+
if node_id is None:
|
|
100
|
+
return set()
|
|
101
|
+
keys: set[str] = set()
|
|
102
|
+
for neighbor_id, edge in generation.adjacency.get(node_id, []):
|
|
103
|
+
# Ignores the caller's `include_inferred` on purpose: a pin must rest
|
|
104
|
+
# on EXTRACTED evidence only.
|
|
105
|
+
if not ranking.edge_followable(edge, include_inferred=False):
|
|
106
|
+
continue
|
|
107
|
+
neighbor_key = generation.key_by_node_id.get(neighbor_id)
|
|
108
|
+
if neighbor_key is None:
|
|
109
|
+
continue
|
|
110
|
+
neighbor_node = generation.node_by_id.get(neighbor_id, {})
|
|
111
|
+
if repo_filter is not None and neighbor_node.get("repo") not in repo_filter:
|
|
112
|
+
continue
|
|
113
|
+
keys.add(neighbor_key)
|
|
114
|
+
return keys
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _full_token_keys(
|
|
118
|
+
token: str, generation: Generation, repo_filter: frozenset[str] | None
|
|
119
|
+
) -> set[str]:
|
|
120
|
+
"""Every key `token` has a posting for (v2 path)."""
|
|
121
|
+
return {
|
|
122
|
+
key
|
|
123
|
+
for repo, key, _field in lexical_read.term_postings(generation.lexical, token)
|
|
124
|
+
if key is not None and (repo_filter is None or repo in repo_filter)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _probe_token_keys(
|
|
129
|
+
token: str, generation: Generation, key_docs: dict[str, list[int]], doc_ids: set[int]
|
|
130
|
+
) -> set[str]:
|
|
131
|
+
"""The probe keys `token` has a posting for (v3 path). Probe keys are
|
|
132
|
+
already repo-filtered, so no posting-level filter is needed."""
|
|
133
|
+
hits = lexical_read.term_doc_hits(generation.lexical, token, doc_ids)
|
|
134
|
+
return {key for key, ids in key_docs.items() if not hits.isdisjoint(ids)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _candidate_scores(
|
|
138
|
+
tokens: list[str],
|
|
139
|
+
via: dict[str, set[str]],
|
|
140
|
+
generation: Generation,
|
|
141
|
+
repo_filter: frozenset[str] | None,
|
|
142
|
+
) -> list[tuple[str, float]]:
|
|
143
|
+
"""Candidates with their idf score, best first. Only candidates and
|
|
144
|
+
their neighbours are ever tested for membership, so on v3 the posting
|
|
145
|
+
lookup is restricted to those keys instead of every posting."""
|
|
146
|
+
cache = _generation_cache(generation)
|
|
147
|
+
neighbors_by_key = {key: _neighbor_keys(key, generation, repo_filter) for key in via}
|
|
148
|
+
needed = [t for t in tokens if any(t not in via_tokens for via_tokens in via.values())]
|
|
149
|
+
|
|
150
|
+
# idf is corpus-wide by design (one df table per generation), not per
|
|
151
|
+
# scope, so under `repo:<id>` it and ANCHOR_MIN_SCORE stay corpus-relative.
|
|
152
|
+
# Clamped at 0: a v2 index without `doc_freq.global` counts field
|
|
153
|
+
# postings, so df can exceed total_docs.
|
|
154
|
+
total_docs = lexical_read.document_count(generation.lexical)
|
|
155
|
+
token_idf: dict[str, float] = {}
|
|
156
|
+
for token in needed:
|
|
157
|
+
df = _doc_freq(token, generation, cache)
|
|
158
|
+
token_idf[token] = max(0.0, math.log(total_docs / df)) if df > 0 and total_docs > 0 else 0.0
|
|
159
|
+
|
|
160
|
+
token_keys: dict[str, set[str]] = {}
|
|
161
|
+
if cache.doc_ids_by_key is None:
|
|
162
|
+
for token in needed:
|
|
163
|
+
token_keys[token] = _full_token_keys(token, generation, repo_filter)
|
|
164
|
+
else:
|
|
165
|
+
probe = set(via).union(*neighbors_by_key.values())
|
|
166
|
+
key_docs = {key: cache.doc_ids_by_key[key] for key in probe if key in cache.doc_ids_by_key}
|
|
167
|
+
doc_ids = {doc_id for ids in key_docs.values() for doc_id in ids}
|
|
168
|
+
for token in needed:
|
|
169
|
+
token_keys[token] = _probe_token_keys(token, generation, key_docs, doc_ids)
|
|
170
|
+
|
|
171
|
+
scores: list[tuple[str, float]] = []
|
|
172
|
+
for key, via_tokens in via.items():
|
|
173
|
+
neighbors = neighbors_by_key[key]
|
|
174
|
+
score = 0.0
|
|
175
|
+
for token in tokens:
|
|
176
|
+
if token in via_tokens:
|
|
177
|
+
continue
|
|
178
|
+
keys = token_keys[token]
|
|
179
|
+
if key in keys:
|
|
180
|
+
score += token_idf[token]
|
|
181
|
+
if not neighbors.isdisjoint(keys):
|
|
182
|
+
score += token_idf[token]
|
|
183
|
+
scores.append((key, score))
|
|
184
|
+
scores.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
185
|
+
return scores
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def anchor_keys(
|
|
189
|
+
query: str,
|
|
190
|
+
generation: Generation,
|
|
191
|
+
repo_filter: frozenset[str] | None,
|
|
192
|
+
exclude: set[str],
|
|
193
|
+
) -> list[str]:
|
|
194
|
+
"""Keys to pin right after the exact-alias hits, best first; `[]` when
|
|
195
|
+
no candidate is both above `ANCHOR_MIN_SCORE` and dominant."""
|
|
196
|
+
tokens = list(dict.fromkeys(tokenize_text(query)))
|
|
197
|
+
if len(tokens) < 2:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
via: dict[str, set[str]] = {}
|
|
201
|
+
for token in tokens:
|
|
202
|
+
for form in (token, f".{token}()"):
|
|
203
|
+
members = _alias_members(form, generation, repo_filter)
|
|
204
|
+
if not 1 <= len(members) <= ANCHOR_MAX_ALIAS_MEMBERS:
|
|
205
|
+
continue
|
|
206
|
+
for key in members:
|
|
207
|
+
if key in exclude or key not in generation.node_id_by_key:
|
|
208
|
+
continue
|
|
209
|
+
via.setdefault(key, set()).add(token)
|
|
210
|
+
if not via:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
scores = _candidate_scores(tokens, via, generation, repo_filter)
|
|
214
|
+
|
|
215
|
+
pinned: list[str] = []
|
|
216
|
+
for index, (key, score) in enumerate(scores):
|
|
217
|
+
if len(pinned) >= ANCHOR_MAX_PINS:
|
|
218
|
+
break
|
|
219
|
+
next_score = scores[index + 1][1] if index + 1 < len(scores) else 0.0
|
|
220
|
+
if score < ANCHOR_MIN_SCORE or score < ANCHOR_DOMINANCE * next_score:
|
|
221
|
+
break
|
|
222
|
+
pinned.append(key)
|
|
223
|
+
return pinned
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The `[repo:source_file:line]` citation every node-returning tool emits.
|
|
2
|
+
|
|
3
|
+
Kept in its own module so `server.py` (search/cross_project/find_similar
|
|
4
|
+
hits) and `traverse.py` (the `neighbors` tool) render citations through one
|
|
5
|
+
function and can never drift apart. The line comes from
|
|
6
|
+
`graphify_mesh.sync.embedding.node_line`, which also reads graphify's
|
|
7
|
+
`source_location: "L34"` form; an unknown line renders as `?`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from graphify_mesh.sync.embedding import node_line
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def citation(repo: str, source_file: str, node: dict) -> str:
|
|
16
|
+
line = node_line(node)
|
|
17
|
+
return f"[{repo}:{source_file}:{line if line is not None else '?'}]"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Resolved runtime configuration for one graphify-mesh server process.
|
|
2
|
+
|
|
3
|
+
Mirrors `graphify_mesh.sync.config.Settings`'s "all paths configurable"
|
|
4
|
+
convention so tests never touch a real filesystem tree; the default below is
|
|
5
|
+
a placeholder — override via GRAPHIFY_MESH_ROOT for your environment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
16
|
+
DEFAULT_HTTP_PORT = 19744
|
|
17
|
+
DEFAULT_HTTP_PATH = "/mcp"
|
|
18
|
+
|
|
19
|
+
# Hostnames that mean loopback without being parseable as an IP address. Any
|
|
20
|
+
# other name is treated as public: the bind guard never resolves DNS, so a name
|
|
21
|
+
# it cannot decide is a name it refuses.
|
|
22
|
+
_LOOPBACK_HOST_NAMES = frozenset(
|
|
23
|
+
{"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# The bearer token is the only gate on a TCP port every local process can reach.
|
|
27
|
+
MIN_HTTP_TOKEN_CHARS = 32
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(ValueError):
|
|
31
|
+
"""Invalid transport configuration. Raised at startup, never per request."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_loopback_bind(host: str) -> bool:
|
|
35
|
+
"""True when binding to `host` reaches loopback only.
|
|
36
|
+
|
|
37
|
+
Everything else, wildcards and unresolvable names included, counts as a
|
|
38
|
+
public bind and needs the explicit opt-in.
|
|
39
|
+
"""
|
|
40
|
+
candidate = host.strip()
|
|
41
|
+
if candidate.startswith("[") and candidate.endswith("]"):
|
|
42
|
+
candidate = candidate[1:-1]
|
|
43
|
+
# Drop an IPv6 zone index ("fe80::1%eth0"), which ip_address rejects.
|
|
44
|
+
candidate = candidate.split("%", 1)[0]
|
|
45
|
+
if not candidate:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
address = ipaddress.ip_address(candidate)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return candidate.lower() in _LOOPBACK_HOST_NAMES
|
|
51
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
|
52
|
+
return address.ipv4_mapped.is_loopback
|
|
53
|
+
return address.is_loopback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ServerConfig:
|
|
58
|
+
mesh_root: Path
|
|
59
|
+
registry_path: Path
|
|
60
|
+
transport: str = "stdio"
|
|
61
|
+
http_host: str = DEFAULT_HTTP_HOST
|
|
62
|
+
http_port: int = DEFAULT_HTTP_PORT
|
|
63
|
+
http_path: str = DEFAULT_HTTP_PATH
|
|
64
|
+
http_token: str | None = field(default=None, repr=False)
|
|
65
|
+
allow_public_bind: bool = False
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def global_dir(self) -> Path:
|
|
69
|
+
return self.mesh_root / "graphify" / "global"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def current_symlink(self) -> Path:
|
|
73
|
+
return self.global_dir / "current"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def embeddings_current_symlink(self) -> Path:
|
|
77
|
+
return self.global_dir / "embeddings" / "current"
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_env(
|
|
81
|
+
cls,
|
|
82
|
+
mesh_root: Path | None = None,
|
|
83
|
+
registry_path: Path | None = None,
|
|
84
|
+
transport: str | None = None,
|
|
85
|
+
http_host: str | None = None,
|
|
86
|
+
http_port: int | None = None,
|
|
87
|
+
http_path: str | None = None,
|
|
88
|
+
allow_public_bind: bool | None = None,
|
|
89
|
+
) -> ServerConfig:
|
|
90
|
+
# Defaults to the current working directory (no machine-specific
|
|
91
|
+
# path); set GRAPHIFY_MESH_ROOT for a real deployment.
|
|
92
|
+
resolved_mesh_root = Path(
|
|
93
|
+
mesh_root or os.environ.get("GRAPHIFY_MESH_ROOT") or Path.cwd()
|
|
94
|
+
).resolve()
|
|
95
|
+
resolved_registry = Path(
|
|
96
|
+
registry_path
|
|
97
|
+
or os.environ.get(
|
|
98
|
+
"GRAPHIFY_MESH_REGISTRY", str(resolved_mesh_root / "bin" / "registry.json")
|
|
99
|
+
)
|
|
100
|
+
).resolve()
|
|
101
|
+
|
|
102
|
+
# Resolve transport (explicit argument > environment > default)
|
|
103
|
+
if transport is not None:
|
|
104
|
+
resolved_transport = transport
|
|
105
|
+
elif "GRAPHIFY_MESH_TRANSPORT" in os.environ:
|
|
106
|
+
resolved_transport = os.environ["GRAPHIFY_MESH_TRANSPORT"]
|
|
107
|
+
else:
|
|
108
|
+
resolved_transport = "stdio"
|
|
109
|
+
|
|
110
|
+
if resolved_transport not in ("stdio", "http"):
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
f"Invalid transport '{resolved_transport}'; must be 'stdio' or 'http'"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Resolve HTTP host (explicit argument > environment > default)
|
|
116
|
+
resolved_http_host = (
|
|
117
|
+
http_host or os.environ.get("GRAPHIFY_MESH_HTTP_HOST") or DEFAULT_HTTP_HOST
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Resolve HTTP port (explicit argument > environment > default)
|
|
121
|
+
resolved_http_port: int
|
|
122
|
+
if http_port is not None:
|
|
123
|
+
resolved_http_port = http_port
|
|
124
|
+
else:
|
|
125
|
+
port_str = os.environ.get("GRAPHIFY_MESH_HTTP_PORT")
|
|
126
|
+
if port_str:
|
|
127
|
+
try:
|
|
128
|
+
resolved_http_port = int(port_str)
|
|
129
|
+
except ValueError as err:
|
|
130
|
+
raise ConfigError(
|
|
131
|
+
f"GRAPHIFY_MESH_HTTP_PORT must be an integer, got '{port_str}'"
|
|
132
|
+
) from err
|
|
133
|
+
else:
|
|
134
|
+
resolved_http_port = DEFAULT_HTTP_PORT
|
|
135
|
+
|
|
136
|
+
# Validate port is in valid range
|
|
137
|
+
if resolved_http_port < 1 or resolved_http_port > 65535:
|
|
138
|
+
raise ConfigError(
|
|
139
|
+
f"HTTP port must be between 1 and 65535, got {resolved_http_port}" # noqa: E501
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Resolve HTTP path (explicit argument > environment > default)
|
|
143
|
+
resolved_http_path = (
|
|
144
|
+
http_path or os.environ.get("GRAPHIFY_MESH_HTTP_PATH") or DEFAULT_HTTP_PATH
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Validate path starts with /
|
|
148
|
+
if not resolved_http_path.startswith("/"):
|
|
149
|
+
raise ConfigError(f"HTTP path must start with '/', got '{resolved_http_path}'")
|
|
150
|
+
|
|
151
|
+
# Resolve allow_public_bind (explicit argument > environment > default)
|
|
152
|
+
resolved_allow_public_bind: bool
|
|
153
|
+
if allow_public_bind is not None:
|
|
154
|
+
resolved_allow_public_bind = allow_public_bind
|
|
155
|
+
else:
|
|
156
|
+
env_val = os.environ.get("GRAPHIFY_MESH_ALLOW_PUBLIC_BIND", "").lower()
|
|
157
|
+
resolved_allow_public_bind = env_val in ("1", "true", "yes")
|
|
158
|
+
|
|
159
|
+
# Check for public bind without opt-in
|
|
160
|
+
if not is_loopback_bind(resolved_http_host) and not resolved_allow_public_bind:
|
|
161
|
+
raise ConfigError(
|
|
162
|
+
f"Cannot bind to '{resolved_http_host}': any non-loopback bind requires "
|
|
163
|
+
"the --allow-public-bind flag (or GRAPHIFY_MESH_ALLOW_PUBLIC_BIND=1). "
|
|
164
|
+
"A hostname that is not localhost and does not parse as an IP address "
|
|
165
|
+
"counts as non-loopback."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Resolve HTTP token (explicit argument > environment > default)
|
|
169
|
+
resolved_http_token: str | None = None
|
|
170
|
+
if resolved_transport == "http":
|
|
171
|
+
token = (os.environ.get("GRAPHIFY_MESH_HTTP_TOKEN") or "").strip()
|
|
172
|
+
if not token:
|
|
173
|
+
raise ConfigError(
|
|
174
|
+
"HTTP transport requires a bearer token: set GRAPHIFY_MESH_HTTP_TOKEN "
|
|
175
|
+
"(a loopback TCP port is reachable by every local process, unlike the "
|
|
176
|
+
"unix socket it replaces)"
|
|
177
|
+
)
|
|
178
|
+
if len(token) < MIN_HTTP_TOKEN_CHARS:
|
|
179
|
+
raise ConfigError(
|
|
180
|
+
"GRAPHIFY_MESH_HTTP_TOKEN is too short: at least "
|
|
181
|
+
f"{MIN_HTTP_TOKEN_CHARS} characters are required. Generate one with: "
|
|
182
|
+
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
183
|
+
)
|
|
184
|
+
resolved_http_token = token
|
|
185
|
+
|
|
186
|
+
return cls(
|
|
187
|
+
mesh_root=resolved_mesh_root,
|
|
188
|
+
registry_path=resolved_registry,
|
|
189
|
+
transport=resolved_transport,
|
|
190
|
+
http_host=resolved_http_host,
|
|
191
|
+
http_port=resolved_http_port,
|
|
192
|
+
http_path=resolved_http_path,
|
|
193
|
+
http_token=resolved_http_token,
|
|
194
|
+
allow_public_bind=resolved_allow_public_bind,
|
|
195
|
+
)
|