diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index a505a5a7..36b462d7 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -8,7 +8,7 @@ import { registerAllSpotifyTools } from "./spotify"; import { registerContactTeamTool } from "./registerContactTeamTool"; import { registerUpdateAccountInfoTool } from "./registerUpdateAccountInfoTool"; import { registerAllArtistSocialsTools } from "./artistSocials"; -import { registerSearchWebTool } from "./registerSearchWebTool"; +import { registerAllSearchTools } from "./search"; import { registerWebDeepResearchTool } from "./registerWebDeepResearchTool"; import { registerArtistDeepResearchTool } from "./registerArtistDeepResearchTool"; import { registerAllFileTools } from "./files"; @@ -37,13 +37,13 @@ export const registerAllTools = (server: McpServer): void => { registerAllFileTools(server); registerAllImageTools(server); registerAllPulseTools(server); + registerAllSearchTools(server); registerAllSora2Tools(server); registerAllSpotifyTools(server); registerAllTaskTools(server); registerTranscribeTools(server); registerContactTeamTool(server); registerGetLocalTimeTool(server); - registerSearchWebTool(server); registerWebDeepResearchTool(server); registerArtistDeepResearchTool(server); registerSendEmailTool(server); diff --git a/lib/mcp/tools/search/__tests__/registerSearchGoogleImagesTool.test.ts b/lib/mcp/tools/search/__tests__/registerSearchGoogleImagesTool.test.ts new file mode 100644 index 00000000..487c651d --- /dev/null +++ b/lib/mcp/tools/search/__tests__/registerSearchGoogleImagesTool.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerSearchGoogleImagesTool } from "../registerSearchGoogleImagesTool"; + +const mockSearchGoogleImages = vi.fn(); + +vi.mock("@/lib/serpapi/searchGoogleImages", () => ({ + searchGoogleImages: (...args: unknown[]) => mockSearchGoogleImages(...args), +})); + +describe("registerSearchGoogleImagesTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + let registeredConfig: { description: string; inputSchema: unknown }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredConfig = config; + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerSearchGoogleImagesTool(mockServer); + }); + + it("registers the search_google_images tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "search_google_images", + expect.objectContaining({ + description: expect.stringContaining("Search for EXISTING images"), + }), + expect.any(Function), + ); + }); + + it("has correct input schema", () => { + expect(registeredConfig.inputSchema).toBeDefined(); + }); + + it("returns image results on successful search", async () => { + mockSearchGoogleImages.mockResolvedValue({ + images_results: [ + { + position: 1, + thumbnail: "https://example.com/thumb1.jpg", + original: "https://example.com/full1.jpg", + original_width: 1920, + original_height: 1080, + title: "Mac Miller Concert", + source: "example.com", + link: "https://example.com/page1", + }, + { + position: 2, + thumbnail: "https://example.com/thumb2.jpg", + original: "https://example.com/full2.jpg", + original_width: 800, + original_height: 600, + title: "Mac Miller Album", + source: "music.com", + link: "https://music.com/page2", + }, + ], + }); + + const result = (await registeredHandler({ + query: "Mac Miller concert", + limit: 8, + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.success).toBe(true); + expect(parsed.query).toBe("Mac Miller concert"); + expect(parsed.total_results).toBe(2); + expect(parsed.images).toHaveLength(2); + expect(parsed.images[0]).toEqual({ + position: 1, + thumbnail: "https://example.com/thumb1.jpg", + original: "https://example.com/full1.jpg", + width: 1920, + height: 1080, + title: "Mac Miller Concert", + source: "example.com", + link: "https://example.com/page1", + }); + }); + + it("passes search options to searchGoogleImages", async () => { + mockSearchGoogleImages.mockResolvedValue({ images_results: [] }); + + await registeredHandler({ + query: "album covers", + limit: 5, + imageSize: "l", + imageType: "photo", + aspectRatio: "wide", + }); + + expect(mockSearchGoogleImages).toHaveBeenCalledWith({ + query: "album covers", + limit: 5, + imageSize: "l", + imageType: "photo", + aspectRatio: "wide", + }); + }); + + it("uses default limit of 8 when not specified", async () => { + mockSearchGoogleImages.mockResolvedValue({ images_results: [] }); + + await registeredHandler({ query: "test" }); + + expect(mockSearchGoogleImages).toHaveBeenCalledWith(expect.objectContaining({ limit: 8 })); + }); + + it("handles empty images_results gracefully", async () => { + mockSearchGoogleImages.mockResolvedValue({ images_results: [] }); + + const result = (await registeredHandler({ + query: "nonexistent thing", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.success).toBe(true); + expect(parsed.total_results).toBe(0); + expect(parsed.images).toHaveLength(0); + }); + + it("handles undefined images_results gracefully", async () => { + mockSearchGoogleImages.mockResolvedValue({}); + + const result = (await registeredHandler({ + query: "nonexistent thing", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.success).toBe(true); + expect(parsed.total_results).toBe(0); + expect(parsed.images).toHaveLength(0); + }); + + it("returns error when searchGoogleImages fails", async () => { + mockSearchGoogleImages.mockRejectedValue(new Error("SerpAPI request failed: 403")); + + const result = (await registeredHandler({ + query: "Mac Miller", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.success).toBe(false); + expect(parsed.message).toContain("SerpAPI request failed: 403"); + }); + + it("returns generic error message for non-Error exceptions", async () => { + mockSearchGoogleImages.mockRejectedValue("unexpected string error"); + + const result = (await registeredHandler({ + query: "Mac Miller", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.success).toBe(false); + expect(parsed.message).toContain("Failed to search Google Images"); + }); +}); diff --git a/lib/mcp/tools/search/index.ts b/lib/mcp/tools/search/index.ts new file mode 100644 index 00000000..e6e4f20f --- /dev/null +++ b/lib/mcp/tools/search/index.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerSearchWebTool } from "./registerSearchWebTool"; +import { registerSearchGoogleImagesTool } from "./registerSearchGoogleImagesTool"; + +/** + * Registers all search-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllSearchTools = (server: McpServer): void => { + registerSearchWebTool(server); + registerSearchGoogleImagesTool(server); +}; diff --git a/lib/mcp/tools/search/registerSearchGoogleImagesTool.ts b/lib/mcp/tools/search/registerSearchGoogleImagesTool.ts new file mode 100644 index 00000000..2853f500 --- /dev/null +++ b/lib/mcp/tools/search/registerSearchGoogleImagesTool.ts @@ -0,0 +1,96 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { searchGoogleImages } from "@/lib/serpapi/searchGoogleImages"; +import { DEFAULT_IMAGE_LIMIT } from "@/lib/serpapi/types"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const searchGoogleImagesSchema = z.object({ + query: z + .string() + .min(1, "Search query is required") + .describe( + "The search query (e.g., 'Mac Miller concert', 'Wiz Khalifa album cover'). Be specific for best results.", + ), + limit: z + .number() + .min(1) + .max(100) + .optional() + .describe(`Number of images to return (1-100, default: ${DEFAULT_IMAGE_LIMIT}).`), + imageSize: z + .enum(["l", "m", "i"]) + .optional() + .describe("Image size: 'l' (large, recommended), 'm' (medium), 'i' (icon/small). Leave unset if unsure."), + imageType: z + .enum(["photo", "clipart", "lineart", "animated"]) + .optional() + .describe("Type of image: 'photo' (default, recommended), 'clipart', 'lineart', 'animated'. Leave unset if unsure."), + aspectRatio: z + .enum(["square", "wide", "tall", "panoramic"]) + .optional() + .describe("Aspect ratio filter. Only use if specifically requested. Leave unset for general searches."), +}); + +type SearchGoogleImagesArgs = z.infer; + +/** + * Registers the "search_google_images" tool on the MCP server. + * Searches Google Images via SerpAPI for existing photos and visual content. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerSearchGoogleImagesTool(server: McpServer): void { + server.registerTool( + "search_google_images", + { + description: + "Search for EXISTING images on Google Images. Use this to FIND real photos, not create new ones.\n\n" + + "Use this tool when the user wants to:\n" + + "- FIND existing photos of artists, concerts, album covers, or events\n" + + "- SEE what something looks like (e.g., 'show me photos of Mac Miller', 'find concert images')\n" + + "- GET reference images or inspiration from real photos\n" + + "- SEARCH for visual content that already exists online\n\n" + + "DO NOT use this tool when the user wants to:\n" + + "- CREATE, GENERATE, or MAKE new images (use generate_image instead)\n" + + "- DESIGN custom album covers or artwork (use generate_image tools)\n" + + "- EDIT existing images (use edit_image instead)\n\n" + + "Key distinction: This finds what EXISTS, generative tools create what DOESN'T exist yet.\n\n" + + "Returns thumbnails and full-resolution URLs for displaying in chat or emails.\n\n" + + "TECHNICAL NOTES: Keep parameters simple. Query is most important. Optional filters can cause errors - if tool fails, retry with just query and limit.", + inputSchema: searchGoogleImagesSchema, + }, + async (args: SearchGoogleImagesArgs) => { + const { query, limit = DEFAULT_IMAGE_LIMIT, imageSize, imageType, aspectRatio } = args; + + try { + const response = await searchGoogleImages({ query, limit, imageSize, imageType, aspectRatio }); + + const images = (response.images_results ?? []).map((img) => ({ + position: img.position, + thumbnail: img.thumbnail, + original: img.original, + width: img.original_width, + height: img.original_height, + title: img.title, + source: img.source, + link: img.link, + })); + + return getToolResultSuccess({ + success: true, + query, + total_results: images.length, + images, + message: `Found ${images.length} images for "${query}"`, + }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? `Google Images search failed: ${error.message}` + : "Failed to search Google Images", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/registerSearchWebTool.ts b/lib/mcp/tools/search/registerSearchWebTool.ts similarity index 100% rename from lib/mcp/tools/registerSearchWebTool.ts rename to lib/mcp/tools/search/registerSearchWebTool.ts diff --git a/lib/serpapi/buildSearchParams.ts b/lib/serpapi/buildSearchParams.ts new file mode 100644 index 00000000..a264ea4e --- /dev/null +++ b/lib/serpapi/buildSearchParams.ts @@ -0,0 +1,43 @@ +import { SERPAPI_BASE_URL } from "./config"; +import type { SearchImagesOptions } from "./types"; + +/** + * Builds the SerpAPI search URL with query parameters and filters. + * + * Note: SerpAPI requires api_key as a query parameter — header-based auth is not supported. + * Ensure request URLs are not logged by middleware or error reporters. + * + * @param options - Search parameters including query and optional filters + * @param apiKey - The SerpAPI API key + * @returns The fully constructed search URL + */ +export function buildSearchUrl(options: SearchImagesOptions, apiKey: string): string { + const { query, page = 0, imageSize, imageType, aspectRatio } = options; + + // SerpAPI uses "tbs" for advanced image filtering (type, size) + const tbsParams: string[] = []; + if (imageType) tbsParams.push(`itp:${imageType}`); + if (imageSize) tbsParams.push(`isz:${imageSize}`); + const tbs = tbsParams.length > 0 ? tbsParams.join(",") : undefined; + + const params = new URLSearchParams({ + engine: "google_images", + q: query, + api_key: apiKey, + ijn: page.toString(), + }); + + // SerpAPI expects abbreviated aspect ratio codes, not full names + const aspectRatioMap: Record = { + square: "s", + wide: "w", + tall: "t", + panoramic: "xw", + }; + + if (tbs) params.append("tbs", tbs); + const mappedAspectRatio = aspectRatio ? aspectRatioMap[aspectRatio] : undefined; + if (mappedAspectRatio) params.append("imgar", mappedAspectRatio); + + return `${SERPAPI_BASE_URL}/search.json?${params.toString()}`; +} diff --git a/lib/serpapi/config.ts b/lib/serpapi/config.ts new file mode 100644 index 00000000..27c516d6 --- /dev/null +++ b/lib/serpapi/config.ts @@ -0,0 +1,23 @@ +/** + * SerpAPI configuration for Google Images search. + */ +export const SERPAPI_BASE_URL = "https://serpapi.com"; + +/** + * Retrieves the SerpAPI key from environment variables. + * Throws if the key is not configured. + * + * @returns The SerpAPI API key string + */ +export function getSerpApiKey(): string { + const apiKey = process.env.SERPAPI_API_KEY; + + if (!apiKey) { + throw new Error( + "SERPAPI_API_KEY environment variable is not set. " + + "Please add it to your environment variables.", + ); + } + + return apiKey; +} diff --git a/lib/serpapi/searchGoogleImages.ts b/lib/serpapi/searchGoogleImages.ts new file mode 100644 index 00000000..8c53be80 --- /dev/null +++ b/lib/serpapi/searchGoogleImages.ts @@ -0,0 +1,50 @@ +import { getSerpApiKey } from "./config"; +import { buildSearchUrl } from "./buildSearchParams"; +import type { SerpApiResponse, SearchImagesOptions } from "./types"; +import { DEFAULT_IMAGE_LIMIT } from "./types"; + +/** + * Searches Google Images via SerpAPI. + * + * @param options - Search parameters including query, limit, and optional filters + * @returns SerpAPI response with image results limited to the requested count + */ +export async function searchGoogleImages(options: SearchImagesOptions): Promise { + const { limit = DEFAULT_IMAGE_LIMIT } = options; + const apiKey = getSerpApiKey(); + const url = buildSearchUrl(options, apiKey); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SerpAPI request failed: ${response.status} - ${errorText}`); + } + + const data: SerpApiResponse = await response.json(); + + return { + ...data, + images_results: data.images_results?.slice(0, limit), + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + "Google Images search timed out after 10 seconds. Please try again with a more specific query.", + ); + } + + throw error; + } +} diff --git a/lib/serpapi/types.ts b/lib/serpapi/types.ts new file mode 100644 index 00000000..f818efd7 --- /dev/null +++ b/lib/serpapi/types.ts @@ -0,0 +1,36 @@ +/** + * Shared types for SerpAPI Google Images integration. + */ + +export interface SerpApiImageResult { + position: number; + thumbnail: string; + original: string; + original_width: number; + original_height: number; + title: string; + link: string; + source: string; + is_product?: boolean; +} + +export interface SerpApiResponse { + images_results?: SerpApiImageResult[]; + search_metadata?: { + id: string; + status: string; + created_at: string; + }; +} + +/** Default number of image results to return */ +export const DEFAULT_IMAGE_LIMIT = 8; + +export interface SearchImagesOptions { + query: string; + limit?: number; + page?: number; + imageSize?: "l" | "m" | "i"; + imageType?: "photo" | "clipart" | "lineart" | "animated"; + aspectRatio?: "square" | "wide" | "tall" | "panoramic"; +}