@abhishekguptain/mcp-search-server
Version:
Multi Provider Search MCP supporting Google, Tavily, DuckDuckGo, and Brave providers.
251 lines (249 loc) • 12.8 kB
JavaScript
// MCP Search Server (TypeScript, MCP SDK, stdio transport)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// Correct the import: CallToolResultSchema instead of CallToolResponseSchema
import { ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { PROVIDER_LIST, SEARCH_STRATEGY, DEFAULT_NUM_RESULTS } from './config.js';
import { searchGoogle } from './providers/google.js';
import { searchTavily } from './providers/tavily.js';
import { searchDuckDuckGo } from './providers/duckduckgo.js';
import { searchBrave } from './providers/brave.js';
// Import the new interface and updated functions
import { standardizeGoogleResults, standardizeTavilyResults, standardizeDuckDuckGoResults, standardizeBraveResults } from './utils/standardize.js';
const TOOL_NAME = 'search';
const TOOL_DEFINITION = {
name: TOOL_NAME,
description: 'Performs a web search using multiple providers (google, tavily, duckduckgo, brave).',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query.',
},
provider: {
type: 'string',
enum: ['google', 'tavily', 'duckduckgo', 'brave'],
description: 'Optional: Specify a provider directly (google, tavily, duckduckgo, brave). If omitted, uses priority fallback.',
},
num_results: {
type: 'integer',
description: `Number of results desired (default: ${DEFAULT_NUM_RESULTS}).`,
default: DEFAULT_NUM_RESULTS,
minimum: 1,
maximum: 20,
},
},
required: ['query'],
additionalProperties: false,
},
};
const searchFunctions = {
google: searchGoogle,
tavily: searchTavily,
duckduckgo: searchDuckDuckGo,
brave: searchBrave,
};
const standardizeFunctions = {
google: standardizeGoogleResults,
tavily: standardizeTavilyResults,
duckduckgo: standardizeDuckDuckGoResults,
brave: standardizeBraveResults,
};
const API_KEYS = {
tavily_key: process.env.TAVILY_API_KEY || '',
google_key: process.env.GOOGLE_API_KEY || '',
google_cx: process.env.GOOGLE_CX || '',
brave_key: process.env.BRAVE_API_KEY || '',
};
class McpSearchServer {
server;
constructor() {
this.server = new Server({
name: 'mcp-search-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
toolDescriptions: true,
},
});
this.setupToolHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [TOOL_DEFINITION],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== TOOL_NAME) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
const args = request.params.arguments;
// Validate input
if (!args.query || typeof args.query !== 'string' || args.query.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required argument: query (string)');
}
const query = args.query;
const requestedProvider = args.provider;
const numResults = args.num_results || DEFAULT_NUM_RESULTS;
if (requestedProvider && !searchFunctions[requestedProvider]) {
throw new McpError(ErrorCode.InvalidParams, `Invalid provider specified: ${requestedProvider}. Valid options are: google, tavily`);
}
if (requestedProvider === 'tavily') {
if (!API_KEYS.tavily_key) {
// Throw as InvalidParams because it's a configuration issue tied to the specific request
throw new McpError(ErrorCode.InvalidParams, `Tavily API key is not configured for this server`);
}
}
if (requestedProvider === 'google') {
if (!API_KEYS.google_key || !API_KEYS.google_cx) {
// Throw as InvalidParams because it's a configuration issue tied to the specific request
throw new McpError(ErrorCode.InvalidParams, `Google API key or CX are not configured for this server`);
}
}
// --- Start of logic to track fallback and order ---
let providersAttemptedOrder;
if (requestedProvider) {
providersAttemptedOrder = [requestedProvider];
}
else if (SEARCH_STRATEGY === 'random') {
// Pick a random provider from the enabled list
const enabled = PROVIDER_LIST.filter(p => searchFunctions[p]);
if (enabled.length === 0) {
throw new McpError(ErrorCode.InternalError, 'No enabled providers available for random selection.');
}
const randomProvider = enabled[Math.floor(Math.random() * enabled.length)];
providersAttemptedOrder = [randomProvider];
}
else {
// Default: priority order (try all in order)
providersAttemptedOrder = PROVIDER_LIST.filter(p => searchFunctions[p]);
}
// Fallback is considered triggered if no specific provider was requested,
// meaning the server attempted providers based on the configured list.
const fallbackTriggered = !requestedProvider;
// --- End of logic to track fallback and order ---
// Provider execution logic
let finalResults = [];
let errorMessages = [];
let successfulProvider = null;
const timeoutMs = 30000; // 15 second timeout per provider
for (const provider of providersAttemptedOrder) { // Use the list captured for tracking
try {
const searchFn = searchFunctions[provider];
const standardizeFn = standardizeFunctions[provider];
// --- Move API key check here, inside the loop before attempting the provider ---
// This ensures we only throw missing key errors for providers we actually try
// and prevents the loop from stopping if the *first* provider in the priority list
// has a missing key but later ones might be configured.
switch (provider) {
case 'google':
if (!API_KEYS.google_key || !API_KEYS.google_cx) {
// Throw a standard Error here, which will be caught by the inner try/catch
throw new Error('Google API key or CX missing');
}
break;
case 'tavily':
if (!API_KEYS.tavily_key) {
// Throw a standard Error here
throw new Error('Tavily API key missing');
}
break;
case 'duckduckgo':
// DuckDuckGo does not require an API key
break;
case 'brave':
if (!API_KEYS.brave_key) {
throw new Error('Brave Search API key missing');
}
break;
}
// --- End of moved API key check ---
// Prepare provider-specific arguments
let providerArgs;
switch (provider) {
case 'google':
providerArgs = [API_KEYS.google_key, API_KEYS.google_cx, numResults];
break;
case 'tavily':
providerArgs = [API_KEYS.tavily_key, numResults];
break;
case 'duckduckgo':
providerArgs = [numResults];
break;
case 'brave':
providerArgs = [API_KEYS.brave_key, numResults];
break;
default:
// Should not happen due to earlier checks, but good practice
throw new McpError(ErrorCode.InternalError, `Internal error: Unknown provider ${provider}`);
}
// Execute search with timeout
const rawResults = await Promise.race([
searchFn(query, ...providerArgs),
new Promise((_, reject) => setTimeout(() => reject(new Error(`${provider} search timed out after ${timeoutMs}ms`)), timeoutMs)),
]);
// Standardize results, passing the provider name
// Add type assertion for rawResults from Promise.race
const standardized = standardizeFn(rawResults, provider);
if (standardized.length > 0) {
finalResults = standardized;
successfulProvider = provider;
break; // Exit loop on first success
}
else {
console.error(`${provider} returned 0 results.`);
// Don't add to errorMessages here, it's not an error, just no results
}
}
catch (error) {
// Catch errors thrown by providers (missing keys, API errors, timeouts)
const message = `${provider}: ${error.message}`;
console.error(`Error during search with ${provider}:`, error.message);
errorMessages.push(message);
}
}
// Check if any provider succeeded
if (successfulProvider && finalResults.length > 0) {
// Format results according to MCP spec (array of content items)
const content = finalResults.map(item => ({
type: 'text', // Use 'text' type for the search results
// Combine the details into a single text string for the 'text' content item
text: `Title: ${item.title}\nLink: ${item.link}\nSnippet: ${item.snippet}\nSource: ${item.provider}`,
}));
// --- Construct the 'data' object with fallback information ---
const responseData = {
fallbackTriggered: fallbackTriggered,
providersAttemptedOrder: providersAttemptedOrder,
successfulProvider: successfulProvider, // Include the successful provider for clarity
errorsDuringAttempt: errorMessages.length > 0 ? errorMessages : undefined // Optionally include errors if any occurred before success
};
// --- End of 'data' object construction ---
// Return MCP success response including content AND data
return { content, data: responseData };
}
else {
// If no provider succeeded, throw an MCP error
const combinedErrors = errorMessages.join('; ');
console.error(`Search failed for query "${query}". Errors: ${combinedErrors}`);
// You could also include the attempted providers list in the error data if needed
// const errorData = { providersAttemptedOrder };
throw new McpError(ErrorCode.InternalError, // Or a more specific code if applicable
`Search failed. No provider succeeded for query "${query}". Errors: ${combinedErrors || 'No results found or provider configuration issue.'}`);
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('MCP Search Server running on stdio');
}
}
const server = new McpSearchServer();
server.run().catch(console.error);