mcp-multitool 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -75,6 +75,8 @@ Search code using AST patterns. Matches code structure, not text. Use `$VAR` for
75
75
 
76
76
  **Response:** JSON object with `results` array (each with `file`, `range`, `text`) and `errors` array.
77
77
 
78
+ **Pattern Validation:** Patterns are validated before search. Invalid patterns (e.g., `class X` without a body, `function foo` without parentheses) return an error explaining the issue. Patterns must be syntactically complete code fragments.
79
+
78
80
  **Examples:**
79
81
 
80
82
  ```
@@ -176,16 +178,17 @@ Compress a log file using semantic pattern extraction. Groups similar lines into
176
178
 
177
179
  **Content-hashed template IDs:** Template IDs are 12-character base64URL hashes derived from the pattern itself. The same pattern **always** gets the same ID, regardless of file order or when you call the tool. This means drill-down always works if the pattern still exists.
178
180
 
179
- | Parameter | Type | Required | Description |
180
- | -------------- | --------- | -------- | ------------------------------------------------------------------ |
181
- | `path` | `string` | ✅ | Path to the log file. |
182
- | `simThreshold` | `number` | ✅ | Similarity threshold (0-1). Lower values group more aggressively. |
183
- | `tail` | `integer` | — | Last N lines. |
184
- | `head` | `integer` | — | First N lines. |
185
- | `grep` | `string` | — | Regex filter for lines before compression. |
186
- | `templateId` | `string` | — | Drill into a specific template by its hash ID for sample captures. |
181
+ | Parameter | Type | Required | Description |
182
+ | -------------- | --------- | -------- | ----------------------------------------------------------------------------- |
183
+ | `path` | `string` | ✅ | Path to the log file. |
184
+ | `simThreshold` | `number` | ✅ | Similarity threshold (0-1). Lower values group more aggressively. |
185
+ | `tail` | `integer` | — | Last N lines. |
186
+ | `head` | `integer` | — | First N lines. |
187
+ | `grep` | `string` | — | Regex filter for lines. Smart case: all-lowercase = case-insensitive. |
188
+ | `lineStart` | `string` | — | Regex for entry start lines. Non-matching lines join previous entry with `⏎`. |
189
+ | `templateId` | `string` | — | Drill into a specific template by its hash ID for sample captures. |
187
190
 
188
- **Response:** Compressed log summary showing template IDs, occurrence counts, and patterns with `<*>` wildcards.
191
+ **Response:** Compressed log summary with header showing line reduction, character reduction, template IDs, occurrence counts, and patterns with `<*>` wildcards.
189
192
 
190
193
  **Examples:**
191
194
 
@@ -194,6 +197,7 @@ readLogFile path="/var/log/app.log" simThreshold=0.4
194
197
  readLogFile path="./logs/server.log" simThreshold=0.4 tail=1000
195
198
  readLogFile path="app.log" simThreshold=0.3 grep="ERROR|WARN"
196
199
  readLogFile path="app.log" simThreshold=0.4 templateId="aB3x_Yz7Q2Kf"
200
+ readLogFile path="tsserver.log" simThreshold=0.5 lineStart="^(Info|Err|Perf)\\s+\\d+"
197
201
  ```
198
202
 
199
203
  <details>
@@ -1,5 +1,30 @@
1
1
  import { z } from "zod";
2
- import { findInFiles } from "@ast-grep/napi";
2
+ import { findInFiles, parse } from "@ast-grep/napi";
3
+ function validatePattern(lang, pattern) {
4
+ const tree = parse(lang, pattern);
5
+ const root = tree.root();
6
+ function findErrors(node) {
7
+ const errors = [];
8
+ if (node.kind() === "ERROR") {
9
+ errors.push(`"${node.text().slice(0, 50)}"`);
10
+ }
11
+ for (const child of node.children()) {
12
+ errors.push(...findErrors(child));
13
+ }
14
+ return errors;
15
+ }
16
+ const errors = findErrors(root);
17
+ if (errors.length > 0) {
18
+ return {
19
+ valid: false,
20
+ error: `Pattern "${pattern}" is not valid ${lang} syntax.\n` +
21
+ `Invalid syntax near: ${errors.join(", ")}\n\n` +
22
+ `Tip: Patterns must be syntactically complete code fragments. ` +
23
+ `For example, "class X" is invalid (needs body), use "class X { $$$BODY }" instead.`,
24
+ };
25
+ }
26
+ return { valid: true };
27
+ }
3
28
  const builtinLangs = [
4
29
  "javascript",
5
30
  "typescript",
@@ -38,6 +63,13 @@ export function register(server) {
38
63
  try {
39
64
  const lang = langMap[input.lang];
40
65
  const paths = Array.isArray(input.paths) ? input.paths : [input.paths];
66
+ const validation = validatePattern(lang, input.pattern);
67
+ if (!validation.valid) {
68
+ return {
69
+ isError: true,
70
+ content: [{ type: "text", text: validation.error }],
71
+ };
72
+ }
41
73
  const results = [];
42
74
  const errors = [];
43
75
  await findInFiles(lang, {
@@ -174,6 +174,24 @@ function compress(lines, simThreshold) {
174
174
  function hashTemplateId(pattern) {
175
175
  return createHash("sha256").update(pattern).digest("base64url").slice(0, 12);
176
176
  }
177
+ const CONTINUATION_MARKER = " \u23CE ";
178
+ function joinMultilineEntries(lines, lineStartPattern) {
179
+ const result = [];
180
+ let current = "";
181
+ for (const line of lines) {
182
+ if (lineStartPattern.test(line)) {
183
+ if (current)
184
+ result.push(current);
185
+ current = line;
186
+ }
187
+ else {
188
+ current += CONTINUATION_MARKER + line;
189
+ }
190
+ }
191
+ if (current)
192
+ result.push(current);
193
+ return result;
194
+ }
177
195
  const schema = z.object({
178
196
  path: z.string().min(1).describe("Path to the log file."),
179
197
  simThreshold: z
@@ -183,7 +201,14 @@ const schema = z.object({
183
201
  .describe("Similarity threshold (0-1). Lower = more aggressive grouping."),
184
202
  tail: z.number().int().min(1).optional().describe("Last N lines."),
185
203
  head: z.number().int().min(1).optional().describe("First N lines."),
186
- grep: z.string().optional().describe("Regex filter for lines."),
204
+ grep: z
205
+ .string()
206
+ .optional()
207
+ .describe("Regex filter for lines. Smart case: all-lowercase pattern = case-insensitive, any uppercase = exact case."),
208
+ lineStart: z
209
+ .string()
210
+ .optional()
211
+ .describe("Regex identifying log entry start lines. Lines not matching are joined to previous entry with \u23CE. For tsserver: ^(Info|Err|Perf)\\s+\\d+"),
187
212
  templateId: z
188
213
  .string()
189
214
  .optional()
@@ -219,10 +244,31 @@ async function processLog(input) {
219
244
  lines = lines.slice(0, input.head);
220
245
  else if (input.tail)
221
246
  lines = lines.slice(-input.tail);
222
- if (input.grep)
223
- lines = lines.filter((l) => new RegExp(input.grep).test(l));
247
+ if (input.lineStart) {
248
+ let lineStartRe;
249
+ try {
250
+ lineStartRe = new RegExp(input.lineStart);
251
+ }
252
+ catch {
253
+ return `Invalid lineStart regex: "${input.lineStart}"`;
254
+ }
255
+ lines = joinMultilineEntries(lines, lineStartRe);
256
+ }
257
+ if (input.grep) {
258
+ let re;
259
+ try {
260
+ // Smart case: all-lowercase pattern → case-insensitive (like ripgrep --smart-case)
261
+ const flags = /[A-Z]/.test(input.grep) ? "" : "i";
262
+ re = new RegExp(input.grep, flags);
263
+ }
264
+ catch {
265
+ return `Invalid grep regex: "${input.grep}"`;
266
+ }
267
+ lines = lines.filter((l) => re.test(l));
268
+ }
224
269
  if (!lines.length)
225
270
  return "No log lines to process.";
271
+ const inputLength = lines.reduce((sum, l) => sum + l.length, 0);
226
272
  const templates = compress(lines, input.simThreshold);
227
273
  const templateMap = buildTemplateMap(templates);
228
274
  if (input.templateId) {
@@ -233,7 +279,7 @@ async function processLog(input) {
233
279
  }
234
280
  return formatDrillDown(template);
235
281
  }
236
- return formatOverview(templateMap, lines.length);
282
+ return formatOverview(templateMap, lines.length, inputLength);
237
283
  }
238
284
  function buildTemplateMap(templates) {
239
285
  const map = new Map();
@@ -248,17 +294,22 @@ function buildTemplateMap(templates) {
248
294
  }
249
295
  return map;
250
296
  }
251
- function formatOverview(templateMap, lineCount) {
297
+ function formatOverview(templateMap, lineCount, inputLength) {
252
298
  const sorted = [...templateMap.values()].sort((a, b) => b.count - a.count);
253
- const reduction = Math.round((1 - templateMap.size / lineCount) * 100);
254
- const header = `=== Log Compression ===\n${lineCount} lines → ${templateMap.size} templates (${reduction}% reduction)\n`;
299
+ const lineReduction = Math.round((1 - templateMap.size / lineCount) * 100);
255
300
  const top20 = sorted.slice(0, 20);
256
301
  const topLines = top20
257
302
  .map((t) => `${t.id} [${t.count}x] ${t.pattern}`)
258
303
  .join("\n");
259
304
  const remaining = sorted.length - 20;
260
305
  const footer = remaining > 0 ? `\n... and ${remaining} more templates` : "";
261
- return `${header}\n${topLines}${footer}`;
306
+ const body = `${topLines}${footer}`;
307
+ const outputLength = body.length;
308
+ const charReduction = Math.round((1 - outputLength / inputLength) * 100);
309
+ const header = `=== Log Compression ===\n` +
310
+ `${lineCount} lines → ${templateMap.size} templates (${lineReduction}% reduction)\n` +
311
+ `${inputLength.toLocaleString()} chars → ${outputLength.toLocaleString()} chars (${charReduction}% reduction)\n`;
312
+ return `${header}\n${body}`;
262
313
  }
263
314
  function formatDrillDown(template) {
264
315
  const header = `Template: ${template.id}\nPattern: ${template.pattern}\nMatches: ${template.count}\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-multitool",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "MCP server with file operations (delete, move) and timing utilities.",
5
5
  "license": "MIT",
6
6
  "type": "module",