mcp-hashline-edit-server 0.1.0 → 0.2.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 +61 -35
- package/package.json +1 -1
- package/src/descriptions.ts +7 -2
- package/src/diff.ts +3 -1
- package/src/hashline.ts +10 -3
- package/src/server.ts +10 -7
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# mcp-hashline-edit-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server providing hashline-based file editing — line-addressed edits with content hashes for integrity verification.
|
|
4
4
|
|
|
5
|
-
Based on
|
|
5
|
+
Based on [hashline edit format](https://blog.can.ac/2026/02/12/the-harness-problem/) from [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
6
6
|
|
|
7
7
|
## What is Hashline?
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
LLM reads file, every line tagged with short content hash:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
1:a3|function hello() {
|
|
@@ -14,78 +14,104 @@ When an LLM reads a file, every line comes back tagged with a short content hash
|
|
|
14
14
|
3:0e|}
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Model references tags when editing — "replace line `2:f1`", "replace range `1:a3` through `3:0e`", "insert after `3:0e`". If file changed since last read, hashes won't match, edit rejected before corruption.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Model doesn't need to reproduce old content/whitespace to identify changes. Benchmarks show hashline matches or beats `str_replace` for most models, weakest models gain most (up to 10x improvement).
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
|
-
- [Bun](https://bun.sh/) runtime (
|
|
23
|
+
- [Bun](https://bun.sh/) runtime (`Bun.hash.xxHash32` for line hashing)
|
|
24
|
+
- [ripgrep](https://github.com/BurnSushi/ripgrep) (`rg`) on PATH for `grep` tool
|
|
24
25
|
|
|
25
26
|
## Install
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
npm install mcp-hashline-edit-server
|
|
30
|
+
# or
|
|
31
|
+
bun add mcp-hashline-edit-server
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
## Usage
|
|
33
35
|
|
|
34
|
-
###
|
|
36
|
+
### Claude Desktop
|
|
35
37
|
|
|
36
|
-
Add to
|
|
38
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
37
39
|
|
|
38
40
|
```json
|
|
39
41
|
{
|
|
40
42
|
"mcpServers": {
|
|
41
43
|
"hashline-edit": {
|
|
42
|
-
"command": "
|
|
43
|
-
"args": ["
|
|
44
|
+
"command": "bunx",
|
|
45
|
+
"args": ["mcp-hashline-edit-server"]
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
###
|
|
51
|
+
### Cursor
|
|
52
|
+
|
|
53
|
+
Add to `.cursor/mcp.json` (project root or global):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"hashline-edit": {
|
|
59
|
+
"command": "bunx",
|
|
60
|
+
"args": ["mcp-hashline-edit-server"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Any MCP client
|
|
67
|
+
|
|
68
|
+
Server runs over stdio:
|
|
50
69
|
|
|
51
70
|
```bash
|
|
52
|
-
|
|
71
|
+
bunx mcp-hashline-edit-server
|
|
53
72
|
```
|
|
54
73
|
|
|
55
|
-
|
|
74
|
+
### From source
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git clone https://github.com/Submerisble/mcp-hashline-edit-server.git
|
|
78
|
+
cd mcp-hashline-edit-server
|
|
79
|
+
bun install
|
|
80
|
+
bun run src/index.ts
|
|
81
|
+
```
|
|
56
82
|
|
|
57
83
|
## Tools
|
|
58
84
|
|
|
59
85
|
### `read_file`
|
|
60
86
|
|
|
61
|
-
Read
|
|
87
|
+
Read file with hashline-prefixed output (`LINE:HASH|content`).
|
|
62
88
|
|
|
63
89
|
| Parameter | Type | Description |
|
|
64
90
|
|-----------|------|-------------|
|
|
65
91
|
| `path` | string | File path (relative or absolute) |
|
|
66
|
-
| `offset` | number? |
|
|
67
|
-
| `limit` | number? | Max lines
|
|
92
|
+
| `offset` | number? | Start line (1-indexed) |
|
|
93
|
+
| `limit` | number? | Max lines (default: 2000) |
|
|
68
94
|
|
|
69
95
|
### `edit_file`
|
|
70
96
|
|
|
71
|
-
Edit
|
|
97
|
+
Edit file using hash-verified line references. Four edit variants:
|
|
72
98
|
|
|
73
|
-
**`set_line`** — Replace
|
|
99
|
+
**`set_line`** — Replace single line:
|
|
74
100
|
```json
|
|
75
101
|
{"set_line": {"anchor": "2:f1", "new_text": " return \"universe\";"}}
|
|
76
102
|
```
|
|
77
103
|
|
|
78
|
-
**`replace_lines`** — Replace
|
|
104
|
+
**`replace_lines`** — Replace range:
|
|
79
105
|
```json
|
|
80
106
|
{"replace_lines": {"start_anchor": "1:a3", "end_anchor": "3:0e", "new_text": "function greet() {\n return \"hi\";\n}"}}
|
|
81
107
|
```
|
|
82
108
|
|
|
83
|
-
**`insert_after`** — Insert after
|
|
109
|
+
**`insert_after`** — Insert after line:
|
|
84
110
|
```json
|
|
85
111
|
{"insert_after": {"anchor": "1:a3", "text": " // new comment"}}
|
|
86
112
|
```
|
|
87
113
|
|
|
88
|
-
**`replace`** —
|
|
114
|
+
**`replace`** — Fuzzy substring replace (fallback, no hashes needed):
|
|
89
115
|
```json
|
|
90
116
|
{"replace": {"old_text": "return \"world\"", "new_text": "return \"universe\""}}
|
|
91
117
|
```
|
|
@@ -93,13 +119,13 @@ Edit a file using hash-verified line references. Supports four edit variants:
|
|
|
93
119
|
| Parameter | Type | Description |
|
|
94
120
|
|-----------|------|-------------|
|
|
95
121
|
| `path` | string | File path |
|
|
96
|
-
| `edits` | array |
|
|
122
|
+
| `edits` | array | Edit operations |
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
Edits validated atomically against file as last read. Sorted, applied bottom-up automatically.
|
|
99
125
|
|
|
100
126
|
### `write_file`
|
|
101
127
|
|
|
102
|
-
Create or overwrite
|
|
128
|
+
Create or overwrite file.
|
|
103
129
|
|
|
104
130
|
| Parameter | Type | Description |
|
|
105
131
|
|-----------|------|-------------|
|
|
@@ -113,7 +139,7 @@ Search files with hashline-prefixed results.
|
|
|
113
139
|
| Parameter | Type | Description |
|
|
114
140
|
|-----------|------|-------------|
|
|
115
141
|
| `pattern` | string | Regex pattern |
|
|
116
|
-
| `path` | string? | File or directory
|
|
142
|
+
| `path` | string? | File or directory |
|
|
117
143
|
| `glob` | string? | Filter by glob (e.g., `*.js`) |
|
|
118
144
|
| `type` | string? | Filter by file type |
|
|
119
145
|
| `i` | boolean? | Case-insensitive |
|
|
@@ -121,23 +147,23 @@ Search files with hashline-prefixed results.
|
|
|
121
147
|
| `post` | number? | Context lines after |
|
|
122
148
|
| `limit` | number? | Max matches (default: 100) |
|
|
123
149
|
|
|
124
|
-
Requires `rg` (ripgrep)
|
|
150
|
+
Requires `rg` (ripgrep) on system.
|
|
125
151
|
|
|
126
152
|
## How the Hash Works
|
|
127
153
|
|
|
128
154
|
1. Strip trailing `\r`
|
|
129
|
-
2. Remove all whitespace
|
|
130
|
-
3.
|
|
131
|
-
4. Modulo 256, encode as 2-
|
|
155
|
+
2. Remove all whitespace
|
|
156
|
+
3. `xxHash32` on whitespace-stripped string
|
|
157
|
+
4. Modulo 256, encode as 2-char lowercase hex (`00`-`ff`)
|
|
132
158
|
|
|
133
|
-
|
|
159
|
+
Hash whitespace-insensitive — indentation changes don't affect hash, references robust against reformatting.
|
|
134
160
|
|
|
135
161
|
## Error Recovery
|
|
136
162
|
|
|
137
|
-
**Hash mismatch**:
|
|
163
|
+
**Hash mismatch**: Error shows updated `LINE:HASH` refs with `>>>` markers. Copy new refs, retry.
|
|
138
164
|
|
|
139
|
-
**No-op error**:
|
|
165
|
+
**No-op error**: Replacement matches current content. Re-read file for current state.
|
|
140
166
|
|
|
141
167
|
## License
|
|
142
168
|
|
|
143
|
-
MIT
|
|
169
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-hashline-edit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server providing hashline-based file editing tools — line-addressed edits using content hashes for integrity verification",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/descriptions.ts
CHANGED
|
@@ -10,6 +10,8 @@ Each line is tagged with a content hash: \`LINE:HASH|content\`.
|
|
|
10
10
|
The LINE:HASH pairs serve as stable, verifiable anchors for the edit_file tool.
|
|
11
11
|
|
|
12
12
|
Use \`offset\` and \`limit\` for large files (reads up to 2000 lines by default).
|
|
13
|
+
Set \`plain: true\` when reading to understand code structure — returns \`LINE|content\` without hashes.
|
|
14
|
+
Omit or set false when you plan to edit — you'll need the LINE:HASH refs.
|
|
13
15
|
Supports text files only.`;
|
|
14
16
|
|
|
15
17
|
export const EDIT_FILE_DESCRIPTION = `Edit a file using hash-verified line references.
|
|
@@ -24,7 +26,7 @@ and hashes refer to the original state, not after earlier edits in the same arra
|
|
|
24
26
|
- Copy LINE:HASH refs verbatim from read output — never fabricate or guess hashes
|
|
25
27
|
- new_text/text contains plain replacement lines only — no LINE:HASH prefix, no diff + markers
|
|
26
28
|
- On hash mismatch: use the updated LINE:HASH refs shown by >>> directly
|
|
27
|
-
-
|
|
29
|
+
- After a successful edit, the diff output includes LINE:HASH refs for changed/surrounding lines — you can use these directly for follow-up edits without re-reading
|
|
28
30
|
- new_text must differ from the current line content — identical content is rejected
|
|
29
31
|
|
|
30
32
|
**Edit variants:**
|
|
@@ -39,7 +41,10 @@ new_text: "" means delete (for set_line/replace_lines).
|
|
|
39
41
|
**Recovery:**
|
|
40
42
|
- Hash mismatch (>>> error): copy updated LINE:HASH refs from error and retry
|
|
41
43
|
- No-op error: your replacement matches current content — re-read the file
|
|
42
|
-
|
|
44
|
+
**After a successful edit:**
|
|
45
|
+
The diff output includes LINE:HASH refs for all changed and surrounding lines. You can chain edits
|
|
46
|
+
using these refs without re-reading. Unchanged lines keep their original hashes (hash is content-based,
|
|
47
|
+
not position-based). The edit tool also handles line relocation if numbers shifted.`;
|
|
43
48
|
|
|
44
49
|
export const WRITE_FILE_DESCRIPTION = `Create or overwrite a file at the specified path.
|
|
45
50
|
|
package/src/diff.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Diff generation and replace-mode utilities.
|
|
3
3
|
*/
|
|
4
4
|
import * as Diff from "diff";
|
|
5
|
+
import { computeLineHash } from "./hashline";
|
|
5
6
|
import { DEFAULT_FUZZY_THRESHOLD, findMatch, type FuzzyMatch } from "./fuzzy";
|
|
6
7
|
import { adjustIndentation, normalizeToLF } from "./normalize";
|
|
7
8
|
|
|
@@ -29,7 +30,8 @@ function countContentLines(content: string): number {
|
|
|
29
30
|
|
|
30
31
|
function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string {
|
|
31
32
|
const padded = String(lineNum).padStart(width, " ");
|
|
32
|
-
|
|
33
|
+
const hash = computeLineHash(lineNum, content);
|
|
34
|
+
return `${prefix}${padded}:${hash}|${content}`;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
|
package/src/hashline.ts
CHANGED
|
@@ -134,9 +134,15 @@ function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[
|
|
|
134
134
|
return out;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
function isSubstantive(line: string): boolean {
|
|
138
|
+
return line.trim().length > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
137
141
|
function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] {
|
|
138
142
|
if (dstLines.length <= 1) return dstLines;
|
|
139
|
-
|
|
143
|
+
// Don't strip blank lines — two blank lines matching is coincidence, not echo
|
|
144
|
+
if (!isSubstantive(anchorLine) || !isSubstantive(dstLines[0])) return dstLines;
|
|
145
|
+
if (vibeCheck(dstLines[0], anchorLine)) return dstLines.slice(1);
|
|
140
146
|
return dstLines;
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -145,9 +151,10 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
|
|
|
145
151
|
if (dstLines.length <= 1 || dstLines.length <= count) return dstLines;
|
|
146
152
|
let out = dstLines;
|
|
147
153
|
const beforeIdx = startLine - 2;
|
|
148
|
-
|
|
154
|
+
// Don't strip blank lines — two blank lines matching is coincidence, not echo
|
|
155
|
+
if (beforeIdx >= 0 && isSubstantive(out[0]) && isSubstantive(fileLines[beforeIdx]) && vibeCheck(out[0], fileLines[beforeIdx])) out = out.slice(1);
|
|
149
156
|
const afterIdx = endLine;
|
|
150
|
-
if (afterIdx < fileLines.length && out.length > 0 &&
|
|
157
|
+
if (afterIdx < fileLines.length && out.length > 0 && isSubstantive(out[out.length - 1]) && isSubstantive(fileLines[afterIdx]) && vibeCheck(out[out.length - 1], fileLines[afterIdx])) {
|
|
151
158
|
out = out.slice(0, -1);
|
|
152
159
|
}
|
|
153
160
|
return out;
|
package/src/server.ts
CHANGED
|
@@ -40,8 +40,9 @@ export function createServer(): McpServer {
|
|
|
40
40
|
path: z.string().describe("Path to the file to read (relative or absolute)"),
|
|
41
41
|
offset: z.number().optional().describe("Line number to start reading from (1-indexed)"),
|
|
42
42
|
limit: z.number().optional().describe("Maximum number of lines to read"),
|
|
43
|
+
plain: z.boolean().optional().describe("If true, return plain numbered lines without hashes (for reading, not editing)"),
|
|
43
44
|
},
|
|
44
|
-
async ({ path: filePath, offset, limit }) => {
|
|
45
|
+
async ({ path: filePath, offset, limit, plain }) => {
|
|
45
46
|
const absolutePath = resolvePath(filePath);
|
|
46
47
|
|
|
47
48
|
try {
|
|
@@ -52,7 +53,9 @@ export function createServer(): McpServer {
|
|
|
52
53
|
const endLine = Math.min(lines.length, startLine - 1 + maxLines);
|
|
53
54
|
const selectedLines = lines.slice(startLine - 1, endLine);
|
|
54
55
|
const selectedContent = selectedLines.join("\n");
|
|
55
|
-
const formatted =
|
|
56
|
+
const formatted = plain
|
|
57
|
+
? selectedLines.map((line, i) => `${startLine + i}|${line}`).join("\n")
|
|
58
|
+
: formatHashLines(selectedContent, startLine);
|
|
56
59
|
|
|
57
60
|
const totalLines = lines.length;
|
|
58
61
|
let header = `File: ${filePath} (${totalLines} lines)`;
|
|
@@ -63,7 +66,7 @@ export function createServer(): McpServer {
|
|
|
63
66
|
header += ` (${totalLines - endLine} more lines below)`;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
return { content: [{ type: "text", text: `${header}\n\n${formatted}
|
|
69
|
+
return { content: [{ type: "text", text: `${header}\n\n\`\`\`\n${formatted}\n\`\`\`` }] };
|
|
67
70
|
} catch (err) {
|
|
68
71
|
try {
|
|
69
72
|
const stat = await fs.stat(absolutePath);
|
|
@@ -72,7 +75,7 @@ export function createServer(): McpServer {
|
|
|
72
75
|
const listing = entries
|
|
73
76
|
.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
|
|
74
77
|
.join("\n");
|
|
75
|
-
return { content: [{ type: "text", text: `Directory: ${filePath}\n\n${listing}
|
|
78
|
+
return { content: [{ type: "text", text: `Directory: ${filePath}\n\n\`\`\`\n${listing}\n\`\`\`` }] };
|
|
76
79
|
}
|
|
77
80
|
} catch {
|
|
78
81
|
// Not a directory either
|
|
@@ -186,7 +189,7 @@ export function createServer(): McpServer {
|
|
|
186
189
|
resultText += `\n\nWarnings:\n${anchorResult.warnings.join("\n")}`;
|
|
187
190
|
}
|
|
188
191
|
if (diffResult.diff) {
|
|
189
|
-
resultText += `\n\nDiff:\n${diffResult.diff}
|
|
192
|
+
resultText += `\n\nDiff:\n\`\`\`diff\n${diffResult.diff}\n\`\`\``;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
return { content: [{ type: "text", text: resultText }] };
|
|
@@ -238,7 +241,7 @@ export function createServer(): McpServer {
|
|
|
238
241
|
limit: z.number().optional().describe("Limit output to first N matches (default: 100)"),
|
|
239
242
|
},
|
|
240
243
|
async ({ pattern, path: searchPath, glob: globPattern, type: fileType, i: caseInsensitive, pre, post, limit }) => {
|
|
241
|
-
const args = ["rg", "--line-number", "--no-heading"];
|
|
244
|
+
const args = ["rg", "--line-number", "--no-heading", "--with-filename"];
|
|
242
245
|
|
|
243
246
|
if (caseInsensitive) args.push("-i");
|
|
244
247
|
if (pre) args.push("-B", String(pre));
|
|
@@ -300,7 +303,7 @@ export function createServer(): McpServer {
|
|
|
300
303
|
formatted.push(line);
|
|
301
304
|
}
|
|
302
305
|
|
|
303
|
-
return { content: [{ type: "text", text: formatted.join("\n") }] };
|
|
306
|
+
return { content: [{ type: "text", text: `\`\`\`\n${formatted.join("\n")}\n\`\`\`` }] };
|
|
304
307
|
} catch (err) {
|
|
305
308
|
const message = err instanceof Error ? err.message : String(err);
|
|
306
309
|
return { content: [{ type: "text", text: `grep error: ${message}` }], isError: true };
|