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/LICENSE +21 -0
- package/LICENSE-tgrep +21 -0
- package/README.md +283 -0
- package/package.json +38 -0
- package/server.mjs +2274 -0
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
|
+
}
|