Skip to content

Commit 5678d0f

Browse files
authored
feat(official-mcp-registry): Implement Federated Registry Provider for local and official package support (#103)
1 parent b1847d2 commit 5678d0f

17 files changed

+1159
-12
lines changed

src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import { swaggerUI } from "@hono/swagger-ui";
55
import { OpenAPIHono } from "@hono/zod-openapi";
66
import type { Context } from "hono";
77
import { configRoutes } from "../domains/config/config-route";
8+
import { repository } from "../domains/package/package-handler";
89
import { packageRoutes } from "../domains/package/package-route";
10+
import { initRegistryFactory } from "../domains/registry/registry-factory";
911
import { searchRoutes } from "../domains/search/search-route";
1012
import { SearchSO } from "../domains/search/search-so";
1113
import { getServerPort, isSearchEnabled } from "../shared/config/environment";
1214
import { getDirname } from "../shared/utils";
1315

16+
// Initialize Registry Factory with the local repository
17+
initRegistryFactory(repository);
18+
1419
const initializeSearchService = async () => {
1520
try {
1621
await SearchSO.getInstance();

src/domains/package/package-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { PackageSO } from "./package-so";
99
const __dirname = getDirname(import.meta.url);
1010

1111
const packagesDir = path.join(__dirname, "../../../packages");
12-
const repository = new PackageRepository(packagesDir);
12+
export const repository = new PackageRepository(packagesDir);
1313

1414
export const packageHandler = {
1515
getPackageDetail: async (packageName: string, sandboxProvider?: MCPSandboxProvider) => {

src/domains/package/package-so.test.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { ToolExecutor } from "../executor/executor-types";
4+
import { initRegistryFactory, resetRegistryFactory } from "../registry/registry-factory";
45
import type { PackageRepository } from "./package-repository";
56
import { PackageSO } from "./package-so";
67
import type { MCPServerPackageConfig } from "./package-types";
@@ -10,13 +11,19 @@ describe("PackageSO", () => {
1011
let mockExecutor: ToolExecutor;
1112

1213
beforeEach(() => {
14+
// Reset factory before each test
15+
resetRegistryFactory();
16+
1317
// Mock PackageRepository
1418
mockRepository = {
1519
getPackageConfig: vi.fn(),
1620
getAllPackages: vi.fn(),
1721
exists: vi.fn(),
1822
} as unknown as PackageRepository;
1923

24+
// Initialize Registry Factory with mock repository
25+
initRegistryFactory(mockRepository);
26+
2027
// Mock ToolExecutor
2128
mockExecutor = {
2229
listTools: vi.fn(),
@@ -41,6 +48,7 @@ describe("PackageSO", () => {
4148
validated: true,
4249
};
4350

51+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
4452
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
4553
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({
4654
[packageName]: mockPackageInfo,
@@ -55,6 +63,7 @@ describe("PackageSO", () => {
5563
expect(packageSO.description).toBe("A server for filesystem operations");
5664
expect(packageSO.category).toBe("filesystem");
5765
expect(packageSO.validated).toBe(true);
66+
expect(mockRepository.exists).toHaveBeenCalledWith(packageName);
5867
expect(mockRepository.getPackageConfig).toHaveBeenCalledWith(packageName);
5968
expect(mockRepository.getAllPackages).toHaveBeenCalled();
6069
});
@@ -70,6 +79,7 @@ describe("PackageSO", () => {
7079
description: "A new package",
7180
};
7281

82+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
7383
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
7484
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
7585

@@ -94,6 +104,7 @@ describe("PackageSO", () => {
94104
description: null,
95105
} as unknown as MCPServerPackageConfig;
96106

107+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
97108
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
98109
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
99110

@@ -140,6 +151,7 @@ describe("PackageSO", () => {
140151
},
141152
];
142153

154+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
143155
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
144156
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
145157
vi.spyOn(mockExecutor, "listTools").mockResolvedValue(mockTools);
@@ -168,6 +180,7 @@ describe("PackageSO", () => {
168180
};
169181
const errorMessage = "Failed to list tools";
170182

183+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
171184
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
172185
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
173186
vi.spyOn(mockExecutor, "listTools").mockRejectedValue(new Error(errorMessage));
@@ -196,6 +209,7 @@ describe("PackageSO", () => {
196209
description: "Test description",
197210
};
198211

212+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
199213
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
200214
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
201215
vi.spyOn(mockExecutor, "executeTool").mockResolvedValue(mockResult);
@@ -231,6 +245,7 @@ describe("PackageSO", () => {
231245
description: "Test description",
232246
};
233247

248+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
234249
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
235250
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
236251
vi.spyOn(mockExecutor, "executeTool").mockResolvedValue(mockResult);
@@ -265,6 +280,7 @@ describe("PackageSO", () => {
265280
description: "Test description",
266281
};
267282

283+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
268284
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
269285
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
270286
vi.spyOn(mockExecutor, "executeTool").mockRejectedValue(new Error(errorMessage));
@@ -303,6 +319,7 @@ describe("PackageSO", () => {
303319
},
304320
];
305321

322+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
306323
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
307324
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({
308325
[packageName]: mockPackageInfo,
@@ -316,11 +333,11 @@ describe("PackageSO", () => {
316333

317334
// Assert
318335
expect(detail).toEqual({
336+
type: "mcp-server",
337+
runtime: "node",
319338
name: "Test Package",
320339
packageName: "@test/package",
321340
description: "A test package for demonstration",
322-
category: "testing",
323-
validated: true,
324341
tools: mockTools,
325342
});
326343
});
@@ -336,6 +353,7 @@ describe("PackageSO", () => {
336353
description: "Test description",
337354
};
338355

356+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
339357
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
340358
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
341359
vi.spyOn(mockExecutor, "listTools").mockRejectedValue(new Error("Failed to get tools"));
@@ -371,6 +389,7 @@ describe("PackageSO", () => {
371389
};
372390
const mockTools: Tool[] = [];
373391

392+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
374393
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
375394
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
376395
vi.spyOn(mockExecutor, "listTools").mockResolvedValue(mockTools);
@@ -382,12 +401,12 @@ describe("PackageSO", () => {
382401

383402
// Assert
384403
expect(detail).toEqual({
404+
type: "mcp-server",
405+
runtime: "node",
385406
name: "Minimal Package",
386407
packageName: "@test/minimal-package",
387408
description: "A minimal package",
388-
category: undefined,
389-
validated: undefined,
390-
tools: mockTools,
409+
tools: [],
391410
});
392411
});
393412
});
@@ -409,6 +428,7 @@ describe("PackageSO", () => {
409428
validated: false,
410429
};
411430

431+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
412432
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
413433
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({
414434
[packageName]: mockPackageInfo,
@@ -437,6 +457,7 @@ describe("PackageSO", () => {
437457
description: "Test description",
438458
};
439459

460+
vi.spyOn(mockRepository, "exists").mockReturnValue(true);
440461
vi.spyOn(mockRepository, "getPackageConfig").mockReturnValue(mockConfig);
441462
vi.spyOn(mockRepository, "getAllPackages").mockReturnValue({});
442463

src/domains/package/package-so.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
22
import type { ToolExecutor } from "../executor/executor-types";
3+
import { getRegistryProvider } from "../registry/registry-factory";
4+
import type { IRegistryProvider } from "../registry/registry-types";
35
import type { PackageRepository } from "./package-repository";
46
import type { MCPServerPackageConfig, MCPServerPackageConfigWithTools } from "./package-types";
57

@@ -8,7 +10,6 @@ export class PackageSO {
810
private readonly _packageName: string,
911
private readonly _config: MCPServerPackageConfig,
1012
private readonly _packageInfo: { category?: string; validated?: boolean },
11-
_repository: PackageRepository,
1213
private readonly _executor: ToolExecutor,
1314
) {}
1415

@@ -36,10 +37,19 @@ export class PackageSO {
3637
repository: PackageRepository,
3738
executor: ToolExecutor,
3839
): Promise<PackageSO> {
39-
const config = repository.getPackageConfig(packageName);
40+
// Use FederatedRegistryProvider, which checks for local packages first and falls back to the official registry if not found locally.
41+
const provider: IRegistryProvider = getRegistryProvider("FEDERATED");
42+
const config = await provider.getPackageConfig(packageName);
43+
44+
if (!config) {
45+
throw new Error(`Package '${packageName}' not found`);
46+
}
47+
48+
// Get package metadata (category, validated) from local repository index if available.
4049
const allPackages = repository.getAllPackages();
4150
const packageInfo = allPackages[packageName] || {};
42-
return new PackageSO(packageName, config, packageInfo, repository, executor);
51+
52+
return new PackageSO(packageName, config, packageInfo, executor);
4353
}
4454

4555
async getTools(): Promise<Tool[]> {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { MCPServerPackageConfig } from "../../package/package-types";
3+
import { FederatedRegistryProvider } from "../providers/federated-registry-provider";
4+
import type { LocalRegistryProvider } from "../providers/local-registry-provider";
5+
import type { OfficialRegistryProvider } from "../providers/official-registry-provider";
6+
7+
describe("FederatedRegistryProvider", () => {
8+
let mockLocalProvider: LocalRegistryProvider;
9+
let mockOfficialProvider: OfficialRegistryProvider;
10+
let provider: FederatedRegistryProvider;
11+
12+
beforeEach(() => {
13+
// Mock LocalRegistryProvider
14+
mockLocalProvider = {
15+
getPackageConfig: vi.fn(),
16+
exists: vi.fn(),
17+
} as unknown as LocalRegistryProvider;
18+
19+
// Mock OfficialRegistryProvider
20+
mockOfficialProvider = {
21+
getPackageConfig: vi.fn(),
22+
exists: vi.fn(),
23+
search: vi.fn(),
24+
} as unknown as OfficialRegistryProvider;
25+
26+
provider = new FederatedRegistryProvider(mockLocalProvider, mockOfficialProvider);
27+
});
28+
29+
describe("getPackageConfig", () => {
30+
it("should return local config when package exists locally", async () => {
31+
// Arrange
32+
const packageName = "@modelcontextprotocol/server-filesystem";
33+
const mockConfig: MCPServerPackageConfig = {
34+
type: "mcp-server",
35+
runtime: "node",
36+
packageName,
37+
name: "Filesystem Server",
38+
description: "A server for filesystem operations",
39+
};
40+
41+
vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(mockConfig);
42+
43+
// Act
44+
const result = await provider.getPackageConfig(packageName);
45+
46+
// Assert
47+
expect(result).toEqual(mockConfig);
48+
expect(mockLocalProvider.getPackageConfig).toHaveBeenCalledWith(packageName);
49+
expect(mockOfficialProvider.getPackageConfig).not.toHaveBeenCalled();
50+
});
51+
52+
it("should query official provider when local returns null", async () => {
53+
// Arrange
54+
const packageName = "@toolsdk.ai/tavily-mcp";
55+
const officialConfig: MCPServerPackageConfig = {
56+
type: "mcp-server",
57+
runtime: "node",
58+
packageName,
59+
name: "Tavily MCP Server",
60+
description: "MCP server for Tavily search",
61+
};
62+
63+
vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null);
64+
vi.spyOn(mockOfficialProvider, "getPackageConfig").mockResolvedValue(officialConfig);
65+
66+
// Act
67+
const result = await provider.getPackageConfig(packageName);
68+
69+
// Assert
70+
expect(result).toEqual(officialConfig);
71+
expect(mockLocalProvider.getPackageConfig).toHaveBeenCalledWith(packageName);
72+
expect(mockOfficialProvider.getPackageConfig).toHaveBeenCalledWith(packageName);
73+
});
74+
75+
it("should return null when both providers return null", async () => {
76+
// Arrange
77+
const packageName = "non-existent-package";
78+
79+
vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null);
80+
vi.spyOn(mockOfficialProvider, "getPackageConfig").mockResolvedValue(null);
81+
82+
// Act
83+
const result = await provider.getPackageConfig(packageName);
84+
85+
// Assert
86+
expect(result).toBeNull();
87+
});
88+
89+
it("should return null when official provider throws error", async () => {
90+
// Arrange
91+
const packageName = "@toolsdk.ai/tavily-mcp";
92+
93+
vi.spyOn(mockLocalProvider, "getPackageConfig").mockResolvedValue(null);
94+
vi.spyOn(mockOfficialProvider, "getPackageConfig").mockRejectedValue(
95+
new Error("Network error"),
96+
);
97+
98+
// Act
99+
const result = await provider.getPackageConfig(packageName);
100+
101+
// Assert
102+
expect(result).toBeNull();
103+
});
104+
});
105+
106+
describe("exists", () => {
107+
it("should return true when package exists locally", async () => {
108+
// Arrange
109+
const packageName = "@modelcontextprotocol/server-filesystem";
110+
vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(true);
111+
112+
// Act
113+
const result = await provider.exists(packageName);
114+
115+
// Assert
116+
expect(result).toBe(true);
117+
expect(mockLocalProvider.exists).toHaveBeenCalledWith(packageName);
118+
expect(mockOfficialProvider.exists).not.toHaveBeenCalled();
119+
});
120+
121+
it("should check official provider when local returns false", async () => {
122+
// Arrange
123+
const packageName = "@toolsdk.ai/tavily-mcp";
124+
vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false);
125+
vi.spyOn(mockOfficialProvider, "exists").mockResolvedValue(true);
126+
127+
// Act
128+
const result = await provider.exists(packageName);
129+
130+
// Assert
131+
expect(result).toBe(true);
132+
expect(mockLocalProvider.exists).toHaveBeenCalledWith(packageName);
133+
expect(mockOfficialProvider.exists).toHaveBeenCalledWith(packageName);
134+
});
135+
136+
it("should return false when both providers return false", async () => {
137+
// Arrange
138+
const packageName = "non-existent-package";
139+
vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false);
140+
vi.spyOn(mockOfficialProvider, "exists").mockResolvedValue(false);
141+
142+
// Act
143+
const result = await provider.exists(packageName);
144+
145+
// Assert
146+
expect(result).toBe(false);
147+
});
148+
149+
it("should return false when official provider throws error", async () => {
150+
// Arrange
151+
const packageName = "@toolsdk.ai/tavily-mcp";
152+
vi.spyOn(mockLocalProvider, "exists").mockResolvedValue(false);
153+
vi.spyOn(mockOfficialProvider, "exists").mockRejectedValue(new Error("Network error"));
154+
155+
// Act
156+
const result = await provider.exists(packageName);
157+
158+
// Assert
159+
expect(result).toBe(false);
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)