@levnikolaevich/hex-line-mcp 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/hook.mjs +1 -1
- package/dist/server.mjs +65 -17
- package/output-style.md +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ Hash-verified file editing MCP + token efficiency hook for AI coding agents.
|
|
|
9
9
|
|
|
10
10
|
Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption. Hashing works on normalized logical text; writes preserve the file's existing line endings and trailing-newline shape.
|
|
11
11
|
|
|
12
|
+
By default, mutating tools stay inside the current project root. If you intentionally need to edit a temp or external path, pass `allow_external: true` on `edit_file`, `write_file`, or `bulk_replace`.
|
|
13
|
+
|
|
12
14
|
## Features
|
|
13
15
|
|
|
14
16
|
### 9 MCP Tools
|
|
@@ -182,6 +184,7 @@ Edit using revision-aware hash-verified anchors. Prefer one batched call per fil
|
|
|
182
184
|
| `restore_indent` | boolean | no | Auto-fix indentation to match anchor context (default: false) |
|
|
183
185
|
| `base_revision` | string | no | Prior revision from `read_file` / `edit_file` for same-file follow-up edits |
|
|
184
186
|
| `conflict_policy` | enum | no | `conservative` or `strict` (default: `conservative`) |
|
|
187
|
+
| `allow_external` | boolean | no | Allow editing a path outside the current project root |
|
|
185
188
|
|
|
186
189
|
Edit operations (JSON array):
|
|
187
190
|
|
|
@@ -227,6 +230,7 @@ Create a new file or overwrite an existing one. Creates parent directories autom
|
|
|
227
230
|
|-----------|------|----------|-------------|
|
|
228
231
|
| `path` | string | yes | File path |
|
|
229
232
|
| `content` | string | yes | File content |
|
|
233
|
+
| `allow_external` | boolean | no | Allow writing a path outside the current project root |
|
|
230
234
|
|
|
231
235
|
### grep_search
|
|
232
236
|
|
|
@@ -247,7 +251,7 @@ Search file contents using ripgrep. Three output modes: `content` (canonical `se
|
|
|
247
251
|
| `context_before` | number | no | Context lines BEFORE match (`-B`) |
|
|
248
252
|
| `context_after` | number | no | Context lines AFTER match (`-A`) |
|
|
249
253
|
| `limit` | number | no | Max matches per file (default: 100) |
|
|
250
|
-
| `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (0 = unlimited) |
|
|
254
|
+
| `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (default: 200 for `content`, 1000 for `files`/`count`, 0 = unlimited) |
|
|
251
255
|
| `plain` | boolean | no | Omit hash tags inside block entries, return `lineNum\|content` |
|
|
252
256
|
|
|
253
257
|
`content` mode returns canonical `search_hunk` blocks with per-hunk checksums enabling direct `replace_lines` from grep results without intermediate `read_file`.
|
package/dist/hook.mjs
CHANGED
|
@@ -439,7 +439,7 @@ function handleSessionStart() {
|
|
|
439
439
|
} catch {
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
|
-
const msg = styleActive ? "Hex-line MCP available. Output style active.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <note>Follow the active hex-line output style for primary tool choices.</note>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, Glob, .claude/settings.json, and .claude/settings.local.json.</exceptions>\n</hex-line_instructions>" : "Hex-line MCP available.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <exploration>\n <rule>Use outline for structure (code + markdown), not Read. ~10-20 lines vs hundreds.</rule>\n <rule>Use read_file with offset/limit or ranges for targeted reads.</rule>\n <rule>Use grep_search before editing to get hash anchors.</rule>\n </exploration>\n <editing>\n <path name='surgical'>grep_search \u2192 edit_file (fastest: hash-verified, no full read needed)</path>\n <path name='exploratory'>outline \u2192 read_file (ranges) \u2192 edit_file with base_revision</path>\n <path name='multi-file'>bulk_replace(path="<project root>") for text rename/refactor across files</path>\n </editing>\n <tips>\n <tip>Auto-fill path from the active file or project root.
|
|
442
|
+
const msg = styleActive ? "Hex-line MCP available. Output style active.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <note>Follow the active hex-line output style for primary tool choices.</note>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, Glob, .claude/settings.json, and .claude/settings.local.json.</exceptions>\n</hex-line_instructions>" : "Hex-line MCP available.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <exploration>\n <rule>Use outline for structure (code + markdown), not Read. ~10-20 lines vs hundreds.</rule>\n <rule>Use read_file with offset/limit or ranges for targeted reads.</rule>\n <rule>Use grep_search before editing to get hash anchors.</rule>\n </exploration>\n <editing>\n <path name='surgical'>grep_search \u2192 edit_file (fastest: hash-verified, no full read needed)</path>\n <path name='exploratory'>outline \u2192 read_file (ranges) \u2192 edit_file with base_revision</path>\n <path name='multi-file'>bulk_replace(path="<project root>") for text rename/refactor across files</path>\n </editing>\n <tips>\n <tip>Auto-fill path from the active file or project root. Read-only tools may inspect explicit temp-file paths outside the repo. Mutating tools stay project-scoped unless you intentionally pass allow_external=true.</tip>\n <tip>Never invent range_checksum. Copy it from fresh read_file or grep_search blocks.</tip>\n <tip>Prefer set_line or insert_after for small local changes and replace_between for larger bounded rewrites.</tip>\n <tip>Carry revision from read_file into base_revision on edit_file.</tip>\n <tip>If edit returns CONFLICT, call verify \u2014 only reread when STALE.</tip>\n <tip>Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned revision.</tip>\n <tip>Use write_file for new files (no prior Read needed).</tip>\n </tips>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, Glob, .claude/settings.json, and .claude/settings.local.json.</exceptions>\n</hex-line_instructions>";
|
|
443
443
|
safeExit(1, JSON.stringify({ systemMessage: msg }), 0);
|
|
444
444
|
}
|
|
445
445
|
var _norm = (p) => p.replace(/\\/g, "/");
|
package/dist/server.mjs
CHANGED
|
@@ -107,6 +107,7 @@ import { statSync as statSync4 } from "node:fs";
|
|
|
107
107
|
|
|
108
108
|
// lib/security.mjs
|
|
109
109
|
import { realpathSync, statSync as statSync2, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
110
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
110
111
|
import { resolve, isAbsolute, dirname } from "node:path";
|
|
111
112
|
|
|
112
113
|
// lib/format.mjs
|
|
@@ -202,11 +203,43 @@ function readText(filePath) {
|
|
|
202
203
|
// lib/security.mjs
|
|
203
204
|
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
204
205
|
function normalizePath(p) {
|
|
205
|
-
if (process.platform === "win32"
|
|
206
|
-
|
|
206
|
+
if (process.platform === "win32") {
|
|
207
|
+
if (p === "/tmp" || p.startsWith("/tmp/")) {
|
|
208
|
+
const suffix = p.slice("/tmp".length).replace(/^\/+/, "");
|
|
209
|
+
p = suffix ? resolve(tmpdir2(), suffix) : tmpdir2();
|
|
210
|
+
} else if (p === "/var/tmp" || p.startsWith("/var/tmp/")) {
|
|
211
|
+
const suffix = p.slice("/var/tmp".length).replace(/^\/+/, "");
|
|
212
|
+
p = suffix ? resolve(tmpdir2(), suffix) : tmpdir2();
|
|
213
|
+
} else if (/^\/[a-zA-Z]\//.test(p)) {
|
|
214
|
+
p = p[1] + ":" + p.slice(2);
|
|
215
|
+
}
|
|
207
216
|
}
|
|
208
217
|
return p.replace(/\\/g, "/");
|
|
209
218
|
}
|
|
219
|
+
function normalizeScopeValue(value) {
|
|
220
|
+
const normalized = value.replace(/\\/g, "/");
|
|
221
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
222
|
+
}
|
|
223
|
+
function resolveInputPath(filePath) {
|
|
224
|
+
const normalized = normalizePath(filePath);
|
|
225
|
+
const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
|
|
226
|
+
return abs.replace(/\\/g, "/");
|
|
227
|
+
}
|
|
228
|
+
function isWithinRoot(rootPath, targetPath) {
|
|
229
|
+
const root = normalizeScopeValue(rootPath).replace(/\/+$/, "");
|
|
230
|
+
const target = normalizeScopeValue(targetPath);
|
|
231
|
+
return target === root || target.startsWith(`${root}/`);
|
|
232
|
+
}
|
|
233
|
+
function assertProjectScopedPath(filePath, { allowExternal = false } = {}) {
|
|
234
|
+
if (!filePath) throw new Error("Empty file path");
|
|
235
|
+
const abs = resolveInputPath(filePath);
|
|
236
|
+
if (allowExternal) return abs;
|
|
237
|
+
const projectRoot = resolve(process.cwd()).replace(/\\/g, "/");
|
|
238
|
+
if (isWithinRoot(projectRoot, abs)) return abs;
|
|
239
|
+
throw new Error(
|
|
240
|
+
`PATH_OUTSIDE_PROJECT: ${abs}. Editing is restricted to the current project by default. If you intentionally need a temp or external path, retry with allow_external=true.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
210
243
|
function validatePath(filePath) {
|
|
211
244
|
if (!filePath) throw new Error("Empty file path");
|
|
212
245
|
const normalized = normalizePath(filePath);
|
|
@@ -2610,6 +2643,8 @@ try {
|
|
|
2610
2643
|
} catch {
|
|
2611
2644
|
}
|
|
2612
2645
|
var DEFAULT_LIMIT2 = 100;
|
|
2646
|
+
var DEFAULT_TOTAL_LIMIT_CONTENT = 200;
|
|
2647
|
+
var DEFAULT_TOTAL_LIMIT_LIST = 1e3;
|
|
2613
2648
|
var MAX_OUTPUT = 10 * 1024 * 1024;
|
|
2614
2649
|
var TIMEOUT2 = 3e4;
|
|
2615
2650
|
var MAX_SEARCH_OUTPUT_CHARS = 8e4;
|
|
@@ -2651,12 +2686,19 @@ function grepSearch(pattern, opts = {}) {
|
|
|
2651
2686
|
const target = normPath ? resolve2(normPath) : process.cwd();
|
|
2652
2687
|
const output = opts.output || "content";
|
|
2653
2688
|
const plain = !!opts.plain;
|
|
2654
|
-
const
|
|
2655
|
-
|
|
2656
|
-
if (output === "
|
|
2689
|
+
const defaultTotalLimit = output === "content" ? DEFAULT_TOTAL_LIMIT_CONTENT : DEFAULT_TOTAL_LIMIT_LIST;
|
|
2690
|
+
const totalLimit = opts.totalLimit === 0 ? 0 : opts.totalLimit && opts.totalLimit > 0 ? opts.totalLimit : defaultTotalLimit;
|
|
2691
|
+
if (output === "files") return filesMode(pattern, target, opts, totalLimit);
|
|
2692
|
+
if (output === "count") return countMode(pattern, target, opts, totalLimit);
|
|
2657
2693
|
return contentMode(pattern, target, opts, plain, totalLimit);
|
|
2658
2694
|
}
|
|
2659
|
-
|
|
2695
|
+
function applyListModeTotalLimit(lines, totalLimit) {
|
|
2696
|
+
if (!totalLimit || totalLimit <= 0 || lines.length <= totalLimit) return lines.join("\n");
|
|
2697
|
+
const visible = lines.slice(0, totalLimit);
|
|
2698
|
+
visible.push(`OUTPUT_CAPPED: ${lines.length - totalLimit} more result line(s) omitted. Narrow with path= or glob=, or raise total_limit.`);
|
|
2699
|
+
return visible.join("\n");
|
|
2700
|
+
}
|
|
2701
|
+
async function filesMode(pattern, target, opts, totalLimit) {
|
|
2660
2702
|
const realArgs = ["-l"];
|
|
2661
2703
|
if (opts.caseInsensitive) realArgs.push("-i");
|
|
2662
2704
|
else if (opts.smartCase) realArgs.push("-S");
|
|
@@ -2671,9 +2713,9 @@ async function filesMode(pattern, target, opts) {
|
|
|
2671
2713
|
if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
|
|
2672
2714
|
const lines = stdout.trimEnd().split("\n").filter(Boolean);
|
|
2673
2715
|
const normalized = lines.map((l) => l.replace(/\\/g, "/"));
|
|
2674
|
-
return normalized
|
|
2716
|
+
return applyListModeTotalLimit(normalized, totalLimit);
|
|
2675
2717
|
}
|
|
2676
|
-
async function countMode(pattern, target, opts) {
|
|
2718
|
+
async function countMode(pattern, target, opts, totalLimit) {
|
|
2677
2719
|
const realArgs = ["-c"];
|
|
2678
2720
|
if (opts.caseInsensitive) realArgs.push("-i");
|
|
2679
2721
|
else if (opts.smartCase) realArgs.push("-S");
|
|
@@ -2688,7 +2730,7 @@ async function countMode(pattern, target, opts) {
|
|
|
2688
2730
|
if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
|
|
2689
2731
|
const lines = stdout.trimEnd().split("\n").filter(Boolean);
|
|
2690
2732
|
const normalized = lines.map((l) => l.replace(/\\/g, "/"));
|
|
2691
|
-
return normalized
|
|
2733
|
+
return applyListModeTotalLimit(normalized, totalLimit);
|
|
2692
2734
|
}
|
|
2693
2735
|
async function contentMode(pattern, target, opts, plain, totalLimit) {
|
|
2694
2736
|
const realArgs = ["--json"];
|
|
@@ -2800,7 +2842,7 @@ async function contentMode(pattern, target, opts, plain, totalLimit) {
|
|
|
2800
2842
|
flushGroup();
|
|
2801
2843
|
blocks.push(buildDiagnosticBlock({
|
|
2802
2844
|
kind: "total_limit",
|
|
2803
|
-
message: `Search stopped after ${totalLimit} match event(s). Narrow the query to
|
|
2845
|
+
message: `Search stopped after ${totalLimit} match event(s). Narrow the query, raise total_limit, or pass total_limit=0 to disable the cap.`,
|
|
2804
2846
|
path: String(target).replace(/\\/g, "/")
|
|
2805
2847
|
}));
|
|
2806
2848
|
return blocks.map((block) => block.type === "edit_ready_block" ? serializeSearchBlock(block, { plain }) : serializeDiagnosticBlock(block)).join("\n\n");
|
|
@@ -4192,7 +4234,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
|
|
|
4192
4234
|
}
|
|
4193
4235
|
|
|
4194
4236
|
// server.mjs
|
|
4195
|
-
var version = true ? "1.
|
|
4237
|
+
var version = true ? "1.14.0" : (await null).createRequire(import.meta.url)("./package.json").version;
|
|
4196
4238
|
var { server, StdioServerTransport } = await createServerRuntime({
|
|
4197
4239
|
name: "hex-line-mcp",
|
|
4198
4240
|
version
|
|
@@ -4261,12 +4303,14 @@ server.registerTool("edit_file", {
|
|
|
4261
4303
|
dry_run: flexBool().describe("Preview changes without writing"),
|
|
4262
4304
|
restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
|
|
4263
4305
|
base_revision: z2.string().optional().describe("Prior revision from read_file/edit_file. Enables conservative auto-rebase for same-file follow-up edits."),
|
|
4264
|
-
conflict_policy: z2.enum(["strict", "conservative"]).optional().describe('Conflict handling (default: "conservative"). "conservative" returns structured CONFLICT output with recovery_ranges, retry_edit/retry_edits, suggested_read_call, and retry_plan when available.')
|
|
4306
|
+
conflict_policy: z2.enum(["strict", "conservative"]).optional().describe('Conflict handling (default: "conservative"). "conservative" returns structured CONFLICT output with recovery_ranges, retry_edit/retry_edits, suggested_read_call, and retry_plan when available.'),
|
|
4307
|
+
allow_external: flexBool().describe("Allow editing a path outside the current project root. Use only when you intentionally target a temp or external file.")
|
|
4265
4308
|
}),
|
|
4266
4309
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
|
|
4267
4310
|
}, async (rawParams) => {
|
|
4268
|
-
const { path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy } = rawParams ?? {};
|
|
4311
|
+
const { path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy, allow_external } = rawParams ?? {};
|
|
4269
4312
|
try {
|
|
4313
|
+
assertProjectScopedPath(p, { allowExternal: !!allow_external });
|
|
4270
4314
|
let parsed;
|
|
4271
4315
|
try {
|
|
4272
4316
|
parsed = typeof json === "string" ? JSON.parse(json) : json;
|
|
@@ -4294,12 +4338,14 @@ server.registerTool("write_file", {
|
|
|
4294
4338
|
description: "Create a new file or overwrite existing. Creates parent dirs. For existing files prefer edit_file (shows diff, verifies hashes).",
|
|
4295
4339
|
inputSchema: z2.object({
|
|
4296
4340
|
path: z2.string().describe("File path"),
|
|
4297
|
-
content: z2.string().describe("File content")
|
|
4341
|
+
content: z2.string().describe("File content"),
|
|
4342
|
+
allow_external: flexBool().describe("Allow writing a path outside the current project root. Use only when you intentionally target a temp or external file.")
|
|
4298
4343
|
}),
|
|
4299
4344
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
4300
4345
|
}, async (rawParams) => {
|
|
4301
|
-
const { path: p, content } = rawParams ?? {};
|
|
4346
|
+
const { path: p, content, allow_external } = rawParams ?? {};
|
|
4302
4347
|
try {
|
|
4348
|
+
assertProjectScopedPath(p, { allowExternal: !!allow_external });
|
|
4303
4349
|
const abs = validateWritePath(p);
|
|
4304
4350
|
mkdirSync2(dirname6(abs), { recursive: true });
|
|
4305
4351
|
writeFileSync4(abs, content, "utf-8");
|
|
@@ -4325,7 +4371,7 @@ server.registerTool("grep_search", {
|
|
|
4325
4371
|
context_before: flexNum().describe("Context lines BEFORE match (-B)"),
|
|
4326
4372
|
context_after: flexNum().describe("Context lines AFTER match (-A)"),
|
|
4327
4373
|
limit: flexNum().describe("Max matches per file (default: 100)"),
|
|
4328
|
-
total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (0 = unlimited)"),
|
|
4374
|
+
total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (default: 200 for content, 1000 for files/count, 0 = unlimited)"),
|
|
4329
4375
|
plain: flexBool().describe("Omit hash tags, return file:line:content")
|
|
4330
4376
|
}),
|
|
4331
4377
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
@@ -4450,12 +4496,14 @@ server.registerTool("bulk_replace", {
|
|
|
4450
4496
|
path: z2.string().describe("Root directory for the replacement scope"),
|
|
4451
4497
|
dry_run: flexBool().describe("Preview without writing (default: false)"),
|
|
4452
4498
|
max_files: flexNum().describe("Max files to process (default: 100)"),
|
|
4453
|
-
format: z2.enum(["compact", "full"]).optional().describe('"compact" (default) = summary only, "full" = include capped diffs')
|
|
4499
|
+
format: z2.enum(["compact", "full"]).optional().describe('"compact" (default) = summary only, "full" = include capped diffs'),
|
|
4500
|
+
allow_external: flexBool().describe("Allow a replacement root outside the current project root. Use only when you intentionally target a temp or external directory.")
|
|
4454
4501
|
}),
|
|
4455
4502
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
4456
4503
|
}, async (rawParams) => {
|
|
4457
4504
|
try {
|
|
4458
4505
|
const params = rawParams ?? {};
|
|
4506
|
+
assertProjectScopedPath(params.path, { allowExternal: !!params.allow_external });
|
|
4459
4507
|
const raw = params.replacements;
|
|
4460
4508
|
let replacementsInput;
|
|
4461
4509
|
try {
|
package/output-style.md
CHANGED
|
@@ -35,7 +35,9 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
|
|
|
35
35
|
|
|
36
36
|
- Auto-fill `path` instead of leaving scope implicit.
|
|
37
37
|
- For file tools (`read_file`, `edit_file`, `outline`, `changes` on one file), use the target file path.
|
|
38
|
+
- Read-only file tools may target explicit temp-file paths outside the repo when you intentionally inspect a scratch file.
|
|
38
39
|
- For repo-wide tools (`bulk_replace`, directory `inspect_path`, broad `grep_search`), use the resolved project root or intended directory scope.
|
|
40
|
+
- Mutating tools stay inside the current project root by default. Add `allow_external=true` only when you intentionally edit a temp or external path.
|
|
39
41
|
- Treat missing or ambiguous scope as an error to fix, not as a reason to guess across repositories.
|
|
40
42
|
|
|
41
43
|
## Edit Discipline
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levnikolaevich/hex-line-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"mcpName": "io.github.levnikolaevich/hex-line-mcp",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 9 tools: inspect_path, read, edit, write, grep, outline, verify, changes, bulk_replace.",
|