feat: add HTTP proxy, custom CA, metrics exposure, and category filtering

- Add createHttpAgent() for proxy/CA support via undici
- Thread dispatcher through all registry sources
- Add collectMetrics() for SRE metrics exposure
- Add caPath to RegistryClientConfig
- Add category field to RegistryServer with Glama extraction
- Add category filtering in client search
- Add pr.sh for Gitea PR creation

63 tests passing (13 new).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 03:53:08 +00:00
parent 386029d052
commit 53245b4826
15 changed files with 447 additions and 16 deletions

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { collectMetrics, type RegistryMetrics } from '../../src/registry/metrics.js';
import { RegistryClient } from '../../src/registry/client.js';
import type { RegistryServer } from '../../src/registry/types.js';
const mockFetch = vi.fn();
function makeServer(name: string, source: 'official' | 'glama' | 'smithery'): RegistryServer {
return {
name,
description: `${name} description`,
packages: { npm: `@test/${name}` },
envTemplate: [],
transport: 'stdio',
popularityScore: 50,
verified: false,
sourceRegistry: source,
};
}
function mockAllRegistries(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: [{ registryType: 'npm', identifier: s.packages.npm, transport: { type: 'stdio' }, environmentVariables: [] }],
remotes: [],
},
})),
metadata: { nextCursor: null },
}),
});
}
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: '' })),
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: false, useCount: 0, remote: false })),
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
}),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
}
describe('collectMetrics', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockReset();
});
it('returns correct structure with all required fields', async () => {
mockAllRegistries([makeServer('test', 'official')]);
const client = new RegistryClient();
await client.search({ query: 'test' });
const metrics = collectMetrics(client);
expect(metrics).toHaveProperty('queryLatencyMs');
expect(metrics).toHaveProperty('cacheHitRatio');
expect(metrics).toHaveProperty('cacheHits');
expect(metrics).toHaveProperty('cacheMisses');
expect(metrics).toHaveProperty('errorCounts');
expect(Array.isArray(metrics.queryLatencyMs)).toBe(true);
expect(Array.isArray(metrics.errorCounts)).toBe(true);
expect(typeof metrics.cacheHitRatio).toBe('number');
});
it('captures latencies per source', async () => {
mockAllRegistries([
makeServer('test', 'official'),
makeServer('test', 'glama'),
makeServer('test', 'smithery'),
]);
const client = new RegistryClient();
await client.search({ query: 'test' });
const metrics = collectMetrics(client);
expect(metrics.queryLatencyMs.length).toBeGreaterThan(0);
for (const entry of metrics.queryLatencyMs) {
expect(entry).toHaveProperty('source');
expect(entry).toHaveProperty('latencies');
expect(Array.isArray(entry.latencies)).toBe(true);
expect(entry.latencies.length).toBeGreaterThan(0);
}
});
it('captures cache hit ratio', async () => {
mockAllRegistries([makeServer('test', 'official')]);
const client = new RegistryClient();
// First call: miss
await client.search({ query: 'test' });
// Second call: hit
await client.search({ query: 'test' });
const metrics = collectMetrics(client);
expect(metrics.cacheHits).toBe(1);
expect(metrics.cacheMisses).toBe(1);
expect(metrics.cacheHitRatio).toBe(0.5);
});
it('captures error counts per source', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('glama.ai')) {
return Promise.reject(new Error('fail'));
}
if (url.includes('registry.modelcontextprotocol.io')) {
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 metrics = collectMetrics(client);
const glamaError = metrics.errorCounts.find((e) => e.source === 'glama');
expect(glamaError).toBeDefined();
expect(glamaError!.count).toBe(1);
});
it('works with empty metrics (no queries made)', () => {
const client = new RegistryClient();
const metrics = collectMetrics(client);
expect(metrics.queryLatencyMs).toEqual([]);
expect(metrics.errorCounts).toEqual([]);
expect(metrics.cacheHits).toBe(0);
expect(metrics.cacheMisses).toBe(0);
expect(metrics.cacheHitRatio).toBe(0);
});
});