Skip to content

Commit 747ec66

Browse files
committed
feat: Add Google Researcher MCP Server to marketplace with local fallback
- Add local marketplace data file for development (src/services/marketplace/data/mcps.yaml) - Implement local fallback functionality in RemoteConfigLoader - Add Google Researcher MCP Server configuration with multiple installation methods: - STDIO installation (recommended) - Local installation - HTTP+SSE installation - Include proper parameter configuration for Google APIs - Add comprehensive test coverage for local fallback mechanism Addresses issue #5438: Add Google Researcher MCP Server to marketplace
1 parent 25be233 commit 747ec66

File tree

3 files changed

+306
-10
lines changed

3 files changed

+306
-10
lines changed

src/services/marketplace/RemoteConfigLoader.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import axios from "axios"
22
import * as yaml from "yaml"
33
import { z } from "zod"
4+
import * as fs from "fs"
5+
import * as path from "path"
46
import { getRooCodeApiUrl } from "@roo-code/cloud"
57
import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types"
68
import { modeMarketplaceItemSchema, mcpMarketplaceItemSchema } from "@roo-code/types"
@@ -57,19 +59,51 @@ export class RemoteConfigLoader {
5759
const cached = this.getFromCache(cacheKey)
5860
if (cached) return cached
5961

60-
const data = await this.fetchWithRetry<string>(`${this.apiBaseUrl}/api/marketplace/mcps`)
62+
try {
63+
const data = await this.fetchWithRetry<string>(`${this.apiBaseUrl}/api/marketplace/mcps`)
6164

62-
// Parse and validate YAML response
63-
const yamlData = yaml.parse(data)
64-
const validated = mcpMarketplaceResponse.parse(yamlData)
65+
// Parse and validate YAML response
66+
const yamlData = yaml.parse(data)
67+
const validated = mcpMarketplaceResponse.parse(yamlData)
6568

66-
const items: MarketplaceItem[] = validated.items.map((item) => ({
67-
type: "mcp" as const,
68-
...item,
69-
}))
69+
const items: MarketplaceItem[] = validated.items.map((item) => ({
70+
type: "mcp" as const,
71+
...item,
72+
}))
7073

71-
this.setCache(cacheKey, items)
72-
return items
74+
this.setCache(cacheKey, items)
75+
return items
76+
} catch (error) {
77+
// Fallback to local development data if remote fetch fails
78+
console.warn("Failed to fetch remote MCP data, falling back to local data:", error)
79+
return this.loadLocalMcps()
80+
}
81+
}
82+
83+
private async loadLocalMcps(): Promise<MarketplaceItem[]> {
84+
try {
85+
const localDataPath = path.join(__dirname, "data", "mcps.yaml")
86+
87+
if (!fs.existsSync(localDataPath)) {
88+
console.warn("Local MCP data file not found:", localDataPath)
89+
return []
90+
}
91+
92+
const fileContent = fs.readFileSync(localDataPath, "utf-8")
93+
const yamlData = yaml.parse(fileContent)
94+
const validated = mcpMarketplaceResponse.parse(yamlData)
95+
96+
const items: MarketplaceItem[] = validated.items.map((item) => ({
97+
type: "mcp" as const,
98+
...item,
99+
}))
100+
101+
console.log(`Loaded ${items.length} MCP items from local data`)
102+
return items
103+
} catch (error) {
104+
console.error("Failed to load local MCP data:", error)
105+
return []
106+
}
73107
}
74108

75109
private async fetchWithRetry<T>(url: string, maxRetries = 3): Promise<T> {

src/services/marketplace/__tests__/RemoteConfigLoader.spec.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
// npx vitest services/marketplace/__tests__/RemoteConfigLoader.spec.ts
22

33
import axios from "axios"
4+
import * as fs from "fs"
5+
import * as path from "path"
46
import { RemoteConfigLoader } from "../RemoteConfigLoader"
57
import type { MarketplaceItemType } from "@roo-code/types"
68

79
// Mock axios
810
vi.mock("axios")
911
const mockedAxios = axios as any
1012

13+
// Mock fs
14+
vi.mock("fs")
15+
const mockedFs = fs as any
16+
17+
// Mock path
18+
vi.mock("path")
19+
const mockedPath = path as any
20+
1121
// Mock the cloud config
1222
vi.mock("@roo-code/cloud", () => ({
1323
getRooCodeApiUrl: () => "https://test.api.com",
@@ -332,4 +342,202 @@ describe("RemoteConfigLoader", () => {
332342
Date.now = originalDateNow
333343
})
334344
})
345+
346+
describe("local fallback functionality", () => {
347+
beforeEach(() => {
348+
// Reset mocks
349+
vi.clearAllMocks()
350+
loader.clearCache()
351+
// Mock path.join to return a predictable path
352+
mockedPath.join.mockReturnValue("/test/path/data/mcps.yaml")
353+
})
354+
355+
it("should fallback to local data when remote API fails", async () => {
356+
const localMcpsYaml = `items:
357+
- id: "test-mcp"
358+
name: "Test MCP"
359+
description: "A test MCP"
360+
url: "https://github.com/test/test-mcp"
361+
content:
362+
- name: "Installation"
363+
content: '{"command": "test"}'`
364+
365+
// Mock remote API failure
366+
mockedAxios.get.mockImplementation((url: string) => {
367+
if (url.includes("/modes")) {
368+
return Promise.resolve({ data: "items: []" })
369+
}
370+
if (url.includes("/mcps")) {
371+
return Promise.reject(new Error("Network error"))
372+
}
373+
return Promise.reject(new Error("Unknown URL"))
374+
})
375+
376+
// Mock local file system
377+
mockedFs.existsSync.mockReturnValue(true)
378+
mockedFs.readFileSync.mockReturnValue(localMcpsYaml)
379+
380+
const items = await loader.loadAllItems()
381+
382+
// Should have attempted remote call first
383+
expect(mockedAxios.get).toHaveBeenCalledWith(
384+
"https://test.api.com/api/marketplace/mcps",
385+
expect.any(Object),
386+
)
387+
388+
// Should have fallen back to local file
389+
expect(mockedFs.existsSync).toHaveBeenCalled()
390+
expect(mockedFs.readFileSync).toHaveBeenCalled()
391+
392+
// Should contain the test MCP
393+
expect(items).toHaveLength(1)
394+
expect(items[0]).toEqual({
395+
type: "mcp",
396+
id: "test-mcp",
397+
name: "Test MCP",
398+
description: "A test MCP",
399+
url: "https://github.com/test/test-mcp",
400+
content: [
401+
{
402+
name: "Installation",
403+
content: '{"command": "test"}',
404+
},
405+
],
406+
})
407+
})
408+
409+
it("should return empty array when local file doesn't exist", async () => {
410+
// Mock remote API failure
411+
mockedAxios.get.mockImplementation((url: string) => {
412+
if (url.includes("/modes")) {
413+
return Promise.resolve({ data: "items: []" })
414+
}
415+
if (url.includes("/mcps")) {
416+
return Promise.reject(new Error("Network error"))
417+
}
418+
return Promise.reject(new Error("Unknown URL"))
419+
})
420+
421+
// Mock local file not existing
422+
mockedFs.existsSync.mockReturnValue(false)
423+
424+
const items = await loader.loadAllItems()
425+
426+
// Should have attempted remote call first
427+
expect(mockedAxios.get).toHaveBeenCalledWith(
428+
"https://test.api.com/api/marketplace/mcps",
429+
expect.any(Object),
430+
)
431+
432+
// Should have checked for local file
433+
expect(mockedFs.existsSync).toHaveBeenCalledWith(expect.stringContaining("data/mcps.yaml"))
434+
435+
// Should not have tried to read the file
436+
expect(mockedFs.readFileSync).not.toHaveBeenCalled()
437+
438+
// Should return empty array (only modes, no MCPs)
439+
expect(items).toHaveLength(0)
440+
})
441+
442+
it("should handle local file read errors gracefully", async () => {
443+
// Mock remote API failure
444+
mockedAxios.get.mockImplementation((url: string) => {
445+
if (url.includes("/modes")) {
446+
return Promise.resolve({ data: "items: []" })
447+
}
448+
if (url.includes("/mcps")) {
449+
return Promise.reject(new Error("Network error"))
450+
}
451+
return Promise.reject(new Error("Unknown URL"))
452+
})
453+
454+
// Mock local file exists but read fails
455+
mockedFs.existsSync.mockReturnValue(true)
456+
mockedFs.readFileSync.mockImplementation(() => {
457+
throw new Error("File read error")
458+
})
459+
460+
const items = await loader.loadAllItems()
461+
462+
// Should have attempted to read local file
463+
expect(mockedFs.readFileSync).toHaveBeenCalledWith(expect.stringContaining("data/mcps.yaml"), "utf-8")
464+
465+
// Should return empty array when local fallback fails
466+
expect(items).toHaveLength(0)
467+
})
468+
469+
it("should prefer remote data over local when remote is available", async () => {
470+
const remoteMcpsYaml = `items:
471+
- id: "remote-mcp"
472+
name: "Remote MCP"
473+
description: "From remote API"
474+
url: "https://github.com/remote/mcp"
475+
content:
476+
- name: "Installation"
477+
content: '{"command": "remote"}'`
478+
479+
// Mock successful remote API
480+
mockedAxios.get.mockImplementation((url: string) => {
481+
if (url.includes("/modes")) {
482+
return Promise.resolve({ data: "items: []" })
483+
}
484+
if (url.includes("/mcps")) {
485+
return Promise.resolve({ data: remoteMcpsYaml })
486+
}
487+
return Promise.reject(new Error("Unknown URL"))
488+
})
489+
490+
const items = await loader.loadAllItems()
491+
492+
// Should have used remote data
493+
expect(items).toHaveLength(1)
494+
expect(items[0].id).toBe("remote-mcp")
495+
expect(items[0].name).toBe("Remote MCP")
496+
497+
// Should not have accessed local file system
498+
expect(mockedFs.existsSync).not.toHaveBeenCalled()
499+
expect(mockedFs.readFileSync).not.toHaveBeenCalled()
500+
})
501+
})
502+
503+
describe("Google Researcher MCP Server integration", () => {
504+
it("should find Google Researcher MCP by ID when loaded from local data", async () => {
505+
const localMcpsYaml = `items:
506+
- id: "google-researcher-mcp"
507+
name: "Google Researcher MCP Server"
508+
description: "Power your AI agents with Google Search–enhanced research"
509+
author: "Zohar Babin"
510+
url: "https://github.com/zoharbabin/google-research-mcp"
511+
content:
512+
- name: "STDIO Installation"
513+
content: '{"google-researcher": {"command": "npx", "args": ["google-researcher-mcp@latest"]}}'`
514+
515+
// Mock remote API failure to trigger local fallback
516+
mockedAxios.get.mockImplementation((url: string) => {
517+
if (url.includes("/modes")) {
518+
return Promise.resolve({ data: "items: []" })
519+
}
520+
if (url.includes("/mcps")) {
521+
return Promise.reject(new Error("Network error"))
522+
}
523+
return Promise.reject(new Error("Unknown URL"))
524+
})
525+
526+
// Mock local file system
527+
mockedFs.existsSync.mockReturnValue(true)
528+
mockedFs.readFileSync.mockReturnValue(localMcpsYaml)
529+
530+
const item = await loader.getItem("google-researcher-mcp", "mcp" as MarketplaceItemType)
531+
532+
expect(item).not.toBeNull()
533+
expect(item?.id).toBe("google-researcher-mcp")
534+
expect(item?.name).toBe("Google Researcher MCP Server")
535+
expect(item?.author).toBe("Zohar Babin")
536+
537+
// Type guard to ensure we have an MCP item
538+
if (item?.type === "mcp") {
539+
expect(item.url).toBe("https://github.com/zoharbabin/google-research-mcp")
540+
}
541+
})
542+
})
335543
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
items:
2+
- id: "google-researcher-mcp"
3+
name: "Google Researcher MCP Server"
4+
description: "Power your AI agents with Google Search–enhanced research via an open-source MCP server. Includes tools for Google Search, YouTube/web scraping, LLM-driven synthesis, persistent caching, and dual transport (STDIO + HTTP SSE) for efficient, flexible integration."
5+
author: "Zohar Babin"
6+
authorUrl: "https://github.com/zoharbabin"
7+
url: "https://github.com/zoharbabin/google-research-mcp"
8+
tags: ["research", "google", "search", "web-scraping", "ai-analysis", "caching"]
9+
prerequisites:
10+
- "Node.js 18.0.0 or higher"
11+
- "Google Custom Search API Key"
12+
- "Google Custom Search Engine ID"
13+
- "Google Gemini API Key"
14+
parameters:
15+
- name: "Google Custom Search API Key"
16+
key: "google_search_api_key"
17+
placeholder: "Enter your Google Custom Search API key"
18+
- name: "Google Custom Search Engine ID"
19+
key: "google_search_engine_id"
20+
placeholder: "Enter your Google Custom Search Engine ID"
21+
- name: "Google Gemini API Key"
22+
key: "google_gemini_api_key"
23+
placeholder: "Enter your Google Gemini API key"
24+
content:
25+
- name: "STDIO Installation (Recommended)"
26+
content: '{"google-researcher": {"command": "npx", "args": ["google-researcher-mcp@latest"], "env": {"GOOGLE_SEARCH_API_KEY": "{{google_search_api_key}}", "GOOGLE_SEARCH_ENGINE_ID": "{{google_search_engine_id}}", "GOOGLE_GEMINI_API_KEY": "{{google_gemini_api_key}}"}}}'
27+
prerequisites:
28+
- "Ensure you have npm installed"
29+
- "Get API keys from Google Cloud Console"
30+
- name: "Local Installation"
31+
content: '{"google-researcher": {"command": "node", "args": ["{{install_path}}/dist/server.js"], "env": {"GOOGLE_SEARCH_API_KEY": "{{google_search_api_key}}", "GOOGLE_SEARCH_ENGINE_ID": "{{google_search_engine_id}}", "GOOGLE_GEMINI_API_KEY": "{{google_gemini_api_key}}"}}}'
32+
parameters:
33+
- name: "Installation Path"
34+
key: "install_path"
35+
placeholder: "/path/to/google-research-mcp"
36+
prerequisites:
37+
- "Clone the repository: git clone https://github.com/zoharbabin/google-research-mcp.git"
38+
- "Install dependencies: npm install"
39+
- "Build the project: npm run build"
40+
- name: "HTTP+SSE Installation"
41+
content: '{"google-researcher": {"url": "http://localhost:{{port}}/mcp", "headers": {"Authorization": "Bearer {{oauth_token}}"}}}'
42+
parameters:
43+
- name: "Server Port"
44+
key: "port"
45+
placeholder: "3000"
46+
optional: true
47+
- name: "OAuth Token"
48+
key: "oauth_token"
49+
placeholder: "Enter your OAuth token"
50+
optional: true
51+
prerequisites:
52+
- "Start the server: npm start"
53+
- "Configure OAuth 2.1 provider (optional)"
54+
- "Server will be available at http://localhost:3000/mcp"

0 commit comments

Comments
 (0)