lazy-media-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.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # lazy-media-mcp
2
+
3
+ Local MCP server that **compresses images/videos** and **prepares media for AI vision agents**.
4
+
5
+ Designed for coding agents (Claude / Codex / Grok): returns **file paths only** (no inline base64).
6
+
7
+ ## Why this exists
8
+
9
+ Large screenshots and long demos burn context and often fail tool limits. This server:
10
+
11
+ 1. Shrinks images to a sensible size/quality
12
+ 2. Turns videos into **frame packs** agents can actually open
13
+ 3. Uses **JPEG by default** for widest agent compatibility
14
+
15
+ ### WebP / WebM — do they help AI “read better”?
16
+
17
+ | Format | Role | Default here? |
18
+ |--------|------|----------------|
19
+ | **JPEG** | Best universal image input for local agents | **Yes** |
20
+ | **PNG** | Sharper for OCR / UI text / alpha | `ocr_text` profile |
21
+ | **WebP** | Smaller files when the host supports it | **Opt-in only** |
22
+ | **MP4** | Storage/sharing re-encode | Video compress default |
23
+ | **WebM** | Optional container | Opt-in via `video_compress` |
24
+
25
+ **Format does not improve model understanding by itself.** Resolution, blur, and compression artifacts matter more. Over-aggressive WebP/JPEG hurts OCR.
26
+
27
+ Local agents usually **do not natively watch WebM/MP4**. Prefer `prepare_for_ai` / `video_extract_frames` → JPEG paths.
28
+
29
+ ## Requirements
30
+
31
+ - Node.js ≥ 20
32
+ - [ffmpeg](https://ffmpeg.org/) + ffprobe on `PATH` (video tools)
33
+
34
+ ```bash
35
+ # macOS
36
+ brew install ffmpeg
37
+ ```
38
+
39
+ ## Install / run
40
+
41
+ ```bash
42
+ cd lazy-media-mcp
43
+ npm install
44
+ npm run build
45
+ npm test
46
+ node dist/cli.js # stdio MCP
47
+ ```
48
+
49
+ ### MCP client config (example)
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "lazy-media": {
55
+ "command": "node",
56
+ "args": ["/absolute/path/to/lazy-media-mcp/dist/cli.js"],
57
+ "env": {
58
+ "MEDIA_ALLOWED_ROOTS": "/Users/you,/Users/you/WorkSpace",
59
+ "MEDIA_WORKDIR": "/Users/you/.cache/lazy-media-mcp/jobs"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Tools
67
+
68
+ | Tool | Purpose |
69
+ |------|---------|
70
+ | `media_inspect` | Metadata only |
71
+ | `image_compress` | Resize/compress image → workdir path |
72
+ | `video_compress` | Re-encode video (default MP4) |
73
+ | `video_extract_frames` | Extract frames for vision |
74
+ | `prepare_for_ai` | One-shot profile pipeline (**recommended**) |
75
+ | `media_cleanup` | Delete a job directory by `job_id` |
76
+
77
+ ### `prepare_for_ai` profiles
78
+
79
+ | Profile | Behavior |
80
+ |---------|----------|
81
+ | `ai_vision` (default) | Image → JPEG ≤1536 edge; video → up to 10 JPEG frames |
82
+ | `ocr_text` | Prefer PNG / higher quality |
83
+ | `inline_small` | Smaller edges, fewer frames |
84
+ | `archive` | Higher quality + optional compressed MP4 |
85
+
86
+ ## Environment
87
+
88
+ | Variable | Default |
89
+ |----------|---------|
90
+ | `MEDIA_ALLOWED_ROOTS` | `$HOME`, cwd, workdir |
91
+ | `MEDIA_WORKDIR` | `~/.cache/lazy-media-mcp/jobs` |
92
+ | `MEDIA_MAX_INPUT_BYTES` | 500MB |
93
+ | `MEDIA_MAX_OUTPUT_BYTES` | 200MB |
94
+ | `MEDIA_MAX_FRAMES` | 24 |
95
+ | `MEDIA_PROCESS_TIMEOUT_MS` | 120000 |
96
+ | `FFMPEG_BIN` / `FFPROBE_BIN` | `ffmpeg` / `ffprobe` |
97
+ | `LOG_LEVEL` | `info` |
98
+
99
+ ## Security
100
+
101
+ - Path allowlist (realpath checks)
102
+ - Input/output size caps
103
+ - Process timeout
104
+ - ffmpeg/ffprobe invoked with argv arrays only (no shell interpolation)
105
+ - Outputs go to workdir; originals are not overwritten
106
+ - Cleanup only deletes direct children of workdir by `job_id`
107
+
108
+ ## Typical agent flow
109
+
110
+ ```text
111
+ 1. prepare_for_ai({ path: "/path/to/demo.mp4", profile: "ai_vision" })
112
+ 2. Read returned outputs[].path frame files in the next vision step
113
+ 3. media_cleanup({ job_id }) when done (optional)
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/cli.js";
package/dist/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ import { startServer } from "./index.js";
2
+ function usage() {
3
+ return [
4
+ "Usage:",
5
+ " lazy-media-mcp Start MCP server over stdio",
6
+ " lazy-media-mcp --help Show this help",
7
+ "",
8
+ "Environment:",
9
+ " MEDIA_ALLOWED_ROOTS Comma-separated allowed path roots (default: $HOME,cwd,workdir)",
10
+ " MEDIA_WORKDIR Job output directory (default: ~/.cache/lazy-media-mcp/jobs)",
11
+ " MEDIA_MAX_INPUT_BYTES Max input file size (default: 500MB)",
12
+ " MEDIA_MAX_OUTPUT_BYTES Max single output file size (default: 200MB)",
13
+ " MEDIA_MAX_FRAMES Max frames per extract (default: 24)",
14
+ " MEDIA_PROCESS_TIMEOUT_MS ffmpeg/ffprobe timeout (default: 120000)",
15
+ " FFMPEG_BIN / FFPROBE_BIN Binary paths (default: ffmpeg / ffprobe)",
16
+ " LOG_LEVEL debug|info|warn|error"
17
+ ].join("\n");
18
+ }
19
+ async function main() {
20
+ const args = process.argv.slice(2);
21
+ const command = args[0] ?? "";
22
+ if (command === "--help" || command === "-h") {
23
+ process.stdout.write(`${usage()}\n`);
24
+ return;
25
+ }
26
+ if (command !== "" && command !== undefined) {
27
+ process.stderr.write(`Unknown command: ${command}\n`);
28
+ process.stderr.write("Run 'lazy-media-mcp --help' for usage.\n");
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ try {
33
+ await startServer();
34
+ }
35
+ catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ process.stderr.write(`${JSON.stringify({ level: "error", event: "fatal", error: message })}\n`);
38
+ process.exit(1);
39
+ }
40
+ }
41
+ void main();
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function parseCsv(input) {
5
+ if (!input) {
6
+ return [];
7
+ }
8
+ return input
9
+ .split(",")
10
+ .map((value) => value.trim())
11
+ .filter((value) => value.length > 0);
12
+ }
13
+ function parsePositiveInt(input, fallback) {
14
+ if (!input) {
15
+ return fallback;
16
+ }
17
+ const value = Number.parseInt(input, 10);
18
+ if (!Number.isFinite(value) || value <= 0) {
19
+ throw new Error(`Invalid positive integer: ${input}`);
20
+ }
21
+ return value;
22
+ }
23
+ export function loadConfig(env = process.env) {
24
+ const home = os.homedir();
25
+ const workdir = env.MEDIA_WORKDIR ?? path.join(home, ".cache", "lazy-media-mcp", "jobs");
26
+ const configuredRoots = parseCsv(env.MEDIA_ALLOWED_ROOTS).map((root) => path.resolve(root));
27
+ const defaultRoots = [home, process.cwd(), workdir].map((root) => path.resolve(root));
28
+ const allowedRoots = configuredRoots.length > 0 ? configuredRoots : defaultRoots;
29
+ const logLevel = env.LOG_LEVEL ?? "info";
30
+ if (!["debug", "info", "warn", "error"].includes(logLevel)) {
31
+ throw new Error(`Invalid LOG_LEVEL: ${logLevel}`);
32
+ }
33
+ return {
34
+ allowedRoots: dedupePaths([...allowedRoots, path.resolve(workdir)]),
35
+ workdir: path.resolve(workdir),
36
+ logLevel,
37
+ ffmpegBin: env.FFMPEG_BIN ?? "ffmpeg",
38
+ ffprobeBin: env.FFPROBE_BIN ?? "ffprobe",
39
+ maxInputBytes: parsePositiveInt(env.MEDIA_MAX_INPUT_BYTES, 500 * 1024 * 1024),
40
+ maxOutputBytes: parsePositiveInt(env.MEDIA_MAX_OUTPUT_BYTES, 200 * 1024 * 1024),
41
+ maxFrames: parsePositiveInt(env.MEDIA_MAX_FRAMES, 24),
42
+ processTimeoutMs: parsePositiveInt(env.MEDIA_PROCESS_TIMEOUT_MS, 120_000)
43
+ };
44
+ }
45
+ function dedupePaths(paths) {
46
+ const seen = new Set();
47
+ const result = [];
48
+ for (const entry of paths) {
49
+ const absolute = path.resolve(entry);
50
+ let normalized = absolute;
51
+ try {
52
+ if (fs.existsSync(absolute)) {
53
+ normalized = fs.realpathSync(absolute);
54
+ }
55
+ }
56
+ catch {
57
+ normalized = absolute;
58
+ }
59
+ if (seen.has(normalized)) {
60
+ continue;
61
+ }
62
+ seen.add(normalized);
63
+ result.push(normalized);
64
+ }
65
+ return result;
66
+ }
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../errors.js";
4
+ import { extensionForImageFormat, extensionForVideoContainer } from "../formats/profiles.js";
5
+ import { runCommand } from "./process.js";
6
+ export async function probeMedia(filePath, options) {
7
+ const result = await runCommand(options.ffprobeBin, [
8
+ "-v",
9
+ "quiet",
10
+ "-print_format",
11
+ "json",
12
+ "-show_format",
13
+ "-show_streams",
14
+ filePath
15
+ ], {
16
+ timeoutMs: options.timeoutMs,
17
+ traceId: options.traceId,
18
+ label: "ffprobe"
19
+ });
20
+ try {
21
+ return JSON.parse(result.stdout);
22
+ }
23
+ catch (error) {
24
+ throw new AppError({
25
+ message: "ffprobe returned invalid JSON",
26
+ code: "ERR_FFPROBE_PARSE",
27
+ category: "dependency",
28
+ traceId: options.traceId,
29
+ cause: error
30
+ });
31
+ }
32
+ }
33
+ export function toVideoMeta(filePath, probe) {
34
+ const streams = probe.streams ?? [];
35
+ const video = streams.find((s) => s.codec_type === "video");
36
+ const audio = streams.find((s) => s.codec_type === "audio");
37
+ const durationRaw = probe.format?.duration ?? video?.duration;
38
+ const sizeRaw = probe.format?.size;
39
+ return {
40
+ path: filePath,
41
+ bytes: sizeRaw ? Number.parseInt(sizeRaw, 10) : fs.statSync(filePath).size,
42
+ duration_sec: durationRaw ? Number.parseFloat(durationRaw) : null,
43
+ width: video?.width ?? null,
44
+ height: video?.height ?? null,
45
+ video_codec: video?.codec_name ?? null,
46
+ audio_codec: audio?.codec_name ?? null,
47
+ format_name: probe.format?.format_name ?? null,
48
+ has_audio: Boolean(audio),
49
+ has_video: Boolean(video)
50
+ };
51
+ }
52
+ export async function compressVideo(input) {
53
+ // spawn without shell: no backslash escaping needed for commas
54
+ const scaleFilter = `scale=-2:min(ih\\,${input.maxHeight})`;
55
+ const args = [
56
+ "-y",
57
+ "-i",
58
+ input.inputPath,
59
+ "-map_metadata",
60
+ "-1",
61
+ "-vf",
62
+ scaleFilter,
63
+ "-c:v",
64
+ input.container === "webm" ? "libvpx-vp9" : "libx264",
65
+ "-crf",
66
+ String(input.crf),
67
+ "-pix_fmt",
68
+ "yuv420p"
69
+ ];
70
+ if (input.container === "mp4") {
71
+ args.push("-preset", "medium", "-movflags", "+faststart");
72
+ }
73
+ else {
74
+ args.push("-b:v", "0");
75
+ }
76
+ args.push("-c:a", input.container === "webm" ? "libopus" : "aac", "-b:a", `${input.audioBitrateK}k`);
77
+ args.push(input.outputPath);
78
+ await fs.promises.mkdir(path.dirname(input.outputPath), { recursive: true });
79
+ await runCommand(input.ffmpegBin, args, {
80
+ timeoutMs: input.timeoutMs,
81
+ traceId: input.traceId,
82
+ label: "ffmpeg-compress"
83
+ });
84
+ const stat = await fs.promises.stat(input.outputPath);
85
+ return { path: input.outputPath, bytes: stat.size };
86
+ }
87
+ export async function extractFrames(input) {
88
+ await fs.promises.mkdir(input.outputDir, { recursive: true });
89
+ const ext = extensionForImageFormat(input.format);
90
+ const pattern = path.join(input.outputDir, `frame_%04d.${ext}`);
91
+ const fpsExpr = buildFpsExpression(input);
92
+ const vfParts = [`fps=${fpsExpr}`, `scale=min(${input.maxEdge}\\,iw):-2`];
93
+ const args = ["-y", "-i", input.inputPath, "-vf", vfParts.join(","), "-frames:v", String(input.maxFrames)];
94
+ if (input.format === "jpeg") {
95
+ args.push("-q:v", jpegQualityToFfmpeg(input.quality));
96
+ }
97
+ else if (input.format === "webp") {
98
+ args.push("-quality", String(input.quality));
99
+ }
100
+ args.push(pattern);
101
+ await runCommand(input.ffmpegBin, args, {
102
+ timeoutMs: input.timeoutMs,
103
+ traceId: input.traceId,
104
+ label: "ffmpeg-frames"
105
+ });
106
+ const files = (await fs.promises.readdir(input.outputDir))
107
+ .filter((name) => name.startsWith("frame_") && name.endsWith(`.${ext}`))
108
+ .sort()
109
+ .map((name) => path.join(input.outputDir, name));
110
+ if (files.length === 0) {
111
+ throw new AppError({
112
+ message: "No frames were extracted from the video",
113
+ code: "ERR_NO_FRAMES",
114
+ category: "business",
115
+ traceId: input.traceId
116
+ });
117
+ }
118
+ return files.slice(0, input.maxFrames);
119
+ }
120
+ function buildFpsExpression(input) {
121
+ if (input.mode === "interval") {
122
+ const interval = Math.max(0.2, input.intervalSec);
123
+ return `1/${interval}`;
124
+ }
125
+ const duration = input.durationSec && input.durationSec > 0 ? input.durationSec : null;
126
+ if (!duration) {
127
+ // Fallback: ~1 fps capped by maxFrames via -frames:v
128
+ return "1";
129
+ }
130
+ // Spread maxFrames across duration (avoid div by zero)
131
+ const fps = Math.max(input.maxFrames / duration, 0.05);
132
+ return String(fps);
133
+ }
134
+ /** Map 1-100 quality to ffmpeg mjpeg qscale (2 best .. 31 worst). */
135
+ function jpegQualityToFfmpeg(quality) {
136
+ const q = Math.min(100, Math.max(1, quality));
137
+ const scale = Math.round(2 + ((100 - q) / 100) * 29);
138
+ return String(scale);
139
+ }
140
+ export function defaultVideoOutputName(baseName, container) {
141
+ const stem = path.parse(baseName).name || "video";
142
+ return `${stem}.${extensionForVideoContainer(container)}`;
143
+ }
144
+ export function isLikelyVideoPath(filePath) {
145
+ const ext = path.extname(filePath).toLowerCase();
146
+ return [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mpeg", ".mpg", ".wmv", ".flv", ".3gp"].includes(ext);
147
+ }
@@ -0,0 +1,66 @@
1
+ import { spawn } from "node:child_process";
2
+ import { AppError } from "../errors.js";
3
+ export async function runCommand(bin, args, options) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(bin, args, {
6
+ stdio: ["ignore", "pipe", "pipe"]
7
+ });
8
+ let stdout = "";
9
+ let stderr = "";
10
+ let settled = false;
11
+ const timer = setTimeout(() => {
12
+ if (settled) {
13
+ return;
14
+ }
15
+ settled = true;
16
+ child.kill("SIGKILL");
17
+ reject(new AppError({
18
+ message: `${options.label} timed out after ${options.timeoutMs}ms`,
19
+ code: "ERR_PROCESS_TIMEOUT",
20
+ category: "dependency",
21
+ traceId: options.traceId
22
+ }));
23
+ }, options.timeoutMs);
24
+ child.stdout.on("data", (chunk) => {
25
+ stdout += chunk.toString("utf8");
26
+ });
27
+ child.stderr.on("data", (chunk) => {
28
+ stderr += chunk.toString("utf8");
29
+ });
30
+ child.on("error", (error) => {
31
+ if (settled) {
32
+ return;
33
+ }
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ const notFound = "code" in error && error.code === "ENOENT";
37
+ reject(new AppError({
38
+ message: notFound
39
+ ? `${options.label} binary not found: ${bin}. Install ffmpeg (brew install ffmpeg) or set FFMPEG_BIN/FFPROBE_BIN.`
40
+ : `${options.label} failed to start: ${error.message}`,
41
+ code: notFound ? "ERR_BINARY_NOT_FOUND" : "ERR_PROCESS_SPAWN",
42
+ category: "dependency",
43
+ traceId: options.traceId,
44
+ cause: error
45
+ }));
46
+ });
47
+ child.on("close", (code) => {
48
+ if (settled) {
49
+ return;
50
+ }
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ const exitCode = code ?? 1;
54
+ if (exitCode !== 0) {
55
+ reject(new AppError({
56
+ message: `${options.label} exited with code ${exitCode}: ${stderr.trim() || stdout.trim() || "no output"}`,
57
+ code: "ERR_PROCESS_FAILED",
58
+ category: "dependency",
59
+ traceId: options.traceId
60
+ }));
61
+ return;
62
+ }
63
+ resolve({ stdout, stderr, code: exitCode });
64
+ });
65
+ });
66
+ }
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import sharp from "sharp";
4
+ import { AppError } from "../errors.js";
5
+ import { clampQuality, extensionForImageFormat, resolveImageFormat } from "../formats/profiles.js";
6
+ export async function inspectImage(filePath, traceId) {
7
+ try {
8
+ const stat = fs.statSync(filePath);
9
+ const meta = await sharp(filePath).metadata();
10
+ return {
11
+ path: filePath,
12
+ bytes: stat.size,
13
+ width: meta.width ?? null,
14
+ height: meta.height ?? null,
15
+ format: meta.format ?? null,
16
+ hasAlpha: Boolean(meta.hasAlpha),
17
+ space: meta.space ?? null
18
+ };
19
+ }
20
+ catch (error) {
21
+ throw new AppError({
22
+ message: `Failed to inspect image: ${filePath}`,
23
+ code: "ERR_IMAGE_INSPECT",
24
+ category: "dependency",
25
+ traceId,
26
+ cause: error
27
+ });
28
+ }
29
+ }
30
+ export async function compressImage(input) {
31
+ const source = await inspectImage(input.inputPath, input.traceId);
32
+ const format = resolveImageFormat(input.format, {
33
+ hasAlpha: source.hasAlpha,
34
+ preferLossless: input.preferLossless
35
+ });
36
+ const quality = clampQuality(input.quality, input.qualityFloor);
37
+ try {
38
+ let pipeline = sharp(input.inputPath, { failOn: "none" }).rotate();
39
+ if (source.width && source.height) {
40
+ const longest = Math.max(source.width, source.height);
41
+ if (longest > input.maxEdge) {
42
+ pipeline = pipeline.resize({
43
+ width: source.width >= source.height ? input.maxEdge : undefined,
44
+ height: source.height > source.width ? input.maxEdge : undefined,
45
+ fit: "inside",
46
+ withoutEnlargement: true
47
+ });
48
+ }
49
+ }
50
+ // sharp strips most metadata by default; only re-attach when strip is false
51
+ if (!input.stripMetadata) {
52
+ pipeline = pipeline.withMetadata();
53
+ }
54
+ switch (format) {
55
+ case "jpeg":
56
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
57
+ break;
58
+ case "png":
59
+ pipeline = pipeline.png({ compressionLevel: 9 });
60
+ break;
61
+ case "webp":
62
+ pipeline = pipeline.webp({ quality });
63
+ break;
64
+ }
65
+ await fs.promises.mkdir(path.dirname(input.outputPath), { recursive: true });
66
+ const info = await pipeline.toFile(input.outputPath);
67
+ return {
68
+ path: input.outputPath,
69
+ bytes: info.size,
70
+ width: info.width,
71
+ height: info.height,
72
+ format,
73
+ input_bytes: source.bytes,
74
+ input_width: source.width,
75
+ input_height: source.height
76
+ };
77
+ }
78
+ catch (error) {
79
+ if (error instanceof AppError) {
80
+ throw error;
81
+ }
82
+ throw new AppError({
83
+ message: `Image compression failed: ${error instanceof Error ? error.message : "unknown"}`,
84
+ code: "ERR_IMAGE_COMPRESS",
85
+ category: "dependency",
86
+ traceId: input.traceId,
87
+ cause: error
88
+ });
89
+ }
90
+ }
91
+ export function defaultImageOutputName(baseName, format) {
92
+ const stem = path.parse(baseName).name || "image";
93
+ return `${stem}.${extensionForImageFormat(format)}`;
94
+ }
95
+ export function isLikelyImagePath(filePath) {
96
+ const ext = path.extname(filePath).toLowerCase();
97
+ return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff", ".heic", ".heif", ".avif", ".bmp"].includes(ext);
98
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,57 @@
1
+ import { ZodError } from "zod";
2
+ export class AppError extends Error {
3
+ code;
4
+ category;
5
+ traceId;
6
+ constructor(options) {
7
+ super(options.message, { cause: options.cause });
8
+ this.name = "AppError";
9
+ this.code = options.code;
10
+ this.category = options.category;
11
+ this.traceId = options.traceId;
12
+ }
13
+ }
14
+ export function toErrorResponse(error) {
15
+ return {
16
+ error: error.message,
17
+ code: error.code,
18
+ trace_id: error.traceId
19
+ };
20
+ }
21
+ export function normalizeError(error, traceId) {
22
+ if (error instanceof AppError) {
23
+ return error;
24
+ }
25
+ if (error instanceof ZodError) {
26
+ return new AppError({
27
+ message: summarizeZodError(error),
28
+ code: "ERR_VALIDATION",
29
+ category: "validation",
30
+ traceId,
31
+ cause: error
32
+ });
33
+ }
34
+ if (error instanceof Error) {
35
+ return new AppError({
36
+ message: error.message,
37
+ code: "ERR_INTERNAL",
38
+ category: "system",
39
+ traceId,
40
+ cause: error
41
+ });
42
+ }
43
+ return new AppError({
44
+ message: "Unknown server error",
45
+ code: "ERR_INTERNAL",
46
+ category: "system",
47
+ traceId
48
+ });
49
+ }
50
+ function summarizeZodError(error) {
51
+ const issue = error.issues[0];
52
+ if (!issue) {
53
+ return "Invalid tool arguments";
54
+ }
55
+ const path = issue.path.length > 0 ? issue.path.join(".") : "arguments";
56
+ return `Invalid tool arguments: ${path} ${issue.message}`;
57
+ }