-
Notifications
You must be signed in to change notification settings - Fork 26
feat(official-mcp-registry): Implement Federated Registry Provider for local and official package support #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
8bd65c2
2ef73d9
a0a91b2
660d7ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<PackageSO> { | ||||||||
| const config = repository.getPackageConfig(packageName); | ||||||||
| // Use FederatedRegistryProvider to support both local and official packages | ||||||||
|
||||||||
| // Use FederatedRegistryProvider to support both local and official packages | |
| // Use FederatedRegistryProvider, which checks for local packages first and falls back to the official registry if not found locally. | |
| // This is the key behavior that differs from the previous implementation. |
Outdated
Copilot
AI
Nov 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] This comment could be more specific - it's retrieving category and validation metadata from the local packages-list.json index, which may not exist for official packages. Consider clarifying: 'Get package metadata (category, validated) from local repository index if available'.
| // Get package metadata from local repository | |
| // Get package metadata (category, validated) from local repository index if available. | |
| // Note: This may not exist for official packages. |
Outdated
Copilot
AI
Nov 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type assertion 'as MCPServerPackageConfig' is unnecessary here. The config is already checked for null on line 44, and the IRegistryProvider.getPackageConfig signature returns 'Promise<unknown | null>'. Since the method is typed to return 'unknown', consider updating the IRegistryProvider interface to have a generic type parameter or change the return type to 'Promise<MCPServerPackageConfig | null>' for type safety without requiring assertions.
| return new PackageSO(packageName, config as MCPServerPackageConfig, packageInfo, executor); | |
| return new PackageSO(packageName, config, packageInfo, executor); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line 52 is a duplicate of line 51. This duplicated spy setup should be removed.