Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/domains/package/package-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { PackageSO } from "./package-so";
const __dirname = getDirname(import.meta.url);

const packagesDir = path.join(__dirname, "../../../packages");
const repository = new PackageRepository(packagesDir);
export const repository = new PackageRepository(packagesDir);

export const packageHandler = {
getPackageDetail: async (packageName: string, sandboxProvider?: MCPSandboxProvider) => {
Expand Down
32 changes: 27 additions & 5 deletions src/domains/package/package-so.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,13 +11,19 @@ describe("PackageSO", () => {
let mockExecutor: ToolExecutor;

beforeEach(() => {
// Reset factory before each test
resetRegistryFactory();

// Mock PackageRepository
mockRepository = {
getPackageConfig: vi.fn(),
getAllPackages: vi.fn(),
exists: vi.fn(),
} as unknown as PackageRepository;

// Initialize Registry Factory with mock repository
initRegistryFactory(mockRepository);

// Mock ToolExecutor
mockExecutor = {
listTools: vi.fn(),
Expand All @@ -41,6 +48,8 @@ describe("PackageSO", () => {
validated: true,
};

vi.spyOn(mockRepository, "exists").mockReturnValue(true);
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
Copy link

Copilot AI Nov 4, 2025

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.

Suggested change
vi.spyOn(mockRepository, "exists").mockReturnValue(true);

Copilot uses AI. Check for mistakes.
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({
[packageName]: mockPackageInfo,
Expand All @@ -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();
});
Expand All @@ -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({});

Expand All @@ -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({});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
});
Expand All @@ -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"));
Expand Down Expand Up @@ -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);
Expand All @@ -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: [],
});
});
});
Expand All @@ -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,
Expand Down Expand Up @@ -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({});

Expand Down
16 changes: 13 additions & 3 deletions src/domains/package/package-so.ts
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";

Expand All @@ -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,
) {}

Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This comment should clarify that the federated provider checks local packages first before falling back to the official registry, as this is the key behavior that differs from the previous implementation.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Nov 4, 2025

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'.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Nov 4, 2025

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.

Suggested change
return new PackageSO(packageName, config as MCPServerPackageConfig, packageInfo, executor);
return new PackageSO(packageName, config, packageInfo, executor);

Copilot uses AI. Check for mistakes.
}

async getTools(): Promise<Tool[]> {
Expand Down
162 changes: 162 additions & 0 deletions src/domains/registry/__tests__/federated-registry-provider.test.ts
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);
});
});
});
Loading