@levnikolaevich/hex-line-mcp 1.29.0 → 1.30.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/dist/server.mjs +160 -15
- package/package.json +1 -1
package/dist/server.mjs
CHANGED
|
@@ -202,11 +202,12 @@ function readText(filePath) {
|
|
|
202
202
|
// lib/security.mjs
|
|
203
203
|
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
204
204
|
var EXTERNAL_SAFE_FOLDERS = [
|
|
205
|
-
".hex-skills
|
|
206
|
-
".claude
|
|
205
|
+
".hex-skills",
|
|
206
|
+
".claude"
|
|
207
207
|
];
|
|
208
208
|
function isInExternalSafeFolder(absPath) {
|
|
209
|
-
|
|
209
|
+
const parts = normalizeScopeValue(absPath).split("/").filter(Boolean);
|
|
210
|
+
return EXTERNAL_SAFE_FOLDERS.some((folder) => parts.includes(folder));
|
|
210
211
|
}
|
|
211
212
|
function normalizePath(p) {
|
|
212
213
|
if (process.platform === "win32") {
|
|
@@ -5008,6 +5009,85 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
|
|
|
5008
5009
|
return output;
|
|
5009
5010
|
}
|
|
5010
5011
|
|
|
5012
|
+
// ../hex-common/src/runtime/error-classifier.mjs
|
|
5013
|
+
var FAILURE_CLASS = Object.freeze({
|
|
5014
|
+
NONE: "none",
|
|
5015
|
+
TIMEOUT_IDLE: "timeout_idle",
|
|
5016
|
+
TIMEOUT_PRODUCTIVE: "timeout_productive",
|
|
5017
|
+
PERMISSION_DENIAL: "permission_denial",
|
|
5018
|
+
TOOL_MISSING: "tool_missing",
|
|
5019
|
+
AUTH_MISSING: "auth_missing",
|
|
5020
|
+
RATE_LIMITED: "rate_limited",
|
|
5021
|
+
ASKED_QUESTION: "asked_question",
|
|
5022
|
+
AGENT_ERROR: "agent_error",
|
|
5023
|
+
UNKNOWN: "unknown"
|
|
5024
|
+
});
|
|
5025
|
+
var INPUT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5026
|
+
"BAD_INPUT",
|
|
5027
|
+
"BAD_PATH",
|
|
5028
|
+
"BAD_REMOTE_PLATFORM",
|
|
5029
|
+
"INVALID_INPUT",
|
|
5030
|
+
"INVALID_EDIT_PAYLOAD",
|
|
5031
|
+
"INVALID_JSON",
|
|
5032
|
+
"PATH_OUTSIDE_ROOT",
|
|
5033
|
+
"PATH_NOT_FOUND",
|
|
5034
|
+
"FILE_NOT_FOUND",
|
|
5035
|
+
"FILE_OUTSIDE_PROJECT",
|
|
5036
|
+
"OUT_OF_RANGE",
|
|
5037
|
+
"UNSUPPORTED_REMOTE_PLATFORM"
|
|
5038
|
+
]);
|
|
5039
|
+
var PERMISSION_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5040
|
+
"SSH_HOST_NOT_ALLOWED",
|
|
5041
|
+
"BLOCKED_COMMAND",
|
|
5042
|
+
"REMOTE_SSH_DISABLED"
|
|
5043
|
+
]);
|
|
5044
|
+
var AUTH_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5045
|
+
"SSH_AUTH_FAILED",
|
|
5046
|
+
"SSH_AUTH_MISSING",
|
|
5047
|
+
"SSH_KEY_UNREADABLE"
|
|
5048
|
+
]);
|
|
5049
|
+
var TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5050
|
+
"SSH_EXEC_TIMEOUT",
|
|
5051
|
+
"SSH_CONNECT_TIMEOUT",
|
|
5052
|
+
"EXEC_TIMEOUT",
|
|
5053
|
+
"TRANSFER_TIMEOUT"
|
|
5054
|
+
]);
|
|
5055
|
+
function textFor(input) {
|
|
5056
|
+
return [
|
|
5057
|
+
input?.code,
|
|
5058
|
+
input?.message,
|
|
5059
|
+
input?.recovery,
|
|
5060
|
+
input?.stderr,
|
|
5061
|
+
input?.error
|
|
5062
|
+
].filter(Boolean).map(String).join("\n");
|
|
5063
|
+
}
|
|
5064
|
+
function classifyMcpFailure(input = {}) {
|
|
5065
|
+
const code = String(input.code || "").toUpperCase();
|
|
5066
|
+
const text = textFor(input).toLowerCase();
|
|
5067
|
+
if (/\b(429|rate limit|too many requests|quota exceeded|throttl)/i.test(text) || code.includes("RATE_LIMIT")) {
|
|
5068
|
+
return { failure_class: FAILURE_CLASS.RATE_LIMITED, next_action: "defer_retry" };
|
|
5069
|
+
}
|
|
5070
|
+
if (/\b(auth|authentication|unauthorized|not authorized|login required|credential|token missing|no user for host|permission denied \(publickey\)|publickey)\b/i.test(text) || code.includes("AUTH") || AUTH_ERROR_CODES.has(code)) {
|
|
5071
|
+
return { failure_class: FAILURE_CLASS.AUTH_MISSING, next_action: "authenticate" };
|
|
5072
|
+
}
|
|
5073
|
+
if (/\b(eacces|eperm|permission denied|access denied|operation not permitted|forbidden)\b/i.test(text) || code === "GRAPH_DB_UNREADABLE" || PERMISSION_ERROR_CODES.has(code)) {
|
|
5074
|
+
return { failure_class: FAILURE_CLASS.PERMISSION_DENIAL, next_action: "fix_permissions" };
|
|
5075
|
+
}
|
|
5076
|
+
if (/\b(enoent|command not found|not recognized as|not found in path|required tool|provider setup failed|missing provider|tool missing)\b/i.test(text) || code === "GRAPH_PROVIDER_SETUP_FAILED") {
|
|
5077
|
+
return { failure_class: FAILURE_CLASS.TOOL_MISSING, next_action: "install_tool" };
|
|
5078
|
+
}
|
|
5079
|
+
if (/\b(etimedout|timed out|timeout|transfer_timeout|connection timed out|database is locked|busy or locked)\b/i.test(text) || code.includes("TIMEOUT") || code === "GRAPH_DB_BUSY" || TIMEOUT_ERROR_CODES.has(code)) {
|
|
5080
|
+
return { failure_class: FAILURE_CLASS.TIMEOUT_IDLE, next_action: "retry_after_wait" };
|
|
5081
|
+
}
|
|
5082
|
+
if (/\?\s*$|\b(please confirm|confirm\?|choose one|which option)\b/i.test(text)) {
|
|
5083
|
+
return { failure_class: FAILURE_CLASS.ASKED_QUESTION, next_action: "fix_inputs" };
|
|
5084
|
+
}
|
|
5085
|
+
if (INPUT_ERROR_CODES.has(code) || code.startsWith("INVALID_") || code.endsWith("_REQUIRED")) {
|
|
5086
|
+
return { failure_class: FAILURE_CLASS.UNKNOWN, next_action: "fix_inputs" };
|
|
5087
|
+
}
|
|
5088
|
+
return { failure_class: FAILURE_CLASS.UNKNOWN, next_action: "fix_inputs" };
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5011
5091
|
// ../hex-common/src/runtime/results.mjs
|
|
5012
5092
|
var LARGE_RESULT_META = { "anthropic/maxResultSizeChars": 5e5 };
|
|
5013
5093
|
function result(structured, { large = false } = {}) {
|
|
@@ -5021,9 +5101,26 @@ function result(structured, { large = false } = {}) {
|
|
|
5021
5101
|
return response;
|
|
5022
5102
|
}
|
|
5023
5103
|
function errorResult(code, message, recovery, { large = false, extra = null } = {}) {
|
|
5104
|
+
const normalizedCode = String(code || "ERROR");
|
|
5105
|
+
const normalizedMessage = String(message || "Unknown MCP tool error");
|
|
5106
|
+
const normalizedRecovery = String(recovery || "Review the error and retry with corrected inputs");
|
|
5107
|
+
const classification = classifyMcpFailure({
|
|
5108
|
+
code: normalizedCode,
|
|
5109
|
+
message: normalizedMessage,
|
|
5110
|
+
recovery: normalizedRecovery
|
|
5111
|
+
});
|
|
5024
5112
|
const payload = {
|
|
5025
5113
|
status: "ERROR",
|
|
5026
|
-
|
|
5114
|
+
code: normalizedCode,
|
|
5115
|
+
summary: normalizedMessage,
|
|
5116
|
+
next_action: classification.next_action,
|
|
5117
|
+
recovery: normalizedRecovery,
|
|
5118
|
+
failure_class: classification.failure_class,
|
|
5119
|
+
error: {
|
|
5120
|
+
code: normalizedCode,
|
|
5121
|
+
message: normalizedMessage,
|
|
5122
|
+
recovery: normalizedRecovery
|
|
5123
|
+
}
|
|
5027
5124
|
};
|
|
5028
5125
|
if (extra && typeof extra === "object") {
|
|
5029
5126
|
Object.assign(payload, extra);
|
|
@@ -5032,9 +5129,17 @@ function errorResult(code, message, recovery, { large = false, extra = null } =
|
|
|
5032
5129
|
}
|
|
5033
5130
|
|
|
5034
5131
|
// server.mjs
|
|
5035
|
-
var version = true ? "1.
|
|
5132
|
+
var version = true ? "1.30.0" : (await null).createRequire(import.meta.url)("./package.json").version;
|
|
5036
5133
|
var STATUS_ENUM = z2.enum(STATUS_VALUES);
|
|
5037
5134
|
var ERROR_SHAPE = z2.object({ code: z2.string(), message: z2.string(), recovery: z2.string() }).optional();
|
|
5135
|
+
var ERROR_RESULT_FIELDS = {
|
|
5136
|
+
code: z2.string().optional(),
|
|
5137
|
+
summary: z2.string().optional(),
|
|
5138
|
+
next_action: z2.string().optional(),
|
|
5139
|
+
recovery: z2.string().optional(),
|
|
5140
|
+
failure_class: z2.string().optional(),
|
|
5141
|
+
error: ERROR_SHAPE
|
|
5142
|
+
};
|
|
5038
5143
|
var LINE_REPORT_KEYS = /* @__PURE__ */ new Set([
|
|
5039
5144
|
"status",
|
|
5040
5145
|
"reason",
|
|
@@ -5055,6 +5160,46 @@ var LINE_REPORT_KEYS = /* @__PURE__ */ new Set([
|
|
|
5055
5160
|
"remapped_refs",
|
|
5056
5161
|
"warnings"
|
|
5057
5162
|
]);
|
|
5163
|
+
var EDIT_PAYLOAD_TYPES = ["set_line", "insert_after", "replace_lines", "replace_between"];
|
|
5164
|
+
var EDIT_REQUIRED_FIELDS = {
|
|
5165
|
+
set_line: ["anchor", "new_text"],
|
|
5166
|
+
insert_after: ["anchor", "text"],
|
|
5167
|
+
replace_lines: ["start_anchor", "end_anchor", "new_text"],
|
|
5168
|
+
replace_between: ["start_anchor", "end_anchor", "new_text"]
|
|
5169
|
+
};
|
|
5170
|
+
function inputError(code, message, recovery) {
|
|
5171
|
+
const error = new Error(message);
|
|
5172
|
+
error.code = code;
|
|
5173
|
+
error.recovery = recovery;
|
|
5174
|
+
return error;
|
|
5175
|
+
}
|
|
5176
|
+
function validateEditPayload(edits) {
|
|
5177
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
5178
|
+
throw inputError("INVALID_EDIT_PAYLOAD", "BAD_INPUT: edits must be a non-empty JSON array", 'Pass canonical edit objects such as {"set_line":{"anchor":"ab.12","new_text":"..."}}');
|
|
5179
|
+
}
|
|
5180
|
+
edits.forEach((edit, index) => {
|
|
5181
|
+
if (!edit || typeof edit !== "object" || Array.isArray(edit)) {
|
|
5182
|
+
throw inputError("INVALID_EDIT_PAYLOAD", `BAD_INPUT: edit at index ${index} must be an object`, "Use one canonical edit object per array item");
|
|
5183
|
+
}
|
|
5184
|
+
const keys = EDIT_PAYLOAD_TYPES.filter((type2) => Object.prototype.hasOwnProperty.call(edit, type2));
|
|
5185
|
+
if (keys.length === 0) {
|
|
5186
|
+
throw inputError("INVALID_EDIT_PAYLOAD", `BAD_INPUT: unknown edit type at index ${index}`, "Use set_line, insert_after, replace_lines, or replace_between");
|
|
5187
|
+
}
|
|
5188
|
+
if (keys.length > 1) {
|
|
5189
|
+
throw inputError("INVALID_EDIT_PAYLOAD", `BAD_INPUT: edit at index ${index} has multiple edit types`, "Use exactly one edit type per object");
|
|
5190
|
+
}
|
|
5191
|
+
const [type] = keys;
|
|
5192
|
+
const payload = edit[type];
|
|
5193
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
5194
|
+
throw inputError("INVALID_EDIT_PAYLOAD", `BAD_INPUT: ${type} payload at index ${index} must be an object`, "Nest fields under the canonical edit type");
|
|
5195
|
+
}
|
|
5196
|
+
for (const field of EDIT_REQUIRED_FIELDS[type]) {
|
|
5197
|
+
if (typeof payload[field] !== "string") {
|
|
5198
|
+
throw inputError("INVALID_EDIT_PAYLOAD", `BAD_INPUT: ${type}.${field} must be a string at index ${index}`, "Provide all required canonical edit fields before retrying");
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
});
|
|
5202
|
+
}
|
|
5058
5203
|
var { server, StdioServerTransport } = await createServerRuntime({
|
|
5059
5204
|
name: "hex-line-mcp",
|
|
5060
5205
|
version
|
|
@@ -5126,7 +5271,7 @@ server.registerTool("read_file", {
|
|
|
5126
5271
|
content: z2.string().optional(),
|
|
5127
5272
|
edit_ready: z2.boolean().optional(),
|
|
5128
5273
|
next_action: z2.string().optional(),
|
|
5129
|
-
|
|
5274
|
+
...ERROR_RESULT_FIELDS
|
|
5130
5275
|
}),
|
|
5131
5276
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
5132
5277
|
}, async (rawParams) => {
|
|
@@ -5190,7 +5335,7 @@ server.registerTool("edit_file", {
|
|
|
5190
5335
|
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.'),
|
|
5191
5336
|
allow_external: flexBool().describe("Allow editing a path outside the current project root. Use only when you intentionally target a temp or external file.")
|
|
5192
5337
|
}),
|
|
5193
|
-
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(),
|
|
5338
|
+
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(), warnings: z2.array(z2.object({ code: z2.string() }).passthrough()).optional(), ...ERROR_RESULT_FIELDS }),
|
|
5194
5339
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
|
|
5195
5340
|
}, async (rawParams) => {
|
|
5196
5341
|
const { file_path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy, allow_external } = rawParams ?? {};
|
|
@@ -5202,7 +5347,7 @@ server.registerTool("edit_file", {
|
|
|
5202
5347
|
} catch {
|
|
5203
5348
|
throw new Error('edits: invalid JSON. Expected: [{"set_line":{"anchor":"xx.N","new_text":"..."}}]');
|
|
5204
5349
|
}
|
|
5205
|
-
|
|
5350
|
+
validateEditPayload(parsed);
|
|
5206
5351
|
const content = editFile(p, parsed, {
|
|
5207
5352
|
dryRun: dry_run,
|
|
5208
5353
|
restoreIndent: restore_indent,
|
|
@@ -5222,7 +5367,7 @@ server.registerTool("write_file", {
|
|
|
5222
5367
|
content: z2.string().describe("File content"),
|
|
5223
5368
|
allow_external: flexBool().describe("Allow writing a path outside the current project root. Use only when you intentionally target a temp or external file.")
|
|
5224
5369
|
}),
|
|
5225
|
-
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), lines: z2.number().optional(),
|
|
5370
|
+
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), lines: z2.number().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5226
5371
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
5227
5372
|
}, async (rawParams) => {
|
|
5228
5373
|
const { file_path: p, content, allow_external } = rawParams ?? {};
|
|
@@ -5259,7 +5404,7 @@ server.registerTool("grep_search", {
|
|
|
5259
5404
|
edit_ready: flexBool().describe("Preserve hash/checksum search hunks in `content` mode. Default: false."),
|
|
5260
5405
|
allow_large_output: flexBool().describe("Bypass the default content-mode block/char caps when you intentionally need a larger payload.")
|
|
5261
5406
|
}),
|
|
5262
|
-
outputSchema: z2.object({ status: STATUS_ENUM, pattern: z2.string().optional(), content: z2.string().optional(),
|
|
5407
|
+
outputSchema: z2.object({ status: STATUS_ENUM, pattern: z2.string().optional(), content: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5263
5408
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
5264
5409
|
}, async (rawParams) => {
|
|
5265
5410
|
const {
|
|
@@ -5314,7 +5459,7 @@ server.registerTool("outline", {
|
|
|
5314
5459
|
inputSchema: z2.object({
|
|
5315
5460
|
file_path: z2.string().describe("Source file path")
|
|
5316
5461
|
}),
|
|
5317
|
-
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(),
|
|
5462
|
+
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5318
5463
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
5319
5464
|
}, async (rawParams) => {
|
|
5320
5465
|
const { file_path: p } = rawParams ?? {};
|
|
@@ -5333,7 +5478,7 @@ server.registerTool("verify", {
|
|
|
5333
5478
|
checksums: z2.array(z2.string()).describe('Checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
|
|
5334
5479
|
base_revision: z2.string().optional().describe("Optional prior revision to compare against latest state.")
|
|
5335
5480
|
}),
|
|
5336
|
-
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(),
|
|
5481
|
+
outputSchema: z2.object({ status: STATUS_ENUM, file_path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5337
5482
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
5338
5483
|
}, async (rawParams) => {
|
|
5339
5484
|
const { file_path: p, checksums, base_revision } = rawParams ?? {};
|
|
@@ -5360,7 +5505,7 @@ server.registerTool("inspect_path", {
|
|
|
5360
5505
|
format: z2.enum(["compact", "full"]).optional().describe('"compact" = shorter path view, "full" = include sizes/metadata where available'),
|
|
5361
5506
|
verbosity: z2.enum(["minimal", "compact", "full"]).optional().describe("Response budget. `minimal` returns the shortest tree summary.")
|
|
5362
5507
|
}),
|
|
5363
|
-
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(),
|
|
5508
|
+
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5364
5509
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
5365
5510
|
}, async (rawParams) => {
|
|
5366
5511
|
const { path: p, max_depth, max_entries, gitignore, format, pattern, type: entryType, verbosity } = rawParams ?? {};
|
|
@@ -5386,7 +5531,7 @@ server.registerTool("changes", {
|
|
|
5386
5531
|
path: z2.string().describe("File or directory path"),
|
|
5387
5532
|
compare_against: z2.string().optional().describe('Git ref to compare against (default: "HEAD")')
|
|
5388
5533
|
}),
|
|
5389
|
-
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(),
|
|
5534
|
+
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5390
5535
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
5391
5536
|
}, async (rawParams) => {
|
|
5392
5537
|
const { path: p, compare_against } = rawParams ?? {};
|
|
@@ -5409,7 +5554,7 @@ server.registerTool("bulk_replace", {
|
|
|
5409
5554
|
format: z2.enum(["compact", "full"]).optional().describe('"compact" (default) = summary only, "full" = include capped diffs'),
|
|
5410
5555
|
allow_external: flexBool().describe("Allow a replacement root outside the current project root. Use only when you intentionally target a temp or external directory.")
|
|
5411
5556
|
}),
|
|
5412
|
-
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(),
|
|
5557
|
+
outputSchema: z2.object({ status: STATUS_ENUM, path: z2.string().optional(), content: z2.string().optional(), reason: z2.string().optional(), ...ERROR_RESULT_FIELDS }),
|
|
5413
5558
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
5414
5559
|
}, async (rawParams) => {
|
|
5415
5560
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levnikolaevich/hex-line-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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.",
|