@dudamel/mcp-context 0.1.0 → 2026.918.1

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 (48) hide show
  1. package/dist/annotations.test.d.ts +2 -0
  2. package/dist/annotations.test.d.ts.map +1 -0
  3. package/dist/cache-dir.d.ts +14 -0
  4. package/dist/cache-dir.d.ts.map +1 -0
  5. package/dist/cache-dir.test.d.ts +2 -0
  6. package/dist/cache-dir.test.d.ts.map +1 -0
  7. package/dist/context-backend.d.ts +36 -0
  8. package/dist/context-backend.d.ts.map +1 -0
  9. package/dist/context-client.d.ts +5 -2
  10. package/dist/context-client.d.ts.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1645 -3
  14. package/dist/mirror-backend.d.ts +61 -0
  15. package/dist/mirror-backend.d.ts.map +1 -0
  16. package/dist/mirror-backend.test.d.ts +2 -0
  17. package/dist/mirror-backend.test.d.ts.map +1 -0
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/stdio-server.d.ts +12 -0
  20. package/dist/stdio-server.d.ts.map +1 -1
  21. package/dist/stdio-server.js +1700 -36
  22. package/dist/tools/instructions.d.ts +2 -2
  23. package/dist/tools/instructions.d.ts.map +1 -1
  24. package/dist/tools/navigate.d.ts +4 -4
  25. package/dist/tools/navigate.d.ts.map +1 -1
  26. package/dist/tools/read.d.ts +7 -7
  27. package/dist/tools/read.d.ts.map +1 -1
  28. package/dist/tools/search.d.ts +3 -3
  29. package/dist/tools/search.d.ts.map +1 -1
  30. package/dist/types.d.ts +37 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +5 -3
  33. package/dist/context-client.js +0 -118
  34. package/dist/context-client.js.map +0 -1
  35. package/dist/index.js.map +0 -1
  36. package/dist/server.js +0 -90
  37. package/dist/server.js.map +0 -1
  38. package/dist/stdio-server.js.map +0 -1
  39. package/dist/tools/instructions.js +0 -40
  40. package/dist/tools/instructions.js.map +0 -1
  41. package/dist/tools/navigate.js +0 -121
  42. package/dist/tools/navigate.js.map +0 -1
  43. package/dist/tools/read.js +0 -352
  44. package/dist/tools/read.js.map +0 -1
  45. package/dist/tools/search.js +0 -103
  46. package/dist/tools/search.js.map +0 -1
  47. package/dist/types.js +0 -2
  48. package/dist/types.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,3 +1,1645 @@
1
- export { createContextServer } from './server.js';
2
- export { ContextServerClient, ContextServerError } from './context-client.js';
3
- //# sourceMappingURL=index.js.map
1
+ // src/server.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+
4
+ // src/context-client.ts
5
+ import yaml from "js-yaml";
6
+ var ContextServerError = class extends Error {
7
+ constructor(message, status, url) {
8
+ super(message);
9
+ this.status = status;
10
+ this.url = url;
11
+ this.name = "ContextServerError";
12
+ }
13
+ };
14
+ var ContextServerClient = class {
15
+ baseUrl;
16
+ apiKey;
17
+ pathPrefix;
18
+ constructor(options) {
19
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
20
+ this.apiKey = options.apiKey;
21
+ this.pathPrefix = this.apiKey ? "/api/context" : "";
22
+ }
23
+ url(path) {
24
+ if (!path.startsWith("/"))
25
+ throw new Error(`ContextServerClient path must start with "/", got "${path}"`);
26
+ return `${this.baseUrl}${this.pathPrefix}${path}`;
27
+ }
28
+ headers(extra) {
29
+ const out = { ...extra ?? {} };
30
+ if (this.apiKey) out["Authorization"] = `ApiKey ${this.apiKey}`;
31
+ return out;
32
+ }
33
+ async get(path) {
34
+ const target = this.url(path);
35
+ const res = await fetch(target, { method: "GET", headers: this.headers() });
36
+ if (!res.ok) {
37
+ const body = await res.text().catch(() => "");
38
+ throw new ContextServerError(
39
+ `GET ${path} -> ${res.status}${body ? `: ${body}` : ""}`,
40
+ res.status,
41
+ target
42
+ );
43
+ }
44
+ return await res.json();
45
+ }
46
+ async post(path, body) {
47
+ const target = this.url(path);
48
+ const res = await fetch(target, {
49
+ method: "POST",
50
+ headers: this.headers({ "Content-Type": "application/json" }),
51
+ body: JSON.stringify(body ?? {})
52
+ });
53
+ if (!res.ok) {
54
+ const text = await res.text().catch(() => "");
55
+ throw new ContextServerError(
56
+ `POST ${path} -> ${res.status}${text ? `: ${text}` : ""}`,
57
+ res.status,
58
+ target
59
+ );
60
+ }
61
+ return await res.json();
62
+ }
63
+ /** Cheap change-detection digest of the whole VFS (used by the local mirror). */
64
+ getManifest() {
65
+ return this.get("/manifest");
66
+ }
67
+ grep(req) {
68
+ return this.post("/search/grep", req);
69
+ }
70
+ find(req) {
71
+ return this.post("/search/find", req);
72
+ }
73
+ stat(path) {
74
+ return this.post("/search/stat", { path });
75
+ }
76
+ tree(path, maxDepth) {
77
+ return this.post("/search/tree", { path, maxDepth });
78
+ }
79
+ tail(path, lines) {
80
+ return this.post("/search/tail", { path, lines });
81
+ }
82
+ readSection(path, heading) {
83
+ return this.post("/search/section", { path, heading });
84
+ }
85
+ readYaml(path, keyPath) {
86
+ return this.post("/search/yaml", { path, keyPath });
87
+ }
88
+ readCsv(path, opts) {
89
+ return this.post("/search/csv", { path, ...opts });
90
+ }
91
+ /**
92
+ * Fetch a file's raw text from the VFS. Path is VFS-relative:
93
+ * "AGENTS.md" -> /data/context/AGENTS.md
94
+ * "src/foo" -> /data/context/src/foo
95
+ */
96
+ async readFile(vfsPath) {
97
+ const segments = vfsPath.split("/").filter((s) => s.length > 0);
98
+ if (segments.length === 0) throw new Error("Path cannot be empty");
99
+ const [sourceName, ...rest] = segments;
100
+ const encoded = encodeURIComponent(sourceName);
101
+ const url = rest.length === 0 ? `/read/${encoded}` : `/read/${encoded}/${rest.map(encodeURIComponent).join("/")}`;
102
+ return this.get(url);
103
+ }
104
+ /**
105
+ * Parsed .index.yaml for a directory.
106
+ * `dirPath` is VFS-relative: "source" or "source/sub/dir".
107
+ */
108
+ async readDirectoryCatalog(dirPath) {
109
+ const trimmed = dirPath.replace(/\/$/, "");
110
+ const catalogPath = trimmed.length === 0 ? ".index.yaml" : `${trimmed}/.index.yaml`;
111
+ const raw = await this.readFile(catalogPath);
112
+ return parseCatalog(raw.content);
113
+ }
114
+ };
115
+ function parseCatalog(text) {
116
+ const parsed = yaml.load(text);
117
+ if (parsed == null || typeof parsed !== "object") {
118
+ throw new Error(`Catalog YAML did not parse into an object: ${typeof parsed}`);
119
+ }
120
+ return parsed;
121
+ }
122
+
123
+ // src/mirror-backend.ts
124
+ import {
125
+ existsSync as existsSync2,
126
+ mkdirSync,
127
+ readFileSync as readFileSync3,
128
+ readdirSync as readdirSync3,
129
+ renameSync,
130
+ rmSync,
131
+ statSync as statSync3,
132
+ writeFileSync
133
+ } from "node:fs";
134
+ import { dirname, join as join3 } from "node:path";
135
+
136
+ // ../../vfs-search/dist/search.js
137
+ import { execFile } from "node:child_process";
138
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
139
+ import { join, relative, resolve } from "node:path";
140
+ import { promisify } from "node:util";
141
+ import yaml2 from "js-yaml";
142
+
143
+ // ../../vfs-search/dist/logger.js
144
+ var noopLogger = {
145
+ debug() {
146
+ },
147
+ warn() {
148
+ }
149
+ };
150
+ var current = noopLogger;
151
+ function vfsLogger() {
152
+ return current;
153
+ }
154
+
155
+ // ../../vfs-search/dist/search.js
156
+ var execFileAsync = promisify(execFile);
157
+ var GREP_TIMEOUT_MS = 15e3;
158
+ var FIND_TIMEOUT_MS = 5e3;
159
+ var TAIL_TIMEOUT_MS = 5e3;
160
+ var DEFAULT_MAX_RESULTS = 50;
161
+ var HARD_MAX_RESULTS = 500;
162
+ var HARD_MAX_OUTPUT_BYTES = 1 * 1024 * 1024;
163
+ var DEFAULT_TREE_DEPTH = 3;
164
+ var HARD_MAX_TREE_ENTRIES = 2e3;
165
+ var DEFAULT_TAIL_LINES = 20;
166
+ var HARD_MAX_TAIL_LINES = 1e3;
167
+ function resolveScope(contextDir, userPath) {
168
+ const trimmed = (userPath ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
169
+ const absolute = resolve(contextDir, trimmed);
170
+ const relativeBack = relative(contextDir, absolute);
171
+ if (relativeBack.startsWith("..") || resolve(relativeBack) === resolve(".."))
172
+ return null;
173
+ if (!existsSync(absolute))
174
+ return null;
175
+ return absolute;
176
+ }
177
+ function toVfsPath(contextDir, absolutePath) {
178
+ return relative(contextDir, absolutePath);
179
+ }
180
+ async function grepVfs(contextDir, opts) {
181
+ const scopes = [];
182
+ if (opts.paths && opts.paths.length > 0) {
183
+ for (const p of opts.paths) {
184
+ const s = resolveScope(contextDir, p);
185
+ if (s)
186
+ scopes.push(s);
187
+ }
188
+ } else {
189
+ const s = resolveScope(contextDir, opts.path);
190
+ if (s)
191
+ scopes.push(s);
192
+ }
193
+ if (scopes.length === 0)
194
+ return { results: [], truncated: false };
195
+ const maxResults = Math.min(opts.maxResults ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS);
196
+ const args = ["-r", "-n", "-H", "-I"];
197
+ if (opts.fixedString)
198
+ args.push("-F");
199
+ else
200
+ args.push("-E");
201
+ if (opts.ignoreCase)
202
+ args.push("-i");
203
+ args.push("--color=never");
204
+ args.push("--exclude=.index.yaml");
205
+ args.push("--exclude-dir=.git");
206
+ args.push("-m", String(maxResults));
207
+ args.push("--");
208
+ args.push(opts.pattern);
209
+ args.push(...scopes);
210
+ vfsLogger().debug({ scopes, pattern: opts.pattern, maxResults }, "grep running");
211
+ let stdout;
212
+ try {
213
+ const res = await execFileAsync("grep", args, {
214
+ timeout: GREP_TIMEOUT_MS,
215
+ maxBuffer: HARD_MAX_OUTPUT_BYTES,
216
+ env: { ...process.env, LC_ALL: "C" }
217
+ });
218
+ stdout = res.stdout;
219
+ } catch (error) {
220
+ const err = error;
221
+ if (err.code === 1)
222
+ return { results: [], truncated: false };
223
+ if (err.code === 2) {
224
+ vfsLogger().warn({ pattern: opts.pattern, stderr: error.stderr }, "grep rejected pattern");
225
+ return { results: [], truncated: false };
226
+ }
227
+ stdout = err.stdout ?? "";
228
+ }
229
+ const hits = [];
230
+ for (const rawLine of stdout.split("\n")) {
231
+ if (rawLine.length === 0)
232
+ continue;
233
+ const first = rawLine.indexOf(":");
234
+ if (first < 0)
235
+ continue;
236
+ const second = rawLine.indexOf(":", first + 1);
237
+ if (second < 0)
238
+ continue;
239
+ const absolute = rawLine.slice(0, first);
240
+ const lineStr = rawLine.slice(first + 1, second);
241
+ const text = rawLine.slice(second + 1);
242
+ const lineNo = parseInt(lineStr, 10);
243
+ if (Number.isNaN(lineNo))
244
+ continue;
245
+ const vfsPath = toVfsPath(contextDir, absolute);
246
+ if (vfsPath.split("/").some((seg) => seg.startsWith(".")))
247
+ continue;
248
+ hits.push({ path: vfsPath, line: lineNo, text });
249
+ if (hits.length >= maxResults)
250
+ break;
251
+ }
252
+ const truncated = hits.length >= maxResults;
253
+ return { results: hits, truncated };
254
+ }
255
+ async function findVfs(contextDir, opts) {
256
+ const scopes = [];
257
+ if (opts.paths && opts.paths.length > 0) {
258
+ for (const p of opts.paths) {
259
+ const s = resolveScope(contextDir, p);
260
+ if (s)
261
+ scopes.push(s);
262
+ }
263
+ } else {
264
+ const s = resolveScope(contextDir, opts.path);
265
+ if (s)
266
+ scopes.push(s);
267
+ }
268
+ if (scopes.length === 0)
269
+ return { results: [], truncated: false };
270
+ const maxResults = Math.min(opts.maxResults ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS);
271
+ const args = [...scopes];
272
+ if (opts.type === "f")
273
+ args.push("-type", "f");
274
+ else if (opts.type === "d")
275
+ args.push("-type", "d");
276
+ args.push("-not", "-path", "*/.*");
277
+ args.push("-not", "-name", ".*");
278
+ args.push("-name", opts.name);
279
+ vfsLogger().debug({ scopes, name: opts.name, type: opts.type }, "find running");
280
+ let stdout;
281
+ try {
282
+ const res = await execFileAsync("find", args, {
283
+ timeout: FIND_TIMEOUT_MS,
284
+ maxBuffer: HARD_MAX_OUTPUT_BYTES
285
+ });
286
+ stdout = res.stdout;
287
+ } catch (error) {
288
+ const err = error;
289
+ vfsLogger().warn({ name: opts.name, code: err.code, stderr: err.stderr }, "find failed");
290
+ stdout = err.stdout ?? "";
291
+ }
292
+ const rawPaths = stdout.split("\n").filter((line) => line.length > 0);
293
+ const results = [];
294
+ for (const absolute of rawPaths) {
295
+ const vfsPath = toVfsPath(contextDir, absolute);
296
+ if (!vfsPath || vfsPath === "." || vfsPath === "..")
297
+ continue;
298
+ results.push(vfsPath);
299
+ if (results.length >= maxResults)
300
+ break;
301
+ }
302
+ const truncated = results.length >= maxResults && rawPaths.length > maxResults;
303
+ return { results, truncated };
304
+ }
305
+ var STAT_LINE_COUNT_MAX_BYTES = 2 * 1024 * 1024;
306
+ function statVfs(contextDir, vfsPath) {
307
+ const absolute = resolveScope(contextDir, vfsPath);
308
+ if (!absolute)
309
+ return { exists: false };
310
+ try {
311
+ const st = statSync(absolute);
312
+ if (st.isDirectory()) {
313
+ const children = readdirSync(absolute).filter((name) => !name.startsWith("."));
314
+ return {
315
+ exists: true,
316
+ type: "directory",
317
+ entries: children.length,
318
+ modified: st.mtime.toISOString()
319
+ };
320
+ }
321
+ if (st.isFile()) {
322
+ const result = {
323
+ exists: true,
324
+ type: "file",
325
+ size: st.size,
326
+ modified: st.mtime.toISOString()
327
+ };
328
+ if (st.size <= STAT_LINE_COUNT_MAX_BYTES) {
329
+ try {
330
+ const buf = readFileSync(absolute);
331
+ let lines = 0;
332
+ for (let i = 0; i < buf.length; i++)
333
+ if (buf[i] === 10)
334
+ lines++;
335
+ if (buf.length > 0 && buf[buf.length - 1] !== 10)
336
+ lines++;
337
+ result.lines = lines;
338
+ } catch {
339
+ }
340
+ }
341
+ return result;
342
+ }
343
+ return { exists: true, type: "other", modified: st.mtime.toISOString() };
344
+ } catch {
345
+ return { exists: false };
346
+ }
347
+ }
348
+ function treeVfs(contextDir, opts) {
349
+ const scope = resolveScope(contextDir, opts.path);
350
+ const rootLabel = (opts.path ?? "").replace(/\/$/, "");
351
+ if (!scope)
352
+ return { root: rootLabel, entries: [], truncated: false };
353
+ const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? DEFAULT_TREE_DEPTH, 10));
354
+ const entries = [];
355
+ let truncated = false;
356
+ function walk(absolute, depth) {
357
+ if (truncated)
358
+ return;
359
+ if (depth > maxDepth)
360
+ return;
361
+ let children;
362
+ try {
363
+ children = readdirSync(absolute, { withFileTypes: true });
364
+ } catch {
365
+ return;
366
+ }
367
+ children.sort((a, b) => {
368
+ if (a.isDirectory() && !b.isDirectory())
369
+ return -1;
370
+ if (!a.isDirectory() && b.isDirectory())
371
+ return 1;
372
+ return a.name.localeCompare(b.name);
373
+ });
374
+ for (const entry of children) {
375
+ if (entry.name.startsWith("."))
376
+ continue;
377
+ if (entries.length >= HARD_MAX_TREE_ENTRIES) {
378
+ truncated = true;
379
+ return;
380
+ }
381
+ const childAbs = join(absolute, entry.name);
382
+ const vfsPath = toVfsPath(contextDir, childAbs);
383
+ if (entry.isDirectory()) {
384
+ entries.push({ path: vfsPath, type: "directory", depth });
385
+ walk(childAbs, depth + 1);
386
+ } else if (entry.isFile()) {
387
+ entries.push({ path: vfsPath, type: "file", depth });
388
+ }
389
+ }
390
+ }
391
+ walk(scope, 1);
392
+ return { root: rootLabel, entries, truncated };
393
+ }
394
+ function parseMarkdownHeadings(content) {
395
+ const lines = content.split("\n");
396
+ const headings = [];
397
+ let fence = null;
398
+ for (let i = 0; i < lines.length; i++) {
399
+ const rawLine = lines[i];
400
+ if (rawLine === void 0)
401
+ continue;
402
+ const trimmed = rawLine.trim();
403
+ if (!fence && (trimmed.startsWith("```") || trimmed.startsWith("~~~"))) {
404
+ fence = trimmed.slice(0, 3);
405
+ continue;
406
+ }
407
+ if (fence && trimmed.startsWith(fence)) {
408
+ fence = null;
409
+ continue;
410
+ }
411
+ if (fence)
412
+ continue;
413
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(rawLine);
414
+ if (match) {
415
+ headings.push({ line: i + 1, level: match[1].length, text: match[2] });
416
+ }
417
+ }
418
+ return headings;
419
+ }
420
+ function readMarkdownSection(contextDir, opts) {
421
+ const absolute = resolveScope(contextDir, opts.path);
422
+ if (!absolute)
423
+ return { exists: false, headings: [] };
424
+ let content;
425
+ try {
426
+ const st = statSync(absolute);
427
+ if (!st.isFile())
428
+ return { exists: false, headings: [] };
429
+ content = readFileSync(absolute, "utf-8");
430
+ } catch {
431
+ return { exists: false, headings: [] };
432
+ }
433
+ const headings = parseMarkdownHeadings(content);
434
+ const result = { exists: true, headings };
435
+ if (!opts.heading)
436
+ return result;
437
+ const needle = opts.heading.toLowerCase();
438
+ const matches = headings.filter((h) => h.text.toLowerCase().includes(needle));
439
+ if (matches.length === 0)
440
+ return result;
441
+ const selected = matches[0];
442
+ if (!selected)
443
+ return result;
444
+ const selectedIdx = headings.indexOf(selected);
445
+ const lines = content.split("\n");
446
+ let endLine = lines.length;
447
+ for (let i = selectedIdx + 1; i < headings.length; i++) {
448
+ const heading = headings[i];
449
+ if (heading && heading.level <= selected.level) {
450
+ endLine = heading.line - 1;
451
+ break;
452
+ }
453
+ }
454
+ const sectionLines = lines.slice(selected.line - 1, endLine);
455
+ result.match = {
456
+ heading: selected,
457
+ startLine: selected.line,
458
+ endLine,
459
+ content: sectionLines.join("\n"),
460
+ ambiguousMatches: matches.length > 1 ? matches.length : void 0
461
+ };
462
+ return result;
463
+ }
464
+ async function tailVfs(contextDir, opts) {
465
+ const absolute = resolveScope(contextDir, opts.path);
466
+ if (!absolute)
467
+ return { content: "", returnedLines: 0, size: 0, exists: false };
468
+ let size;
469
+ try {
470
+ const st = statSync(absolute);
471
+ if (!st.isFile())
472
+ return { content: "", returnedLines: 0, size: 0, exists: false };
473
+ size = st.size;
474
+ } catch {
475
+ return { content: "", returnedLines: 0, size: 0, exists: false };
476
+ }
477
+ const n = Math.max(1, Math.min(opts.lines ?? DEFAULT_TAIL_LINES, HARD_MAX_TAIL_LINES));
478
+ let stdout;
479
+ try {
480
+ const res = await execFileAsync("tail", ["-n", String(n), "--", absolute], {
481
+ timeout: TAIL_TIMEOUT_MS,
482
+ maxBuffer: HARD_MAX_OUTPUT_BYTES
483
+ });
484
+ stdout = res.stdout;
485
+ } catch (error) {
486
+ const err = error;
487
+ vfsLogger().warn({ path: opts.path, stderr: err.stderr }, "tail failed");
488
+ stdout = err.stdout ?? "";
489
+ }
490
+ const trimmed = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
491
+ const returnedLines = trimmed.length === 0 ? 0 : trimmed.split("\n").length;
492
+ return { content: stdout, returnedLines, size, exists: true };
493
+ }
494
+ function yamlTypeOf(value) {
495
+ if (value === null || value === void 0)
496
+ return "null";
497
+ if (Array.isArray(value))
498
+ return "array";
499
+ if (typeof value === "object")
500
+ return "object";
501
+ if (typeof value === "number")
502
+ return "number";
503
+ if (typeof value === "boolean")
504
+ return "boolean";
505
+ return "string";
506
+ }
507
+ function yamlPreview(value) {
508
+ const t = yamlTypeOf(value);
509
+ if (t === "null")
510
+ return "null";
511
+ if (t === "string") {
512
+ const s = String(value);
513
+ return s.length > 80 ? `"${s.slice(0, 77)}..."` : `"${s}"`;
514
+ }
515
+ if (t === "number" || t === "boolean")
516
+ return String(value);
517
+ if (t === "array")
518
+ return `[${value.length} items]`;
519
+ const keys = Object.keys(value);
520
+ return `{${keys.length} keys: ${keys.slice(0, 5).join(", ")}${keys.length > 5 ? ", ..." : ""}}`;
521
+ }
522
+ function resolveYamlKeyPath(root, keyPath) {
523
+ const segments = keyPath.split(".");
524
+ let current2 = root;
525
+ for (const seg of segments) {
526
+ if (current2 === null || current2 === void 0)
527
+ return void 0;
528
+ if (Array.isArray(current2)) {
529
+ const idx = parseInt(seg, 10);
530
+ if (Number.isNaN(idx) || idx < 0 || idx >= current2.length)
531
+ return void 0;
532
+ current2 = current2[idx];
533
+ } else if (typeof current2 === "object") {
534
+ const obj = current2;
535
+ if (!(seg in obj))
536
+ return void 0;
537
+ current2 = obj[seg];
538
+ } else {
539
+ return void 0;
540
+ }
541
+ }
542
+ return current2;
543
+ }
544
+ function listYamlKeys(value) {
545
+ if (value === null || value === void 0)
546
+ return [];
547
+ if (Array.isArray(value)) {
548
+ return value.map((item, i) => ({
549
+ key: String(i),
550
+ type: yamlTypeOf(item),
551
+ preview: yamlPreview(item)
552
+ }));
553
+ }
554
+ if (typeof value === "object") {
555
+ return Object.entries(value).map(([k, v]) => ({
556
+ key: k,
557
+ type: yamlTypeOf(v),
558
+ preview: yamlPreview(v)
559
+ }));
560
+ }
561
+ return [];
562
+ }
563
+ function readYamlPath(contextDir, opts) {
564
+ const absolute = resolveScope(contextDir, opts.path);
565
+ if (!absolute)
566
+ return { exists: false, keys: [] };
567
+ let content;
568
+ try {
569
+ const st = statSync(absolute);
570
+ if (!st.isFile())
571
+ return { exists: false, keys: [] };
572
+ content = readFileSync(absolute, "utf-8");
573
+ } catch {
574
+ return { exists: false, keys: [] };
575
+ }
576
+ let parsed;
577
+ try {
578
+ parsed = yaml2.load(content);
579
+ } catch (e) {
580
+ vfsLogger().warn({ path: opts.path, error: e instanceof Error ? e.message : String(e) }, "YAML parse failed");
581
+ return { exists: true, keys: [] };
582
+ }
583
+ if (!opts.keyPath) {
584
+ return { exists: true, keys: listYamlKeys(parsed) };
585
+ }
586
+ const value = resolveYamlKeyPath(parsed, opts.keyPath);
587
+ if (value === void 0) {
588
+ return { exists: true, keys: listYamlKeys(parsed) };
589
+ }
590
+ const t = yamlTypeOf(value);
591
+ const serialized = t === "string" || t === "number" || t === "boolean" || t === "null" ? String(value) : yaml2.dump(value, { lineWidth: 120, noRefs: true }).trimEnd();
592
+ const result = {
593
+ exists: true,
594
+ keys: listYamlKeys(value),
595
+ match: {
596
+ keyPath: opts.keyPath,
597
+ type: t,
598
+ content: serialized
599
+ }
600
+ };
601
+ return result;
602
+ }
603
+ var DEFAULT_CSV_PREVIEW_ROWS = 10;
604
+ var HARD_MAX_CSV_ROWS = 200;
605
+ function parseCsvLine(line) {
606
+ const fields = [];
607
+ let current2 = "";
608
+ let inQuotes = false;
609
+ for (let i = 0; i < line.length; i++) {
610
+ const ch = line[i];
611
+ if (inQuotes) {
612
+ if (ch === '"') {
613
+ if (i + 1 < line.length && line[i + 1] === '"') {
614
+ current2 += '"';
615
+ i++;
616
+ } else {
617
+ inQuotes = false;
618
+ }
619
+ } else {
620
+ current2 += ch;
621
+ }
622
+ } else {
623
+ if (ch === '"') {
624
+ inQuotes = true;
625
+ } else if (ch === ",") {
626
+ fields.push(current2.trim());
627
+ current2 = "";
628
+ } else {
629
+ current2 += ch;
630
+ }
631
+ }
632
+ }
633
+ fields.push(current2.trim());
634
+ return fields;
635
+ }
636
+ function readCsvRows(contextDir, opts) {
637
+ const absolute = resolveScope(contextDir, opts.path);
638
+ if (!absolute)
639
+ return { exists: false, headers: [], totalRows: 0, rows: [], truncated: false };
640
+ let content;
641
+ try {
642
+ const st = statSync(absolute);
643
+ if (!st.isFile())
644
+ return { exists: false, headers: [], totalRows: 0, rows: [], truncated: false };
645
+ content = readFileSync(absolute, "utf-8");
646
+ } catch {
647
+ return { exists: false, headers: [], totalRows: 0, rows: [], truncated: false };
648
+ }
649
+ const rawLines = content.split("\n").filter((l) => l.trim().length > 0);
650
+ if (rawLines.length === 0) {
651
+ return { exists: true, headers: [], totalRows: 0, rows: [], truncated: false };
652
+ }
653
+ const headerFields = parseCsvLine(rawLines[0]);
654
+ const headers = headerFields.map((name, index) => ({ name, index }));
655
+ const headerNames = new Set(headerFields.map((h) => h.toLowerCase()));
656
+ const maxRows = Math.min(opts.maxRows ?? DEFAULT_CSV_PREVIEW_ROWS, HARD_MAX_CSV_ROWS);
657
+ const selectedCols = opts.columns ? opts.columns.filter((c) => headerNames.has(c.toLowerCase())) : void 0;
658
+ const filterCol = opts.filterColumn?.toLowerCase();
659
+ const filterVal = opts.filterValue?.toLowerCase();
660
+ const filterIdx = filterCol ? headerFields.findIndex((h) => h.toLowerCase() === filterCol) : -1;
661
+ const rows = [];
662
+ let matchedTotal = 0;
663
+ const totalRows = rawLines.length - 1;
664
+ for (let i = 1; i < rawLines.length; i++) {
665
+ const fields = parseCsvLine(rawLines[i]);
666
+ if (filterIdx >= 0 && filterVal) {
667
+ const cellValue = (fields[filterIdx] ?? "").toLowerCase();
668
+ if (!cellValue.includes(filterVal))
669
+ continue;
670
+ }
671
+ matchedTotal++;
672
+ if (rows.length >= maxRows)
673
+ continue;
674
+ const row = {};
675
+ if (selectedCols) {
676
+ for (const col of selectedCols) {
677
+ const idx = headerFields.findIndex((h) => h.toLowerCase() === col.toLowerCase());
678
+ if (idx >= 0)
679
+ row[headerFields[idx]] = fields[idx] ?? "";
680
+ }
681
+ } else {
682
+ for (let j = 0; j < headerFields.length; j++) {
683
+ row[headerFields[j]] = fields[j] ?? "";
684
+ }
685
+ }
686
+ rows.push(row);
687
+ }
688
+ const result = {
689
+ exists: true,
690
+ headers,
691
+ totalRows,
692
+ rows,
693
+ truncated: rows.length < matchedTotal
694
+ };
695
+ if (filterIdx >= 0 && filterVal) {
696
+ result.filterApplied = {
697
+ column: opts.filterColumn,
698
+ value: opts.filterValue,
699
+ matchedRows: matchedTotal
700
+ };
701
+ }
702
+ return result;
703
+ }
704
+
705
+ // ../../vfs-search/dist/manifest.js
706
+ import { createHash } from "node:crypto";
707
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
708
+ import { join as join2, relative as relative2 } from "node:path";
709
+ var HASH_HEX = 32;
710
+ var defaultFileHasher = (_meta, absPath) => createHash("sha256").update(readFileSync2(absPath)).digest("hex");
711
+ function walkFiles(root) {
712
+ const out = [];
713
+ function walk(dir) {
714
+ let entries;
715
+ try {
716
+ entries = readdirSync2(dir, { withFileTypes: true });
717
+ } catch {
718
+ return;
719
+ }
720
+ for (const entry of entries) {
721
+ if (entry.name === ".git")
722
+ continue;
723
+ const abs = join2(dir, entry.name);
724
+ if (entry.isDirectory()) {
725
+ walk(abs);
726
+ } else if (entry.isFile()) {
727
+ let st;
728
+ try {
729
+ st = statSync2(abs);
730
+ } catch {
731
+ continue;
732
+ }
733
+ out.push({
734
+ path: relative2(root, abs).split("\\").join("/"),
735
+ mtimeMs: st.mtimeMs,
736
+ size: st.size
737
+ });
738
+ }
739
+ }
740
+ }
741
+ walk(root);
742
+ out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
743
+ return out;
744
+ }
745
+ function hashLocalFile(absPath, hasher = defaultFileHasher) {
746
+ let st;
747
+ try {
748
+ st = statSync2(absPath);
749
+ } catch {
750
+ return "";
751
+ }
752
+ return hasher({ path: absPath, mtimeMs: st.mtimeMs, size: st.size }, absPath).slice(0, HASH_HEX);
753
+ }
754
+
755
+ // src/mirror-backend.ts
756
+ import yaml3 from "js-yaml";
757
+ var READ_MAX_BYTES = 1048576;
758
+ function log(msg) {
759
+ process.stderr.write(`[dudamel-mcp-context] ${msg}
760
+ `);
761
+ }
762
+ var MirrorContextBackend = class {
763
+ http;
764
+ mirrorDir;
765
+ statePath;
766
+ ttlMs;
767
+ maxBytes;
768
+ /** entry path -> applied digest (files materialized on disk). */
769
+ applied = /* @__PURE__ */ new Map();
770
+ /** entries that contain an un-mirrorable file (>1 MiB) — always served via HTTP for parity. */
771
+ proxyOnly = /* @__PURE__ */ new Set();
772
+ lastManifest = null;
773
+ lastManifestAt = 0;
774
+ refreshing = null;
775
+ disabled = false;
776
+ constructor(http, cache) {
777
+ this.http = http;
778
+ this.mirrorDir = join3(cache.dir, "mirror");
779
+ this.statePath = join3(cache.dir, "state.json");
780
+ this.ttlMs = cache.ttlMs;
781
+ this.maxBytes = cache.maxBytes;
782
+ this.loadState();
783
+ }
784
+ // ── State persistence (warm start across sessions) ─────────────────────────
785
+ loadState() {
786
+ try {
787
+ const raw = JSON.parse(readFileSync3(this.statePath, "utf-8"));
788
+ for (const [k, v] of Object.entries(raw.appliedDigests ?? {})) this.applied.set(k, v);
789
+ this.lastManifestAt = typeof raw.lastManifestAt === "number" ? raw.lastManifestAt : 0;
790
+ } catch {
791
+ }
792
+ }
793
+ saveState() {
794
+ const state = {
795
+ appliedDigests: Object.fromEntries(this.applied),
796
+ lastManifestAt: this.lastManifestAt
797
+ };
798
+ try {
799
+ mkdirSync(dirname(this.statePath), { recursive: true });
800
+ writeFileSync(this.statePath, JSON.stringify(state), "utf-8");
801
+ } catch (error) {
802
+ log(
803
+ `failed to persist cache state: ${error instanceof Error ? error.message : String(error)}`
804
+ );
805
+ }
806
+ }
807
+ // ── Freshness / refresh ────────────────────────────────────────────────────
808
+ /** Synchronously bring the mirror up to date (pre-warm; also used by tests). */
809
+ async warm() {
810
+ if (this.disabled) return;
811
+ await this.refresh();
812
+ }
813
+ /** Kick off a background refresh if the manifest is stale. Never blocks. */
814
+ ensureFresh() {
815
+ if (this.disabled || this.refreshing) return;
816
+ const now = Date.now();
817
+ if (this.lastManifestAt !== 0 && now - this.lastManifestAt < this.ttlMs) return;
818
+ this.refreshing = this.refresh().catch((error) => {
819
+ log(`refresh failed: ${error instanceof Error ? error.message : String(error)}`);
820
+ }).finally(() => {
821
+ this.refreshing = null;
822
+ });
823
+ }
824
+ async refresh() {
825
+ const manifest = await this.http.getManifest();
826
+ const total = manifest.entries.reduce((sum, e) => sum + entrySize(e), 0);
827
+ if (total > this.maxBytes) {
828
+ this.disabled = true;
829
+ log(
830
+ `VFS (${Math.round(total / 1024)} KiB) exceeds cache cap (${Math.round(
831
+ this.maxBytes / 1024
832
+ )} KiB) \u2014 local cache disabled, serving via HTTP.`
833
+ );
834
+ return;
835
+ }
836
+ const manifestPaths = new Set(manifest.entries.map((e) => e.path));
837
+ for (const entry of manifest.entries) {
838
+ if (this.applied.get(entry.path) === entry.digest && !this.proxyOnly.has(entry.path)) {
839
+ continue;
840
+ }
841
+ try {
842
+ await this.refreshEntry(entry);
843
+ } catch (error) {
844
+ log(
845
+ `failed to mirror "${entry.path}": ${error instanceof Error ? error.message : String(error)}`
846
+ );
847
+ }
848
+ }
849
+ for (const applied of [...this.applied.keys()]) {
850
+ if (!manifestPaths.has(applied)) {
851
+ rmSync(join3(this.mirrorDir, applied), { recursive: true, force: true });
852
+ this.applied.delete(applied);
853
+ this.proxyOnly.delete(applied);
854
+ }
855
+ }
856
+ this.lastManifest = manifest;
857
+ this.lastManifestAt = Date.now();
858
+ this.saveState();
859
+ }
860
+ async refreshEntry(entry) {
861
+ if (entry.type === "file") {
862
+ if (entry.size > READ_MAX_BYTES) {
863
+ this.markProxyOnly(entry.path);
864
+ return;
865
+ }
866
+ const abs = join3(this.mirrorDir, entry.path);
867
+ if (hashLocalFile(abs) !== entry.digest) await this.downloadFile(entry.path);
868
+ this.applied.set(entry.path, entry.digest);
869
+ this.proxyOnly.delete(entry.path);
870
+ return;
871
+ }
872
+ if (entry.files.some((f) => f.size > READ_MAX_BYTES)) {
873
+ this.markProxyOnly(entry.path);
874
+ return;
875
+ }
876
+ const entryDir = join3(this.mirrorDir, entry.path);
877
+ const target = new Set(entry.files.map((f) => f.path));
878
+ for (const f of entry.files) {
879
+ const abs = join3(entryDir, f.path);
880
+ if (hashLocalFile(abs) !== f.hash) {
881
+ await this.downloadFile(`${entry.path}/${f.path}`);
882
+ }
883
+ }
884
+ if (existsSync2(entryDir)) {
885
+ for (const local of walkFiles(entryDir)) {
886
+ if (!target.has(local.path)) rmSync(join3(entryDir, local.path), { force: true });
887
+ }
888
+ pruneEmptyDirs(entryDir);
889
+ }
890
+ this.applied.set(entry.path, entry.digest);
891
+ this.proxyOnly.delete(entry.path);
892
+ }
893
+ markProxyOnly(entryPath) {
894
+ this.proxyOnly.add(entryPath);
895
+ this.applied.delete(entryPath);
896
+ rmSync(join3(this.mirrorDir, entryPath), { recursive: true, force: true });
897
+ }
898
+ /** Fetch one file over HTTP and write it into the mirror atomically. */
899
+ async downloadFile(vfsPath) {
900
+ const res = await this.http.readFile(vfsPath);
901
+ const abs = join3(this.mirrorDir, vfsPath);
902
+ mkdirSync(dirname(abs), { recursive: true });
903
+ const tmp = `${abs}.tmp-${process.pid}-${writeCounter++}`;
904
+ writeFileSync(tmp, res.content, "utf-8");
905
+ renameSync(tmp, abs);
906
+ }
907
+ // ── Serving decisions ──────────────────────────────────────────────────────
908
+ /** Top-level entry name for a VFS path ("src/foo/bar.md" -> "src"). */
909
+ entryOf(vfsPath) {
910
+ return vfsPath.split("/").filter((s) => s.length > 0)[0];
911
+ }
912
+ /** Can this path be answered from the mirror? (undefined = whole-VFS op). */
913
+ canServe(vfsPath) {
914
+ if (this.disabled) return false;
915
+ if (vfsPath === void 0 || vfsPath === "") return this.fullyWarm();
916
+ const top = this.entryOf(vfsPath);
917
+ return top !== void 0 && this.applied.has(top) && !this.proxyOnly.has(top);
918
+ }
919
+ /** Whole-VFS reads (unscoped grep/find) need every current entry mirrored. */
920
+ fullyWarm() {
921
+ if (this.disabled || !this.lastManifest) return false;
922
+ return this.lastManifest.entries.every(
923
+ (e) => this.applied.get(e.path) === e.digest && !this.proxyOnly.has(e.path)
924
+ );
925
+ }
926
+ // ── ContextBackend surface ─────────────────────────────────────────────────
927
+ async grep(req) {
928
+ this.ensureFresh();
929
+ if (!this.canServe(req.path)) return this.http.grep(req);
930
+ return grepVfs(this.mirrorDir, {
931
+ pattern: req.pattern,
932
+ path: req.path,
933
+ maxResults: req.maxResults,
934
+ fixedString: req.fixedString,
935
+ ignoreCase: req.ignoreCase
936
+ });
937
+ }
938
+ async find(req) {
939
+ this.ensureFresh();
940
+ if (!this.canServe(req.path)) return this.http.find(req);
941
+ return findVfs(this.mirrorDir, {
942
+ name: req.name,
943
+ path: req.path,
944
+ maxResults: req.maxResults,
945
+ type: req.type
946
+ });
947
+ }
948
+ async stat(path) {
949
+ this.ensureFresh();
950
+ if (!this.canServe(path)) return this.http.stat(path);
951
+ return statVfs(this.mirrorDir, path);
952
+ }
953
+ async tree(path, maxDepth) {
954
+ this.ensureFresh();
955
+ if (!this.canServe(path)) return this.http.tree(path, maxDepth);
956
+ return treeVfs(this.mirrorDir, { path, maxDepth });
957
+ }
958
+ async tail(path, lines) {
959
+ this.ensureFresh();
960
+ if (!this.canServe(path)) return this.http.tail(path, lines);
961
+ return tailVfs(this.mirrorDir, { path, lines });
962
+ }
963
+ async readSection(path, heading) {
964
+ this.ensureFresh();
965
+ if (!this.canServe(path)) return this.http.readSection(path, heading);
966
+ return readMarkdownSection(this.mirrorDir, { path, heading });
967
+ }
968
+ async readYaml(path, keyPath) {
969
+ this.ensureFresh();
970
+ if (!this.canServe(path)) return this.http.readYaml(path, keyPath);
971
+ return readYamlPath(this.mirrorDir, { path, keyPath });
972
+ }
973
+ async readCsv(path, opts) {
974
+ this.ensureFresh();
975
+ if (!this.canServe(path)) return this.http.readCsv(path, opts);
976
+ return readCsvRows(this.mirrorDir, { path, ...opts });
977
+ }
978
+ async readFile(vfsPath) {
979
+ this.ensureFresh();
980
+ if (!this.canServe(vfsPath)) return this.http.readFile(vfsPath);
981
+ const abs = join3(this.mirrorDir, vfsPath);
982
+ try {
983
+ const st = statSync3(abs);
984
+ if (!st.isFile() || st.size > READ_MAX_BYTES) return this.http.readFile(vfsPath);
985
+ const content = readFileSync3(abs, "utf-8");
986
+ const segments = vfsPath.split("/").filter((s) => s.length > 0);
987
+ const displayPath = segments.length <= 1 ? vfsPath : segments.slice(1).join("/");
988
+ return { content, size: st.size, path: displayPath };
989
+ } catch {
990
+ return this.http.readFile(vfsPath);
991
+ }
992
+ }
993
+ async readDirectoryCatalog(dirPath) {
994
+ const trimmed = dirPath.replace(/\/$/, "");
995
+ const catalogPath = trimmed.length === 0 ? ".index.yaml" : `${trimmed}/.index.yaml`;
996
+ const raw = await this.readFile(catalogPath);
997
+ const parsed = yaml3.load(raw.content);
998
+ if (parsed == null || typeof parsed !== "object") {
999
+ throw new Error(`Catalog YAML did not parse into an object: ${typeof parsed}`);
1000
+ }
1001
+ return parsed;
1002
+ }
1003
+ };
1004
+ var writeCounter = 0;
1005
+ function entrySize(entry) {
1006
+ return entry.type === "file" ? entry.size : entry.files.reduce((s, f) => s + f.size, 0);
1007
+ }
1008
+ function pruneEmptyDirs(root) {
1009
+ function walk(dir) {
1010
+ let entries;
1011
+ try {
1012
+ entries = readdirSync3(dir, { withFileTypes: true });
1013
+ } catch {
1014
+ return;
1015
+ }
1016
+ for (const e of entries) {
1017
+ if (e.isDirectory()) {
1018
+ const child = join3(dir, e.name);
1019
+ walk(child);
1020
+ try {
1021
+ if (readdirSync3(child).length === 0) rmSync(child, { recursive: true, force: true });
1022
+ } catch {
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+ walk(root);
1028
+ }
1029
+
1030
+ // src/tools/instructions.ts
1031
+ function registerInstructionsTool(server, client) {
1032
+ server.tool(
1033
+ "get_instructions",
1034
+ "Read the usage guide for this knowledge base (AGENTS.md). Recommended on your first interaction \u2014 it explains the .index.yaml catalog structure and navigation conventions. Not required: `list_sources` and `browse` work without it.",
1035
+ {},
1036
+ { readOnlyHint: true },
1037
+ async () => {
1038
+ try {
1039
+ const result = await client.readFile("AGENTS.md");
1040
+ return {
1041
+ content: [{ type: "text", text: result.content }]
1042
+ };
1043
+ } catch (error) {
1044
+ if (error instanceof ContextServerError && error.status === 404) {
1045
+ return {
1046
+ content: [
1047
+ {
1048
+ type: "text",
1049
+ text: 'No AGENTS.md was found at the VFS root. The admin may not have seeded one yet. For reference, each directory should contain a .index.yaml like:\n\n```yaml\ndescription: "Summary of this directory"\nentries:\n - path: file.md\n type: file\n description: "Summary of this file"\n```\n\nStart by calling `list_sources`, then `browse` to drill down, and `read_file` for actual content.'
1050
+ }
1051
+ ]
1052
+ };
1053
+ }
1054
+ throw error;
1055
+ }
1056
+ }
1057
+ );
1058
+ }
1059
+
1060
+ // src/tools/navigate.ts
1061
+ import { z } from "zod";
1062
+ function registerListSourcesTool(server, client) {
1063
+ server.tool(
1064
+ "list_sources",
1065
+ "List the top-level knowledge sources in this VFS. Each source is a directory at the VFS root with its own documentation tree. Use this to discover what is available, then call `browse` on a source to explore it. If you have not read the usage guide yet, call `get_instructions` first.",
1066
+ {},
1067
+ { readOnlyHint: true },
1068
+ async () => {
1069
+ const catalog = await client.readDirectoryCatalog("");
1070
+ const entries = catalog.entries ?? [];
1071
+ if (entries.length === 0) {
1072
+ return {
1073
+ content: [{ type: "text", text: "No sources are configured in the VFS yet." }]
1074
+ };
1075
+ }
1076
+ const text = entries.map((e) => `- **${e.path}** \u2014 ${e.description || "(no description)"}`).join("\n");
1077
+ return { content: [{ type: "text", text }] };
1078
+ }
1079
+ );
1080
+ }
1081
+ function registerBrowseTool(server, client) {
1082
+ server.tool(
1083
+ "browse",
1084
+ 'List the contents of a directory in the VFS with a description for each entry. Path is VFS-relative, e.g. "my-source" or "my-source/subdir". Omit `path` to browse the root. Prefer this over `read_file` for navigation \u2014 reading catalog descriptions is much cheaper than reading every file.',
1085
+ {
1086
+ path: z.string().optional().describe(
1087
+ 'VFS path to the directory. Examples: "my-source", "my-source/tech", "". Leave empty for the root.'
1088
+ )
1089
+ },
1090
+ { readOnlyHint: true },
1091
+ async ({ path }) => {
1092
+ const normalised = (path ?? "").replace(/\/$/, "");
1093
+ try {
1094
+ const catalog = await client.readDirectoryCatalog(normalised);
1095
+ const lines = [
1096
+ normalised === "" ? "# / (root)" : `# /${normalised}`,
1097
+ "",
1098
+ catalog.description ? catalog.description : "(no directory description)",
1099
+ "",
1100
+ "## Entries"
1101
+ ];
1102
+ if (!catalog.entries || catalog.entries.length === 0) {
1103
+ lines.push("_(empty)_");
1104
+ } else {
1105
+ for (const entry of catalog.entries) {
1106
+ lines.push(
1107
+ `- **${entry.path}** (${entry.type}) \u2014 ${entry.description || "(no description)"}`
1108
+ );
1109
+ }
1110
+ }
1111
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1112
+ } catch (error) {
1113
+ if (error instanceof ContextServerError && error.status === 404) {
1114
+ return {
1115
+ content: [
1116
+ {
1117
+ type: "text",
1118
+ text: `No directory catalog found at "${normalised}". The path may not exist or may not have been indexed yet. Try \`list_sources\` to see what's available.`
1119
+ }
1120
+ ],
1121
+ isError: true
1122
+ };
1123
+ }
1124
+ throw error;
1125
+ }
1126
+ }
1127
+ );
1128
+ }
1129
+ function registerTreeTool(server, client) {
1130
+ server.tool(
1131
+ "tree",
1132
+ "Recursively list directories + files under a VFS path, with depth information. Returns a compact outline \u2014 much cheaper than chained `browse` calls when you want a broad map. Defaults to depth 3; max 10.",
1133
+ {
1134
+ path: z.string().optional().describe(
1135
+ 'VFS path to the subtree root. Examples: "my-source", "my-source/tech". Omit for the whole VFS.'
1136
+ ),
1137
+ maxDepth: z.number().int().min(1).max(10).optional().describe("How many levels deep to descend (default 3, hard cap 10).")
1138
+ },
1139
+ { readOnlyHint: true },
1140
+ async ({ path, maxDepth }) => {
1141
+ const result = await client.tree(path, maxDepth);
1142
+ if (result.entries.length === 0) {
1143
+ return {
1144
+ content: [
1145
+ {
1146
+ type: "text",
1147
+ text: `No entries under ${result.root ? `"${result.root}"` : "the VFS root"} (or it doesn't exist).`
1148
+ }
1149
+ ]
1150
+ };
1151
+ }
1152
+ const lines = formatTree(result.entries);
1153
+ const footer = result.truncated ? "\n\n(truncated \u2014 pass a more specific `path` or a smaller `maxDepth`)" : "";
1154
+ const header = result.root ? `# /${result.root}
1155
+ ` : "# / (root)\n";
1156
+ return { content: [{ type: "text", text: header + lines + footer }] };
1157
+ }
1158
+ );
1159
+ }
1160
+ function formatTree(entries) {
1161
+ return entries.map((e) => {
1162
+ const indent = " ".repeat(Math.max(0, e.depth - 1));
1163
+ const suffix = e.type === "directory" ? "/" : "";
1164
+ return `${indent}- ${e.path}${suffix}`;
1165
+ }).join("\n");
1166
+ }
1167
+
1168
+ // src/tools/read.ts
1169
+ import { z as z2 } from "zod";
1170
+ var MAX_SLICE_BYTES = 256 * 1024;
1171
+ var MAX_TAIL_LINES = 1e3;
1172
+ function registerReadFileTool(server, client) {
1173
+ server.tool(
1174
+ "read_file",
1175
+ `Read the full text of a file in the VFS. Path is VFS-relative, e.g. "AGENTS.md" or "my-source/tech/spec.md". Supports optional byte offset + length slicing for big files (max ${MAX_SLICE_BYTES} bytes per call). Prefer \`browse\` first to see descriptions before reading.`,
1176
+ {
1177
+ path: z2.string().min(1).describe('VFS path to the file. Examples: "AGENTS.md", "my-source/tech/spec.md".'),
1178
+ offset: z2.number().int().nonnegative().optional().describe("Optional byte offset to start reading from. Defaults to 0."),
1179
+ length: z2.number().int().positive().optional().describe(`Optional max bytes to return. Capped at ${MAX_SLICE_BYTES}.`)
1180
+ },
1181
+ { readOnlyHint: true },
1182
+ async ({ path, offset, length }) => {
1183
+ try {
1184
+ const file = await client.readFile(path);
1185
+ const start = offset ?? 0;
1186
+ const requested = Math.min(length ?? MAX_SLICE_BYTES, MAX_SLICE_BYTES);
1187
+ const totalChars = file.content.length;
1188
+ const totalBytes = file.size;
1189
+ if (start >= totalChars) {
1190
+ return {
1191
+ content: [
1192
+ {
1193
+ type: "text",
1194
+ text: `File "${path}" is ${totalBytes} bytes. Offset ${start} is past the end.`
1195
+ }
1196
+ ]
1197
+ };
1198
+ }
1199
+ const slice = file.content.slice(start, start + requested);
1200
+ const end = start + slice.length;
1201
+ const truncated = end < totalChars;
1202
+ const header = start === 0 && !truncated ? `# ${path} (${totalBytes} bytes)` : `# ${path} \u2014 chars ${start}..${end} of ${totalChars} (${totalBytes} bytes)${truncated ? ` (truncated, call again with offset=${end} for more)` : ""}`;
1203
+ return {
1204
+ content: [{ type: "text", text: `${header}
1205
+
1206
+ ${slice}` }]
1207
+ };
1208
+ } catch (error) {
1209
+ if (error instanceof ContextServerError) {
1210
+ if (error.status === 404) {
1211
+ return {
1212
+ content: [
1213
+ {
1214
+ type: "text",
1215
+ text: `File "${path}" not found. Use \`browse\` to see what exists at that path.`
1216
+ }
1217
+ ],
1218
+ isError: true
1219
+ };
1220
+ }
1221
+ if (error.status === 413) {
1222
+ return {
1223
+ content: [
1224
+ {
1225
+ type: "text",
1226
+ text: `File "${path}" is too large to fetch in one call. Use the \`offset\` + \`length\` arguments to read it in chunks.`
1227
+ }
1228
+ ],
1229
+ isError: true
1230
+ };
1231
+ }
1232
+ }
1233
+ throw error;
1234
+ }
1235
+ }
1236
+ );
1237
+ }
1238
+ function registerStatTool(server, client) {
1239
+ server.tool(
1240
+ "stat",
1241
+ "Return metadata for a VFS path (exists, type, size, lines, entries, modified). Use this to size a file before reading or to confirm a path before acting on it.",
1242
+ {
1243
+ path: z2.string().min(1).describe('VFS path. Examples: "AGENTS.md", "my-source/tech", "my-source/CLAUDE.md".')
1244
+ },
1245
+ { readOnlyHint: true },
1246
+ async ({ path }) => {
1247
+ const info = await client.stat(path);
1248
+ if (!info.exists) {
1249
+ return {
1250
+ content: [{ type: "text", text: `"${path}" does not exist in the VFS.` }]
1251
+ };
1252
+ }
1253
+ const bits = [`# ${path}`, `type: ${info.type ?? "unknown"}`];
1254
+ if (info.size !== void 0) bits.push(`size: ${info.size} bytes`);
1255
+ if (info.lines !== void 0) bits.push(`lines: ${info.lines}`);
1256
+ if (info.entries !== void 0) bits.push(`entries: ${info.entries}`);
1257
+ if (info.modified) bits.push(`modified: ${info.modified}`);
1258
+ return { content: [{ type: "text", text: bits.join("\n") }] };
1259
+ }
1260
+ );
1261
+ }
1262
+ function registerTailTool(server, client) {
1263
+ server.tool(
1264
+ "tail",
1265
+ `Return the last N lines of a VFS file (default 20, max ${MAX_TAIL_LINES}). Use this for logs or when you only need the tail of a large file \u2014 for arbitrary byte slices, use \`read_file\` with offset/length.`,
1266
+ {
1267
+ path: z2.string().min(1).describe("VFS path to the file."),
1268
+ lines: z2.number().int().positive().max(MAX_TAIL_LINES).optional().describe(`Number of trailing lines to return (default 20, max ${MAX_TAIL_LINES}).`)
1269
+ },
1270
+ { readOnlyHint: true },
1271
+ async ({ path, lines }) => {
1272
+ try {
1273
+ const result = await client.tail(path, lines);
1274
+ if (!result.exists) {
1275
+ return {
1276
+ content: [{ type: "text", text: `File "${path}" does not exist in the VFS.` }],
1277
+ isError: true
1278
+ };
1279
+ }
1280
+ const header = `# ${path} \u2014 last ${result.returnedLines} line${result.returnedLines === 1 ? "" : "s"} of ${result.size} bytes`;
1281
+ return { content: [{ type: "text", text: `${header}
1282
+
1283
+ ${result.content}` }] };
1284
+ } catch (error) {
1285
+ if (error instanceof ContextServerError && error.status === 404) {
1286
+ return {
1287
+ content: [{ type: "text", text: `File "${path}" not found.` }],
1288
+ isError: true
1289
+ };
1290
+ }
1291
+ throw error;
1292
+ }
1293
+ }
1294
+ );
1295
+ }
1296
+ function registerReadSectionTool(server, client) {
1297
+ server.tool(
1298
+ "read_section",
1299
+ "Read a single section of a markdown file by heading (case-insensitive substring match). The section spans from the matched heading down to the next heading at the same or higher level. Omit `heading` to just list the file's available headings. Ideal to pull one part of a large doc without reading it all.",
1300
+ {
1301
+ path: z2.string().min(1).describe("VFS path to the markdown file."),
1302
+ heading: z2.string().optional().describe(
1303
+ 'Heading text or case-insensitive substring (e.g. "Screens", "state management"). Omit to list headings.'
1304
+ )
1305
+ },
1306
+ { readOnlyHint: true },
1307
+ async ({ path, heading }) => {
1308
+ try {
1309
+ const result = await client.readSection(path, heading);
1310
+ if (!result.exists) {
1311
+ return {
1312
+ content: [{ type: "text", text: `File "${path}" does not exist in the VFS.` }],
1313
+ isError: true
1314
+ };
1315
+ }
1316
+ if (!heading) {
1317
+ if (result.headings.length === 0) {
1318
+ return {
1319
+ content: [{ type: "text", text: `File "${path}" has no markdown headings.` }]
1320
+ };
1321
+ }
1322
+ const lines = result.headings.map(
1323
+ (h) => `${" ".repeat(Math.max(0, h.level - 1))}- L${h.line} (h${h.level}): ${h.text}`
1324
+ );
1325
+ return {
1326
+ content: [{ type: "text", text: `# Headings in ${path}
1327
+
1328
+ ${lines.join("\n")}` }]
1329
+ };
1330
+ }
1331
+ if (!result.match) {
1332
+ const suggestions = result.headings.length === 0 ? " No headings in this file." : ` Available headings: ${result.headings.map((h) => `"${h.text}"`).join(", ")}.`;
1333
+ return {
1334
+ content: [
1335
+ {
1336
+ type: "text",
1337
+ text: `No heading in "${path}" matched "${heading}".${suggestions}`
1338
+ }
1339
+ ],
1340
+ isError: true
1341
+ };
1342
+ }
1343
+ const { match } = result;
1344
+ const ambig = match.ambiguousMatches ? ` (${match.ambiguousMatches} headings matched; returning the first)` : "";
1345
+ const header = `# ${path} \u2014 section "${match.heading.text}" (L${match.startLine}..${match.endLine})${ambig}`;
1346
+ return {
1347
+ content: [{ type: "text", text: `${header}
1348
+
1349
+ ${match.content}` }]
1350
+ };
1351
+ } catch (error) {
1352
+ if (error instanceof ContextServerError && error.status === 404) {
1353
+ return {
1354
+ content: [{ type: "text", text: `File "${path}" not found.` }],
1355
+ isError: true
1356
+ };
1357
+ }
1358
+ throw error;
1359
+ }
1360
+ }
1361
+ );
1362
+ }
1363
+ function registerReadYamlTool(server, client) {
1364
+ server.tool(
1365
+ "read_yaml",
1366
+ 'Read a YAML file by key path. Omit `keyPath` to list top-level keys with types and previews. Provide a dot-separated `keyPath` (e.g. "database.host", "servers.0.port") to extract a specific value. Ideal for inspecting config files, workflow definitions, or catalog metadata without reading the whole file.',
1367
+ {
1368
+ path: z2.string().min(1).describe("VFS path to the YAML file."),
1369
+ keyPath: z2.string().optional().describe(
1370
+ 'Dot-separated key path (e.g. "database.host", "items.0.name"). Omit to list top-level keys.'
1371
+ )
1372
+ },
1373
+ { readOnlyHint: true },
1374
+ async ({ path, keyPath }) => {
1375
+ try {
1376
+ const result = await client.readYaml(path, keyPath);
1377
+ if (!result.exists) {
1378
+ return {
1379
+ content: [{ type: "text", text: `File "${path}" does not exist in the VFS.` }],
1380
+ isError: true
1381
+ };
1382
+ }
1383
+ if (!keyPath) {
1384
+ if (result.keys.length === 0) {
1385
+ return {
1386
+ content: [
1387
+ { type: "text", text: `File "${path}" is empty or not a valid YAML structure.` }
1388
+ ]
1389
+ };
1390
+ }
1391
+ const lines = result.keys.map(
1392
+ (k) => `- **${k.key}** (${k.type}): ${k.preview}`
1393
+ );
1394
+ return {
1395
+ content: [{ type: "text", text: `# Keys in ${path}
1396
+
1397
+ ${lines.join("\n")}` }]
1398
+ };
1399
+ }
1400
+ if (!result.match) {
1401
+ const available = result.keys.length > 0 ? ` Available keys: ${result.keys.map((k) => `"${k.key}"`).join(", ")}.` : " No keys found.";
1402
+ return {
1403
+ content: [
1404
+ { type: "text", text: `Key path "${keyPath}" not found in "${path}".${available}` }
1405
+ ],
1406
+ isError: true
1407
+ };
1408
+ }
1409
+ const { match } = result;
1410
+ const childInfo = result.keys.length > 0 ? `
1411
+
1412
+ Child keys: ${result.keys.map((k) => `${k.key} (${k.type})`).join(", ")}` : "";
1413
+ const header = `# ${path} \u2192 ${match.keyPath} (${match.type})${childInfo}`;
1414
+ return {
1415
+ content: [{ type: "text", text: `${header}
1416
+
1417
+ ${match.content}` }]
1418
+ };
1419
+ } catch (error) {
1420
+ if (error instanceof ContextServerError && error.status === 404) {
1421
+ return {
1422
+ content: [{ type: "text", text: `File "${path}" not found.` }],
1423
+ isError: true
1424
+ };
1425
+ }
1426
+ throw error;
1427
+ }
1428
+ }
1429
+ );
1430
+ }
1431
+ var MAX_CSV_ROWS = 200;
1432
+ function registerReadCsvTool(server, client) {
1433
+ server.tool(
1434
+ "read_csv",
1435
+ `Read a CSV file with optional filtering. Without filters, returns column headers and a preview of the first rows. With \`filterColumn\` + \`filterValue\`, returns rows where that column matches (case-insensitive substring). Use \`columns\` to select only specific columns and reduce output size. Max ${MAX_CSV_ROWS} rows per call.`,
1436
+ {
1437
+ path: z2.string().min(1).describe("VFS path to the CSV file."),
1438
+ filterColumn: z2.string().optional().describe("Column name to filter on (case-insensitive)."),
1439
+ filterValue: z2.string().optional().describe("Value to match in the filter column (case-insensitive substring)."),
1440
+ columns: z2.array(z2.string()).optional().describe("Column names to include in the output. Omit for all columns."),
1441
+ maxRows: z2.number().int().positive().max(MAX_CSV_ROWS).optional().describe(`Max rows to return (default 10, max ${MAX_CSV_ROWS}).`)
1442
+ },
1443
+ { readOnlyHint: true },
1444
+ async ({ path, filterColumn, filterValue, columns, maxRows }) => {
1445
+ try {
1446
+ const result = await client.readCsv(path, { filterColumn, filterValue, columns, maxRows });
1447
+ if (!result.exists) {
1448
+ return {
1449
+ content: [{ type: "text", text: `File "${path}" does not exist in the VFS.` }],
1450
+ isError: true
1451
+ };
1452
+ }
1453
+ if (result.headers.length === 0) {
1454
+ return {
1455
+ content: [{ type: "text", text: `File "${path}" is empty or has no CSV headers.` }]
1456
+ };
1457
+ }
1458
+ const colList = result.headers.map((h) => h.name).join(", ");
1459
+ const filterInfo = result.filterApplied ? ` | filter: ${result.filterApplied.column}="${result.filterApplied.value}" \u2192 ${result.filterApplied.matchedRows} matches` : "";
1460
+ const truncInfo = result.truncated ? " (truncated)" : "";
1461
+ const header = `# ${path} \u2014 ${result.totalRows} rows, ${result.headers.length} columns${filterInfo}${truncInfo}
1462
+ Columns: ${colList}`;
1463
+ if (result.rows.length === 0) {
1464
+ const msg = result.filterApplied ? `No rows matched filter ${result.filterApplied.column}="${result.filterApplied.value}".` : "No data rows found.";
1465
+ return { content: [{ type: "text", text: `${header}
1466
+
1467
+ ${msg}` }] };
1468
+ }
1469
+ const displayCols = columns && columns.length > 0 ? result.headers.filter(
1470
+ (h) => columns.some((c) => c.toLowerCase() === h.name.toLowerCase())
1471
+ ).map((h) => h.name) : result.headers.map((h) => h.name);
1472
+ const lines = result.rows.map(
1473
+ (row) => displayCols.map((col) => `${col}: ${row[col] ?? ""}`).join(" | ")
1474
+ );
1475
+ const body = lines.join("\n");
1476
+ return {
1477
+ content: [{ type: "text", text: `${header}
1478
+
1479
+ ${body}` }]
1480
+ };
1481
+ } catch (error) {
1482
+ if (error instanceof ContextServerError && error.status === 404) {
1483
+ return {
1484
+ content: [{ type: "text", text: `File "${path}" not found.` }],
1485
+ isError: true
1486
+ };
1487
+ }
1488
+ throw error;
1489
+ }
1490
+ }
1491
+ );
1492
+ }
1493
+
1494
+ // src/tools/search.ts
1495
+ import { z as z3 } from "zod";
1496
+ function registerGrepTool(server, client) {
1497
+ server.tool(
1498
+ "grep",
1499
+ "Search file contents across the VFS with a regex (or a literal string if `fixedString` is true). Returns matching lines with their file path and line number. `path` accepts a source, a subfolder, or a single file \u2014 narrow the scope as much as you can. Prefer `browse` + catalog descriptions to narrow first; broad greps are cheap but noisy.",
1500
+ {
1501
+ pattern: z3.string().min(1).describe("The regex (ERE) or literal string to search for."),
1502
+ path: z3.string().optional().describe(
1503
+ 'Scope the search to this VFS path (e.g. "my-source" or "my-source/tech"). Omit for the whole VFS.'
1504
+ ),
1505
+ maxResults: z3.number().int().positive().max(500).optional().describe("Max hits to return (default 50, hard cap 500)."),
1506
+ fixedString: z3.boolean().optional().describe("Treat `pattern` as a literal string, not a regex (grep -F)."),
1507
+ ignoreCase: z3.boolean().optional().describe("Case-insensitive match (grep -i).")
1508
+ },
1509
+ { readOnlyHint: true },
1510
+ async (args) => {
1511
+ const result = await client.grep(args);
1512
+ if (result.results.length === 0) {
1513
+ return {
1514
+ content: [
1515
+ {
1516
+ type: "text",
1517
+ text: `No matches for /${args.pattern}/${args.ignoreCase ? "i" : ""}${args.path ? ` under "${args.path}"` : ""}.`
1518
+ }
1519
+ ]
1520
+ };
1521
+ }
1522
+ const body = result.results.map((hit) => `${hit.path}:${hit.line}: ${hit.text}`).join("\n");
1523
+ const footer = result.truncated ? `
1524
+
1525
+ (truncated at ${result.results.length} hits \u2014 narrow the scope with \`path\` or tighten the pattern)` : "";
1526
+ return {
1527
+ content: [{ type: "text", text: body + footer }]
1528
+ };
1529
+ }
1530
+ );
1531
+ }
1532
+ function registerFindTool(server, client) {
1533
+ server.tool(
1534
+ "find",
1535
+ 'Find files or directories by name across the VFS. `name` accepts find-style globs ("*.md", "foo?", "[ab]*"). Optionally scope with `path` and/or restrict to files ("f") or directories ("d"). Returns a list of VFS paths \u2014 use `read_file` / `browse` to inspect them.',
1536
+ {
1537
+ name: z3.string().min(1).describe('Filename glob. Examples: "*.md", "README*", "spec.*".'),
1538
+ path: z3.string().optional().describe("Scope to this VFS path. Omit for the whole VFS."),
1539
+ maxResults: z3.number().int().positive().max(500).optional().describe("Max paths to return (default 50, hard cap 500)."),
1540
+ type: z3.enum(["f", "d"]).optional().describe('Restrict to files ("f") or directories ("d").')
1541
+ },
1542
+ { readOnlyHint: true },
1543
+ async (args) => {
1544
+ const result = await client.find(args);
1545
+ if (result.results.length === 0) {
1546
+ return {
1547
+ content: [
1548
+ {
1549
+ type: "text",
1550
+ text: `No ${args.type === "d" ? "directories" : args.type === "f" ? "files" : "paths"} match "${args.name}"${args.path ? ` under "${args.path}"` : ""}.`
1551
+ }
1552
+ ]
1553
+ };
1554
+ }
1555
+ const body = result.results.map((p) => `- ${p}`).join("\n");
1556
+ const footer = result.truncated ? `
1557
+
1558
+ (truncated at ${result.results.length} hits \u2014 narrow the scope with \`path\` or use a tighter name pattern)` : "";
1559
+ return {
1560
+ content: [{ type: "text", text: body + footer }]
1561
+ };
1562
+ }
1563
+ );
1564
+ }
1565
+
1566
+ // src/server.ts
1567
+ var BASE_INSTRUCTIONS = `
1568
+ Dudamel Context MCP \u2014 read-only access to the organization's knowledge base (the VFS).
1569
+
1570
+ Call \`get_instructions\` on your first interaction for background on the .index.yaml
1571
+ catalog convention. Not required \u2014 the tools work without it, but it helps you
1572
+ understand the data model the first time.
1573
+
1574
+ Typical workflow:
1575
+ 1. get_instructions \u2014 understand the conventions
1576
+ 2. list_sources \u2014 see what knowledge bases exist
1577
+ 3. browse <path> \u2014 explore a source; read catalog descriptions
1578
+ 4. tree <path> \u2014 compact recursive map of a subtree in one call
1579
+ 5. stat <path> \u2014 check size / line count before reading large files
1580
+ 6. read_file <path> \u2014 fetch actual content when it's relevant
1581
+ 7. read_section <path, h> \u2014 pull one markdown section instead of the whole file
1582
+ 8. read_yaml <path, key> \u2014 navigate a YAML file by dot-path key
1583
+ 9. read_csv <path, filter> \u2014 query a CSV file with column filtering
1584
+ 10. tail <path> \u2014 peek at the end of a file (logs, recent entries)
1585
+ 11. grep / find \u2014 search content or filenames across the VFS
1586
+
1587
+ Prefer reading catalog descriptions (via \`browse\`) over reading full files \u2014
1588
+ catalogs are dense summaries. Use \`grep\`/\`find\` to locate things you don't
1589
+ already have a path for; scope them with \`path\` when you can.
1590
+ `.trim();
1591
+ async function buildInstructions(client) {
1592
+ try {
1593
+ const catalog = await client.readDirectoryCatalog("");
1594
+ const entries = catalog.entries ?? [];
1595
+ if (entries.length === 0) return BASE_INSTRUCTIONS;
1596
+ const sourceLines = entries.map(
1597
+ (e) => `- **${e.path}** \u2014 ${e.description || "(no description yet \u2014 call browse for details)"}`
1598
+ ).join("\n");
1599
+ const discovery = `
1600
+ ## Available knowledge sources
1601
+
1602
+ This server is the authoritative source for the topics below. **When the user
1603
+ asks about anything that overlaps with these descriptions, use this server's
1604
+ tools before answering from memory** \u2014 call \`browse\`/\`grep\`/\`read_file\`
1605
+ to confirm the current state rather than guessing.
1606
+
1607
+ ${sourceLines}
1608
+ `.trim();
1609
+ return `${BASE_INSTRUCTIONS}
1610
+
1611
+ ${discovery}`;
1612
+ } catch {
1613
+ return BASE_INSTRUCTIONS;
1614
+ }
1615
+ }
1616
+ async function createContextServer(config) {
1617
+ const httpClient = config.mode === "proxy" ? new ContextServerClient({ baseUrl: config.baseUrl, apiKey: config.apiKey }) : new ContextServerClient({ baseUrl: config.baseUrl });
1618
+ const client = config.mode === "proxy" && config.cache ? new MirrorContextBackend(httpClient, config.cache) : httpClient;
1619
+ const instructions = await buildInstructions(client);
1620
+ const server = new McpServer(
1621
+ { name: "dudamel-context", version: "0.1.0" },
1622
+ {
1623
+ capabilities: { tools: {} },
1624
+ instructions
1625
+ }
1626
+ );
1627
+ registerInstructionsTool(server, client);
1628
+ registerListSourcesTool(server, client);
1629
+ registerBrowseTool(server, client);
1630
+ registerTreeTool(server, client);
1631
+ registerReadFileTool(server, client);
1632
+ registerReadSectionTool(server, client);
1633
+ registerReadYamlTool(server, client);
1634
+ registerReadCsvTool(server, client);
1635
+ registerStatTool(server, client);
1636
+ registerTailTool(server, client);
1637
+ registerGrepTool(server, client);
1638
+ registerFindTool(server, client);
1639
+ return server;
1640
+ }
1641
+ export {
1642
+ ContextServerClient,
1643
+ ContextServerError,
1644
+ createContextServer
1645
+ };