@levnikolaevich/hex-line-mcp 1.3.1 → 1.3.3

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/server.mjs CHANGED
@@ -13,20 +13,16 @@ import { dirname } from "node:path";
13
13
  import { createRequire } from "node:module";
14
14
  const { version } = createRequire(import.meta.url)("./package.json");
15
15
  import { z } from "zod";
16
+ import { createServerRuntime } from "@levnikolaevich/hex-common/runtime/mcp-bootstrap";
17
+ import { flexBool, flexNum } from "@levnikolaevich/hex-common/runtime/schema";
18
+ import { coerceParams } from "@levnikolaevich/hex-common/runtime/coerce";
19
+ import { checkForUpdates } from "@levnikolaevich/hex-common/runtime/update-check";
16
20
  // LLM clients may send booleans as strings ("true"/"false").
17
21
  // z.coerce.boolean() is unsafe: Boolean("false") === true.
18
- const flexBool = () => z.preprocess(
19
- v => typeof v === "string" ? v === "true" : v,
20
- z.boolean().optional()
21
- ).optional();
22
22
  // LLM clients may send numbers as strings ("5" instead of 5).
23
23
  // z.coerce.number() generates {"type":"number"} → strict MCP clients reject strings.
24
24
  // flexNum generates schema accepting both, coerces at runtime.
25
25
  // Outer .optional() ensures JSON Schema marks field as not-required.
26
- const flexNum = () => z.preprocess(
27
- v => typeof v === "string" ? Number(v) : v,
28
- z.number().optional()
29
- ).optional();
30
26
 
31
27
  import { readFile } from "./lib/read.mjs";
32
28
  import { editFile } from "./lib/edit.mjs";
@@ -39,24 +35,12 @@ import { fileInfo } from "./lib/info.mjs";
39
35
  import { setupHooks } from "./lib/setup.mjs";
40
36
  import { fileChanges } from "./lib/changes.mjs";
41
37
  import { bulkReplace } from "./lib/bulk-replace.mjs";
42
- import { coerceParams } from "./lib/coerce.mjs";
43
- import { checkForUpdates } from "./lib/update-check.mjs";
44
38
 
45
- // --- SDK ---
46
-
47
- let McpServer, StdioServerTransport;
48
- try {
49
- ({ McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js"));
50
- ({ StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js"));
51
- } catch {
52
- process.stderr.write(
53
- "hex-line-mcp: @modelcontextprotocol/sdk not found.\n" +
54
- "Run: cd mcp/hex-line-mcp && npm install\n"
55
- );
56
- process.exit(1);
57
- }
58
-
59
- const server = new McpServer({ name: "hex-line-mcp", version });
39
+ const { server, StdioServerTransport } = await createServerRuntime({
40
+ name: "hex-line-mcp",
41
+ version,
42
+ installDir: "mcp/hex-line-mcp",
43
+ });
60
44
 
61
45
 
62
46
  // ==================== read_file ====================
@@ -64,10 +48,8 @@ const server = new McpServer({ name: "hex-line-mcp", version });
64
48
  server.registerTool("read_file", {
65
49
  title: "Read File",
66
50
  description:
67
- "Read a file with FNV-1a hash-annotated lines and range checksums. " +
68
- "Directory listing if path is a directory. " +
69
- "For files >100 lines: ALWAYS use outline first, then read_file with offset/limit for specific sections. " +
70
- "Reading a 500+ line file in full wastes 75% of context tokens.",
51
+ "Read a file with hash-annotated lines, range checksums, and current revision. " +
52
+ "Use offset/limit for targeted reads; use outline first for large code files.",
71
53
  inputSchema: z.object({
72
54
  path: z.string().optional().describe("File or directory path"),
73
55
  paths: z.array(z.string()).optional().describe("Array of file paths to read (batch mode)"),
@@ -103,30 +85,40 @@ server.registerTool("read_file", {
103
85
  server.registerTool("edit_file", {
104
86
  title: "Edit File",
105
87
  description:
106
- "Edit a file using hash-verified anchors or text replacement. Returns diff + post-edit checksums. " +
107
- "Batch multiple edits in ONE call for atomicity (bottom-to-top auto-sorted). " +
108
- "set_line: single line (from grep/read). replace_lines: range with checksum. " +
109
- "replace: unique text match, or all:true for rename. insert_after: add below anchor.",
88
+ "Apply revision-aware partial edits to one file. " +
89
+ "Prefer one batched call per file. Supports set_line, replace_lines, insert_after, and replace_between. " +
90
+ "For text rename/refactor use bulk_replace.",
110
91
  inputSchema: z.object({
111
92
  path: z.string().describe("File to edit"),
112
93
  edits: z.string().describe(
113
94
  'JSON array. Examples:\n' +
114
95
  '{"set_line":{"anchor":"ab.12","new_text":"new"}} — replace line\n' +
115
96
  '{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"...","range_checksum":"10-15:a1b2c3d4"}} — range\n' +
116
- '{"insert_after":{"anchor":"ab.20","text":"inserted"}} — insert below\n' +
117
- '{"replace":{"old_text":"find","new_text":"replace"}} — unique match\n' +
118
- '{"replace":{"old_text":"find","new_text":"replace","all":true}} — replace all',
97
+ '{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"...","boundary_mode":"inclusive"}} — block rewrite\n' +
98
+ '{"insert_after":{"anchor":"ab.20","text":"inserted"}} — insert below. For text rename use bulk_replace tool.',
119
99
  ),
120
100
  dry_run: flexBool().describe("Preview changes without writing"),
121
101
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
102
+ base_revision: z.string().optional().describe("Prior revision from read_file/edit_file. Enables conservative auto-rebase for same-file follow-up edits."),
103
+ conflict_policy: z.enum(["strict", "conservative"]).optional().describe('Conflict handling (default: "conservative"). "conservative" returns structured CONFLICT output for stale edits instead of forcing reread.'),
122
104
  }),
123
105
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
124
106
  }, async (rawParams) => {
125
- const { path: p, edits: json, dry_run, restore_indent } = coerceParams(rawParams);
107
+ const { path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy } = coerceParams(rawParams);
126
108
  try {
127
109
  const parsed = JSON.parse(json);
128
110
  if (!Array.isArray(parsed) || !parsed.length) throw new Error("Edits: non-empty JSON array required");
129
- return { content: [{ type: "text", text: editFile(p, parsed, { dryRun: dry_run, restoreIndent: restore_indent }) }] };
111
+ return {
112
+ content: [{
113
+ type: "text",
114
+ text: editFile(p, parsed, {
115
+ dryRun: dry_run,
116
+ restoreIndent: restore_indent,
117
+ baseRevision: base_revision,
118
+ conflictPolicy: conflict_policy,
119
+ }),
120
+ }],
121
+ };
130
122
  } catch (e) {
131
123
  return { content: [{ type: "text", text: e.message }], isError: true };
132
124
  }
@@ -229,19 +221,19 @@ server.registerTool("outline", {
229
221
  server.registerTool("verify", {
230
222
  title: "Verify Checksums",
231
223
  description:
232
- "Check if range checksums from prior reads are still valid. " +
233
- "Single-line response when nothing changed. Use to check file staleness without re-reading.",
224
+ "Check whether held checksums and optional base_revision are still current, without rereading the file.",
234
225
  inputSchema: z.object({
235
226
  path: z.string().describe("File path"),
236
227
  checksums: z.string().describe('JSON array of checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
228
+ base_revision: z.string().optional().describe("Optional prior revision to compare against latest state."),
237
229
  }),
238
230
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
239
231
  }, async (rawParams) => {
240
- const { path: p, checksums } = coerceParams(rawParams);
232
+ const { path: p, checksums, base_revision } = coerceParams(rawParams);
241
233
  try {
242
234
  const parsed = JSON.parse(checksums);
243
235
  if (!Array.isArray(parsed)) throw new Error("checksums must be a JSON array of strings");
244
- return { content: [{ type: "text", text: verifyChecksums(p, parsed) }] };
236
+ return { content: [{ type: "text", text: verifyChecksums(p, parsed, { baseRevision: base_revision }) }] };
245
237
  } catch (e) {
246
238
  return { content: [{ type: "text", text: e.message }], isError: true };
247
239
  }
@@ -349,7 +341,7 @@ server.registerTool("bulk_replace", {
349
341
  title: "Bulk Replace",
350
342
  description:
351
343
  "Search-and-replace across multiple files. Finds files by glob, applies ordered text replacements, returns per-file diffs. " +
352
- "Use dry_run:true to preview. For single-file edits use edit_file instead.",
344
+ "Use dry_run:true to preview. For single-file rename, set glob to the filename.",
353
345
  inputSchema: z.object({
354
346
  replacements: z.string().describe('JSON array of {old, new} pairs: [{"old":"foo","new":"bar"}]'),
355
347
  glob: z.string().optional().describe('File glob (default: "**/*.{md,mjs,json,yml,ts,js}")'),