Add registry client that queries Official, Glama, and Smithery MCP registries with caching, request deduplication, retry logic, and result ranking/dedup. Includes 53 tests covering all components. Also fix null priority values in cancelled tasks (19-21) that broke Task Master, and add new tasks 25-27 for registry completion and CLI discover/install commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
9.0 KiB
TypeScript
283 lines
9.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { RegistryClient } from '../../src/registry/client.js';
|
|
import type { RegistryServer } from '../../src/registry/types.js';
|
|
|
|
function makeServer(name: string, source: 'official' | 'glama' | 'smithery'): RegistryServer {
|
|
return {
|
|
name,
|
|
description: `${name} description`,
|
|
packages: { npm: `@test/${name}` },
|
|
envTemplate: [],
|
|
transport: 'stdio',
|
|
popularityScore: 50,
|
|
verified: source === 'smithery',
|
|
sourceRegistry: source,
|
|
};
|
|
}
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
function mockRegistryResponse(source: string, servers: RegistryServer[]): void {
|
|
mockFetch.mockImplementation((url: string) => {
|
|
if (url.includes('registry.modelcontextprotocol.io')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: servers
|
|
.filter((s) => s.sourceRegistry === 'official')
|
|
.map((s) => ({
|
|
server: {
|
|
name: s.name,
|
|
description: s.description,
|
|
packages: s.packages.npm !== undefined ? [{
|
|
registryType: 'npm',
|
|
identifier: s.packages.npm,
|
|
transport: { type: 'stdio' },
|
|
environmentVariables: [],
|
|
}] : [],
|
|
remotes: [],
|
|
},
|
|
})),
|
|
metadata: { nextCursor: null, count: 1 },
|
|
}),
|
|
});
|
|
}
|
|
if (url.includes('glama.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: servers
|
|
.filter((s) => s.sourceRegistry === 'glama')
|
|
.map((s) => ({
|
|
id: s.name,
|
|
name: s.name,
|
|
description: s.description,
|
|
attributes: [],
|
|
slug: s.packages.npm ?? '',
|
|
})),
|
|
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
|
}),
|
|
});
|
|
}
|
|
if (url.includes('registry.smithery.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: servers
|
|
.filter((s) => s.sourceRegistry === 'smithery')
|
|
.map((s) => ({
|
|
qualifiedName: s.name,
|
|
displayName: s.name,
|
|
description: s.description,
|
|
verified: s.verified,
|
|
useCount: s.popularityScore,
|
|
remote: false,
|
|
})),
|
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
|
}),
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
|
});
|
|
}
|
|
|
|
describe('RegistryClient', () => {
|
|
it('queries all enabled registries', async () => {
|
|
const testServers = [
|
|
makeServer('slack-official', 'official'),
|
|
makeServer('slack-glama', 'glama'),
|
|
makeServer('slack-smithery', 'smithery'),
|
|
];
|
|
mockRegistryResponse('all', testServers);
|
|
|
|
const client = new RegistryClient();
|
|
const results = await client.search({ query: 'slack' });
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('uses cached results on second call', async () => {
|
|
mockRegistryResponse('all', [makeServer('slack', 'official')]);
|
|
|
|
const client = new RegistryClient();
|
|
await client.search({ query: 'slack' });
|
|
mockFetch.mockClear();
|
|
await client.search({ query: 'slack' });
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('filters by registry when specified', async () => {
|
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
|
|
|
const client = new RegistryClient();
|
|
await client.search({ query: 'test', registries: ['official'] });
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const calledUrl = mockFetch.mock.calls[0]?.[0] as string;
|
|
expect(calledUrl).toContain('modelcontextprotocol.io');
|
|
});
|
|
|
|
it('handles partial failures gracefully', async () => {
|
|
mockFetch.mockImplementation((url: string) => {
|
|
if (url.includes('glama.ai')) {
|
|
return Promise.reject(new Error('Network error'));
|
|
}
|
|
if (url.includes('registry.smithery.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: [{
|
|
qualifiedName: 'slack',
|
|
displayName: 'Slack',
|
|
description: 'Slack',
|
|
verified: true,
|
|
useCount: 100,
|
|
remote: false,
|
|
}],
|
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
|
}),
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: [],
|
|
metadata: { nextCursor: null },
|
|
}),
|
|
});
|
|
});
|
|
|
|
const client = new RegistryClient();
|
|
const results = await client.search({ query: 'slack' });
|
|
|
|
// Should still return results from successful sources
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('records error counts on failures', async () => {
|
|
mockFetch.mockImplementation((url: string) => {
|
|
if (url.includes('glama.ai')) {
|
|
return Promise.reject(new Error('fail'));
|
|
}
|
|
// Return empty for others
|
|
if (url.includes('modelcontextprotocol')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: [],
|
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 0 },
|
|
}),
|
|
});
|
|
});
|
|
|
|
const client = new RegistryClient();
|
|
await client.search({ query: 'test' });
|
|
|
|
const errors = client.getErrorCounts();
|
|
expect(errors.get('glama')).toBe(1);
|
|
});
|
|
|
|
it('filters by verified when specified', async () => {
|
|
mockFetch.mockImplementation((url: string) => {
|
|
if (url.includes('registry.smithery.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: [
|
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
|
],
|
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
|
}),
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
|
});
|
|
});
|
|
|
|
// Mock glama too
|
|
mockFetch.mockImplementation((url: string) => {
|
|
if (url.includes('registry.smithery.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
servers: [
|
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
|
],
|
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
|
}),
|
|
});
|
|
}
|
|
if (url.includes('glama.ai')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ servers: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } }),
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
|
});
|
|
});
|
|
|
|
const client = new RegistryClient();
|
|
const results = await client.search({ query: 'test', verified: true });
|
|
|
|
for (const r of results) {
|
|
expect(r.verified).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('respects limit option', async () => {
|
|
mockRegistryResponse('all', [
|
|
makeServer('a', 'official'),
|
|
makeServer('b', 'glama'),
|
|
makeServer('c', 'smithery'),
|
|
]);
|
|
|
|
const client = new RegistryClient();
|
|
const results = await client.search({ query: 'test', limit: 1 });
|
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('records latency metrics', async () => {
|
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
|
|
|
const client = new RegistryClient();
|
|
await client.search({ query: 'test' });
|
|
|
|
const latencies = client.getQueryLatencies();
|
|
expect(latencies.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clearCache empties cache', async () => {
|
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
|
|
|
const client = new RegistryClient();
|
|
await client.search({ query: 'test' });
|
|
client.clearCache();
|
|
mockFetch.mockClear();
|
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
|
await client.search({ query: 'test' });
|
|
|
|
// Should have fetched again after cache clear
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
});
|
|
});
|