diff --git a/marketplace-data/README.md b/marketplace-data/README.md new file mode 100644 index 0000000000..d75c8f40cf --- /dev/null +++ b/marketplace-data/README.md @@ -0,0 +1,97 @@ +# Marketplace Data + +This directory contains marketplace data files for Roo Code's marketplace system. + +## Structure + +- `mcps.yaml` - MCP (Model Context Protocol) servers available in the marketplace +- `modes.yaml` - Custom modes available in the marketplace (to be added) + +## MCP Servers + +The `mcps.yaml` file contains a list of MCP servers that can be installed through the Roo Code marketplace. Each MCP server entry includes: + +- `id` - Unique identifier for the MCP server +- `name` - Display name +- `description` - Detailed description of functionality +- `author` - Author name +- `authorUrl` - Author's website or GitHub profile +- `url` - Repository or documentation URL +- `tags` - Array of tags for categorization +- `prerequisites` - Array of requirements needed before installation +- `content` - Installation methods (can be a single string or array of methods) + +### Installation Methods + +Each installation method can include: + +- `name` - Method name (e.g., "uvx", "pip install") +- `content` - JSON configuration to be added to MCP settings +- `parameters` - Optional parameters that users can customize +- `prerequisites` - Method-specific prerequisites + +### Parameters + +Parameters allow users to customize the installation: + +- `name` - Display name for the parameter +- `key` - Key used in the configuration template +- `placeholder` - Default value or example +- `optional` - Whether the parameter is optional (default: false) + +## VoiceVox MCP Server + +The VoiceVox MCP Server provides Japanese text-to-speech capabilities using the VoiceVox engine. It includes: + +### Features + +- Text-to-Speech conversion for Japanese text +- Multiple voice characters with different personalities +- Customizable speech speed and voice selection +- Automatic audio playback after generation +- WAV format audio file management + +### Available Tools + +1. `get_voices` - Retrieve list of available voices from VoiceVox +2. `text_to_speech` - Convert text to speech with customizable settings + +### Use Cases + +- Educational content creation with Japanese narration +- Accessibility improvements for Japanese content +- Podcast and video production +- Language learning applications +- Chatbot voice functionality + +### Installation Options + +1. **uvx (Recommended)** - Uses uvx to run the server directly +2. **pip install** - Install via pip and run with Python +3. **Custom Configuration** - Full customization with all available parameters + +### Prerequisites + +- VoiceVox Engine running (default: http://localhost:50021) +- Python 3.10+ +- For uvx method: `pip install uvx` +- For pip method: `pip install mcp-server-voicevox` + +## Usage + +This marketplace data is used by the Roo Code extension to populate the marketplace UI. Users can browse, search, and install MCP servers directly from the extension. + +## Contributing + +To add a new MCP server to the marketplace: + +1. Add an entry to `mcps.yaml` following the schema +2. Ensure all required fields are provided +3. Test the installation methods +4. Submit a pull request + +## References + +- [VoiceVox MCP Server Repository](https://github.com/Sunwood-ai-labs/mcp-voicevox) +- [VoiceVox MCP Server PyPI Package](https://pypi.org/project/mcp-server-voicevox/) +- [Model Context Protocol Documentation](https://docs.roocode.com/advanced-usage/mcp) diff --git a/marketplace-data/mcps.yaml b/marketplace-data/mcps.yaml new file mode 100644 index 0000000000..e12131e5f1 --- /dev/null +++ b/marketplace-data/mcps.yaml @@ -0,0 +1,74 @@ +items: + - id: "voicevox-tts" + name: "VoiceVox Text-to-Speech Server" + description: "Japanese Text-to-Speech integration using VoiceVox engine with multiple voice characters and customizable playback settings" + author: "Sunwood AI Labs" + authorUrl: "https://github.com/Sunwood-ai-labs" + url: "https://github.com/Sunwood-ai-labs/mcp-voicevox" + tags: + - "text-to-speech" + - "japanese" + - "audio" + - "voice" + - "accessibility" + - "content-creation" + prerequisites: + - "VoiceVox Engine running (locally or remotely)" + - "Python 3.10+" + content: + - name: "uvx (Recommended)" + content: | + { + "voicevox": { + "command": "uvx", + "args": ["mcp-server-voicevox", "--voicevox-url=http://localhost:50021"] + } + } + parameters: + - name: "VoiceVox URL" + key: "voicevox-url" + placeholder: "http://localhost:50021" + optional: true + prerequisites: + - "uvx installed (pip install uvx)" + - "VoiceVox Engine running on specified URL" + - name: "pip install" + content: | + { + "voicevox": { + "command": "python", + "args": ["-m", "mcp_server_voicevox", "--voicevox-url=http://localhost:50021"] + } + } + parameters: + - name: "VoiceVox URL" + key: "voicevox-url" + placeholder: "http://localhost:50021" + optional: true + prerequisites: + - "pip install mcp-server-voicevox" + - "VoiceVox Engine running on specified URL" + - name: "Custom Configuration" + content: | + { + "voicevox": { + "command": "uvx", + "args": ["mcp-server-voicevox", "--voicevox-url={{voicevox-url}}", "--default-speaker={{default-speaker}}", "--speed={{speed}}"] + } + } + parameters: + - name: "VoiceVox URL" + key: "voicevox-url" + placeholder: "http://localhost:50021" + optional: false + - name: "Default Speaker ID" + key: "default-speaker" + placeholder: "1" + optional: true + - name: "Speech Speed" + key: "speed" + placeholder: "1.0" + optional: true + prerequisites: + - "uvx installed (pip install uvx)" + - "VoiceVox Engine running on specified URL" \ No newline at end of file diff --git a/src/services/marketplace/RemoteConfigLoader.ts b/src/services/marketplace/RemoteConfigLoader.ts index a37f619b4d..258c36cd5b 100644 --- a/src/services/marketplace/RemoteConfigLoader.ts +++ b/src/services/marketplace/RemoteConfigLoader.ts @@ -1,5 +1,7 @@ import axios from "axios" import * as yaml from "yaml" +import * as fs from "fs/promises" +import * as path from "path" import { z } from "zod" import { getRooCodeApiUrl } from "@roo-code/cloud" import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types" @@ -37,19 +39,24 @@ export class RemoteConfigLoader { const cached = this.getFromCache(cacheKey) if (cached) return cached - const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/modes`) + try { + const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/modes`) - // Parse and validate YAML response - const yamlData = yaml.parse(data) - const validated = modeMarketplaceResponse.parse(yamlData) + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = modeMarketplaceResponse.parse(yamlData) - const items: MarketplaceItem[] = validated.items.map((item) => ({ - type: "mode" as const, - ...item, - })) + const items: MarketplaceItem[] = validated.items.map((item) => ({ + type: "mode" as const, + ...item, + })) - this.setCache(cacheKey, items) - return items + this.setCache(cacheKey, items) + return items + } catch (error) { + console.warn("Failed to fetch modes from remote API, trying local fallback:", error) + return this.fetchLocalModes() + } } private async fetchMcps(): Promise { @@ -57,19 +64,24 @@ export class RemoteConfigLoader { const cached = this.getFromCache(cacheKey) if (cached) return cached - const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/mcps`) + try { + const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/mcps`) - // Parse and validate YAML response - const yamlData = yaml.parse(data) - const validated = mcpMarketplaceResponse.parse(yamlData) + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = mcpMarketplaceResponse.parse(yamlData) - const items: MarketplaceItem[] = validated.items.map((item) => ({ - type: "mcp" as const, - ...item, - })) + const items: MarketplaceItem[] = validated.items.map((item) => ({ + type: "mcp" as const, + ...item, + })) - this.setCache(cacheKey, items) - return items + this.setCache(cacheKey, items) + return items + } catch (error) { + console.warn("Failed to fetch MCPs from remote API, trying local fallback:", error) + return this.fetchLocalMcps() + } } private async fetchWithRetry(url: string, maxRetries = 3): Promise { @@ -126,4 +138,104 @@ export class RemoteConfigLoader { clearCache(): void { this.cache.clear() } + + /** + * Fallback method to load MCPs from local marketplace data file + */ + private async fetchLocalMcps(): Promise { + try { + // Try to load from local marketplace-data directory + // Look for marketplace-data in current directory, parent directories, or relative to __dirname + const possiblePaths = [ + path.join(process.cwd(), "marketplace-data", "mcps.yaml"), + path.join(process.cwd(), "..", "marketplace-data", "mcps.yaml"), + path.join(process.cwd(), "..", "..", "marketplace-data", "mcps.yaml"), + path.join(process.cwd(), "..", "..", "..", "marketplace-data", "mcps.yaml"), + path.join(__dirname, "..", "..", "..", "marketplace-data", "mcps.yaml"), + ] + + let data: string | null = null + let usedPath: string | null = null + + for (const localMcpPath of possiblePaths) { + try { + data = await fs.readFile(localMcpPath, "utf-8") + usedPath = localMcpPath + break + } catch (error) { + // Continue to next path + continue + } + } + + if (!data) { + throw new Error("Could not find mcps.yaml in any expected location") + } + + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = mcpMarketplaceResponse.parse(yamlData) + + const items: MarketplaceItem[] = validated.items.map((item) => ({ + type: "mcp" as const, + ...item, + })) + + console.log(`Loaded ${items.length} MCP items from local marketplace data at ${usedPath}`) + return items + } catch (error) { + console.warn("Failed to load local MCP data:", error) + return [] + } + } + + /** + * Fallback method to load modes from local marketplace data file + */ + private async fetchLocalModes(): Promise { + try { + // Try to load from local marketplace-data directory + // Look for marketplace-data in current directory, parent directories, or relative to __dirname + const possiblePaths = [ + path.join(process.cwd(), "marketplace-data", "modes.yaml"), + path.join(process.cwd(), "..", "marketplace-data", "modes.yaml"), + path.join(process.cwd(), "..", "..", "marketplace-data", "modes.yaml"), + path.join(process.cwd(), "..", "..", "..", "marketplace-data", "modes.yaml"), + path.join(__dirname, "..", "..", "..", "marketplace-data", "modes.yaml"), + ] + + let data: string | null = null + let usedPath: string | null = null + + for (const localModePath of possiblePaths) { + try { + data = await fs.readFile(localModePath, "utf-8") + usedPath = localModePath + break + } catch (error) { + // Continue to next path + continue + } + } + + if (!data) { + throw new Error("Could not find modes.yaml in any expected location") + } + + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = modeMarketplaceResponse.parse(yamlData) + + const items: MarketplaceItem[] = validated.items.map((item) => ({ + type: "mode" as const, + ...item, + })) + + console.log(`Loaded ${items.length} mode items from local marketplace data at ${usedPath}`) + return items + } catch (error) { + console.warn("Failed to load local mode data:", error) + return [] + } + } } diff --git a/src/services/marketplace/__tests__/voicevox-mcp.spec.ts b/src/services/marketplace/__tests__/voicevox-mcp.spec.ts new file mode 100644 index 0000000000..0ee25d1412 --- /dev/null +++ b/src/services/marketplace/__tests__/voicevox-mcp.spec.ts @@ -0,0 +1,123 @@ +import { describe, test, expect, beforeEach, vi } from "vitest" +import nock from "nock" +import { RemoteConfigLoader } from "../RemoteConfigLoader" +import type { MarketplaceItem, McpMarketplaceItem } from "@roo-code/types" + +describe("VoiceVox MCP Server", () => { + let configLoader: RemoteConfigLoader + + beforeEach(() => { + // Mock all remote API calls to force fallback to local data + nock("https://app.roocode.com").get("/api/marketplace/modes").reply(500, "Service unavailable").persist() + + nock("https://app.roocode.com").get("/api/marketplace/mcps").reply(500, "Service unavailable").persist() + + configLoader = new RemoteConfigLoader() + }) + + test("should load VoiceVox MCP server from marketplace data", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem).toBeDefined() + expect(voiceVoxItem?.type).toBe("mcp") + expect(voiceVoxItem?.name).toBe("VoiceVox Text-to-Speech Server") + expect(voiceVoxItem?.author).toBe("Sunwood AI Labs") + + // Type assertion for MCP item to access url property + const mcpItem = voiceVoxItem as McpMarketplaceItem & { type: "mcp" } + expect(mcpItem?.url).toBe("https://github.com/Sunwood-ai-labs/mcp-voicevox") + }) + + test("should have correct tags for VoiceVox MCP server", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem?.tags).toContain("text-to-speech") + expect(voiceVoxItem?.tags).toContain("japanese") + expect(voiceVoxItem?.tags).toContain("audio") + expect(voiceVoxItem?.tags).toContain("voice") + expect(voiceVoxItem?.tags).toContain("accessibility") + expect(voiceVoxItem?.tags).toContain("content-creation") + }) + + test("should have multiple installation methods", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem).toBeDefined() + + // Type assertion for MCP item + const mcpItem = voiceVoxItem as McpMarketplaceItem & { type: "mcp" } + expect(Array.isArray(mcpItem.content)).toBe(true) + + const content = mcpItem.content as any[] + expect(content).toHaveLength(3) + + // Check installation method names + const methodNames = content.map((method) => method.name) + expect(methodNames).toContain("uvx (Recommended)") + expect(methodNames).toContain("pip install") + expect(methodNames).toContain("Custom Configuration") + }) + + test("should have correct prerequisites", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem?.prerequisites).toContain("VoiceVox Engine running (locally or remotely)") + expect(voiceVoxItem?.prerequisites).toContain("Python 3.10+") + }) + + test("should have parameters for custom configuration", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem).toBeDefined() + + // Type assertion for MCP item + const mcpItem = voiceVoxItem as McpMarketplaceItem & { type: "mcp" } + const content = mcpItem.content as any[] + const customConfig = content.find((method) => method.name === "Custom Configuration") + + expect(customConfig).toBeDefined() + expect(customConfig.parameters).toBeDefined() + expect(customConfig.parameters).toHaveLength(3) + + const parameterKeys = customConfig.parameters.map((param: any) => param.key) + expect(parameterKeys).toContain("voicevox-url") + expect(parameterKeys).toContain("default-speaker") + expect(parameterKeys).toContain("speed") + }) + + test("should be retrievable by ID and type", async () => { + const voiceVoxItem = await configLoader.getItem("voicevox-tts", "mcp") + + expect(voiceVoxItem).toBeDefined() + expect(voiceVoxItem?.id).toBe("voicevox-tts") + expect(voiceVoxItem?.type).toBe("mcp") + }) + + test("should have valid JSON configuration content", async () => { + const items = await configLoader.loadAllItems() + const voiceVoxItem = items.find((item) => item.id === "voicevox-tts") + + expect(voiceVoxItem).toBeDefined() + + // Type assertion for MCP item + const mcpItem = voiceVoxItem as McpMarketplaceItem & { type: "mcp" } + const content = mcpItem.content as any[] + + for (const method of content) { + expect(() => { + JSON.parse(method.content) + }).not.toThrow() + + const config = JSON.parse(method.content) + expect(config.voicevox).toBeDefined() + expect(config.voicevox.command).toBeDefined() + expect(config.voicevox.args).toBeDefined() + expect(Array.isArray(config.voicevox.args)).toBe(true) + } + }) +})