tgrep-mcp 0.3.0

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.
package/server.mjs ADDED
@@ -0,0 +1,2274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tgrep-mcp - a stdio MCP server that exposes the tgrep search tool as MCP tools.
4
+ *
5
+ * tgrep answers regex queries from a prebuilt trigram index instead of scanning every file. This
6
+ * server keeps a search backend resident so that repeated queries do not pay process startup on
7
+ * every call.
8
+ *
9
+ * Transport: newline-delimited JSON-RPC 2.0 over stdin and stdout. stdout carries protocol frames
10
+ * only. Diagnostics and audit records go to stderr.
11
+ *
12
+ * Backends, tried in this order:
13
+ *
14
+ * 1. direct - a TCP JSON-RPC connection to a resident `tgrep serve` process. The port is
15
+ * advertised in <index-dir>/serve.json. This is the fast path.
16
+ * 2. cli - `tgrep <flags> -- <pattern> <target> --json` in a child process. Slower, because each
17
+ * call creates a process and reads the index again. This is the fallback.
18
+ *
19
+ * The direct protocol is not a documented tgrep interface. It is verified against the tgrep release
20
+ * named in VERIFIED_TGREP_VERSION, and any connection or protocol error falls back to the CLI
21
+ * backend. Set TGREP_DIRECT=off to use the CLI backend only.
22
+ *
23
+ * Configuration comes from the environment, from the workspace roots the client declares, and from
24
+ * the working directory. The server has no machine-specific defaults.
25
+ *
26
+ * Environment (all optional):
27
+ * TGREP_BIN tgrep executable. Default: <package dir>/bin/tgrep[.exe] when that
28
+ * file exists, otherwise `tgrep` resolved from PATH. An npm install
29
+ * does not ship the vendored binary, so set this or install tgrep.
30
+ * TGREP_INDEX_ROOT Directory for per-root indexes. Default: the platform cache
31
+ * directory, for example %LOCALAPPDATA%\tgrep-mcp\index on Windows.
32
+ * TGREP_DEFAULT_ROOT Root searched when a call omits `path`. Default: the client's
33
+ * declared workspace root, otherwise the working directory.
34
+ * TGREP_ALLOWED_ROOTS Separator-separated list of roots that may be searched. `*` allows
35
+ * any path. Default: the default root plus client workspace roots.
36
+ * TGREP_TIMEOUT_MS Per-operation timeout in milliseconds. Default: 60000. Index builds
37
+ * use four times this value.
38
+ * TGREP_TOOL_TIMEOUT_MS Deadline for one tool call. Default: 4x TGREP_TIMEOUT_MS + 30s.
39
+ * TGREP_DIRECT auto (default) or off.
40
+ * TGREP_DAEMON_WAIT_MS Time to wait for a newly started `tgrep serve` to become ready.
41
+ * Default: 15000.
42
+ * TGREP_MAX_CONCURRENCY Tool calls that may run at once, across roots. Default: 4.
43
+ * TGREP_MAX_CALLS_PER_MINUTE Call budget. 0 disables it. Default: 600.
44
+ * TGREP_MAX_RESULTS Upper bound for the maxResults argument. Default: 5000.
45
+ * TGREP_MAX_PER_FILE Upper bound for the maxPerFile argument. Default: 10000.
46
+ * TGREP_MAX_LINE_CHARS Per-match text cap. Default: 512.
47
+ * TGREP_MAX_OUTPUT_BYTES Per-call budget for match text. Default: 262144.
48
+ * TGREP_INDEX_STALE_MS Index age that raises a staleness warning. Default: 24 hours.
49
+ * TGREP_LOG_LEVEL quiet, normal (default), or debug.
50
+ */
51
+
52
+ import { spawn } from 'node:child_process'
53
+ import { createHash } from 'node:crypto'
54
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'
55
+ import { createConnection } from 'node:net'
56
+ import { homedir } from 'node:os'
57
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
58
+ import { fileURLToPath, pathToFileURL } from 'node:url'
59
+
60
+ const HERE = dirname(fileURLToPath(import.meta.url))
61
+ const SERVER_NAME = 'tgrep'
62
+
63
+ /**
64
+ * Server version, read from the package manifest so that serverInfo matches the published version.
65
+ * Falls back to a literal when the file is absent, which is the case for a single-file copy.
66
+ */
67
+ function resolveVersion() {
68
+ try {
69
+ const manifest = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'))
70
+ if (typeof manifest?.version === 'string' && manifest.version.length > 0) return manifest.version
71
+ } catch {
72
+ // no manifest available
73
+ }
74
+ return '0.3.0'
75
+ }
76
+
77
+ const VERSION = resolveVersion()
78
+
79
+ const SUPPORTED_PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
80
+ const FALLBACK_PROTOCOL = '2025-06-18'
81
+
82
+ const MAX_RESULTS_DEFAULT = 200
83
+ const RENDER_LINES = 60
84
+ const CLI_OUTPUT_CAP_BYTES = 16 * 1024 * 1024
85
+ const CLI_STDERR_CAP_BYTES = 256 * 1024
86
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Logging — stderr is the only safe channel, stdout is the protocol stream
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const LOG_LEVELS = { quiet: 0, normal: 1, debug: 2 }
93
+ let logLevel = LOG_LEVELS.normal
94
+
95
+ function log(message, level = 'normal') {
96
+ if (LOG_LEVELS[level] > logLevel) return
97
+ try {
98
+ process.stderr.write(`[tgrep-mcp] ${message}\n`)
99
+ } catch {
100
+ // stderr gone: nothing useful left to do about it
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Configuration — everything machine-specific enters here, from the environment
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Resolve an integer env var, clamped to [min, max]. An unparseable value falls back with a
110
+ * recorded issue instead of killing the server: a typo in a client config should degrade the tool,
111
+ * not make it disappear from the client's tool list.
112
+ */
113
+ function envInt(env, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
114
+ const raw = env[name]
115
+ if (raw === undefined || String(raw).trim() === '') return { value: fallback, issue: null }
116
+ const parsed = Number(String(raw).trim())
117
+ if (!Number.isFinite(parsed)) return { value: fallback, issue: `${name}="${raw}" is not a number; using ${fallback}` }
118
+ const clamped = Math.min(max, Math.max(min, Math.floor(parsed)))
119
+ return { value: clamped, issue: clamped === parsed ? null : `${name}=${raw} clamped to ${clamped}` }
120
+ }
121
+
122
+ function envString(env, name) {
123
+ const raw = env[name]
124
+ if (raw === undefined) return null
125
+ const text = String(raw).trim()
126
+ return text.length === 0 ? null : text
127
+ }
128
+
129
+ /** Per-user index location for this platform. Deliberately outside any repository. */
130
+ function defaultIndexRoot(env, platform, home) {
131
+ if (platform === 'win32') {
132
+ const base = env.LOCALAPPDATA || env.APPDATA || join(home, 'AppData', 'Local')
133
+ return join(base, 'tgrep-mcp', 'index')
134
+ }
135
+ if (platform === 'darwin') return join(home, 'Library', 'Caches', 'tgrep-mcp', 'index')
136
+ return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'tgrep-mcp', 'index')
137
+ }
138
+
139
+ /**
140
+ * Where the tgrep executable comes from, in priority order: an explicit TGREP_BIN, the binary
141
+ * vendored next to this file, then PATH. `source` is reported by tgrep_status so an operator can
142
+ * see which one won without reading this source.
143
+ */
144
+ function resolveBin(env, platform, here, exists = existsSync) {
145
+ const explicit = envString(env, 'TGREP_BIN')
146
+ if (explicit !== null) {
147
+ const candidate = resolve(explicit)
148
+ return { path: candidate, source: 'TGREP_BIN', absolute: true, present: exists(candidate) }
149
+ }
150
+ const vendored = join(here, 'bin', platform === 'win32' ? 'tgrep.exe' : 'tgrep')
151
+ if (exists(vendored)) return { path: vendored, source: 'vendored', absolute: true, present: true }
152
+ return { path: platform === 'win32' ? 'tgrep.exe' : 'tgrep', source: 'PATH', absolute: false, present: null }
153
+ }
154
+
155
+ /** Case-insensitive de-duplication on Windows, where two spellings can name one directory. */
156
+ function dedupePaths(paths, platform = process.platform) {
157
+ const seen = new Set()
158
+ const out = []
159
+ for (const path of paths) {
160
+ const abs = resolve(path)
161
+ const key = platform === 'win32' ? abs.toLowerCase() : abs
162
+ if (seen.has(key)) continue
163
+ seen.add(key)
164
+ out.push(abs)
165
+ }
166
+ return out
167
+ }
168
+
169
+ /**
170
+ * Parse the allow-list. `*` disables the check. Unset means "the default root and whatever
171
+ * workspace roots the client declares" — never "any path". Windows drive letters collide with the
172
+ * POSIX separator, so the split character is platform-dependent.
173
+ */
174
+ function parseAllowedRoots(raw, defaultRoot, platform = process.platform, cwd = process.cwd()) {
175
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
176
+ return { allowAll: false, roots: dedupePaths([resolve(cwd, defaultRoot)], platform), explicit: false }
177
+ }
178
+ const text = String(raw).trim()
179
+ if (text === '*') return { allowAll: true, roots: [], explicit: true }
180
+ const parts = text
181
+ .split(platform === 'win32' ? /[;,]/ : /[:,]/)
182
+ .map((part) => part.trim())
183
+ .filter((part) => part.length > 0)
184
+ .map((part) => resolve(cwd, part))
185
+ return { allowAll: false, roots: dedupePaths(parts, platform), explicit: true }
186
+ }
187
+
188
+ /**
189
+ * Build the effective configuration. Pure: it reads the environment and the filesystem, never
190
+ * writes, so it can be unit-tested for any platform without side effects.
191
+ */
192
+ function buildConfig(env = process.env, platform = process.platform, here = HERE, home = homedir(), cwd = process.cwd()) {
193
+ const issues = []
194
+ const take = (result) => {
195
+ if (result.issue !== null) issues.push(result.issue)
196
+ return result.value
197
+ }
198
+
199
+ const timeoutMs = take(envInt(env, 'TGREP_TIMEOUT_MS', 60000, { min: 1000, max: 3_600_000 }))
200
+ const defaultTimeoutMs = timeoutMs * 4 + 30_000
201
+ const rawDefaultRoot = envString(env, 'TGREP_DEFAULT_ROOT')
202
+ const defaultRoot = resolve(cwd, rawDefaultRoot ?? cwd)
203
+ const rawIndexRoot = envString(env, 'TGREP_INDEX_ROOT')
204
+ const indexRoot = resolve(cwd, rawIndexRoot ?? defaultIndexRoot(env, platform, home))
205
+ const allowed = parseAllowedRoots(env.TGREP_ALLOWED_ROOTS, defaultRoot, platform, cwd)
206
+ if (!allowed.allowAll && allowed.roots.length === 0) {
207
+ issues.push('TGREP_ALLOWED_ROOTS matched no usable path; falling back to the default root')
208
+ allowed.roots = dedupePaths([defaultRoot], platform)
209
+ }
210
+
211
+ const direct = (envString(env, 'TGREP_DIRECT') ?? 'auto').toLowerCase()
212
+ if (direct !== 'auto' && direct !== 'off') issues.push(`TGREP_DIRECT="${direct}" is not auto|off; using auto`)
213
+
214
+ const rawLogLevel = (envString(env, 'TGREP_LOG_LEVEL') ?? 'normal').toLowerCase()
215
+ if (!(rawLogLevel in LOG_LEVELS)) issues.push(`TGREP_LOG_LEVEL="${rawLogLevel}" is not quiet|normal|debug; using normal`)
216
+
217
+ return {
218
+ platform,
219
+ cwd,
220
+ bin: resolveBin(env, platform, here),
221
+ indexRoot,
222
+ defaultRoot,
223
+ defaultRootSource: rawDefaultRoot === null ? 'cwd' : 'TGREP_DEFAULT_ROOT',
224
+ indexRootSource: rawIndexRoot === null ? 'platform-cache' : 'TGREP_INDEX_ROOT',
225
+ allowed,
226
+ timeoutMs,
227
+ toolTimeoutMs: take(envInt(env, 'TGREP_TOOL_TIMEOUT_MS', defaultTimeoutMs, { min: 5000, max: 3_600_000 })),
228
+ daemonWaitMs: take(envInt(env, 'TGREP_DAEMON_WAIT_MS', 15000, { min: 1000, max: 300_000 })),
229
+ direct: direct === 'off' ? 'off' : 'auto',
230
+ maxConcurrency: take(envInt(env, 'TGREP_MAX_CONCURRENCY', 4, { min: 1, max: 64 })),
231
+ maxCallsPerMinute: take(envInt(env, 'TGREP_MAX_CALLS_PER_MINUTE', 600, { min: 0, max: 1_000_000 })),
232
+ maxResultsDefault: MAX_RESULTS_DEFAULT,
233
+ maxResultsCeiling: take(envInt(env, 'TGREP_MAX_RESULTS', 5000, { min: 1, max: 1_000_000 })),
234
+ maxPerFileCeiling: take(envInt(env, 'TGREP_MAX_PER_FILE', 10000, { min: 1, max: 1_000_000 })),
235
+ maxLineChars: take(envInt(env, 'TGREP_MAX_LINE_CHARS', 512, { min: 32, max: 100_000 })),
236
+ maxOutputBytes: take(envInt(env, 'TGREP_MAX_OUTPUT_BYTES', 256 * 1024, { min: 4096, max: 64 * 1024 * 1024 })),
237
+ indexStaleMs: take(envInt(env, 'TGREP_INDEX_STALE_MS', 24 * 60 * 60 * 1000, { min: 60_000, max: 365 * 24 * 60 * 60 * 1000 })),
238
+ logLevel: LOG_LEVELS[rawLogLevel] ?? LOG_LEVELS.normal,
239
+ issues,
240
+ }
241
+ }
242
+
243
+ const CONFIG = buildConfig()
244
+ logLevel = CONFIG.logLevel
245
+
246
+ /**
247
+ * The tgrep release whose serve protocol this server was verified against. The direct backend is
248
+ * undocumented internal API, so a different release is reported by tgrep_status as degraded rather
249
+ * than trusted silently.
250
+ */
251
+ const VERIFIED_TGREP_VERSION = '1.0.8'
252
+
253
+ // Negotiated during initialize; also reported by tgrep_status.
254
+ let MCP_PROTOCOL_VERSION = FALLBACK_PROTOCOL
255
+ let clientInfo = null
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Errors — one code vocabulary shared by text and structuredContent
259
+ // ---------------------------------------------------------------------------
260
+
261
+ const ERROR_CODES = Object.freeze({
262
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
263
+ PATH_NOT_ALLOWED: 'PATH_NOT_ALLOWED',
264
+ NOT_FOUND: 'NOT_FOUND',
265
+ BINARY_MISSING: 'BINARY_MISSING',
266
+ INDEX_FAILED: 'INDEX_FAILED',
267
+ BACKEND_UNAVAILABLE: 'BACKEND_UNAVAILABLE',
268
+ TIMEOUT: 'TIMEOUT',
269
+ CANCELLED: 'CANCELLED',
270
+ RATE_LIMITED: 'RATE_LIMITED',
271
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
272
+ })
273
+
274
+ /** A tool execution failure the caller can act on: stable code, optional hint and retry delay. */
275
+ class ToolError extends Error {
276
+ constructor(code, message, { hint, details, retryAfterSeconds } = {}) {
277
+ super(message)
278
+ this.name = 'ToolError'
279
+ this.code = code
280
+ this.hint = hint
281
+ this.details = details
282
+ this.retryAfterSeconds = retryAfterSeconds
283
+ }
284
+
285
+ toStructured() {
286
+ const error = { code: this.code, message: this.message }
287
+ if (this.hint !== undefined) error.hint = this.hint
288
+ if (this.details !== undefined && Object.keys(this.details).length > 0) error.details = this.details
289
+ if (this.retryAfterSeconds !== undefined) error.retryAfterSeconds = this.retryAfterSeconds
290
+ return error
291
+ }
292
+ }
293
+
294
+ /** A JSON-RPC level failure. Per the spec these cover unknown methods/tools and invalid arguments. */
295
+ class ProtocolError extends Error {
296
+ constructor(code, message, data) {
297
+ super(message)
298
+ this.name = 'ProtocolError'
299
+ this.code = code
300
+ this.data = data
301
+ }
302
+ }
303
+
304
+ function asToolError(error) {
305
+ if (error instanceof ToolError) return error
306
+ return new ToolError(ERROR_CODES.INTERNAL_ERROR, String(error?.message ?? error))
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Input validation
311
+ // ---------------------------------------------------------------------------
312
+
313
+ const ARG_SPECS = Object.freeze({
314
+ tgrep_search: {
315
+ pattern: { type: 'string', required: true, maxLength: 2048 },
316
+ path: { type: 'string', maxLength: 4096 },
317
+ glob: { type: 'string', maxLength: 512 },
318
+ ignoreCase: { type: 'boolean' },
319
+ maxPerFile: { type: 'integer', min: 1, ceiling: 'maxPerFileCeiling' },
320
+ maxResults: { type: 'integer', min: 1, ceiling: 'maxResultsCeiling' },
321
+ includeGitIgnored: { type: 'boolean' },
322
+ },
323
+ tgrep_index: {
324
+ path: { type: 'string', maxLength: 4096 },
325
+ },
326
+ tgrep_status: {
327
+ path: { type: 'string', maxLength: 4096 },
328
+ },
329
+ })
330
+
331
+ /**
332
+ * Validate a tools/call argument bag. Returns issues rather than throwing, so the caller decides
333
+ * whether a problem is a protocol error (bad arguments) or a tool error (bad world).
334
+ */
335
+ function validateArguments(toolName, raw, ceilings = {}) {
336
+ const spec = ARG_SPECS[toolName]
337
+ if (spec === undefined) {
338
+ return { ok: false, issues: [{ code: 'unknown_tool', message: `unknown tool: ${toolName}` }] }
339
+ }
340
+ const bag = raw === undefined || raw === null ? {} : raw
341
+ if (typeof bag !== 'object' || Array.isArray(bag)) {
342
+ return { ok: false, issues: [{ code: 'invalid_arguments', message: 'arguments must be an object' }] }
343
+ }
344
+
345
+ const issues = []
346
+ const value = {}
347
+ for (const key of Object.keys(bag)) {
348
+ if (!Object.hasOwn(spec, key)) {
349
+ issues.push({ code: 'unknown_argument', argument: key, message: `unknown argument "${key}"` })
350
+ continue
351
+ }
352
+ const rule = spec[key]
353
+ const given = bag[key]
354
+ if (given === undefined || given === null) continue
355
+
356
+ if (rule.type === 'string') {
357
+ if (typeof given !== 'string') {
358
+ issues.push({ code: 'invalid_type', argument: key, message: `"${key}" must be a string` })
359
+ continue
360
+ }
361
+ if (given.includes('\u0000')) {
362
+ issues.push({ code: 'invalid_value', argument: key, message: `"${key}" must not contain NUL` })
363
+ continue
364
+ }
365
+ if (rule.maxLength !== undefined && given.length > rule.maxLength) {
366
+ issues.push({ code: 'invalid_value', argument: key, message: `"${key}" exceeds ${rule.maxLength} characters` })
367
+ continue
368
+ }
369
+ value[key] = given
370
+ continue
371
+ }
372
+
373
+ if (rule.type === 'boolean') {
374
+ if (typeof given !== 'boolean') {
375
+ issues.push({ code: 'invalid_type', argument: key, message: `"${key}" must be a boolean` })
376
+ continue
377
+ }
378
+ value[key] = given
379
+ continue
380
+ }
381
+
382
+ if (rule.type === 'integer') {
383
+ if (typeof given !== 'number' || !Number.isInteger(given)) {
384
+ issues.push({ code: 'invalid_type', argument: key, message: `"${key}" must be an integer` })
385
+ continue
386
+ }
387
+ const ceiling = rule.ceiling === undefined ? Infinity : (ceilings[rule.ceiling] ?? Infinity)
388
+ if (given < (rule.min ?? -Infinity) || given > ceiling) {
389
+ issues.push({
390
+ code: 'invalid_value',
391
+ argument: key,
392
+ message: `"${key}" must be between ${rule.min ?? '-inf'} and ${ceiling}`,
393
+ })
394
+ continue
395
+ }
396
+ value[key] = given
397
+ continue
398
+ }
399
+ }
400
+
401
+ for (const [key, rule] of Object.entries(spec)) {
402
+ if (rule.required === true && value[key] === undefined) {
403
+ issues.push({ code: 'missing_argument', argument: key, message: `"${key}" is required` })
404
+ }
405
+ }
406
+ if (issues.length === 0 && value.pattern !== undefined && value.pattern.length === 0) {
407
+ issues.push({ code: 'invalid_value', argument: 'pattern', message: '"pattern" must not be empty' })
408
+ }
409
+
410
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, value }
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Paths — containment, identity, and display
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function normalizeForCompare(path, platform = process.platform) {
418
+ const abs = resolve(path)
419
+ return platform === 'win32' ? abs.toLowerCase() : abs
420
+ }
421
+
422
+ /**
423
+ * True when `child` is `parent` or lives under it. Path-aware on purpose: a plain string prefix would
424
+ * let `/srv/app` match `/srv/application`, and on Windows `relative()` returns an absolute path when
425
+ * the two sides are on different volumes, which no `..` check on a string would catch.
426
+ */
427
+ function isInside(parent, child, platform = process.platform) {
428
+ const from = normalizeForCompare(parent, platform)
429
+ const to = normalizeForCompare(child, platform)
430
+ if (from === to) return true
431
+ const rel = relative(from, to)
432
+ if (rel.length === 0) return true
433
+ if (isAbsolute(rel)) return false
434
+ return rel.split(/[\\/]/)[0] !== '..'
435
+ }
436
+
437
+ function checkPathAllowed(target, allowed, platform = process.platform) {
438
+ if (allowed.allowAll) return true
439
+ return allowed.roots.some((root) => isInside(root, target, platform))
440
+ }
441
+
442
+ /**
443
+ * Per-root index directory. The readable slug is decorative; the digest is the identity, so two roots
444
+ * that sanitize to the same slug (`/srv/a/b` and `/srv/a_b`) can never share one index and serve each
445
+ * other's files. Both parts fold case on Windows, where one directory has many names.
446
+ */
447
+ function indexDirFor(root, indexRoot = CONFIG.indexRoot, platform = process.platform) {
448
+ const abs = resolve(root)
449
+ const folded = platform === 'win32' ? abs.toLowerCase() : abs
450
+ const digest = createHash('sha256').update(folded).digest('hex').slice(0, 16)
451
+ const slug = basename(folded)
452
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
453
+ .replace(/^[_.-]+|[_.-]+$/g, '')
454
+ .slice(0, 40)
455
+ return join(indexRoot, `${slug || 'root'}-${digest}`)
456
+ }
457
+
458
+ /** Path relative to a root, forward-slashed for the wire. Throws if it escapes the root. */
459
+ function relativeScope(root, target, platform = process.platform) {
460
+ if (!isInside(root, target, platform)) {
461
+ throw new ToolError(ERROR_CODES.PATH_NOT_ALLOWED, `${target} is outside the index root ${root}`)
462
+ }
463
+ const rel = relative(resolve(root), resolve(target))
464
+ return rel.split(sep).join('/')
465
+ }
466
+
467
+ /** Normalize a reported path to a forward-slashed path relative to the search root. */
468
+ function displayPath(root, target, platform = process.platform) {
469
+ const abs = isAbsolute(target) ? target : resolve(root, target)
470
+ if (isInside(root, abs, platform)) return relative(resolve(root), resolve(abs)).split(sep).join('/')
471
+ return abs.split(sep).join('/')
472
+ }
473
+
474
+ /**
475
+ * The index a root belongs to: one index per outermost allowed root, so searching a subdirectory
476
+ * reuses the repository index with a narrower scope instead of building a second one.
477
+ */
478
+ function canonicalRootFor(target, allowed, platform = process.platform) {
479
+ if (allowed.allowAll) return resolve(target)
480
+ const containing = allowed.roots.filter((root) => isInside(root, target, platform))
481
+ if (containing.length === 0) return resolve(target)
482
+ return containing.sort((a, b) => a.length - b.length)[0]
483
+ }
484
+
485
+ /**
486
+ * Resolve the `path` argument into the root to search, the exact target, and the scope to narrow to.
487
+ * The allow-list is enforced here: this is the only door into the filesystem.
488
+ */
489
+ function resolveSearchTarget(rawPath, config, allowed, stat = statSync) {
490
+ const candidate = rawPath === undefined || rawPath === null || rawPath === ''
491
+ ? resolve(config.defaultRoot)
492
+ : resolve(config.cwd, String(rawPath))
493
+
494
+ if (!checkPathAllowed(candidate, allowed, config.platform)) {
495
+ const shown = allowed.allowAll ? '(unexpected: allow-all rejected)' : allowed.roots.join(', ')
496
+ throw new ToolError(
497
+ ERROR_CODES.PATH_NOT_ALLOWED,
498
+ `path is outside the allowed search roots: ${candidate}`,
499
+ {
500
+ hint: 'Search an allowed root, set TGREP_ALLOWED_ROOTS to include this path, or set it to "*" to allow any path.',
501
+ details: { allowedRoots: allowed.roots },
502
+ },
503
+ )
504
+ }
505
+
506
+ let info
507
+ try {
508
+ info = stat(candidate)
509
+ } catch (error) {
510
+ throw new ToolError(ERROR_CODES.NOT_FOUND, `path does not exist: ${candidate}`, {
511
+ hint: 'Pass an absolute path to a file or directory that exists.',
512
+ details: { cause: String(error?.message ?? error) },
513
+ })
514
+ }
515
+
516
+ const isDirectory = info.isDirectory()
517
+ if (!isDirectory && !info.isFile()) {
518
+ throw new ToolError(ERROR_CODES.VALIDATION_ERROR, `path is neither a file nor a directory: ${candidate}`)
519
+ }
520
+
521
+ const anchor = isDirectory ? candidate : dirname(candidate)
522
+ const root = canonicalRootFor(anchor, allowed, config.platform)
523
+ const scope = isDirectory ? candidate : dirname(candidate)
524
+ return { root, target: candidate, kind: isDirectory ? 'directory' : 'file', scope: relativeScope(root, scope, config.platform) }
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Output hygiene
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Strip control characters — a matched line is untrusted input to the client's terminal — and cap
533
+ * its length so one pathological line cannot dominate the context window.
534
+ */
535
+ function sanitizeText(value, maxChars) {
536
+ let text = String(value ?? '')
537
+ text = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
538
+ if (maxChars !== undefined && text.length > maxChars) text = `${text.slice(0, maxChars)}…`
539
+ return text
540
+ }
541
+
542
+ /**
543
+ * Compare two index-relative paths from the wire, which may arrive with either separator. Used to
544
+ * narrow a file search to exactly the requested file.
545
+ */
546
+ function sameRelativePath(a, b, platform = process.platform) {
547
+ const norm = (value) => {
548
+ const text = String(value ?? '')
549
+ .split(/[\\/]/)
550
+ .filter((part) => part.length > 0 && part !== '.')
551
+ .join('/')
552
+ return platform === 'win32' ? text.toLowerCase() : text
553
+ }
554
+ return norm(a) === norm(b)
555
+ }
556
+
557
+ /** Cap a match list by count and by total text bytes, reporting whichever bound bound first. */function capMatches(matches, limit, maxBytes) {
558
+ const kept = []
559
+ let bytes = 0
560
+ let truncatedByBytes = false
561
+ for (const match of matches) {
562
+ if (kept.length >= limit) break
563
+ const size = match.text.length + match.path.length + 16
564
+ if (kept.length > 0 && bytes + size > maxBytes) {
565
+ truncatedByBytes = true
566
+ break
567
+ }
568
+ bytes += size
569
+ kept.push(match)
570
+ }
571
+ return { kept, truncatedByBytes }
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Concurrency primitives
576
+ // ---------------------------------------------------------------------------
577
+
578
+ /** Counting semaphore with hand-off on release, so a waiter never loses a waking race. */
579
+ function createSemaphore(limit) {
580
+ let active = 0
581
+ const waiters = []
582
+ const release = () => {
583
+ const next = waiters.shift()
584
+ if (next !== undefined) next()
585
+ else active -= 1
586
+ }
587
+ return {
588
+ async acquire() {
589
+ if (active < limit) {
590
+ active += 1
591
+ return release
592
+ }
593
+ await new Promise((resolveWaiter) => waiters.push(resolveWaiter))
594
+ return release
595
+ },
596
+ snapshot: () => ({ limit, active, queued: waiters.length }),
597
+ }
598
+ }
599
+
600
+ /** Token bucket. The realistic failure mode is a runaway agent loop, not an attacker. */
601
+ function createRateLimiter(perMinute) {
602
+ let tokens = perMinute
603
+ let last = Date.now()
604
+ return {
605
+ take(now = Date.now()) {
606
+ if (perMinute <= 0) return { allowed: true }
607
+ // A clock that jumps backwards must not drain the budget: treat it as no elapsed time.
608
+ const elapsed = Math.max(0, now - last)
609
+ tokens = Math.min(perMinute, tokens + (elapsed / 60000) * perMinute)
610
+ last = Math.max(last, now)
611
+ if (tokens < 1) {
612
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(((1 - tokens) * 60000) / perMinute)) }
613
+ }
614
+ tokens -= 1
615
+ return { allowed: true, remaining: Math.floor(tokens) }
616
+ },
617
+ snapshot: () => ({ perMinute, tokens: Math.floor(tokens) }),
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Circuit breaker for the undocumented direct protocol. Without it every call against a broken
623
+ * protocol pays a full daemon spawn (~300ms) before falling back to the CLI.
624
+ */
625
+ function createCircuitBreaker({ threshold = 3, cooldownMs = 30000, maxCooldownMs = 300000 } = {}) {
626
+ let state = 'closed'
627
+ let failures = 0
628
+ let openedAt = 0
629
+ let cooldown = cooldownMs
630
+ return {
631
+ allow(now = Date.now()) {
632
+ if (state === 'closed') return true
633
+ if (state === 'half-open') return false
634
+ if (now - openedAt >= cooldown) {
635
+ state = 'half-open'
636
+ openedAt = now
637
+ return true
638
+ }
639
+ return false
640
+ },
641
+ recordSuccess() {
642
+ state = 'closed'
643
+ failures = 0
644
+ cooldown = cooldownMs
645
+ },
646
+ recordFailure(now = Date.now()) {
647
+ failures += 1
648
+ if (state === 'half-open' || failures >= threshold) {
649
+ state = 'open'
650
+ openedAt = now
651
+ cooldown = Math.min(maxCooldownMs, cooldown * 2)
652
+ }
653
+ },
654
+ snapshot: () => ({
655
+ state,
656
+ failures,
657
+ cooldownMs: cooldown,
658
+ opensInMs: state === 'open' ? Math.max(0, cooldown - (Date.now() - openedAt)) : 0,
659
+ }),
660
+ }
661
+ }
662
+
663
+ /** Cooperative cancellation, so a client's notifications/cancelled stops work we no longer owe. */
664
+ function createCancellationToken() {
665
+ const hooks = []
666
+ const token = {
667
+ cancelled: false,
668
+ onCancel(hook) {
669
+ if (token.cancelled) hook()
670
+ else hooks.push(hook)
671
+ },
672
+ cancel() {
673
+ if (token.cancelled) return
674
+ token.cancelled = true
675
+ for (const hook of hooks.splice(0)) {
676
+ try {
677
+ hook()
678
+ } catch {
679
+ // cancellation is best effort
680
+ }
681
+ }
682
+ },
683
+ }
684
+ return token
685
+ }
686
+
687
+ // ---------------------------------------------------------------------------
688
+ // Metrics and health
689
+ // ---------------------------------------------------------------------------
690
+
691
+ function percentile(sorted, fraction) {
692
+ if (sorted.length === 0) return null
693
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1))
694
+ return sorted[index]
695
+ }
696
+
697
+ const metrics = {
698
+ startedAt: Date.now(),
699
+ tools: new Map(), // name -> { calls, errors, latencies: number[] }
700
+ directAttempts: 0,
701
+ directSuccesses: 0,
702
+ directFailures: 0,
703
+ cliFallbacks: 0,
704
+ indexBuilds: 0,
705
+ rateLimited: 0,
706
+ }
707
+
708
+ const LATENCY_SAMPLES = 200
709
+
710
+ function recordToolCall(tool, elapsedMs, ok) {
711
+ let entry = metrics.tools.get(tool)
712
+ if (entry === undefined) {
713
+ entry = { calls: 0, errors: 0, latencies: [] }
714
+ metrics.tools.set(tool, entry)
715
+ }
716
+ entry.calls += 1
717
+ if (!ok) entry.errors += 1
718
+ entry.latencies.push(elapsedMs)
719
+ if (entry.latencies.length > LATENCY_SAMPLES) entry.latencies.shift()
720
+ }
721
+
722
+ function snapshotMetrics() {
723
+ const tools = {}
724
+ for (const [name, entry] of metrics.tools) {
725
+ const sorted = [...entry.latencies].sort((a, b) => a - b)
726
+ tools[name] = {
727
+ calls: entry.calls,
728
+ errors: entry.errors,
729
+ p50Ms: percentile(sorted, 0.5),
730
+ p95Ms: percentile(sorted, 0.95),
731
+ maxMs: sorted.length > 0 ? sorted[sorted.length - 1] : null,
732
+ }
733
+ }
734
+ return {
735
+ uptimeMs: Date.now() - metrics.startedAt,
736
+ tools,
737
+ direct: {
738
+ attempts: metrics.directAttempts,
739
+ successes: metrics.directSuccesses,
740
+ failures: metrics.directFailures,
741
+ fallbacks: metrics.cliFallbacks,
742
+ circuit: directBreaker.snapshot(),
743
+ },
744
+ indexBuilds: metrics.indexBuilds,
745
+ rateLimited: metrics.rateLimited,
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Collapse named checks into one verdict — the shape the best-practices guide asks for: a single
751
+ * status the operator can alert on, plus the per-check detail that explains it.
752
+ */
753
+ function aggregateHealth(checks) {
754
+ if (checks.some((check) => check.status === 'unhealthy')) return 'unhealthy'
755
+ if (checks.some((check) => check.status === 'degraded')) return 'degraded'
756
+ return 'healthy'
757
+ }
758
+
759
+ /** Every call writes one audit line to stderr: who asked for what, and how it ended. */
760
+ function audit(record) {
761
+ log(`audit ${JSON.stringify(record)}`, 'normal')
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Backend 1: direct JSON-RPC over TCP to a resident `tgrep serve`
766
+ // ---------------------------------------------------------------------------
767
+
768
+ /**
769
+ * One persistent newline-delimited JSON-RPC connection to a tgrep server.
770
+ *
771
+ * tgrep's wire format (verified against tgrep-cli/src/serve.rs at the release named in
772
+ * VERIFIED_TGREP_VERSION): one JSON object per
773
+ * line, `{jsonrpc, id, method, params}`, methods `search | files | status | reload`, every reply
774
+ * newline-terminated. The port is advertised in `<index-dir>/serve.json` as `{pid, port}`.
775
+ */
776
+ class DirectClient {
777
+ constructor(port) {
778
+ this.port = port
779
+ this.socket = null
780
+ this.buffer = ''
781
+ this.waiters = new Map()
782
+ this.nextId = 0
783
+ this.failed = null
784
+ }
785
+
786
+ connect(timeoutMs) {
787
+ return new Promise((resolveConnect, reject) => {
788
+ const socket = createConnection({ host: '127.0.0.1', port: this.port })
789
+ this.socket = socket
790
+ const timer = setTimeout(() => {
791
+ socket.destroy()
792
+ reject(new Error(`connect timeout after ${timeoutMs}ms`))
793
+ }, timeoutMs)
794
+ socket.once('connect', () => {
795
+ clearTimeout(timer)
796
+ socket.setNoDelay(true)
797
+ resolveConnect()
798
+ })
799
+ socket.once('error', (error) => {
800
+ clearTimeout(timer)
801
+ this.fail(error)
802
+ reject(error)
803
+ })
804
+ socket.on('data', (chunk) => this.onData(chunk))
805
+ socket.on('close', () => this.fail(new Error('connection closed by server')))
806
+ })
807
+ }
808
+
809
+ onData(chunk) {
810
+ this.buffer += chunk.toString('utf8')
811
+ let index
812
+ while ((index = this.buffer.indexOf('\n')) !== -1) {
813
+ const line = this.buffer.slice(0, index)
814
+ this.buffer = this.buffer.slice(index + 1)
815
+ if (line.trim().length === 0) continue
816
+ let message
817
+ try {
818
+ message = JSON.parse(line)
819
+ } catch (error) {
820
+ log(`dropped unparseable server frame: ${String(error?.message ?? error)}`, 'debug')
821
+ continue
822
+ }
823
+ const waiter = this.waiters.get(message.id)
824
+ if (waiter === undefined) continue
825
+ this.waiters.delete(message.id)
826
+ waiter(message)
827
+ }
828
+ }
829
+
830
+ /** Reject every in-flight request once the connection is unusable. */
831
+ fail(error) {
832
+ if (this.failed === null) this.failed = error
833
+ for (const waiter of this.waiters.values()) waiter({ __transportError: error })
834
+ this.waiters.clear()
835
+ }
836
+
837
+ call(method, params, timeoutMs) {
838
+ const socket = this.socket
839
+ if (this.failed !== null) return Promise.resolve({ __transportError: this.failed })
840
+ if (socket === null || socket.destroyed) {
841
+ return Promise.resolve({ __transportError: new Error('not connected') })
842
+ }
843
+ const id = ++this.nextId
844
+ return new Promise((resolveCall) => {
845
+ const timer = setTimeout(() => {
846
+ this.waiters.delete(id)
847
+ resolveCall({ __transportError: new Error(`${method} timed out after ${timeoutMs}ms`) })
848
+ }, timeoutMs)
849
+ this.waiters.set(id, (message) => {
850
+ clearTimeout(timer)
851
+ resolveCall(message)
852
+ })
853
+ try {
854
+ socket.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
855
+ } catch (error) {
856
+ clearTimeout(timer)
857
+ this.waiters.delete(id)
858
+ resolveCall({ __transportError: error })
859
+ }
860
+ })
861
+ }
862
+
863
+ close() {
864
+ try {
865
+ this.socket?.destroy()
866
+ } catch {
867
+ // best effort
868
+ }
869
+ this.fail(new Error('client closed'))
870
+ }
871
+ }
872
+
873
+ /** root -> { client, pid, status } — one daemon per searched root. */
874
+ const daemons = new Map()
875
+ const seenRoots = new Set()
876
+
877
+ function indexPathFor(root, config = CONFIG) {
878
+ return join(indexDirFor(root, config.indexRoot, config.platform), 'meta.json')
879
+ }
880
+
881
+ function serveInfoPathFor(root, config = CONFIG) {
882
+ return join(indexDirFor(root, config.indexRoot, config.platform), 'serve.json')
883
+ }
884
+
885
+ function indexAgeMs(root, config = CONFIG) {
886
+ try {
887
+ return Date.now() - statSync(indexPathFor(root, config)).mtimeMs
888
+ } catch {
889
+ return null
890
+ }
891
+ }
892
+
893
+ function readServeInfo(root) {
894
+ try {
895
+ const info = JSON.parse(readFileSync(serveInfoPathFor(root), 'utf8'))
896
+ return typeof info?.port === 'number' ? info : null
897
+ } catch {
898
+ return null
899
+ }
900
+ }
901
+
902
+ async function probeExistingServer(root) {
903
+ const info = readServeInfo(root)
904
+ if (info === null) return null
905
+ const client = new DirectClient(info.port)
906
+ try {
907
+ await client.connect(3000)
908
+ const status = await client.call('status', {}, 5000)
909
+ if (status.__transportError !== undefined || status.error !== undefined) throw new Error('status failed')
910
+ return { client, pid: info.pid, spawned: false }
911
+ } catch (error) {
912
+ client.close()
913
+ log(`existing server for ${root} unusable (${String(error?.message ?? error)}); will start our own`)
914
+ return null
915
+ }
916
+ }
917
+
918
+ /** Read-only liveness probe for health checks: never spawns a daemon. */
919
+ async function pingDaemon(root) {
920
+ const info = readServeInfo(root)
921
+ if (info === null) return { reachable: false, reason: 'no serve.json' }
922
+ const client = new DirectClient(info.port)
923
+ try {
924
+ await client.connect(2000)
925
+ const status = await client.call('status', {}, 4000)
926
+ client.close()
927
+ if (status.__transportError !== undefined) {
928
+ return { reachable: false, reason: String(status.__transportError.message ?? status.__transportError) }
929
+ }
930
+ if (status.error !== undefined) return { reachable: false, reason: JSON.stringify(status.error) }
931
+ return { reachable: true, pid: info.pid, port: info.port }
932
+ } catch (error) {
933
+ client.close()
934
+ return { reachable: false, reason: String(error?.message ?? error) }
935
+ }
936
+ }
937
+
938
+ async function spawnServer(root, config, token) {
939
+ const indexDir = indexDirFor(root, config.indexRoot, config.platform)
940
+ mkdirSync(indexDir, { recursive: true })
941
+ // A stale serve.json from a dead server would make us connect to the wrong (or no) port.
942
+ rmSync(serveInfoPathFor(root, config), { force: true })
943
+ const child = spawn(config.bin.path, ['serve', '--index-path', indexDir, root], {
944
+ windowsHide: true,
945
+ stdio: ['ignore', 'ignore', 'pipe'],
946
+ })
947
+ token?.onCancel(() => {
948
+ try {
949
+ child.kill()
950
+ } catch {
951
+ // best effort
952
+ }
953
+ })
954
+ let stderrTail = ''
955
+ child.stderr.on('data', (chunk) => {
956
+ stderrTail = `${stderrTail}${chunk.toString('utf8')}`.slice(-2000)
957
+ })
958
+ child.on('error', (error) => log(`daemon spawn error: ${String(error?.message ?? error)}`))
959
+
960
+ const deadline = Date.now() + config.daemonWaitMs
961
+ while (Date.now() < deadline) {
962
+ if (token?.cancelled === true) throw new ToolError(ERROR_CODES.CANCELLED, 'cancelled while starting the search daemon')
963
+ if (child.exitCode !== null) {
964
+ throw new ToolError(ERROR_CODES.BINARY_MISSING, `tgrep server exited early (code ${child.exitCode})`, {
965
+ hint: `Check that TGREP_BIN points at a working tgrep binary (currently ${config.bin.path}).`,
966
+ details: { stderr: stderrTail.trim() },
967
+ })
968
+ }
969
+ const info = readServeInfo(root)
970
+ if (info !== null) {
971
+ const client = new DirectClient(info.port)
972
+ try {
973
+ await client.connect(1500)
974
+ const status = await client.call('status', {}, 5000)
975
+ if (status.__transportError === undefined && status.error === undefined) {
976
+ log(`daemon ready for ${root} (pid ${info.pid}, port ${info.port})`, 'debug')
977
+ return { client, pid: info.pid, child, spawned: true }
978
+ }
979
+ } catch {
980
+ client.close()
981
+ }
982
+ }
983
+ await new Promise((resolveWait) => setTimeout(resolveWait, 150))
984
+ }
985
+ try {
986
+ child.kill()
987
+ } catch {
988
+ // best effort
989
+ }
990
+ throw new ToolError(
991
+ ERROR_CODES.BACKEND_UNAVAILABLE,
992
+ `tgrep server did not become ready within ${config.daemonWaitMs}ms`,
993
+ { details: { stderr: stderrTail.trim() }, retryAfterSeconds: 5 },
994
+ )
995
+ }
996
+
997
+ async function ensureDaemon(root, config, token) {
998
+ const known = daemons.get(root)
999
+ if (known?.status === 'ready' && known.client.failed === null) return known
1000
+ if (known?.status === 'starting') return known.promise
1001
+ const entry = { status: 'starting', client: null, child: null, pid: null, spawned: false }
1002
+ entry.promise = (async () => {
1003
+ const reused = await probeExistingServer(root)
1004
+ const started = reused ?? (await spawnServer(root, config, token))
1005
+ entry.client = started.client
1006
+ entry.child = started.child ?? null
1007
+ entry.pid = started.pid
1008
+ entry.spawned = started.spawned === true
1009
+ entry.status = 'ready'
1010
+ daemons.set(root, entry)
1011
+ return entry
1012
+ })()
1013
+ daemons.set(root, entry)
1014
+ try {
1015
+ return await entry.promise
1016
+ } catch (error) {
1017
+ daemons.delete(root)
1018
+ throw error
1019
+ }
1020
+ }
1021
+
1022
+ /** Drop a daemon: used before a rebuild and at shutdown so no orphan holds the index. */
1023
+ function stopDaemon(root) {
1024
+ const entry = daemons.get(root)
1025
+ if (entry === undefined) return false
1026
+ entry.client?.close()
1027
+ const own = entry.child !== null && entry.child !== undefined
1028
+ if (own && entry.child.exitCode === null) {
1029
+ try {
1030
+ entry.child.kill()
1031
+ } catch {
1032
+ // best effort
1033
+ }
1034
+ }
1035
+ // Only a daemon we started leaves an advertisement we are entitled to remove; a reused one
1036
+ // belongs to whoever started it, so leave its serve.json alone.
1037
+ if (own) rmSync(serveInfoPathFor(root), { force: true })
1038
+ daemons.delete(root)
1039
+ return own
1040
+ }
1041
+
1042
+ function shutdownAll() {
1043
+ for (const root of [...daemons.keys()]) stopDaemon(root)
1044
+ }
1045
+
1046
+ async function searchDirect(root, plan, args, config, token) {
1047
+ const entry = await ensureDaemon(root, config, token)
1048
+ const params = {
1049
+ pattern: args.pattern,
1050
+ // The protocol returns span and column arrays by default. This tool does not display them, and
1051
+ // building them dominates response time on large result sets, so both are disabled.
1052
+ detail: false,
1053
+ positions: false,
1054
+ }
1055
+ // The protocol scopes directories only: a file target narrows to its directory, then the rows are
1056
+ // filtered to the exact file below.
1057
+ if (plan.scope.length > 0) params.scope = plan.scope
1058
+ if (typeof args.glob === 'string' && args.glob.length > 0) params.glob = [args.glob]
1059
+ if (args.ignoreCase === true) params.case_insensitive = true
1060
+ if (Number.isFinite(args.maxPerFile) && args.maxPerFile > 0) params.max_count = Math.floor(args.maxPerFile)
1061
+
1062
+ const started = Date.now()
1063
+ const response = await entry.client.call('search', params, config.timeoutMs)
1064
+ const elapsedMs = Date.now() - started
1065
+ if (response.__transportError !== undefined) {
1066
+ stopDaemon(root)
1067
+ throw response.__transportError
1068
+ }
1069
+ if (response.error !== undefined) {
1070
+ throw new ToolError(ERROR_CODES.BACKEND_UNAVAILABLE, `tgrep server refused the search: ${JSON.stringify(response.error)}`)
1071
+ }
1072
+
1073
+ const result = response.result ?? {}
1074
+ const rows = Array.isArray(result.matches) ? result.matches : []
1075
+ // The wire scopes directories only, so a file search narrows to its directory and filters here.
1076
+ const targetRel = plan.kind === 'file'
1077
+ ? (plan.scope.length === 0 ? basename(plan.target) : `${plan.scope}/${basename(plan.target)}`)
1078
+ : null
1079
+
1080
+ const matches = []
1081
+ const files = new Set()
1082
+ for (const row of rows) {
1083
+ if (row === null || typeof row !== 'object' || typeof row.file !== 'string') continue
1084
+ const path = row.file.split(sep).join('/').replace(/^\.\//, '')
1085
+ if (targetRel !== null && !sameRelativePath(path, targetRel, config.platform)) continue
1086
+ files.add(path)
1087
+ matches.push({
1088
+ path,
1089
+ line: typeof row.line === 'number' ? row.line : 0,
1090
+ text: sanitizeText(row.content, config.maxLineChars),
1091
+ })
1092
+ }
1093
+
1094
+ return {
1095
+ backend: 'direct',
1096
+ elapsedMs,
1097
+ engineMs: typeof result.elapsed_ms === 'number' ? result.elapsed_ms : undefined,
1098
+ total: targetRel === null
1099
+ ? (typeof result.num_matches === 'number' ? Math.max(result.num_matches, matches.length) : matches.length)
1100
+ : matches.length,
1101
+ files: files.size,
1102
+ matches,
1103
+ daemonPid: entry.pid,
1104
+ reusedDaemon: entry.spawned === false,
1105
+ }
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // Backend 2: CLI fallback (`tgrep ... --json` per search)
1110
+ // ---------------------------------------------------------------------------
1111
+
1112
+ /**
1113
+ * Run the tgrep binary once with a plain argv vector (no shell, so no quoting layer) and collect
1114
+ * bounded stdout/stderr. Resolves — never rejects — so a spawn failure becomes a reportable result.
1115
+ */
1116
+ function runTgrep(argv, timeoutMs, token, bin = CONFIG.bin.path) {
1117
+ return new Promise((resolveRun) => {
1118
+ let child
1119
+ try {
1120
+ child = spawn(bin, argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
1121
+ } catch (error) {
1122
+ resolveRun({ exitCode: null, stdout: '', stderr: String(error?.message ?? error), timedOut: false, spawnError: true, cancelled: false })
1123
+ return
1124
+ }
1125
+ const out = []
1126
+ const err = []
1127
+ let outBytes = 0
1128
+ let errBytes = 0
1129
+ let timedOut = false
1130
+ const timer = setTimeout(() => {
1131
+ timedOut = true
1132
+ child.kill()
1133
+ }, timeoutMs)
1134
+ token?.onCancel(() => {
1135
+ try {
1136
+ child.kill()
1137
+ } catch {
1138
+ // best effort
1139
+ }
1140
+ })
1141
+ child.stdout.on('data', (chunk) => {
1142
+ if (outBytes >= CLI_OUTPUT_CAP_BYTES) return
1143
+ outBytes += chunk.length
1144
+ out.push(chunk)
1145
+ })
1146
+ child.stderr.on('data', (chunk) => {
1147
+ if (errBytes >= CLI_STDERR_CAP_BYTES) return
1148
+ errBytes += chunk.length
1149
+ err.push(chunk)
1150
+ })
1151
+ child.on('error', (error) => {
1152
+ clearTimeout(timer)
1153
+ resolveRun({ exitCode: null, stdout: '', stderr: String(error?.message ?? error), timedOut, spawnError: true, cancelled: token?.cancelled === true })
1154
+ })
1155
+ child.on('close', (code) => {
1156
+ clearTimeout(timer)
1157
+ resolveRun({
1158
+ exitCode: code,
1159
+ stdout: Buffer.concat(out).toString('utf8'),
1160
+ stderr: Buffer.concat(err).toString('utf8'),
1161
+ timedOut,
1162
+ spawnError: false,
1163
+ cancelled: token?.cancelled === true,
1164
+ })
1165
+ })
1166
+ })
1167
+ }
1168
+
1169
+ /** Parse ripgrep-compatible `--json` output into flat matches. */
1170
+ function parseMatches(stdout) {
1171
+ const matches = []
1172
+ for (const raw of stdout.split('\n')) {
1173
+ if (raw.length === 0 || raw.charCodeAt(0) !== 0x7b) continue
1174
+ let record
1175
+ try {
1176
+ record = JSON.parse(raw)
1177
+ } catch {
1178
+ continue
1179
+ }
1180
+ const data = record?.type === 'match' ? record.data : undefined
1181
+ if (data === undefined) continue
1182
+ matches.push({
1183
+ path: typeof data.path?.text === 'string' ? data.path.text : '',
1184
+ line: typeof data.line_number === 'number' ? data.line_number : 0,
1185
+ text: (typeof data.lines?.text === 'string' ? data.lines.text : '').replace(/\r?\n$/, ''),
1186
+ })
1187
+ }
1188
+ return matches
1189
+ }
1190
+
1191
+ /** Classify tgrep's stderr so a degraded call is visible instead of silently slower. */
1192
+ function modeFromStderr(stderr) {
1193
+ const text = String(stderr ?? '')
1194
+ if (text.includes('falling back')) return 'local-index'
1195
+ if (text.includes('no index')) return 'scan'
1196
+ return 'indexed'
1197
+ }
1198
+
1199
+ async function searchCli(root, plan, args, config, token) {
1200
+ // Flags must precede `--`: tgrep reads everything after it as the pattern, so a search for the
1201
+ // bare word `index` or `serve` cannot be mistaken for a subcommand.
1202
+ const argv = ['--index-path', indexDirFor(root, config.indexRoot, config.platform), '--no-config']
1203
+ if (typeof args.glob === 'string' && args.glob.length > 0) argv.push('-g', args.glob)
1204
+ if (args.ignoreCase === true) argv.push('-i')
1205
+ if (Number.isFinite(args.maxPerFile) && args.maxPerFile > 0) argv.push('-m', String(Math.floor(args.maxPerFile)))
1206
+ if (args.includeGitIgnored === true) argv.push('--no-ignore-vcs')
1207
+ argv.push('--json', '--', args.pattern, plan.target)
1208
+
1209
+ const started = Date.now()
1210
+ const result = await runTgrep(argv, config.timeoutMs, token, config.bin.path)
1211
+ const elapsedMs = Date.now() - started
1212
+
1213
+ if (result.cancelled) throw new ToolError(ERROR_CODES.CANCELLED, 'search cancelled')
1214
+ if (result.spawnError) {
1215
+ throw new ToolError(ERROR_CODES.BINARY_MISSING, `could not start the tgrep executable: ${config.bin.path}`, {
1216
+ hint: 'Set TGREP_BIN to the tgrep executable, install tgrep on PATH, or vendor it at <server dir>/bin/tgrep(.exe).',
1217
+ details: { stderr: result.stderr.trim().slice(0, 500), source: config.bin.source },
1218
+ })
1219
+ }
1220
+ if (result.timedOut) {
1221
+ throw new ToolError(ERROR_CODES.TIMEOUT, `tgrep search timed out after ${config.timeoutMs}ms`, {
1222
+ hint: 'Narrow the pattern, add a glob, or scope the search to a subdirectory.',
1223
+ retryAfterSeconds: 5,
1224
+ })
1225
+ }
1226
+ if (result.exitCode !== 0) {
1227
+ throw new ToolError(ERROR_CODES.BACKEND_UNAVAILABLE, `tgrep exited with code ${result.exitCode}`, {
1228
+ details: { stderr: result.stderr.trim().split('\n').slice(-3).join(' | ').slice(0, 500) },
1229
+ })
1230
+ }
1231
+
1232
+ const parsed = parseMatches(result.stdout)
1233
+ const files = new Set()
1234
+ const matches = []
1235
+ for (const item of parsed) {
1236
+ const path = displayPath(root, item.path, config.platform)
1237
+ files.add(path)
1238
+ matches.push({ path, line: item.line, text: sanitizeText(item.text, config.maxLineChars) })
1239
+ }
1240
+ return {
1241
+ backend: 'cli',
1242
+ elapsedMs,
1243
+ engineMs: undefined,
1244
+ total: matches.length,
1245
+ files: files.size,
1246
+ matches,
1247
+ mode: modeFromStderr(result.stderr),
1248
+ exitCode: result.exitCode,
1249
+ daemonPid: undefined,
1250
+ reusedDaemon: undefined,
1251
+ }
1252
+ }
1253
+
1254
+ // ---------------------------------------------------------------------------
1255
+ // Index lifecycle
1256
+ // ---------------------------------------------------------------------------
1257
+
1258
+ const indexState = new Map()
1259
+
1260
+ /** Build the index for a root, once per process unless `force` is set. */
1261
+ async function ensureIndex(root, force, config, token) {
1262
+ const existing = indexState.get(root)
1263
+ if (!force && existing?.status === 'ready') return existing
1264
+ // Without this, two callers racing on a cold root would both run a full build. With per-root
1265
+ // serialisation they should not, but a health check or a future caller could still overlap.
1266
+ if (!force && existing?.status === 'building') return existing.promise
1267
+ if (!force && existsSync(indexPathFor(root, config))) {
1268
+ const state = { status: 'ready', builtMs: null }
1269
+ indexState.set(root, state)
1270
+ return state
1271
+ }
1272
+
1273
+ const task = (async () => {
1274
+ mkdirSync(indexDirFor(root, config.indexRoot, config.platform), { recursive: true })
1275
+ const started = Date.now()
1276
+ metrics.indexBuilds += 1
1277
+ const result = await runTgrep(
1278
+ ['index', '--index-path', indexDirFor(root, config.indexRoot, config.platform), root],
1279
+ config.timeoutMs * 4,
1280
+ token,
1281
+ config.bin.path,
1282
+ )
1283
+ const state = {
1284
+ status: result.exitCode === 0 ? 'ready' : 'failed',
1285
+ builtMs: Date.now() - started,
1286
+ exitCode: result.exitCode,
1287
+ stderr: result.stderr.trim().split('\n').slice(-3).join(' | ').slice(0, 500),
1288
+ }
1289
+ indexState.set(root, state)
1290
+ log(`index ${root}: ${state.status} in ${state.builtMs}ms`)
1291
+ return state
1292
+ })()
1293
+ indexState.set(root, { status: 'building', promise: task })
1294
+ return task
1295
+ }
1296
+
1297
+ // ---------------------------------------------------------------------------
1298
+ // Rate limiting, concurrency and root locking
1299
+ // ---------------------------------------------------------------------------
1300
+
1301
+ const rateLimiter = createRateLimiter(CONFIG.maxCallsPerMinute)
1302
+ const globalGate = createSemaphore(CONFIG.maxConcurrency)
1303
+ const directBreaker = createCircuitBreaker()
1304
+ const rootQueues = new Map()
1305
+
1306
+ /**
1307
+ * Serialise work per root (an index build and a search must not overlap) while letting independent
1308
+ * roots proceed in parallel, up to the global concurrency budget.
1309
+ */
1310
+ function withRootLock(root, task, config = CONFIG) {
1311
+ const key = normalizeForCompare(root, config.platform)
1312
+ const previous = rootQueues.get(key) ?? Promise.resolve()
1313
+ const next = previous.then(task, task)
1314
+ const settled = next.then(() => undefined, () => undefined)
1315
+ rootQueues.set(key, settled)
1316
+ settled.then(() => {
1317
+ if (rootQueues.get(key) === settled) rootQueues.delete(key)
1318
+ })
1319
+ return next
1320
+ }
1321
+
1322
+ async function runExclusive(root, task, config = CONFIG) {
1323
+ return withRootLock(root, async () => {
1324
+ const release = await globalGate.acquire()
1325
+ try {
1326
+ return await task()
1327
+ } finally {
1328
+ release()
1329
+ }
1330
+ }, config)
1331
+ }
1332
+
1333
+ /** Reject rather than hang: a tool call that never answers is worse than one that fails. */
1334
+ function withDeadline(promise, ms, label) {
1335
+ let timer
1336
+ const timeout = new Promise((_, reject) => {
1337
+ timer = setTimeout(() => {
1338
+ reject(new ToolError(ERROR_CODES.TIMEOUT, `${label} exceeded its ${ms}ms deadline`, {
1339
+ hint: 'Narrow the request, or raise TGREP_TOOL_TIMEOUT_MS.',
1340
+ }))
1341
+ }, ms)
1342
+ })
1343
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
1344
+ }
1345
+
1346
+ // ---------------------------------------------------------------------------
1347
+ // Client-declared workspace roots (MCP `roots`)
1348
+ // ---------------------------------------------------------------------------
1349
+
1350
+ let clientRoots = []
1351
+ let clientSupportsRoots = false
1352
+
1353
+ function uriToPath(uri) {
1354
+ if (typeof uri !== 'string' || !uri.startsWith('file://')) return null
1355
+ try {
1356
+ return fileURLToPath(uri)
1357
+ } catch {
1358
+ return null
1359
+ }
1360
+ }
1361
+
1362
+ /**
1363
+ * The client's workspace roots are the most accurate answer to "what should this server search",
1364
+ * so they outrank both the cwd default and the configured allow-list. A client that does not
1365
+ * declare the roots capability is never asked, so nothing changes for it.
1366
+ */
1367
+ function effectiveDefaultRoot(config = CONFIG) {
1368
+ if (config.defaultRootSource === 'TGREP_DEFAULT_ROOT') return config.defaultRoot
1369
+ if (clientRoots.length > 0) return clientRoots[0]
1370
+ return config.defaultRoot
1371
+ }
1372
+
1373
+ function effectiveAllowed(config = CONFIG) {
1374
+ if (config.allowed.allowAll) return { allowAll: true, roots: [] }
1375
+ return { allowAll: false, roots: dedupePaths([...config.allowed.roots, ...clientRoots], config.platform) }
1376
+ }
1377
+
1378
+ function configForRequest(config = CONFIG) {
1379
+ return { ...config, defaultRoot: effectiveDefaultRoot(config) }
1380
+ }
1381
+
1382
+ // ---------------------------------------------------------------------------
1383
+ // Tools
1384
+ // ---------------------------------------------------------------------------
1385
+
1386
+ const MATCH_SCHEMA = Object.freeze({
1387
+ type: 'object',
1388
+ properties: {
1389
+ path: { type: 'string', description: 'Path relative to the search root, forward-slashed.' },
1390
+ line: { type: 'integer', description: '1-based line number.' },
1391
+ text: { type: 'string', description: 'Matching line, control characters stripped and length-capped.' },
1392
+ },
1393
+ required: ['path', 'line', 'text'],
1394
+ additionalProperties: false,
1395
+ })
1396
+
1397
+ const ERROR_SCHEMA = Object.freeze({
1398
+ type: 'object',
1399
+ properties: {
1400
+ code: { type: 'string', enum: Object.values(ERROR_CODES) },
1401
+ message: { type: 'string' },
1402
+ hint: { type: 'string' },
1403
+ details: { type: 'object' },
1404
+ retryAfterSeconds: { type: 'number' },
1405
+ },
1406
+ required: ['code', 'message'],
1407
+ additionalProperties: false,
1408
+ })
1409
+
1410
+ /**
1411
+ * The three output schemas below are `{ oneOf: [...] }` with NO sibling keywords, on purpose.
1412
+ *
1413
+ * The harness enforces a JSON Schema subset (dsh-tools assertSupportedJsonSchema, reached through
1414
+ * dsh-mcp-client's supportedOutputSchema) that rejects a node declaring `type`, `properties`,
1415
+ * `required`, `additionalProperties`, `items`, `enum` or `const` *beside* `oneOf` — and a rejected
1416
+ * schema is silently discarded, which would quietly relax structuredContent from required back to
1417
+ * optional without anyone noticing. So every branch is a complete object schema standing alone, and
1418
+ * the discriminating `const` carries its own `type` (the subset requires a type-correct const).
1419
+ */
1420
+ const OK_BRANCH = (properties, required) => ({
1421
+ type: 'object',
1422
+ properties: { ok: { type: 'boolean', const: true }, ...properties },
1423
+ required: ['ok', ...required],
1424
+ additionalProperties: false,
1425
+ })
1426
+
1427
+ const ERROR_BRANCH = {
1428
+ type: 'object',
1429
+ properties: { ok: { type: 'boolean', const: false }, error: ERROR_SCHEMA },
1430
+ required: ['ok', 'error'],
1431
+ additionalProperties: false,
1432
+ }
1433
+
1434
+ const SEARCH_SUCCESS_PROPERTIES = {
1435
+ root: { type: 'string', description: 'Indexed root the search ran against.' },
1436
+ target: { type: 'string', description: 'Exact file or directory searched.' },
1437
+ kind: { type: 'string', enum: ['directory', 'file'] },
1438
+ backend: { type: 'string', enum: ['direct', 'cli'] },
1439
+ elapsedMs: { type: 'number' },
1440
+ engineMs: { type: 'number' },
1441
+ buildMs: { type: 'number' },
1442
+ total: { type: 'integer', description: 'Matches tgrep reported before capping.' },
1443
+ files: { type: 'integer' },
1444
+ returned: { type: 'integer' },
1445
+ truncated: { type: 'boolean' },
1446
+ matches: { type: 'array', items: MATCH_SCHEMA },
1447
+ index: { type: 'string' },
1448
+ indexAgeMs: { type: 'number' },
1449
+ mode: { type: 'string' },
1450
+ daemonPid: { type: 'integer' },
1451
+ reusedDaemon: { type: 'boolean' },
1452
+ warnings: { type: 'array', items: { type: 'string' } },
1453
+ }
1454
+
1455
+ const SEARCH_OUTPUT_SCHEMA = Object.freeze({
1456
+ oneOf: [
1457
+ OK_BRANCH(SEARCH_SUCCESS_PROPERTIES, ['root', 'target', 'kind', 'backend', 'elapsedMs', 'total', 'files', 'returned', 'truncated', 'matches']),
1458
+ ERROR_BRANCH,
1459
+ ],
1460
+ })
1461
+
1462
+ const INDEX_OUTPUT_SCHEMA = Object.freeze({
1463
+ oneOf: [
1464
+ OK_BRANCH({
1465
+ root: { type: 'string' },
1466
+ status: { type: 'string', enum: ['ready', 'failed'] },
1467
+ builtMs: { type: 'number' },
1468
+ indexDir: { type: 'string' },
1469
+ indexAgeMs: { type: 'number' },
1470
+ daemonRestarted: { type: 'boolean' },
1471
+ }, ['root', 'status', 'indexDir']),
1472
+ ERROR_BRANCH,
1473
+ ],
1474
+ })
1475
+
1476
+ const STATUS_OUTPUT_SCHEMA = Object.freeze({
1477
+ oneOf: [
1478
+ OK_BRANCH({
1479
+ status: { type: 'string', enum: ['healthy', 'degraded', 'unhealthy'] },
1480
+ version: { type: 'string' },
1481
+ checks: {
1482
+ type: 'array',
1483
+ items: {
1484
+ type: 'object',
1485
+ properties: {
1486
+ name: { type: 'string' },
1487
+ status: { type: 'string', enum: ['healthy', 'degraded', 'unhealthy'] },
1488
+ message: { type: 'string' },
1489
+ responseTimeMs: { type: 'number' },
1490
+ details: { type: 'object' },
1491
+ },
1492
+ required: ['name', 'status', 'message'],
1493
+ additionalProperties: false,
1494
+ },
1495
+ },
1496
+ config: { type: 'object' },
1497
+ metrics: { type: 'object' },
1498
+ }, ['status', 'version', 'checks', 'config', 'metrics']),
1499
+ ERROR_BRANCH,
1500
+ ],
1501
+ })
1502
+
1503
+ /**
1504
+ * Server instructions: the context an agent cannot get from a tool description. Kept to the
1505
+ * relationships between tools and the limits of the backend, never a restatement of the schemas.
1506
+ */
1507
+ const INSTRUCTIONS = [
1508
+ 'tgrep serves regex search over local code trees from a prebuilt trigram index, so it stays fast on',
1509
+ 'trees where scanning every file is slow. Use it for content search; prefer a plain scan when a',
1510
+ 'negative result is your conclusion, because tgrep does not honor ripgrep .ignore re-include rules',
1511
+ '(`!pattern`), and an empty result can therefore be wrong.',
1512
+ 'Freshness: search results reflect the index as of its last build or watcher event. After a branch',
1513
+ 'switch or a large pull, call tgrep_index before trusting an empty or stale-looking result.',
1514
+ 'Scope: searches are confined to the allowed roots. Call tgrep_status once to see the effective',
1515
+ 'search root, the allowed roots, and the index location on this machine; pass `path` explicitly when',
1516
+ 'the default root is not the tree you mean. Include gitignored files only with includeGitIgnored,',
1517
+ 'which forces the slower scan backend.',
1518
+ 'Recovery: read error.code before retrying. RATE_LIMITED and TIMEOUT carry retryAfterSeconds;',
1519
+ 'PATH_NOT_ALLOWED needs a different path or a config change rather than a retry; BINARY_MISSING',
1520
+ 'means tgrep itself is not installed where this server can find it.',
1521
+ ].join('\n')
1522
+
1523
+ const TOOLS = [
1524
+ {
1525
+ name: 'tgrep_search',
1526
+ title: 'Search a code tree (tgrep index)',
1527
+ description:
1528
+ 'Regex search over a code tree using tgrep, which answers from a prebuilt trigram index instead of '
1529
+ + 'scanning every file. Use it on large trees where a full scan is slow. Returns matching lines as '
1530
+ + 'path:line:text. '
1531
+ + 'Note: tgrep does not apply ripgrep\'s .ignore re-include rules, so on repositories that re-admit '
1532
+ + 'ignored trees through .ignore it can return fewer files than ripgrep. When an empty result is the '
1533
+ + 'conclusion, confirm it with a search tool that follows ripgrep ignore semantics.',
1534
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1535
+ inputSchema: {
1536
+ type: 'object',
1537
+ properties: {
1538
+ pattern: { type: 'string', description: 'Regular expression (ripgrep syntax) to search for.' },
1539
+ path: {
1540
+ type: 'string',
1541
+ description:
1542
+ 'File or directory to search, absolute or relative to the server working directory. Defaults to the '
1543
+ + 'workspace root the client declared, else the server default root (tgrep_status reports both). '
1544
+ + 'A path outside the allowed roots is refused. The result reports `root` — the indexed root the search '
1545
+ + 'ran against, which is the outermost allowed root containing the path — and paths are relative to it.',
1546
+ },
1547
+ glob: { type: 'string', description: 'Single glob filter, e.g. "*.ts".' },
1548
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive match.' },
1549
+ maxPerFile: { type: 'integer', description: 'Maximum matching lines per file (tgrep -m).' },
1550
+ maxResults: {
1551
+ type: 'integer',
1552
+ description: `Maximum matches returned in structuredContent (default ${MAX_RESULTS_DEFAULT}, ceiling ${CONFIG.maxResultsCeiling}).`,
1553
+ },
1554
+ includeGitIgnored: {
1555
+ type: 'boolean',
1556
+ description: 'Also search files excluded by .gitignore (rg parity on trees re-admitted by .ignore). Forces the CLI backend.',
1557
+ },
1558
+ },
1559
+ required: ['pattern'],
1560
+ additionalProperties: false,
1561
+ },
1562
+ outputSchema: SEARCH_OUTPUT_SCHEMA,
1563
+ },
1564
+ {
1565
+ name: 'tgrep_index',
1566
+ title: 'Build or rebuild the tgrep index',
1567
+ description:
1568
+ 'Build or rebuild the tgrep trigram index for a repository, and restart its search daemon. Runs automatically '
1569
+ + 'on the first search, so call this only to refresh after a branch switch, a large pull, or when results look '
1570
+ + 'stale. The index is written outside the searched repo (never into it).',
1571
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1572
+ inputSchema: {
1573
+ type: 'object',
1574
+ properties: {
1575
+ path: {
1576
+ type: 'string',
1577
+ description:
1578
+ 'Repository root, absolute or relative to the server working directory. Defaults to the same root tgrep_search uses. '
1579
+ + 'The outermost allowed root containing the path is indexed, so a repository is indexed once and subdirectories reuse it.',
1580
+ },
1581
+ },
1582
+ additionalProperties: false,
1583
+ },
1584
+ outputSchema: INDEX_OUTPUT_SCHEMA,
1585
+ },
1586
+ {
1587
+ name: 'tgrep_status',
1588
+ title: 'tgrep server health and configuration',
1589
+ description:
1590
+ 'Report this server\'s health, effective configuration, index freshness and call counters. Read-only and cheap: '
1591
+ + 'it never builds an index and never starts a daemon. Use it to find out which roots are searchable, where the '
1592
+ + 'index lives, and why a search failed.',
1593
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1594
+ inputSchema: {
1595
+ type: 'object',
1596
+ properties: {
1597
+ path: { type: 'string', description: 'Optional root to report index and daemon detail for.' },
1598
+ },
1599
+ additionalProperties: false,
1600
+ },
1601
+ outputSchema: STATUS_OUTPUT_SCHEMA,
1602
+ },
1603
+ ]
1604
+
1605
+ function renderSearch(payload) {
1606
+ const bits = [`tgrep ${payload.total} match(es) in ${payload.files} file(s) — ${payload.elapsedMs}ms`]
1607
+ bits.push(`backend=${payload.backend}`)
1608
+ if (payload.engineMs !== undefined) bits.push(`engine=${Number(payload.engineMs).toFixed(1)}ms`)
1609
+ if (payload.mode !== undefined) bits.push(`mode=${payload.mode}`)
1610
+ if (payload.index !== undefined) bits.push(`index=${payload.index}`)
1611
+ if (payload.truncated) bits.push(`showing ${payload.returned} of ${payload.total}`)
1612
+ const lines = [bits.join(', ')]
1613
+ for (const warning of payload.warnings ?? []) lines.push(`warning: ${warning}`)
1614
+ if (payload.matches.length === 0) lines.push('(no matches)')
1615
+ for (const item of payload.matches.slice(0, RENDER_LINES)) {
1616
+ lines.push(`${item.path}:${item.line}: ${item.text}`)
1617
+ }
1618
+ if (payload.matches.length > RENDER_LINES) {
1619
+ lines.push(`... ${payload.matches.length - RENDER_LINES} more match(es) in structuredContent`)
1620
+ }
1621
+ return lines.join('\n')
1622
+ }
1623
+
1624
+ function renderIndex(payload) {
1625
+ if (payload.status === 'ready') {
1626
+ return `tgrep index ready for ${payload.root} in ${payload.builtMs}ms (index at ${payload.indexDir}); daemon restarts on the next search`
1627
+ }
1628
+ return `tgrep index FAILED for ${payload.root}: ${payload.error?.message ?? 'unknown error'}`
1629
+ }
1630
+
1631
+ function renderStatus(payload) {
1632
+ const lines = [
1633
+ `tgrep-mcp ${payload.version} — ${payload.status.toUpperCase()} (pid ${process.pid}, uptime ${Math.round(payload.metrics.uptimeMs / 1000)}s)`,
1634
+ ]
1635
+ for (const check of payload.checks) {
1636
+ lines.push(` [${check.status}] ${check.name}: ${check.message}`)
1637
+ }
1638
+ lines.push(` search root: ${payload.config.defaultRoot} (${payload.config.defaultRootSource})`)
1639
+ lines.push(` allowed roots: ${payload.config.allowedRoots.length === 0 ? '*' : payload.config.allowedRoots.join(', ')}`)
1640
+ lines.push(` index root: ${payload.config.indexRoot} (${payload.config.indexRootSource})`)
1641
+ lines.push(` tgrep binary: ${payload.config.bin.path} (${payload.config.bin.source})`)
1642
+ lines.push(` concurrency: ${payload.metrics.concurrency.active}/${payload.metrics.concurrency.limit} active, ${payload.metrics.concurrency.queued} queued`)
1643
+ const calls = Object.entries(payload.metrics.tools)
1644
+ if (calls.length > 0) {
1645
+ lines.push(` calls: ${calls.map(([name, m]) => `${name} ${m.calls} (${m.errors} err, p95 ${m.p95Ms ?? '-'}ms)`).join('; ')}`)
1646
+ }
1647
+ return lines.join('\n')
1648
+ }
1649
+
1650
+ // ---------------------------------------------------------------------------
1651
+ // Tool execution
1652
+ // ---------------------------------------------------------------------------
1653
+
1654
+ function toolErrorOutcome(error) {
1655
+ const toolError = asToolError(error)
1656
+ const structured = { ok: false, error: toolError.toStructured() }
1657
+ const { code, message, hint, retryAfterSeconds } = structured.error
1658
+ const text = [`${code}: ${message}`, hint, retryAfterSeconds === undefined ? null : `retry after ${retryAfterSeconds}s`]
1659
+ .filter((part) => part !== null && part !== undefined)
1660
+ .join(' — ')
1661
+ return { isError: true, text, structured }
1662
+ }
1663
+
1664
+ async function executeSearch(rawArgs, token, config) {
1665
+ const validation = validateArguments('tgrep_search', rawArgs, {
1666
+ maxResultsCeiling: config.maxResultsCeiling,
1667
+ maxPerFileCeiling: config.maxPerFileCeiling,
1668
+ })
1669
+ if (!validation.ok) throw new ProtocolError(-32602, 'invalid arguments for tgrep_search', validation.issues)
1670
+ const args = validation.value
1671
+ const limit = args.maxResults ?? config.maxResultsDefault
1672
+
1673
+ const allowed = effectiveAllowed(config)
1674
+ const plan = resolveSearchTarget(args.path, config, allowed)
1675
+ seenRoots.add(plan.root)
1676
+
1677
+ return runExclusive(plan.root, async () => {
1678
+ const buildStart = Date.now()
1679
+ const index = await ensureIndex(plan.root, false, config, token)
1680
+ const buildMs = Date.now() - buildStart
1681
+
1682
+ let payload = null
1683
+ let directError = null
1684
+ const wantsCli = args.includeGitIgnored === true
1685
+ if (config.direct !== 'off' && !wantsCli) {
1686
+ if (directBreaker.allow()) {
1687
+ metrics.directAttempts += 1
1688
+ try {
1689
+ payload = await searchDirect(plan.root, plan, args, config, token)
1690
+ metrics.directSuccesses += 1
1691
+ directBreaker.recordSuccess()
1692
+ } catch (error) {
1693
+ if (error instanceof ToolError && error.code === ERROR_CODES.CANCELLED) throw error
1694
+ directError = asToolError(error)
1695
+ metrics.directFailures += 1
1696
+ directBreaker.recordFailure()
1697
+ log(`direct backend failed for "${args.pattern}"; falling back to CLI: ${directError.message}`, 'debug')
1698
+ }
1699
+ } else {
1700
+ directError = new ToolError(
1701
+ ERROR_CODES.BACKEND_UNAVAILABLE,
1702
+ 'direct backend is paused after repeated protocol failures',
1703
+ { details: directBreaker.snapshot() },
1704
+ )
1705
+ }
1706
+ }
1707
+
1708
+ if (payload === null) {
1709
+ if (directError !== null) metrics.cliFallbacks += 1
1710
+ try {
1711
+ payload = await searchCli(plan.root, plan, args, config, token)
1712
+ } catch (error) {
1713
+ const cliError = asToolError(error)
1714
+ cliError.details = { ...(cliError.details ?? {}), directError: directError?.message ?? null }
1715
+ throw cliError
1716
+ }
1717
+ }
1718
+
1719
+ const { kept, truncatedByBytes } = capMatches(payload.matches, limit, config.maxOutputBytes)
1720
+ const warnings = []
1721
+ if (truncatedByBytes) warnings.push(`match list capped at TGREP_MAX_OUTPUT_BYTES (${config.maxOutputBytes} bytes)`)
1722
+ if (kept.length < payload.matches.length) {
1723
+ warnings.push(`showing ${kept.length} of ${payload.matches.length} matches; raise maxResults or narrow the pattern`)
1724
+ }
1725
+ const age = indexAgeMs(plan.root, config)
1726
+ if (age !== null && age > config.indexStaleMs) {
1727
+ warnings.push(`index is ${Math.round(age / 3600000)}h old; call tgrep_index if the tree changed`)
1728
+ }
1729
+ if (index.status !== 'ready') warnings.push(`index state: ${index.status}`)
1730
+ if (directError !== null && payload.backend === 'cli') {
1731
+ warnings.push(`direct backend unavailable: ${directError.message}`)
1732
+ }
1733
+
1734
+ return {
1735
+ ok: true,
1736
+ root: plan.root,
1737
+ target: plan.target,
1738
+ kind: plan.kind,
1739
+ backend: payload.backend,
1740
+ elapsedMs: payload.elapsedMs,
1741
+ engineMs: payload.engineMs,
1742
+ buildMs,
1743
+ total: payload.total,
1744
+ files: payload.files,
1745
+ returned: kept.length,
1746
+ truncated: kept.length < payload.total,
1747
+ matches: kept,
1748
+ index: index.status,
1749
+ indexAgeMs: age ?? undefined,
1750
+ mode: payload.mode,
1751
+ daemonPid: payload.daemonPid,
1752
+ reusedDaemon: payload.reusedDaemon,
1753
+ warnings,
1754
+ }
1755
+ }, config)
1756
+ }
1757
+
1758
+ async function executeIndex(rawArgs, token, config) {
1759
+ const validation = validateArguments('tgrep_index', rawArgs, {})
1760
+ if (!validation.ok) throw new ProtocolError(-32602, 'invalid arguments for tgrep_index', validation.issues)
1761
+ const args = validation.value
1762
+
1763
+ const allowed = effectiveAllowed(config)
1764
+ const anchor = args.path === undefined || args.path === ''
1765
+ ? resolve(config.defaultRoot)
1766
+ : resolve(config.cwd, args.path)
1767
+ if (!checkPathAllowed(anchor, allowed, config.platform)) {
1768
+ throw new ToolError(ERROR_CODES.PATH_NOT_ALLOWED, `path is outside the allowed roots: ${anchor}`, {
1769
+ hint: 'Set TGREP_ALLOWED_ROOTS to include this path, or set it to "*" to allow any path.',
1770
+ details: { allowedRoots: allowed.roots },
1771
+ })
1772
+ }
1773
+ let info
1774
+ try {
1775
+ info = statSync(anchor)
1776
+ } catch (error) {
1777
+ throw new ToolError(ERROR_CODES.NOT_FOUND, `path does not exist: ${anchor}`, {
1778
+ details: { cause: String(error?.message ?? error) },
1779
+ })
1780
+ }
1781
+ if (!info.isDirectory()) {
1782
+ throw new ToolError(ERROR_CODES.VALIDATION_ERROR, 'tgrep_index needs a directory, not a file', {
1783
+ hint: 'Pass the repository root directory.',
1784
+ })
1785
+ }
1786
+
1787
+ const root = canonicalRootFor(anchor, allowed, config.platform)
1788
+ seenRoots.add(root)
1789
+
1790
+ return runExclusive(root, async () => {
1791
+ // The daemon pins the current index; release it before publishing a new generation.
1792
+ const daemonRestarted = stopDaemon(root)
1793
+ indexState.delete(root)
1794
+ const state = await ensureIndex(root, true, config, token)
1795
+ const payload = {
1796
+ ok: state.status === 'ready',
1797
+ root,
1798
+ status: state.status,
1799
+ builtMs: state.builtMs,
1800
+ indexDir: indexDirFor(root, config.indexRoot, config.platform),
1801
+ indexAgeMs: indexAgeMs(root, config) ?? undefined,
1802
+ daemonRestarted,
1803
+ }
1804
+ if (state.status !== 'ready') {
1805
+ payload.ok = false
1806
+ payload.error = new ToolError(ERROR_CODES.INDEX_FAILED, state.stderr || `tgrep index exited with code ${state.exitCode}`, {
1807
+ hint: 'Check that the root is readable and that the binary at TGREP_BIN is a working tgrep.',
1808
+ details: { exitCode: state.exitCode },
1809
+ }).toStructured()
1810
+ }
1811
+ return payload
1812
+ }, config)
1813
+ }
1814
+
1815
+ /** Named checks, each independent: one broken root must not hide a different broken assumption. */
1816
+ async function collectChecks(config, root) {
1817
+ const checks = []
1818
+ const time = async (name, fn) => {
1819
+ const started = Date.now()
1820
+ let result
1821
+ try {
1822
+ result = await fn()
1823
+ } catch (error) {
1824
+ result = { status: 'unhealthy', message: String(error?.message ?? error) }
1825
+ }
1826
+ checks.push({ name, responseTimeMs: Date.now() - started, ...result })
1827
+ }
1828
+
1829
+ await time('configuration', async () => (config.issues.length === 0
1830
+ ? { status: 'healthy', message: 'all environment overrides parsed' }
1831
+ : { status: 'degraded', message: config.issues.join('; '), details: { issues: config.issues } }))
1832
+
1833
+ await time('tgrep_binary', async () => {
1834
+ if (config.bin.present === false) {
1835
+ return {
1836
+ status: 'unhealthy',
1837
+ message: `not found at ${config.bin.path} (from ${config.bin.source})`,
1838
+ details: { path: config.bin.path, source: config.bin.source },
1839
+ }
1840
+ }
1841
+ const probe = await runTgrep(['--version'], 5000, null, config.bin.path)
1842
+ if (probe.spawnError) {
1843
+ return { status: 'unhealthy', message: `cannot execute ${config.bin.path}: ${probe.stderr.trim().slice(0, 200)}` }
1844
+ }
1845
+ if (probe.timedOut) return { status: 'degraded', message: 'tgrep --version timed out' }
1846
+ const version = probe.stdout.trim().split('\n')[0] ?? ''
1847
+ const details = { path: config.bin.path, source: config.bin.source, version, verifiedVersion: VERIFIED_TGREP_VERSION }
1848
+ if (probe.exitCode !== 0) {
1849
+ return { status: 'unhealthy', message: `tgrep --version exited with ${probe.exitCode}`, details }
1850
+ }
1851
+ // The direct backend speaks an undocumented protocol, so an unverified release is a real risk
1852
+ // worth surfacing before a search mysteriously returns wrong results.
1853
+ if (!version.includes(VERIFIED_TGREP_VERSION)) {
1854
+ return {
1855
+ status: 'degraded',
1856
+ message: `${version || 'unknown version'} is not the release the direct protocol was verified against (${VERIFIED_TGREP_VERSION}); install that release, or set TGREP_DIRECT=off to use the CLI backend`,
1857
+ details,
1858
+ }
1859
+ }
1860
+ return { status: 'healthy', message: `${version} (${config.bin.source})`, details }
1861
+ })
1862
+
1863
+ await time('index_root', async () => {
1864
+ try {
1865
+ mkdirSync(config.indexRoot, { recursive: true })
1866
+ return { status: 'healthy', message: config.indexRoot, details: { source: config.indexRootSource } }
1867
+ } catch (error) {
1868
+ return { status: 'unhealthy', message: `not writable: ${String(error?.message ?? error)}` }
1869
+ }
1870
+ })
1871
+
1872
+ await time('search_root', async () => {
1873
+ try {
1874
+ if (!statSync(root).isDirectory()) return { status: 'unhealthy', message: `${root} is not a directory` }
1875
+ return { status: 'healthy', message: root, details: { allowedRoots: effectiveAllowed(config).allowAll ? ['*'] : effectiveAllowed(config).roots } }
1876
+ } catch (error) {
1877
+ return { status: 'unhealthy', message: `${root} is not readable: ${String(error?.message ?? error)}` }
1878
+ }
1879
+ })
1880
+
1881
+ await time('index_freshness', async () => {
1882
+ const age = indexAgeMs(root, config)
1883
+ if (age === null) {
1884
+ return { status: 'degraded', message: 'no index yet; the first search will build one', details: { indexDir: indexDirFor(root, config.indexRoot, config.platform) } }
1885
+ }
1886
+ const hours = Math.round(age / 3600000)
1887
+ if (age > config.indexStaleMs) {
1888
+ return { status: 'degraded', message: `index is ${hours}h old; call tgrep_index if the tree changed`, details: { indexAgeMs: age } }
1889
+ }
1890
+ return { status: 'healthy', message: `index age ${hours}h`, details: { indexAgeMs: age } }
1891
+ })
1892
+
1893
+ await time('indexed_daemon', async () => {
1894
+ const ping = await pingDaemon(root)
1895
+ if (ping.reachable) return { status: 'healthy', message: `resident server on port ${ping.port} (pid ${ping.pid})`, details: { pid: ping.pid, port: ping.port } }
1896
+ if (config.direct === 'off') return { status: 'healthy', message: 'not used (TGREP_DIRECT=off)' }
1897
+ if (ping.reason === 'no serve.json') return { status: 'degraded', message: 'no resident server; searches use the CLI backend', details: { direct: config.direct } }
1898
+ const circuit = directBreaker.snapshot()
1899
+ return { status: circuit.state === 'open' ? 'degraded' : 'healthy', message: `resident server unreachable (${ping.reason})`, details: { circuit } }
1900
+ })
1901
+
1902
+ return checks
1903
+ }
1904
+
1905
+ async function executeStatus(rawArgs, config) {
1906
+ const validation = validateArguments('tgrep_status', rawArgs, {})
1907
+ if (!validation.ok) throw new ProtocolError(-32602, 'invalid arguments for tgrep_status', validation.issues)
1908
+ const args = validation.value
1909
+
1910
+ const allowed = effectiveAllowed(config)
1911
+ let root = resolve(config.defaultRoot)
1912
+ if (args.path !== undefined && args.path !== '') {
1913
+ const candidate = resolve(config.cwd, args.path)
1914
+ if (!checkPathAllowed(candidate, allowed, config.platform)) {
1915
+ throw new ToolError(ERROR_CODES.PATH_NOT_ALLOWED, `path is outside the allowed roots: ${candidate}`, {
1916
+ details: { allowedRoots: allowed.roots },
1917
+ })
1918
+ }
1919
+ root = candidate
1920
+ }
1921
+
1922
+ const checks = await collectChecks(config, root)
1923
+ return {
1924
+ ok: true,
1925
+ status: aggregateHealth(checks),
1926
+ version: VERSION,
1927
+ checks,
1928
+ config: {
1929
+ serverName: SERVER_NAME,
1930
+ protocolVersion: MCP_PROTOCOL_VERSION,
1931
+ client: clientInfo,
1932
+ platform: config.platform,
1933
+ cwd: config.cwd,
1934
+ defaultRoot: config.defaultRoot,
1935
+ defaultRootSource: config.defaultRootSource,
1936
+ indexRoot: config.indexRoot,
1937
+ indexRootSource: config.indexRootSource,
1938
+ allowedRoots: allowed.allowAll ? [] : allowed.roots,
1939
+ allowAllRoots: allowed.allowAll,
1940
+ clientRoots,
1941
+ clientSupportsRoots,
1942
+ bin: config.bin,
1943
+ direct: config.direct,
1944
+ timeouts: { operationMs: config.timeoutMs, toolMs: config.toolTimeoutMs, daemonWaitMs: config.daemonWaitMs },
1945
+ limits: {
1946
+ maxConcurrency: config.maxConcurrency,
1947
+ maxCallsPerMinute: config.maxCallsPerMinute,
1948
+ maxResultsDefault: config.maxResultsDefault,
1949
+ maxResultsCeiling: config.maxResultsCeiling,
1950
+ maxLineChars: config.maxLineChars,
1951
+ maxOutputBytes: config.maxOutputBytes,
1952
+ indexStaleMs: config.indexStaleMs,
1953
+ },
1954
+ issues: config.issues,
1955
+ },
1956
+ metrics: {
1957
+ ...snapshotMetrics(),
1958
+ concurrency: globalGate.snapshot(),
1959
+ rateLimit: rateLimiter.snapshot(),
1960
+ roots: [...seenRoots],
1961
+ },
1962
+ }
1963
+ }
1964
+
1965
+ /**
1966
+ * Run one tool call under the server's policy: rate budget, deadline, metrics and audit line.
1967
+ * Protocol errors (bad arguments) propagate to the JSON-RPC layer; everything else is a tool error.
1968
+ */
1969
+ async function dispatchTool(name, rawArgs, config, token) {
1970
+ if (!Object.hasOwn(ARG_SPECS, name)) {
1971
+ throw new ProtocolError(-32602, `unknown tool: ${name}`, { available: Object.keys(ARG_SPECS) })
1972
+ }
1973
+
1974
+ const budget = rateLimiter.take()
1975
+ if (!budget.allowed) {
1976
+ metrics.rateLimited += 1
1977
+ throw new ToolError(ERROR_CODES.RATE_LIMITED, `call budget exhausted (${config.maxCallsPerMinute}/minute)`, {
1978
+ hint: 'Wait for the budget to refill, or raise TGREP_MAX_CALLS_PER_MINUTE.',
1979
+ retryAfterSeconds: budget.retryAfterSeconds,
1980
+ })
1981
+ }
1982
+
1983
+ const started = Date.now()
1984
+ const requestConfig = configForRequest(config)
1985
+ let outcome
1986
+ try {
1987
+ let structured
1988
+ if (name === 'tgrep_search') {
1989
+ structured = await withDeadline(executeSearch(rawArgs, token, requestConfig), config.toolTimeoutMs, 'tgrep_search')
1990
+ outcome = { isError: false, text: renderSearch(structured), structured }
1991
+ } else if (name === 'tgrep_index') {
1992
+ structured = await withDeadline(executeIndex(rawArgs, token, requestConfig), config.toolTimeoutMs, 'tgrep_index')
1993
+ outcome = structured.ok
1994
+ ? { isError: false, text: renderIndex(structured), structured }
1995
+ : toolErrorOutcome(new ToolError(ERROR_CODES.INDEX_FAILED, structured.error?.message ?? 'index build failed', { details: structured.error?.details }))
1996
+ } else {
1997
+ structured = await withDeadline(executeStatus(rawArgs, requestConfig), config.toolTimeoutMs, 'tgrep_status')
1998
+ outcome = { isError: false, text: renderStatus(structured), structured }
1999
+ }
2000
+ } catch (error) {
2001
+ if (error instanceof ProtocolError) {
2002
+ recordToolCall(name, Date.now() - started, false)
2003
+ audit({ tool: name, outcome: 'protocol_error', code: error.code, elapsedMs: Date.now() - started })
2004
+ throw error
2005
+ }
2006
+ outcome = toolErrorOutcome(error)
2007
+ }
2008
+
2009
+ const elapsedMs = Date.now() - started
2010
+ recordToolCall(name, elapsedMs, outcome.isError !== true)
2011
+ audit({
2012
+ tool: name,
2013
+ outcome: outcome.isError === true ? 'error' : 'ok',
2014
+ code: outcome.structured?.error?.code ?? null,
2015
+ backend: outcome.structured?.backend ?? null,
2016
+ root: outcome.structured?.root ?? null,
2017
+ total: outcome.structured?.total ?? null,
2018
+ returned: outcome.structured?.returned ?? null,
2019
+ elapsedMs,
2020
+ })
2021
+ return outcome
2022
+ }
2023
+
2024
+ // ---------------------------------------------------------------------------
2025
+ // Protocol layer
2026
+ // ---------------------------------------------------------------------------
2027
+
2028
+ const clientRequests = new Map() // id -> resolve, for requests this server sends to the client
2029
+ const inflight = new Map() // client request id -> cancellation token
2030
+ let serverRequestSeq = 0
2031
+
2032
+ function write(message) {
2033
+ try {
2034
+ process.stdout.write(`${JSON.stringify(message)}\n`)
2035
+ } catch (error) {
2036
+ log(`failed to write a protocol frame: ${String(error?.message ?? error)}`)
2037
+ }
2038
+ }
2039
+
2040
+ function reply(id, result) {
2041
+ write({ jsonrpc: '2.0', id, result })
2042
+ }
2043
+
2044
+ function replyError(id, code, message, data) {
2045
+ const error = { code, message }
2046
+ if (data !== undefined) error.data = data
2047
+ write({ jsonrpc: '2.0', id, error })
2048
+ }
2049
+
2050
+ /** A server-initiated request. Clients that never declared the capability are never asked. */
2051
+ function sendClientRequest(method, params, timeoutMs) {
2052
+ const id = `tgrep-${++serverRequestSeq}`
2053
+ return new Promise((resolveRequest) => {
2054
+ const timer = setTimeout(() => {
2055
+ clientRequests.delete(id)
2056
+ resolveRequest({ __timeout: true })
2057
+ }, timeoutMs)
2058
+ clientRequests.set(id, (message) => {
2059
+ clearTimeout(timer)
2060
+ resolveRequest(message)
2061
+ })
2062
+ write({ jsonrpc: '2.0', id, method, params })
2063
+ })
2064
+ }
2065
+
2066
+ async function refreshClientRoots() {
2067
+ if (!clientSupportsRoots) return
2068
+ const response = await sendClientRequest('roots/list', {}, 5000)
2069
+ if (response.__timeout === true) {
2070
+ log('roots/list timed out; keeping the current workspace roots', 'debug')
2071
+ return
2072
+ }
2073
+ if (response.error !== undefined) {
2074
+ log(`roots/list failed: ${JSON.stringify(response.error)}`, 'debug')
2075
+ return
2076
+ }
2077
+ const roots = Array.isArray(response.result?.roots) ? response.result.roots : []
2078
+ const paths = roots.map((entry) => uriToPath(entry?.uri)).filter((path) => path !== null)
2079
+ if (paths.length > 0) {
2080
+ clientRoots = dedupePaths(paths, CONFIG.platform)
2081
+ log(`client workspace roots: ${clientRoots.join(', ')}`)
2082
+ }
2083
+ }
2084
+
2085
+ async function handleRequest(message) {
2086
+ const { id, method, params } = message
2087
+ switch (method) {
2088
+ case 'initialize': {
2089
+ const requested = params?.protocolVersion
2090
+ MCP_PROTOCOL_VERSION = SUPPORTED_PROTOCOLS.includes(requested) ? requested : FALLBACK_PROTOCOL
2091
+ clientInfo = params?.clientInfo ?? null
2092
+ clientSupportsRoots = params?.capabilities?.roots !== undefined
2093
+ reply(id, {
2094
+ protocolVersion: MCP_PROTOCOL_VERSION,
2095
+ capabilities: { tools: { listChanged: false } },
2096
+ serverInfo: { name: SERVER_NAME, title: 'tgrep code search', version: VERSION },
2097
+ instructions: INSTRUCTIONS,
2098
+ })
2099
+ return
2100
+ }
2101
+ case 'ping':
2102
+ reply(id, {})
2103
+ return
2104
+ case 'tools/list':
2105
+ reply(id, { tools: TOOLS })
2106
+ return
2107
+ case 'tools/call': {
2108
+ const name = params?.name
2109
+ if (typeof name !== 'string' || name.length === 0) {
2110
+ replyError(id, -32602, 'tools/call requires a tool name')
2111
+ return
2112
+ }
2113
+ const token = createCancellationToken()
2114
+ inflight.set(id, token)
2115
+ try {
2116
+ const outcome = await dispatchTool(name, params?.arguments ?? {}, CONFIG, token)
2117
+ if (token.cancelled) return
2118
+ const result = { content: [{ type: 'text', text: outcome.text }], isError: outcome.isError === true }
2119
+ if (outcome.structured !== undefined) result.structuredContent = outcome.structured
2120
+ reply(id, result)
2121
+ } catch (error) {
2122
+ if (token.cancelled) return
2123
+ if (error instanceof ProtocolError) {
2124
+ replyError(id, error.code, error.message, error.data)
2125
+ return
2126
+ }
2127
+ const outcome = toolErrorOutcome(error)
2128
+ reply(id, {
2129
+ content: [{ type: 'text', text: outcome.text }],
2130
+ isError: true,
2131
+ structuredContent: outcome.structured,
2132
+ })
2133
+ } finally {
2134
+ inflight.delete(id)
2135
+ }
2136
+ return
2137
+ }
2138
+ case 'resources/list':
2139
+ case 'prompts/list':
2140
+ replyError(id, -32601, `method not found: ${method}`)
2141
+ return
2142
+ default:
2143
+ replyError(id, -32601, `method not found: ${method}`)
2144
+ }
2145
+ }
2146
+
2147
+ function handleNotification(message) {
2148
+ switch (message.method) {
2149
+ case 'notifications/initialized':
2150
+ refreshClientRoots().catch((error) => log(`root refresh failed: ${String(error?.message ?? error)}`, 'debug'))
2151
+ return
2152
+ case 'notifications/cancelled': {
2153
+ const target = message.params?.requestId
2154
+ const token = inflight.get(target)
2155
+ if (token !== undefined) {
2156
+ log(`client cancelled request ${target}`, 'debug')
2157
+ token.cancel()
2158
+ }
2159
+ return
2160
+ }
2161
+ case 'notifications/roots/list_changed':
2162
+ refreshClientRoots().catch((error) => log(`root refresh failed: ${String(error?.message ?? error)}`, 'debug'))
2163
+ return
2164
+ default:
2165
+ log(`ignoring notification ${String(message.method)}`, 'debug')
2166
+ }
2167
+ }
2168
+
2169
+ async function handleLine(line) {
2170
+ let message
2171
+ try {
2172
+ message = JSON.parse(line)
2173
+ } catch (error) {
2174
+ log(`dropped unparseable frame: ${line.slice(0, 120)}`)
2175
+ replyError(null, -32700, `parse error: ${String(error?.message ?? error)}`)
2176
+ return
2177
+ }
2178
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) {
2179
+ replyError(null, -32600, 'invalid request: expected a JSON object')
2180
+ return
2181
+ }
2182
+ // A response to a request this server sent: it has an id and no method.
2183
+ if (message.method === undefined) {
2184
+ const waiter = clientRequests.get(message.id)
2185
+ if (waiter === undefined) {
2186
+ log(`dropped unroutable response with id ${JSON.stringify(message.id)}`, 'debug')
2187
+ return
2188
+ }
2189
+ clientRequests.delete(message.id)
2190
+ waiter(message)
2191
+ return
2192
+ }
2193
+ if (message.id === undefined || message.id === null) {
2194
+ handleNotification(message)
2195
+ return
2196
+ }
2197
+ if (message.jsonrpc !== undefined && message.jsonrpc !== '2.0') {
2198
+ replyError(message.id, -32600, 'invalid request: jsonrpc must be "2.0"')
2199
+ return
2200
+ }
2201
+ handleRequest(message).catch((error) => {
2202
+ log(`request ${String(message.method)} failed: ${String(error?.message ?? error)}`)
2203
+ replyError(message.id, -32603, String(error?.message ?? error))
2204
+ })
2205
+ }
2206
+
2207
+ function startServer() {
2208
+ let buffer = ''
2209
+ process.stdin.setEncoding('utf8')
2210
+ process.stdin.on('data', (chunk) => {
2211
+ buffer += chunk
2212
+ if (buffer.length > MAX_FRAME_BYTES) {
2213
+ log(`dropping ${buffer.length} bytes of oversized frame input`)
2214
+ buffer = ''
2215
+ return
2216
+ }
2217
+ let index
2218
+ while ((index = buffer.indexOf('\n')) !== -1) {
2219
+ const line = buffer.slice(0, index).trim()
2220
+ buffer = buffer.slice(index + 1)
2221
+ if (line.length === 0) continue
2222
+ handleLine(line).catch((error) => log(`frame handling failed: ${String(error?.message ?? error)}`))
2223
+ }
2224
+ })
2225
+
2226
+ const shutdown = (code) => {
2227
+ shutdownAll()
2228
+ process.exit(code)
2229
+ }
2230
+ process.stdin.on('end', () => shutdown(0))
2231
+ process.on('SIGINT', () => shutdown(0))
2232
+ process.on('SIGTERM', () => shutdown(0))
2233
+ process.on('exit', () => shutdownAll())
2234
+
2235
+ for (const issue of CONFIG.issues) log(`config: ${issue}`)
2236
+ log(`ready — v${VERSION} bin=${CONFIG.bin.path} (${CONFIG.bin.source}) indexRoot=${CONFIG.indexRoot}`)
2237
+ log(`defaultRoot=${CONFIG.defaultRoot} (${CONFIG.defaultRootSource}) allowedRoots=${CONFIG.allowed.allowAll ? '*' : CONFIG.allowed.roots.join(', ')} direct=${CONFIG.direct}`, 'debug')
2238
+ }
2239
+
2240
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
2241
+ if (isMain) startServer()
2242
+
2243
+ export {
2244
+ buildConfig,
2245
+ defaultIndexRoot,
2246
+ resolveBin,
2247
+ parseAllowedRoots,
2248
+ dedupePaths,
2249
+ indexDirFor,
2250
+ isInside,
2251
+ checkPathAllowed,
2252
+ canonicalRootFor,
2253
+ relativeScope,
2254
+ displayPath,
2255
+ validateArguments,
2256
+ sanitizeText,
2257
+ capMatches,
2258
+ createSemaphore,
2259
+ createRateLimiter,
2260
+ createCircuitBreaker,
2261
+ aggregateHealth,
2262
+ percentile,
2263
+ renderSearch,
2264
+ envInt,
2265
+ ERROR_CODES,
2266
+ ToolError,
2267
+ ProtocolError,
2268
+ INSTRUCTIONS,
2269
+ TOOLS,
2270
+ CONFIG,
2271
+ SEARCH_OUTPUT_SCHEMA,
2272
+ INDEX_OUTPUT_SCHEMA,
2273
+ STATUS_OUTPUT_SCHEMA,
2274
+ }