trueline-mcp 2.2.0 → 2.4.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 +36 -133
- package/dist/server.js +195 -15
- package/hooks/core/instructions.js +24 -4
- package/hooks/core/routing.js +28 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ VS Code Copilot, OpenCode, and Codex CLI.
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
-
**Claude Code** (recommended
|
|
11
|
+
**Claude Code** (recommended; hooks are automatic):
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
/plugin marketplace add rjkaes/trueline-mcp
|
|
@@ -23,7 +23,7 @@ See [INSTALL.md](INSTALL.md) for platform-specific setup instructions.
|
|
|
23
23
|
AI agents waste tokens in two ways:
|
|
24
24
|
|
|
25
25
|
1. **Reading too much.** To find a function in a 500-line file, the agent
|
|
26
|
-
reads all 500 lines
|
|
26
|
+
reads all 500 lines, most of which it doesn't need.
|
|
27
27
|
|
|
28
28
|
2. **Echoing on edit.** The built-in `Edit` tool requires the agent to
|
|
29
29
|
output the old text being replaced (`old_string`) plus the new text.
|
|
@@ -32,19 +32,19 @@ AI agents waste tokens in two ways:
|
|
|
32
32
|
Both problems compound. A typical editing session reads dozens of files
|
|
33
33
|
and makes multiple edits, burning through context on redundant content.
|
|
34
34
|
|
|
35
|
-
And when things go wrong
|
|
36
|
-
matches
|
|
35
|
+
And when things go wrong (stale reads, hallucinated anchors, ambiguous
|
|
36
|
+
matches) the agent silently corrupts your code.
|
|
37
37
|
|
|
38
38
|
## How trueline fixes this
|
|
39
39
|
|
|
40
|
-
trueline replaces
|
|
41
|
-
|
|
40
|
+
trueline replaces the built-in `Read` and `Edit` with five tools that
|
|
41
|
+
are smaller, faster, and verified.
|
|
42
42
|
|
|
43
|
-
###
|
|
43
|
+
### Read less: `trueline_outline` + `trueline_read`
|
|
44
44
|
|
|
45
|
-
Instead of reading an entire file
|
|
46
|
-
`trueline_outline
|
|
47
|
-
classes,
|
|
45
|
+
Instead of reading an entire file, the agent starts with
|
|
46
|
+
`trueline_outline`, a compact AST outline showing just the functions,
|
|
47
|
+
classes, and declarations with their line ranges:
|
|
48
48
|
|
|
49
49
|
```
|
|
50
50
|
1-10: (10 imports)
|
|
@@ -57,150 +57,53 @@ classes, types, and declarations with their line ranges:
|
|
|
57
57
|
(12 symbols, 139 source lines)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
12 lines instead of 139. The agent sees the full structure,
|
|
61
|
-
ranges it needs,
|
|
62
|
-
lines.
|
|
60
|
+
12 lines instead of 139. The agent sees the full structure, then reads
|
|
61
|
+
only the ranges it needs, skipping hundreds of irrelevant lines.
|
|
63
62
|
|
|
64
63
|
| File size | Full read | Outline | Savings |
|
|
65
|
-
|
|
64
|
+
|-------------|-----------|---------|--------|
|
|
66
65
|
| 140 lines | 140 tokens | 12 tokens | 91% |
|
|
67
66
|
| 245 lines | 245 tokens | 14 tokens | 94% |
|
|
68
67
|
| 504 lines | 504 tokens | 4 tokens | 99% |
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
`trueline_read` supports multiple disjoint ranges in a single call and
|
|
70
|
+
an optional `hashes: false` mode for exploratory reads that saves ~3
|
|
71
|
+
tokens per line.
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
C++, C#, Ruby, PHP, Kotlin, Swift, Scala, Elixir, Lua, Dart, Zig, Bash.
|
|
73
|
+
### Find and fix: `trueline_search`
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
When the agent knows what it's looking for, `trueline_search` finds
|
|
76
|
+
lines by regex and returns them with enough context to edit immediately,
|
|
77
|
+
no outline or read step needed.
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
A search-based workflow uses **~127 tokens** vs **~2000** for
|
|
80
|
+
outline+read, a 93% reduction for targeted lookups.
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
// Built-in Edit — model must output the old text
|
|
81
|
-
{
|
|
82
|
-
"old_string": "export function handleRequest(req: Request) {\n ...\n}",
|
|
83
|
-
"new_string": "export function handleRequest(req: Request) {\n ...(new)...\n}"
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
`trueline_edit` replaces old text with a compact line-range reference:
|
|
88
|
-
|
|
89
|
-
```json
|
|
90
|
-
// trueline_edit — just the range and the new content
|
|
91
|
-
{
|
|
92
|
-
"edits": [{
|
|
93
|
-
"checksum": "1-50:a3b1c2d4",
|
|
94
|
-
"range": "12:kf..16:qz",
|
|
95
|
-
"content": "export function handleRequest(req: Request) {\n ...(new)...\n}"
|
|
96
|
-
}]
|
|
97
|
-
}
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
The model never echoes old text. For a typical 15-line edit:
|
|
101
|
-
|
|
102
|
-
| | Built-in Edit | trueline_edit |
|
|
103
|
-
|---|---|---|
|
|
104
|
-
| Old text echoed (output tokens) | ~225 | 0 |
|
|
105
|
-
| New text (output tokens) | ~225 | ~225 |
|
|
106
|
-
| Range/checksum overhead | 0 | ~13 |
|
|
107
|
-
| **Total output tokens** | **~470** | **~263** |
|
|
108
|
-
| **Savings** | | **44%** |
|
|
109
|
-
|
|
110
|
-
Output tokens are the most expensive token class. Cutting them by 44%
|
|
111
|
-
on every edit adds up fast.
|
|
112
|
-
|
|
113
|
-
### Targeted reads save more input tokens
|
|
82
|
+
### Write less: `trueline_edit`
|
|
114
83
|
|
|
115
|
-
`
|
|
116
|
-
|
|
117
|
-
the
|
|
84
|
+
The built-in `Edit` makes the model echo back the old text being
|
|
85
|
+
replaced. `trueline_edit` replaces that with a compact line-range
|
|
86
|
+
reference: the model only outputs the new content.
|
|
118
87
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
```
|
|
88
|
+
For a typical 15-line edit, that's **44% fewer output tokens**. Output
|
|
89
|
+
tokens are the most expensive token class, so this adds up fast.
|
|
122
90
|
|
|
123
|
-
|
|
91
|
+
Multiple edits can be batched in a single call and applied atomically.
|
|
124
92
|
|
|
125
|
-
###
|
|
93
|
+
### Never corrupt: hash verification
|
|
126
94
|
|
|
127
95
|
Every line from `trueline_read` carries a content hash. Every edit must
|
|
128
96
|
present those hashes back, proving the agent is working against the
|
|
129
|
-
file's actual content
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
1:bx|import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
133
|
-
2:dd|
|
|
134
|
-
3:ew|const server = new Server({ name: "trueline-mcp", version: "0.1.0" });
|
|
135
|
-
|
|
136
|
-
checksum: 1-3:8a64a3f7
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
If anything changed since the read — concurrent edits, model
|
|
140
|
-
hallucination, stale context — the edit is rejected before any bytes
|
|
141
|
-
hit disk. Three layers of protection:
|
|
142
|
-
|
|
143
|
-
| Layer | What it catches |
|
|
144
|
-
|-------|----------------|
|
|
145
|
-
| Per-line hash | Changed content at edit boundaries |
|
|
146
|
-
| Range checksum | Any change within the read window |
|
|
147
|
-
| mtime guard | Concurrent modification by another process |
|
|
97
|
+
file's actual content. If anything changed (concurrent edits, model
|
|
98
|
+
hallucination, stale context) the edit is rejected before any bytes hit
|
|
99
|
+
disk.
|
|
148
100
|
|
|
149
101
|
No more silent corruption. No more ambiguous string matches.
|
|
150
102
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
The built-in `Edit` handles one replacement per call. `trueline_edit`
|
|
154
|
-
accepts an array of edits applied atomically, cutting tool-call
|
|
155
|
-
overhead for multi-site changes.
|
|
156
|
-
|
|
157
|
-
## Workflow
|
|
158
|
-
|
|
159
|
-
```
|
|
160
|
-
trueline_outline (navigate)
|
|
161
|
-
→ trueline_read (targeted ranges)
|
|
162
|
-
→ trueline_diff (preview) [optional]
|
|
163
|
-
→ trueline_edit (apply)
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
A `SessionStart` hook injects instructions directing the agent to use
|
|
167
|
-
trueline tools. A `PreToolUse` hook blocks the built-in `Edit` tool and
|
|
168
|
-
redirects to the trueline workflow. On other platforms, equivalent hooks
|
|
169
|
-
are available via the `trueline-hook` CLI dispatcher — see
|
|
170
|
-
[INSTALL.md](INSTALL.md).
|
|
171
|
-
|
|
172
|
-
## Path access
|
|
173
|
-
|
|
174
|
-
By default, trueline tools can access files inside the project directory.
|
|
175
|
-
When running under Claude Code, `~/.claude/` is also allowed (it stores
|
|
176
|
-
plans, memory, and settings). To allow additional directories on any
|
|
177
|
-
platform, set `TRUELINE_ALLOWED_DIRS` to a colon-separated list of paths
|
|
178
|
-
(semicolon-separated on Windows).
|
|
179
|
-
|
|
180
|
-
## Runtime Engine Selection
|
|
181
|
-
|
|
182
|
-
trueline runs on Bun, Deno, and Node.js. It will use whichever runtime is
|
|
183
|
-
available on your system, but the choice matters for performance. Bun is the
|
|
184
|
-
fastest by a comfortable margin, followed by Deno, with Node.js coming in last.
|
|
185
|
-
If you have the option, install [Bun](https://bun.sh) — you'll notice the
|
|
186
|
-
difference on large files and batch edits.
|
|
187
|
-
|
|
188
|
-
## Benchmarks
|
|
189
|
-
|
|
190
|
-
Measured on Apple M4 Max (48 GB) with Bun 1.3.9. Edit times include a
|
|
191
|
-
fresh range-read for checksum verification.
|
|
192
|
-
|
|
193
|
-
| File size | Read (full) | Read (100 lines) | Edit (11-line replace) |
|
|
194
|
-
|-----------|-------------|-------------------|------------------------|
|
|
195
|
-
| 10 KB / 100 lines | 0.4 ms | 0.3 ms | 0.6 ms |
|
|
196
|
-
| 100 KB / 1K lines | 1.3 ms | 0.5 ms | 1.9 ms |
|
|
197
|
-
| 1 MB / 10K lines | 2.1 ms | 3.7 ms | 15.4 ms |
|
|
198
|
-
| 5 MB / 50K lines | 2.1 ms | 17.8 ms | 75.5 ms |
|
|
199
|
-
| 10 MB / 100K lines | 2.2 ms | 37.7 ms | 152 ms |
|
|
103
|
+
## Design
|
|
200
104
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
file size since they stream the entire file.
|
|
105
|
+
See [DESIGN.md](DESIGN.md) for the protocol specification, hash
|
|
106
|
+
algorithm details, streaming architecture, and security model.
|
|
204
107
|
|
|
205
108
|
## Development
|
|
206
109
|
|
package/dist/server.js
CHANGED
|
@@ -9391,7 +9391,7 @@ ${JSON.stringify(symbolNames, null, 2)}`);
|
|
|
9391
9391
|
});
|
|
9392
9392
|
|
|
9393
9393
|
// src/server.ts
|
|
9394
|
-
import { mkdir, realpath as
|
|
9394
|
+
import { mkdir as mkdir2, realpath as realpath3 } from "node:fs/promises";
|
|
9395
9395
|
import { homedir as homedir2 } from "node:os";
|
|
9396
9396
|
import { delimiter, join as join2 } from "node:path";
|
|
9397
9397
|
|
|
@@ -22353,7 +22353,7 @@ class StdioServerTransport {
|
|
|
22353
22353
|
// package.json
|
|
22354
22354
|
var package_default = {
|
|
22355
22355
|
name: "trueline-mcp",
|
|
22356
|
-
version: "2.
|
|
22356
|
+
version: "2.4.0",
|
|
22357
22357
|
type: "module",
|
|
22358
22358
|
description: "Truth-verified file editing for AI coding agents via MCP",
|
|
22359
22359
|
license: "Apache-2.0",
|
|
@@ -22396,7 +22396,8 @@ var package_default = {
|
|
|
22396
22396
|
typecheck: "bun x tsc --noEmit",
|
|
22397
22397
|
test: "bun test",
|
|
22398
22398
|
lint: "bunx biome ci .",
|
|
22399
|
-
"lint:fix": "bunx biome check --write ."
|
|
22399
|
+
"lint:fix": "bunx biome check --write .",
|
|
22400
|
+
benchmark: "bun run benchmarks/token-benchmark.ts"
|
|
22400
22401
|
},
|
|
22401
22402
|
dependencies: {
|
|
22402
22403
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
@@ -23399,6 +23400,7 @@ function editSummary(ops) {
|
|
|
23399
23400
|
// src/tools/read.ts
|
|
23400
23401
|
async function handleRead(params) {
|
|
23401
23402
|
const { file_path, start_line, end_line, projectDir, allowedDirs } = params;
|
|
23403
|
+
const includeHashes = params.hashes !== false;
|
|
23402
23404
|
const validated = await validatePath(file_path, "Read", projectDir, allowedDirs);
|
|
23403
23405
|
if (!validated.ok)
|
|
23404
23406
|
return validated.error;
|
|
@@ -23469,7 +23471,7 @@ checksum: ${formatChecksum(rangeFirstLine, rangeLastLine, rangeChecksumHash)}
|
|
|
23469
23471
|
if (rangeFirstLine === 0)
|
|
23470
23472
|
rangeFirstLine = lineNumber;
|
|
23471
23473
|
const h = fnv1aHashBytes(lineBytes, 0, lineBytes.length);
|
|
23472
|
-
const prefix = Buffer.from(`${lineNumber}:${hashToLetters(h)}|`);
|
|
23474
|
+
const prefix = includeHashes ? Buffer.from(`${lineNumber}:${hashToLetters(h)}|`) : Buffer.from(`${lineNumber}|`);
|
|
23473
23475
|
const lineLen = prefix.length + lineBytes.length + 1;
|
|
23474
23476
|
outputLines++;
|
|
23475
23477
|
if (outputLines > MAX_OUTPUT_LINES || outputLen + lineLen > MAX_OUTPUT_BYTES) {
|
|
@@ -23559,9 +23561,24 @@ async function extractOutline(source, config2) {
|
|
|
23559
23561
|
const lines = source.split(`
|
|
23560
23562
|
`);
|
|
23561
23563
|
const entries = [];
|
|
23562
|
-
function
|
|
23563
|
-
const
|
|
23564
|
-
|
|
23564
|
+
function extractSignature(node) {
|
|
23565
|
+
const startRow = node.startPosition.row;
|
|
23566
|
+
const endRow = node.endPosition.row;
|
|
23567
|
+
const fl = lines[startRow]?.trimEnd() ?? "";
|
|
23568
|
+
if (startRow === endRow || fl.includes("{")) {
|
|
23569
|
+
return fl.length > 200 ? `${fl.slice(0, 197)}...` : fl;
|
|
23570
|
+
}
|
|
23571
|
+
const parts2 = [fl.trimEnd()];
|
|
23572
|
+
for (let row = startRow + 1;row <= Math.min(endRow, startRow + 20); row++) {
|
|
23573
|
+
const line = (lines[row] ?? "").trimEnd();
|
|
23574
|
+
parts2.push(line.trim());
|
|
23575
|
+
if (line.includes("{"))
|
|
23576
|
+
break;
|
|
23577
|
+
}
|
|
23578
|
+
let sig = parts2.join(" ").replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/,\s*\)/g, ")").replace(/\s*\{\s*$/, "");
|
|
23579
|
+
if (sig.length > 200)
|
|
23580
|
+
sig = `${sig.slice(0, 197)}...`;
|
|
23581
|
+
return sig;
|
|
23565
23582
|
}
|
|
23566
23583
|
let skipStart = -1;
|
|
23567
23584
|
let skipEnd = -1;
|
|
@@ -23621,7 +23638,7 @@ async function extractOutline(source, config2) {
|
|
|
23621
23638
|
endLine: node.endPosition.row + 1,
|
|
23622
23639
|
depth,
|
|
23623
23640
|
nodeType: node.type,
|
|
23624
|
-
text:
|
|
23641
|
+
text: extractSignature(node)
|
|
23625
23642
|
});
|
|
23626
23643
|
for (const child of node.children) {
|
|
23627
23644
|
if (!child.isNamed)
|
|
@@ -23952,8 +23969,149 @@ async function handleOutline(params) {
|
|
|
23952
23969
|
}
|
|
23953
23970
|
}
|
|
23954
23971
|
|
|
23972
|
+
// src/tools/search.ts
|
|
23973
|
+
async function handleSearch(params) {
|
|
23974
|
+
const { file_path, pattern, projectDir, allowedDirs } = params;
|
|
23975
|
+
const contextLines = params.context_lines ?? 2;
|
|
23976
|
+
const maxMatches = params.max_matches ?? 10;
|
|
23977
|
+
const validated = await validatePath(file_path, "Read", projectDir, allowedDirs);
|
|
23978
|
+
if (!validated.ok)
|
|
23979
|
+
return validated.error;
|
|
23980
|
+
let regex;
|
|
23981
|
+
try {
|
|
23982
|
+
regex = new RegExp(pattern);
|
|
23983
|
+
} catch {
|
|
23984
|
+
return errorResult(`Invalid regex pattern: "${pattern}"`);
|
|
23985
|
+
}
|
|
23986
|
+
const { resolvedPath } = validated;
|
|
23987
|
+
const allLines = [];
|
|
23988
|
+
const matchIndices = [];
|
|
23989
|
+
try {
|
|
23990
|
+
for await (const { lineBytes, lineNumber } of splitLines(resolvedPath, { detectBinary: true })) {
|
|
23991
|
+
const h = fnv1aHashBytes(lineBytes, 0, lineBytes.length);
|
|
23992
|
+
const text = lineBytes.toString("utf-8");
|
|
23993
|
+
const isMatch = regex.test(text);
|
|
23994
|
+
allLines.push({ lineNumber, content: lineBytes, hash: h, isMatch });
|
|
23995
|
+
if (isMatch)
|
|
23996
|
+
matchIndices.push(allLines.length - 1);
|
|
23997
|
+
}
|
|
23998
|
+
} catch (err2) {
|
|
23999
|
+
if (err2 instanceof Error && err2.message.includes("binary")) {
|
|
24000
|
+
return errorResult(`"${file_path}" appears to be a binary file`);
|
|
24001
|
+
}
|
|
24002
|
+
throw err2;
|
|
24003
|
+
}
|
|
24004
|
+
if (matchIndices.length === 0) {
|
|
24005
|
+
return textResult(`No matches for pattern "${pattern}" in ${file_path}`);
|
|
24006
|
+
}
|
|
24007
|
+
const totalMatches = matchIndices.length;
|
|
24008
|
+
const cappedIndices = matchIndices.slice(0, maxMatches);
|
|
24009
|
+
const ranges = [];
|
|
24010
|
+
for (const idx of cappedIndices) {
|
|
24011
|
+
const start2 = Math.max(0, idx - contextLines);
|
|
24012
|
+
const end = Math.min(allLines.length - 1, idx + contextLines);
|
|
24013
|
+
if (ranges.length > 0 && start2 <= ranges[ranges.length - 1].end + 1) {
|
|
24014
|
+
ranges[ranges.length - 1].end = end;
|
|
24015
|
+
} else {
|
|
24016
|
+
ranges.push({ start: start2, end });
|
|
24017
|
+
}
|
|
24018
|
+
}
|
|
24019
|
+
const parts2 = [];
|
|
24020
|
+
for (let i2 = 0;i2 < ranges.length; i2++) {
|
|
24021
|
+
const range = ranges[i2];
|
|
24022
|
+
let checksumHash = FNV_OFFSET_BASIS;
|
|
24023
|
+
let firstLine = 0;
|
|
24024
|
+
let lastLine = 0;
|
|
24025
|
+
if (i2 > 0)
|
|
24026
|
+
parts2.push("");
|
|
24027
|
+
for (let idx = range.start;idx <= range.end; idx++) {
|
|
24028
|
+
const line = allLines[idx];
|
|
24029
|
+
if (firstLine === 0)
|
|
24030
|
+
firstLine = line.lineNumber;
|
|
24031
|
+
lastLine = line.lineNumber;
|
|
24032
|
+
checksumHash = foldHash(checksumHash, line.hash);
|
|
24033
|
+
const marker = line.isMatch ? " ← match" : "";
|
|
24034
|
+
parts2.push(`${line.lineNumber}:${hashToLetters(line.hash)}|${line.content.toString("utf-8")}${marker}`);
|
|
24035
|
+
}
|
|
24036
|
+
parts2.push("");
|
|
24037
|
+
parts2.push(`checksum: ${formatChecksum(firstLine, lastLine, checksumHash)}`);
|
|
24038
|
+
}
|
|
24039
|
+
if (totalMatches > maxMatches) {
|
|
24040
|
+
parts2.push("");
|
|
24041
|
+
parts2.push(`(showing ${maxMatches} of ${totalMatches} matches — increase max_matches to see more)`);
|
|
24042
|
+
}
|
|
24043
|
+
return textResult(parts2.join(`
|
|
24044
|
+
`));
|
|
24045
|
+
}
|
|
24046
|
+
|
|
24047
|
+
// src/tools/write.ts
|
|
24048
|
+
import { dirname as dirname3, resolve as resolve5, sep as sep2 } from "node:path";
|
|
24049
|
+
import { mkdir, realpath as realpath2, stat as stat4, writeFile } from "node:fs/promises";
|
|
24050
|
+
async function validateWritePath(file_path, projectDir, allowedDirs = []) {
|
|
24051
|
+
const resolvedPath = file_path.startsWith("/") ? file_path : resolve5(projectDir ?? process.cwd(), file_path);
|
|
24052
|
+
let realPath;
|
|
24053
|
+
let exists = false;
|
|
24054
|
+
try {
|
|
24055
|
+
realPath = await realpath2(resolvedPath);
|
|
24056
|
+
exists = true;
|
|
24057
|
+
const fileStat = await stat4(realPath);
|
|
24058
|
+
if (!fileStat.isFile()) {
|
|
24059
|
+
return { ok: false, error: errorResult(`"${file_path}" is not a regular file`) };
|
|
24060
|
+
}
|
|
24061
|
+
} catch {
|
|
24062
|
+
realPath = resolvedPath;
|
|
24063
|
+
let ancestor = dirname3(resolvedPath);
|
|
24064
|
+
let realAncestor;
|
|
24065
|
+
while (ancestor !== dirname3(ancestor)) {
|
|
24066
|
+
try {
|
|
24067
|
+
realAncestor = await realpath2(ancestor);
|
|
24068
|
+
break;
|
|
24069
|
+
} catch {
|
|
24070
|
+
ancestor = dirname3(ancestor);
|
|
24071
|
+
}
|
|
24072
|
+
}
|
|
24073
|
+
if (!realAncestor) {
|
|
24074
|
+
return { ok: false, error: errorResult(`No accessible ancestor directory for "${file_path}"`) };
|
|
24075
|
+
}
|
|
24076
|
+
const tail = resolvedPath.slice(ancestor.length);
|
|
24077
|
+
realPath = realAncestor + tail;
|
|
24078
|
+
}
|
|
24079
|
+
let realBase;
|
|
24080
|
+
try {
|
|
24081
|
+
realBase = await realpath2(projectDir ? projectDir : process.cwd());
|
|
24082
|
+
} catch {
|
|
24083
|
+
return { ok: false, error: errorResult("Project directory not found or inaccessible") };
|
|
24084
|
+
}
|
|
24085
|
+
const resolvedAllowed = await Promise.all(allowedDirs.map((d) => realpath2(d).catch(() => d)));
|
|
24086
|
+
const allBases = [realBase, ...resolvedAllowed];
|
|
24087
|
+
const isContained = allBases.some((base) => realPath === base || realPath.startsWith(base + sep2));
|
|
24088
|
+
if (!isContained) {
|
|
24089
|
+
return { ok: false, error: errorResult(`Access denied: "${file_path}" is outside the project directory`) };
|
|
24090
|
+
}
|
|
24091
|
+
return { ok: true, resolvedPath: realPath, exists };
|
|
24092
|
+
}
|
|
24093
|
+
async function handleWrite(params) {
|
|
24094
|
+
const { file_path, content, projectDir, allowedDirs } = params;
|
|
24095
|
+
const createDirs = params.create_directories !== false;
|
|
24096
|
+
const validated = await validateWritePath(file_path, projectDir, allowedDirs);
|
|
24097
|
+
if (!validated.ok)
|
|
24098
|
+
return validated.error;
|
|
24099
|
+
const { resolvedPath } = validated;
|
|
24100
|
+
if (createDirs) {
|
|
24101
|
+
await mkdir(dirname3(resolvedPath), { recursive: true });
|
|
24102
|
+
}
|
|
24103
|
+
await writeFile(resolvedPath, content, "utf-8");
|
|
24104
|
+
const readResult = await handleRead({ file_path: resolvedPath, projectDir, allowedDirs });
|
|
24105
|
+
const checksumMatch = readResult.content[0].text.match(/checksum: (\S+)/);
|
|
24106
|
+
const checksum = checksumMatch ? checksumMatch[1] : "0-0:00000000";
|
|
24107
|
+
const verb = validated.exists ? "overwritten" : "created";
|
|
24108
|
+
return textResult(`File ${verb}: ${file_path}
|
|
24109
|
+
|
|
24110
|
+
checksum: ${checksum}`);
|
|
24111
|
+
}
|
|
24112
|
+
|
|
23955
24113
|
// src/update-check.ts
|
|
23956
|
-
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
24114
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
23957
24115
|
import { join } from "node:path";
|
|
23958
24116
|
import { tmpdir } from "node:os";
|
|
23959
24117
|
var PACKAGE_NAME = "trueline-mcp";
|
|
@@ -23969,7 +24127,7 @@ async function readCache() {
|
|
|
23969
24127
|
}
|
|
23970
24128
|
}
|
|
23971
24129
|
async function writeCache(entry) {
|
|
23972
|
-
await
|
|
24130
|
+
await writeFile2(CACHE_FILE, JSON.stringify(entry)).catch(() => {});
|
|
23973
24131
|
}
|
|
23974
24132
|
async function fetchLatestVersion() {
|
|
23975
24133
|
try {
|
|
@@ -24028,20 +24186,20 @@ var server = new McpServer({
|
|
|
24028
24186
|
version: VERSION2
|
|
24029
24187
|
});
|
|
24030
24188
|
var rawProjectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
24031
|
-
var projectDir = await
|
|
24189
|
+
var projectDir = await realpath3(rawProjectDir).catch(() => rawProjectDir);
|
|
24032
24190
|
async function resolveAllowedDirs() {
|
|
24033
24191
|
const dirs = [];
|
|
24034
24192
|
if (process.env.CLAUDE_PROJECT_DIR) {
|
|
24035
24193
|
const claudeDir = join2(homedir2(), ".claude");
|
|
24036
|
-
await
|
|
24037
|
-
const realClaudeDir = await
|
|
24194
|
+
await mkdir2(claudeDir, { recursive: true }).catch(() => {});
|
|
24195
|
+
const realClaudeDir = await realpath3(claudeDir).catch(() => null);
|
|
24038
24196
|
if (realClaudeDir)
|
|
24039
24197
|
dirs.push(realClaudeDir);
|
|
24040
24198
|
}
|
|
24041
24199
|
const extra = process.env.TRUELINE_ALLOWED_DIRS;
|
|
24042
24200
|
if (extra) {
|
|
24043
24201
|
for (const raw of extra.split(delimiter).filter(Boolean)) {
|
|
24044
|
-
const resolved = await
|
|
24202
|
+
const resolved = await realpath3(raw).catch(() => null);
|
|
24045
24203
|
if (resolved)
|
|
24046
24204
|
dirs.push(resolved);
|
|
24047
24205
|
}
|
|
@@ -24057,7 +24215,8 @@ server.registerTool("trueline_read", {
|
|
|
24057
24215
|
start: exports_external.number().int().positive().describe("First line to read (1-based).").optional(),
|
|
24058
24216
|
end: exports_external.number().int().positive().describe("Last line to read (1-based, inclusive).").optional()
|
|
24059
24217
|
})).describe("Line ranges to read. Omit to read the whole file. Example: [{start: 10, end: 25}] or [{start: 1, end: 50}, {start: 200, end: 220}] for disjoint ranges. Each range gets its own checksum.")).optional(),
|
|
24060
|
-
encoding: exports_external.string().describe("File encoding. Defaults to utf-8. Supported: utf-8, ascii, latin1.").optional()
|
|
24218
|
+
encoding: exports_external.string().describe("File encoding. Defaults to utf-8. Supported: utf-8, ascii, latin1.").optional(),
|
|
24219
|
+
hashes: exports_external.boolean().describe("Include per-line hashes in output. Defaults to true. Set to false for exploratory reads where you don't plan to edit — saves tokens. Checksums are always included.").optional()
|
|
24061
24220
|
})
|
|
24062
24221
|
}, async (params) => {
|
|
24063
24222
|
return handleRead({ ...params, projectDir, allowedDirs });
|
|
@@ -24098,6 +24257,27 @@ server.registerTool("trueline_outline", {
|
|
|
24098
24257
|
}, async (params) => {
|
|
24099
24258
|
return handleOutline({ ...params, projectDir, allowedDirs });
|
|
24100
24259
|
});
|
|
24260
|
+
server.registerTool("trueline_search", {
|
|
24261
|
+
description: "Search a file by regex pattern. Returns matching lines with context, per-line hashes, and checksums — " + "ready for immediate editing. Use instead of outline+read when you know what pattern to look for.",
|
|
24262
|
+
inputSchema: exports_external.object({
|
|
24263
|
+
file_path: exports_external.string(),
|
|
24264
|
+
pattern: exports_external.string().describe("Regex pattern to search for (line-by-line matching)."),
|
|
24265
|
+
context_lines: exports_external.number().int().min(0).describe("Lines of context above/below each match. Default: 2.").optional(),
|
|
24266
|
+
max_matches: exports_external.number().int().positive().describe("Maximum number of matches to return. Default: 10.").optional()
|
|
24267
|
+
})
|
|
24268
|
+
}, async (params) => {
|
|
24269
|
+
return handleSearch({ ...params, projectDir, allowedDirs });
|
|
24270
|
+
});
|
|
24271
|
+
server.registerTool("trueline_write", {
|
|
24272
|
+
description: "Create or overwrite a file. Returns a checksum of the written content for verification. " + "To edit afterward, call trueline_read first to get per-line hashes.",
|
|
24273
|
+
inputSchema: exports_external.object({
|
|
24274
|
+
file_path: exports_external.string(),
|
|
24275
|
+
content: exports_external.string().describe("The full file content to write."),
|
|
24276
|
+
create_directories: exports_external.boolean().describe("Create parent directories if they don't exist. Default: true.").optional()
|
|
24277
|
+
})
|
|
24278
|
+
}, async (params) => {
|
|
24279
|
+
return handleWrite({ ...params, projectDir, allowedDirs });
|
|
24280
|
+
});
|
|
24101
24281
|
var transport = new StdioServerTransport;
|
|
24102
24282
|
try {
|
|
24103
24283
|
await server.connect(transport);
|
|
@@ -11,6 +11,9 @@ const PLATFORM_RULES = {
|
|
|
11
11
|
readAdvice:
|
|
12
12
|
"Never use the built-in Read tool \u2014 use trueline_read instead. " +
|
|
13
13
|
"trueline_read returns per-line hashes and checksums needed for trueline_edit.",
|
|
14
|
+
writeAdvice:
|
|
15
|
+
"Never use the built-in Write tool for files in the project directory \u2014 use trueline_write instead. " +
|
|
16
|
+
"trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
|
|
14
17
|
subagentRule: true,
|
|
15
18
|
},
|
|
16
19
|
"gemini-cli": {
|
|
@@ -18,6 +21,9 @@ const PLATFORM_RULES = {
|
|
|
18
21
|
readAdvice:
|
|
19
22
|
"Never use read_file or read_many_files \u2014 use trueline_read instead. " +
|
|
20
23
|
"trueline_read returns per-line hashes and checksums needed for trueline_edit.",
|
|
24
|
+
writeAdvice:
|
|
25
|
+
"Never use write_file for files in the project directory \u2014 use trueline_write instead. " +
|
|
26
|
+
"trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
|
|
21
27
|
subagentRule: false,
|
|
22
28
|
},
|
|
23
29
|
"vscode-copilot": {
|
|
@@ -25,6 +31,9 @@ const PLATFORM_RULES = {
|
|
|
25
31
|
readAdvice:
|
|
26
32
|
"Never use the built-in Read tool \u2014 use trueline_read instead. " +
|
|
27
33
|
"trueline_read returns per-line hashes and checksums needed for trueline_edit.",
|
|
34
|
+
writeAdvice:
|
|
35
|
+
"Never use the built-in Write tool for files in the project directory \u2014 use trueline_write instead. " +
|
|
36
|
+
"trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
|
|
28
37
|
subagentRule: false,
|
|
29
38
|
},
|
|
30
39
|
opencode: {
|
|
@@ -32,6 +41,9 @@ const PLATFORM_RULES = {
|
|
|
32
41
|
readAdvice:
|
|
33
42
|
"Never use the built-in view tool \u2014 use trueline_read instead. " +
|
|
34
43
|
"trueline_read returns per-line hashes and checksums needed for trueline_edit.",
|
|
44
|
+
writeAdvice:
|
|
45
|
+
"Never use the built-in write tool for files in the project directory \u2014 use trueline_write instead. " +
|
|
46
|
+
"trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
|
|
35
47
|
subagentRule: false,
|
|
36
48
|
},
|
|
37
49
|
codex: {
|
|
@@ -39,6 +51,9 @@ const PLATFORM_RULES = {
|
|
|
39
51
|
readAdvice:
|
|
40
52
|
"Never use read_file or shell with cat/head/tail \u2014 use trueline_read instead. " +
|
|
41
53
|
"trueline_read returns per-line hashes and checksums needed for trueline_edit.",
|
|
54
|
+
writeAdvice:
|
|
55
|
+
"Never use shell with echo/cat redirection for files in the project directory \u2014 use trueline_write instead. " +
|
|
56
|
+
"trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
|
|
42
57
|
subagentRule: false,
|
|
43
58
|
},
|
|
44
59
|
};
|
|
@@ -58,17 +73,22 @@ export function getInstructions(platform = "claude-code") {
|
|
|
58
73
|
|
|
59
74
|
return `<trueline_mcp_instructions>
|
|
60
75
|
<tools>
|
|
61
|
-
<tool name="trueline_read">Read a file; returns per-line hashes and a checksum per range. Supports multiple disjoint ranges in one call. Call before editing.</tool>
|
|
76
|
+
<tool name="trueline_read">Read a file; returns per-line hashes and a checksum per range. Supports multiple disjoint ranges in one call. Call before editing. Pass hashes=false for exploratory reads where you don't plan to edit — saves tokens while keeping checksums.</tool>
|
|
62
77
|
<tool name="trueline_edit">Edit a file with hash verification. Replaces the built-in Edit tool, which is blocked. Each edit needs: checksum (from trueline_read for the covering range), range (startLine:hash..endLine:hash or +startLine:hash for insert-after), content (replacement lines as newline-separated string; empty string to delete). Pass all changes to the same file in the edits array.</tool>
|
|
63
78
|
<tool name="trueline_diff">Preview edits as a unified diff without writing to disk.</tool>
|
|
64
79
|
<tool name="trueline_outline">Get a compact structural outline of a source file (functions, classes, types, etc.) without reading full content. Often sufficient on its own for navigation and understanding. Use before trueline_read to identify the right line ranges when you do need to read.</tool>
|
|
80
|
+
<tool name="trueline_search">Search a file by regex. Returns matching lines with surrounding context, per-line hashes, and checksums \u2014 output is edit-ready (pass checksums directly to trueline_edit). Preferred over Grep for single-file searches because results include hashes for immediate editing. Preferred over outline+read when you know a pattern to search for.</tool>
|
|
81
|
+
<tool name="trueline_write">Create or overwrite a file. Returns a checksum for verification. To edit afterward, call trueline_read first to get per-line hashes. Use instead of the built-in Write tool for files in the project directory.</tool>
|
|
65
82
|
</tools>
|
|
66
|
-
<workflow>trueline_outline (navigate / understand)
|
|
83
|
+
<workflow>trueline_outline (navigate / understand) → trueline_read (targeted ranges, only if needed) → trueline_diff (optional) → trueline_edit</workflow>
|
|
84
|
+
<workflow>trueline_search (find + read in one step) → trueline_edit (edit directly from search results, no re-read needed)</workflow>
|
|
67
85
|
<rules>${editRule}
|
|
68
|
-
<rule>${rules.readAdvice}</rule
|
|
86
|
+
<rule>${rules.readAdvice}</rule>
|
|
87
|
+
<rule>${rules.writeAdvice}</rule>${subagentRule}
|
|
69
88
|
<rule>trueline_outline is often enough by itself for questions about file structure, purpose, or navigation. Only call trueline_read when you actually need the source code (e.g. to edit, debug, or understand implementation details).</rule>
|
|
70
89
|
<rule>After using trueline_outline, if you do need to read, use its line numbers to read only the specific ranges you need \u2014 do NOT read the entire file.</rule>
|
|
71
90
|
<rule>Only read a full file (no ranges) when you have not used trueline_outline and the file is short, or you genuinely need every line.</rule>
|
|
72
|
-
|
|
91
|
+
<rule>When you need to find a pattern in a specific file (e.g. to locate code before editing, find usages within a file, or search for a string), use trueline_search instead of Grep or outline+read. trueline_search returns hashes and checksums so you can edit immediately without a separate trueline_read call.</rule>
|
|
92
|
+
<rule>When you need to find a pattern across many files, use Grep to identify the files, then use trueline_search on individual files you need to edit.</rule>
|
|
73
93
|
</trueline_mcp_instructions>`;
|
|
74
94
|
}
|
package/hooks/core/routing.js
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
// block/approve decisions. Returns normalized {action, reason} objects
|
|
7
7
|
// that platform-specific formatters translate to the right JSON shape.
|
|
8
8
|
|
|
9
|
+
import { stat } from "node:fs/promises";
|
|
10
|
+
|
|
9
11
|
// Maps platform-specific built-in tool names to canonical names.
|
|
10
12
|
const TOOL_ALIASES = {
|
|
11
13
|
// Gemini CLI
|
|
12
14
|
read_file: "Read",
|
|
13
15
|
read_many_files: "Read",
|
|
14
16
|
edit_file: "Edit",
|
|
17
|
+
write_file: "Write",
|
|
15
18
|
run_shell_command: "Bash",
|
|
16
19
|
// OpenCode
|
|
17
20
|
view: "Read",
|
|
@@ -88,5 +91,30 @@ export async function routePreToolUse(toolName, toolInput, canAccessFn) {
|
|
|
88
91
|
return null;
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
if (canonical === "Write") {
|
|
95
|
+
if (typeof filePath === "string") {
|
|
96
|
+
const canWrite = await canAccessFn(filePath, "Edit");
|
|
97
|
+
if (canWrite) {
|
|
98
|
+
// Fall through to built-in Write for non-regular files (directories,
|
|
99
|
+
// devices, etc.) that trueline_write would reject.
|
|
100
|
+
try {
|
|
101
|
+
const s = await stat(filePath);
|
|
102
|
+
if (!s.isFile()) return null;
|
|
103
|
+
} catch {
|
|
104
|
+
// File doesn't exist yet — trueline_write can handle creation.
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
action: "block",
|
|
108
|
+
reason:
|
|
109
|
+
"<trueline_redirect>" +
|
|
110
|
+
"Use trueline_write instead of Write for files in the project directory. " +
|
|
111
|
+
"trueline_write returns a checksum for verification." +
|
|
112
|
+
"</trueline_redirect>",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
return null;
|
|
92
120
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trueline-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Truth-verified file editing for AI coding agents via MCP",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"typecheck": "bun x tsc --noEmit",
|
|
44
44
|
"test": "bun test",
|
|
45
45
|
"lint": "bunx biome ci .",
|
|
46
|
-
"lint:fix": "bunx biome check --write ."
|
|
46
|
+
"lint:fix": "bunx biome check --write .",
|
|
47
|
+
"benchmark": "bun run benchmarks/token-benchmark.ts"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.26.0",
|