har-mcp 0.1.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.
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Read HAR Tool
3
+ *
4
+ * Reads and analyzes HAR (HTTP Archive) files with multiple output modes
5
+ * and filtering capabilities. Returns LLM-friendly markdown output.
6
+ */
7
+
8
+ import * as fs from "node:fs/promises"
9
+ import * as path from "node:path"
10
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
11
+ import { z } from "zod"
12
+ import { HarFileSchema } from "../../types/har.ts"
13
+ import { getDetailedJsonError, repairTruncatedHar } from "./repair.ts"
14
+
15
+ // Re-export schema and types
16
+ export { readHarSchema, type ReadHarInput, type ReadHarInputRaw } from "./schema.ts"
17
+ import type { ReadHarInput } from "./schema.ts"
18
+
19
+ // Re-export helper functions
20
+ export {
21
+ formatBytes,
22
+ formatDuration,
23
+ matchUrl,
24
+ matchStatus,
25
+ getContentTypeCategory,
26
+ truncateUrl,
27
+ getUrlPath,
28
+ isError,
29
+ getResponseSize,
30
+ getTotalTiming,
31
+ identifyUrlPatterns,
32
+ getCodeFenceLanguage,
33
+ formatContentForDisplay,
34
+ } from "./helpers.ts"
35
+ import { getResponseSize } from "./helpers.ts"
36
+
37
+ // Re-export filter types and functions
38
+ export { type IndexedEntry, filterEntries, sortEntries } from "./filters.ts"
39
+ import { type IndexedEntry, filterEntries, sortEntries } from "./filters.ts"
40
+
41
+ // Re-export formatters
42
+ export {
43
+ formatSummary,
44
+ formatList,
45
+ formatEntryDetail,
46
+ formatEntryContent,
47
+ formatTimeline,
48
+ formatStats,
49
+ formatSizeAnalysis,
50
+ formatCookiesAnalysis,
51
+ } from "./formatters/index.ts"
52
+ import {
53
+ formatCookiesAnalysis,
54
+ formatEntryContent,
55
+ formatEntryDetail,
56
+ formatList,
57
+ formatSizeAnalysis,
58
+ formatStats,
59
+ formatSummary,
60
+ formatTimeline,
61
+ } from "./formatters/index.ts"
62
+
63
+ // ============================================================================
64
+ // Main Handler
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Handler function for the read_har tool.
69
+ */
70
+ export async function readHarHandler(input: ReadHarInput): Promise<string> {
71
+ // Read and parse the HAR file
72
+ const filePath = input.path
73
+ let fileContent: string
74
+
75
+ try {
76
+ fileContent = await fs.readFile(filePath, "utf-8")
77
+ } catch (err) {
78
+ const error = err as { code?: string; message: string }
79
+ if (error.code === "ENOENT") {
80
+ throw new Error(`HAR file not found: ${filePath}`)
81
+ }
82
+ if (error.code === "EACCES") {
83
+ throw new Error(`Permission denied reading HAR file: ${filePath}`)
84
+ }
85
+ throw new Error(`Failed to read HAR file: ${error.message}`)
86
+ }
87
+
88
+ let harData: unknown
89
+ let repairWarning = ""
90
+ try {
91
+ harData = JSON.parse(fileContent)
92
+ } catch (jsonError) {
93
+ // Try to repair truncated HAR files
94
+ const errorDetails = getDetailedJsonError(jsonError, fileContent)
95
+ const repairResult = repairTruncatedHar(fileContent, errorDetails)
96
+
97
+ if (repairResult.success && repairResult.repairedJson) {
98
+ harData = JSON.parse(repairResult.repairedJson)
99
+ repairWarning = `\n\n> **Warning**: HAR file was truncated. Recovered ${repairResult.entriesRecovered} entries using ${repairResult.repairMethod} repair method. Some entries may be missing.\n`
100
+ } else {
101
+ throw new Error(`Invalid JSON in HAR file: ${errorDetails}`)
102
+ }
103
+ }
104
+
105
+ // Validate HAR structure
106
+ const parseResult = HarFileSchema.safeParse(harData)
107
+ if (!parseResult.success) {
108
+ const issues = parseResult.error.issues
109
+ .slice(0, 3)
110
+ .map(i => i.message)
111
+ .join("; ")
112
+ throw new Error(`Invalid HAR file format: ${issues}`)
113
+ }
114
+
115
+ const harFile = parseResult.data
116
+ const filename = path.basename(filePath)
117
+
118
+ // Create indexed entries
119
+ let indexedEntries: IndexedEntry[] = harFile.log.entries.map((entry, index) => ({
120
+ index,
121
+ entry,
122
+ }))
123
+
124
+ // Apply filters
125
+ indexedEntries = filterEntries(indexedEntries, input.filter)
126
+
127
+ // Apply sorting
128
+ indexedEntries = sortEntries(indexedEntries, input.sort)
129
+
130
+ // Mode-specific handling
131
+ const mode = input.mode
132
+ const format = input.format ?? "markdown"
133
+
134
+ // Helper to add repair warning to output
135
+ const addWarning = (output: string): string => {
136
+ if (repairWarning && format === "markdown") {
137
+ return repairWarning + output
138
+ }
139
+ return output
140
+ }
141
+
142
+ if (mode === "summary") {
143
+ // Summary doesn't use pagination, operates on all entries
144
+ if (format === "json") {
145
+ const methodCounts: Record<string, number> = {}
146
+ const statusCounts: Record<string, number> = {}
147
+ for (const entry of harFile.log.entries) {
148
+ methodCounts[entry.request.method] = (methodCounts[entry.request.method] || 0) + 1
149
+ const statusClass = `${Math.floor(entry.response.status / 100)}xx`
150
+ statusCounts[statusClass] = (statusCounts[statusClass] || 0) + 1
151
+ }
152
+ const jsonData = {
153
+ file: filename,
154
+ mode: "summary",
155
+ version: harFile.log.version,
156
+ creator: harFile.log.creator,
157
+ totalEntries: harFile.log.entries.length,
158
+ pages: harFile.log.pages?.length || 0,
159
+ methods: methodCounts,
160
+ statuses: statusCounts,
161
+ }
162
+ return JSON.stringify(jsonData, null, 2)
163
+ }
164
+ return addWarning(formatSummary(harFile, filename))
165
+ }
166
+
167
+ if (mode === "list") {
168
+ // Apply pagination
169
+ const page = input.page
170
+ const pageSize = input.pageSize
171
+ const totalCount = indexedEntries.length
172
+ const start = (page - 1) * pageSize
173
+ const paginatedEntries = indexedEntries.slice(start, start + pageSize)
174
+
175
+ if (format === "json") {
176
+ const jsonData = {
177
+ file: filename,
178
+ mode: "list",
179
+ pagination: { page, pageSize, total: totalCount },
180
+ entries: paginatedEntries.map(({ index, entry }) => ({
181
+ index,
182
+ method: entry.request.method,
183
+ url: entry.request.url,
184
+ status: entry.response.status,
185
+ size: getResponseSize(entry),
186
+ time: entry.time,
187
+ })),
188
+ }
189
+ return JSON.stringify(jsonData, null, 2)
190
+ }
191
+
192
+ return addWarning(formatList(paginatedEntries, filename, page, pageSize, totalCount, input.filter))
193
+ }
194
+
195
+ if (mode === "detail") {
196
+ // Detail mode - show specific entries or paginated list
197
+ const include = {
198
+ headers: input.include?.headers ?? false,
199
+ cookies: input.include?.cookies ?? false,
200
+ timing: input.include?.timing ?? true,
201
+ queryParams: input.include?.queryParams ?? true,
202
+ }
203
+
204
+ if (input.entries && input.entries.length > 0) {
205
+ // Show specific entries
206
+ const results: string[] = []
207
+
208
+ for (const idx of input.entries) {
209
+ const found = indexedEntries.find(ie => ie.index === idx)
210
+ if (found) {
211
+ results.push(formatEntryDetail(found.index, found.entry, include))
212
+ } else {
213
+ results.push(`# Entry #${idx}\n\n> Entry not found or filtered out`)
214
+ }
215
+ }
216
+
217
+ return addWarning(results.join("\n\n---\n\n"))
218
+ }
219
+ // Paginate and show first entry on page
220
+ const page = input.page
221
+ const pageSize = input.pageSize
222
+ const start = (page - 1) * pageSize
223
+ const paginatedEntries = indexedEntries.slice(start, start + pageSize)
224
+
225
+ if (paginatedEntries.length === 0) {
226
+ return addWarning("# No entries found\n\nNo entries match the current filter criteria.")
227
+ }
228
+
229
+ const results: string[] = []
230
+ for (const ie of paginatedEntries) {
231
+ results.push(formatEntryDetail(ie.index, ie.entry, include))
232
+ }
233
+
234
+ const header = `**Showing entries ${start + 1}-${start + paginatedEntries.length} of ${indexedEntries.length}**\n\n`
235
+ return addWarning(header + results.join("\n\n---\n\n"))
236
+ }
237
+
238
+ if (mode === "content") {
239
+ // Content mode - requires entries parameter
240
+ if (!input.entries || input.entries.length === 0) {
241
+ throw new Error('Content mode requires the "entries" parameter with at least one entry index')
242
+ }
243
+
244
+ const options = {
245
+ includeRequestBody: input.contentOptions?.includeRequestBody ?? false,
246
+ includeResponseBody: input.contentOptions?.includeResponseBody ?? true,
247
+ maxContentLength: input.contentOptions?.maxContentLength ?? 50000,
248
+ }
249
+
250
+ const results: string[] = []
251
+
252
+ for (const idx of input.entries) {
253
+ const found = harFile.log.entries[idx]
254
+ if (found) {
255
+ results.push(formatEntryContent(idx, found, options))
256
+ } else {
257
+ results.push(`# Entry #${idx}\n\n> Entry index out of range (0-${harFile.log.entries.length - 1})`)
258
+ }
259
+ }
260
+
261
+ return addWarning(results.join("\n\n---\n\n"))
262
+ }
263
+
264
+ if (mode === "timeline") {
265
+ return addWarning(formatTimeline(harFile, filename, input.pageSize, input.page))
266
+ }
267
+
268
+ if (mode === "cookies") {
269
+ return addWarning(formatCookiesAnalysis(harFile, filename))
270
+ }
271
+
272
+ if (mode === "size") {
273
+ return addWarning(formatSizeAnalysis(harFile, filename))
274
+ }
275
+
276
+ if (mode === "stats") {
277
+ return addWarning(formatStats(harFile, filename))
278
+ }
279
+
280
+ throw new Error(`Unknown mode: ${mode}`)
281
+ }
282
+
283
+ // ============================================================================
284
+ // Tool Registration
285
+ // ============================================================================
286
+
287
+ /**
288
+ * Registers the read_har tool with the MCP server.
289
+ */
290
+ export function registerReadHarTool(server: McpServer): void {
291
+ server.tool(
292
+ "read_har",
293
+ "Reads and analyzes HAR (HTTP Archive) files. Supports multiple output modes: summary (overview stats), list (paginated entries), detail (full metadata), and content (with response bodies). Includes filtering, sorting, and pagination.",
294
+ {
295
+ path: z.string().describe("Path to the HAR file"),
296
+
297
+ mode: z
298
+ .enum(["summary", "list", "detail", "content", "timeline", "cookies", "size", "stats"])
299
+ .default("summary")
300
+ .describe(
301
+ "Output mode: summary (overview), list (entries), detail (full metadata), content (with body), timeline (waterfall visualization), cookies (cookie propagation tracking), size (payload size analysis), stats (aggregate by endpoint)",
302
+ ),
303
+
304
+ page: z.number().int().min(1).default(1).describe("Page number for pagination"),
305
+ pageSize: z.number().int().min(1).max(100).default(20).describe("Number of entries per page"),
306
+
307
+ entries: z
308
+ .array(z.number().int().min(0))
309
+ .max(10)
310
+ .optional()
311
+ .describe("Specific entry indexes to return (required for content mode)"),
312
+
313
+ filter: z
314
+ .object({
315
+ url: z.string().optional().describe("URL pattern (substring, glob with *, or ~regex)"),
316
+ method: z
317
+ .union([z.string(), z.array(z.string())])
318
+ .optional()
319
+ .describe("HTTP method(s) to filter"),
320
+ status: z
321
+ .union([
322
+ z.number(),
323
+ z.string().regex(/^\d{1}xx$/i),
324
+ z.object({ min: z.number().optional(), max: z.number().optional() }),
325
+ ])
326
+ .optional()
327
+ .describe("Status code, pattern (e.g., 4xx), or range"),
328
+ contentType: z.string().optional().describe("Response content type filter"),
329
+ minDuration: z.number().optional().describe("Minimum request duration in ms"),
330
+ hasError: z.boolean().optional().describe("Filter to only errors (4xx/5xx)"),
331
+ bodyContains: z.string().optional().describe("Filter entries whose response body contains this string"),
332
+ })
333
+ .optional()
334
+ .describe("Filter criteria for entries"),
335
+
336
+ include: z
337
+ .object({
338
+ headers: z.boolean().default(false).describe("Include headers in detail mode"),
339
+ cookies: z.boolean().default(false).describe("Include cookies in detail mode"),
340
+ timing: z.boolean().default(true).describe("Include timing breakdown in detail mode"),
341
+ queryParams: z.boolean().default(true).describe("Include query params in detail mode"),
342
+ })
343
+ .optional()
344
+ .describe("Options for detail mode"),
345
+
346
+ contentOptions: z
347
+ .object({
348
+ includeRequestBody: z.boolean().default(false).describe("Include request body in content mode"),
349
+ includeResponseBody: z.boolean().default(true).describe("Include response body in content mode"),
350
+ maxContentLength: z.number().default(50000).describe("Max content length before truncation"),
351
+ })
352
+ .optional()
353
+ .describe("Options for content mode"),
354
+
355
+ sort: z
356
+ .object({
357
+ by: z.enum(["index", "time", "duration", "size", "status"]).default("index").describe("Field to sort by"),
358
+ order: z.enum(["asc", "desc"]).default("asc").describe("Sort order"),
359
+ })
360
+ .optional()
361
+ .describe("Sorting options"),
362
+
363
+ format: z
364
+ .enum(["markdown", "json"])
365
+ .default("markdown")
366
+ .describe("Output format: markdown (default) or json for programmatic use"),
367
+ },
368
+ async args => {
369
+ try {
370
+ const result = await readHarHandler(args as ReadHarInput)
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text" as const,
375
+ text: result,
376
+ },
377
+ ],
378
+ }
379
+ } catch (err) {
380
+ const error = err as Error
381
+ return {
382
+ content: [
383
+ {
384
+ type: "text" as const,
385
+ text: `Error: ${error.message}`,
386
+ },
387
+ ],
388
+ isError: true,
389
+ }
390
+ }
391
+ },
392
+ )
393
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * HAR File Repair Utilities
3
+ *
4
+ * Handles truncated or malformed HAR files from browser exports.
5
+ * Chrome and other browsers sometimes export incomplete HAR files,
6
+ * especially with large response bodies (like base64-encoded content).
7
+ */
8
+
9
+ export interface RepairResult {
10
+ success: boolean
11
+ repairedJson?: string
12
+ entriesRecovered?: number
13
+ originalError: string
14
+ repairMethod?: string
15
+ }
16
+
17
+ /**
18
+ * Attempts to repair a truncated HAR JSON string.
19
+ *
20
+ * HAR files exported from Chrome can be truncated mid-content, especially
21
+ * when dealing with large base64-encoded response bodies. This function
22
+ * attempts to find the last complete entry and close the JSON structure.
23
+ *
24
+ * @param jsonStr - The potentially truncated JSON string
25
+ * @param originalError - The original JSON parse error message
26
+ * @returns RepairResult with success status and repaired JSON if successful
27
+ */
28
+ export function repairTruncatedHar(jsonStr: string, originalError: string): RepairResult {
29
+ // First, try to parse as-is (maybe it's valid)
30
+ try {
31
+ JSON.parse(jsonStr)
32
+ return { success: true, repairedJson: jsonStr, originalError }
33
+ } catch {
34
+ // Continue with repair attempts
35
+ }
36
+
37
+ // Strategy 1: Chrome-specific - find entries by _workerRespondWithSettled field
38
+ // Chrome DevTools HAR always ends timings with this field
39
+ const result1 = tryRepairByChromeTimings(jsonStr, originalError)
40
+ if (result1.success) {
41
+ return result1
42
+ }
43
+
44
+ // Strategy 2: Find the last complete entry by looking for "timings" object endings
45
+ // HAR entries end with a "timings" object, so we can find complete entries this way
46
+ const result2 = tryRepairByTimings(jsonStr, originalError)
47
+ if (result2.success) {
48
+ return result2
49
+ }
50
+
51
+ // Strategy 3: Find last complete entry boundary (},{ pattern between entries)
52
+ const result3 = tryRepairByEntryBoundary(jsonStr, originalError)
53
+ if (result3.success) {
54
+ return result3
55
+ }
56
+
57
+ // Strategy 4: Binary search for a valid truncation point
58
+ const result4 = tryRepairByBinarySearch(jsonStr, originalError)
59
+ if (result4.success) {
60
+ return result4
61
+ }
62
+
63
+ return {
64
+ success: false,
65
+ originalError,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Chrome DevTools specific repair - looks for _workerRespondWithSettled field.
71
+ * Chrome's HAR format always ends timings with this field.
72
+ */
73
+ function tryRepairByChromeTimings(jsonStr: string, originalError: string): RepairResult {
74
+ // Chrome's timings end with _workerRespondWithSettled, followed by } (timings) and } (entry)
75
+ const entryEndPattern = /"_workerRespondWithSettled"\s*:\s*[\d.-]+\s*\}\s*\}/g
76
+ const matches = [...jsonStr.matchAll(entryEndPattern)]
77
+
78
+ if (matches.length === 0) {
79
+ return { success: false, originalError }
80
+ }
81
+
82
+ // Try from the last match backwards
83
+ for (let i = matches.length - 1; i >= Math.max(0, matches.length - 20); i--) {
84
+ const match = matches[i]
85
+ if (!match || match.index === undefined) continue
86
+
87
+ const pos = match.index + match[0].length
88
+ // HAR structure: { log: { entries: [...] } }
89
+ // After entry }, we close: entries ], log }, root }
90
+ const testStr = `${jsonStr.substring(0, pos)}]}}`
91
+
92
+ try {
93
+ const parsed = JSON.parse(testStr)
94
+ return {
95
+ success: true,
96
+ repairedJson: testStr,
97
+ entriesRecovered: parsed.log?.entries?.length ?? 0,
98
+ originalError,
99
+ repairMethod: "chrome-timings",
100
+ }
101
+ } catch {
102
+ // Continue to next candidate
103
+ }
104
+ }
105
+
106
+ return { success: false, originalError }
107
+ }
108
+
109
+ /**
110
+ * Try to repair by finding the last complete "timings" object.
111
+ * Entries in HAR format end with a "timings" object containing timing data.
112
+ */
113
+ function tryRepairByTimings(jsonStr: string, originalError: string): RepairResult {
114
+ // Find all complete timings objects
115
+ // Pattern matches "timings":{...} where the inner content has no nested braces
116
+ const timingsPattern = /"timings"\s*:\s*\{[^{}]*\}/g
117
+ const matches = [...jsonStr.matchAll(timingsPattern)]
118
+
119
+ if (matches.length === 0) {
120
+ return { success: false, originalError }
121
+ }
122
+
123
+ // Try from the last match backwards
124
+ for (let i = matches.length - 1; i >= Math.max(0, matches.length - 20); i--) {
125
+ const match = matches[i]
126
+ if (!match || match.index === undefined) continue
127
+
128
+ let pos = match.index + match[0].length
129
+
130
+ // Skip whitespace after timings
131
+ while (pos < jsonStr.length && /\s/.test(jsonStr[pos] ?? "")) {
132
+ pos++
133
+ }
134
+
135
+ // After timings should come } to close the entry
136
+ if (jsonStr[pos] === "}") {
137
+ // Try closing the structure: entry } + entries ] + log } + root }
138
+ const testStr = `${jsonStr.substring(0, pos + 1)}]}}`
139
+ try {
140
+ const parsed = JSON.parse(testStr)
141
+ return {
142
+ success: true,
143
+ repairedJson: testStr,
144
+ entriesRecovered: parsed.log?.entries?.length ?? 0,
145
+ originalError,
146
+ repairMethod: "timings-boundary",
147
+ }
148
+ } catch {
149
+ // Continue to next candidate
150
+ }
151
+ }
152
+ }
153
+
154
+ return { success: false, originalError }
155
+ }
156
+
157
+ /**
158
+ * Try to repair by finding entry boundaries (the pattern between entries).
159
+ */
160
+ function tryRepairByEntryBoundary(jsonStr: string, originalError: string): RepairResult {
161
+ // Look for patterns that indicate entry boundaries
162
+ // Entries are separated by },{ or },"startedDateTime"
163
+ const boundaryPattern = /\}\s*,\s*\{/g
164
+ const boundaries: number[] = []
165
+
166
+ let match: RegExpExecArray | null = boundaryPattern.exec(jsonStr)
167
+ while (match !== null) {
168
+ // Position after the closing brace of the completed entry
169
+ boundaries.push(match.index + 1)
170
+ match = boundaryPattern.exec(jsonStr)
171
+ }
172
+
173
+ if (boundaries.length === 0) {
174
+ return { success: false, originalError }
175
+ }
176
+
177
+ // Try from the end backwards
178
+ for (let i = boundaries.length - 1; i >= Math.max(0, boundaries.length - 50); i--) {
179
+ const pos = boundaries[i]
180
+ if (pos === undefined) continue
181
+ const testStr = `${jsonStr.substring(0, pos)}]}}` // ] closes entries, }} closes log and root
182
+ try {
183
+ const parsed = JSON.parse(testStr)
184
+ return {
185
+ success: true,
186
+ repairedJson: testStr,
187
+ entriesRecovered: parsed.log?.entries?.length ?? 0,
188
+ originalError,
189
+ repairMethod: "entry-boundary",
190
+ }
191
+ } catch {
192
+ // Continue to next candidate
193
+ }
194
+ }
195
+
196
+ return { success: false, originalError }
197
+ }
198
+
199
+ /**
200
+ * Binary search for a valid truncation point.
201
+ * This is slower but more thorough.
202
+ */
203
+ function tryRepairByBinarySearch(jsonStr: string, originalError: string): RepairResult {
204
+ // Find all positions where we have a closing brace
205
+ const closingBraces: number[] = []
206
+ for (let i = 0; i < jsonStr.length; i++) {
207
+ if (jsonStr[i] === "}") {
208
+ closingBraces.push(i)
209
+ }
210
+ }
211
+
212
+ if (closingBraces.length === 0) {
213
+ return { success: false, originalError }
214
+ }
215
+
216
+ // Binary search for the rightmost valid truncation point
217
+ let lo = 0
218
+ let hi = closingBraces.length - 1
219
+ let lastValid: { pos: number; entries: number } | null = null
220
+
221
+ while (lo <= hi) {
222
+ const mid = Math.floor((lo + hi) / 2)
223
+ const pos = closingBraces[mid]
224
+ if (pos === undefined) {
225
+ hi = mid - 1
226
+ continue
227
+ }
228
+ const testStr = `${jsonStr.substring(0, pos + 1)}]}}`
229
+
230
+ try {
231
+ const parsed = JSON.parse(testStr)
232
+ lastValid = { pos, entries: parsed.log?.entries?.length ?? 0 }
233
+ lo = mid + 1 // Try to find a later valid point
234
+ } catch {
235
+ hi = mid - 1 // This point is invalid, try earlier
236
+ }
237
+ }
238
+
239
+ if (lastValid) {
240
+ return {
241
+ success: true,
242
+ repairedJson: `${jsonStr.substring(0, lastValid.pos + 1)}]}}`,
243
+ entriesRecovered: lastValid.entries,
244
+ originalError,
245
+ repairMethod: "binary-search",
246
+ }
247
+ }
248
+
249
+ return { success: false, originalError }
250
+ }
251
+
252
+ /**
253
+ * Extract a more descriptive error message from JSON parse errors.
254
+ */
255
+ export function getDetailedJsonError(error: unknown, fileContent: string): string {
256
+ const err = error as Error
257
+ const message = err.message || "Unknown error"
258
+
259
+ // Try to extract position information
260
+ const posMatch = message.match(/position (\d+)/i)
261
+ if (posMatch?.[1]) {
262
+ const pos = Number.parseInt(posMatch[1], 10)
263
+ const lines = fileContent.substring(0, pos).split("\n")
264
+ const line = lines.length
265
+ const lastLine = lines[lines.length - 1]
266
+ const column = lastLine ? lastLine.length : 0
267
+
268
+ // Get context around the error
269
+ const start = Math.max(0, pos - 50)
270
+ const end = Math.min(fileContent.length, pos + 50)
271
+ const context = fileContent.substring(start, end)
272
+
273
+ return `JSON parse error at line ${line}, column ${column}: ${message}\nContext: ...${context}...`
274
+ }
275
+
276
+ return `JSON parse error: ${message}`
277
+ }