feat: implement MCP registry client with multi-source search
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>
This commit is contained in:
90
src/cli/tests/registry/cache.test.ts
Normal file
90
src/cli/tests/registry/cache.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { RegistryCache } from '../../src/registry/cache.js';
|
||||
import type { RegistryServer, SearchOptions } from '../../src/registry/types.js';
|
||||
|
||||
function makeServer(name: string): RegistryServer {
|
||||
return {
|
||||
name,
|
||||
description: `${name} server`,
|
||||
packages: {},
|
||||
envTemplate: [],
|
||||
transport: 'stdio',
|
||||
popularityScore: 0,
|
||||
verified: false,
|
||||
sourceRegistry: 'official',
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = { query: 'test' };
|
||||
|
||||
describe('RegistryCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null for cache miss', () => {
|
||||
const cache = new RegistryCache();
|
||||
expect(cache.get('unknown', defaultOptions)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns data for cache hit within TTL', () => {
|
||||
const cache = new RegistryCache();
|
||||
const data = [makeServer('test')];
|
||||
cache.set('test', defaultOptions, data);
|
||||
expect(cache.get('test', defaultOptions)).toEqual(data);
|
||||
});
|
||||
|
||||
it('returns null after TTL expires', () => {
|
||||
const cache = new RegistryCache(1000); // 1 second TTL
|
||||
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(cache.get('test', defaultOptions)).toBeNull();
|
||||
});
|
||||
|
||||
it('generates deterministic cache keys', () => {
|
||||
const cache = new RegistryCache();
|
||||
const data = [makeServer('test')];
|
||||
cache.set('query', { query: 'query', limit: 10 }, data);
|
||||
expect(cache.get('query', { query: 'query', limit: 10 })).toEqual(data);
|
||||
});
|
||||
|
||||
it('generates different keys for different queries', () => {
|
||||
const cache = new RegistryCache();
|
||||
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||
expect(cache.get('b', { query: 'b' })).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks hits and misses correctly', () => {
|
||||
const cache = new RegistryCache();
|
||||
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||
|
||||
cache.get('test', defaultOptions); // hit
|
||||
cache.get('test', defaultOptions); // hit
|
||||
cache.get('miss', { query: 'miss' }); // miss
|
||||
|
||||
const ratio = cache.getHitRatio();
|
||||
expect(ratio.hits).toBe(2);
|
||||
expect(ratio.misses).toBe(1);
|
||||
expect(ratio.ratio).toBeCloseTo(2 / 3);
|
||||
});
|
||||
|
||||
it('returns 0 ratio when no accesses', () => {
|
||||
const cache = new RegistryCache();
|
||||
expect(cache.getHitRatio().ratio).toBe(0);
|
||||
});
|
||||
|
||||
it('clears all entries and resets metrics', () => {
|
||||
const cache = new RegistryCache();
|
||||
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||
cache.get('a', { query: 'a' }); // hit
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get('a', { query: 'a' })).toBeNull();
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache.getHitRatio().hits).toBe(0);
|
||||
});
|
||||
});
|
||||
282
src/cli/tests/registry/client.test.ts
Normal file
282
src/cli/tests/registry/client.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
105
src/cli/tests/registry/dedup.test.ts
Normal file
105
src/cli/tests/registry/dedup.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deduplicateResults } from '../../src/registry/dedup.js';
|
||||
import type { RegistryServer } from '../../src/registry/types.js';
|
||||
|
||||
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||
return {
|
||||
name: 'test-server',
|
||||
description: 'A test server',
|
||||
packages: {},
|
||||
envTemplate: [],
|
||||
transport: 'stdio',
|
||||
popularityScore: 0,
|
||||
verified: false,
|
||||
sourceRegistry: 'official',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('deduplicateResults', () => {
|
||||
it('keeps unique servers', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'server-a', packages: { npm: 'pkg-a' } }),
|
||||
makeServer({ name: 'server-b', packages: { npm: 'pkg-b' } }),
|
||||
];
|
||||
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deduplicates by npm package name, keeps higher popularity', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'low', packages: { npm: '@test/slack' }, popularityScore: 10, sourceRegistry: 'official' }),
|
||||
makeServer({ name: 'high', packages: { npm: '@test/slack' }, popularityScore: 100, sourceRegistry: 'smithery' }),
|
||||
];
|
||||
const result = deduplicateResults(servers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.name).toBe('high');
|
||||
expect(result[0]?.popularityScore).toBe(100);
|
||||
});
|
||||
|
||||
it('deduplicates by GitHub URL with different formats', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'a', repositoryUrl: 'https://github.com/org/repo', popularityScore: 5 }),
|
||||
makeServer({ name: 'b', repositoryUrl: 'git@github.com:org/repo.git', popularityScore: 50 }),
|
||||
];
|
||||
const result = deduplicateResults(servers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.name).toBe('b');
|
||||
});
|
||||
|
||||
it('merges envTemplate from both sources', () => {
|
||||
const servers = [
|
||||
makeServer({
|
||||
name: 'a',
|
||||
packages: { npm: 'pkg' },
|
||||
envTemplate: [{ name: 'TOKEN', description: 'API token', isSecret: true }],
|
||||
popularityScore: 10,
|
||||
}),
|
||||
makeServer({
|
||||
name: 'b',
|
||||
packages: { npm: 'pkg' },
|
||||
envTemplate: [{ name: 'URL', description: 'Base URL', isSecret: false }],
|
||||
popularityScore: 5,
|
||||
}),
|
||||
];
|
||||
const result = deduplicateResults(servers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.envTemplate).toHaveLength(2);
|
||||
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('TOKEN');
|
||||
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('URL');
|
||||
});
|
||||
|
||||
it('deduplicates envTemplate by var name', () => {
|
||||
const servers = [
|
||||
makeServer({
|
||||
packages: { npm: 'pkg' },
|
||||
envTemplate: [{ name: 'TOKEN', description: 'from a', isSecret: true }],
|
||||
popularityScore: 10,
|
||||
}),
|
||||
makeServer({
|
||||
packages: { npm: 'pkg' },
|
||||
envTemplate: [{ name: 'TOKEN', description: 'from b', isSecret: true }],
|
||||
popularityScore: 5,
|
||||
}),
|
||||
];
|
||||
const result = deduplicateResults(servers);
|
||||
expect(result[0]?.envTemplate).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('merges verified status (OR)', () => {
|
||||
const servers = [
|
||||
makeServer({ packages: { npm: 'pkg' }, verified: true, popularityScore: 10 }),
|
||||
makeServer({ packages: { npm: 'pkg' }, verified: false, popularityScore: 5 }),
|
||||
];
|
||||
const result = deduplicateResults(servers);
|
||||
expect(result[0]?.verified).toBe(true);
|
||||
});
|
||||
|
||||
it('handles servers with no npm or repo', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'a' }),
|
||||
makeServer({ name: 'b' }),
|
||||
];
|
||||
// No matching key → no dedup
|
||||
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
91
src/cli/tests/registry/ranking.test.ts
Normal file
91
src/cli/tests/registry/ranking.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankResults } from '../../src/registry/ranking.js';
|
||||
import type { RegistryServer } from '../../src/registry/types.js';
|
||||
|
||||
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||
return {
|
||||
name: 'test-server',
|
||||
description: 'A test server',
|
||||
packages: {},
|
||||
envTemplate: [],
|
||||
transport: 'stdio',
|
||||
popularityScore: 0,
|
||||
verified: false,
|
||||
sourceRegistry: 'official',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('rankResults', () => {
|
||||
it('puts exact name match first', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'slack-extended-tools' }),
|
||||
makeServer({ name: 'slack' }),
|
||||
makeServer({ name: 'my-slack-bot' }),
|
||||
];
|
||||
const ranked = rankResults(servers, 'slack');
|
||||
expect(ranked[0]?.name).toBe('slack');
|
||||
});
|
||||
|
||||
it('ranks verified servers higher than unverified', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'server-a', verified: false }),
|
||||
makeServer({ name: 'server-b', verified: true }),
|
||||
];
|
||||
const ranked = rankResults(servers, 'server');
|
||||
expect(ranked[0]?.name).toBe('server-b');
|
||||
});
|
||||
|
||||
it('ranks popular servers higher', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'unpopular', popularityScore: 1 }),
|
||||
makeServer({ name: 'popular', popularityScore: 10000 }),
|
||||
];
|
||||
const ranked = rankResults(servers, 'test');
|
||||
expect(ranked[0]?.name).toBe('popular');
|
||||
});
|
||||
|
||||
it('considers recency', () => {
|
||||
const recent = new Date();
|
||||
const old = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||
const servers = [
|
||||
makeServer({ name: 'old-server', lastUpdated: old }),
|
||||
makeServer({ name: 'new-server', lastUpdated: recent }),
|
||||
];
|
||||
const ranked = rankResults(servers, 'test');
|
||||
expect(ranked[0]?.name).toBe('new-server');
|
||||
});
|
||||
|
||||
it('handles missing lastUpdated gracefully', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'no-date' }),
|
||||
makeServer({ name: 'has-date', lastUpdated: new Date() }),
|
||||
];
|
||||
// Should not throw
|
||||
const ranked = rankResults(servers, 'test');
|
||||
expect(ranked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('produces stable ordering for identical scores', () => {
|
||||
const servers = Array.from({ length: 10 }, (_, i) =>
|
||||
makeServer({ name: `server-${String(i)}` }),
|
||||
);
|
||||
const ranked1 = rankResults(servers, 'test');
|
||||
const ranked2 = rankResults(servers, 'test');
|
||||
expect(ranked1.map((s) => s.name)).toEqual(ranked2.map((s) => s.name));
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(rankResults([], 'test')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate original array', () => {
|
||||
const servers = [
|
||||
makeServer({ name: 'b' }),
|
||||
makeServer({ name: 'a' }),
|
||||
];
|
||||
const original = [...servers];
|
||||
rankResults(servers, 'test');
|
||||
expect(servers.map((s) => s.name)).toEqual(original.map((s) => s.name));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user