@mixio-pro/kalaasetu-mcp 1.1.3 → 1.2.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/package.json +1 -1
- package/src/index.ts +10 -9
- package/src/test-context.ts +52 -0
- package/src/test-error-handling.ts +31 -0
- package/src/tools/fal/config.ts +95 -1
- package/src/tools/fal/generate.ts +48 -17
- package/src/tools/fal/index.ts +2 -2
- package/src/tools/fal/models.ts +73 -27
- package/src/tools/fal/storage.ts +62 -58
- package/src/tools/gemini.ts +263 -237
- package/src/tools/image-to-video.ts +199 -185
- package/src/tools/perplexity.ts +194 -154
- package/src/tools/youtube.ts +52 -33
- package/src/utils/tool-wrapper.ts +86 -0
package/src/tools/perplexity.ts
CHANGED
|
@@ -1,190 +1,230 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { safeToolExecute } from "../utils/tool-wrapper";
|
|
2
3
|
|
|
3
4
|
export const perplexityImages = {
|
|
4
5
|
name: "perplexityImages",
|
|
5
|
-
description:
|
|
6
|
+
description:
|
|
7
|
+
"Searches for images using the Perplexity API. Returns a formatted text response that includes a summary and a numbered list of image URLs with citations mapped to the text.",
|
|
6
8
|
parameters: z.object({
|
|
7
9
|
query: z.string().describe("The search query for images."),
|
|
8
|
-
image_domain_filter: z
|
|
9
|
-
|
|
10
|
+
image_domain_filter: z
|
|
11
|
+
.array(z.string())
|
|
12
|
+
.optional()
|
|
13
|
+
.describe(
|
|
14
|
+
"A list of domains to include or exclude. To exclude, prefix with '-'. E.g., ['wikimedia.org', '-gettyimages.com']."
|
|
15
|
+
),
|
|
16
|
+
image_format_filter: z
|
|
17
|
+
.array(z.string())
|
|
18
|
+
.optional()
|
|
19
|
+
.describe(
|
|
20
|
+
"A list of allowed image formats. E.g., ['jpg', 'png', 'gif']."
|
|
21
|
+
),
|
|
10
22
|
}),
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"accept": "application/json"
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const payload: any = {
|
|
25
|
-
model: "sonar",
|
|
26
|
-
messages: [
|
|
27
|
-
{ role: "user", content: `Show me images of ${args.query}` }
|
|
28
|
-
],
|
|
29
|
-
return_images: true
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
if (args.image_domain_filter) {
|
|
33
|
-
payload.image_domain_filter = args.image_domain_filter;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (args.image_format_filter) {
|
|
37
|
-
payload.image_format_filter = args.image_format_filter;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const res = await fetch(url, {
|
|
41
|
-
method: "POST",
|
|
42
|
-
headers: headers,
|
|
43
|
-
body: JSON.stringify(payload),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
if (!res.ok) {
|
|
47
|
-
const text = await res.text();
|
|
48
|
-
throw new Error(`Perplexity API request failed: ${res.status} ${text}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const data: any = await res.json();
|
|
52
|
-
let content = data.choices?.[0]?.message?.content;
|
|
53
|
-
const images = data.images;
|
|
54
|
-
const citations = data.citations;
|
|
55
|
-
|
|
56
|
-
if (!images || images.length === 0) {
|
|
57
|
-
return `No direct image URLs found in the API response. The text content was: ${content}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Create a map of origin_url -> new 1-based index
|
|
61
|
-
const originUrlToImageIndex: { [key: string]: number } = {};
|
|
62
|
-
images.forEach((img: any, index: number) => {
|
|
63
|
-
if (img.origin_url) {
|
|
64
|
-
originUrlToImageIndex[img.origin_url] = index + 1;
|
|
23
|
+
timeoutMs: 300000,
|
|
24
|
+
execute: async (args: {
|
|
25
|
+
query: string;
|
|
26
|
+
image_domain_filter?: string[];
|
|
27
|
+
image_format_filter?: string[];
|
|
28
|
+
}) => {
|
|
29
|
+
return safeToolExecute(async () => {
|
|
30
|
+
const apiKey = process.env.PERPLEXITY_API_KEY;
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
throw new Error("PERPLEXITY_API_KEY environment variable is not set.");
|
|
65
33
|
}
|
|
66
|
-
});
|
|
67
34
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
35
|
+
const url = "https://api.perplexity.ai/chat/completions";
|
|
36
|
+
const headers = {
|
|
37
|
+
Authorization: `Bearer ${apiKey}`,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
accept: "application/json",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const payload: any = {
|
|
43
|
+
model: "sonar",
|
|
44
|
+
messages: [
|
|
45
|
+
{ role: "user", content: `Show me images of ${args.query}` },
|
|
46
|
+
],
|
|
47
|
+
return_images: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (args.image_domain_filter) {
|
|
51
|
+
payload.image_domain_filter = args.image_domain_filter;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (args.image_format_filter) {
|
|
55
|
+
payload.image_format_filter = args.image_format_filter;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: headers,
|
|
61
|
+
body: JSON.stringify(payload),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
throw new Error(`Perplexity API request failed: ${res.status} ${text}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data: any = await res.json();
|
|
70
|
+
let content = data.choices?.[0]?.message?.content;
|
|
71
|
+
const images = data.images;
|
|
72
|
+
const citations = data.citations;
|
|
73
|
+
|
|
74
|
+
if (!images || images.length === 0) {
|
|
75
|
+
return `No direct image URLs found in the API response. The text content was: ${content}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Create a map of origin_url -> new 1-based index
|
|
79
|
+
const originUrlToImageIndex: { [key: string]: number } = {};
|
|
80
|
+
images.forEach((img: any, index: number) => {
|
|
81
|
+
if (img.origin_url) {
|
|
82
|
+
originUrlToImageIndex[img.origin_url] = index + 1;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Create a map of old citation index -> new image index
|
|
87
|
+
const oldToNewCitationMap: { [key: number]: number } = {};
|
|
88
|
+
if (citations && Array.isArray(citations)) {
|
|
71
89
|
citations.forEach((citationUrl: string, index: number) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
if (originUrlToImageIndex[citationUrl]) {
|
|
91
|
+
oldToNewCitationMap[index + 1] = originUrlToImageIndex[citationUrl];
|
|
92
|
+
}
|
|
75
93
|
});
|
|
76
|
-
|
|
94
|
+
}
|
|
77
95
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
content = content
|
|
96
|
+
// Replace citations in the content
|
|
97
|
+
if (content && typeof content === "string") {
|
|
98
|
+
content = content
|
|
99
|
+
.replace(/\[(\d+)\]/g, (match: string, oldIndexStr: string) => {
|
|
81
100
|
const oldIndex = parseInt(oldIndexStr, 10);
|
|
82
101
|
const newIndex = oldToNewCitationMap[oldIndex];
|
|
83
102
|
if (newIndex) {
|
|
84
|
-
|
|
103
|
+
return `[${newIndex}]`;
|
|
85
104
|
}
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
output
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
105
|
+
return ""; // Remove citation if it doesn't correspond to an image
|
|
106
|
+
})
|
|
107
|
+
.replace(/(\s\s+)/g, " ")
|
|
108
|
+
.trim(); // Clean up extra spaces
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Build the final formatted output
|
|
112
|
+
let output = content + "\n\n--- Images ---\n";
|
|
113
|
+
images.forEach((img: any, index: number) => {
|
|
114
|
+
output += `${index + 1}. ${img.image_url}\n (Source: ${
|
|
115
|
+
img.origin_url
|
|
116
|
+
})\n`;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return output;
|
|
120
|
+
}, "perplexityImages");
|
|
97
121
|
},
|
|
98
122
|
};
|
|
99
123
|
|
|
100
124
|
export const perplexityVideos = {
|
|
101
125
|
name: "perplexityVideos",
|
|
102
|
-
description:
|
|
126
|
+
description:
|
|
127
|
+
"Searches for videos using the Perplexity API. Returns a formatted text response that includes a summary and a numbered list of video URLs with citations mapped to the text.",
|
|
103
128
|
parameters: z.object({
|
|
104
129
|
query: z.string().describe("The search query for videos."),
|
|
105
|
-
search_domain_filter: z
|
|
130
|
+
search_domain_filter: z
|
|
131
|
+
.array(z.string())
|
|
132
|
+
.optional()
|
|
133
|
+
.describe(
|
|
134
|
+
"A list of domains to limit the search to (e.g., ['youtube.com']). Use a '-' prefix to exclude a domain."
|
|
135
|
+
),
|
|
106
136
|
}),
|
|
137
|
+
timeoutMs: 300000,
|
|
107
138
|
execute: async (args: { query: string; search_domain_filter?: string[] }) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const res = await fetch(url, {
|
|
133
|
-
method: "POST",
|
|
134
|
-
headers: headers,
|
|
135
|
-
body: JSON.stringify(payload),
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
if (!res.ok) {
|
|
139
|
-
const text = await res.text();
|
|
140
|
-
throw new Error(`Perplexity API request failed: ${res.status} ${text}`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const data: any = await res.json();
|
|
144
|
-
let content = data.choices?.[0]?.message?.content;
|
|
145
|
-
const videos = data.videos;
|
|
146
|
-
const citations = data.citations;
|
|
147
|
-
|
|
148
|
-
if (!videos || videos.length === 0) {
|
|
149
|
-
return `No direct video URLs found in the API response. Full API Response: ${JSON.stringify(data, null, 2)}`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Create a map of video url -> new 1-based index
|
|
153
|
-
const urlToVideoIndex: { [key: string]: number } = {};
|
|
154
|
-
videos.forEach((video: any, index: number) => {
|
|
155
|
-
if (video.url) {
|
|
156
|
-
urlToVideoIndex[video.url] = index + 1;
|
|
139
|
+
return safeToolExecute(async () => {
|
|
140
|
+
const apiKey = process.env.PERPLEXITY_API_KEY;
|
|
141
|
+
if (!apiKey) {
|
|
142
|
+
throw new Error("PERPLEXITY_API_KEY environment variable is not set.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const url = "https://api.perplexity.ai/chat/completions";
|
|
146
|
+
const headers = {
|
|
147
|
+
Authorization: `Bearer ${apiKey}`,
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
accept: "application/json",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const payload: any = {
|
|
153
|
+
model: "sonar-pro",
|
|
154
|
+
messages: [
|
|
155
|
+
{ role: "user", content: `Show me videos of ${args.query}` },
|
|
156
|
+
],
|
|
157
|
+
media_response: { overrides: { return_videos: true } },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (args.search_domain_filter) {
|
|
161
|
+
payload.search_domain_filter = args.search_domain_filter;
|
|
157
162
|
}
|
|
158
|
-
});
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
const res = await fetch(url, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: headers,
|
|
167
|
+
body: JSON.stringify(payload),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const text = await res.text();
|
|
172
|
+
throw new Error(`Perplexity API request failed: ${res.status} ${text}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data: any = await res.json();
|
|
176
|
+
let content = data.choices?.[0]?.message?.content;
|
|
177
|
+
const videos = data.videos;
|
|
178
|
+
const citations = data.citations;
|
|
179
|
+
|
|
180
|
+
if (!videos || videos.length === 0) {
|
|
181
|
+
return `No direct video URLs found in the API response. Full API Response: ${JSON.stringify(
|
|
182
|
+
data,
|
|
183
|
+
null,
|
|
184
|
+
2
|
|
185
|
+
)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Create a map of video url -> new 1-based index
|
|
189
|
+
const urlToVideoIndex: { [key: string]: number } = {};
|
|
190
|
+
videos.forEach((video: any, index: number) => {
|
|
191
|
+
if (video.url) {
|
|
192
|
+
urlToVideoIndex[video.url] = index + 1;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Create a map of old citation index -> new video index
|
|
197
|
+
const oldToNewCitationMap: { [key: number]: number } = {};
|
|
198
|
+
if (citations && Array.isArray(citations)) {
|
|
163
199
|
citations.forEach((citationUrl: string, index: number) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
if (urlToVideoIndex[citationUrl]) {
|
|
201
|
+
oldToNewCitationMap[index + 1] = urlToVideoIndex[citationUrl];
|
|
202
|
+
}
|
|
167
203
|
});
|
|
168
|
-
|
|
204
|
+
}
|
|
169
205
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
content = content
|
|
206
|
+
// Replace citations in the content
|
|
207
|
+
if (content && typeof content === "string") {
|
|
208
|
+
content = content
|
|
209
|
+
.replace(/\[(\d+)\]/g, (match: string, oldIndexStr: string) => {
|
|
173
210
|
const oldIndex = parseInt(oldIndexStr, 10);
|
|
174
211
|
const newIndex = oldToNewCitationMap[oldIndex];
|
|
175
212
|
if (newIndex) {
|
|
176
|
-
|
|
213
|
+
return `[${newIndex}]`;
|
|
177
214
|
}
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
output
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
215
|
+
return ""; // Remove citation if it doesn't correspond to a video
|
|
216
|
+
})
|
|
217
|
+
.replace(/(\s\s+)/g, " ")
|
|
218
|
+
.trim(); // Clean up extra spaces
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Build the final formatted output
|
|
222
|
+
let output = content + "\n\n--- Videos ---\n";
|
|
223
|
+
videos.forEach((video: any, index: number) => {
|
|
224
|
+
output += `${index + 1}. ${video.url}\n`;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return output;
|
|
228
|
+
}, "perplexityVideos");
|
|
189
229
|
},
|
|
190
230
|
};
|
package/src/tools/youtube.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { GoogleGenAI } from "@google/genai";
|
|
3
|
+
import { safeToolExecute } from "../utils/tool-wrapper";
|
|
3
4
|
|
|
4
5
|
const ai = new GoogleGenAI({
|
|
5
6
|
apiKey: process.env.GEMINI_API_KEY || "",
|
|
@@ -7,46 +8,64 @@ const ai = new GoogleGenAI({
|
|
|
7
8
|
|
|
8
9
|
export const analyzeYoutubeVideo = {
|
|
9
10
|
name: "analyzeYoutubeVideo",
|
|
10
|
-
description:
|
|
11
|
+
description:
|
|
12
|
+
"Analyze YouTube videos for content using the correct GenAI JS API approach with FileData fileUri. Perfect for extracting stock media content, analyzing video content, or getting descriptions of YouTube videos",
|
|
11
13
|
parameters: z.object({
|
|
12
|
-
youtube_url: z
|
|
13
|
-
|
|
14
|
+
youtube_url: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe(
|
|
17
|
+
"YouTube video URL to analyze (format: https://www.youtube.com/watch?v=VIDEO_ID)"
|
|
18
|
+
),
|
|
19
|
+
prompt: z
|
|
20
|
+
.string()
|
|
21
|
+
.describe("Analysis prompt or question about the YouTube video content"),
|
|
14
22
|
}),
|
|
23
|
+
timeoutMs: 300000,
|
|
15
24
|
execute: async (args: { youtube_url: string; prompt: string }) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
fileData: {
|
|
29
|
-
fileUri: args.youtube_url
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
{ text: args.prompt }
|
|
33
|
-
]
|
|
25
|
+
return safeToolExecute(async () => {
|
|
26
|
+
try {
|
|
27
|
+
// Validate YouTube URL format
|
|
28
|
+
if (
|
|
29
|
+
!args.youtube_url ||
|
|
30
|
+
(!args.youtube_url.includes("youtube.com/watch") &&
|
|
31
|
+
!args.youtube_url.includes("youtu.be"))
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Invalid YouTube URL format. Expected: https://www.youtube.com/watch?v=VIDEO_ID"
|
|
35
|
+
);
|
|
34
36
|
}
|
|
35
|
-
});
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// Create content using the correct FileData approach with fileUri
|
|
39
|
+
const response = await ai.models.generateContent({
|
|
40
|
+
model: "models/gemini-2.5-flash",
|
|
41
|
+
contents: {
|
|
42
|
+
parts: [
|
|
43
|
+
{
|
|
44
|
+
fileData: {
|
|
45
|
+
fileUri: args.youtube_url,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{ text: args.prompt },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let result = "";
|
|
54
|
+
if (response.candidates && response.candidates[0]?.content?.parts) {
|
|
55
|
+
for (const part of response.candidates[0].content.parts) {
|
|
56
|
+
if (part.text) {
|
|
57
|
+
result += part.text;
|
|
58
|
+
}
|
|
42
59
|
}
|
|
43
60
|
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
result ||
|
|
64
|
+
"YouTube video analysis completed but no text response received"
|
|
65
|
+
);
|
|
66
|
+
} catch (error: any) {
|
|
67
|
+
throw new Error(`YouTube video analysis failed: ${error.message}`);
|
|
44
68
|
}
|
|
45
|
-
|
|
46
|
-
return result || "YouTube video analysis completed but no text response received";
|
|
47
|
-
|
|
48
|
-
} catch (error: any) {
|
|
49
|
-
throw new Error(`YouTube video analysis failed: ${error.message}`);
|
|
50
|
-
}
|
|
69
|
+
}, "analyzeYoutubeVideo");
|
|
51
70
|
},
|
|
52
71
|
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standardized error result for MCP tools
|
|
5
|
+
*/
|
|
6
|
+
export interface ToolErrorResult {
|
|
7
|
+
isError: true;
|
|
8
|
+
content: Array<{
|
|
9
|
+
type: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Helper to check if a result is a ToolErrorResult
|
|
16
|
+
*/
|
|
17
|
+
export function isToolErrorResult(result: any): result is ToolErrorResult {
|
|
18
|
+
return (
|
|
19
|
+
typeof result === "object" &&
|
|
20
|
+
result !== null &&
|
|
21
|
+
result.isError === true &&
|
|
22
|
+
Array.isArray(result.content)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format an error into a user-friendly string and log secure details
|
|
28
|
+
*/
|
|
29
|
+
export function formatToolError(error: unknown, context?: string): string {
|
|
30
|
+
let errorMessage = error instanceof Error ? error.message : String(error);
|
|
31
|
+
|
|
32
|
+
// Enhanced Zod Error Handling
|
|
33
|
+
if (error instanceof z.ZodError) {
|
|
34
|
+
const issues = error.issues.map(
|
|
35
|
+
(issue) => `[${issue.path.join(".")}] ${issue.message}`
|
|
36
|
+
);
|
|
37
|
+
errorMessage = `Validation Error: ${issues.join("; ")}`;
|
|
38
|
+
} else {
|
|
39
|
+
// Enhanced API Error Handling (looking for common properties)
|
|
40
|
+
const errObj = error as any;
|
|
41
|
+
if (errObj?.status || errObj?.statusText) {
|
|
42
|
+
const status = errObj.status ? `[${errObj.status}]` : "";
|
|
43
|
+
const text = errObj.statusText || "";
|
|
44
|
+
// If the message doesn't already contain the status info, append it
|
|
45
|
+
if (!errorMessage.includes(String(errObj.status))) {
|
|
46
|
+
errorMessage = `API Error ${status} ${text}: ${errorMessage}`.trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Secure logging (never expose stack traces to the LLM, but log them internally)
|
|
52
|
+
console.error(
|
|
53
|
+
`[Tool Error] ${context ? `${context}: ` : ""}${errorMessage}`,
|
|
54
|
+
error
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Return sanitized message for the LLM
|
|
58
|
+
return `Tool execution failed${
|
|
59
|
+
context ? ` in ${context}` : ""
|
|
60
|
+
}: ${errorMessage}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Safely execute a tool function with standardized error handling
|
|
65
|
+
* @param fn The async tool execution function
|
|
66
|
+
* @param context Optional context name (e.g. tool name) for logging
|
|
67
|
+
*/
|
|
68
|
+
export async function safeToolExecute<T>(
|
|
69
|
+
fn: () => Promise<T>,
|
|
70
|
+
context?: string
|
|
71
|
+
): Promise<T | ToolErrorResult> {
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const errorText = formatToolError(error, context);
|
|
76
|
+
return {
|
|
77
|
+
isError: true,
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: errorText,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|