From 8bd65c20be4f977a74a5a457ac09bbc0c9987421 Mon Sep 17 00:00:00 2001 From: Seey215 Date: Tue, 4 Nov 2025 15:18:58 +0800 Subject: [PATCH 1/4] feat(official-mcp-registry): Implement Federated Registry Provider for local and official package support - Added FederatedRegistryProvider to prioritize local package queries before official ones. - Developed OfficialRegistryProvider to interact with the official API for package data. - Established registry-factory for initializing and retrieving registry providers. --- src/domains/package/package-handler.ts | 4 + src/domains/package/package-so.test.ts | 32 +- src/domains/package/package-so.ts | 16 +- .../federated-registry-provider.test.ts | 162 +++++++++ .../__tests__/local-registry-provider.test.ts | 101 ++++++ ...cial-registry-provider.integration.test.ts | 83 +++++ .../registry/__tests__/registry-utils.test.ts | 341 ++++++++++++++++++ src/domains/registry/index.ts | 27 ++ .../providers/federated-registry-provider.ts | 56 +++ .../providers/local-registry-provider.ts | 39 ++ .../providers/official-registry-provider.ts | 95 +++++ src/domains/registry/registry-factory.ts | 65 ++++ src/domains/registry/registry-schema.ts | 56 +++ src/domains/registry/registry-types.ts | 24 ++ src/domains/registry/registry-utils.ts | 61 ++++ 15 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 src/domains/registry/__tests__/federated-registry-provider.test.ts create mode 100644 src/domains/registry/__tests__/local-registry-provider.test.ts create mode 100644 src/domains/registry/__tests__/official-registry-provider.integration.test.ts create mode 100644 src/domains/registry/__tests__/registry-utils.test.ts create mode 100644 src/domains/registry/index.ts create mode 100644 src/domains/registry/providers/federated-registry-provider.ts create mode 100644 src/domains/registry/providers/local-registry-provider.ts create mode 100644 src/domains/registry/providers/official-registry-provider.ts create mode 100644 src/domains/registry/registry-factory.ts create mode 100644 src/domains/registry/registry-schema.ts create mode 100644 src/domains/registry/registry-types.ts create mode 100644 src/domains/registry/registry-utils.ts diff --git a/src/domains/package/package-handler.ts b/src/domains/package/package-handler.ts index 31ffbe6c..9c316d3f 100644 --- a/src/domains/package/package-handler.ts +++ b/src/domains/package/package-handler.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { getDirname } from "../../shared/utils/file-util"; import { createErrorResponse, createResponse } from "../../shared/utils/response-util"; import { ExecutorFactory } from "../executor/executor-factory"; +import { initRegistryFactory } from "../registry/registry-factory"; import type { MCPSandboxProvider } from "../sandbox/sandbox-types"; import { PackageRepository } from "./package-repository"; import { PackageSO } from "./package-so"; @@ -11,6 +12,9 @@ const __dirname = getDirname(import.meta.url); const packagesDir = path.join(__dirname, "../../../packages"); const repository = new PackageRepository(packagesDir); +// Initialize Registry Factory with the local repository +initRegistryFactory(repository); + export const packageHandler = { getPackageDetail: async (packageName: string, sandboxProvider?: MCPSandboxProvider) => { try { diff --git a/src/domains/package/package-so.test.ts b/src/domains/package/package-so.test.ts index c09ddfb6..a6b667fe 100644 --- a/src/domains/package/package-so.test.ts +++ b/src/domains/package/package-so.test.ts @@ -1,6 +1,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolExecutor } from "../executor/executor-types"; +import { initRegistryFactory, resetRegistryFactory } from "../registry/registry-factory"; import type { PackageRepository } from "./package-repository"; import { PackageSO } from "./package-so"; import type { MCPServerPackageConfig } from "./package-types"; @@ -10,6 +11,9 @@ describe("PackageSO", () => { let mockExecutor: ToolExecutor; beforeEach(() => { + // Reset factory before each test + resetRegistryFactory(); + // Mock PackageRepository mockRepository = { getPackageConfig: vi.fn(), @@ -17,6 +21,9 @@ describe("PackageSO", () => { exists: vi.fn(), } as unknown as PackageRepository; + // Initialize Registry Factory with mock repository + initRegistryFactory(mockRepository); + // Mock ToolExecutor mockExecutor = { listTools: vi.fn(), @@ -41,6 +48,8 @@ describe("PackageSO", () => { validated: true, }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({ [packageName]: mockPackageInfo, @@ -55,6 +64,7 @@ describe("PackageSO", () => { expect(packageSO.description).toBe("A server for filesystem operations"); expect(packageSO.category).toBe("filesystem"); expect(packageSO.validated).toBe(true); + expect(mockRepository.exists).toHaveBeenCalledWith(packageName); expect(mockRepository.getPackageConfig).toHaveBeenCalledWith(packageName); expect(mockRepository.getAllPackages).toHaveBeenCalled(); }); @@ -70,6 +80,7 @@ describe("PackageSO", () => { description: "A new package", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); @@ -94,6 +105,7 @@ describe("PackageSO", () => { description: null, } as unknown as MCPServerPackageConfig; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); @@ -140,6 +152,7 @@ describe("PackageSO", () => { }, ]; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "listTools").mockResolvedValue(mockTools); @@ -168,6 +181,7 @@ describe("PackageSO", () => { }; const errorMessage = "Failed to list tools"; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "listTools").mockRejectedValue(new Error(errorMessage)); @@ -196,6 +210,7 @@ describe("PackageSO", () => { description: "Test description", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "executeTool").mockResolvedValue(mockResult); @@ -231,6 +246,7 @@ describe("PackageSO", () => { description: "Test description", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "executeTool").mockResolvedValue(mockResult); @@ -265,6 +281,7 @@ describe("PackageSO", () => { description: "Test description", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "executeTool").mockRejectedValue(new Error(errorMessage)); @@ -303,6 +320,7 @@ describe("PackageSO", () => { }, ]; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({ [packageName]: mockPackageInfo, @@ -316,11 +334,11 @@ describe("PackageSO", () => { // Assert expect(detail).toEqual({ + type: "mcp-server", + runtime: "node", name: "Test Package", packageName: "@test/package", description: "A test package for demonstration", - category: "testing", - validated: true, tools: mockTools, }); }); @@ -336,6 +354,7 @@ describe("PackageSO", () => { description: "Test description", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "listTools").mockRejectedValue(new Error("Failed to get tools")); @@ -371,6 +390,7 @@ describe("PackageSO", () => { }; const mockTools: Tool[] = []; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); vi.spyOn(mockExecutor, "listTools").mockResolvedValue(mockTools); @@ -382,12 +402,12 @@ describe("PackageSO", () => { // Assert expect(detail).toEqual({ + type: "mcp-server", + runtime: "node", name: "Minimal Package", packageName: "@test/minimal-package", description: "A minimal package", - category: undefined, - validated: undefined, - tools: mockTools, + tools: [], }); }); }); @@ -409,6 +429,7 @@ describe("PackageSO", () => { validated: false, }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({ [packageName]: mockPackageInfo, @@ -437,6 +458,7 @@ describe("PackageSO", () => { description: "Test description", }; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({}); diff --git a/src/domains/package/package-so.ts b/src/domains/package/package-so.ts index 141c2c57..8b12a204 100644 --- a/src/domains/package/package-so.ts +++ b/src/domains/package/package-so.ts @@ -1,5 +1,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { ToolExecutor } from "../executor/executor-types"; +import { getRegistryProvider } from "../registry/registry-factory"; +import type { IRegistryProvider } from "../registry/registry-types"; import type { PackageRepository } from "./package-repository"; import type { MCPServerPackageConfig, MCPServerPackageConfigWithTools } from "./package-types"; @@ -8,7 +10,6 @@ export class PackageSO { private readonly _packageName: string, private readonly _config: MCPServerPackageConfig, private readonly _packageInfo: { category?: string; validated?: boolean }, - _repository: PackageRepository, private readonly _executor: ToolExecutor, ) {} @@ -36,10 +37,19 @@ export class PackageSO { repository: PackageRepository, executor: ToolExecutor, ): Promise { - const config = repository.getPackageConfig(packageName); + // Use FederatedRegistryProvider to support both local and official packages + const provider: IRegistryProvider = getRegistryProvider("FEDERATED"); + const config = await provider.getPackageConfig(packageName); + + if (!config) { + throw new Error(`Package '${packageName}' not found`); + } + + // Get package metadata from local repository const allPackages = repository.getAllPackages(); const packageInfo = allPackages[packageName] || {}; - return new PackageSO(packageName, config, packageInfo, repository, executor); + + return new PackageSO(packageName, config as MCPServerPackageConfig, packageInfo, executor); } async getTools(): Promise { diff --git a/src/domains/registry/__tests__/federated-registry-provider.test.ts b/src/domains/registry/__tests__/federated-registry-provider.test.ts new file mode 100644 index 00000000..c0ff3e3b --- /dev/null +++ b/src/domains/registry/__tests__/federated-registry-provider.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MCPServerPackageConfig } from "../../package/package-types"; +import { FederatedRegistryProvider } from "../providers/federated-registry-provider"; +import type { LocalRegistryProvider } from "../providers/local-registry-provider"; +import type { OfficialRegistryProvider } from "../providers/official-registry-provider"; + +describe("FederatedRegistryProvider", () => { + let mockLocalProvider: LocalRegistryProvider; + let mockOfficialProvider: OfficialRegistryProvider; + let provider: FederatedRegistryProvider; + + beforeEach(() => { + // Mock LocalRegistryProvider + mockLocalProvider = { + getPackageConfig: vi.fn(), + exists: vi.fn(), + } as unknown as LocalRegistryProvider; + + // Mock OfficialRegistryProvider + mockOfficialProvider = { + getPackageConfig: vi.fn(), + exists: vi.fn(), + search: vi.fn(), + } as unknown as OfficialRegistryProvider; + + provider = new FederatedRegistryProvider(mockLocalProvider, mockOfficialProvider); + }); + + describe("getPackageConfig", () => { + it("should return local config when package exists locally", async () => { + // Arrange + const packageName = "@modelcontextprotocol/server-filesystem"; + const mockConfig: MCPServerPackageConfig = { + type: "mcp-server", + runtime: "node", + packageName, + name: "Filesystem Server", + description: "A server for filesystem operations", + }; + + vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(mockConfig); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toEqual(mockConfig); + expect(mockLocalProvider.getPackageConfig).toHaveBeenCalledWith(packageName); + expect(mockOfficialProvider.getPackageConfig).not.toHaveBeenCalled(); + }); + + it("should query official provider when local returns null", async () => { + // Arrange + const packageName = "@toolsdk.ai/tavily-mcp"; + const officialConfig: MCPServerPackageConfig = { + type: "mcp-server", + runtime: "node", + packageName, + name: "Tavily MCP Server", + description: "MCP server for Tavily search", + }; + + vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null); + vi.spyOn(mockOfficialProvider, "getPackageConfig").mockResolvedValue(officialConfig); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toEqual(officialConfig); + expect(mockLocalProvider.getPackageConfig).toHaveBeenCalledWith(packageName); + expect(mockOfficialProvider.getPackageConfig).toHaveBeenCalledWith(packageName); + }); + + it("should return null when both providers return null", async () => { + // Arrange + const packageName = "non-existent-package"; + + vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null); + vi.spyOn(mockOfficialProvider, "getPackageConfig").mockResolvedValue(null); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null when official provider throws error", async () => { + // Arrange + const packageName = "@toolsdk.ai/tavily-mcp"; + + vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null); + vi.spyOn(mockOfficialProvider, "getPackageConfig").mockRejectedValue( + new Error("Network error"), + ); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe("exists", () => { + it("should return true when package exists locally", async () => { + // Arrange + const packageName = "@modelcontextprotocol/server-filesystem"; + vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(true); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(true); + expect(mockLocalProvider.exists).toHaveBeenCalledWith(packageName); + expect(mockOfficialProvider.exists).not.toHaveBeenCalled(); + }); + + it("should check official provider when local returns false", async () => { + // Arrange + const packageName = "@toolsdk.ai/tavily-mcp"; + vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false); + vi.spyOn(mockOfficialProvider, "exists").mockResolvedValue(true); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(true); + expect(mockLocalProvider.exists).toHaveBeenCalledWith(packageName); + expect(mockOfficialProvider.exists).toHaveBeenCalledWith(packageName); + }); + + it("should return false when both providers return false", async () => { + // Arrange + const packageName = "non-existent-package"; + vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false); + vi.spyOn(mockOfficialProvider, "exists").mockResolvedValue(false); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(false); + }); + + it("should return false when official provider throws error", async () => { + // Arrange + const packageName = "@toolsdk.ai/tavily-mcp"; + vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false); + vi.spyOn(mockOfficialProvider, "exists").mockRejectedValue(new Error("Network error")); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(false); + }); + }); +}); diff --git a/src/domains/registry/__tests__/local-registry-provider.test.ts b/src/domains/registry/__tests__/local-registry-provider.test.ts new file mode 100644 index 00000000..fd2abcb8 --- /dev/null +++ b/src/domains/registry/__tests__/local-registry-provider.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PackageRepository } from "../../package/package-repository"; +import type { MCPServerPackageConfig } from "../../package/package-types"; +import { LocalRegistryProvider } from "../providers/local-registry-provider"; + +describe("LocalRegistryProvider", () => { + let mockRepository: PackageRepository; + let provider: LocalRegistryProvider; + + beforeEach(() => { + // Mock PackageRepository + mockRepository = { + getPackageConfig: vi.fn(), + getAllPackages: vi.fn(), + exists: vi.fn(), + } as unknown as PackageRepository; + + provider = new LocalRegistryProvider(mockRepository); + }); + + describe("getPackageConfig", () => { + it("should return package config when package exists", async () => { + // Arrange + const packageName = "@modelcontextprotocol/server-filesystem"; + const mockConfig: MCPServerPackageConfig = { + type: "mcp-server", + runtime: "node", + packageName, + name: "Filesystem Server", + description: "A server for filesystem operations", + }; + + vi.spyOn(mockRepository, "exists").mockReturnValue(true); + vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toEqual(mockConfig); + expect(mockRepository.exists).toHaveBeenCalledWith(packageName); + expect(mockRepository.getPackageConfig).toHaveBeenCalledWith(packageName); + }); + + it("should return null when package does not exist", async () => { + // Arrange + const packageName = "non-existent-package"; + vi.spyOn(mockRepository, "exists").mockReturnValue(false); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toBeNull(); + expect(mockRepository.exists).toHaveBeenCalledWith(packageName); + expect(mockRepository.getPackageConfig).not.toHaveBeenCalled(); + }); + + it("should return null when getPackageConfig throws error", async () => { + // Arrange + const packageName = "@modelcontextprotocol/server-filesystem"; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); + vi.spyOn(mockRepository, "getPackageConfig").mockImplementation(() => { + throw new Error("File read error"); + }); + + // Act + const result = await provider.getPackageConfig(packageName); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe("exists", () => { + it("should return true when package exists", async () => { + // Arrange + const packageName = "@modelcontextprotocol/server-filesystem"; + vi.spyOn(mockRepository, "exists").mockReturnValue(true); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(true); + expect(mockRepository.exists).toHaveBeenCalledWith(packageName); + }); + + it("should return false when package does not exist", async () => { + // Arrange + const packageName = "non-existent-package"; + vi.spyOn(mockRepository, "exists").mockReturnValue(false); + + // Act + const result = await provider.exists(packageName); + + // Assert + expect(result).toBe(false); + }); + }); +}); diff --git a/src/domains/registry/__tests__/official-registry-provider.integration.test.ts b/src/domains/registry/__tests__/official-registry-provider.integration.test.ts new file mode 100644 index 00000000..b421ea8e --- /dev/null +++ b/src/domains/registry/__tests__/official-registry-provider.integration.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { OfficialRegistryProvider } from "../providers/official-registry-provider"; + +/** + * Integration tests for OfficialRegistryProvider + * These tests call the real official API + */ +describe("OfficialRegistryProvider - Integration", () => { + const provider = new OfficialRegistryProvider(); + + describe("search", () => { + it("should search and return results from official API", async () => { + // Act - search for tavily + const results = await provider.search("tavily"); + + // Assert + expect(results).toBeDefined(); + expect(Array.isArray(results)).toBe(true); + + // Log results for debugging + console.log(`Found ${results.length} results for 'tavily'`); + if (results.length > 0) { + console.log("First result:", JSON.stringify(results[0], null, 2)); + } + }, 10000); // 10s timeout for network call + + it("should return empty array for non-existent package", async () => { + // Act + const results = await provider.search("non-existent-package-xyz-123"); + + // Assert + expect(results).toBeDefined(); + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(0); + }, 10000); + }); + + describe("getPackageConfig", () => { + it("should get config for tavily-mcp package", async () => { + // Act + const config = await provider.getPackageConfig("tavily"); + + // Assert + if (config) { + console.log("Tavily package config:", JSON.stringify(config, null, 2)); + expect(config.type).toBe("mcp-server"); + expect(config.runtime).toBe("node"); + expect(config.packageName).toBeDefined(); + expect(config.name).toBeDefined(); + } else { + console.log("No tavily package found"); + } + }, 10000); + + it("should return null for non-existent package", async () => { + // Act + const config = await provider.getPackageConfig("non-existent-package-xyz-123"); + + // Assert + expect(config).toBeNull(); + }, 10000); + }); + + describe("exists", () => { + it("should return true for existing package", async () => { + // Act + const exists = await provider.exists("tavily"); + + // Assert + console.log(`Tavily exists: ${exists}`); + // We expect it to exist, but don't fail the test if the API changes + expect(typeof exists).toBe("boolean"); + }, 10000); + + it("should return false for non-existent package", async () => { + // Act + const exists = await provider.exists("non-existent-package-xyz-123"); + + // Assert + expect(exists).toBe(false); + }, 10000); + }); +}); diff --git a/src/domains/registry/__tests__/registry-utils.test.ts b/src/domains/registry/__tests__/registry-utils.test.ts new file mode 100644 index 00000000..06bf7c79 --- /dev/null +++ b/src/domains/registry/__tests__/registry-utils.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; +import type { OfficialServer } from "../registry-schema"; +import { transformAndFilterServers, transformServer } from "../registry-utils"; + +describe("registry-utils", () => { + describe("transformServer", () => { + it("should transform npm+stdio package correctly", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.Seey215/tavily-mcp", + title: "Tavily MCP Server", + description: "MCP server for advanced web search using Tavily API.", + repository: { + url: "https://github.com/Seey215/tavily-mcp", + }, + version: "0.2.9", + packages: [ + { + registryType: "npm", + identifier: "@toolsdk.ai/tavily-mcp", + version: "0.2.9", + transport: { + type: "stdio", + }, + environmentVariables: [ + { + name: "TAVILY_API_KEY", + description: "Your TAVILY_API_KEY", + isRequired: true, + isSecret: true, + }, + ], + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).not.toBeNull(); + expect(result).toMatchObject({ + type: "mcp-server", + runtime: "node", + packageName: "@toolsdk.ai/tavily-mcp", + packageVersion: "0.2.9", + name: "Tavily MCP Server", + description: "MCP server for advanced web search using Tavily API.", + url: "https://github.com/Seey215/tavily-mcp", + env: { + TAVILY_API_KEY: { + description: "Your TAVILY_API_KEY", + required: true, + }, + }, + }); + }); + + it("should use title as name if available", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/package", + title: "Test Package Title", + packages: [ + { + registryType: "npm", + identifier: "@test/package", + transport: { + type: "stdio", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result?.name).toBe("Test Package Title"); + }); + + it("should use name if title is not available", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/package", + packages: [ + { + registryType: "npm", + identifier: "@test/package", + transport: { + type: "stdio", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result?.name).toBe("io.github.test/package"); + }); + + it("should return null for non-npm packages", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/docker-package", + packages: [ + { + registryType: "docker", + identifier: "test/package", + transport: { + type: "stdio", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null for non-stdio transport", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/sse-package", + packages: [ + { + registryType: "npm", + identifier: "@test/package", + transport: { + type: "sse", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).toBeNull(); + }); + + it("should select first npm+stdio package when multiple packages exist", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/multi-package", + packages: [ + { + registryType: "docker", + identifier: "test/docker", + transport: { + type: "stdio", + }, + }, + { + registryType: "npm", + identifier: "@test/first-npm", + transport: { + type: "stdio", + }, + }, + { + registryType: "npm", + identifier: "@test/second-npm", + transport: { + type: "stdio", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).not.toBeNull(); + expect(result?.packageName).toBe("@test/first-npm"); + }); + + it("should handle empty environment variables", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/no-env", + packages: [ + { + registryType: "npm", + identifier: "@test/package", + transport: { + type: "stdio", + }, + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).not.toBeNull(); + expect(result?.env).toBeUndefined(); + }); + + it("should handle optional environment variable fields", () => { + // Arrange + const officialServer: OfficialServer = { + name: "io.github.test/package", + packages: [ + { + registryType: "npm", + identifier: "@test/package", + transport: { + type: "stdio", + }, + environmentVariables: [ + { + name: "API_KEY", + // description and isRequired are optional + }, + ], + }, + ], + }; + + // Act + const result = transformServer(officialServer); + + // Assert + expect(result).not.toBeNull(); + expect(result?.env).toEqual({ + API_KEY: { + description: "", + required: false, + }, + }); + }); + }); + + describe("transformAndFilterServers", () => { + it("should transform and filter multiple servers", () => { + // Arrange + const servers: OfficialServer[] = [ + { + name: "io.github.test/npm-stdio", + packages: [ + { + registryType: "npm", + identifier: "@test/npm-stdio", + transport: { type: "stdio" }, + }, + ], + }, + { + name: "io.github.test/docker", + packages: [ + { + registryType: "docker", + identifier: "test/docker", + transport: { type: "stdio" }, + }, + ], + }, + { + name: "io.github.test/npm-sse", + packages: [ + { + registryType: "npm", + identifier: "@test/npm-sse", + transport: { type: "sse" }, + }, + ], + }, + { + name: "io.github.test/another-npm-stdio", + packages: [ + { + registryType: "npm", + identifier: "@test/another-npm-stdio", + transport: { type: "stdio" }, + }, + ], + }, + ]; + + // Act + const result = transformAndFilterServers(servers); + + // Assert + expect(result).toHaveLength(2); + expect(result[0].packageName).toBe("@test/npm-stdio"); + expect(result[1].packageName).toBe("@test/another-npm-stdio"); + }); + + it("should return empty array when no servers match criteria", () => { + // Arrange + const servers: OfficialServer[] = [ + { + name: "io.github.test/docker", + packages: [ + { + registryType: "docker", + identifier: "test/docker", + transport: { type: "stdio" }, + }, + ], + }, + { + name: "io.github.test/npm-sse", + packages: [ + { + registryType: "npm", + identifier: "@test/npm-sse", + transport: { type: "sse" }, + }, + ], + }, + ]; + + // Act + const result = transformAndFilterServers(servers); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle empty array", () => { + // Arrange + const servers: OfficialServer[] = []; + + // Act + const result = transformAndFilterServers(servers); + + // Assert + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/src/domains/registry/index.ts b/src/domains/registry/index.ts new file mode 100644 index 00000000..f05328cb --- /dev/null +++ b/src/domains/registry/index.ts @@ -0,0 +1,27 @@ +// Types and interfaces + +export { FederatedRegistryProvider } from "./providers/federated-registry-provider"; +// Providers +export { LocalRegistryProvider } from "./providers/local-registry-provider"; +export { OfficialRegistryProvider } from "./providers/official-registry-provider"; +export type { RegistryProviderType } from "./registry-factory"; +// Factory +export { + getRegistryProvider, + initRegistryFactory, + resetRegistryFactory, +} from "./registry-factory"; +// Schemas +export type { + OfficialEnvironmentVariable, + OfficialPackage, + OfficialRepository, + OfficialSearchResponse, + OfficialServer, + OfficialServerItem, + OfficialTransport, +} from "./registry-schema"; +export type { IRegistryProvider, RegistrySource } from "./registry-types"; + +// Utils +export { transformAndFilterServers, transformServer } from "./registry-utils"; diff --git a/src/domains/registry/providers/federated-registry-provider.ts b/src/domains/registry/providers/federated-registry-provider.ts new file mode 100644 index 00000000..0c81c183 --- /dev/null +++ b/src/domains/registry/providers/federated-registry-provider.ts @@ -0,0 +1,56 @@ +import type { MCPServerPackageConfig } from "../../package/package-types"; +import type { IRegistryProvider } from "../registry-types"; +import type { LocalRegistryProvider } from "./local-registry-provider"; +import type { OfficialRegistryProvider } from "./official-registry-provider"; + +/** + * 联邦 Registry Provider + * 实现本地优先的联邦查询策略 + */ +export class FederatedRegistryProvider implements IRegistryProvider { + constructor( + private readonly localProvider: LocalRegistryProvider, + private readonly officialProvider: OfficialRegistryProvider, + ) {} + + /** + * 获取包配置(本地优先) + * @param packageName - 包名 + * @returns 包配置,如果不存在返回 null + */ + async getPackageConfig(packageName: string): Promise { + // 1. 优先查询本地 + const localConfig = await this.localProvider.getPackageConfig(packageName); + if (localConfig) { + return localConfig; + } + + // 2. 本地不存在,查询官方 + try { + const officialConfig = await this.officialProvider.getPackageConfig(packageName); + return officialConfig; + } catch (error) { + console.warn(`[FederatedRegistry] Failed to fetch from official: ${error}`); + return null; // 官方 API 失败,返回 null + } + } + + /** + * 检查包是否存在 + * @param packageName - 包名 + * @returns 是否存在 + */ + async exists(packageName: string): Promise { + // 1. 先查本地 + if (await this.localProvider.exists(packageName)) { + return true; + } + + // 2. 再查官方 + try { + return await this.officialProvider.exists(packageName); + } catch { + return false; + } + } +} diff --git a/src/domains/registry/providers/local-registry-provider.ts b/src/domains/registry/providers/local-registry-provider.ts new file mode 100644 index 00000000..65849b9b --- /dev/null +++ b/src/domains/registry/providers/local-registry-provider.ts @@ -0,0 +1,39 @@ +import type { PackageRepository } from "../../package/package-repository"; +import type { MCPServerPackageConfig } from "../../package/package-types"; +import type { IRegistryProvider } from "../registry-types"; + +/** + * 本地 Registry Provider 适配器 + * 将 PackageRepository 包装为异步接口 + */ +export class LocalRegistryProvider implements IRegistryProvider { + constructor(private readonly packageRepository: PackageRepository) {} + + /** + * 获取包配置 + * @param packageName - 包名 + * @returns 包配置,如果不存在返回 null + */ + async getPackageConfig(packageName: string): Promise { + if (!this.packageRepository.exists(packageName)) { + return null; + } + + try { + const config = this.packageRepository.getPackageConfig(packageName); + return config; + } catch (error) { + console.error(`[LocalRegistry] Failed to get package config for '${packageName}':`, error); + return null; + } + } + + /** + * 检查包是否存在 + * @param packageName - 包名 + * @returns 是否存在 + */ + async exists(packageName: string): Promise { + return this.packageRepository.exists(packageName); + } +} diff --git a/src/domains/registry/providers/official-registry-provider.ts b/src/domains/registry/providers/official-registry-provider.ts new file mode 100644 index 00000000..6872f209 --- /dev/null +++ b/src/domains/registry/providers/official-registry-provider.ts @@ -0,0 +1,95 @@ +import type { MCPServerPackageConfig } from "../../package/package-types"; +import { + type OfficialSearchResponse, + OfficialSearchResponseSchema, + type OfficialServerItem, +} from "../registry-schema"; +import type { IRegistryProvider } from "../registry-types"; +import { transformAndFilterServers } from "../registry-utils"; + +/** + * 官方 Registry Provider + * 负责调用官方 API 并转换数据 + */ +export class OfficialRegistryProvider implements IRegistryProvider { + private readonly baseUrl = "https://registry.modelcontextprotocol.io/v0.1"; + private readonly timeout = 5000; // 5秒超时 + + /** + * 获取包配置 + * @param packageName - 包名(官方 Registry ID) + * @returns 包配置,如果不存在返回 null + */ + async getPackageConfig(packageName: string): Promise { + try { + // 1. 调用搜索 API + const searchResults = await this.search(packageName); + + // 2. 返回第一个结果(如果存在) + if (searchResults.length > 0) { + return searchResults[0]; + } + + return null; + } catch (error) { + console.warn(`[OfficialRegistry] Failed to get package config for '${packageName}':`, error); + return null; + } + } + + /** + * 检查包是否存在 + * @param packageName - 包名 + * @returns 是否存在 + */ + async exists(packageName: string): Promise { + const config = await this.getPackageConfig(packageName); + return config !== null; + } + + /** + * 搜索包 + * @param query - 搜索关键词 + * @returns 包配置列表 + */ + async search(query: string): Promise { + try { + const response = await this.fetchFromOfficial(`/servers?search=${encodeURIComponent(query)}`); + const servers = response.servers.map((item: OfficialServerItem) => item.server); + return transformAndFilterServers(servers); + } catch (error) { + console.error(`[OfficialRegistry] Search error for '${query}':`, error); + return []; + } + } + + /** + * 从官方 API 获取数据 + * @param endpoint - API 端点 + * @returns 响应数据 + */ + private async fetchFromOfficial(endpoint: string): Promise { + const url = `${this.baseUrl}${endpoint}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return OfficialSearchResponseSchema.parse(data); + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/src/domains/registry/registry-factory.ts b/src/domains/registry/registry-factory.ts new file mode 100644 index 00000000..d4a50246 --- /dev/null +++ b/src/domains/registry/registry-factory.ts @@ -0,0 +1,65 @@ +import type { PackageRepository } from "../package/package-repository"; +import { FederatedRegistryProvider } from "./providers/federated-registry-provider"; +import { LocalRegistryProvider } from "./providers/local-registry-provider"; +import { OfficialRegistryProvider } from "./providers/official-registry-provider"; +import type { IRegistryProvider } from "./registry-types"; + +/** + * Registry Provider 类型 + */ +export type RegistryProviderType = "LOCAL" | "OFFICIAL" | "FEDERATED"; + +/** + * Registry Provider 实例容器 + */ +let localProvider: LocalRegistryProvider | null = null; +let officialProvider: OfficialRegistryProvider | null = null; +let federatedProvider: FederatedRegistryProvider | null = null; +let initialized = false; + +/** + * 初始化 Registry Factory + * @param packageRepository - 本地包仓库 + */ +export function initRegistryFactory(packageRepository: PackageRepository): void { + if (initialized) { + return; + } + + localProvider = new LocalRegistryProvider(packageRepository); + officialProvider = new OfficialRegistryProvider(); + federatedProvider = new FederatedRegistryProvider(localProvider, officialProvider); + initialized = true; +} + +/** + * 获取 Registry Provider + * @param type - Provider 类型,默认为 FEDERATED + * @returns Registry Provider 实例 + */ +export function getRegistryProvider(type: RegistryProviderType = "FEDERATED"): IRegistryProvider { + if (!initialized || !localProvider || !officialProvider || !federatedProvider) { + throw new Error("RegistryFactory not initialized. Call initRegistryFactory() first."); + } + + switch (type) { + case "LOCAL": + return localProvider; + case "OFFICIAL": + return officialProvider; + case "FEDERATED": + return federatedProvider; + default: + throw new Error(`Unknown provider type: ${type}`); + } +} + +/** + * 重置工厂(主要用于测试) + */ +export function resetRegistryFactory(): void { + localProvider = null; + officialProvider = null; + federatedProvider = null; + initialized = false; +} diff --git a/src/domains/registry/registry-schema.ts b/src/domains/registry/registry-schema.ts new file mode 100644 index 00000000..2835199e --- /dev/null +++ b/src/domains/registry/registry-schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +/** + * 官方 Registry API 数据结构 Schema + * 基于 https://registry.modelcontextprotocol.io/v0.1 + */ + +export const OfficialRepositorySchema = z.object({ + url: z.string().optional(), +}); + +export const OfficialTransportSchema = z.object({ + type: z.enum(["stdio", "sse"]), +}); + +export const OfficialEnvironmentVariableSchema = z.object({ + name: z.string(), + description: z.string().optional(), + isRequired: z.boolean().optional(), + isSecret: z.boolean().optional(), +}); + +export const OfficialPackageSchema = z.object({ + registryType: z.enum(["npm", "docker", "pypi"]), + identifier: z.string(), + version: z.string().optional(), + transport: OfficialTransportSchema, + environmentVariables: z.array(OfficialEnvironmentVariableSchema).optional(), +}); + +export const OfficialServerSchema = z.object({ + name: z.string(), + title: z.string().optional(), + description: z.string().optional(), + repository: OfficialRepositorySchema.optional(), + version: z.string().optional(), + packages: z.array(OfficialPackageSchema), +}); + +export const OfficialServerItemSchema = z.object({ + server: OfficialServerSchema, +}); + +export const OfficialSearchResponseSchema = z.object({ + servers: z.array(OfficialServerItemSchema), + nextCursor: z.string().optional(), +}); + +// Type exports +export type OfficialRepository = z.infer; +export type OfficialTransport = z.infer; +export type OfficialEnvironmentVariable = z.infer; +export type OfficialPackage = z.infer; +export type OfficialServer = z.infer; +export type OfficialServerItem = z.infer; +export type OfficialSearchResponse = z.infer; diff --git a/src/domains/registry/registry-types.ts b/src/domains/registry/registry-types.ts new file mode 100644 index 00000000..293025fe --- /dev/null +++ b/src/domains/registry/registry-types.ts @@ -0,0 +1,24 @@ +/** + * Registry 数据源类型 + */ +export type RegistrySource = "LOCAL" | "OFFICIAL"; + +/** + * Registry Provider 抽象接口 + * 定义统一的 Registry 查询接口 + */ +export interface IRegistryProvider { + /** + * 获取包配置 + * @param packageName - 包名 + * @returns 包配置,如果不存在返回 null + */ + getPackageConfig(packageName: string): Promise; + + /** + * 检查包是否存在 + * @param packageName - 包名 + * @returns 是否存在 + */ + exists(packageName: string): Promise; +} diff --git a/src/domains/registry/registry-utils.ts b/src/domains/registry/registry-utils.ts new file mode 100644 index 00000000..8ba49f41 --- /dev/null +++ b/src/domains/registry/registry-utils.ts @@ -0,0 +1,61 @@ +import type { MCPServerPackageConfig } from "../package/package-types"; +import type { OfficialServer } from "./registry-schema"; + +/** + * 转换单个服务器配置 + * @param server - 官方服务器配置 + * @returns 本地格式配置,如果不符合条件返回 null + */ +export function transformServer(server: OfficialServer): MCPServerPackageConfig | null { + try { + // 1. 过滤出 npm + stdio 包 + const npmStdioPackages = server.packages.filter( + (pkg) => pkg.registryType === "npm" && pkg.transport.type === "stdio", + ); + + if (npmStdioPackages.length === 0) { + return null; // 不符合条件,跳过 + } + + // 2. 取第一个符合条件的包 + const pkg = npmStdioPackages[0]; + + // 3. 转换为本地格式 + const config: MCPServerPackageConfig = { + type: "mcp-server", + runtime: "node", // npm 包默认为 node + packageName: pkg.identifier, // 使用 npm 包名作为 packageName + packageVersion: pkg.version, + name: server.title || server.name, + description: server.description, + url: server.repository?.url, + // 转换环境变量 + env: pkg.environmentVariables?.reduce( + (acc, env) => { + acc[env.name] = { + description: env.description || "", + required: env.isRequired || false, + }; + return acc; + }, + {} as Record, + ), + }; + + return config; + } catch (error) { + console.error(`[RegistryDataTransformer] Transform error for ${server.name}:`, error); + return null; + } +} + +/** + * 批量转换并过滤服务器配置 + * @param servers - 官方服务器配置数组 + * @returns 本地格式配置数组 + */ +export function transformAndFilterServers(servers: OfficialServer[]): MCPServerPackageConfig[] { + return servers + .map((server) => transformServer(server)) + .filter((config): config is MCPServerPackageConfig => config !== null); +} From 2ef73d9f7569f0761ffe52e8f5afe9b4a0b4e9b0 Mon Sep 17 00:00:00 2001 From: Seey215 Date: Tue, 4 Nov 2025 15:42:35 +0800 Subject: [PATCH 2/4] feat(registry): Initialize Registry Factory with local repository in index.ts --- src/api/index.ts | 5 +++++ src/domains/package/package-handler.ts | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index acbb3e9c..2d6e9473 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -5,12 +5,17 @@ import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; import type { Context } from "hono"; import { configRoutes } from "../domains/config/config-route"; +import { repository } from "../domains/package/package-handler"; import { packageRoutes } from "../domains/package/package-route"; import { searchRoutes } from "../domains/search/search-route"; import { SearchSO } from "../domains/search/search-so"; +import { initRegistryFactory } from "../domains/registry/registry-factory"; import { getServerPort, isSearchEnabled } from "../shared/config/environment"; import { getDirname } from "../shared/utils"; +// Initialize Registry Factory with the local repository +initRegistryFactory(repository); + const initializeSearchService = async () => { try { await SearchSO.getInstance(); diff --git a/src/domains/package/package-handler.ts b/src/domains/package/package-handler.ts index 9c316d3f..6d77c1b9 100644 --- a/src/domains/package/package-handler.ts +++ b/src/domains/package/package-handler.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { getDirname } from "../../shared/utils/file-util"; import { createErrorResponse, createResponse } from "../../shared/utils/response-util"; import { ExecutorFactory } from "../executor/executor-factory"; -import { initRegistryFactory } from "../registry/registry-factory"; import type { MCPSandboxProvider } from "../sandbox/sandbox-types"; import { PackageRepository } from "./package-repository"; import { PackageSO } from "./package-so"; @@ -10,10 +9,7 @@ import { PackageSO } from "./package-so"; const __dirname = getDirname(import.meta.url); const packagesDir = path.join(__dirname, "../../../packages"); -const repository = new PackageRepository(packagesDir); - -// Initialize Registry Factory with the local repository -initRegistryFactory(repository); +export const repository = new PackageRepository(packagesDir); export const packageHandler = { getPackageDetail: async (packageName: string, sandboxProvider?: MCPSandboxProvider) => { From a0a91b2318f6b66872270d72c386896e681c37c3 Mon Sep 17 00:00:00 2001 From: Seey215 Date: Tue, 4 Nov 2025 15:53:41 +0800 Subject: [PATCH 3/4] feat(registry): Update Registry Provider documentation and improve initialization logic --- src/api/index.ts | 2 +- src/domains/package/package-so.test.ts | 1 - .../providers/federated-registry-provider.ts | 26 +++++++------- .../providers/local-registry-provider.ts | 16 ++++----- .../providers/official-registry-provider.ts | 34 +++++++++---------- src/domains/registry/registry-factory.ts | 16 ++++----- src/domains/registry/registry-schema.ts | 4 +-- src/domains/registry/registry-types.ts | 18 +++++----- src/domains/registry/registry-utils.ts | 26 +++++++------- src/domains/search/search.html | 6 ++-- 10 files changed, 74 insertions(+), 75 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 2d6e9473..32166e65 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,9 +7,9 @@ import type { Context } from "hono"; import { configRoutes } from "../domains/config/config-route"; import { repository } from "../domains/package/package-handler"; import { packageRoutes } from "../domains/package/package-route"; +import { initRegistryFactory } from "../domains/registry/registry-factory"; import { searchRoutes } from "../domains/search/search-route"; import { SearchSO } from "../domains/search/search-so"; -import { initRegistryFactory } from "../domains/registry/registry-factory"; import { getServerPort, isSearchEnabled } from "../shared/config/environment"; import { getDirname } from "../shared/utils"; diff --git a/src/domains/package/package-so.test.ts b/src/domains/package/package-so.test.ts index a6b667fe..dc2e00e5 100644 --- a/src/domains/package/package-so.test.ts +++ b/src/domains/package/package-so.test.ts @@ -48,7 +48,6 @@ describe("PackageSO", () => { validated: true, }; - vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "exists").mockReturnValue(true); vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig); vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({ diff --git a/src/domains/registry/providers/federated-registry-provider.ts b/src/domains/registry/providers/federated-registry-provider.ts index 0c81c183..573e3112 100644 --- a/src/domains/registry/providers/federated-registry-provider.ts +++ b/src/domains/registry/providers/federated-registry-provider.ts @@ -4,8 +4,8 @@ import type { LocalRegistryProvider } from "./local-registry-provider"; import type { OfficialRegistryProvider } from "./official-registry-provider"; /** - * 联邦 Registry Provider - * 实现本地优先的联邦查询策略 + * Federated Registry Provider + * Implements local-first federated query strategy */ export class FederatedRegistryProvider implements IRegistryProvider { constructor( @@ -14,39 +14,39 @@ export class FederatedRegistryProvider implements IRegistryProvider { ) {} /** - * 获取包配置(本地优先) - * @param packageName - 包名 - * @returns 包配置,如果不存在返回 null + * Get package configuration (local first) + * @param packageName - Package name + * @returns Package configuration, null if not found */ async getPackageConfig(packageName: string): Promise { - // 1. 优先查询本地 + // 1. Query local first const localConfig = await this.localProvider.getPackageConfig(packageName); if (localConfig) { return localConfig; } - // 2. 本地不存在,查询官方 + // 2. If not found locally, query official try { const officialConfig = await this.officialProvider.getPackageConfig(packageName); return officialConfig; } catch (error) { console.warn(`[FederatedRegistry] Failed to fetch from official: ${error}`); - return null; // 官方 API 失败,返回 null + return null; // Official API failed, return null } } /** - * 检查包是否存在 - * @param packageName - 包名 - * @returns 是否存在 + * Check if package exists + * @param packageName - Package name + * @returns Whether the package exists */ async exists(packageName: string): Promise { - // 1. 先查本地 + // 1. Check local first if (await this.localProvider.exists(packageName)) { return true; } - // 2. 再查官方 + // 2. Then check official try { return await this.officialProvider.exists(packageName); } catch { diff --git a/src/domains/registry/providers/local-registry-provider.ts b/src/domains/registry/providers/local-registry-provider.ts index 65849b9b..e865436e 100644 --- a/src/domains/registry/providers/local-registry-provider.ts +++ b/src/domains/registry/providers/local-registry-provider.ts @@ -3,16 +3,16 @@ import type { MCPServerPackageConfig } from "../../package/package-types"; import type { IRegistryProvider } from "../registry-types"; /** - * 本地 Registry Provider 适配器 - * 将 PackageRepository 包装为异步接口 + * Local Registry Provider adapter + * Wraps PackageRepository as an async interface */ export class LocalRegistryProvider implements IRegistryProvider { constructor(private readonly packageRepository: PackageRepository) {} /** - * 获取包配置 - * @param packageName - 包名 - * @returns 包配置,如果不存在返回 null + * Get package configuration + * @param packageName - Package name + * @returns Package configuration, null if not found */ async getPackageConfig(packageName: string): Promise { if (!this.packageRepository.exists(packageName)) { @@ -29,9 +29,9 @@ export class LocalRegistryProvider implements IRegistryProvider { } /** - * 检查包是否存在 - * @param packageName - 包名 - * @returns 是否存在 + * Check if package exists + * @param packageName - Package name + * @returns Whether the package exists */ async exists(packageName: string): Promise { return this.packageRepository.exists(packageName); diff --git a/src/domains/registry/providers/official-registry-provider.ts b/src/domains/registry/providers/official-registry-provider.ts index 6872f209..d422d80d 100644 --- a/src/domains/registry/providers/official-registry-provider.ts +++ b/src/domains/registry/providers/official-registry-provider.ts @@ -8,24 +8,24 @@ import type { IRegistryProvider } from "../registry-types"; import { transformAndFilterServers } from "../registry-utils"; /** - * 官方 Registry Provider - * 负责调用官方 API 并转换数据 + * Official Registry Provider + * Responsible for calling official API and transforming data */ export class OfficialRegistryProvider implements IRegistryProvider { private readonly baseUrl = "https://registry.modelcontextprotocol.io/v0.1"; - private readonly timeout = 5000; // 5秒超时 + private readonly timeout = 5000; // 5 second timeout /** - * 获取包配置 - * @param packageName - 包名(官方 Registry ID) - * @returns 包配置,如果不存在返回 null + * Get package configuration + * @param packageName - Package name (official Registry ID) + * @returns Package configuration, null if not found */ async getPackageConfig(packageName: string): Promise { try { - // 1. 调用搜索 API + // 1. Call search API const searchResults = await this.search(packageName); - // 2. 返回第一个结果(如果存在) + // 2. Return first result (if exists) if (searchResults.length > 0) { return searchResults[0]; } @@ -38,9 +38,9 @@ export class OfficialRegistryProvider implements IRegistryProvider { } /** - * 检查包是否存在 - * @param packageName - 包名 - * @returns 是否存在 + * Check if package exists + * @param packageName - Package name + * @returns Whether the package exists */ async exists(packageName: string): Promise { const config = await this.getPackageConfig(packageName); @@ -48,9 +48,9 @@ export class OfficialRegistryProvider implements IRegistryProvider { } /** - * 搜索包 - * @param query - 搜索关键词 - * @returns 包配置列表 + * Search packages + * @param query - Search keyword + * @returns List of package configurations */ async search(query: string): Promise { try { @@ -64,9 +64,9 @@ export class OfficialRegistryProvider implements IRegistryProvider { } /** - * 从官方 API 获取数据 - * @param endpoint - API 端点 - * @returns 响应数据 + * Fetch data from official API + * @param endpoint - API endpoint + * @returns Response data */ private async fetchFromOfficial(endpoint: string): Promise { const url = `${this.baseUrl}${endpoint}`; diff --git a/src/domains/registry/registry-factory.ts b/src/domains/registry/registry-factory.ts index d4a50246..7cabd3d9 100644 --- a/src/domains/registry/registry-factory.ts +++ b/src/domains/registry/registry-factory.ts @@ -5,12 +5,12 @@ import { OfficialRegistryProvider } from "./providers/official-registry-provider import type { IRegistryProvider } from "./registry-types"; /** - * Registry Provider 类型 + * Registry Provider type */ export type RegistryProviderType = "LOCAL" | "OFFICIAL" | "FEDERATED"; /** - * Registry Provider 实例容器 + * Registry Provider instance container */ let localProvider: LocalRegistryProvider | null = null; let officialProvider: OfficialRegistryProvider | null = null; @@ -18,8 +18,8 @@ let federatedProvider: FederatedRegistryProvider | null = null; let initialized = false; /** - * 初始化 Registry Factory - * @param packageRepository - 本地包仓库 + * Initialize Registry Factory + * @param packageRepository - Local package repository */ export function initRegistryFactory(packageRepository: PackageRepository): void { if (initialized) { @@ -33,9 +33,9 @@ export function initRegistryFactory(packageRepository: PackageRepository): void } /** - * 获取 Registry Provider - * @param type - Provider 类型,默认为 FEDERATED - * @returns Registry Provider 实例 + * Get Registry Provider + * @param type - Provider type, defaults to FEDERATED + * @returns Registry Provider instance */ export function getRegistryProvider(type: RegistryProviderType = "FEDERATED"): IRegistryProvider { if (!initialized || !localProvider || !officialProvider || !federatedProvider) { @@ -55,7 +55,7 @@ export function getRegistryProvider(type: RegistryProviderType = "FEDERATED"): I } /** - * 重置工厂(主要用于测试) + * Reset factory (mainly used for testing) */ export function resetRegistryFactory(): void { localProvider = null; diff --git a/src/domains/registry/registry-schema.ts b/src/domains/registry/registry-schema.ts index 2835199e..44e639e3 100644 --- a/src/domains/registry/registry-schema.ts +++ b/src/domains/registry/registry-schema.ts @@ -1,8 +1,8 @@ import { z } from "zod"; /** - * 官方 Registry API 数据结构 Schema - * 基于 https://registry.modelcontextprotocol.io/v0.1 + * Official Registry API data structure schema + * Based on https://registry.modelcontextprotocol.io/v0.1 */ export const OfficialRepositorySchema = z.object({ diff --git a/src/domains/registry/registry-types.ts b/src/domains/registry/registry-types.ts index 293025fe..121e510e 100644 --- a/src/domains/registry/registry-types.ts +++ b/src/domains/registry/registry-types.ts @@ -1,24 +1,24 @@ /** - * Registry 数据源类型 + * Registry data source type */ export type RegistrySource = "LOCAL" | "OFFICIAL"; /** - * Registry Provider 抽象接口 - * 定义统一的 Registry 查询接口 + * Registry Provider abstract interface + * Define unified Registry query interface */ export interface IRegistryProvider { /** - * 获取包配置 - * @param packageName - 包名 - * @returns 包配置,如果不存在返回 null + * Get package configuration + * @param packageName - Package name + * @returns Package configuration, null if not found */ getPackageConfig(packageName: string): Promise; /** - * 检查包是否存在 - * @param packageName - 包名 - * @returns 是否存在 + * Check if package exists + * @param packageName - Package name + * @returns Whether the package exists */ exists(packageName: string): Promise; } diff --git a/src/domains/registry/registry-utils.ts b/src/domains/registry/registry-utils.ts index 8ba49f41..44d39ffe 100644 --- a/src/domains/registry/registry-utils.ts +++ b/src/domains/registry/registry-utils.ts @@ -2,34 +2,34 @@ import type { MCPServerPackageConfig } from "../package/package-types"; import type { OfficialServer } from "./registry-schema"; /** - * 转换单个服务器配置 - * @param server - 官方服务器配置 - * @returns 本地格式配置,如果不符合条件返回 null + * Transform a single server configuration + * @param server - Official server configuration + * @returns Local format configuration, null if not meeting conditions */ export function transformServer(server: OfficialServer): MCPServerPackageConfig | null { try { - // 1. 过滤出 npm + stdio 包 + // 1. Filter npm + stdio packages const npmStdioPackages = server.packages.filter( (pkg) => pkg.registryType === "npm" && pkg.transport.type === "stdio", ); if (npmStdioPackages.length === 0) { - return null; // 不符合条件,跳过 + return null; // Does not meet conditions, skip } - // 2. 取第一个符合条件的包 + // 2. Take the first matching package const pkg = npmStdioPackages[0]; - // 3. 转换为本地格式 + // 3. Convert to local format const config: MCPServerPackageConfig = { type: "mcp-server", - runtime: "node", // npm 包默认为 node - packageName: pkg.identifier, // 使用 npm 包名作为 packageName + runtime: "node", // npm packages default to node + packageName: pkg.identifier, // Use npm package name as packageName packageVersion: pkg.version, name: server.title || server.name, description: server.description, url: server.repository?.url, - // 转换环境变量 + // Transform environment variables env: pkg.environmentVariables?.reduce( (acc, env) => { acc[env.name] = { @@ -50,9 +50,9 @@ export function transformServer(server: OfficialServer): MCPServerPackageConfig } /** - * 批量转换并过滤服务器配置 - * @param servers - 官方服务器配置数组 - * @returns 本地格式配置数组 + * Batch transform and filter server configurations + * @param servers - Array of official server configurations + * @returns Array of local format configurations */ export function transformAndFilterServers(servers: OfficialServer[]): MCPServerPackageConfig[] { return servers diff --git a/src/domains/search/search.html b/src/domains/search/search.html index 0481089c..e72c5722 100644 --- a/src/domains/search/search.html +++ b/src/domains/search/search.html @@ -4,7 +4,7 @@ - Awesome MCP Registry + ToolSDK MCP Registry