@mrfentmen/openedge-abl-mcp 1.0.0 → 1.0.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.
package/dist/core.js CHANGED
@@ -1,301 +1,146 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { errorMessage, fail, run, text, ToolError } from "./run.js";
5
- const MAX_INPUT = 2_000_000;
6
- const DEFAULT_TIMEOUT = 30_000;
7
- const SKIP_DIRS = new Set([".git", ".mcp-build", "build", "dist", "node_modules", "vendor"]);
8
- function resolveDir(value) {
9
- return path.resolve(value && value.length > 0 ? value : process.cwd());
10
- }
11
- function resolveFile(file, cwd) {
12
- if (file.includes("\0"))
13
- throw new ToolError("Path contains a null byte");
14
- return path.isAbsolute(file) ? file : path.resolve(cwd, file);
15
- }
16
- function escapeRegExp(value) {
17
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
- }
19
- async function fileExists(file) {
20
- try {
21
- await fs.stat(file);
22
- return true;
23
- }
24
- catch {
25
- return false;
4
+ import { errorMessage, fail, text, ToolError } from "./run.js";
5
+ const MAX_INPUT = 4_000_000;
6
+ const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".mcp-build"]);
7
+ async function readInput(input) {
8
+ if (input.path && input.content !== undefined)
9
+ throw new ToolError("Use path or content, not both");
10
+ if (input.content !== undefined) {
11
+ if (input.content.length > MAX_INPUT)
12
+ throw new ToolError(`Input is larger than ${MAX_INPUT} characters`);
13
+ return { body: input.content };
26
14
  }
27
- }
28
- async function readFileSafe(file) {
15
+ if (!input.path)
16
+ throw new ToolError("Provide a path or content");
17
+ if (input.path.includes("\0"))
18
+ throw new ToolError("Path contains a null byte");
19
+ const file = path.isAbsolute(input.path) ? input.path : path.resolve(input.cwd ? path.resolve(input.cwd) : process.cwd(), input.path);
29
20
  const stat = await fs.stat(file);
30
21
  if (!stat.isFile())
31
22
  throw new ToolError(`Not a file: ${file}`);
32
23
  if (stat.size > MAX_INPUT)
33
24
  throw new ToolError(`File is larger than ${MAX_INPUT} bytes`);
34
- return fs.readFile(file, "utf8");
35
- }
36
- async function loadSource(input) {
37
- const cwd = resolveDir(input.cwd);
38
- if (input.path && input.content !== undefined) {
39
- throw new ToolError("Use path or content, not both");
40
- }
41
- if (input.content !== undefined) {
42
- return { content: input.content, cwd };
43
- }
44
- if (!input.path)
45
- throw new ToolError("Provide a source path or content");
46
- const file = resolveFile(input.path, cwd);
47
- return { content: await readFileSafe(file), file, cwd: path.dirname(file) };
48
- }
49
- function commandContext(file, cwd, config) {
50
- const ext = path.extname(file);
51
- return {
52
- file,
53
- dir: cwd,
54
- build: path.join(cwd, config.buildDir ?? ".mcp-build"),
55
- stem: path.basename(file, ext),
56
- ext,
57
- };
58
- }
59
- function expandCommand(spec, ctx) {
60
- const values = {
61
- file: ctx.file,
62
- dir: ctx.dir,
63
- build: ctx.build,
64
- stem: ctx.stem,
65
- ext: ctx.ext,
66
- };
67
- const replace = (value) => value.replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
68
- return {
69
- command: replace(spec.command),
70
- args: spec.args.map(replace),
71
- timeoutMs: spec.timeoutMs,
72
- };
73
- }
74
- async function commandAvailable(command) {
75
- const result = await run("which", [command], { timeoutMs: 5_000 });
76
- return result.code === 0;
25
+ return { body: await fs.readFile(file, "utf8"), file };
77
26
  }
78
- async function runSteps(steps, ctx, cwd, timeoutMs) {
79
- let output = "";
80
- for (const step of steps) {
81
- const expanded = expandCommand(step, ctx);
82
- const result = await run(expanded.command, expanded.args, {
83
- cwd,
84
- timeoutMs: step.timeoutMs ?? timeoutMs,
85
- });
86
- output += result.stdout;
87
- if (result.stderr.trim().length > 0) {
88
- if (output.length > 0 && !output.endsWith("\n"))
89
- output += "\n";
90
- output += result.stderr;
91
- }
92
- if (result.code !== 0) {
93
- throw new ToolError((result.stderr || result.stdout || `${expanded.command} exited with code ${result.code}`).trim());
94
- }
27
+ function parseOrThrow(format, body) {
28
+ const parsed = format.parse(body);
29
+ const errors = parsed.issues.filter((issue) => issue.severity === "error");
30
+ if (errors.length > 0) {
31
+ const first = errors.slice(0, 5).map((issue) => (issue.line ? `line ${issue.line}: ${issue.message}` : issue.message));
32
+ throw new ToolError(`${format.displayName} could not be parsed:\n${first.join("\n")}`);
95
33
  }
96
- return output;
97
- }
98
- async function pickWorkflow(steps) {
99
- if (steps.length === 0)
100
- throw new ToolError("No command is configured");
101
- if (await commandAvailable(steps[0].command))
102
- return steps;
103
- throw new ToolError(`${steps[0].command} is not installed or not on PATH. Install one of: ${[
104
- ...new Set(steps.map((step) => step.command)),
105
- ].join(", ")}`);
34
+ return parsed;
106
35
  }
107
- async function ensureBuildDir(ctx) {
108
- await fs.mkdir(ctx.build, { recursive: true });
109
- }
110
- function scanStructure(content, config) {
111
- const issues = [];
112
- const stack = [];
113
- const pairs = { ")": "(", "]": "[", "}": "{" };
114
- const opens = new Set(["(", "[", "{"]);
115
- const quotes = config.stringQuotes ?? ['"'];
116
- let inString = null;
117
- let stringLine = 0;
118
- let escaped = false;
119
- let line = 1;
120
- for (let index = 0; index < content.length; index += 1) {
121
- const char = content[index];
122
- const rest = content.slice(index);
123
- if (inString) {
124
- if (escaped) {
125
- escaped = false;
126
- }
127
- else if (char === "\\") {
128
- escaped = true;
129
- }
130
- else if (char === inString) {
131
- inString = null;
132
- }
133
- if (char === "\n")
134
- line += 1;
135
- continue;
136
- }
137
- if (config.blockComment && rest.startsWith(config.blockComment.start)) {
138
- const end = content.indexOf(config.blockComment.end, index + config.blockComment.start.length);
139
- if (end === -1) {
140
- issues.push(`Line ${line}: unclosed block comment`);
141
- break;
142
- }
143
- const skipped = content.slice(index, end + config.blockComment.end.length);
144
- line += (skipped.match(/\n/g) ?? []).length;
145
- index = end + config.blockComment.end.length - 1;
146
- continue;
147
- }
148
- if (config.comment && rest.startsWith(config.comment)) {
149
- const newline = content.indexOf("\n", index);
150
- if (newline === -1)
151
- break;
152
- index = newline;
153
- line += 1;
154
- continue;
155
- }
156
- if (quotes.includes(char)) {
157
- inString = char;
158
- stringLine = line;
159
- escaped = false;
160
- continue;
161
- }
162
- if (char === "\n")
163
- line += 1;
164
- if (opens.has(char)) {
165
- stack.push({ char, line });
166
- }
167
- else if (char in pairs) {
168
- const open = stack.pop();
169
- if (!open) {
170
- issues.push(`Line ${line}: unexpected ${char}`);
36
+ function numericStats(records) {
37
+ const out = {};
38
+ for (const record of records) {
39
+ for (const [key, value] of Object.entries(record.fields)) {
40
+ if (typeof value !== "number" || !Number.isFinite(value))
41
+ continue;
42
+ const current = out[key];
43
+ if (!current) {
44
+ out[key] = { count: 1, min: value, max: value, mean: value, sum: value };
171
45
  }
172
- else if (open.char !== pairs[char]) {
173
- issues.push(`Line ${line}: ${char} closes ${open.char} from line ${open.line}`);
46
+ else {
47
+ current.count += 1;
48
+ current.sum += value;
49
+ current.min = Math.min(current.min, value);
50
+ current.max = Math.max(current.max, value);
51
+ current.mean = current.sum / current.count;
174
52
  }
175
53
  }
176
54
  }
177
- if (inString)
178
- issues.push(`Line ${stringLine}: unclosed string`);
179
- for (const item of stack.slice(-5))
180
- issues.push(`Line ${item.line}: unclosed ${item.char}`);
181
- return issues;
182
- }
183
- function cleanFormat(content, expandTabs, indentWidth) {
184
- const normalized = content.replace(/\r\n?/g, "\n");
185
- const lines = normalized.split("\n").map((line) => {
186
- const noTrailing = line.replace(/[ \t]+$/, "");
187
- return expandTabs ? noTrailing.replace(/\t/g, " ".repeat(indentWidth)) : noTrailing;
188
- });
189
- const body = lines.join("\n").replace(/\n+$/, "");
190
- return body.length > 0 ? `${body}\n` : "";
55
+ return out;
191
56
  }
192
- async function scanProject(dir, config, maxDepth) {
193
- const scan = { files: 0, byExtension: {}, markers: {}, samples: [] };
194
- const root = path.resolve(dir);
195
- async function walk(current, depth) {
196
- if (depth > maxDepth || scan.files >= 2_000)
197
- return;
198
- let entries;
199
- try {
200
- entries = await fs.readdir(current, { withFileTypes: true });
201
- }
202
- catch {
203
- return;
204
- }
205
- for (const entry of entries) {
206
- if (entry.name.startsWith(".") && entry.name !== ".env")
207
- continue;
208
- const full = path.join(current, entry.name);
209
- if (entry.isDirectory()) {
210
- if (SKIP_DIRS.has(entry.name))
211
- continue;
212
- await walk(full, depth + 1);
213
- continue;
214
- }
215
- if (!entry.isFile())
216
- continue;
217
- const ext = path.extname(entry.name).toLowerCase();
218
- const isMarker = config.markers.includes(entry.name);
219
- if (!config.extensions.includes(ext) && !isMarker)
220
- continue;
221
- scan.files += 1;
222
- if (ext)
223
- scan.byExtension[ext] = (scan.byExtension[ext] ?? 0) + 1;
224
- if (isMarker)
225
- scan.markers[entry.name] = (scan.markers[entry.name] ?? 0) + 1;
226
- if (scan.samples.length < 20)
227
- scan.samples.push(path.relative(root, full));
228
- if (scan.files >= 2_000)
229
- return;
230
- }
231
- }
232
- await walk(root, 0);
233
- return scan;
57
+ function round(value) {
58
+ return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : value;
234
59
  }
235
- export function registerLanguageTools(server, config) {
60
+ export function registerFormatTools(server, format) {
236
61
  const sourceArgs = {
237
62
  path: z.string().min(1).max(2_000).optional(),
238
63
  content: z.string().max(MAX_INPUT).optional(),
239
64
  cwd: z.string().min(1).max(2_000).optional(),
240
65
  };
241
- const timeout = z.number().int().min(1_000).max(120_000).default(DEFAULT_TIMEOUT);
242
- server.tool("detect_project", `Find ${config.displayName} files, project markers and the toolchain layout in a directory.`, {
66
+ server.tool("detect_files", `Find ${format.displayName} files (${format.extensions.join(", ")}) in a directory tree.`, {
243
67
  path: z.string().min(1).max(2_000).default("."),
244
- maxDepth: z.number().int().min(0).max(8).default(3),
68
+ maxDepth: z.number().int().min(0).max(8).default(4),
245
69
  }, async (a) => {
246
70
  try {
247
- const root = resolveDir(a.path);
71
+ const root = path.resolve(a.path);
248
72
  const stat = await fs.stat(root);
249
73
  if (!stat.isDirectory())
250
74
  throw new ToolError(`Not a directory: ${root}`);
251
- const scan = await scanProject(root, config, a.maxDepth);
252
- return text(JSON.stringify({
253
- language: config.displayName,
254
- root,
255
- extensions: config.extensions,
256
- markers: config.markers,
257
- files: scan.files,
258
- byExtension: scan.byExtension,
259
- markerFiles: scan.markers,
260
- samples: scan.samples,
261
- }, null, 2));
75
+ const found = [];
76
+ let scanned = 0;
77
+ async function walk(dir, depth) {
78
+ if (depth > a.maxDepth || found.length >= 500)
79
+ return;
80
+ let entries;
81
+ try {
82
+ entries = await fs.readdir(dir, { withFileTypes: true });
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ for (const entry of entries) {
88
+ if (entry.name.startsWith("."))
89
+ continue;
90
+ const full = path.join(dir, entry.name);
91
+ if (entry.isDirectory()) {
92
+ if (SKIP_DIRS.has(entry.name))
93
+ continue;
94
+ await walk(full, depth + 1);
95
+ continue;
96
+ }
97
+ if (!entry.isFile())
98
+ continue;
99
+ scanned += 1;
100
+ if (!format.extensions.includes(path.extname(entry.name).toLowerCase()))
101
+ continue;
102
+ found.push(path.relative(root, full));
103
+ if (found.length >= 500)
104
+ return;
105
+ }
106
+ }
107
+ await walk(root, 0);
108
+ return text(JSON.stringify({ format: format.displayName, root, filesScanned: scanned, matches: found.length, files: found }, null, 2));
262
109
  }
263
110
  catch (error) {
264
111
  return fail(errorMessage(error));
265
112
  }
266
113
  });
267
- server.tool("read_source", `Read a ${config.displayName} source file, optionally a line range.`, {
114
+ server.tool("read_file", `Read a ${format.displayName} file, optionally a line range.`, {
268
115
  path: z.string().min(1).max(2_000),
269
116
  startLine: z.number().int().min(1).default(1),
270
117
  endLine: z.number().int().min(1).optional(),
271
118
  cwd: z.string().min(1).max(2_000).optional(),
272
119
  }, async (a) => {
273
120
  try {
274
- const file = resolveFile(a.path, resolveDir(a.cwd));
275
- const content = await readFileSafe(file);
276
- const lines = content.split("\n");
121
+ const { body } = await readInput({ path: a.path, cwd: a.cwd });
122
+ const lines = body.split(/\r?\n/);
277
123
  const end = Math.min(a.endLine ?? lines.length, lines.length);
278
- const body = lines.slice(a.startLine - 1, end).join("\n");
279
- return text(body);
124
+ return text(lines.slice(a.startLine - 1, end).join("\n"));
280
125
  }
281
126
  catch (error) {
282
127
  return fail(errorMessage(error));
283
128
  }
284
129
  });
285
- server.tool("write_source", `Write a ${config.displayName} source file. Refuses to overwrite unless overwrite is true.`, {
130
+ server.tool("write_file", `Write a ${format.displayName} file. Refuses to overwrite unless overwrite is true.`, {
286
131
  path: z.string().min(1).max(2_000),
287
132
  content: z.string().max(MAX_INPUT),
288
133
  overwrite: z.boolean().default(false),
289
- createDirs: z.boolean().default(true),
290
134
  cwd: z.string().min(1).max(2_000).optional(),
291
135
  }, async (a) => {
292
136
  try {
293
- const file = resolveFile(a.path, resolveDir(a.cwd));
294
- if (!a.overwrite && (await fileExists(file))) {
295
- throw new ToolError(`File already exists: ${file}. Set overwrite=true to replace it.`);
137
+ const file = path.isAbsolute(a.path) ? a.path : path.resolve(a.cwd ? path.resolve(a.cwd) : process.cwd(), a.path);
138
+ if (!a.overwrite) {
139
+ const exists = await fs.stat(file).then(() => true).catch(() => false);
140
+ if (exists)
141
+ throw new ToolError(`File already exists: ${file}. Set overwrite=true to replace it.`);
296
142
  }
297
- if (a.createDirs)
298
- await fs.mkdir(path.dirname(file), { recursive: true });
143
+ await fs.mkdir(path.dirname(file), { recursive: true });
299
144
  await fs.writeFile(file, a.content, "utf8");
300
145
  return text(`Wrote ${Buffer.byteLength(a.content)} bytes to ${file}`);
301
146
  }
@@ -303,400 +148,196 @@ export function registerLanguageTools(server, config) {
303
148
  return fail(errorMessage(error));
304
149
  }
305
150
  });
306
- server.tool("syntax_check", `Check ${config.displayName} structure and, when configured, run the real parser without executing the program.`, {
307
- ...sourceArgs,
308
- }, async (a) => {
309
- try {
310
- const source = await loadSource(a);
311
- const issues = scanStructure(source.content, config);
312
- let parserChecked = false;
313
- if (config.syntax && source.file) {
314
- const ctx = commandContext(source.file, source.cwd, config);
315
- const command = expandCommand(config.syntax, ctx);
316
- const result = await run(command.command, command.args, {
317
- cwd: source.cwd,
318
- timeoutMs: DEFAULT_TIMEOUT,
319
- });
320
- parserChecked = result.code === 0;
321
- if (result.code !== 0) {
322
- issues.push((result.stderr || result.stdout || "parser rejected the file").trim());
323
- }
324
- }
325
- if (issues.length > 0)
326
- throw new ToolError(issues.join("\n"));
327
- return text(parserChecked ? "Syntax OK (parser accepted the file)" : "Syntax OK (structure check)");
328
- }
329
- catch (error) {
330
- return fail(errorMessage(error));
331
- }
332
- });
333
- server.tool("format_source", `Trim trailing whitespace, normalize line endings and ensure a final newline in ${config.displayName} code.`, {
334
- ...sourceArgs,
335
- expandTabs: z.boolean().default(false),
336
- indentWidth: z.number().int().min(2).max(8).default(2),
337
- write: z.boolean().default(false),
338
- }, async (a) => {
151
+ server.tool("parse", `Parse ${format.displayName} into records, fields and document metadata.`, { ...sourceArgs }, async (a) => {
339
152
  try {
340
- const source = await loadSource(a);
341
- const formatted = cleanFormat(source.content, a.expandTabs, a.indentWidth);
342
- if (a.write && source.file) {
343
- await fs.writeFile(source.file, formatted, "utf8");
344
- return text(`Formatted ${source.file}`);
345
- }
346
- return text(formatted);
153
+ const { body, file } = await readInput(a);
154
+ const parsed = parseOrThrow(format, body);
155
+ return text(JSON.stringify({
156
+ file: file ?? null,
157
+ format: parsed.format,
158
+ meta: parsed.meta,
159
+ records: parsed.records.length,
160
+ kinds: parsed.records.reduce((acc, record) => {
161
+ acc[record.kind] = (acc[record.kind] ?? 0) + 1;
162
+ return acc;
163
+ }, {}),
164
+ issues: parsed.issues,
165
+ }, null, 2));
347
166
  }
348
167
  catch (error) {
349
168
  return fail(errorMessage(error));
350
169
  }
351
170
  });
352
- server.tool("lint_source", `Check ${config.displayName} code for trailing whitespace, tabs, long lines, missing final newline and unbalanced structure.`, {
353
- ...sourceArgs,
354
- maxLineLength: z.number().int().min(40).max(400).default(120),
355
- }, async (a) => {
171
+ server.tool("validate", `Validate ${format.displayName} against its structural rules and report every issue with a line number.`, { ...sourceArgs }, async (a) => {
356
172
  try {
357
- const source = await loadSource(a);
358
- const issues = [];
359
- const lines = source.content.replace(/\r\n?/g, "\n").split("\n");
360
- lines.forEach((line, index) => {
361
- if (/[ \t]+$/.test(line))
362
- issues.push(`Line ${index + 1}: trailing whitespace`);
363
- if (!config.allowTabs && line.includes("\t"))
364
- issues.push(`Line ${index + 1}: tab character`);
365
- if (line.length > a.maxLineLength) {
366
- issues.push(`Line ${index + 1}: ${line.length} characters (max ${a.maxLineLength})`);
367
- }
368
- });
369
- if (source.content.length > 0 && !source.content.endsWith("\n")) {
370
- issues.push("File does not end with a newline");
371
- }
372
- issues.push(...scanStructure(source.content, config));
373
- if (issues.length === 0)
374
- return text("No lint issues");
375
- return text(issues.join("\n"));
173
+ const { body } = await readInput(a);
174
+ const parsed = format.parse(body);
175
+ const errors = parsed.issues.filter((issue) => issue.severity === "error").length;
176
+ const warnings = parsed.issues.length - errors;
177
+ return text(JSON.stringify({
178
+ format: parsed.format,
179
+ valid: errors === 0,
180
+ errors,
181
+ warnings,
182
+ issues: parsed.issues,
183
+ }, null, 2));
376
184
  }
377
185
  catch (error) {
378
186
  return fail(errorMessage(error));
379
187
  }
380
188
  });
381
- server.tool("extract_symbols", `List functions, classes, types and other ${config.displayName} definitions with line numbers.`, {
382
- ...sourceArgs,
383
- }, async (a) => {
189
+ server.tool("summarize", `Summarize a ${format.displayName} document: metadata, record counts and per-kind totals.`, { ...sourceArgs }, async (a) => {
384
190
  try {
385
- const source = await loadSource(a);
386
- const found = [];
387
- const lines = source.content.split("\n");
388
- for (const rule of config.symbols) {
389
- const regex = new RegExp(rule.pattern, rule.flags ?? "i");
390
- lines.forEach((line, index) => {
391
- const match = regex.exec(line);
392
- if (match)
393
- found.push(`Line ${index + 1}: ${rule.kind} ${match[1] ?? match[0]}`);
394
- });
395
- }
396
- return text(found.length > 0 ? found.join("\n") : "No symbols found");
191
+ const { body } = await readInput(a);
192
+ const parsed = parseOrThrow(format, body);
193
+ const kinds = parsed.records.reduce((acc, record) => {
194
+ acc[record.kind] = (acc[record.kind] ?? 0) + 1;
195
+ return acc;
196
+ }, {});
197
+ const lines = [
198
+ `${format.displayName} document`,
199
+ `records: ${parsed.records.length}`,
200
+ ...Object.entries(kinds).map(([kind, count]) => ` ${kind}: ${count}`),
201
+ ...Object.entries(parsed.meta).map(([key, value]) => `${key}: ${String(value)}`),
202
+ `issues: ${parsed.issues.length}`,
203
+ ];
204
+ return text(lines.join("\n"));
397
205
  }
398
206
  catch (error) {
399
207
  return fail(errorMessage(error));
400
208
  }
401
209
  });
402
- server.tool("list_imports", `List imports, includes and dependencies in a ${config.displayName} source file.`, {
210
+ server.tool("list_records", `List ${format.displayName} record ids and kinds, with a limit.`, {
403
211
  ...sourceArgs,
212
+ limit: z.number().int().min(1).max(1_000).default(50),
404
213
  }, async (a) => {
405
214
  try {
406
- const source = await loadSource(a);
407
- const found = [];
408
- const lines = source.content.split("\n");
409
- for (const rule of config.imports) {
410
- const regex = new RegExp(rule.pattern, rule.flags ?? "i");
411
- lines.forEach((line, index) => {
412
- const match = regex.exec(line);
413
- if (match)
414
- found.push(`Line ${index + 1}: ${rule.kind} ${match[1] ?? match[0]}`);
415
- });
416
- }
417
- return text(found.length > 0 ? found.join("\n") : "No imports found");
215
+ const { body } = await readInput(a);
216
+ const parsed = parseOrThrow(format, body);
217
+ const rows = parsed.records.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
218
+ const rest = parsed.records.length - rows.length;
219
+ return text(rows.join("\n") + (rest > 0 ? `\n... ${rest} more` : ""));
418
220
  }
419
221
  catch (error) {
420
222
  return fail(errorMessage(error));
421
223
  }
422
224
  });
423
- server.tool("list_todos", `Find TODO, FIXME, HACK and XXX comments in ${config.displayName} code.`, {
225
+ server.tool("get_record", `Return one ${format.displayName} record by id, with all parsed fields.`, {
424
226
  ...sourceArgs,
227
+ id: z.string().min(1).max(300),
425
228
  }, async (a) => {
426
229
  try {
427
- const source = await loadSource(a);
428
- const found = [];
429
- source.content.split("\n").forEach((line, index) => {
430
- if (/\b(TODO|FIXME|HACK|XXX)\b/i.test(line))
431
- found.push(`Line ${index + 1}: ${line.trim()}`);
432
- });
433
- return text(found.length > 0 ? found.join("\n") : "No TODO comments found");
434
- }
435
- catch (error) {
436
- return fail(errorMessage(error));
437
- }
438
- });
439
- server.tool("find_references", `Find every mention of a symbol in one ${config.displayName} source file.`, {
440
- path: z.string().min(1).max(2_000),
441
- symbol: z.string().min(1).max(200),
442
- cwd: z.string().min(1).max(2_000).optional(),
443
- }, async (a) => {
444
- try {
445
- const file = resolveFile(a.path, resolveDir(a.cwd));
446
- const content = await readFileSafe(file);
447
- const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.symbol)}(?![A-Za-z0-9_])`, "g");
448
- const found = [];
449
- content.split("\n").forEach((line, index) => {
450
- const count = [...line.matchAll(regex)].length;
451
- if (count > 0)
452
- found.push(`Line ${index + 1}: ${count} reference(s)`);
453
- });
454
- return text(found.length > 0 ? found.join("\n") : `No references to ${a.symbol}`);
455
- }
456
- catch (error) {
457
- return fail(errorMessage(error));
458
- }
459
- });
460
- server.tool("rename_symbol", `Rename one symbol in a ${config.displayName} file. Defaults to a dry run.`, {
461
- path: z.string().min(1).max(2_000),
462
- oldName: z.string().min(1).max(200),
463
- newName: z.string().min(1).max(200),
464
- dryRun: z.boolean().default(true),
465
- cwd: z.string().min(1).max(2_000).optional(),
466
- }, async (a) => {
467
- try {
468
- if (/[\r\n]/.test(a.newName))
469
- throw new ToolError("New name cannot contain a newline");
470
- const file = resolveFile(a.path, resolveDir(a.cwd));
471
- const content = await readFileSafe(file);
472
- const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.oldName)}(?![A-Za-z0-9_])`, "g");
473
- const count = [...content.matchAll(regex)].length;
474
- if (count === 0)
475
- return text(`No references to ${a.oldName}`);
476
- if (a.dryRun)
477
- return text(`Dry run: would replace ${count} occurrence(s) in ${file}`);
478
- await fs.writeFile(file, content.replace(regex, () => a.newName), "utf8");
479
- return text(`Renamed ${count} occurrence(s) in ${file}`);
480
- }
481
- catch (error) {
482
- return fail(errorMessage(error));
483
- }
484
- });
485
- server.tool("insert_snippet", `Insert a real ${config.displayName} starter snippet into a file. Defaults to a dry run.`, {
486
- path: z.string().min(1).max(2_000),
487
- snippet: z.string().min(1).max(80),
488
- line: z.number().int().min(1).optional(),
489
- dryRun: z.boolean().default(true),
490
- cwd: z.string().min(1).max(2_000).optional(),
491
- }, async (a) => {
492
- try {
493
- const file = resolveFile(a.path, resolveDir(a.cwd));
494
- const snippet = config.snippets[a.snippet];
495
- if (!snippet) {
496
- throw new ToolError(`Unknown snippet: ${a.snippet}. Available: ${Object.entries(config.snippets)
497
- .map(([key, value]) => `${key} (${value.description})`)
498
- .join(", ")}`);
499
- }
500
- const content = await readFileSafe(file);
501
- const lines = content.split("\n");
502
- const index = a.line ? Math.min(a.line - 1, lines.length) : lines.length;
503
- lines.splice(index, 0, ...snippet.body.split("\n"));
504
- const next = lines.join("\n");
505
- if (a.dryRun) {
506
- return text(`Dry run: would insert ${a.snippet} at line ${index + 1} in ${file}`);
507
- }
508
- await fs.writeFile(file, next, "utf8");
509
- return text(`Inserted ${a.snippet} at line ${index + 1} in ${file}`);
230
+ const { body } = await readInput(a);
231
+ const parsed = parseOrThrow(format, body);
232
+ const record = parsed.records.find((item) => item.id === a.id);
233
+ if (!record)
234
+ throw new ToolError(`No record with id ${a.id}. Use list_records to see available ids.`);
235
+ return text(JSON.stringify(record, null, 2));
510
236
  }
511
237
  catch (error) {
512
238
  return fail(errorMessage(error));
513
239
  }
514
240
  });
515
- server.tool("comment_lines", `Comment a line range in ${config.displayName} code using the language comment marker.`, {
241
+ server.tool("search", `Search ${format.displayName} records by plain text or regular expression.`, {
516
242
  ...sourceArgs,
517
- startLine: z.number().int().min(1).default(1),
518
- endLine: z.number().int().min(1).optional(),
519
- write: z.boolean().default(false),
243
+ query: z.string().min(1).max(500),
244
+ regex: z.boolean().default(false),
245
+ limit: z.number().int().min(1).max(500).default(50),
520
246
  }, async (a) => {
521
247
  try {
522
- const source = await loadSource(a);
523
- const lines = source.content.split("\n");
524
- const end = Math.min(a.endLine ?? lines.length, lines.length);
525
- let changed = 0;
526
- for (let index = a.startLine - 1; index < end; index += 1) {
527
- if (lines[index].trim().length > 0 && !lines[index].trimStart().startsWith(config.comment)) {
528
- const indent = lines[index].slice(0, lines[index].length - lines[index].trimStart().length);
529
- lines[index] = `${indent}${config.comment} ${lines[index].slice(indent.length)}`;
530
- changed += 1;
248
+ const { body } = await readInput(a);
249
+ const parsed = parseOrThrow(format, body);
250
+ let test;
251
+ if (a.regex) {
252
+ let pattern;
253
+ try {
254
+ pattern = new RegExp(a.query, "i");
255
+ }
256
+ catch {
257
+ throw new ToolError(`Invalid regular expression: ${a.query}`);
531
258
  }
259
+ test = (value) => pattern.test(value);
532
260
  }
533
- const next = lines.join("\n");
534
- if (a.write && source.file) {
535
- await fs.writeFile(source.file, next, "utf8");
536
- return text(`Commented ${changed} line(s) in ${source.file}`);
261
+ else {
262
+ const needle = a.query.toLowerCase();
263
+ test = (value) => value.toLowerCase().includes(needle);
537
264
  }
538
- return text(next);
265
+ const matches = parsed.records.filter((record) => test(`${record.id} ${record.kind} ${JSON.stringify(record.fields)}`));
266
+ const rows = matches.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
267
+ return text(`${matches.length} match(es)\n${rows.join("\n")}`.trim());
539
268
  }
540
269
  catch (error) {
541
270
  return fail(errorMessage(error));
542
271
  }
543
272
  });
544
- server.tool("uncomment_lines", `Remove the ${config.displayName} comment marker from a line range.`, {
273
+ server.tool("stats", `Numeric statistics for ${format.displayName} fields across all records.`, {
545
274
  ...sourceArgs,
546
- startLine: z.number().int().min(1).default(1),
547
- endLine: z.number().int().min(1).optional(),
548
- write: z.boolean().default(false),
275
+ field: z.string().min(1).max(120).optional(),
549
276
  }, async (a) => {
550
277
  try {
551
- const source = await loadSource(a);
552
- const lines = source.content.split("\n");
553
- const end = Math.min(a.endLine ?? lines.length, lines.length);
554
- let changed = 0;
555
- for (let index = a.startLine - 1; index < end; index += 1) {
556
- const match = lines[index].match(/^(\s*)(.*)$/);
557
- if (!match)
558
- continue;
559
- const indent = match[1];
560
- const rest = match[2];
561
- if (!rest.startsWith(config.comment))
562
- continue;
563
- const after = rest.slice(config.comment.length);
564
- lines[index] = `${indent}${after.startsWith(" ") ? after.slice(1) : after}`;
565
- changed += 1;
278
+ const { body } = await readInput(a);
279
+ const parsed = parseOrThrow(format, body);
280
+ const stats = numericStats(parsed.records);
281
+ if (a.field) {
282
+ const one = stats[a.field];
283
+ if (!one)
284
+ throw new ToolError(`Field ${a.field} has no numeric values. Numeric fields: ${Object.keys(stats).join(", ") || "none"}`);
285
+ return text(JSON.stringify({ [a.field]: { ...one, mean: round(one.mean) } }, null, 2));
566
286
  }
567
- const next = lines.join("\n");
568
- if (a.write && source.file) {
569
- await fs.writeFile(source.file, next, "utf8");
570
- return text(`Uncommented ${changed} line(s) in ${source.file}`);
571
- }
572
- return text(next);
287
+ const rounded = Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, { ...value, mean: round(value.mean) }]));
288
+ return text(JSON.stringify(rounded, null, 2));
573
289
  }
574
290
  catch (error) {
575
291
  return fail(errorMessage(error));
576
292
  }
577
293
  });
578
- server.tool("scaffold_project", `Create a small runnable ${config.displayName} project with a starter file and test directory.`, {
579
- path: z.string().min(1).max(2_000).default("."),
580
- projectName: z.string().min(1).max(120).optional(),
581
- overwrite: z.boolean().default(false),
582
- }, async (a) => {
294
+ server.tool("to_json", `Export every parsed ${format.displayName} record as JSON.`, { ...sourceArgs }, async (a) => {
583
295
  try {
584
- const root = resolveDir(a.path);
585
- const name = a.projectName ?? config.slug;
586
- if (!/^[A-Za-z0-9_.-]+$/.test(name))
587
- throw new ToolError("Invalid project name");
588
- const created = [];
589
- for (const file of config.scaffold) {
590
- const relative = file.path.replace(/\{\{name\}\}/g, name);
591
- const normalized = path.normalize(relative);
592
- if (path.isAbsolute(normalized) || normalized.startsWith("..")) {
593
- throw new ToolError(`Unsafe scaffold path: ${relative}`);
594
- }
595
- const target = path.join(root, normalized);
596
- if (!a.overwrite && (await fileExists(target))) {
597
- throw new ToolError(`File already exists: ${target}`);
598
- }
599
- await fs.mkdir(path.dirname(target), { recursive: true });
600
- await fs.writeFile(target, file.content.replace(/\{\{name\}\}/g, name), "utf8");
601
- created.push(normalized);
602
- }
603
- return text(`Created ${created.length} file(s):\n${created.join("\n")}`);
296
+ const { body, file } = await readInput(a);
297
+ const parsed = parseOrThrow(format, body);
298
+ return text(JSON.stringify({
299
+ file: file ?? null,
300
+ format: parsed.format,
301
+ meta: parsed.meta,
302
+ issues: parsed.issues,
303
+ records: parsed.records,
304
+ }, null, 2));
604
305
  }
605
306
  catch (error) {
606
307
  return fail(errorMessage(error));
607
308
  }
608
309
  });
609
- server.tool("run_source", `Run a ${config.displayName} file with the installed interpreter or compiler.`, {
610
- path: z.string().min(1).max(2_000),
310
+ server.tool("diff", `Compare two ${format.displayName} documents: added, removed and changed records.`, {
311
+ leftPath: z.string().min(1).max(2_000).optional(),
312
+ leftContent: z.string().max(MAX_INPUT).optional(),
313
+ rightPath: z.string().min(1).max(2_000).optional(),
314
+ rightContent: z.string().max(MAX_INPUT).optional(),
611
315
  cwd: z.string().min(1).max(2_000).optional(),
612
- timeoutMs: timeout,
613
- }, async (a) => {
614
- try {
615
- if (!config.runner || config.runner.length === 0) {
616
- throw new ToolError(`No runner is configured for ${config.displayName}`);
617
- }
618
- const file = resolveFile(a.path, resolveDir(a.cwd));
619
- const cwd = path.dirname(file);
620
- const ctx = commandContext(file, cwd, config);
621
- await ensureBuildDir(ctx);
622
- const workflow = await pickWorkflow(config.runner);
623
- const output = await runSteps(workflow, ctx, cwd, a.timeoutMs);
624
- return text(output.trim().length > 0 ? output : "(no output)");
625
- }
626
- catch (error) {
627
- return fail(errorMessage(error));
628
- }
629
- });
630
- server.tool("run_tests", `Run the configured ${config.displayName} test command in a project directory.`, {
631
- cwd: z.string().min(1).max(2_000).default("."),
632
- timeoutMs: timeout,
633
- }, async (a) => {
634
- try {
635
- if (!config.test || config.test.length === 0) {
636
- throw new ToolError(`No test command is configured for ${config.displayName}`);
637
- }
638
- const cwd = resolveDir(a.cwd);
639
- const ctx = commandContext(path.join(cwd, config.slug), cwd, config);
640
- await ensureBuildDir(ctx);
641
- const output = await runSteps(config.test, ctx, cwd, a.timeoutMs);
642
- return text(output.trim().length > 0 ? output : "Tests passed");
643
- }
644
- catch (error) {
645
- return fail(errorMessage(error));
646
- }
647
- });
648
- server.tool("build_project", `Build a ${config.displayName} project with the configured compiler command.`, {
649
- cwd: z.string().min(1).max(2_000).default("."),
650
- path: z.string().min(1).max(2_000).optional(),
651
- timeoutMs: timeout,
652
316
  }, async (a) => {
653
317
  try {
654
- if (!config.build || config.build.length === 0) {
655
- throw new ToolError(`No build step is configured for ${config.displayName}`);
656
- }
657
- const cwd = resolveDir(a.cwd);
658
- let file = a.path ? resolveFile(a.path, cwd) : "";
659
- if (!file) {
660
- const scan = await scanProject(cwd, config, 3);
661
- const first = scan.samples.find((sample) => config.extensions.includes(path.extname(sample).toLowerCase()));
662
- if (first)
663
- file = path.join(cwd, first);
664
- }
665
- const ctx = commandContext(file || path.join(cwd, config.slug), cwd, config);
666
- await ensureBuildDir(ctx);
667
- const output = await runSteps(config.build, ctx, cwd, a.timeoutMs);
668
- return text(output.trim().length > 0 ? output : "Build passed");
669
- }
670
- catch (error) {
671
- return fail(errorMessage(error));
672
- }
673
- });
674
- server.tool("toolchain_status", `Show which ${config.displayName} compilers, interpreters, build tools and test commands are installed.`, {}, async () => {
675
- try {
676
- const commands = new Set();
677
- for (const step of [
678
- ...(config.runner ?? []),
679
- ...(config.syntax ? [config.syntax] : []),
680
- ...(config.build ?? []),
681
- ...(config.test ?? []),
682
- ]) {
683
- commands.add(step.command);
684
- }
685
- const lines = [];
686
- for (const command of [...commands].sort()) {
687
- if (!(await commandAvailable(command))) {
688
- lines.push(`${command}: not found`);
689
- continue;
690
- }
691
- const version = await run(command, ["--version"], { timeoutMs: 5_000 });
692
- const first = (version.stdout || version.stderr).split("\n").find((line) => line.trim().length > 0);
693
- lines.push(`${command}: ${first?.trim() ?? "installed"}`);
694
- }
695
- return text(lines.join("\n") || "No toolchain commands configured");
318
+ const left = await readInput({ path: a.leftPath, content: a.leftContent, cwd: a.cwd });
319
+ const right = await readInput({ path: a.rightPath, content: a.rightContent, cwd: a.cwd });
320
+ const before = parseOrThrow(format, left.body);
321
+ const after = parseOrThrow(format, right.body);
322
+ const beforeFields = new Map(before.records.map((record) => [record.id, JSON.stringify(record.fields)]));
323
+ const afterFields = new Map(after.records.map((record) => [record.id, JSON.stringify(record.fields)]));
324
+ const added = [...afterFields.keys()].filter((id) => !beforeFields.has(id));
325
+ const removed = [...beforeFields.keys()].filter((id) => !afterFields.has(id));
326
+ const changed = [...afterFields.keys()].filter((id) => beforeFields.has(id) && beforeFields.get(id) !== afterFields.get(id));
327
+ return text(JSON.stringify({
328
+ format: before.format,
329
+ added: added.length,
330
+ removed: removed.length,
331
+ changed: changed.length,
332
+ unchanged: afterFields.size - added.length - changed.length,
333
+ records: { added, removed, changed },
334
+ issues: { left: before.issues.length, right: after.issues.length },
335
+ }, null, 2));
696
336
  }
697
337
  catch (error) {
698
338
  return fail(errorMessage(error));
699
339
  }
700
340
  });
701
- server.tool("language_reference", `Return a compact ${config.displayName} syntax and toolchain reference for agents.`, {}, async () => text(config.reference));
341
+ server.tool("format_reference", `Return a compact ${format.displayName} reference: structure, required fields and common pitfalls.`, {}, async () => text(format.reference));
342
+ server.tool("sample", `Return a small but valid ${format.displayName} example document.`, {}, async () => text(format.sample()));
702
343
  }