mcp-multitool 0.1.6 → 0.1.8

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
@@ -4,19 +4,22 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/mcp-multitool)](https://www.npmjs.com/package/mcp-multitool)
5
5
  [![license](https://img.shields.io/npm/l/mcp-multitool)](./LICENSE)
6
6
 
7
- A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server with basic **file operations** and **timing utilities**.
7
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server with **file operations**, **log compression**, and **timing utilities**.
8
8
 
9
9
  ## Tools
10
10
 
11
- | Tool | Description |
12
- | ------------ | --------------------------------------------- |
13
- | `deleteFile` | Delete files or directories (single or batch) |
14
- | `moveFile` | Move/rename files or directories |
15
- | `wait` | Pause execution for rate limits or timing |
11
+ | Tool | Description |
12
+ | ----------------- | ------------------------------------------------------- |
13
+ | `checkFileOrDir` | Check if a file or directory exists and return metadata |
14
+ | `deleteFileOrDir` | Delete one or more files or directories |
15
+ | `moveFileOrDir` | Move one or more files or directories to a new location |
16
+ | `readLogFile` | Read and compress logs with 60-90% token reduction |
17
+ | `renameFileOrDir` | Rename a single file or directory |
18
+ | `wait` | Pause execution for rate limits or timing |
16
19
 
17
20
  ## Why
18
21
 
19
- Some tasks need simple utilities that don't warrant a larger server — cleaning up temp files, pausing for rate limits. `mcp-multitool` gives any MCP-compatible client a small set of reliable tools.
22
+ Some tasks need simple utilities that don't warrant a larger server — cleaning up temp files, compressing verbose logs before analysis, pausing for rate limits. `mcp-multitool` gives any MCP-compatible client a small set of reliable tools.
20
23
 
21
24
  ## Quick Start
22
25
 
@@ -58,45 +61,131 @@ npx mcp-multitool
58
61
 
59
62
  ## Tool Reference
60
63
 
61
- ### `deleteFile`
64
+ ### `checkFileOrDir`
65
+
66
+ Check if a file or directory exists and return its metadata (type, size, timestamps, permissions). Returns an error if the path does not exist.
67
+
68
+ | Parameter | Type | Required | Description |
69
+ | --------- | -------- | -------- | --------------------------------------- |
70
+ | `path` | `string` | ✅ | Path to the file or directory to check. |
71
+
72
+ **Response:** JSON object with raw `fs.Stats` properties plus computed `name`, `path`, and `type` fields.
73
+
74
+ **Examples:**
75
+
76
+ ```
77
+ checkFileOrDir path="config.json"
78
+ checkFileOrDir path="/var/log/"
79
+ checkFileOrDir path="./src/index.ts"
80
+ ```
81
+
82
+ ---
83
+
84
+ ### `deleteFileOrDir`
62
85
 
63
86
  Delete one or more files or directories.
64
87
 
65
- | Parameter | Type | Required | Default | Description |
66
- | ----------- | -------------------- | -------- | ------- | ----------------------------------------------------- |
67
- | `paths` | `string \| string[]` | ✅ | — | File or directory path(s) to delete. |
68
- | `recursive` | `boolean` | | `false` | If true, delete directories and contents recursively. |
88
+ | Parameter | Type | Required | Description |
89
+ | ----------- | -------------------- | -------- | ----------------------------------------------------- |
90
+ | `paths` | `string \| string[]` | ✅ | File or directory path(s) to delete. |
91
+ | `recursive` | `boolean` | | If true, delete directories and contents recursively. |
69
92
 
70
93
  **Response:** `"Deleted N path(s)."`
71
94
 
72
95
  **Examples:**
73
96
 
74
97
  ```
75
- deleteFile paths="temp.txt"
76
- deleteFile paths=["a.txt", "b.txt"]
77
- deleteFile paths="build/" recursive=true
98
+ deleteFileOrDir paths="temp.txt" recursive=false
99
+ deleteFileOrDir paths=["a.txt", "b.txt"] recursive=false
100
+ deleteFileOrDir paths="build/" recursive=true
78
101
  ```
79
102
 
80
103
  ---
81
104
 
82
- ### `moveFile`
105
+ ### `moveFileOrDir`
83
106
 
84
107
  Move one or more files or directories to a destination directory.
85
108
 
86
- | Parameter | Type | Required | Default | Description |
87
- | ----------- | -------------------- | -------- | ------- | ---------------------------------- |
88
- | `from` | `string \| string[]` | ✅ | — | Source path(s) to move. |
89
- | `to` | `string` | ✅ | — | Destination directory. |
90
- | `overwrite` | `boolean` | | `false` | If true, overwrite existing files. |
109
+ | Parameter | Type | Required | Description |
110
+ | ----------- | -------------------- | -------- | ---------------------------------- |
111
+ | `from` | `string \| string[]` | ✅ | Source path(s) to move. |
112
+ | `to` | `string` | ✅ | Destination directory. |
113
+ | `overwrite` | `boolean` | | If true, overwrite existing files. |
91
114
 
92
115
  **Response:** `"Moved N path(s)."`
93
116
 
94
117
  **Examples:**
95
118
 
96
119
  ```
97
- moveFile from="old.txt" to="archive/"
98
- moveFile from=["a.txt", "b.txt"] to="backup/"
99
- moveFile from="config.json" to="dest/" overwrite=true
120
+ moveFileOrDir from="old.txt" to="archive/" overwrite=false
121
+ moveFileOrDir from=["a.txt", "b.txt"] to="backup/" overwrite=false
122
+ moveFileOrDir from="config.json" to="dest/" overwrite=true
123
+ ```
124
+
125
+ ---
126
+
127
+ ### `readLogFile`
128
+
129
+ Compress a log file using semantic pattern extraction (60-90% token reduction). Creates stateful drains for incremental reads. Use `flushLogFile` to release.
130
+
131
+ **Stateful drains:** On first call for a file, creates a stateful drain. Subsequent calls append only new lines to the existing drain, preserving template IDs. This enables incremental log analysis as files grow. When any drain is active, a dynamic `flushLogFile` tool appears to release drains.
132
+
133
+ | Parameter | Type | Required | Description |
134
+ | -------------- | --------- | -------- | ------------------------------------------------ |
135
+ | `path` | `string` | ✅ | Path to the log file. |
136
+ | `format` | `string` | ✅ | Output format: `summary`, `detailed`, or `json`. |
137
+ | `depth` | `integer` | ✅ | Parse tree depth (2-8). |
138
+ | `simThreshold` | `number` | ✅ | Similarity threshold (0-1). |
139
+ | `tail` | `integer` | — | Last N lines (first read only). |
140
+ | `head` | `integer` | — | First N lines (first read only). |
141
+ | `grep` | `string` | — | Regex filter for lines. |
142
+
143
+ **Response:** Compressed log summary showing unique templates and occurrence counts.
144
+
145
+ **Examples:**
146
+
147
+ ```
148
+ readLogFile path="/var/log/app.log" format="summary" depth=4 simThreshold=0.4
149
+ readLogFile path="./logs/server.log" format="detailed" depth=4 simThreshold=0.4 tail=1000
150
+ readLogFile path="app.log" format="json" depth=6 simThreshold=0.3 grep="ERROR|WARN"
151
+ ```
152
+
153
+ ---
154
+
155
+ ### `flushLogFile` (dynamic)
156
+
157
+ Release a log drain to free memory. Next `readLogFile` creates fresh drain. **This tool only appears when at least one drain is active.** When the last drain is flushed, the tool is automatically removed.
158
+
159
+ | Parameter | Type | Required | Description |
160
+ | --------- | -------- | -------- | ------------------------------ |
161
+ | `path` | `string` | ✅ | Path to the log file to flush. |
162
+
163
+ **Response:** `"Flushed {filename}. Released N templates from M lines."`
164
+
165
+ **Example:**
166
+
167
+ ```
168
+ flushLogFile path="/var/log/app.log"
169
+ ```
170
+
171
+ ---
172
+
173
+ ### `renameFileOrDir`
174
+
175
+ Rename a single file or directory. Only the name can change — the parent directory must stay the same. Use `moveFileOrDir` to change directories.
176
+
177
+ | Parameter | Type | Required | Description |
178
+ | --------- | -------- | -------- | -------------------------------------------- |
179
+ | `oldPath` | `string` | ✅ | Current path to the file or directory. |
180
+ | `newPath` | `string` | ✅ | New path with the renamed file or directory. |
181
+
182
+ **Response:** `"Renamed "{oldName}" to "{newName}"."`
183
+
184
+ **Examples:**
185
+
186
+ ```
187
+ renameFileOrDir oldPath="config.json" newPath="config.backup.json"
188
+ renameFileOrDir oldPath="/app/src" newPath="/app/source"
100
189
  ```
101
190
 
102
191
  ---
@@ -125,8 +214,12 @@ wait durationSeconds=1 reason="animation to complete"
125
214
  | Variable | Default | Description |
126
215
  | ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------- |
127
216
  | `waitMaxDurationSeconds` | `300` | Override the maximum allowed `durationSeconds`. Must be a positive number. Server refuses to start if invalid. |
128
- | `deleteFile` | _(on)_ | Set to `"false"` to disable the `deleteFile` tool at startup. |
129
- | `moveFile` | _(on)_ | Set to `"false"` to disable the `moveFile` tool at startup. |
217
+ | `readLogFileTimeoutMs` | `5000` | Override the timeout for `readLogFile` processing in milliseconds. Server refuses to start if invalid. |
218
+ | `checkFileOrDir` | _(on)_ | Set to `"false"` to disable the `checkFileOrDir` tool at startup. |
219
+ | `deleteFileOrDir` | _(on)_ | Set to `"false"` to disable the `deleteFileOrDir` tool at startup. |
220
+ | `moveFileOrDir` | _(on)_ | Set to `"false"` to disable the `moveFileOrDir` tool at startup. |
221
+ | `readLogFile` | _(on)_ | Set to `"false"` to disable the `readLogFile` tool at startup. |
222
+ | `renameFileOrDir` | _(on)_ | Set to `"false"` to disable the `renameFileOrDir` tool at startup. |
130
223
  | `wait` | _(on)_ | Set to `"false"` to disable the `wait` tool at startup. |
131
224
 
132
225
  ### Disabling Individual Tools
package/dist/index.js CHANGED
@@ -2,17 +2,26 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { register as registerDeleteFile } from "./tools/deleteFile.js";
6
- import { register as registerMoveFile } from "./tools/moveFile.js";
5
+ import { register as registerCheckFileOrDir } from "./tools/checkFileOrDir.js";
6
+ import { register as registerDeleteFileOrDir } from "./tools/deleteFileOrDir.js";
7
+ import { register as registerMoveFileOrDir } from "./tools/moveFileOrDir.js";
8
+ import { register as registerReadLogFile } from "./tools/readLogFile.js";
9
+ import { register as registerRenameFileOrDir } from "./tools/renameFileOrDir.js";
7
10
  import { register as registerWait } from "./tools/wait.js";
8
11
  const require = createRequire(import.meta.url);
9
12
  const { version } = require("../package.json");
10
13
  const isEnabled = (name) => process.env[name] !== "false";
11
14
  const server = new McpServer({ name: "mcp-multitool", version });
12
- if (isEnabled("deleteFile"))
13
- registerDeleteFile(server);
14
- if (isEnabled("moveFile"))
15
- registerMoveFile(server);
15
+ if (isEnabled("checkFileOrDir"))
16
+ registerCheckFileOrDir(server);
17
+ if (isEnabled("deleteFileOrDir"))
18
+ registerDeleteFileOrDir(server);
19
+ if (isEnabled("moveFileOrDir"))
20
+ registerMoveFileOrDir(server);
21
+ if (isEnabled("readLogFile"))
22
+ registerReadLogFile(server);
23
+ if (isEnabled("renameFileOrDir"))
24
+ registerRenameFileOrDir(server);
16
25
  if (isEnabled("wait"))
17
26
  registerWait(server);
18
27
  await server.connect(new StdioServerTransport());
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { lstat } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ const schema = z.object({
5
+ path: z.string().min(1).describe("Path to the file or folder to check."),
6
+ });
7
+ export function register(server) {
8
+ server.registerTool("checkFile", {
9
+ description: "Check if a file or folder exists and return its metadata (type, size, timestamps, permissions). Returns an error if the path does not exist.",
10
+ inputSchema: schema,
11
+ annotations: {
12
+ readOnlyHint: true,
13
+ openWorldHint: false,
14
+ },
15
+ }, async (input) => {
16
+ try {
17
+ const fullPath = resolve(process.cwd(), input.path);
18
+ const stats = await lstat(fullPath);
19
+ const type = stats.isFile() ? "file"
20
+ : stats.isDirectory() ? "directory"
21
+ : stats.isSymbolicLink() ? "symlink"
22
+ : stats.isBlockDevice() ? "block-device"
23
+ : stats.isCharacterDevice() ? "character-device"
24
+ : stats.isFIFO() ? "fifo"
25
+ : stats.isSocket() ? "socket"
26
+ : "unknown";
27
+ // Build output from raw stats, adding computed fields
28
+ const meta = {
29
+ name: basename(fullPath),
30
+ path: fullPath,
31
+ type,
32
+ ...stats,
33
+ };
34
+ return {
35
+ content: [{ type: "text", text: JSON.stringify(meta, null, 2) }],
36
+ };
37
+ }
38
+ catch (err) {
39
+ return {
40
+ isError: true,
41
+ content: [{ type: "text", text: String(err) }],
42
+ };
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { lstat } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ const schema = z.object({
5
+ path: z.string().min(1).describe("Path to the file or folder to check."),
6
+ });
7
+ export function register(server) {
8
+ server.registerTool("checkFileOrDir", {
9
+ description: "Check if a file or directory exists and return its metadata (type, size, timestamps, permissions). Returns an error if the path does not exist.",
10
+ inputSchema: schema,
11
+ annotations: {
12
+ readOnlyHint: true,
13
+ openWorldHint: false,
14
+ },
15
+ }, async (input) => {
16
+ try {
17
+ const fullPath = resolve(process.cwd(), input.path);
18
+ const stats = await lstat(fullPath);
19
+ const type = stats.isFile() ? "file"
20
+ : stats.isDirectory() ? "directory"
21
+ : stats.isSymbolicLink() ? "symlink"
22
+ : stats.isBlockDevice() ? "block-device"
23
+ : stats.isCharacterDevice() ? "character-device"
24
+ : stats.isFIFO() ? "fifo"
25
+ : stats.isSocket() ? "socket"
26
+ : "unknown";
27
+ // Build output from raw stats, adding computed fields
28
+ const meta = {
29
+ name: basename(fullPath),
30
+ path: fullPath,
31
+ type,
32
+ ...stats,
33
+ };
34
+ return {
35
+ content: [{ type: "text", text: JSON.stringify(meta, null, 2) }],
36
+ };
37
+ }
38
+ catch (err) {
39
+ return {
40
+ isError: true,
41
+ content: [{ type: "text", text: String(err) }],
42
+ };
43
+ }
44
+ });
45
+ }
@@ -6,13 +6,16 @@ const schema = z.object({
6
6
  .describe("File or directory path(s) to delete."),
7
7
  recursive: z
8
8
  .boolean()
9
- .default(false)
10
9
  .describe("If true, delete directories and contents recursively."),
11
10
  });
12
11
  export function register(server) {
13
12
  server.registerTool("deleteFile", {
14
13
  description: "Delete one or more files or directories.",
15
14
  inputSchema: schema,
15
+ annotations: {
16
+ destructiveHint: true,
17
+ openWorldHint: false,
18
+ },
16
19
  }, async (input) => {
17
20
  try {
18
21
  const paths = Array.isArray(input.paths) ? input.paths : [input.paths];
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,34 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ const schema = z.object({
4
+ paths: z
5
+ .union([z.string(), z.array(z.string())])
6
+ .describe("File or directory path(s) to delete."),
7
+ recursive: z
8
+ .boolean()
9
+ .describe("If true, delete directories and contents recursively."),
10
+ });
11
+ export function register(server) {
12
+ server.registerTool("deleteFileOrDir", {
13
+ description: "Delete one or more files or directories.",
14
+ inputSchema: schema,
15
+ annotations: {
16
+ destructiveHint: true,
17
+ openWorldHint: false,
18
+ },
19
+ }, async (input) => {
20
+ try {
21
+ const paths = Array.isArray(input.paths) ? input.paths : [input.paths];
22
+ await Promise.all(paths.map((p) => rm(p, { recursive: input.recursive })));
23
+ return {
24
+ content: [{ type: "text", text: `Deleted ${paths.length} path(s).` }],
25
+ };
26
+ }
27
+ catch (err) {
28
+ return {
29
+ isError: true,
30
+ content: [{ type: "text", text: String(err) }],
31
+ };
32
+ }
33
+ });
34
+ }
@@ -8,7 +8,6 @@ const schema = z.object({
8
8
  to: z.string().describe("Destination directory."),
9
9
  overwrite: z
10
10
  .boolean()
11
- .default(false)
12
11
  .describe("If true, overwrite existing files."),
13
12
  });
14
13
  async function exists(path) {
@@ -24,6 +23,10 @@ export function register(server) {
24
23
  server.registerTool("moveFile", {
25
24
  description: "Move one or more files or directories to a destination directory.",
26
25
  inputSchema: schema,
26
+ annotations: {
27
+ destructiveHint: true,
28
+ openWorldHint: false,
29
+ },
27
30
  }, async (input) => {
28
31
  try {
29
32
  const sources = Array.isArray(input.from) ? input.from : [input.from];
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,49 @@
1
+ import { access, rename } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { z } from "zod";
4
+ const schema = z.object({
5
+ from: z
6
+ .union([z.string(), z.array(z.string())])
7
+ .describe("Source path(s) to move."),
8
+ to: z.string().describe("Destination directory."),
9
+ overwrite: z.boolean().describe("If true, overwrite existing files."),
10
+ });
11
+ async function exists(path) {
12
+ try {
13
+ await access(path);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export function register(server) {
21
+ server.registerTool("moveFileOrDir", {
22
+ description: "Move one or more files or directories to a destination directory.",
23
+ inputSchema: schema,
24
+ annotations: {
25
+ destructiveHint: true,
26
+ openWorldHint: false,
27
+ },
28
+ }, async (input) => {
29
+ try {
30
+ const sources = Array.isArray(input.from) ? input.from : [input.from];
31
+ for (const src of sources) {
32
+ const dest = join(input.to, basename(src));
33
+ if (!input.overwrite && (await exists(dest))) {
34
+ throw new Error(`Destination exists: ${dest}. Set overwrite=true to replace.`);
35
+ }
36
+ await rename(src, dest);
37
+ }
38
+ return {
39
+ content: [{ type: "text", text: `Moved ${sources.length} path(s).` }],
40
+ };
41
+ }
42
+ catch (err) {
43
+ return {
44
+ isError: true,
45
+ content: [{ type: "text", text: String(err) }],
46
+ };
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,152 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, basename } from "node:path";
3
+ import { z } from "zod";
4
+ import { createDrain } from "logpare";
5
+ const timeoutMs = (() => {
6
+ const env = process.env.readLogTimeoutMs;
7
+ if (!env)
8
+ return 5000;
9
+ const n = Number(env);
10
+ if (!Number.isFinite(n) || n <= 0) {
11
+ process.stderr.write(`Invalid readLogTimeoutMs: "${env}".\n`);
12
+ process.exit(1);
13
+ }
14
+ return n;
15
+ })();
16
+ const drains = new Map();
17
+ let flushTool = null;
18
+ const schema = z
19
+ .object({
20
+ path: z.string().min(1).describe("Path to the log file."),
21
+ format: z.enum(["summary", "detailed", "json"]).describe("Output format."),
22
+ depth: z.number().int().min(2).max(8).describe("Parse tree depth (2-8)."),
23
+ simThreshold: z
24
+ .number()
25
+ .min(0)
26
+ .max(1)
27
+ .describe("Similarity threshold (0-1)."),
28
+ tail: z
29
+ .number()
30
+ .int()
31
+ .min(1)
32
+ .optional()
33
+ .describe("Last N lines (first read only)."),
34
+ head: z
35
+ .number()
36
+ .int()
37
+ .min(1)
38
+ .optional()
39
+ .describe("First N lines (first read only)."),
40
+ grep: z.string().optional().describe("Regex filter for lines."),
41
+ })
42
+ .refine((d) => !(d.head && d.tail), {
43
+ message: "Cannot use both head and tail.",
44
+ });
45
+ const flushSchema = z.object({
46
+ path: z.string().min(1).describe("Path to the log file to flush."),
47
+ });
48
+ const ok = (text) => ({ content: [{ type: "text", text }] });
49
+ const err = (text) => ({
50
+ isError: true,
51
+ content: [{ type: "text", text }],
52
+ });
53
+ export function register(server) {
54
+ server.registerTool("readLog", {
55
+ description: "Compress a log file using semantic pattern extraction (60-90% reduction). Creates stateful drains for incremental reads. Use flushLog to release.",
56
+ inputSchema: schema,
57
+ annotations: { readOnlyHint: true, openWorldHint: false },
58
+ }, async (input) => {
59
+ try {
60
+ return ok(await Promise.race([processLog(server, input), timeout()]));
61
+ }
62
+ catch (e) {
63
+ return err(String(e));
64
+ }
65
+ });
66
+ }
67
+ async function processLog(server, input) {
68
+ const path = resolve(process.cwd(), input.path);
69
+ let state = drains.get(path);
70
+ if (state &&
71
+ (state.depth !== input.depth || state.simThreshold !== input.simThreshold)) {
72
+ return `Error: Drain exists with depth=${state.depth}, simThreshold=${state.simThreshold}. Flush first.`;
73
+ }
74
+ const content = await readFile(path, "utf-8");
75
+ let lines = content.split(/\r?\n/).filter(Boolean);
76
+ if (!state) {
77
+ const wasEmpty = drains.size === 0;
78
+ if (input.head)
79
+ lines = lines.slice(0, input.head);
80
+ else if (input.tail)
81
+ lines = lines.slice(-input.tail);
82
+ if (input.grep)
83
+ lines = lines.filter((l) => new RegExp(input.grep).test(l));
84
+ if (!lines.length)
85
+ return "No log lines to process.";
86
+ const drain = createDrain({
87
+ depth: input.depth,
88
+ simThreshold: input.simThreshold,
89
+ });
90
+ drain.addLogLines(lines);
91
+ state = {
92
+ drain,
93
+ lastLine: lines.length,
94
+ depth: input.depth,
95
+ simThreshold: input.simThreshold,
96
+ };
97
+ drains.set(path, state);
98
+ if (wasEmpty)
99
+ registerFlush(server);
100
+ return `${state.drain.getResult(input.format).formatted}\n\n[New drain. ${state.lastLine} lines. Use flushLog when done.]`;
101
+ }
102
+ const newLines = lines.slice(state.lastLine);
103
+ if (!newLines.length) {
104
+ return `${state.drain.getResult(input.format).formatted}\n\n[No new lines. Total: ${state.lastLine}]`;
105
+ }
106
+ const filtered = input.grep ?
107
+ newLines.filter((l) => new RegExp(input.grep).test(l))
108
+ : newLines;
109
+ if (filtered.length)
110
+ state.drain.addLogLines(filtered);
111
+ state.lastLine = lines.length;
112
+ return `${state.drain.getResult(input.format).formatted}\n\n[+${newLines.length} lines. Total: ${state.lastLine}]`;
113
+ }
114
+ function registerFlush(server) {
115
+ if (flushTool)
116
+ return;
117
+ try {
118
+ flushTool = server.registerTool("flushLog", {
119
+ description: "Release a log drain to free memory. Next readLog creates fresh drain.",
120
+ inputSchema: flushSchema,
121
+ annotations: { destructiveHint: true, idempotentHint: true },
122
+ }, async (input) => {
123
+ try {
124
+ const path = resolve(process.cwd(), input.path);
125
+ const state = drains.get(path);
126
+ if (!state) {
127
+ if (!drains.size)
128
+ return ok("No active drains.");
129
+ return ok(`No drain for "${basename(path)}". Active: ${[...drains.keys()].map((p) => basename(p)).join(", ")}`);
130
+ }
131
+ const { totalClusters, lastLine } = {
132
+ totalClusters: state.drain.totalClusters,
133
+ lastLine: state.lastLine,
134
+ };
135
+ drains.delete(path);
136
+ if (!drains.size && flushTool) {
137
+ flushTool.remove();
138
+ flushTool = null;
139
+ }
140
+ return ok(`Flushed ${basename(path)}. Released ${totalClusters} templates from ${lastLine} lines.`);
141
+ }
142
+ catch (e) {
143
+ return err(String(e));
144
+ }
145
+ });
146
+ server.sendToolListChanged();
147
+ }
148
+ catch { }
149
+ }
150
+ function timeout() {
151
+ return new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout: ${timeoutMs}ms`)), timeoutMs));
152
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,151 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, basename } from "node:path";
3
+ import { z } from "zod";
4
+ import { createDrain } from "logpare";
5
+ const timeoutMs = (() => {
6
+ const env = process.env.readLogFileTimeoutMs;
7
+ if (!env)
8
+ return 5000;
9
+ const n = Number(env);
10
+ if (!Number.isFinite(n) || n <= 0) {
11
+ process.stderr.write(`Invalid readLogFileTimeoutMs: "${env}".\n`);
12
+ process.exit(1);
13
+ }
14
+ return n;
15
+ })();
16
+ const drains = new Map();
17
+ let flushTool = null;
18
+ const schema = z.object({
19
+ path: z.string().min(1).describe("Path to the log file."),
20
+ format: z.enum(["summary", "detailed", "json"]).describe("Output format."),
21
+ depth: z.number().int().min(2).max(8).describe("Parse tree depth (2-8)."),
22
+ simThreshold: z
23
+ .number()
24
+ .min(0)
25
+ .max(1)
26
+ .describe("Similarity threshold (0-1)."),
27
+ tail: z
28
+ .number()
29
+ .int()
30
+ .min(1)
31
+ .optional()
32
+ .describe("Last N lines (first read only)."),
33
+ head: z
34
+ .number()
35
+ .int()
36
+ .min(1)
37
+ .optional()
38
+ .describe("First N lines (first read only)."),
39
+ grep: z.string().optional().describe("Regex filter for lines."),
40
+ });
41
+ const flushSchema = z.object({
42
+ path: z.string().min(1).describe("Path to the log file to flush."),
43
+ });
44
+ const ok = (text) => ({ content: [{ type: "text", text }] });
45
+ const err = (text) => ({
46
+ isError: true,
47
+ content: [{ type: "text", text }],
48
+ });
49
+ export function register(server) {
50
+ server.registerTool("readLogFile", {
51
+ description: "Compress a log file using semantic pattern extraction (60-90% reduction). Creates stateful drains for incremental reads. Use flushLogFile to release.",
52
+ inputSchema: schema,
53
+ annotations: { readOnlyHint: true, openWorldHint: false },
54
+ }, async (input) => {
55
+ try {
56
+ return ok(await Promise.race([processLog(server, input), timeout()]));
57
+ }
58
+ catch (e) {
59
+ return err(String(e));
60
+ }
61
+ });
62
+ }
63
+ async function processLog(server, input) {
64
+ const path = resolve(process.cwd(), input.path);
65
+ let state = drains.get(path);
66
+ if (input.head && input.tail) {
67
+ return "Cannot use both head and tail.";
68
+ }
69
+ if (state &&
70
+ (state.depth !== input.depth || state.simThreshold !== input.simThreshold)) {
71
+ return `Error: Drain exists with depth=${state.depth}, simThreshold=${state.simThreshold}. Flush first.`;
72
+ }
73
+ const content = await readFile(path, "utf-8");
74
+ let lines = content.split(/\r?\n/).filter(Boolean);
75
+ if (!state) {
76
+ const wasEmpty = drains.size === 0;
77
+ if (input.head)
78
+ lines = lines.slice(0, input.head);
79
+ else if (input.tail)
80
+ lines = lines.slice(-input.tail);
81
+ if (input.grep)
82
+ lines = lines.filter((l) => new RegExp(input.grep).test(l));
83
+ if (!lines.length)
84
+ return "No log lines to process.";
85
+ const drain = createDrain({
86
+ depth: input.depth,
87
+ simThreshold: input.simThreshold,
88
+ });
89
+ drain.addLogLines(lines);
90
+ state = {
91
+ drain,
92
+ lastLine: lines.length,
93
+ depth: input.depth,
94
+ simThreshold: input.simThreshold,
95
+ };
96
+ drains.set(path, state);
97
+ if (wasEmpty)
98
+ registerFlush(server);
99
+ return `${state.drain.getResult(input.format).formatted}\n\n[New drain. ${state.lastLine} lines. Use flushLogFile when done.]`;
100
+ }
101
+ const newLines = lines.slice(state.lastLine);
102
+ if (!newLines.length) {
103
+ return `${state.drain.getResult(input.format).formatted}\n\n[No new lines. Total: ${state.lastLine}]`;
104
+ }
105
+ const filtered = input.grep ?
106
+ newLines.filter((l) => new RegExp(input.grep).test(l))
107
+ : newLines;
108
+ if (filtered.length)
109
+ state.drain.addLogLines(filtered);
110
+ state.lastLine = lines.length;
111
+ return `${state.drain.getResult(input.format).formatted}\n\n[+${newLines.length} lines. Total: ${state.lastLine}]`;
112
+ }
113
+ function registerFlush(server) {
114
+ if (flushTool)
115
+ return;
116
+ try {
117
+ flushTool = server.registerTool("flushLogFile", {
118
+ description: "Release a log drain to free memory. Next readLogFile creates fresh drain.",
119
+ inputSchema: flushSchema,
120
+ annotations: { destructiveHint: true, idempotentHint: true },
121
+ }, async (input) => {
122
+ try {
123
+ const path = resolve(process.cwd(), input.path);
124
+ const state = drains.get(path);
125
+ if (!state) {
126
+ if (!drains.size)
127
+ return ok("No active drains.");
128
+ return ok(`No drain for "${basename(path)}". Active: ${[...drains.keys()].map((p) => basename(p)).join(", ")}`);
129
+ }
130
+ const { totalClusters, lastLine } = {
131
+ totalClusters: state.drain.totalClusters,
132
+ lastLine: state.lastLine,
133
+ };
134
+ drains.delete(path);
135
+ if (!drains.size && flushTool) {
136
+ flushTool.remove();
137
+ flushTool = null;
138
+ }
139
+ return ok(`Flushed ${basename(path)}. Released ${totalClusters} templates from ${lastLine} lines.`);
140
+ }
141
+ catch (e) {
142
+ return err(String(e));
143
+ }
144
+ });
145
+ server.sendToolListChanged();
146
+ }
147
+ catch { }
148
+ }
149
+ function timeout() {
150
+ return new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout: ${timeoutMs}ms`)), timeoutMs));
151
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { rename, stat } from "node:fs/promises";
3
+ import { dirname, basename } from "node:path";
4
+ const schema = z
5
+ .object({
6
+ oldPath: z.string().min(1).describe("Current path to the file or folder."),
7
+ newPath: z
8
+ .string()
9
+ .min(1)
10
+ .describe("New path with the renamed file or folder."),
11
+ })
12
+ .refine((d) => dirname(d.oldPath) === dirname(d.newPath), {
13
+ message: "Parent directory must match. Use moveFile to change directories.",
14
+ });
15
+ export function register(server) {
16
+ server.registerTool("renameFile", {
17
+ description: "Rename a single file or folder. Only the name can change — the parent directory must stay the same. Use moveFile to change directories.",
18
+ inputSchema: schema,
19
+ annotations: {
20
+ destructiveHint: true,
21
+ idempotentHint: false,
22
+ openWorldHint: false,
23
+ },
24
+ }, async (input) => {
25
+ try {
26
+ const oldName = basename(input.oldPath);
27
+ const newName = basename(input.newPath);
28
+ if (oldName === newName) {
29
+ return {
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: `Nothing to rename — names are identical.`,
34
+ },
35
+ ],
36
+ };
37
+ }
38
+ await stat(input.oldPath); // Verify exists
39
+ await rename(input.oldPath, input.newPath);
40
+ return {
41
+ content: [
42
+ { type: "text", text: `Renamed "${oldName}" to "${newName}".` },
43
+ ],
44
+ };
45
+ }
46
+ catch (err) {
47
+ return {
48
+ isError: true,
49
+ content: [{ type: "text", text: String(err) }],
50
+ };
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { rename, stat } from "node:fs/promises";
3
+ import { dirname, basename } from "node:path";
4
+ const schema = z.object({
5
+ oldPath: z.string().min(1).describe("Current path to the file or directory."),
6
+ newPath: z
7
+ .string()
8
+ .min(1)
9
+ .describe("New path with the renamed file or directory."),
10
+ });
11
+ export function register(server) {
12
+ server.registerTool("renameFileOrDir", {
13
+ description: "Rename a single file or directory. Only the name can change — the parent directory must stay the same. Use moveFileOrDir to change directories.",
14
+ inputSchema: schema,
15
+ annotations: {
16
+ destructiveHint: true,
17
+ idempotentHint: false,
18
+ openWorldHint: false,
19
+ },
20
+ }, async (input) => {
21
+ try {
22
+ if (dirname(input.oldPath) !== dirname(input.newPath)) {
23
+ return {
24
+ isError: true,
25
+ content: [
26
+ {
27
+ type: "text",
28
+ text: "Parent directory must match. Use moveFileOrDir to change directories.",
29
+ },
30
+ ],
31
+ };
32
+ }
33
+ const oldName = basename(input.oldPath);
34
+ const newName = basename(input.newPath);
35
+ if (oldName === newName) {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: `Nothing to rename — names are identical.`,
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ await stat(input.oldPath); // Verify exists
46
+ await rename(input.oldPath, input.newPath);
47
+ return {
48
+ content: [
49
+ { type: "text", text: `Renamed "${oldName}" to "${newName}".` },
50
+ ],
51
+ };
52
+ }
53
+ catch (err) {
54
+ return {
55
+ isError: true,
56
+ content: [{ type: "text", text: String(err) }],
57
+ };
58
+ }
59
+ });
60
+ }
@@ -29,6 +29,10 @@ export function register(server) {
29
29
  server.registerTool("wait", {
30
30
  description: "Wait for a specified duration before continuing.",
31
31
  inputSchema: schema,
32
+ annotations: {
33
+ readOnlyHint: true,
34
+ idempotentHint: true,
35
+ },
32
36
  }, async (input) => {
33
37
  try {
34
38
  const ms = Math.round(input.durationSeconds * 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-multitool",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "MCP server with file operations (delete, move) and timing utilities.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "1.29.0",
33
+ "logpare": "^0.1.0",
33
34
  "zod": "^3.24.0"
34
35
  },
35
36
  "devDependencies": {