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.
- token_context_mcp/__init__.py +4 -0
- token_context_mcp/__main__.py +5 -0
- token_context_mcp/cli.py +373 -0
- token_context_mcp/client_profile.py +135 -0
- token_context_mcp/config.py +320 -0
- token_context_mcp/constants.py +32 -0
- token_context_mcp/discovery/__init__.py +7 -0
- token_context_mcp/discovery/catalog.py +293 -0
- token_context_mcp/discovery/tools.py +114 -0
- token_context_mcp/gui/__init__.py +5 -0
- token_context_mcp/gui/bridge.py +894 -0
- token_context_mcp/gui/main.py +55 -0
- token_context_mcp/gui/main_window.py +312 -0
- token_context_mcp/gui/models.py +85 -0
- token_context_mcp/gui/perf.py +78 -0
- token_context_mcp/gui/theme.py +319 -0
- token_context_mcp/gui/widgets/__init__.py +3 -0
- token_context_mcp/gui/widgets/agents_tab.py +341 -0
- token_context_mcp/gui/widgets/cache_tab.py +222 -0
- token_context_mcp/gui/widgets/dashboard_tab.py +282 -0
- token_context_mcp/gui/widgets/loading_overlay.py +85 -0
- token_context_mcp/gui/widgets/repositories_tab.py +355 -0
- token_context_mcp/gui/widgets/settings_tab.py +181 -0
- token_context_mcp/gui/widgets/tasks_tab.py +191 -0
- token_context_mcp/gui/workers.py +130 -0
- token_context_mcp/index/__init__.py +2 -0
- token_context_mcp/index/freshness.py +24 -0
- token_context_mcp/index/gitinfo.py +43 -0
- token_context_mcp/index/hashing.py +17 -0
- token_context_mcp/index/runner.py +1738 -0
- token_context_mcp/index/sqlite_store.py +996 -0
- token_context_mcp/index/watch.py +144 -0
- token_context_mcp/kernel/__init__.py +36 -0
- token_context_mcp/kernel/context.py +88 -0
- token_context_mcp/kernel/pipeline.py +244 -0
- token_context_mcp/kernel/tools.py +116 -0
- token_context_mcp/mcp_compat.py +11 -0
- token_context_mcp/memory/__init__.py +7 -0
- token_context_mcp/memory/service.py +152 -0
- token_context_mcp/memory/store.py +558 -0
- token_context_mcp/models.py +130 -0
- token_context_mcp/parse/__init__.py +2 -0
- token_context_mcp/parse/jvm_calls.py +788 -0
- token_context_mcp/parse/jvm_types.py +680 -0
- token_context_mcp/parse/lexical_edges.py +1615 -0
- token_context_mcp/parse/lsp.py +28 -0
- token_context_mcp/parse/scip.py +27 -0
- token_context_mcp/parse/treesitter.py +2344 -0
- token_context_mcp/release.py +77 -0
- token_context_mcp/retrieve/__init__.py +2 -0
- token_context_mcp/retrieve/batch.py +160 -0
- token_context_mcp/retrieve/code_tokens.py +287 -0
- token_context_mcp/retrieve/context.py +40 -0
- token_context_mcp/retrieve/edge_stats.py +34 -0
- token_context_mcp/retrieve/expansion.py +252 -0
- token_context_mcp/retrieve/freshness.py +196 -0
- token_context_mcp/retrieve/graph_cache.py +199 -0
- token_context_mcp/retrieve/packet.py +577 -0
- token_context_mcp/retrieve/packet_inputs.py +263 -0
- token_context_mcp/retrieve/projection.py +100 -0
- token_context_mcp/retrieve/ranking.py +391 -0
- token_context_mcp/retrieve/serialization.py +99 -0
- token_context_mcp/retrieve/service.py +2634 -0
- token_context_mcp/retrieve/token_budget.py +42 -0
- token_context_mcp/retrieve/workflows.py +431 -0
- token_context_mcp/sampling/__init__.py +7 -0
- token_context_mcp/sampling/guardrail.py +67 -0
- token_context_mcp/sampling/hardware_probe.py +192 -0
- token_context_mcp/sampling/prompts.py +40 -0
- token_context_mcp/sampling/provider.py +87 -0
- token_context_mcp/sampling/router.py +247 -0
- token_context_mcp/sampling/schema.py +76 -0
- token_context_mcp/sampling/skeleton_hybrid.py +158 -0
- token_context_mcp/security/__init__.py +10 -0
- token_context_mcp/security/access_control.py +367 -0
- token_context_mcp/security/audit.py +244 -0
- token_context_mcp/security/content_policy.py +76 -0
- token_context_mcp/security/governance_store.py +265 -0
- token_context_mcp/security/local_privacy.py +263 -0
- token_context_mcp/security/path_policy.py +91 -0
- token_context_mcp/server.py +778 -0
- token_context_mcp/stubs/__init__.py +5 -0
- token_context_mcp/stubs/catalog.py +106 -0
- token_context_mcp/telemetry/__init__.py +2 -0
- token_context_mcp/telemetry/benchmark.py +137 -0
- token_context_mcp-0.3.5.dist-info/METADATA +238 -0
- token_context_mcp-0.3.5.dist-info/RECORD +90 -0
- token_context_mcp-0.3.5.dist-info/WHEEL +4 -0
- token_context_mcp-0.3.5.dist-info/entry_points.txt +4 -0
- token_context_mcp-0.3.5.dist-info/licenses/LICENSE +22 -0
token_context_mcp/cli.py
ADDED
|
@@ -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
|