Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
173 changes: 173 additions & 0 deletions lib/mcp/tools/search/__tests__/registerSearchGoogleImagesTool.test.ts
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");
});
});
13 changes: 13 additions & 0 deletions lib/mcp/tools/search/index.ts
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);
};
96 changes: 96 additions & 0 deletions lib/mcp/tools/search/registerSearchGoogleImagesTool.ts
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",
);
}
},
);
}
43 changes: 43 additions & 0 deletions lib/serpapi/buildSearchParams.ts
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()}`;
}
23 changes: 23 additions & 0 deletions lib/serpapi/config.ts
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;
}
Loading