-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add search_google_images MCP tool with SerpAPI integration #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
f24e412
feat: add search_google_images MCP tool with SerpAPI integration
sidneyswift 2499801
fix: make SerpApiResponse images_results optional to match runtime be…
sidneyswift e0eb479
refactor: remove console.error and 'what' comments per code principles
sidneyswift ce13c5b
refactor: address all reviewer feedback
sidneyswift 8f2c9b7
fix: map aspect ratio values to SerpAPI abbreviations
sidneyswift b33f45d
Merge branch 'test' into feature/add-search-google-images-tool
sidneyswift File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
173 changes: 173 additions & 0 deletions
173
lib/mcp/tools/search/__tests__/registerSearchGoogleImagesTool.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<unknown>; | ||
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof searchGoogleImagesSchema>; | ||
|
|
||
| /** | ||
| * 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", | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
| } | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> = { | ||
| 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()}`; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.