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.
Files changed (60) hide show
  1. graphify_mesh/__init__.py +20 -0
  2. graphify_mesh/_version.py +24 -0
  3. graphify_mesh/server/__init__.py +61 -0
  4. graphify_mesh/server/anchors.py +223 -0
  5. graphify_mesh/server/citation.py +17 -0
  6. graphify_mesh/server/config.py +195 -0
  7. graphify_mesh/server/context_pack.py +212 -0
  8. graphify_mesh/server/embed_query.py +206 -0
  9. graphify_mesh/server/frames.py +93 -0
  10. graphify_mesh/server/http_app.py +367 -0
  11. graphify_mesh/server/lexical_read.py +208 -0
  12. graphify_mesh/server/project_map.py +92 -0
  13. graphify_mesh/server/ranking.py +209 -0
  14. graphify_mesh/server/retrieval.py +343 -0
  15. graphify_mesh/server/rwlock.py +61 -0
  16. graphify_mesh/server/scope.py +207 -0
  17. graphify_mesh/server/sdk_app.py +108 -0
  18. graphify_mesh/server/server.py +813 -0
  19. graphify_mesh/server/similar.py +284 -0
  20. graphify_mesh/server/stdio_guard.py +287 -0
  21. graphify_mesh/server/store.py +733 -0
  22. graphify_mesh/server/traverse.py +305 -0
  23. graphify_mesh/sync/__init__.py +26 -0
  24. graphify_mesh/sync/backend.py +80 -0
  25. graphify_mesh/sync/cli.py +249 -0
  26. graphify_mesh/sync/clustering.py +182 -0
  27. graphify_mesh/sync/config.py +815 -0
  28. graphify_mesh/sync/discovery.py +668 -0
  29. graphify_mesh/sync/embed_similarity.py +516 -0
  30. graphify_mesh/sync/embedding.py +1372 -0
  31. graphify_mesh/sync/graphify_cli.py +941 -0
  32. graphify_mesh/sync/lexical_index.py +293 -0
  33. graphify_mesh/sync/locking.py +30 -0
  34. graphify_mesh/sync/naming.py +563 -0
  35. graphify_mesh/sync/naming_state.py +143 -0
  36. graphify_mesh/sync/overlay.py +146 -0
  37. graphify_mesh/sync/overlay_api.py +312 -0
  38. graphify_mesh/sync/overlay_depends.py +324 -0
  39. graphify_mesh/sync/overlay_refs.py +143 -0
  40. graphify_mesh/sync/overlay_similar.py +357 -0
  41. graphify_mesh/sync/perms.py +116 -0
  42. graphify_mesh/sync/pipeline.py +1891 -0
  43. graphify_mesh/sync/progress.py +41 -0
  44. graphify_mesh/sync/publish.py +374 -0
  45. graphify_mesh/sync/reaper.py +201 -0
  46. graphify_mesh/sync/reaper_cli.py +62 -0
  47. graphify_mesh/sync/registry.py +184 -0
  48. graphify_mesh/sync/repo_tags.py +299 -0
  49. graphify_mesh/sync/rss.py +68 -0
  50. graphify_mesh/sync/source_cache.py +149 -0
  51. graphify_mesh/sync/state.py +268 -0
  52. graphify_mesh/sync/sync_project.py +793 -0
  53. graphify_mesh/sync/tls.py +119 -0
  54. graphify_mesh/sync/validate.py +277 -0
  55. graphify_mesh/sync/vectors.py +234 -0
  56. graphify_mesh-0.0.22.dist-info/METADATA +219 -0
  57. graphify_mesh-0.0.22.dist-info/RECORD +60 -0
  58. graphify_mesh-0.0.22.dist-info/WHEEL +4 -0
  59. graphify_mesh-0.0.22.dist-info/entry_points.txt +4 -0
  60. 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
+ )