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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # mcp-hashline-edit-server
2
2
 
3
- An MCP (Model Context Protocol) server that provides hashline-based file editing tools — line-addressed edits using content hashes for integrity verification.
3
+ MCP server providing hashline-based file editing — line-addressed edits with content hashes for integrity verification.
4
4
 
5
- Based on the [hashline edit format](https://blog.can.ac/2026/02/12/the-harness-problem/) from [oh-my-pi](https://github.com/can1357/oh-my-pi).
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
- When an LLM reads a file, every line comes back tagged with a short content hash:
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
- When editing, the model references those tags — "replace line `2:f1`", "replace range `1:a3` through `3:0e`", "insert after `3:0e`". If the file changed since the last read, the hashes won't match and the edit is rejected before anything gets corrupted.
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
- This means the model doesn't need to reproduce old content (or whitespace) to identify what it wants to change. Benchmark results show hashline matches or beats traditional `str_replace` for most models, with the weakest models gaining the most (up to 10x improvement).
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 (uses `Bun.hash.xxHash32` for line hashing)
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
- cd mcp-hashline-edit-server
29
- bun install
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
- ### With Claude Desktop / Cursor / any MCP client
36
+ ### Claude Desktop
35
37
 
36
- Add to your MCP configuration:
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": "bun",
43
- "args": ["run", "/path/to/mcp-hashline-edit-server/src/index.ts"]
44
+ "command": "bunx",
45
+ "args": ["mcp-hashline-edit-server"]
44
46
  }
45
47
  }
46
48
  }
47
49
  ```
48
50
 
49
- ### Running directly
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
- bun run src/index.ts
71
+ bunx mcp-hashline-edit-server
53
72
  ```
54
73
 
55
- The server communicates via stdio using the MCP protocol.
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 a file with hashline-prefixed output (`LINE:HASH|content`).
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? | Line number to start from (1-indexed) |
67
- | `limit` | number? | Max lines to read (default: 2000) |
92
+ | `offset` | number? | Start line (1-indexed) |
93
+ | `limit` | number? | Max lines (default: 2000) |
68
94
 
69
95
  ### `edit_file`
70
96
 
71
- Edit a file using hash-verified line references. Supports four edit variants:
97
+ Edit file using hash-verified line references. Four edit variants:
72
98
 
73
- **`set_line`** — Replace a single line:
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 a range:
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 a line:
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`** — Substring fuzzy replace (fallback, no hashes needed):
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 | Array of edit operations |
122
+ | `edits` | array | Edit operations |
97
123
 
98
- All edits are validated atomically against the file as last read. Edits are sorted and applied bottom-up automatically.
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 a file.
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 to search |
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) installed on the system.
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 from the line
130
- 3. Compute `xxHash32` on the whitespace-stripped string
131
- 4. Modulo 256, encode as 2-character lowercase hex (`00`-`ff`)
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
- The hash is whitespace-insensitive — indentation changes alone don't change the hash, making references robust against reformatting.
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**: The error shows updated `LINE:HASH` refs with `>>>` markers. Copy the new refs and retry.
163
+ **Hash mismatch**: Error shows updated `LINE:HASH` refs with `>>>` markers. Copy new refs, retry.
138
164
 
139
- **No-op error**: The replacement text matches current content. Re-read the file to see current state.
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.1.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",
@@ -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
- - If you already edited a file in this turn, re-read before the next edit
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
- - After successful edit, re-read before making another edit to same file`;
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
- return `${prefix}${padded}|${content}`;
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
- if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) return dstLines.slice(1);
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
- if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) out = out.slice(1);
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 && equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])) {
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 = formatHashLines(selectedContent, startLine);
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 };