querysub 0.441.0 → 0.442.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.
Files changed (30) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/bin/mcp-indexed-logs.js +6 -0
  3. package/package.json +7 -4
  4. package/spec.txt +1 -0
  5. package/src/-a-archives/archiveCache.ts +1 -1
  6. package/src/-e-certs/EdgeCertController.ts +2 -8
  7. package/src/-f-node-discovery/NodeDiscovery.ts +14 -7
  8. package/src/-g-core-values/NodeCapabilities.ts +4 -0
  9. package/src/0-path-value-core/AuthorityLookup.ts +9 -4
  10. package/src/0-path-value-core/LockWatcher2.ts +1 -0
  11. package/src/0-path-value-core/PathValueController.ts +1 -1
  12. package/src/0-path-value-core/PathWatcher.ts +17 -19
  13. package/src/0-path-value-core/pathValueArchives.ts +20 -2
  14. package/src/0-path-value-core/pathValueCore.ts +5 -3
  15. package/src/1-path-client/RemoteWatcher.ts +17 -22
  16. package/src/1-path-client/pathValueClientWatcher.ts +1 -1
  17. package/src/2-proxy/PathValueProxyWatcher.ts +5 -5
  18. package/src/4-querysub/Querysub.ts +24 -7
  19. package/src/4-querysub/QuerysubController.ts +9 -2
  20. package/src/archiveapps/archiveJoinEntry.ts +1 -1
  21. package/src/diagnostics/ValuePathWarning.tsx +68 -0
  22. package/src/diagnostics/logs/IndexedLogs/BufferIndex.ts +113 -1
  23. package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +4 -4
  24. package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +37 -2
  25. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +389 -0
  26. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts +190 -0
  27. package/src/diagnostics/logs/diskLogger.ts +3 -0
  28. package/src/diagnostics/logs/errorNotifications2/errorNotifications.ts +2 -1
  29. package/src/diagnostics/managementPages.tsx +5 -1
  30. package/src/library-components/SyncedController.ts +2 -1
@@ -0,0 +1,389 @@
1
+ import { timeInHour, timeInMinute, timeoutToError, timeoutToUndefined, timeoutToUndefinedSilent } from "socket-function/src/misc";
2
+ import { lazy } from "socket-function/src/caching";
3
+ import { getMachineId } from "../../../-a-auth/certs";
4
+ import { getAllNodeIds, getOwnMachineId, isOwnNodeId } from "../../../-f-node-discovery/NodeDiscovery";
5
+ import { NodeCapabilitiesController } from "../../../-g-core-values/NodeCapabilities";
6
+ import { getLoggers2Async, LogDatum } from "../diskLogger";
7
+ import { Archives } from "../../../-a-archives/archives";
8
+ import { TimeFilePath, TimeFileTree } from "./TimeFileTree";
9
+ import { BufferReader, INDEX_EXTENSION, createEmptyIndexedLogResults } from "./BufferIndexHelpers";
10
+ import { BufferIndex } from "./BufferIndex";
11
+ import { LogStreamer } from "./LogStreamer";
12
+ import { createMatchesPattern } from "./bufferSearchFindMatcher";
13
+ import { IndexedLogs, IndexedLogShimController } from "./IndexedLogs";
14
+ import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
15
+
16
+ // endTime must be at least this far in the past — we never want to search the
17
+ // most-recent slice (it's still being written to and not yet promoted to public).
18
+ const END_TIME_MIN_AGE = timeInMinute;
19
+ // After we trigger forceMoveLogsToPublic for a machine, we record that the
20
+ // machine's logs are guaranteed to be in public storage up through (now - this).
21
+ const MOVE_GRACE = timeInMinute;
22
+ // TTL for the path-cache (the slow TimeFileTree.findAllPaths walk).
23
+ const PATHS_CACHE_TTL = timeInMinute;
24
+
25
+ const LOGGER_NAMES = ["logs/log", "logs/info", "logs/warn", "logs/error"] as const;
26
+ type LoggerName = typeof LOGGER_NAMES[number];
27
+
28
+ export type SearchResult = {
29
+ allColumns: string[];
30
+ results: Record<string, string>[];
31
+ // Per-logger file counts: how many we found in range vs how many we
32
+ // actually searched (cutoff-pruning skips the rest).
33
+ files: Record<string, { total: number; scanned: number }>;
34
+ // Both fields are only present when we stopped early because we hit
35
+ // `limit`. Their *presence* signals truncation; their absence means the
36
+ // results are complete. (We rely on JSON.stringify dropping undefined
37
+ // fields so callers see them only when they matter.)
38
+ limitHit?: true;
39
+ note?: string;
40
+ };
41
+
42
+ type Direction = "fromStart" | "fromEnd";
43
+
44
+ // Accept epoch ms (number) or any string `new Date(...)` understands. String
45
+ // inputs without a timezone designator are interpreted as local time, which is
46
+ // what callers typically have on hand (e.g. "2026-05-09 03:00").
47
+ function normalizeTime(value: string | number, label: string): number {
48
+ if (typeof value === "number") return value;
49
+ let asNumber = Number(value);
50
+ if (!Number.isNaN(asNumber) && value.trim() !== "") return asNumber;
51
+ let parsed = new Date(value).getTime();
52
+ if (Number.isNaN(parsed)) {
53
+ throw new Error(`${label}: could not parse ${JSON.stringify(value)} as a time`);
54
+ }
55
+ return parsed;
56
+ }
57
+
58
+ export class MCPIndexedLogs {
59
+ // machineId -> latest timestamp guaranteed to already be moved-to-public.
60
+ private movedThroughByMachine = new Map<string, number>();
61
+
62
+ // Cache: `${type}|${loggerName}|${startBucket}|${endBucket}` -> { time, paths }.
63
+ // Buckets are hour-aligned start/end so adjacent searches reuse work.
64
+ private pathsCache = new Map<string, { time: number; paths: TimeFilePath[] }>();
65
+
66
+ public async search(config: {
67
+ query: string;
68
+ machine: string | "local";
69
+ startTime: string | number;
70
+ endTime: string | number;
71
+ direction: Direction;
72
+ columns: string[];
73
+ limit?: number;
74
+ }): Promise<SearchResult> {
75
+ let limit = config.limit ?? 100;
76
+ let startTime = normalizeTime(config.startTime, "startTime");
77
+ let endTime = normalizeTime(config.endTime, "endTime");
78
+ console.log(`[search] query=${JSON.stringify(config.query)} | machine=${config.machine} | startTime=${formatDateTime(startTime)} | endTime=${formatDateTime(endTime)} | direction=${config.direction} | columns=[${config.columns.join(",")}] | limit=${config.limit ?? "(default)"}`);
79
+ let now = Date.now();
80
+ if (endTime > now - END_TIME_MIN_AGE) {
81
+ throw new Error(`endTime must be at least ${formatTime(END_TIME_MIN_AGE)} in the past (got ${formatTime(now - endTime)} ago)`);
82
+ }
83
+ if (startTime >= endTime) {
84
+ throw new Error(`startTime (${formatDateTime(startTime)}) must be < endTime (${formatDateTime(endTime)})`);
85
+ }
86
+
87
+ let machineId = config.machine === "local" ? getOwnMachineId() : config.machine;
88
+
89
+ let moveStart = Date.now();
90
+ let moveOutcome = await this.ensureMovedThrough(machineId, endTime);
91
+ console.log(`[search] ensureMovedThrough ${moveOutcome} in ${formatTime(Date.now() - moveStart)}`);
92
+
93
+ let loggers = await getLoggers2Async();
94
+ let useType: "local" | "public" = config.machine === "local" ? "local" : "public";
95
+ let queryBuffer = Buffer.from(config.query, "utf8");
96
+ let matchesPattern = createMatchesPattern(queryBuffer, false);
97
+
98
+ // Gather all files (across the 4 loggers) into a single time-ordered list.
99
+ type FileEntry = {
100
+ path: TimeFilePath;
101
+ loggerName: LoggerName;
102
+ archives: Archives;
103
+ };
104
+ let allFiles: FileEntry[] = [];
105
+
106
+ // Per-logger counts: total = files in range matching this machine,
107
+ // scanned = files we actually opened and ran findMatchingBlocks on.
108
+ let fileCounts: Record<string, { total: number; scanned: number }> = {};
109
+ for (let name of LOGGER_NAMES) fileCounts[name] = { total: 0, scanned: 0 };
110
+
111
+ let pathsStart = Date.now();
112
+ let totalPathsSeen = 0;
113
+ await Promise.all(LOGGER_NAMES.map(async (loggerName) => {
114
+ let logger = this.getLoggerByName(loggers, loggerName);
115
+ let archives = logger.debugGetCachedLogs({ type: useType });
116
+
117
+ let paths = await this.getCachedPaths({
118
+ archives,
119
+ type: useType,
120
+ loggerName,
121
+ startTime,
122
+ endTime,
123
+ });
124
+ totalPathsSeen += paths.length;
125
+
126
+ for (let p of paths) {
127
+ if (p.machineId !== machineId) continue;
128
+ if (p.endTime < startTime || p.startTime > endTime) continue;
129
+ allFiles.push({ path: p, loggerName, archives });
130
+ fileCounts[loggerName].total++;
131
+ }
132
+ }));
133
+ console.log(`[search] paths gathered in ${formatTime(Date.now() - pathsStart)} (totalSeen=${totalPathsSeen} matchedMachine=${allFiles.length})`);
134
+
135
+ if (config.direction === "fromStart") {
136
+ allFiles.sort((a, b) => a.path.startTime - b.path.startTime);
137
+ } else {
138
+ allFiles.sort((a, b) => b.path.startTime - a.path.startTime);
139
+ }
140
+
141
+ // Sequentially walk files in time-order, applying a moving cutoff once
142
+ // we have `limit` rows: any unprocessed file whose entire range is past
143
+ // the cutoff cannot contribute results we'd keep.
144
+ // - fromStart: cutoff = min(processed.endTime); skip files with startTime >= cutoff
145
+ // - fromEnd: cutoff = max(processed.startTime); skip files with endTime <= cutoff
146
+ let resultRows: Record<string, string>[] = [];
147
+ let allColumnsSet = new Set<string>();
148
+ let stats = createEmptyIndexedLogResults();
149
+ let cutoff: number | undefined;
150
+ let filesScanned = 0;
151
+
152
+ let searchStart = Date.now();
153
+ outer: for (let entry of allFiles) {
154
+ if (resultRows.length >= limit) break;
155
+ let p = entry.path;
156
+ if (cutoff !== undefined) {
157
+ if (config.direction === "fromStart") {
158
+ if (p.startTime >= cutoff) continue;
159
+ } else {
160
+ if (p.endTime <= cutoff) continue;
161
+ }
162
+ }
163
+
164
+ let indexBuf = await entry.archives.get(p.fullPath + INDEX_EXTENSION);
165
+ if (!indexBuf) continue;
166
+ let dataBuf = await entry.archives.get(p.fullPath);
167
+ if (!dataBuf) continue;
168
+ let dataReader = new BufferReader(dataBuf);
169
+
170
+ let blocks: number[];
171
+ try {
172
+ blocks = await BufferIndex.findMatchingBlocks({
173
+ index: indexBuf,
174
+ dataReader,
175
+ query: queryBuffer,
176
+ results: stats,
177
+ });
178
+ filesScanned++;
179
+ fileCounts[entry.loggerName].scanned++;
180
+ } catch (e) {
181
+ console.warn(`MCPIndexedLogs.search: error scanning ${p.fullPath + INDEX_EXTENSION}: ${(e as Error).stack ?? e}`);
182
+ continue;
183
+ }
184
+
185
+ if (config.direction === "fromStart") {
186
+ blocks.sort((a, b) => a - b);
187
+ } else {
188
+ blocks.sort((a, b) => b - a);
189
+ }
190
+
191
+ for (let block of blocks) {
192
+ if (resultRows.length >= limit) break outer;
193
+
194
+ let buffers: Buffer[];
195
+ try {
196
+ buffers = await BufferIndex.getBlockBuffers({
197
+ index: indexBuf,
198
+ dataReader,
199
+ blockIndex: block,
200
+ });
201
+ } catch (e) {
202
+ console.warn(`MCPIndexedLogs.search: error reading block ${block} of ${p.fullPath}: ${(e as Error).stack ?? e}`);
203
+ continue;
204
+ }
205
+
206
+ let iterateForward = config.direction === "fromStart";
207
+ let bStart = iterateForward ? 0 : buffers.length - 1;
208
+ let bEnd = iterateForward ? buffers.length : -1;
209
+ let bStep = iterateForward ? 1 : -1;
210
+ for (let i = bStart; iterateForward ? i < bEnd : i > bEnd; i += bStep) {
211
+ if (resultRows.length >= limit) break outer;
212
+ let buf = buffers[i];
213
+ if (!matchesPattern(buf)) continue;
214
+
215
+ let datum: LogDatum;
216
+ try {
217
+ datum = LogStreamer.deserialize<LogDatum>(buf);
218
+ } catch {
219
+ continue;
220
+ }
221
+
222
+ if (typeof datum.time !== "number") continue;
223
+ if (datum.time < startTime || datum.time > endTime) continue;
224
+
225
+ let row: Record<string, string> = {};
226
+ for (let col of config.columns) {
227
+ if (col in datum) {
228
+ row[col] = stringifyCell(datum[col]);
229
+ }
230
+ }
231
+ for (let key of Object.keys(datum)) {
232
+ allColumnsSet.add(key);
233
+ }
234
+ resultRows.push(row);
235
+ }
236
+ }
237
+
238
+ if (resultRows.length >= limit) {
239
+ if (config.direction === "fromStart") {
240
+ cutoff = cutoff === undefined ? p.endTime : Math.min(cutoff, p.endTime);
241
+ } else {
242
+ cutoff = cutoff === undefined ? p.startTime : Math.max(cutoff, p.startTime);
243
+ }
244
+ }
245
+ }
246
+
247
+ let limitHit = resultRows.length >= limit;
248
+ console.log(`[search] done in ${formatTime(Date.now() - searchStart)} (filesScanned=${filesScanned}/${allFiles.length} results=${resultRows.length} limit=${limit}${limitHit ? " HIT" : ""})`);
249
+
250
+ return {
251
+ allColumns: Array.from(allColumnsSet),
252
+ results: resultRows,
253
+ files: fileCounts,
254
+ limitHit: limitHit ? true : undefined,
255
+ note: limitHit
256
+ ? `Stopped at limit=${limit}. Results are truncated — there are likely more matches outside what's returned. This is NOT missing data; raise the limit or narrow the time range to see more.`
257
+ : undefined,
258
+ };
259
+ }
260
+
261
+ // For each logger, asks each remote node on the target machine whether it
262
+ // has pending logs overlapping [0, endTime]. The first node that answers
263
+ // without throwing wins; if it says yes, we ask the same node to flush.
264
+ // We iterate because not every node necessarily exposes the new endpoints
265
+ // (e.g. older versions still running). Records moved-through up to
266
+ // now - MOVE_GRACE so we skip this on subsequent calls covering the same
267
+ // window.
268
+ private async ensureMovedThrough(machineId: string, endTime: number): Promise<"cached" | "no-node" | "moved"> {
269
+ let lastMoved = this.movedThroughByMachine.get(machineId) ?? 0;
270
+ if (lastMoved >= endTime) return "cached";
271
+
272
+ let nodeIds = await this.findRemoteNodesOnMachine(machineId);
273
+ if (nodeIds.length === 0) {
274
+ console.warn(`MCPIndexedLogs: no remote nodes available for machine ${machineId}; falling back to whatever's in storage now`);
275
+ return "no-node";
276
+ }
277
+
278
+ for (let loggerName of LOGGER_NAMES) {
279
+ let answered = false;
280
+ for (let nodeId of nodeIds) {
281
+ try {
282
+ let hasPending = await timeoutToUndefinedSilent(
283
+ 5000,
284
+ IndexedLogShimController.nodes[nodeId].hasPendingInRange({
285
+ indexedLogsName: loggerName,
286
+ startTime: 0,
287
+ endTime,
288
+ }),
289
+ );
290
+ answered = true;
291
+ if (!hasPending) break;
292
+ console.log(`MCPIndexedLogs: hasPendingInRange returned true for ${loggerName} on ${nodeId}, forcing move to public`);
293
+ await IndexedLogShimController.nodes[nodeId].forceMoveLogsToPublic({
294
+ indexedLogsName: loggerName,
295
+ });
296
+ break;
297
+ } catch (e) {
298
+ console.warn(`MCPIndexedLogs: hasPendingRange failed, trying next node: ${(e as Error).stack ?? e}`, { loggerName, nodeId });
299
+ }
300
+ }
301
+ if (!answered) {
302
+ console.warn(`MCPIndexedLogs: no node on machine ${machineId} could service hasPendingInRange/forceMoveLogsToPublic for ${loggerName}`);
303
+ }
304
+ }
305
+
306
+ this.movedThroughByMachine.set(machineId, Date.now() - MOVE_GRACE);
307
+ return "moved";
308
+ }
309
+
310
+ // Returns every non-self nodeId on the given machineId, ordered with
311
+ // movelogs-entrypoint nodes first (best-effort — a node whose entrypoint
312
+ // probe fails is sorted to the back, not dropped). Caller iterates this
313
+ // list and tries each until one services the request.
314
+ private async findRemoteNodesOnMachine(machineId: string): Promise<string[]> {
315
+ let allIds = await getAllNodeIds();
316
+ let candidates = allIds.filter(id => !isOwnNodeId(id) && getMachineId(id) === machineId);
317
+ if (candidates.length === 0) return [];
318
+
319
+ let preferred: string[] = [];
320
+ let rest: string[] = [];
321
+ await Promise.all(candidates.map(async (nodeId) => {
322
+ let entry = await timeoutToUndefinedSilent(
323
+ 2500,
324
+ NodeCapabilitiesController.nodes[nodeId].getEntryPoint(),
325
+ );
326
+ if (entry?.includes("movelogs")) preferred.push(nodeId);
327
+ else rest.push(nodeId);
328
+ }));
329
+ return [...preferred, ...rest];
330
+ }
331
+
332
+ // Shared by searchIndexes and searchData to map a logger name to the
333
+ // IndexedLogs<LogDatum> instance that owns its archive.
334
+ private getLoggerByName(
335
+ loggers: Awaited<ReturnType<typeof getLoggers2Async>>,
336
+ name: LoggerName,
337
+ ): IndexedLogs<LogDatum> {
338
+ if (name === "logs/log") return loggers.logLogs;
339
+ if (name === "logs/info") return loggers.infoLogs;
340
+ if (name === "logs/warn") return loggers.warnLogs;
341
+ if (name === "logs/error") return loggers.errorLogs;
342
+ throw new Error(`Unknown logger ${name}`);
343
+ }
344
+
345
+ // Caches TimeFileTree.findAllPaths by hour-aligned bucket so repeated
346
+ // searches over similar windows reuse the slow folder walk.
347
+ private async getCachedPaths(config: {
348
+ archives: Archives;
349
+ type: "local" | "public";
350
+ loggerName: LoggerName;
351
+ startTime: number;
352
+ endTime: number;
353
+ }): Promise<TimeFilePath[]> {
354
+ let bucketStart = Math.floor(config.startTime / timeInHour) * timeInHour;
355
+ let bucketEnd = Math.ceil(config.endTime / timeInHour) * timeInHour;
356
+ let key = `${config.type}|${config.loggerName}|${bucketStart}|${bucketEnd}`;
357
+ let now = Date.now();
358
+
359
+ // Prune stale entries on read.
360
+ for (let [k, v] of Array.from(this.pathsCache.entries())) {
361
+ if (now - v.time > PATHS_CACHE_TTL) this.pathsCache.delete(k);
362
+ }
363
+
364
+ let cached = this.pathsCache.get(key);
365
+ if (cached && now - cached.time <= PATHS_CACHE_TTL) {
366
+ return cached.paths;
367
+ }
368
+
369
+ let paths = await new TimeFileTree(config.archives).findAllPaths({
370
+ startTime: bucketStart,
371
+ endTime: bucketEnd,
372
+ });
373
+ this.pathsCache.set(key, { time: now, paths });
374
+ return paths;
375
+ }
376
+
377
+ }
378
+
379
+ function stringifyCell(value: unknown): string {
380
+ if (value === undefined) return "";
381
+ if (value === null) return "null";
382
+ if (typeof value === "string") return value;
383
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
384
+ try {
385
+ return JSON.stringify(value);
386
+ } catch {
387
+ return String(value);
388
+ }
389
+ }
@@ -0,0 +1,190 @@
1
+ module.hotreload = false;
2
+
3
+ // Allow callers to point this process at a different querysub project root
4
+ // (so we use the right keys/certs/local data) before any other imports run.
5
+ {
6
+ let argv = process.argv;
7
+ let cwdIdx = argv.indexOf("--cwd");
8
+ if (cwdIdx >= 0 && argv[cwdIdx + 1]) {
9
+ process.chdir(argv[cwdIdx + 1]);
10
+ argv.splice(cwdIdx, 2);
11
+ }
12
+ }
13
+
14
+ import "../../../inject";
15
+
16
+ import * as http from "http";
17
+ import { logErrors } from "../../../errors";
18
+ import { Querysub } from "../../../4-querysub/QuerysubController";
19
+ import { MCPIndexedLogs } from "./MCPIndexedLogs";
20
+ import { formatTime } from "socket-function/src/formatting/format";
21
+
22
+ const DEFAULT_MCP_HTTP_PORT = 4487;
23
+
24
+ const PROTOCOL_VERSION = "2025-03-26";
25
+ const SERVER_INFO = { name: "querysub-indexed-logs", version: "0.1.0" };
26
+
27
+ const TOOL_DEFS = [
28
+ {
29
+ name: "search",
30
+ description: `Search the indexed logs (across log/info/warn/error) on one machine for rows matching a query, projected to the requested columns.
31
+
32
+ Returns { allColumns, results, files: { <loggerName>: {total, scanned} } }. If a \`limitHit\` field is present, results were truncated by the limit and a \`note\` will explain. The fields are absent when results are complete. Truncation is NOT missing data — to see more, raise the limit or narrow the time range.
33
+
34
+ \`allColumns\` is the union of every key seen on any returned row, so callers can pick additional columns for follow-up calls.
35
+
36
+ Query syntax (case-insensitive substring match by default):
37
+ | — OR. \`cat|dog\` matches if either substring is present.
38
+ & — AND. \`error&timeout\` matches if both are present (in any order, anywhere).
39
+ * — ordered wildcard. \`cat*ate\` matches "cat ate my bird" but NOT "cate"; segments must appear in order with no overlap. (\`cat&ate\` would match "cate".)
40
+ Whitespace around operators is trimmed. No negation is supported — the index is block-level, so a NOT would yield false positives. Express things as positive signals instead.
41
+
42
+ Note: each segment between operators ideally has at least 4 contiguous characters (the indexer uses 4-byte units). Shorter segments still match correctly, but won't narrow the index, so all blocks have to be opened.`,
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ query: { type: "string" },
47
+ machine: { type: "string", description: "machineId, or \"local\" for the machine running this MCP server" },
48
+ startTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse (no-tz strings are local time)" },
49
+ endTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse; must be at least 1 minute in the past" },
50
+ direction: { type: "string", enum: ["fromStart", "fromEnd"] },
51
+ columns: { type: "array", items: { type: "string" }, description: "Which fields to project onto each row. Use [] to get just metadata; use allColumns from a prior result to pick more." },
52
+ limit: { type: "number", default: 100 },
53
+ },
54
+ required: ["query", "machine", "startTime", "endTime", "direction", "columns"],
55
+ },
56
+ },
57
+ ];
58
+
59
+ type JsonRpcRequest = {
60
+ jsonrpc: "2.0";
61
+ id?: number | string | null;
62
+ method: string;
63
+ params?: unknown;
64
+ };
65
+
66
+ type JsonRpcResponse = {
67
+ jsonrpc: "2.0";
68
+ id: number | string | null;
69
+ result?: unknown;
70
+ error?: { code: number; message: string; data?: unknown };
71
+ };
72
+
73
+ let nextRequestSeq = 1;
74
+
75
+ async function handleJsonRpc(body: string, mcp: MCPIndexedLogs): Promise<JsonRpcResponse | undefined> {
76
+ let req: JsonRpcRequest;
77
+ try {
78
+ req = JSON.parse(body);
79
+ } catch (e) {
80
+ return errorResponse(null, -32700, `Parse error: ${(e as Error).message}`);
81
+ }
82
+ if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
83
+ return errorResponse(req.id ?? null, -32600, `Invalid request`);
84
+ }
85
+ // Notifications have no id and expect no response.
86
+ let isNotification = req.id === undefined;
87
+
88
+ let seq = nextRequestSeq++;
89
+ let label = req.method === "tools/call"
90
+ ? `${req.method}/${(req.params as { name?: string } | undefined)?.name ?? "?"}`
91
+ : req.method;
92
+ let startTime = Date.now();
93
+ console.log(`[mcp #${seq}] start ${label}${isNotification ? " (notification)" : ""}`);
94
+
95
+ try {
96
+ let result = await dispatch(req.method, req.params, mcp);
97
+ console.log(`[mcp #${seq}] end ${label} ok in ${formatTime(Date.now() - startTime)}`);
98
+ if (isNotification) return undefined;
99
+ return { jsonrpc: "2.0", id: req.id ?? null, result };
100
+ } catch (e) {
101
+ console.error(`[mcp #${seq}] end ${label} error in ${formatTime(Date.now() - startTime)}:`, (e as Error).stack ?? e);
102
+ if (isNotification) return undefined;
103
+ return errorResponse(req.id ?? null, -32000, (e as Error).message ?? String(e));
104
+ }
105
+ }
106
+
107
+ async function dispatch(method: string, params: unknown, mcp: MCPIndexedLogs): Promise<unknown> {
108
+ if (method === "initialize") {
109
+ return {
110
+ protocolVersion: PROTOCOL_VERSION,
111
+ capabilities: { tools: {} },
112
+ serverInfo: SERVER_INFO,
113
+ };
114
+ }
115
+ if (method === "tools/list") {
116
+ return { tools: TOOL_DEFS };
117
+ }
118
+ if (method === "tools/call") {
119
+ let p = (params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
120
+ let toolName = p.name;
121
+ let args = p.arguments ?? {};
122
+ let result: unknown;
123
+ if (toolName === "search") {
124
+ result = await mcp.search(args as Parameters<MCPIndexedLogs["search"]>[0]);
125
+ } else {
126
+ throw new Error(`Unknown tool ${toolName}`);
127
+ }
128
+ return {
129
+ content: [{ type: "text", text: JSON.stringify(result) }],
130
+ };
131
+ }
132
+ if (method === "ping") {
133
+ return {};
134
+ }
135
+ // Lifecycle notifications (notifications/initialized, notifications/cancelled, ...).
136
+ if (method.startsWith("notifications/")) {
137
+ return {};
138
+ }
139
+ throw new Error(`Unknown method ${method}`);
140
+ }
141
+
142
+ function errorResponse(id: number | string | null, code: number, message: string): JsonRpcResponse {
143
+ return { jsonrpc: "2.0", id, error: { code, message } };
144
+ }
145
+
146
+ async function main() {
147
+ let mcp = new MCPIndexedLogs();
148
+
149
+ let portIdx = process.argv.indexOf("--mcp-port");
150
+ let port = portIdx >= 0 && process.argv[portIdx + 1]
151
+ ? Number(process.argv[portIdx + 1])
152
+ : DEFAULT_MCP_HTTP_PORT;
153
+
154
+ let server = http.createServer((req, res) => {
155
+ if (req.method !== "POST") {
156
+ res.statusCode = 405;
157
+ res.end();
158
+ return;
159
+ }
160
+ let chunks: Buffer[] = [];
161
+ req.on("data", c => chunks.push(c));
162
+ req.on("end", async () => {
163
+ let body = Buffer.concat(chunks).toString("utf8");
164
+ let response = await handleJsonRpc(body, mcp);
165
+ if (response === undefined) {
166
+ // Notification — no body expected.
167
+ res.statusCode = 204;
168
+ res.end();
169
+ return;
170
+ }
171
+ res.setHeader("content-type", "application/json");
172
+ res.end(JSON.stringify(response));
173
+ });
174
+ req.on("error", e => {
175
+ console.error(`MCP request error:`, (e as Error).stack ?? e);
176
+ res.statusCode = 500;
177
+ res.end();
178
+ });
179
+ });
180
+
181
+ await new Promise<void>((resolve, reject) => {
182
+ server.once("error", reject);
183
+ server.listen(port, () => resolve());
184
+ });
185
+ console.log(`MCPIndexedLogs HTTP server listening on http://127.0.0.1:${port}`);
186
+
187
+ await Querysub.hostService("MCPIndexedLogs");
188
+ }
189
+
190
+ main().catch(console.error);
@@ -19,6 +19,9 @@ if (isNode()) {
19
19
  const { logGitHashes } = await import("./logGitHashes");
20
20
  await logGitHashes();
21
21
  }));
22
+ setImmediate(() => {
23
+ void getLoggers2Async();
24
+ });
22
25
  }
23
26
 
24
27
  let loggingToDiskDisabled = false;
@@ -543,7 +543,8 @@ class ErrorNotificationData {
543
543
  }
544
544
  SocketFunction.onNextDisconnect(controllerNodeId, () => {
545
545
  ErrorNotificationData.ensureWatchingErrorsHTTP.reset();
546
- });
546
+ // No need for handling, I think? I believe a refresh of the page will just reconnect automatically.
547
+ }, "iKnowThatServerNodeIdsMayReconnect_andIHandleReconnections");
547
548
  await ErrorNotificationServiceBase.nodes[controllerNodeId].watchUnmatchedErrorsSERVICE();
548
549
  });
549
550
 
@@ -29,6 +29,7 @@ import { delay } from "socket-function/src/batching";
29
29
  import { currentViewParam, selectedServiceIdParam } from "../deployManager/urlParams";
30
30
  import { FunctionCallInfo } from "./FunctionCallInfo";
31
31
  import { PathDistributionInfo } from "./PathDistributionInfo";
32
+ import { ValuePathWarning } from "./ValuePathWarning";
32
33
  import { isCurrentUserSuperUser } from "../user-implementation/userData";
33
34
 
34
35
  export const managementPageURL = new URLParam("managementpage", "");
@@ -366,7 +367,10 @@ class ManagementRoot extends qreact.Component {
366
367
  <ATag values={[{ param: managementPageURL, value: page.componentName }]}>{page.title}</ATag>
367
368
  )}
368
369
  {isCurrentUserSuperUser() && <FunctionCallInfo />}
369
- {isCurrentUserSuperUser() && <PathDistributionInfo />}
370
+ {isCurrentUserSuperUser() && <div className={css.vbox(4)}>
371
+ <PathDistributionInfo />
372
+ <ValuePathWarning />
373
+ </div>}
370
374
  </div>
371
375
  {currentPage &&
372
376
  <div
@@ -203,7 +203,8 @@ export function getSyncedController<T extends {
203
203
  }
204
204
  delete syncedData()[id][nodeId];
205
205
  });
206
- });
206
+ // Reconnection is just the user making another call
207
+ }, "iKnowThatServerNodeIdsMayReconnect_andIHandleReconnections");
207
208
  }
208
209
  return new Proxy({}, {
209
210
  get: (target, fncNameUntyped) => {