gemini-grounding-mcp
Version:
MCP server for Gemini AI web search with grounding, featuring AI-powered summaries and batch search capabilities
896 lines (886 loc) • 33.4 kB
JavaScript
//#region rolldown:runtime
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
key = keys[i];
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
get: ((k) => from[k]).bind(null, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
value: mod,
enumerable: true
}) : target, mod));
//#endregion
require("dotenv/config");
const node_fs = __toESM(require("node:fs"));
const node_path = __toESM(require("node:path"));
const __modelcontextprotocol_sdk_server_index_js = __toESM(require("@modelcontextprotocol/sdk/server/index.js"));
const __modelcontextprotocol_sdk_server_stdio_js = __toESM(require("@modelcontextprotocol/sdk/server/stdio.js"));
const __modelcontextprotocol_sdk_types_js = __toESM(require("@modelcontextprotocol/sdk/types.js"));
const __google_generative_ai = __toESM(require("@google/generative-ai"));
const node_os = __toESM(require("node:os"));
const dotenv = __toESM(require("dotenv"));
//#region src/const.ts
const OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
const OAUTH_CLIENT_SECRET = "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";
//#endregion
//#region src/auth/oauth2.ts
var OAuth2Client = class {
oauthPath = (0, node_path.join)((0, node_os.homedir)(), ".gemini", "oauth_creds.json");
tokenEndpoint = "https://oauth2.googleapis.com/token";
async getValidToken() {
const token = this.loadToken();
if (!token) throw new Error("No OAuth token found. Please authenticate using 'gemini' command first.");
const now = Date.now();
if (token.expiry_date && token.expiry_date > now) return token.access_token;
console.log("OAuth token expired, refreshing...");
try {
const refreshedToken = await this.refreshToken(token.refresh_token);
this.saveToken(refreshedToken);
return refreshedToken.access_token;
} catch (error) {
console.error("Token refresh failed:", error);
throw new Error("OAuth token has expired and refresh failed. Please re-authenticate using 'gemini' command.");
}
}
loadToken() {
try {
const data = (0, node_fs.readFileSync)(this.oauthPath, "utf8");
return JSON.parse(data);
} catch {
return null;
}
}
saveToken(token) {
try {
(0, node_fs.writeFileSync)(this.oauthPath, JSON.stringify(token, null, 2));
} catch (error) {
console.error("Failed to save OAuth token:", error);
}
}
async refreshToken(refreshToken) {
const params = new URLSearchParams({
client_id: OAUTH_CLIENT_ID,
client_secret: OAUTH_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token"
});
const response = await fetch(this.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to refresh token: ${error}`);
}
const data = await response.json();
const expiryDate = Date.now() + data.expires_in * 1e3;
return {
access_token: data.access_token,
refresh_token: refreshToken,
token_type: data.token_type,
expiry_date: expiryDate
};
}
};
//#endregion
//#region src/auth/config.ts
dotenv.default.config();
var AuthConfig = class {
apiKey = null;
oauthToken = null;
refreshToken = null;
tokenExpiry = null;
authMethod = null;
oauth2Client = null;
constructor() {
this._initialize();
}
_initialize() {
if (process.env.GEMINI_API_KEY) {
this.apiKey = process.env.GEMINI_API_KEY;
this.authMethod = "api-key";
return;
}
try {
const oauthPath = (0, node_path.join)((0, node_os.homedir)(), ".gemini", "oauth_creds.json");
const oauthData = JSON.parse((0, node_fs.readFileSync)(oauthPath, "utf8"));
if (oauthData.access_token) {
this.oauthToken = oauthData.access_token;
this.refreshToken = oauthData.refresh_token;
if (oauthData.expiry_date) this.tokenExpiry = new Date(oauthData.expiry_date);
this.authMethod = "oauth";
this.oauth2Client = new OAuth2Client();
return;
}
} catch (_error) {}
throw new Error("No authentication method found. Please set GEMINI_API_KEY environment variable or run \"gemini auth login\"");
}
isApiKey() {
return this.authMethod === "api-key";
}
isOAuth() {
return this.authMethod === "oauth";
}
getApiKey() {
if (!this.isApiKey() || !this.apiKey) throw new Error("API key not available");
return this.apiKey;
}
async getOAuthToken() {
if (!this.isOAuth() || !this.oauth2Client) throw new Error("OAuth not available");
const token = await this.oauth2Client.getValidToken();
this.oauthToken = token;
return token;
}
async getHeaders() {
if (this.isOAuth()) {
const token = await this.getOAuthToken();
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
};
}
return {};
}
};
//#endregion
//#region src/utils/formatter.ts
function formatSearchResult$1(response, query) {
if (!response || !response.text) return {
query,
summary: `No results found for query: "${query}"`,
citations: []
};
return {
query,
summary: response.text,
citations: response.citations || []
};
}
function formatBatchResults(results) {
return {
totalQueries: results.length,
results: results.map((result) => ({
query: result.query,
summary: result.summary,
citations: result.citations || [],
searchResults: result.searchResults || [],
scrapedContent: result.scrapedContent || [],
error: result.error,
searchResultCount: result.searchResultCount,
targetResultCount: result.targetResultCount
}))
};
}
function extractSearchResults(groundingMetadata) {
if (!groundingMetadata || !groundingMetadata.groundingSupports) return [];
const results = [];
const seen = /* @__PURE__ */ new Set();
for (const support of groundingMetadata.groundingSupports) if (support.groundingChunkIndices && support.groundingChunkIndices.length > 0) {
const chunkIndex = support.groundingChunkIndices[0];
const chunk = groundingMetadata.groundingChunks?.[chunkIndex];
if (chunk?.web) {
const url = chunk.web.uri;
if (!seen.has(url)) {
seen.add(url);
results.push({
title: chunk.web.title || "Untitled",
url,
snippet: support.segment?.text || ""
});
}
}
}
const MAX_SEARCH_RESULTS = 5;
return results.slice(0, MAX_SEARCH_RESULTS);
}
function insertCitations(text, groundingSupports) {
if (!groundingSupports || groundingSupports.length === 0) return text;
const sortedSupports = [...groundingSupports].sort((a, b) => {
const aIndex = a.endIndex ?? a.segment?.endIndex ?? 0;
const bIndex = b.endIndex ?? b.segment?.endIndex ?? 0;
return bIndex - aIndex;
});
let result = text;
const insertedPositions = /* @__PURE__ */ new Set();
for (const support of sortedSupports) if (support.groundingChunkIndices && support.groundingChunkIndices.length > 0) {
const citations = support.groundingChunkIndices.map((idx) => `[${idx + 1}]`).join("");
const position = support.endIndex ?? support.segment?.endIndex;
if (!position) continue;
if (!insertedPositions.has(position)) {
insertedPositions.add(position);
result = result.slice(0, position) + citations + result.slice(position);
}
}
return result;
}
function formatError(error, context) {
return {
error: true,
message: error.message || "An unknown error occurred",
context,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
}
//#endregion
//#region src/utils/scraper.ts
let readabilityModule = null;
const getReadability = async () => {
if (!readabilityModule) readabilityModule = await import("@mizchi/readability");
return readabilityModule;
};
var Scraper = class {
cache = /* @__PURE__ */ new Map();
cacheTTL;
scrapeTimeout;
scrapeRetries;
excerptLength;
summaryLength;
geminiClient;
constructor(geminiClient$1) {
this.geminiClient = geminiClient$1;
this.cacheTTL = Number.parseInt(process.env.CACHE_TTL || "3600", 10) * 1e3;
this.scrapeTimeout = Number.parseInt(process.env.SCRAPE_TIMEOUT || "10000", 10);
this.scrapeRetries = Number.parseInt(process.env.SCRAPE_RETRIES || "3", 10);
this.excerptLength = Number.parseInt(process.env.EXCERPT_LENGTH || "1000", 10);
this.summaryLength = Number.parseInt(process.env.SUMMARY_LENGTH || "5000", 10);
if (process.env.DEBUG === "true") console.log("Scraper configuration:", {
excerptLength: this.excerptLength,
summaryLength: this.summaryLength
});
}
async scrapeUrl(url, options) {
const maxRetries = options?.retries ?? this.scrapeRetries;
const contentMode = options?.contentMode ?? "full";
const maxContentLength = options?.maxContentLength ?? 1e4;
const cached = this.cache.get(url);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) return cached.content;
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.scrapeTimeout);
const response = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; GeminiGroundingMCP/1.0)" },
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const html = await response.text();
const { extract, toMarkdown } = await getReadability();
const extracted = extract(html, {
charThreshold: 100,
url
});
if (!extracted || !extracted.root) throw new Error("Failed to extract content from URL");
const fullMarkdown = toMarkdown(extracted.root);
let processedContent;
switch (contentMode) {
case "excerpt":
if (this.geminiClient && fullMarkdown.length > this.excerptLength * 1.5) try {
processedContent = await this.geminiClient.summarize(fullMarkdown, this.excerptLength);
} catch (error) {
console.error("Failed to generate AI excerpt, falling back to truncation:", error);
processedContent = fullMarkdown.slice(0, this.excerptLength);
if (fullMarkdown.length > this.excerptLength) processedContent += "...";
}
else {
processedContent = fullMarkdown.slice(0, this.excerptLength);
if (fullMarkdown.length > this.excerptLength) processedContent += "...";
}
break;
case "summary":
if (this.geminiClient && fullMarkdown.length > this.summaryLength * 1.2) try {
processedContent = await this.geminiClient.summarize(fullMarkdown, this.summaryLength);
} catch (error) {
console.error("Failed to generate AI summary, falling back to truncation:", error);
processedContent = fullMarkdown.slice(0, this.summaryLength);
if (fullMarkdown.length > this.summaryLength) processedContent += "\n\n[Content truncated for summary mode]";
}
else {
processedContent = fullMarkdown.slice(0, this.summaryLength);
if (fullMarkdown.length > this.summaryLength) processedContent += "\n\n[Content truncated for summary mode]";
}
break;
default:
if (fullMarkdown.length > maxContentLength) {
processedContent = fullMarkdown.slice(0, maxContentLength);
processedContent += `\n\n[Content truncated at ${maxContentLength} characters]`;
} else processedContent = fullMarkdown;
break;
}
const result = {
url,
title: extracted.metadata?.title || "Scraped Content",
content: processedContent,
scrapedAt: (/* @__PURE__ */ new Date()).toISOString()
};
this.cache.set(url, {
content: result,
timestamp: Date.now()
});
return result;
} catch (error) {
lastError = error instanceof Error ? error : /* @__PURE__ */ new Error("Unknown error");
console.error(`Failed to scrape ${url} (attempt ${attempt}/${maxRetries}):`, lastError.message);
if (attempt < maxRetries) await new Promise((resolve) => setTimeout(resolve, 2 ** (attempt - 1) * 1e3));
}
const errorMessage = lastError?.message || "Unknown error";
return {
url,
title: "Error",
content: null,
error: errorMessage,
scrapedAt: (/* @__PURE__ */ new Date()).toISOString()
};
}
async scrapeUrls(urls, options) {
const batchSize = Number.parseInt(process.env.BATCH_SIZE || "5", 10);
const results = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map((url) => this.scrapeUrl(url, options)));
results.push(...batchResults);
if (i + batchSize < urls.length) await new Promise((resolve) => setTimeout(resolve, 100));
}
return results;
}
clearCache() {
this.cache.clear();
}
};
//#endregion
//#region src/gemini/code-assist-client.ts
var CodeAssistClient = class {
baseURL = "https://cloudcode-pa.googleapis.com";
projectId = null;
auth;
constructor(auth) {
this.auth = auth;
}
async makeAuthenticatedRequest(url, body) {
const headers = await this.auth.getHeaders();
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body)
});
return response;
}
async ensureProjectId() {
if (this.projectId) return this.projectId;
const loadResponse = await this.makeAuthenticatedRequest(`${this.baseURL}/v1internal:loadCodeAssist`, {});
if (!loadResponse.ok) {
const error = await loadResponse.text();
throw new Error(`Failed to load Code Assist: ${error}`);
}
const loadData = await loadResponse.json();
if (loadData.cloudaicompanionProject) {
this.projectId = loadData.cloudaicompanionProject;
return this.projectId;
}
if (loadData.currentTier) {}
const tiers = loadData.allowedTiers || [];
const defaultTier = tiers.find((t) => t.isDefault);
const selectedTier = defaultTier || tiers[0];
if (!selectedTier) throw new Error("No available tiers for Code Assist");
const onboardBody = { tier: selectedTier.id };
const onboardResponse = await this.makeAuthenticatedRequest(`${this.baseURL}/v1internal:onboardUser`, onboardBody);
if (!onboardResponse.ok) {
const error = await onboardResponse.text();
throw new Error(`Failed to onboard user: ${error}`);
}
const onboardData = await onboardResponse.json();
if (!onboardData.operation) throw new Error("No operation returned from onboarding");
let operation = onboardData.operation;
const MAX_POLLING_RETRIES = 30;
const POLLING_INTERVAL_MS = 1e3;
let retries = 0;
while (!operation.done && retries < MAX_POLLING_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS));
const opUrl = `${this.baseURL}/${operation.name}`;
const opResponse = await fetch(opUrl, {
method: "GET",
headers: await this.auth.getHeaders()
});
if (!opResponse.ok) {
const error = await opResponse.text();
throw new Error(`Failed to get operation status: ${error}`);
}
operation = await opResponse.json();
retries++;
}
if (!operation.done) throw new Error("Onboarding operation timed out");
this.projectId = operation.response?.cloudaicompanionProject?.id || null;
if (!this.projectId) throw new Error("Failed to obtain project ID from onboarding");
return this.projectId;
}
async generateContent(model, query) {
const projectId = await this.ensureProjectId();
const request = {
model,
request: {
contents: [{
role: "user",
parts: [{ text: query }]
}],
tools: [{ googleSearch: {} }]
},
project: projectId
};
const MAX_RETRIES = 3;
const INITIAL_DELAY_MS = 4e3;
const MAX_DELAY_MS = 6e4;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) try {
const response = await this.makeAuthenticatedRequest(`${this.baseURL}/v1internal:generateContent`, request);
if (!response.ok) {
const errorText = await response.text();
if (response.status === 429 && attempt < MAX_RETRIES) {
const delay = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
try {
JSON.parse(errorText);
} catch {}
console.error(`Rate limit hit (attempt ${attempt + 1}/${MAX_RETRIES + 1}). Retrying in ${delay / 1e3} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw new Error(`Code Assist API error: ${response.status} - ${errorText}`);
}
const result = await response.json();
return result.response || result;
} catch (error) {
if (attempt === MAX_RETRIES) throw error;
const delay = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
console.error(`Request failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${error}. Retrying in ${delay / 1e3} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error("Failed to generate content after all retries");
}
};
//#endregion
//#region src/gemini/client.ts
var GeminiClient = class {
auth;
scraper;
model = null;
codeAssistClient = null;
constructor() {
this.auth = new AuthConfig();
this.scraper = new Scraper(this);
this._initializeModel();
if (this.auth.isOAuth()) this.codeAssistClient = new CodeAssistClient(this.auth);
}
_initializeModel() {
if (this.auth.isApiKey()) {
const genAI = new __google_generative_ai.GoogleGenerativeAI(this.auth.getApiKey());
const searchTool = { googleSearchRetrieval: {} };
this.model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
tools: [searchTool]
});
}
}
async summarize(text, maxLength = 500) {
try {
const prompt = `Please provide a concise summary of the following text in about ${maxLength} characters. Focus on the main points and key information:\n\n${text}`;
if (this.auth.isApiKey() && this.model) {
const result = await this.model.generateContent(prompt);
return result.response.text();
} else {
const response = await this._oauthRequest(prompt);
return response.candidates?.[0]?.content?.parts?.[0]?.text || "Summary generation failed";
}
} catch (error) {
console.error("Summarization error:", error);
return `${text.slice(0, maxLength)}...`;
}
}
async searchWithOptions(query, _options) {
const result = await this.search(query);
return result;
}
async search(query) {
try {
let response;
if (this.auth.isApiKey() && this.model) {
const result = await this.model.generateContent(query);
response = result.response;
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
if (groundingMetadata?.groundingSupports) {
let text = response.text();
text = insertCitations(text, groundingMetadata.groundingSupports);
const result$1 = formatSearchResult$1({
text,
citations: this._extractCitations(groundingMetadata)
}, query);
return result$1;
}
return formatSearchResult$1({
text: response.text(),
citations: []
}, query);
} else {
const oauthResponse = await this._oauthSearch(query);
const candidates = oauthResponse.candidates || oauthResponse.response?.candidates;
if (!candidates || candidates.length === 0) throw new Error("No valid response from Code Assist API");
response = {
candidates,
text: () => candidates[0]?.content?.parts?.[0]?.text || ""
};
const candidate = candidates[0];
if (candidate?.content?.parts?.[0]?.text) {
let text = candidate.content.parts[0].text;
const groundingMetadata = candidate.groundingMetadata;
const citationPattern = /\[\d+\]/g;
const existingCitations = text.match(citationPattern);
if (existingCitations && existingCitations.length > 0) text = this._removeDuplicateContent(text);
else if (groundingMetadata?.groundingSupports) text = insertCitations(text, groundingMetadata.groundingSupports);
return formatSearchResult$1({
text,
citations: this._extractCitations(groundingMetadata)
}, query);
}
throw new Error("No valid response from Code Assist API");
}
} catch (error) {
console.error("Search error:", error);
return formatError(error, { query });
}
}
async batchSearch(queries, options = { scrapeContent: true }) {
const results = [];
const DEFAULT_BATCH_SIZE = 5;
const DEFAULT_RATE_LIMIT_DELAY_MS = 100;
const batchSize = Number.parseInt(process.env.BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10);
const delay = Number.parseInt(process.env.RATE_LIMIT_DELAY || String(DEFAULT_RATE_LIMIT_DELAY_MS), 10);
let rateLimitErrors = 0;
const RATE_LIMIT_THRESHOLD = 2;
const RATE_LIMIT_BACKOFF_MULTIPLIER = 3;
for (let i = 0; i < queries.length; i += batchSize) {
const batch = queries.slice(i, i + batchSize);
const batchPromises = batch.map(async (query) => {
try {
const searchResult = await this._searchWithDetails(query);
const urls = searchResult.searchResults.map((r) => r.url);
const scrapedContent = options.scrapeContent && urls.length > 0 ? await this.scraper.scrapeUrls(urls, {
contentMode: options.contentMode,
maxContentLength: options.maxContentLength
}) : [];
return {
query,
summary: searchResult.summary,
citations: searchResult.citations,
searchResults: searchResult.searchResults,
scrapedContent,
searchResultCount: searchResult.searchResults.length,
targetResultCount: 5
};
} catch (error) {
console.error(`Error processing query "${query}":`, error);
const errorMessage = error.message;
if (errorMessage.includes("429") || errorMessage.includes("RESOURCE_EXHAUSTED")) {
rateLimitErrors++;
return {
query,
error: `Failed to load Code Assist: ${JSON.stringify({ error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED"
} }, null, 2)}`
};
}
return {
query,
error: errorMessage
};
}
});
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
if (i + batchSize < queries.length) if (rateLimitErrors >= RATE_LIMIT_THRESHOLD) {
const backoffDelay = delay * RATE_LIMIT_BACKOFF_MULTIPLIER;
console.error(`Rate limit errors detected (${rateLimitErrors}). Increasing delay to ${backoffDelay / 1e3} seconds for next batch.`);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
rateLimitErrors = 0;
} else await new Promise((resolve) => setTimeout(resolve, delay));
}
return formatBatchResults(results);
}
async _searchWithDetails(query) {
try {
let response;
if (this.auth.isApiKey() && this.model) {
const result = await this.model.generateContent(query);
response = result.response;
} else {
const apiResponse = await this._oauthSearch(query);
const candidates = apiResponse.candidates || apiResponse.response?.candidates;
response = {
text: () => candidates?.[0]?.content?.parts?.[0]?.text || "",
candidates
};
}
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const searchResults = extractSearchResults(groundingMetadata);
let summary = response.text();
const citationPattern = /\[\d+\]/g;
const existingCitations = summary.match(citationPattern);
if (!existingCitations && groundingMetadata?.groundingSupports) summary = insertCitations(summary, groundingMetadata.groundingSupports);
return {
summary,
searchResults,
citations: this._extractCitations(groundingMetadata)
};
} catch (error) {
console.error("Search details error:", error);
throw error;
}
}
async _oauthSearch(query) {
if (!this.codeAssistClient) throw new Error("Code Assist client not initialized");
const response = await this.codeAssistClient.generateContent("gemini-2.5-flash", query);
return response;
}
async _oauthRequest(prompt) {
if (!this.codeAssistClient) throw new Error("Code Assist client not initialized");
const response = await this.codeAssistClient.generateContent("gemini-2.5-flash", prompt);
return response;
}
_extractCitations(groundingMetadata) {
if (!groundingMetadata || !groundingMetadata.groundingChunks) return [];
return groundingMetadata.groundingChunks.filter((chunk) => chunk.web).map((chunk, index) => ({
number: index + 1,
title: chunk.web?.title || "Untitled",
url: chunk.web?.uri || ""
}));
}
async scrapeUrl(url) {
return this.scraper.scrapeUrl(url);
}
_removeDuplicateContent(text) {
const sentencePattern = /(?<=[.!?])(?:\[\d+\])*\s*/g;
const sentences = text.split(sentencePattern).filter((s) => s.trim());
const seenSentences = /* @__PURE__ */ new Map();
const result = [];
for (const sentence of sentences) if (sentence.trim()) {
const normalizedKey = sentence.replace(/\[\d+\]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
if (!seenSentences.has(normalizedKey)) {
seenSentences.set(normalizedKey, sentence);
result.push(sentence);
} else {
const existing = seenSentences.get(normalizedKey) || "";
const existingHasCitations = /\[\d+\]/.test(existing);
const currentHasCitations = /\[\d+\]/.test(sentence);
if (!existingHasCitations && currentHasCitations) {
const index = result.indexOf(existing);
if (index !== -1) {
result[index] = sentence;
seenSentences.set(normalizedKey, sentence);
}
}
}
}
return result.join(". ").replace(/\.\s*\./g, ".").trim();
}
};
//#endregion
//#region src/index.ts
const packageJson = JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(__dirname, "..", "package.json"), "utf-8"));
const server = new __modelcontextprotocol_sdk_server_index_js.Server({
name: "gemini-grounding",
vendor: "gemini-grounding-mcp",
version: packageJson.version,
description: packageJson.description
}, { capabilities: { tools: {} } });
let geminiClient;
try {
geminiClient = new GeminiClient();
} catch (error) {
console.error("Failed to initialize Gemini client:", error);
process.exit(1);
}
const TOOLS = [{
name: "google_search",
description: "Uses Google Search via Gemini AI grounding to find information and provide synthesized answers with citations. Returns AI-generated summaries rather than raw search results.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query to find information on the web"
},
includeSearchResults: {
type: "boolean",
description: "Include raw search results in addition to AI summary",
default: false
},
maxResults: {
type: "number",
description: "Maximum number of search results to return",
default: 5
}
},
required: ["query"]
}
}, {
name: "google_search_batch",
description: "Search multiple queries in parallel and optionally scrape content from results. Processes up to 10 queries simultaneously for comprehensive research.",
inputSchema: {
type: "object",
properties: {
queries: {
type: "array",
items: { type: "string" },
description: "Array of search queries (max 10)",
minItems: 1,
maxItems: 10
},
scrapeContent: {
type: "boolean",
description: "Whether to scrape full content from search result URLs",
default: true
},
contentMode: {
type: "string",
enum: [
"excerpt",
"summary",
"full"
],
description: "Content extraction mode: excerpt (AI summary ~1000 chars), summary (AI summary ~3000 chars), or full",
default: "full"
},
maxContentLength: {
type: "number",
description: "Maximum content length for full mode (default: 10000)",
default: 1e4
}
},
required: ["queries"]
}
}];
server.setRequestHandler(__modelcontextprotocol_sdk_types_js.ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
server.setRequestHandler(__modelcontextprotocol_sdk_types_js.CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "google_search": {
if (!args?.query || typeof args.query !== "string") throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.InvalidParams, "Query parameter is required and must be a string");
const result = await geminiClient.searchWithOptions(args.query, {
includeSearchResults: args.includeSearchResults,
maxResults: args.maxResults
});
if ("error" in result && result.error) throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.InternalError, result.message);
return { content: [{
type: "text",
text: formatSearchResult(result)
}] };
}
case "google_search_batch": {
if (!args?.queries || !Array.isArray(args.queries)) throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.InvalidParams, "Queries parameter is required and must be an array");
if (args.queries.length === 0 || args.queries.length > 10) throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.InvalidParams, "Queries array must contain between 1 and 10 items");
const scrapeContent = args.scrapeContent !== false;
const result = await geminiClient.batchSearch(args.queries, {
scrapeContent,
contentMode: args.contentMode,
maxContentLength: args.maxContentLength
});
return { content: [{
type: "text",
text: formatBatchSearchResult(result)
}] };
}
default: throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
if (error instanceof __modelcontextprotocol_sdk_types_js.McpError) throw error;
console.error(`Error in tool ${name}:`, error);
throw new __modelcontextprotocol_sdk_types_js.McpError(__modelcontextprotocol_sdk_types_js.ErrorCode.InternalError, error instanceof Error ? error.message : "An unknown error occurred");
}
});
function formatSearchResult(result) {
let output = `Query: "${result.query}"\n\n`;
output += `${result.summary}\n`;
if (result.citations && result.citations.length > 0) {
output += "\nCitations:\n";
for (const citation of result.citations) output += `[${citation.number}] ${citation.title}\n ${citation.url}\n`;
}
return output;
}
function formatBatchSearchResult(result) {
let output = `# Batch Search Results (${result.totalQueries} ${result.totalQueries === 1 ? "query" : "queries"})\n\n`;
output += `${"=".repeat(50)}\n\n`;
let queryIndex = 0;
for (const queryResult of result.results) {
queryIndex++;
output += `## Query ${queryIndex}: "${queryResult.query}"\n\n`;
if (queryResult.error) {
output += `❌ **Error**: ${queryResult.error}\n\n`;
output += `${"-".repeat(50)}\n\n`;
continue;
}
if (queryResult.summary) output += `### Summary\n\n${queryResult.summary}\n\n`;
if (queryResult.citations && queryResult.citations.length > 0) {
output += `### Citations\n`;
for (const citation of queryResult.citations) output += `[${citation.number}] ${citation.title}\n ${citation.url}\n`;
output += "\n";
}
if (queryResult.searchResults && queryResult.searchResults.length > 0) {
const resultCount = queryResult.searchResultCount || queryResult.searchResults.length;
const targetCount = queryResult.targetResultCount || 5;
output += `### Search Results (${resultCount}/${targetCount})\n\n`;
for (const [idx, result$1] of queryResult.searchResults.entries()) {
output += `**${idx + 1}. ${result$1.title}**\n`;
output += `- URL: ${result$1.url}\n`;
if (result$1.snippet) output += `- Snippet: ${result$1.snippet}\n`;
output += "\n";
}
}
if (queryResult.scrapedContent && queryResult.scrapedContent.length > 0) {
output += `### Scraped Content\n\n`;
let successCount = 0;
let failureCount = 0;
for (const content of queryResult.scrapedContent) if (content.error) {
failureCount++;
output += `#### ❌ Failed: ${content.title}\n`;
output += `- URL: ${content.url}\n`;
output += `- Error: ${content.error}\n\n`;
} else {
successCount++;
output += `#### ✅ ${content.title}\n`;
output += `- URL: ${content.url}\n`;
if (content.content) {
const contentPreview = content.content.slice(0, 200);
output += `- Content Preview: ${contentPreview}${content.content.length > 200 ? "..." : ""}\n`;
output += `- Full Length: ${content.content.length} characters\n`;
}
output += "\n";
}
if (successCount > 0 || failureCount > 0) output += `📊 **Scraping Stats**: ${successCount} succeeded, ${failureCount} failed\n\n`;
}
output += `${"-".repeat(50)}\n\n`;
}
return output;
}
async function main() {
const transport = new __modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport();
await server.connect(transport);
console.error("Gemini Grounding MCP server started");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
//#endregion