token-context-mcp 0.3.5__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 (90) hide show
  1. token_context_mcp/__init__.py +4 -0
  2. token_context_mcp/__main__.py +5 -0
  3. token_context_mcp/cli.py +373 -0
  4. token_context_mcp/client_profile.py +135 -0
  5. token_context_mcp/config.py +320 -0
  6. token_context_mcp/constants.py +32 -0
  7. token_context_mcp/discovery/__init__.py +7 -0
  8. token_context_mcp/discovery/catalog.py +293 -0
  9. token_context_mcp/discovery/tools.py +114 -0
  10. token_context_mcp/gui/__init__.py +5 -0
  11. token_context_mcp/gui/bridge.py +894 -0
  12. token_context_mcp/gui/main.py +55 -0
  13. token_context_mcp/gui/main_window.py +312 -0
  14. token_context_mcp/gui/models.py +85 -0
  15. token_context_mcp/gui/perf.py +78 -0
  16. token_context_mcp/gui/theme.py +319 -0
  17. token_context_mcp/gui/widgets/__init__.py +3 -0
  18. token_context_mcp/gui/widgets/agents_tab.py +341 -0
  19. token_context_mcp/gui/widgets/cache_tab.py +222 -0
  20. token_context_mcp/gui/widgets/dashboard_tab.py +282 -0
  21. token_context_mcp/gui/widgets/loading_overlay.py +85 -0
  22. token_context_mcp/gui/widgets/repositories_tab.py +355 -0
  23. token_context_mcp/gui/widgets/settings_tab.py +181 -0
  24. token_context_mcp/gui/widgets/tasks_tab.py +191 -0
  25. token_context_mcp/gui/workers.py +130 -0
  26. token_context_mcp/index/__init__.py +2 -0
  27. token_context_mcp/index/freshness.py +24 -0
  28. token_context_mcp/index/gitinfo.py +43 -0
  29. token_context_mcp/index/hashing.py +17 -0
  30. token_context_mcp/index/runner.py +1738 -0
  31. token_context_mcp/index/sqlite_store.py +996 -0
  32. token_context_mcp/index/watch.py +144 -0
  33. token_context_mcp/kernel/__init__.py +36 -0
  34. token_context_mcp/kernel/context.py +88 -0
  35. token_context_mcp/kernel/pipeline.py +244 -0
  36. token_context_mcp/kernel/tools.py +116 -0
  37. token_context_mcp/mcp_compat.py +11 -0
  38. token_context_mcp/memory/__init__.py +7 -0
  39. token_context_mcp/memory/service.py +152 -0
  40. token_context_mcp/memory/store.py +558 -0
  41. token_context_mcp/models.py +130 -0
  42. token_context_mcp/parse/__init__.py +2 -0
  43. token_context_mcp/parse/jvm_calls.py +788 -0
  44. token_context_mcp/parse/jvm_types.py +680 -0
  45. token_context_mcp/parse/lexical_edges.py +1615 -0
  46. token_context_mcp/parse/lsp.py +28 -0
  47. token_context_mcp/parse/scip.py +27 -0
  48. token_context_mcp/parse/treesitter.py +2344 -0
  49. token_context_mcp/release.py +77 -0
  50. token_context_mcp/retrieve/__init__.py +2 -0
  51. token_context_mcp/retrieve/batch.py +160 -0
  52. token_context_mcp/retrieve/code_tokens.py +287 -0
  53. token_context_mcp/retrieve/context.py +40 -0
  54. token_context_mcp/retrieve/edge_stats.py +34 -0
  55. token_context_mcp/retrieve/expansion.py +252 -0
  56. token_context_mcp/retrieve/freshness.py +196 -0
  57. token_context_mcp/retrieve/graph_cache.py +199 -0
  58. token_context_mcp/retrieve/packet.py +577 -0
  59. token_context_mcp/retrieve/packet_inputs.py +263 -0
  60. token_context_mcp/retrieve/projection.py +100 -0
  61. token_context_mcp/retrieve/ranking.py +391 -0
  62. token_context_mcp/retrieve/serialization.py +99 -0
  63. token_context_mcp/retrieve/service.py +2634 -0
  64. token_context_mcp/retrieve/token_budget.py +42 -0
  65. token_context_mcp/retrieve/workflows.py +431 -0
  66. token_context_mcp/sampling/__init__.py +7 -0
  67. token_context_mcp/sampling/guardrail.py +67 -0
  68. token_context_mcp/sampling/hardware_probe.py +192 -0
  69. token_context_mcp/sampling/prompts.py +40 -0
  70. token_context_mcp/sampling/provider.py +87 -0
  71. token_context_mcp/sampling/router.py +247 -0
  72. token_context_mcp/sampling/schema.py +76 -0
  73. token_context_mcp/sampling/skeleton_hybrid.py +158 -0
  74. token_context_mcp/security/__init__.py +10 -0
  75. token_context_mcp/security/access_control.py +367 -0
  76. token_context_mcp/security/audit.py +244 -0
  77. token_context_mcp/security/content_policy.py +76 -0
  78. token_context_mcp/security/governance_store.py +265 -0
  79. token_context_mcp/security/local_privacy.py +263 -0
  80. token_context_mcp/security/path_policy.py +91 -0
  81. token_context_mcp/server.py +778 -0
  82. token_context_mcp/stubs/__init__.py +5 -0
  83. token_context_mcp/stubs/catalog.py +106 -0
  84. token_context_mcp/telemetry/__init__.py +2 -0
  85. token_context_mcp/telemetry/benchmark.py +137 -0
  86. token_context_mcp-0.3.5.dist-info/METADATA +238 -0
  87. token_context_mcp-0.3.5.dist-info/RECORD +90 -0
  88. token_context_mcp-0.3.5.dist-info/WHEEL +4 -0
  89. token_context_mcp-0.3.5.dist-info/entry_points.txt +4 -0
  90. token_context_mcp-0.3.5.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,4 @@
1
+ """Read-only code-context MCP server."""
2
+
3
+ __version__ = "0.3.5"
4
+
@@ -0,0 +1,5 @@
1
+ from token_context_mcp.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
5
+
@@ -0,0 +1,373 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from token_context_mcp import __version__
12
+ from token_context_mcp.config import (
13
+ ConfigError,
14
+ default_config_path,
15
+ get_repository,
16
+ index_directory,
17
+ load_config,
18
+ register_repository,
19
+ unregister_repository,
20
+ update_repository,
21
+ )
22
+ from token_context_mcp.index.runner import build_index, stage_of_message
23
+ from token_context_mcp.release import write_release_materials
24
+ from token_context_mcp.retrieve.service import RetrievalService
25
+ from token_context_mcp.security.local_privacy import harden_registry
26
+ from token_context_mcp.server import run_stdio
27
+ from token_context_mcp.telemetry.benchmark import load_runs, summarize
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(prog="token-context", description="Read-only local code-context MCP server")
32
+ parser.add_argument("--version", action="version", version=__version__)
33
+ subparsers = parser.add_subparsers(dest="command", required=True)
34
+ register = subparsers.add_parser("register", help="Register a repository root (admin-only)")
35
+ register.add_argument("--repo-id", required=True)
36
+ register.add_argument("--root", required=True, type=Path)
37
+ register.add_argument("--config", type=Path, default=default_config_path())
38
+ unregister = subparsers.add_parser("unregister", help="Remove a repository from the local registry (admin-only)")
39
+ unregister.add_argument("--repo-id", required=True)
40
+ unregister.add_argument("--config", type=Path, default=default_config_path())
41
+ update = subparsers.add_parser("update", help="Update a registered repository root (admin-only)")
42
+ update.add_argument("--repo-id", required=True)
43
+ update.add_argument("--root", required=True, type=Path)
44
+ update.add_argument("--force", action="store_true", help="Acknowledge that the registered root will change")
45
+ update.add_argument("--config", type=Path, default=default_config_path())
46
+ index = subparsers.add_parser("index", help="Build a local immutable snapshot (admin-only)")
47
+ target = index.add_mutually_exclusive_group(required=True)
48
+ target.add_argument("--repo-id", help="Index one registered repository")
49
+ target.add_argument("--all", action="store_true", help="Index every registered repository (JSON summary)")
50
+ index.add_argument("--config", type=Path, default=default_config_path())
51
+ index.add_argument("--network-policy", default="declared-deny-not-enforced")
52
+ index.add_argument(
53
+ "--progress-format",
54
+ choices=["none", "ndjson"],
55
+ default="none",
56
+ help='"ndjson": one JSON object per line on stdout ({"stage","current","total","ts"}, at most 10 per second), '
57
+ 'the last one {"stage":"done","manifest":{...}}; logs stay on stderr',
58
+ )
59
+ index.add_argument("--workers", type=int, default=None, help="Parser worker processes (default: min(8, CPUs - 1))")
60
+ index.add_argument(
61
+ "--verify-hashes",
62
+ action="store_true",
63
+ help="Read and hash every file instead of trusting (size, mtime_ns) of unchanged files",
64
+ )
65
+ index.add_argument("--full", action="store_true", help="Ignore the active snapshot and index from scratch")
66
+ index.add_argument("--watch", action="store_true", help="Keep running: re-index after the working tree settles")
67
+ index.add_argument("--debounce", type=float, default=1.5, help="--watch: quiet time before re-indexing (seconds)")
68
+ index.add_argument("--poll-interval", type=float, default=2.0, help="--watch without watchdog: poll period (seconds)")
69
+ harden = subparsers.add_parser("harden", help="Restrict the registry and snapshots to the owning account")
70
+ harden.add_argument("--config", type=Path, default=default_config_path())
71
+ harden.add_argument("--check", action="store_true", help="Report current permissions without changing them")
72
+ status = subparsers.add_parser("status", help="Read active snapshot status")
73
+ status.add_argument("--repo-id", required=True)
74
+ status.add_argument("--config", type=Path, default=default_config_path())
75
+ serve = subparsers.add_parser("serve", help="Start the read-only MCP stdio server")
76
+ serve.add_argument("--config", type=Path, default=default_config_path())
77
+ serve.add_argument("--transport", choices=["stdio"], default="stdio")
78
+ serve.add_argument("--network-policy", default="declared-deny-not-enforced")
79
+ serve.add_argument(
80
+ "--output-mode",
81
+ choices=["auto", "structured", "text", "legacy_dual"],
82
+ default=None,
83
+ help="Where tool payloads travel; overrides server.output_mode in the config (auto: structured only for "
84
+ "clients verified to forward structuredContent, otherwise text)",
85
+ )
86
+ serve.add_argument(
87
+ "--schema-profile",
88
+ choices=["auto", "default", "gemini_safe"],
89
+ default="auto",
90
+ help="Tool schema dialect advertised in tools/list (auto: gemini_safe for Gemini/Antigravity clients)",
91
+ )
92
+ serve.add_argument("--enable-admin-tools", action="store_true", default=False, help="Enable admin tools (agent_control, audit_logs)")
93
+ report = subparsers.add_parser("benchmark-report", help="Summarize an instrumented benchmark JSONL")
94
+ report.add_argument("--input", type=Path, required=True)
95
+ report.add_argument("--output", type=Path)
96
+ report.add_argument("--baseline", default="B0")
97
+ materials = subparsers.add_parser("release-materials", help="Generate unsigned local SBOM/provenance starter artifacts")
98
+ materials.add_argument("--output", type=Path, required=True)
99
+ quickstart = subparsers.add_parser("quickstart", help="1-click register, index, and print AI Agent configs")
100
+ quickstart.add_argument("path", nargs="?", default=".", type=Path, help="Directory of the codebase (default: .)")
101
+ quickstart.add_argument("--repo-id", help="Repository ID (default: folder name)")
102
+ quickstart.add_argument("--config", type=Path, default=default_config_path())
103
+ quickstart.add_argument("--full", action="store_true", help="Ignore existing snapshot and index from scratch")
104
+ quickstart.add_argument("--serve", action="store_true", help="Start MCP stdio server immediately after indexing")
105
+ quickstart.add_argument("--gui", action="store_true", help="Launch Desktop GUI controller")
106
+ return parser
107
+
108
+
109
+ def main(argv: list[str] | None = None) -> int:
110
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
111
+ args = build_parser().parse_args(argv)
112
+ try:
113
+ if args.command == "register":
114
+ repository = register_repository(args.config, args.repo_id, args.root)
115
+ _emit({"repo_id": repository.repo_id, "root": repository.root.as_posix(), "config": str(args.config)})
116
+ elif args.command == "unregister":
117
+ repository = unregister_repository(args.config, args.repo_id)
118
+ _emit({"repo_id": repository.repo_id, "root": repository.root.as_posix(), "config": str(args.config)})
119
+ elif args.command == "update":
120
+ repository = update_repository(args.config, args.repo_id, args.root, force=args.force)
121
+ _emit({"repo_id": repository.repo_id, "root": repository.root.as_posix(), "config": str(args.config)})
122
+ elif args.command == "index":
123
+ return _run_index(args)
124
+ elif args.command == "harden":
125
+ _emit(harden_registry(args.config, check_only=args.check))
126
+ elif args.command == "status":
127
+ service = RetrievalService(load_config(args.config), args.config)
128
+ _emit(service.status(args.repo_id))
129
+ elif args.command == "serve":
130
+ run_stdio(
131
+ args.config,
132
+ enable_admin_tools=args.enable_admin_tools,
133
+ output_mode=args.output_mode,
134
+ schema_profile=args.schema_profile,
135
+ )
136
+ elif args.command == "benchmark-report":
137
+ report = summarize(load_runs(args.input), baseline=args.baseline)
138
+ if args.output:
139
+ args.output.parent.mkdir(parents=True, exist_ok=True)
140
+ args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
141
+ _emit(report)
142
+ elif args.command == "release-materials":
143
+ _emit({key: str(value) for key, value in write_release_materials(Path.cwd(), args.output).items()})
144
+ elif args.command == "quickstart":
145
+ return _run_quickstart(args)
146
+ return 0
147
+ except (ConfigError, ValueError, RuntimeError, OSError) as error:
148
+ logging.getLogger("token_context_mcp").error("%s", error)
149
+ return 2
150
+
151
+
152
+ class _NdjsonProgress:
153
+ """Progress lines for ``index --progress-format ndjson``: at most ten per second, ``done`` always goes out."""
154
+
155
+ MIN_INTERVAL = 0.1
156
+
157
+ def __init__(self, stream: TextIO | None = None, repo_id: str | None = None) -> None:
158
+ self._stream = stream or sys.stdout
159
+ self._repo_id = repo_id
160
+ self._last = 0.0
161
+
162
+ def __call__(self, message: str, current: int, total: int) -> None:
163
+ stage = stage_of_message(message)
164
+ if stage == "done": # the one "done" line carries the manifest and is written by done()
165
+ return
166
+ now = time.monotonic()
167
+ if now - self._last < self.MIN_INTERVAL:
168
+ return
169
+ self._last = now
170
+ self._write({"stage": stage, "current": current, "total": total})
171
+
172
+ def done(self, manifest: dict[str, object]) -> None:
173
+ self._write({"stage": "done", "manifest": manifest})
174
+
175
+ def _write(self, payload: dict[str, object]) -> None:
176
+ line = {**payload, "ts": round(time.time(), 3)}
177
+ if self._repo_id is not None:
178
+ line["repo_id"] = self._repo_id
179
+ self._stream.write(json.dumps(line, sort_keys=True, default=str) + "\n")
180
+ self._stream.flush()
181
+
182
+
183
+ def _run_quickstart(args: argparse.Namespace) -> int:
184
+ import re
185
+ if hasattr(sys.stdout, "reconfigure"):
186
+ try:
187
+ sys.stdout.reconfigure(encoding="utf-8")
188
+ except Exception:
189
+ pass
190
+
191
+ root = args.path.resolve()
192
+ if not root.exists() or not root.is_dir():
193
+ sys.stderr.write(f"Error: Directory does not exist: {root}\n")
194
+ return 1
195
+
196
+ repo_id = args.repo_id
197
+ if not repo_id:
198
+ repo_id = re.sub(r"[^a-zA-Z0-9_\-]", "-", root.name.lower()).strip("-")
199
+ if not repo_id:
200
+ repo_id = "default-repo"
201
+
202
+ print("=" * 65)
203
+ print(" >> Token Context MCP — 1-Click Quickstart Setup")
204
+ print(f" [+] Repository: {root}")
205
+ print(f" [+] Repo ID: {repo_id}")
206
+ print("=" * 65)
207
+
208
+ # 1. Register
209
+ register_repository(args.config, repo_id, root)
210
+
211
+ # 2. Build Index
212
+ started = time.perf_counter()
213
+ directory = index_directory(args.config)
214
+ repository = get_repository(load_config(args.config), repo_id)
215
+ manifest = build_index(
216
+ repository,
217
+ directory,
218
+ network_policy="declared-deny-not-enforced",
219
+ full_rebuild=args.full,
220
+ )
221
+ elapsed = time.perf_counter() - started
222
+ files_indexed = manifest.get("files_indexed", 0)
223
+ symbols_indexed = manifest.get("symbols_indexed", 0)
224
+ edges_indexed = manifest.get("edges_indexed", 0)
225
+
226
+ print(f" [OK] Index completed in {elapsed:.2f}s!")
227
+ print(f" - Files indexed: {files_indexed}")
228
+ print(f" - Symbols parsed: {symbols_indexed}")
229
+ print(f" - Dependency edges: {edges_indexed}")
230
+ print("-" * 65)
231
+ print(" [*] AI Coding Agent Connection Configs:")
232
+ print()
233
+ print(" 1. Claude Code CLI:")
234
+ print(" claude mcp add --transport stdio --scope user token-context -- uvx token-context-mcp serve")
235
+ print()
236
+ print(" 2. Claude Desktop / Cursor Settings:")
237
+ print(" {")
238
+ print(' "mcpServers": {')
239
+ print(' "token-context": {')
240
+ print(' "command": "uvx",')
241
+ print(' "args": ["token-context-mcp", "serve"]')
242
+ print(" }")
243
+ print(" }")
244
+ print(" }")
245
+ print()
246
+ print(" 3. Antigravity IDE / Gemini CLI:")
247
+ print(" {")
248
+ print(' "mcpServers": {')
249
+ print(' "token-context": {')
250
+ print(' "command": "uvx",')
251
+ print(' "args": ["token-context-mcp", "serve", "--output-mode", "text", "--schema-profile", "gemini_safe"]')
252
+ print(" }")
253
+ print(" }")
254
+ print(" }")
255
+ print("=" * 65)
256
+
257
+ if args.gui:
258
+ from token_context_mcp.gui.main import main as gui_main
259
+ return gui_main()
260
+
261
+ if args.serve:
262
+ print(f"[*] Starting MCP stdio server for '{repo_id}'...")
263
+ run_stdio(
264
+ args.config,
265
+ enable_admin_tools=False,
266
+ output_mode=None,
267
+ schema_profile="auto",
268
+ )
269
+ return 0
270
+
271
+
272
+ def _index_kwargs(args: argparse.Namespace) -> dict[str, object]:
273
+ kwargs: dict[str, object] = {"verify_hashes": args.verify_hashes, "full_rebuild": args.full}
274
+ if args.workers is not None:
275
+ kwargs["workers"] = max(1, args.workers)
276
+ return kwargs
277
+
278
+
279
+ def _run_index(args: argparse.Namespace) -> int:
280
+ config = load_config(args.config)
281
+ directory = index_directory(args.config)
282
+ ndjson = args.progress_format == "ndjson"
283
+ if args.all:
284
+ return _index_all(config, directory, args, ndjson)
285
+ repository = get_repository(config, args.repo_id)
286
+ progress = _NdjsonProgress(repo_id=None) if ndjson else None
287
+ kwargs = _index_kwargs(args)
288
+ if args.watch:
289
+ from token_context_mcp.index.watch import watch_index
290
+
291
+ def show(manifest: dict[str, object]) -> None:
292
+ if progress is not None:
293
+ progress.done(manifest)
294
+ else:
295
+ _emit(manifest)
296
+
297
+ # each run gets a fresh progress callback so the rate limiter starts clean
298
+ watch_index(
299
+ repository,
300
+ directory,
301
+ network_policy=args.network_policy,
302
+ debounce_seconds=args.debounce,
303
+ poll_seconds=args.poll_interval,
304
+ on_index=show,
305
+ index_kwargs={**kwargs, **({"progress_callback": progress} if progress is not None else {})},
306
+ )
307
+ return 0
308
+ manifest = build_index(
309
+ repository,
310
+ directory,
311
+ network_policy=args.network_policy,
312
+ progress_callback=progress,
313
+ **kwargs,
314
+ )
315
+ if progress is not None:
316
+ progress.done(manifest)
317
+ else:
318
+ _emit(manifest)
319
+ return 0
320
+
321
+
322
+ def _index_all(config: object, directory: Path, args: argparse.Namespace, ndjson: bool) -> int:
323
+ repositories = getattr(config, "repositories")
324
+ if args.watch:
325
+ raise ValueError("--watch works on one repository; use --repo-id")
326
+ kwargs = _index_kwargs(args)
327
+ results: list[dict[str, object]] = []
328
+ for repo_id in sorted(repositories):
329
+ started = time.perf_counter()
330
+ progress = _NdjsonProgress(repo_id=repo_id) if ndjson else None
331
+ try:
332
+ manifest = build_index(
333
+ repositories[repo_id],
334
+ directory,
335
+ network_policy=args.network_policy,
336
+ progress_callback=progress,
337
+ **kwargs,
338
+ )
339
+ except (ValueError, RuntimeError, OSError) as error:
340
+ logging.getLogger("token_context_mcp").error("index %s failed: %s", repo_id, error)
341
+ results.append({"repo_id": repo_id, "ok": False, "error": f"{type(error).__name__}: {error}"})
342
+ continue
343
+ if progress is not None:
344
+ progress.done(manifest)
345
+ results.append(
346
+ {
347
+ "repo_id": repo_id,
348
+ "ok": True,
349
+ "seconds": round(time.perf_counter() - started, 3),
350
+ "incremental": manifest.get("incremental"),
351
+ "files_indexed": manifest.get("files_indexed"),
352
+ "files_reparsed": manifest.get("files_reparsed"),
353
+ "symbols_indexed": manifest.get("symbols_indexed"),
354
+ "edges_indexed": manifest.get("edges_indexed"),
355
+ "index_run_id": manifest.get("index_run_id"),
356
+ }
357
+ )
358
+ failed = [item for item in results if not item["ok"]]
359
+ summary = {"repositories": results, "indexed": len(results) - len(failed), "failed": len(failed)}
360
+ if ndjson:
361
+ sys.stdout.write(json.dumps({"stage": "all_done", "summary": summary, "ts": round(time.time(), 3)}, sort_keys=True) + "\n")
362
+ sys.stdout.flush()
363
+ else:
364
+ _emit(summary)
365
+ return 1 if failed else 0
366
+
367
+
368
+ def _emit(value: object) -> None:
369
+ print(json.dumps(value, indent=2, sort_keys=True, default=str))
370
+
371
+
372
+ if __name__ == "__main__":
373
+ raise SystemExit(main())
@@ -0,0 +1,135 @@
1
+ """Per-client behaviour: output mode ``auto`` and schema profiles (M9.2, M9.3).
2
+
3
+ Nothing here changes what a tool does. ``auto`` only chooses where the payload travels (structuredContent or
4
+ the text block) and the ``gemini_safe`` profile only rewrites the JSON Schemas advertised in ``tools/list``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ OUTPUT_MODES = ("auto", "structured", "text", "legacy_dual")
15
+ SCHEMA_PROFILES = ("auto", "default", "gemini_safe")
16
+
17
+ # Clients verified (docs/CLIENT_MATRIX.md) to hand ``structuredContent`` to the model. Lower-case names.
18
+ STRUCTURED_OK_CLIENTS: frozenset[str] = frozenset({"claude-code"})
19
+
20
+ _GEMINI_MARKERS = ("gemini", "antigravity")
21
+
22
+
23
+ @dataclass
24
+ class ClientState:
25
+ """What the server learned about its client. One stdio connection per process, so one instance."""
26
+
27
+ name: str | None = None
28
+ version: str | None = None
29
+ lock: threading.Lock = None # type: ignore[assignment]
30
+
31
+ def __post_init__(self) -> None:
32
+ self.lock = threading.Lock()
33
+
34
+ def update(self, name: str | None, version: str | None) -> None:
35
+ with self.lock:
36
+ if name:
37
+ self.name = name
38
+ if version:
39
+ self.version = version
40
+
41
+
42
+ def resolve_output_mode(requested: str, client_name: str | None) -> str:
43
+ """``auto`` -> ``structured`` for a known-good client, otherwise ``text`` (never ``legacy_dual``)."""
44
+ if requested != "auto":
45
+ return requested
46
+ if client_name and client_name.strip().lower() in STRUCTURED_OK_CLIENTS:
47
+ return "structured"
48
+ return "text"
49
+
50
+
51
+ def resolve_schema_profile(requested: str, client_name: str | None) -> str:
52
+ if requested != "auto":
53
+ return requested
54
+ lowered = (client_name or "").lower()
55
+ return "gemini_safe" if any(marker in lowered for marker in _GEMINI_MARKERS) else "default"
56
+
57
+
58
+ # --- schema profile --------------------------------------------------------------------------------------------
59
+
60
+ def _non_null(branches: list[Any]) -> list[Any]:
61
+ return [b for b in branches if not (isinstance(b, dict) and b.get("type") == "null")]
62
+
63
+
64
+ def _resolve_ref(ref: str, defs: dict[str, Any]) -> Any:
65
+ name = ref.rsplit("/", 1)[-1]
66
+ return defs.get(name, {})
67
+
68
+
69
+ def _clean(node: Any, defs: dict[str, Any], depth: int = 0) -> Any:
70
+ if isinstance(node, list):
71
+ return [_clean(item, defs, depth) for item in node]
72
+ if not isinstance(node, dict):
73
+ return node
74
+ node = dict(node)
75
+ if "$ref" in node and depth < 8:
76
+ resolved = copy.deepcopy(_resolve_ref(node.pop("$ref"), defs))
77
+ merged = {**resolved, **node}
78
+ return _clean(merged, defs, depth + 1)
79
+ if isinstance(node.get("anyOf"), list):
80
+ branches = _non_null(node["anyOf"])
81
+ rest = {k: v for k, v in node.items() if k != "anyOf"}
82
+ if len(branches) == 1:
83
+ merged = {**_clean(branches[0], defs, depth), **rest}
84
+ if merged.get("default", 0) is None:
85
+ del merged["default"]
86
+ return merged
87
+ node = {**rest, "anyOf": [_clean(b, defs, depth) for b in branches]}
88
+ if isinstance(node.get("type"), list):
89
+ kinds = [k for k in node["type"] if k != "null"]
90
+ node["type"] = kinds[0] if len(kinds) == 1 else (kinds[0] if kinds else "string")
91
+ if "default" in node and node["default"] is None:
92
+ del node["default"]
93
+ if "properties" in node and isinstance(node["properties"], dict):
94
+ node["properties"] = {k: _ensure_type(_clean(v, defs, depth)) for k, v in node["properties"].items()}
95
+ if node.get("type") == "array":
96
+ node["items"] = _ensure_type(_clean(node["items"], defs, depth)) if "items" in node else {"type": "string"}
97
+ elif "items" in node:
98
+ node["items"] = _clean(node["items"], defs, depth)
99
+ for key in ("anyOf", "oneOf", "allOf"):
100
+ if isinstance(node.get(key), list):
101
+ node[key] = [_ensure_type(_clean(b, defs, depth)) for b in node[key]]
102
+ if "additionalProperties" in node and isinstance(node["additionalProperties"], dict):
103
+ node["additionalProperties"] = _clean(node["additionalProperties"], defs, depth)
104
+ node.pop("$defs", None)
105
+ return node
106
+
107
+
108
+ def _ensure_type(node: Any) -> Any:
109
+ if isinstance(node, dict) and not any(k in node for k in ("type", "anyOf", "oneOf", "allOf", "enum", "const")):
110
+ return {**node, "type": "string"}
111
+ return node
112
+
113
+
114
+ def apply_schema_profile(schema: dict[str, Any], profile: str, tool_name: str = "") -> dict[str, Any]:
115
+ """Return the schema advertised for ``profile``. ``default`` returns the input unchanged."""
116
+ if profile != "gemini_safe":
117
+ return schema
118
+ defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
119
+ cleaned = _clean(copy.deepcopy(schema), defs)
120
+ if tool_name == "memory_put":
121
+ props = cleaned.get("properties", {})
122
+ if "value" in props:
123
+ props["value"] = {"title": props["value"].get("title", "Value"), "type": "string",
124
+ "description": "Value to store; JSON text is parsed."}
125
+ return cleaned
126
+
127
+
128
+ def coerce_memory_value(value: Any, profile: str) -> Any:
129
+ """gemini_safe declares memory_put.value as a string; parse JSON text back, keep the string on failure."""
130
+ if profile != "gemini_safe" or not isinstance(value, str):
131
+ return value
132
+ try:
133
+ return json.loads(value)
134
+ except ValueError:
135
+ return value