@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.
@@ -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: "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
+ 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.array(z.string()).optional().describe("A list of domains to include or exclude. To exclude, prefix with '-'. E.g., ['wikimedia.org', '-gettyimages.com']."),
9
- image_format_filter: z.array(z.string()).optional().describe("A list of allowed image formats. E.g., ['jpg', 'png', 'gif']."),
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
- execute: async (args: { query: string; image_domain_filter?: string[]; image_format_filter?: string[] }) => {
12
- const apiKey = process.env.PERPLEXITY_API_KEY;
13
- if (!apiKey) {
14
- throw new Error("PERPLEXITY_API_KEY environment variable is not set.");
15
- }
16
-
17
- const url = "https://api.perplexity.ai/chat/completions";
18
- const headers = {
19
- "Authorization": `Bearer ${apiKey}`,
20
- "Content-Type": "application/json",
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
- // Create a map of old citation index -> new image index
69
- const oldToNewCitationMap: { [key: number]: number } = {};
70
- if (citations && Array.isArray(citations)) {
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
- if (originUrlToImageIndex[citationUrl]) {
73
- oldToNewCitationMap[index + 1] = originUrlToImageIndex[citationUrl];
74
- }
90
+ if (originUrlToImageIndex[citationUrl]) {
91
+ oldToNewCitationMap[index + 1] = originUrlToImageIndex[citationUrl];
92
+ }
75
93
  });
76
- }
94
+ }
77
95
 
78
- // Replace citations in the content
79
- if (content && typeof content === 'string') {
80
- content = content.replace(/\[(\d+)\]/g, (match: string, oldIndexStr: string) => {
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
- return `[${newIndex}]`;
103
+ return `[${newIndex}]`;
85
104
  }
86
- return ''; // Remove citation if it doesn't correspond to an image
87
- }).replace(/(\s\s+)/g, ' ').trim(); // Clean up extra spaces
88
- }
89
-
90
- // Build the final formatted output
91
- let output = content + "\n\n--- Images ---\n";
92
- images.forEach((img: any, index: number) => {
93
- output += `${index + 1}. ${img.image_url}\n (Source: ${img.origin_url})\n`;
94
- });
95
-
96
- return output;
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: "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.",
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.array(z.string()).optional().describe("A list of domains to limit the search to (e.g., ['youtube.com']). Use a '-' prefix to exclude a domain."),
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
- const apiKey = process.env.PERPLEXITY_API_KEY;
109
- if (!apiKey) {
110
- throw new Error("PERPLEXITY_API_KEY environment variable is not set.");
111
- }
112
-
113
- const url = "https://api.perplexity.ai/chat/completions";
114
- const headers = {
115
- "Authorization": `Bearer ${apiKey}`,
116
- "Content-Type": "application/json",
117
- "accept": "application/json"
118
- };
119
-
120
- const payload: any = {
121
- model: "sonar-pro",
122
- messages: [
123
- { role: "user", content: `Show me videos of ${args.query}` }
124
- ],
125
- media_response: { overrides: { return_videos: true } }
126
- };
127
-
128
- if (args.search_domain_filter) {
129
- payload.search_domain_filter = args.search_domain_filter;
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
- // Create a map of old citation index -> new video index
161
- const oldToNewCitationMap: { [key: number]: number } = {};
162
- if (citations && Array.isArray(citations)) {
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
- if (urlToVideoIndex[citationUrl]) {
165
- oldToNewCitationMap[index + 1] = urlToVideoIndex[citationUrl];
166
- }
200
+ if (urlToVideoIndex[citationUrl]) {
201
+ oldToNewCitationMap[index + 1] = urlToVideoIndex[citationUrl];
202
+ }
167
203
  });
168
- }
204
+ }
169
205
 
170
- // Replace citations in the content
171
- if (content && typeof content === 'string') {
172
- content = content.replace(/\[(\d+)\]/g, (match: string, oldIndexStr: string) => {
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
- return `[${newIndex}]`;
213
+ return `[${newIndex}]`;
177
214
  }
178
- return ''; // Remove citation if it doesn't correspond to a video
179
- }).replace(/(\s\s+)/g, ' ').trim(); // Clean up extra spaces
180
- }
181
-
182
- // Build the final formatted output
183
- let output = content + "\n\n--- Videos ---\n";
184
- videos.forEach((video: any, index: number) => {
185
- output += `${index + 1}. ${video.url}\n`;
186
- });
187
-
188
- return output;
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
  };
@@ -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: "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
+ 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.string().describe("YouTube video URL to analyze (format: https://www.youtube.com/watch?v=VIDEO_ID)"),
13
- prompt: z.string().describe("Analysis prompt or question about the YouTube video content"),
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
- try {
17
- // Validate YouTube URL format
18
- if (!args.youtube_url || (!args.youtube_url.includes('youtube.com/watch') && !args.youtube_url.includes('youtu.be'))) {
19
- throw new Error("Invalid YouTube URL format. Expected: https://www.youtube.com/watch?v=VIDEO_ID");
20
- }
21
-
22
- // Create content using the correct FileData approach with fileUri
23
- const response = await ai.models.generateContent({
24
- model: 'models/gemini-2.5-flash',
25
- contents: {
26
- parts: [
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
- let result = "";
38
- if (response.candidates && response.candidates[0]?.content?.parts) {
39
- for (const part of response.candidates[0].content.parts) {
40
- if (part.text) {
41
- result += part.text;
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
+ }