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,212 @@
1
+ """`context_pack(goal, scope, token_budget)` tool (WS5 tool 5): evidence
2
+ cards built from `retrieval.rank`'s ranked hits, each carrying an explicit
3
+ `[repo:path:line]` citation (same shape as everywhere else in this
4
+ package), a snippet, and a confidence flag. Truncates strictly by whole-card
5
+ boundary — a card is either fully included or fully excluded, it is never
6
+ sliced mid-way (plan WS5 tool contract: "budgets").
7
+
8
+ Confidence flag is always `ranking.CONFIDENCE_EXTRACTED` here:
9
+ `context_pack` does not expose an `include_inferred` toggle the way
10
+ `rank()` itself does, so every hit this function can ever surface is, by
11
+ construction, drawn from the EXTRACTED-only default structural traversal
12
+ plus lexical/vector matches — there is no code path by which a
13
+ non-EXTRACTED (INFERRED/AMBIGUOUS) sourced hit reaches this function today. The field is still
14
+ carried explicitly (not hardcoded as a bare string) so a future
15
+ `include_inferred` passthrough only needs to plumb the real value through,
16
+ not invent the field.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ from graphify_mesh.server import ranking
26
+ from graphify_mesh.server.retrieval import EmbedQueryFn, Hit, rank
27
+ from graphify_mesh.server.scope import RegistryEntry
28
+ from graphify_mesh.server.store import Generation
29
+ from graphify_mesh.sync.embedding import build_snippet, node_line
30
+
31
+ # Rough, dependency-free token estimate — no tokenizer dependency added just
32
+ # for a budget heuristic. ~4 chars/token is the commonly-cited English-text
33
+ # average; deliberately errs on the side of UNDER-counting cards (budget
34
+ # runs out slightly early rather than ever overshooting the caller's stated
35
+ # token_budget).
36
+ CHARS_PER_TOKEN_ESTIMATE = 4
37
+
38
+ # How many ranked candidates to consider building cards from before token
39
+ # budget truncation. Independent of the caller's k (context_pack has no k
40
+ # parameter) — kept generous since truncation is budget-driven, not count-driven.
41
+ CONTEXT_PACK_CANDIDATE_K = 30
42
+
43
+ # Snippets are read from the live working tree at query time (see
44
+ # `_card_from_hit`), not from a snapshot taken at sync time — the citation
45
+ # line number comes from the generation's graph data, so edits made after
46
+ # the last sync can silently shift the cited line. Every card therefore
47
+ # declares its snippet provenance explicitly, and is flagged stale when the
48
+ # source file's mtime is newer than the generation manifest's `created_at`.
49
+ SNIPPET_SOURCE_LIVE_TREE = "live_tree"
50
+
51
+ # The generation manifest's `created_at` format (written by
52
+ # `sync/pipeline.py` via `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())`).
53
+ _MANIFEST_CREATED_AT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
54
+
55
+
56
+ @dataclass
57
+ class EvidenceCard:
58
+ citation: str # "[repo:path:line]"
59
+ repo: str
60
+ label: str
61
+ source_file: str
62
+ line: int | None
63
+ confidence: str
64
+ snippet: str
65
+ score: float
66
+ # Snippet provenance: snippets always come from the live working tree,
67
+ # never from generation data — declared explicitly so clients can tell
68
+ # the snippet's freshness class apart from the citation's (which IS
69
+ # generation data). `snippet_stale=True` marks the degraded case where
70
+ # the source file changed after the generation was built, so the cited
71
+ # line may no longer match the snippet content.
72
+ snippet_source: str = SNIPPET_SOURCE_LIVE_TREE
73
+ snippet_stale: bool = False
74
+
75
+ def estimated_tokens(self) -> int:
76
+ # citation + label are always present and cheap; snippet dominates
77
+ # the estimate. +1 keeps a zero-length snippet from costing 0 tokens.
78
+ return max(
79
+ 1,
80
+ (len(self.snippet) + len(self.label) + len(self.citation)) // CHARS_PER_TOKEN_ESTIMATE,
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class ContextPackResult:
86
+ goal: str
87
+ cards: list = field(default_factory=list)
88
+ truncated: bool = False
89
+ degraded: list = field(default_factory=list)
90
+
91
+
92
+ def _root_for_repo(repo_id: str, registry_entries: list[RegistryEntry]) -> Path | None:
93
+ for entry in registry_entries:
94
+ if entry.repo_id == repo_id:
95
+ return entry.root
96
+ return None
97
+
98
+
99
+ def _parse_manifest_timestamp(raw: object) -> float | None:
100
+ if not isinstance(raw, str):
101
+ return None
102
+ try:
103
+ parsed = datetime.strptime(raw, _MANIFEST_CREATED_AT_FORMAT)
104
+ except ValueError:
105
+ return None
106
+ return parsed.replace(tzinfo=UTC).timestamp()
107
+
108
+
109
+ def _generation_created_at_epoch(generation: Generation) -> float | None:
110
+ """Staleness baseline (UTC epoch) for mtime comparison. Prefers the
111
+ manifest's `sync_started_at` (files edited DURING a long sync are newer
112
+ than sync start and correctly flagged), falling back to `created_at`
113
+ (publish stamp) for older generations. Returns `None` when absent or
114
+ malformed — staleness then simply cannot be determined and no card is
115
+ flagged."""
116
+ started = _parse_manifest_timestamp(generation.manifest.get("sync_started_at"))
117
+ if started is not None:
118
+ return started
119
+ return _parse_manifest_timestamp(generation.manifest.get("created_at"))
120
+
121
+
122
+ def _snippet_file_mtime(root: Path | None, source_file: str | None) -> float | None:
123
+ """mtime of the live-tree file a snippet was read from, mirroring
124
+ `build_snippet`'s own path-safety gates (relative only, must resolve
125
+ inside `root`) so a hostile `source_file` can't be stat-probed outside
126
+ the repo root. Returns `None` whenever no snippet could have been read."""
127
+ if root is None or not source_file:
128
+ return None
129
+ if Path(source_file).is_absolute():
130
+ return None
131
+ path = (root / source_file).resolve()
132
+ try:
133
+ path.relative_to(root.resolve())
134
+ except ValueError:
135
+ return None
136
+ if not path.is_file():
137
+ return None
138
+ try:
139
+ return path.stat().st_mtime
140
+ except OSError:
141
+ return None
142
+
143
+
144
+ def _card_from_hit(
145
+ hit: Hit,
146
+ generation: Generation,
147
+ registry_entries: list[RegistryEntry],
148
+ generation_created_at: float | None,
149
+ ) -> EvidenceCard:
150
+ node = generation.node_by_id.get(hit.node_id, {})
151
+ line = node_line(node)
152
+ root = _root_for_repo(hit.repo, registry_entries)
153
+ snippet = build_snippet(root, hit.source_file, line) if hit.source_file else ""
154
+
155
+ snippet_stale = False
156
+ # Staleness is checked whenever a source_file is cited, NOT gated on the
157
+ # snippet being non-empty: an empty snippet (cited line window beyond
158
+ # EOF, e.g. file truncated after the generation was built) is exactly
159
+ # the case where the citation is most likely invalid.
160
+ mtime = _snippet_file_mtime(root, hit.source_file) if hit.source_file else None
161
+ if generation_created_at is not None and mtime is not None and mtime > generation_created_at:
162
+ snippet_stale = True
163
+
164
+ line_part = line if line is not None else "?"
165
+ citation = f"[{hit.repo}:{hit.source_file}:{line_part}]"
166
+ return EvidenceCard(
167
+ citation=citation,
168
+ repo=hit.repo,
169
+ label=hit.label,
170
+ source_file=hit.source_file,
171
+ line=line,
172
+ confidence=ranking.CONFIDENCE_EXTRACTED,
173
+ snippet=snippet,
174
+ score=hit.score,
175
+ snippet_source=SNIPPET_SOURCE_LIVE_TREE,
176
+ snippet_stale=snippet_stale,
177
+ )
178
+
179
+
180
+ def build_context_pack(
181
+ goal: str,
182
+ generation: Generation,
183
+ repo_filter: frozenset[str] | None,
184
+ registry_entries: list[RegistryEntry],
185
+ token_budget: int,
186
+ embed_query_fn: EmbedQueryFn,
187
+ ) -> ContextPackResult:
188
+ ranked = rank(goal, generation, repo_filter, CONTEXT_PACK_CANDIDATE_K, embed_query_fn)
189
+ generation_created_at = _generation_created_at_epoch(generation)
190
+
191
+ # Cards are built LAZILY inside the budget loop: each build costs a
192
+ # file open + read (`build_snippet`), so building all candidates
193
+ # eagerly wastes I/O whenever the budget keeps only the first few.
194
+ # Truncation stays whole-card and order-preserving: iterate hits in
195
+ # ranked order, stop at the first card that doesn't fit — a card is
196
+ # never split mid-way, and no card after the first non-fitting one is
197
+ # ever built.
198
+ selected: list[EvidenceCard] = []
199
+ remaining = token_budget
200
+ truncated = False
201
+ for hit in ranked.hits:
202
+ card = _card_from_hit(hit, generation, registry_entries, generation_created_at)
203
+ cost = card.estimated_tokens()
204
+ if cost > remaining:
205
+ truncated = True
206
+ break
207
+ selected.append(card)
208
+ remaining -= cost
209
+
210
+ return ContextPackResult(
211
+ goal=goal, cards=selected, truncated=truncated, degraded=ranked.degraded
212
+ )
@@ -0,0 +1,206 @@
1
+ """Query-time embedding wrapper (WS5): turns a raw query string into a
2
+ vector for `retrieval.vector_candidates`, reusing the exact `/api/embed`
3
+ contract and base_url/model config `graphify_mesh.sync.embedding` /
4
+ `graphify_mesh.sync.config` already established for the sync pipeline (C9) — the
5
+ query MUST be embedded with the same model/endpoint that produced the
6
+ published vectors, or cosine similarity is meaningless.
7
+
8
+ Never raises: any transport/shape failure degrades to `None` (the caller,
9
+ `retrieval.vector_candidates`, surfaces an `"embeddings_unavailable:<reason>"`
10
+ marker in the response's `degraded` field), mirroring the pipeline's own
11
+ "never crash on a down local service" convention (C9) — just applied at query
12
+ time instead of build time.
13
+
14
+ Cost of degrading (2026-09-23): a backend can accept TCP and answer its
15
+ control-plane routes while every inference request hangs. Measured against
16
+ the then-configured host: `/api/tags` 200 in 0.07 s, `/api/embed` timing out
17
+ on 7 of 7 attempts. A per-query blocking call with a long timeout therefore
18
+ costs the full timeout on EVERY query for as long as the backend stays in
19
+ that state. Two guards below bound that cost:
20
+
21
+ * a short default timeout — a warm single-query embed measured 0.19-0.28 s
22
+ against a local backend, so the budget here is headroom, not a limit on
23
+ healthy operation;
24
+ * a negative cache — one failure suppresses the next `FAILURE_COOLDOWN`
25
+ seconds of calls in this process, so N degraded queries cost one timeout
26
+ per cooldown window instead of N timeouts.
27
+
28
+ Neither guard is a health probe. A startup probe against `/api/tags` would
29
+ have reported this backend HEALTHY (it answered 200 in 0.07 s while every
30
+ embed hung), so the only honest signal is a real embed call's outcome, which
31
+ is what the negative cache records.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import os
38
+ import threading
39
+ import time
40
+
41
+ from graphify_mesh.sync.config import EMBED_DEFAULT_BASE_URL, EMBED_DEFAULT_MODEL
42
+ from graphify_mesh.sync.embedding import embed_batch
43
+
44
+ log = logging.getLogger("graphify_mesh.server.embed_query")
45
+
46
+ # Kept short: a query-time embed call blocks one MCP `search`/`context_pack`
47
+ # invocation, unlike the sync pipeline's bulk embed calls which can afford a
48
+ # longer per-batch timeout. A healthy backend answers a single-input embed in
49
+ # well under a second (0.19-0.28 s measured warm), so this is ~10x headroom;
50
+ # what it actually bounds is the cost of a wedged backend.
51
+ DEFAULT_QUERY_EMBED_TIMEOUT = 2.5
52
+ # After a failure, skip the backend entirely for this long. Sized to be far
53
+ # longer than the timeout (so the amortized cost of a dead backend is near
54
+ # zero) but short enough that a backend coming back is picked up within a
55
+ # minute without restarting the daemon.
56
+ DEFAULT_FAILURE_COOLDOWN = 60.0
57
+
58
+ # Kept as a module constant for callers/tests that imported it before the
59
+ # env override existed.
60
+ QUERY_EMBED_TIMEOUT = DEFAULT_QUERY_EMBED_TIMEOUT
61
+
62
+ # Why the vector retriever contributed nothing. `retrieval` prefixes these
63
+ # with `embeddings_unavailable:` so a client can tell a wedged/unreachable
64
+ # backend apart from a misconfiguration without reading the server log.
65
+ REASON_TIMEOUT = "timeout"
66
+ REASON_UNREACHABLE = "unreachable"
67
+ REASON_BAD_RESPONSE = "bad_response"
68
+
69
+
70
+ def _positive_float_env(name: str, default: float) -> float:
71
+ """Read a positive float from the environment, falling back to `default`
72
+ on anything unparseable or non-positive. A bad value must not be able to
73
+ turn the timeout into 0 (every query degrades) or a negative cooldown."""
74
+ raw = os.environ.get(name)
75
+ if raw is None:
76
+ return default
77
+ try:
78
+ value = float(raw)
79
+ except ValueError:
80
+ log.warning("%s=%r is not a number, using %s", name, raw, default)
81
+ return default
82
+ if value <= 0:
83
+ log.warning("%s=%r is not positive, using %s", name, raw, default)
84
+ return default
85
+ return value
86
+
87
+
88
+ def _classify(exc: Exception) -> str:
89
+ """Map an `embed_batch` RuntimeError onto one of the REASON_* constants.
90
+
91
+ `embed_batch` flattens every transport failure into one RuntimeError whose
92
+ message carries the original exception's text, so the message is the only
93
+ signal available without changing that function's contract."""
94
+ text = str(exc).lower()
95
+ if "timed out" in text or "timeout" in text:
96
+ return REASON_TIMEOUT
97
+ if "shape mismatch" in text or "not a json object" in text or "too large" in text:
98
+ return REASON_BAD_RESPONSE
99
+ return REASON_UNREACHABLE
100
+
101
+
102
+ class QueryEmbedder:
103
+ """Callable matching `retrieval.EmbedQueryFn` (`str -> list[float] | None`),
104
+ plus a `last_failure_reason` attribute the caller reads via `getattr` so
105
+ plain-callable stand-ins in tests keep satisfying the same contract."""
106
+
107
+ def __init__(self, base_url: str, model: str, timeout: float, cooldown: float):
108
+ self.base_url = base_url
109
+ self.model = model
110
+ self.timeout = timeout
111
+ self.cooldown = cooldown
112
+ self.last_failure_reason: str | None = None
113
+ # Guards the cooldown deadline and the reason: the MCP server can
114
+ # serve concurrent requests, and without this two queries racing on a
115
+ # dead backend would each pay a full timeout.
116
+ self._lock = threading.Lock()
117
+ self._cooldown_until = 0.0
118
+
119
+ def _cooling_down(self) -> bool:
120
+ with self._lock:
121
+ return time.monotonic() < self._cooldown_until
122
+
123
+ def _record_failure(self, reason: str) -> None:
124
+ with self._lock:
125
+ self._cooldown_until = time.monotonic() + self.cooldown
126
+ self.last_failure_reason = reason
127
+
128
+ def _record_success(self) -> None:
129
+ with self._lock:
130
+ self._cooldown_until = 0.0
131
+ self.last_failure_reason = None
132
+
133
+ def __call__(self, query: str) -> list[float] | None:
134
+ if not query or not query.strip():
135
+ return None
136
+ if self._cooling_down():
137
+ # Deliberately silent: the failure that opened the cooldown was
138
+ # already logged once. Logging here would restore exactly the
139
+ # per-query noise the cooldown exists to remove.
140
+ return None
141
+ try:
142
+ vectors = embed_batch(self.base_url, self.model, [query], timeout=self.timeout)
143
+ except RuntimeError as exc:
144
+ reason = _classify(exc)
145
+ self._record_failure(reason)
146
+ log.warning(
147
+ "graphify-mesh: query embedding unavailable (%s), degrading to "
148
+ "lexical+structural only for the next %.0fs: %s",
149
+ reason,
150
+ self.cooldown,
151
+ exc,
152
+ )
153
+ return None
154
+ if not vectors:
155
+ self._record_failure(REASON_BAD_RESPONSE)
156
+ log.warning(
157
+ "graphify-mesh: query embedding returned no vector (%s), degrading to "
158
+ "lexical+structural only for the next %.0fs",
159
+ REASON_BAD_RESPONSE,
160
+ self.cooldown,
161
+ )
162
+ return None
163
+ self._record_success()
164
+ return vectors[0]
165
+
166
+ def failure_reason(self) -> str:
167
+ """The reason to report when this embedder just declined to produce a
168
+ vector. A suppressed call reports the failure that opened the cooldown
169
+ rather than `cooling_down`: the cause is what the client needs, and
170
+ the log line already distinguishes the attempt from the suppression."""
171
+ with self._lock:
172
+ return self.last_failure_reason or REASON_UNREACHABLE
173
+
174
+
175
+ def make_embed_query_fn(
176
+ base_url: str | None = None,
177
+ model: str | None = None,
178
+ timeout: float | None = None,
179
+ cooldown: float | None = None,
180
+ ) -> QueryEmbedder:
181
+ """Returns a `str -> list[float] | None` callable matching
182
+ `retrieval.EmbedQueryFn`, bound to the resolved base_url/model (env
183
+ override via the SAME `GRAPHIFY_MESH_OLLAMA_EMBED_*` variables the sync
184
+ pipeline reads, so a query never drifts onto a different embedding
185
+ space than the one the published vectors were built with).
186
+
187
+ Timeout and cooldown are read from `GRAPHIFY_MESH_QUERY_EMBED_TIMEOUT` /
188
+ `GRAPHIFY_MESH_QUERY_EMBED_COOLDOWN` so a wedged backend can be ridden out
189
+ by editing the unit, without promoting a new build."""
190
+ resolved_base_url = base_url or os.environ.get(
191
+ "GRAPHIFY_MESH_OLLAMA_EMBED_BASE_URL", EMBED_DEFAULT_BASE_URL
192
+ )
193
+ resolved_model = model or os.environ.get(
194
+ "GRAPHIFY_MESH_OLLAMA_EMBED_MODEL", EMBED_DEFAULT_MODEL
195
+ )
196
+ resolved_timeout = (
197
+ timeout
198
+ if timeout is not None
199
+ else _positive_float_env("GRAPHIFY_MESH_QUERY_EMBED_TIMEOUT", DEFAULT_QUERY_EMBED_TIMEOUT)
200
+ )
201
+ resolved_cooldown = (
202
+ cooldown
203
+ if cooldown is not None
204
+ else _positive_float_env("GRAPHIFY_MESH_QUERY_EMBED_COOLDOWN", DEFAULT_FAILURE_COOLDOWN)
205
+ )
206
+ return QueryEmbedder(resolved_base_url, resolved_model, resolved_timeout, resolved_cooldown)
@@ -0,0 +1,93 @@
1
+ """JSON-RPC frame limits and envelope validation shared by both transports.
2
+
3
+ Two things must read the same way whichever transport a client speaks:
4
+
5
+ 1. The incoming-message size bound. `MAX_MESSAGE_BYTES` is the single number
6
+ both transports state — `stdio_guard.MAX_LINE_BYTES` per line, and the
7
+ HTTP guard plus the SDK session manager per request body.
8
+ 2. The error a malformed frame gets back. The SDK validates envelopes with
9
+ pydantic and puts `str(ValidationError)` — which quotes the offending
10
+ input — in the client-visible message, so both transports check the
11
+ envelope themselves first and answer with a fixed generic message,
12
+ logging the detail instead.
13
+
14
+ The checks here are deliberately narrower than the SDK's model validation:
15
+ they reject only what no legal JSON-RPC 2.0 frame can be, so a frame that
16
+ passes still reaches the SDK for full validation. A client-to-server
17
+ *response* (a `result`/`error` frame answering a server-initiated request,
18
+ which MCP uses for sampling and roots) carries no `method` and must pass.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from typing import Final
25
+
26
+ # Hard per-message size cap for both transports. The shared HTTP daemon is one
27
+ # process serving every local agent, so its memory is a machine-wide resource,
28
+ # and no legitimate JSON-RPC frame for these seven tools comes close to 4 MiB —
29
+ # a lower ceiling is worth more here than matching stdio's earlier 10 MB.
30
+ # Over-limit input is rejected without ever being buffered whole.
31
+ MAX_MESSAGE_BYTES: Final = 4 * 1024 * 1024
32
+
33
+ PARSE_ERROR: Final = (-32700, "parse error")
34
+ NOT_A_SINGLE_OBJECT: Final = (
35
+ -32600,
36
+ "invalid request: expected a single JSON-RPC object (batch requests are not supported)",
37
+ )
38
+ INVALID_REQUEST: Final = (-32600, "invalid request")
39
+ INVALID_PARAMS: Final = (-32602, "invalid params")
40
+
41
+
42
+ def frame_id(parsed: object) -> str | int | None:
43
+ """The request id to correlate an error response with, or `None` when the
44
+ frame carries no usable id. Only the two types JSON-RPC 2.0 allows count:
45
+ an id of any other shape is itself the defect being reported."""
46
+ if not isinstance(parsed, dict):
47
+ return None
48
+ candidate = parsed.get("id")
49
+ if isinstance(candidate, bool): # `True` is an int in Python, not a JSON-RPC id
50
+ return None
51
+ if isinstance(candidate, str | int):
52
+ return candidate
53
+ return None
54
+
55
+
56
+ def envelope_error(parsed: object) -> tuple[int, str] | None:
57
+ """`(code, message)` for a frame the SDK would reject with a detail-
58
+ carrying validation error, or `None` when the frame is shaped like a
59
+ legal JSON-RPC 2.0 message."""
60
+ if not isinstance(parsed, dict):
61
+ return NOT_A_SINGLE_OBJECT
62
+ if parsed.get("jsonrpc") != "2.0":
63
+ return INVALID_REQUEST
64
+
65
+ raw_id = parsed.get("id")
66
+ if "id" in parsed and (isinstance(raw_id, bool) or not isinstance(raw_id, str | int | None)):
67
+ return INVALID_REQUEST
68
+
69
+ if "method" not in parsed:
70
+ # A response frame (result/error) answering a server-initiated
71
+ # request is the one legal shape with no method.
72
+ if "result" in parsed or "error" in parsed:
73
+ return None
74
+ return INVALID_REQUEST
75
+ if not isinstance(parsed["method"], str) or not parsed["method"]:
76
+ return INVALID_REQUEST
77
+
78
+ params = parsed.get("params")
79
+ if "params" in parsed and params is not None and not isinstance(params, dict):
80
+ # JSON-RPC 2.0 also allows positional array params; MCP does not use
81
+ # them, and the SDK models type `params` as `dict | None`, so an array
82
+ # here is a params-shape error rather than an invalid envelope.
83
+ # Explicit `null` is exempt: it is what that `| None` accepts, and a
84
+ # client whose serializer emits it has always worked.
85
+ return INVALID_PARAMS
86
+
87
+ return None
88
+
89
+
90
+ def error_frame_text(code: int, message: str, request_id: str | int | None = None) -> str:
91
+ return json.dumps(
92
+ {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
93
+ )