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,129 @@
1
+ /**
2
+ * Stats Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarFile } from "../../../types/har.ts"
6
+ import { formatDuration } from "../helpers.ts"
7
+
8
+ /**
9
+ * Formats stats mode output - aggregate statistics by endpoint
10
+ */
11
+ export function formatStats(harFile: HarFile, filename: string): string {
12
+ const entries = harFile.log.entries
13
+ const lines: string[] = []
14
+
15
+ // Group by endpoint pattern
16
+ const endpointStats = new Map<
17
+ string,
18
+ {
19
+ count: number
20
+ methods: Set<string>
21
+ durations: number[]
22
+ sizes: number[]
23
+ errors: number
24
+ statuses: Map<number, number>
25
+ }
26
+ >()
27
+
28
+ for (const entry of entries) {
29
+ try {
30
+ const url = new URL(entry.request.url)
31
+ // Create pattern by replacing IDs with *
32
+ const pathParts = url.pathname.split("/").filter(Boolean)
33
+ const patternParts = pathParts.map(part => {
34
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return "*"
35
+ if (/^\d+$/.test(part)) return "*"
36
+ if (/^[0-9a-f]{24,}$/i.test(part)) return "*"
37
+ return part
38
+ })
39
+ const pattern = `${url.host}/${patternParts.join("/")}`
40
+
41
+ const existing = endpointStats.get(pattern) || {
42
+ count: 0,
43
+ methods: new Set<string>(),
44
+ durations: [],
45
+ sizes: [],
46
+ errors: 0,
47
+ statuses: new Map<number, number>(),
48
+ }
49
+
50
+ existing.count++
51
+ existing.methods.add(entry.request.method)
52
+ existing.durations.push(entry.time)
53
+ existing.sizes.push(entry.response.content.size)
54
+ if (entry.response.status >= 400) existing.errors++
55
+ existing.statuses.set(entry.response.status, (existing.statuses.get(entry.response.status) || 0) + 1)
56
+
57
+ endpointStats.set(pattern, existing)
58
+ } catch {
59
+ // Skip invalid URLs
60
+ }
61
+ }
62
+
63
+ lines.push(`# HAR Statistics: ${filename}`)
64
+ lines.push("")
65
+ lines.push(`**Total Entries:** ${entries.length}`)
66
+ lines.push(`**Unique Endpoints:** ${endpointStats.size}`)
67
+ lines.push("")
68
+
69
+ // Sort by count descending
70
+ const sorted = Array.from(endpointStats.entries()).sort((a, b) => b[1].count - a[1].count)
71
+
72
+ lines.push("## Endpoint Statistics")
73
+ lines.push("")
74
+ lines.push("| Endpoint | Count | Methods | Avg Time | P95 Time | Error Rate |")
75
+ lines.push("|----------|-------|---------|----------|----------|------------|")
76
+
77
+ for (const [pattern, stats] of sorted) {
78
+ const avgTime = stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length
79
+ const sortedDurations = [...stats.durations].sort((a, b) => a - b)
80
+ const p95Idx = Math.floor(sortedDurations.length * 0.95)
81
+ const p95Time = sortedDurations[p95Idx] || sortedDurations[sortedDurations.length - 1] || 0
82
+ const errorRate = ((stats.errors / stats.count) * 100).toFixed(1)
83
+ const methods = Array.from(stats.methods).join(",")
84
+
85
+ const truncatedPattern = pattern.length > 40 ? `${pattern.slice(0, 37)}...` : pattern
86
+ lines.push(
87
+ `| ${truncatedPattern} | ${stats.count} | ${methods} | ${formatDuration(avgTime)} | ${formatDuration(p95Time)} | ${errorRate}% |`,
88
+ )
89
+ }
90
+
91
+ lines.push("")
92
+
93
+ // Top slowest endpoints
94
+ const slowest = sorted
95
+ .map(([pattern, stats]) => ({
96
+ pattern,
97
+ avg: stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length,
98
+ }))
99
+ .sort((a, b) => b.avg - a.avg)
100
+ .slice(0, 5)
101
+
102
+ if (slowest.length > 0) {
103
+ lines.push("## Slowest Endpoints (by avg)")
104
+ for (const { pattern, avg } of slowest) {
105
+ lines.push(`- ${pattern.slice(0, 50)}: ${formatDuration(avg)}`)
106
+ }
107
+ lines.push("")
108
+ }
109
+
110
+ // Highest error rate endpoints
111
+ const highestErrors = sorted
112
+ .filter(([, stats]) => stats.errors > 0)
113
+ .map(([pattern, stats]) => ({
114
+ pattern,
115
+ rate: (stats.errors / stats.count) * 100,
116
+ count: stats.errors,
117
+ }))
118
+ .sort((a, b) => b.rate - a.rate)
119
+ .slice(0, 5)
120
+
121
+ if (highestErrors.length > 0) {
122
+ lines.push("## Highest Error Rates")
123
+ for (const { pattern, rate, count } of highestErrors) {
124
+ lines.push(`- ${pattern.slice(0, 50)}: ${rate.toFixed(1)}% (${count} errors)`)
125
+ }
126
+ }
127
+
128
+ return lines.join("\n")
129
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Summary Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarFile } from "../../../types/har.ts"
6
+ import {
7
+ formatDuration,
8
+ getContentTypeCategory,
9
+ getUrlPath,
10
+ identifyUrlPatterns,
11
+ isError,
12
+ truncateUrl,
13
+ } from "../helpers.ts"
14
+
15
+ /**
16
+ * Formats summary mode output
17
+ */
18
+ export function formatSummary(harFile: HarFile, filename: string): string {
19
+ const log = harFile.log
20
+ const entries = log.entries
21
+
22
+ // Collect statistics
23
+ const methodCounts = new Map<string, number>()
24
+ const statusCounts = new Map<string, number>()
25
+ const typeCounts = new Map<string, number>()
26
+ const errorEntries: { status: number; url: string }[] = []
27
+ let totalDuration = 0
28
+ const durations: number[] = []
29
+ let slowestEntry: { index: number; url: string; duration: number } | null = null
30
+
31
+ for (let index = 0; index < entries.length; index++) {
32
+ const entry = entries[index]!
33
+
34
+ // Method counts
35
+ const method = entry.request.method
36
+ methodCounts.set(method, (methodCounts.get(method) || 0) + 1)
37
+
38
+ // Status counts (group by class)
39
+ const statusClass = `${Math.floor(entry.response.status / 100)}xx`
40
+ statusCounts.set(statusClass, (statusCounts.get(statusClass) || 0) + 1)
41
+
42
+ // Content type counts
43
+ const contentType = getContentTypeCategory(entry.response.content.mimeType)
44
+ typeCounts.set(contentType, (typeCounts.get(contentType) || 0) + 1)
45
+
46
+ // Errors
47
+ if (isError(entry)) {
48
+ errorEntries.push({ status: entry.response.status, url: getUrlPath(entry.request.url) })
49
+ }
50
+
51
+ // Duration stats
52
+ const duration = entry.time
53
+ totalDuration += duration
54
+ durations.push(duration)
55
+
56
+ if (!slowestEntry || duration > slowestEntry.duration) {
57
+ slowestEntry = { index, url: getUrlPath(entry.request.url), duration }
58
+ }
59
+ }
60
+
61
+ // Calculate percentiles
62
+ durations.sort((a, b) => a - b)
63
+ const avgDuration = entries.length > 0 ? totalDuration / entries.length : 0
64
+ const p95Index = Math.floor(durations.length * 0.95)
65
+ const p95Duration = durations[p95Index] || 0
66
+
67
+ // Group errors by status
68
+ const errorsByStatus = new Map<number, string[]>()
69
+ for (const error of errorEntries) {
70
+ const urls = errorsByStatus.get(error.status) || []
71
+ urls.push(error.url)
72
+ errorsByStatus.set(error.status, urls)
73
+ }
74
+
75
+ // Get URL patterns
76
+ const urlPatterns = identifyUrlPatterns(entries)
77
+ const topPatterns = Array.from(urlPatterns.entries())
78
+ .sort((a, b) => b[1].count - a[1].count)
79
+ .slice(0, 10)
80
+
81
+ // Build output
82
+ const lines: string[] = []
83
+
84
+ lines.push(`# HAR Summary: ${filename}`)
85
+ lines.push("")
86
+
87
+ // File Info
88
+ lines.push("## File Info")
89
+ lines.push(`- **Version:** ${log.version}`)
90
+ lines.push(`- **Creator:** ${log.creator.name} ${log.creator.version}`)
91
+ const firstEntry = entries[0]
92
+ if (firstEntry) {
93
+ lines.push(`- **Captured:** ${firstEntry.startedDateTime}`)
94
+ }
95
+ lines.push(`- **Total Entries:** ${entries.length}`)
96
+ lines.push("")
97
+
98
+ // Pages
99
+ if (log.pages && log.pages.length > 0) {
100
+ lines.push("## Pages")
101
+ lines.push("| # | Title | Load Time |")
102
+ lines.push("|---|-------|-----------|")
103
+ log.pages.forEach((page, i) => {
104
+ const loadTime = page.pageTimings.onLoad ? formatDuration(page.pageTimings.onLoad) : "-"
105
+ lines.push(`| ${i + 1} | ${page.title} | ${loadTime} |`)
106
+ })
107
+ lines.push("")
108
+ }
109
+
110
+ // Request Statistics
111
+ lines.push("## Request Statistics")
112
+
113
+ // Methods
114
+ const methodStr = Array.from(methodCounts.entries())
115
+ .sort((a, b) => b[1] - a[1])
116
+ .map(([m, c]) => `${m} (${c})`)
117
+ .join(", ")
118
+ lines.push(`- **By Method:** ${methodStr}`)
119
+
120
+ // Status
121
+ const statusStr = Array.from(statusCounts.entries())
122
+ .sort((a, b) => a[0].localeCompare(b[0]))
123
+ .map(([s, c]) => `${s} (${c})`)
124
+ .join(", ")
125
+ lines.push(`- **By Status:** ${statusStr}`)
126
+
127
+ // Types
128
+ const typeStr = Array.from(typeCounts.entries())
129
+ .sort((a, b) => b[1] - a[1])
130
+ .map(([t, c]) => `${t} (${c})`)
131
+ .join(", ")
132
+ lines.push(`- **By Type:** ${typeStr}`)
133
+ lines.push("")
134
+
135
+ // Errors
136
+ if (errorsByStatus.size > 0) {
137
+ lines.push(`## Errors (${errorEntries.length} total)`)
138
+ lines.push("| Status | Count | Example URLs |")
139
+ lines.push("|--------|-------|--------------|")
140
+ for (const [status, urls] of Array.from(errorsByStatus.entries()).sort((a, b) => a[0] - b[0])) {
141
+ const exampleUrls = urls
142
+ .slice(0, 3)
143
+ .map(u => truncateUrl(u, 30))
144
+ .join(", ")
145
+ lines.push(`| ${status} | ${urls.length} | ${exampleUrls} |`)
146
+ }
147
+ lines.push("")
148
+ }
149
+
150
+ // Performance
151
+ lines.push("## Performance")
152
+ if (slowestEntry) {
153
+ lines.push(
154
+ `- **Slowest:** #${slowestEntry.index} ${truncateUrl(slowestEntry.url, 40)} (${formatDuration(slowestEntry.duration)})`,
155
+ )
156
+ }
157
+ lines.push(`- **Average:** ${formatDuration(avgDuration)}`)
158
+ lines.push(`- **P95:** ${formatDuration(p95Duration)}`)
159
+ lines.push("")
160
+
161
+ // Top URL Patterns
162
+ if (topPatterns.length > 0) {
163
+ lines.push("## Top URL Patterns")
164
+ lines.push("| Pattern | Count | Methods |")
165
+ lines.push("|---------|-------|---------|")
166
+ for (const [pattern, data] of topPatterns) {
167
+ const methods = Array.from(data.methods).join(", ")
168
+ lines.push(`| ${truncateUrl(pattern, 40)} | ${data.count} | ${methods} |`)
169
+ }
170
+ lines.push("")
171
+ }
172
+
173
+ return lines.join("\n")
174
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Timeline Formatter for Read HAR Tool
3
+ */
4
+
5
+ import type { HarFile } from "../../../types/har.ts"
6
+ import { formatDuration } from "../helpers.ts"
7
+
8
+ /**
9
+ * Gets the bar character based on status code
10
+ */
11
+ function getStatusBar(status: number, length: number): string {
12
+ let char: string
13
+ if (status < 300) char = "█"
14
+ else if (status < 400) char = "▓"
15
+ else if (status < 500) char = "░"
16
+ else char = "▒"
17
+ return char.repeat(length)
18
+ }
19
+
20
+ /**
21
+ * Formats timeline mode output - text-based waterfall visualization
22
+ */
23
+ export function formatTimeline(harFile: HarFile, filename: string, pageSize = 20, page = 1): string {
24
+ const entries = harFile.log.entries
25
+ const lines: string[] = []
26
+
27
+ if (entries.length === 0) {
28
+ return "# Timeline\n\nNo entries to display."
29
+ }
30
+
31
+ // Get time range
32
+ const startTimes = entries.map(e => new Date(e.startedDateTime).getTime())
33
+ const minTime = Math.min(...startTimes)
34
+ const endTimes = entries.map((e, i) => startTimes[i]! + e.time)
35
+ const maxTime = Math.max(...endTimes)
36
+ const totalDuration = maxTime - minTime
37
+
38
+ // Pagination
39
+ const start = (page - 1) * pageSize
40
+ const pageEntries = entries.slice(start, start + pageSize)
41
+ const totalPages = Math.ceil(entries.length / pageSize)
42
+
43
+ lines.push(`# HAR Timeline: ${filename}`)
44
+ lines.push("")
45
+ lines.push(
46
+ `**Duration:** ${formatDuration(totalDuration)} | **Entries:** ${entries.length} | **Page:** ${page}/${totalPages}`,
47
+ )
48
+ lines.push("")
49
+
50
+ // Width for the bar visualization
51
+ const barWidth = 40
52
+
53
+ lines.push("```")
54
+ lines.push(`${"Time".padEnd(8)} ${"Method".padEnd(7)} ${"Bar".padEnd(barWidth + 2)} ${"Duration".padEnd(10)} URL`)
55
+ lines.push(`${"-".repeat(8)} ${"-".repeat(7)} ${"-".repeat(barWidth + 2)} ${"-".repeat(10)} ${"-".repeat(30)}`)
56
+
57
+ for (let i = 0; i < pageEntries.length; i++) {
58
+ const entry = pageEntries[i]!
59
+ const entryStart = new Date(entry.startedDateTime).getTime()
60
+ const relativeStart = entryStart - minTime
61
+ const duration = entry.time
62
+
63
+ // Calculate bar position and width
64
+ const barStart = Math.floor((relativeStart / totalDuration) * barWidth)
65
+ const barLength = Math.max(1, Math.ceil((duration / totalDuration) * barWidth))
66
+
67
+ // Create the bar
68
+ const bar = " ".repeat(barStart) + getStatusBar(entry.response.status, barLength)
69
+ const paddedBar = `[${bar.padEnd(barWidth)}]`
70
+
71
+ // Format time offset
72
+ const timeOffset = formatDuration(relativeStart).padEnd(8)
73
+
74
+ // Method and URL
75
+ const method = entry.request.method.padEnd(7)
76
+ const durationStr = formatDuration(duration).padEnd(10)
77
+
78
+ // Truncate URL
79
+ let url: string
80
+ try {
81
+ const parsed = new URL(entry.request.url)
82
+ url = parsed.pathname.slice(0, 30)
83
+ } catch {
84
+ url = entry.request.url.slice(0, 30)
85
+ }
86
+
87
+ lines.push(`${timeOffset} ${method} ${paddedBar} ${durationStr} ${url}`)
88
+ }
89
+
90
+ lines.push("```")
91
+ lines.push("")
92
+ lines.push("**Legend:** █ = 2xx, ▓ = 3xx, ░ = 4xx, ▒ = 5xx")
93
+
94
+ return lines.join("\n")
95
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Helper Functions for Read HAR Tool
3
+ */
4
+
5
+ import type { HarEntry, HarTimings } from "../../types/har.ts"
6
+
7
+ /**
8
+ * Formats bytes into human-readable string
9
+ */
10
+ export function formatBytes(bytes: number): string {
11
+ if (bytes < 0) return "-"
12
+ if (bytes === 0) return "0B"
13
+ if (bytes < 1024) return `${bytes}B`
14
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
15
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
16
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
17
+ }
18
+
19
+ /**
20
+ * Formats duration in milliseconds to human-readable string
21
+ */
22
+ export function formatDuration(ms: number): string {
23
+ if (ms < 0) return "-"
24
+ if (ms < 1) return "<1ms"
25
+ if (ms < 1000) return `${Math.round(ms)}ms`
26
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
27
+ return `${(ms / 60000).toFixed(1)}min`
28
+ }
29
+
30
+ /**
31
+ * Matches URL against a pattern (substring, glob, or regex)
32
+ */
33
+ export function matchUrl(url: string, pattern: string): boolean {
34
+ // Regex pattern (starts with ~)
35
+ if (pattern.startsWith("~")) {
36
+ try {
37
+ const regex = new RegExp(pattern.slice(1), "i")
38
+ return regex.test(url)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ // Glob pattern (contains *)
45
+ if (pattern.includes("*")) {
46
+ const regexPattern = pattern
47
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape special chars except *
48
+ .replace(/\*/g, ".*") // Convert * to .*
49
+ try {
50
+ const regex = new RegExp(`^${regexPattern}$`, "i")
51
+ return regex.test(url)
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ // Substring match (case-insensitive)
58
+ return url.toLowerCase().includes(pattern.toLowerCase())
59
+ }
60
+
61
+ /**
62
+ * Matches status code against filter
63
+ */
64
+ export function matchStatus(status: number, filter: number | string | { min?: number; max?: number }): boolean {
65
+ if (typeof filter === "number") {
66
+ return status === filter
67
+ }
68
+
69
+ if (typeof filter === "string") {
70
+ // Pattern like "2xx", "4xx", "5xx"
71
+ const match = filter.match(/^(\d)xx$/i)
72
+ if (match?.[1]) {
73
+ const firstDigit = Number.parseInt(match[1], 10)
74
+ return Math.floor(status / 100) === firstDigit
75
+ }
76
+ return false
77
+ }
78
+
79
+ // Range filter
80
+ const { min, max } = filter
81
+ if (min !== undefined && status < min) return false
82
+ if (max !== undefined && status > max) return false
83
+ return true
84
+ }
85
+
86
+ /**
87
+ * Gets content type category from MIME type
88
+ */
89
+ export function getContentTypeCategory(mimeType: string): string {
90
+ const type = mimeType.toLowerCase()
91
+ if (type.includes("json")) return "JSON"
92
+ if (type.includes("html")) return "HTML"
93
+ if (type.includes("javascript") || type.includes("ecmascript")) return "JS"
94
+ if (type.includes("css")) return "CSS"
95
+ if (type.includes("xml")) return "XML"
96
+ if (type.includes("image")) return "Image"
97
+ if (type.includes("font")) return "Font"
98
+ if (type.includes("text/plain")) return "Text"
99
+ if (type.includes("form")) return "Form"
100
+ if (type.includes("video")) return "Video"
101
+ if (type.includes("audio")) return "Audio"
102
+ return "Other"
103
+ }
104
+
105
+ /**
106
+ * Truncates URL for display - shows domain + last path segment
107
+ */
108
+ export function truncateUrl(url: string, maxLength = 60): string {
109
+ if (url.length <= maxLength) return url
110
+ try {
111
+ const parsed = new URL(url)
112
+ const pathParts = parsed.pathname.split("/").filter(Boolean)
113
+ const lastPart = pathParts[pathParts.length - 1] || ""
114
+ const domain = parsed.hostname
115
+
116
+ // Try: domain + /.../ + lastPart
117
+ const shortened = `${domain}/.../${lastPart}`
118
+ if (shortened.length <= maxLength) return shortened
119
+
120
+ // If still too long, truncate the last part
121
+ const availableForPath = maxLength - domain.length - 5 // 5 for "/.../"
122
+ if (availableForPath > 10) {
123
+ return `${domain}/.../${lastPart.slice(0, availableForPath)}`
124
+ }
125
+
126
+ // Fallback: just truncate
127
+ return `${url.slice(0, maxLength - 3)}...`
128
+ } catch {
129
+ return `${url.slice(0, maxLength - 3)}...`
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Gets the path portion of a URL for display
135
+ */
136
+ export function getUrlPath(url: string): string {
137
+ try {
138
+ const parsed = new URL(url)
139
+ return parsed.pathname + parsed.search
140
+ } catch {
141
+ return url
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Checks if an entry is an error (4xx or 5xx status)
147
+ */
148
+ export function isError(entry: HarEntry): boolean {
149
+ return entry.response.status >= 400
150
+ }
151
+
152
+ /**
153
+ * Gets response size from entry
154
+ */
155
+ export function getResponseSize(entry: HarEntry): number {
156
+ const bodySize = entry.response.bodySize
157
+ if (bodySize >= 0) return bodySize
158
+ return entry.response.content.size
159
+ }
160
+
161
+ /**
162
+ * Calculates total timing from timings object
163
+ */
164
+ export function getTotalTiming(timings: HarTimings): number {
165
+ let total = 0
166
+ if (timings.blocked && timings.blocked > 0) total += timings.blocked
167
+ if (timings.dns && timings.dns > 0) total += timings.dns
168
+ if (timings.connect && timings.connect > 0) total += timings.connect
169
+ if (timings.ssl && timings.ssl > 0) total += timings.ssl
170
+ total += timings.send > 0 ? timings.send : 0
171
+ total += timings.wait > 0 ? timings.wait : 0
172
+ total += timings.receive > 0 ? timings.receive : 0
173
+ return total
174
+ }
175
+
176
+ /**
177
+ * Identifies URL patterns in entries
178
+ */
179
+ export function identifyUrlPatterns(entries: HarEntry[]): Map<string, { count: number; methods: Set<string> }> {
180
+ const patterns = new Map<string, { count: number; methods: Set<string> }>()
181
+
182
+ for (const entry of entries) {
183
+ try {
184
+ const url = new URL(entry.request.url)
185
+ // Create pattern by generalizing path segments
186
+ const pathParts = url.pathname.split("/").filter(Boolean)
187
+ const patternParts = pathParts.map(part => {
188
+ // Replace UUIDs, numbers, and hex strings with wildcards
189
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return "*"
190
+ if (/^\d+$/.test(part)) return "*"
191
+ if (/^[0-9a-f]{24,}$/i.test(part)) return "*"
192
+ return part
193
+ })
194
+ const pattern = `/${patternParts.join("/")}${url.pathname.endsWith("/") ? "/" : ""}`
195
+
196
+ const existing = patterns.get(pattern)
197
+ if (existing) {
198
+ existing.count++
199
+ existing.methods.add(entry.request.method)
200
+ } else {
201
+ patterns.set(pattern, { count: 1, methods: new Set([entry.request.method]) })
202
+ }
203
+ } catch {
204
+ // Skip invalid URLs
205
+ }
206
+ }
207
+
208
+ return patterns
209
+ }
210
+
211
+ /**
212
+ * Gets appropriate code fence language for MIME type
213
+ */
214
+ export function getCodeFenceLanguage(mimeType: string): string {
215
+ const type = mimeType.toLowerCase()
216
+ if (type.includes("json")) return "json"
217
+ if (type.includes("html")) return "html"
218
+ if (type.includes("xml")) return "xml"
219
+ if (type.includes("javascript")) return "javascript"
220
+ if (type.includes("css")) return "css"
221
+ return ""
222
+ }
223
+
224
+ /**
225
+ * Formats content for display (pretty-print JSON if applicable)
226
+ */
227
+ export function formatContentForDisplay(content: string, mimeType: string): string {
228
+ if (mimeType.toLowerCase().includes("json")) {
229
+ try {
230
+ const parsed = JSON.parse(content)
231
+ return JSON.stringify(parsed, null, 2)
232
+ } catch {
233
+ return content
234
+ }
235
+ }
236
+ return content
237
+ }