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

File diff suppressed because one or more lines are too long

9
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
js-yaml:
specifier: ^4.1.0
version: 4.1.1
undici:
specifier: ^7.22.0
version: 7.22.0
zod:
specifier: ^3.24.0
version: 3.25.76
@@ -1807,6 +1810,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -3580,6 +3587,8 @@ snapshots:
undici-types@7.18.2: {}
undici@7.22.0: {}
unpipe@1.0.0: {}
uri-js@4.4.1:

68
pr.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# pr.sh - Create PRs on Gitea from current branch
# Usage: ./pr.sh [base_branch] [title]
set -euo pipefail
GITEA_API="http://10.0.0.194:3012/api/v1"
GITEA_PUBLIC="https://mysources.co.uk"
GITEA_TOKEN="$(grep '^GITEA_TOKEN=' /home/michal/developer/michalzxc/claude/homeassistant-alchemy/stack/.env | cut -d= -f2-)"
REPO="michal/mcpctl"
if [[ -z "$GITEA_TOKEN" ]]; then
echo "Error: GITEA_TOKEN not found" >&2
exit 1
fi
BRANCH=$(git branch --show-current)
BASE="${1:-main}"
TITLE="${2:-}"
if [[ "$BRANCH" == "$BASE" ]]; then
echo "Error: already on $BASE, switch to a feature branch first" >&2
exit 1
fi
# Check for existing open PR for this branch
EXISTING=$(curl -s "$GITEA_API/repos/$REPO/pulls?state=open&head=$BRANCH" \
-H "Authorization: token $GITEA_TOKEN" | jq -r '.[0].number // empty' 2>/dev/null)
if [[ -n "$EXISTING" ]]; then
echo "PR #$EXISTING already exists for $BRANCH"
echo "$GITEA_PUBLIC/$REPO/pulls/$EXISTING"
exit 0
fi
# Auto-generate title from branch name if not provided
if [[ -z "$TITLE" ]]; then
TITLE=$(echo "$BRANCH" | sed 's|^feat/||;s|^fix/||;s|^chore/||' | tr '-' ' ' | sed 's/.*/\u&/')
fi
# Build body from commit messages on this branch
COMMITS=$(git log "$BASE..$BRANCH" --pretty=format:"- %s" 2>/dev/null)
BODY="## Summary
${COMMITS}
---
Generated with [Claude Code](https://claude.com/claude-code)"
# Push if needed
if ! git rev-parse --verify "origin/$BRANCH" &>/dev/null; then
echo "Pushing $BRANCH to origin..."
git push -u origin "$BRANCH"
fi
# Create PR
RESPONSE=$(curl -s -X POST "$GITEA_API/repos/$REPO/pulls" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d "$(jq -n --arg title "$TITLE" --arg body "$BODY" --arg head "$BRANCH" --arg base "$BASE" \
'{title: $title, body: $body, head: $head, base: $base}')")
PR_NUM=$(echo "$RESPONSE" | jq -r '.number // empty')
if [[ -z "$PR_NUM" ]]; then
echo "Error creating PR: $(echo "$RESPONSE" | jq -r '.message // "unknown error"')" >&2
exit 1
fi
echo "Created PR #$PR_NUM: $TITLE"
echo "$GITEA_PUBLIC/$REPO/pulls/$PR_NUM"

View File

@@ -22,6 +22,7 @@
"commander": "^13.0.0",
"inquirer": "^12.0.0",
"js-yaml": "^4.1.0",
"undici": "^7.22.0",
"zod": "^3.24.0"
}
}

View File

@@ -2,8 +2,21 @@ import type { RegistryServer } from './types.js';
export abstract class RegistrySource {
abstract readonly name: string;
protected dispatcher: unknown | undefined;
setDispatcher(dispatcher: unknown | undefined): void {
this.dispatcher = dispatcher;
}
abstract search(query: string, limit: number): Promise<RegistryServer[]>;
protected abstract normalizeResult(raw: unknown): RegistryServer;
protected fetchWithDispatcher(url: string): Promise<Response> {
if (this.dispatcher) {
// Node.js built-in fetch accepts undici dispatcher option
return fetch(url, { dispatcher: this.dispatcher } as RequestInit);
}
return fetch(url);
}
}

View File

@@ -6,6 +6,7 @@ import { SmitheryRegistrySource } from './sources/smithery.js';
import { RegistryCache } from './cache.js';
import { deduplicateResults } from './dedup.js';
import { rankResults } from './ranking.js';
import { createHttpAgent } from './http-agent.js';
export class RegistryClient {
private sources: Map<RegistryName, RegistrySource>;
@@ -20,11 +21,27 @@ export class RegistryClient {
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
this.cache = new RegistryCache(config.cacheTTLMs);
this.sources = new Map<RegistryName, RegistrySource>([
// Create HTTP agent for proxy/CA support
const dispatcher = createHttpAgent({
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
caPath: config.caPath,
});
const sources: [RegistryName, RegistrySource][] = [
['official', new OfficialRegistrySource()],
['glama', new GlamaRegistrySource()],
['smithery', new SmitheryRegistrySource()],
]);
];
// Set dispatcher on all sources
if (dispatcher) {
for (const [, source] of sources) {
source.setDispatcher(dispatcher);
}
}
this.sources = new Map(sources);
}
async search(options: SearchOptions): Promise<RegistryServer[]> {
@@ -64,6 +81,12 @@ export class RegistryClient {
if (options.transport !== undefined) {
combined = combined.filter((s) => s.transport === options.transport);
}
if (options.category !== undefined) {
const cat = options.category.toLowerCase();
combined = combined.filter((s) =>
s.category !== undefined && s.category.toLowerCase() === cat
);
}
// Deduplicate, rank, and limit
const deduped = deduplicateResults(combined);

View File

@@ -0,0 +1,26 @@
import fs from 'node:fs';
import { Agent, ProxyAgent } from 'undici';
export interface HttpAgentConfig {
httpProxy?: string;
httpsProxy?: string;
caPath?: string;
}
export function createHttpAgent(config: HttpAgentConfig): Agent | ProxyAgent | undefined {
const proxy = (config.httpsProxy ?? config.httpProxy) || undefined;
const caPath = config.caPath || undefined;
if (!proxy && !caPath) return undefined;
const ca = caPath ? fs.readFileSync(caPath) : undefined;
if (proxy) {
return new ProxyAgent({
uri: proxy,
connect: ca ? { ca } : undefined,
});
}
return new Agent({ connect: { ca } });
}

View File

@@ -4,6 +4,8 @@ export { RegistrySource } from './base.js';
export { deduplicateResults } from './dedup.js';
export { rankResults } from './ranking.js';
export { withRetry } from './retry.js';
export { createHttpAgent, type HttpAgentConfig } from './http-agent.js';
export { collectMetrics, type RegistryMetrics } from './metrics.js';
export { OfficialRegistrySource } from './sources/official.js';
export { GlamaRegistrySource } from './sources/glama.js';
export { SmitheryRegistrySource } from './sources/smithery.js';

View File

@@ -0,0 +1,22 @@
import type { RegistryClient } from './client.js';
export interface RegistryMetrics {
queryLatencyMs: { source: string; latencies: number[] }[];
cacheHitRatio: number;
cacheHits: number;
cacheMisses: number;
errorCounts: { source: string; count: number }[];
}
export function collectMetrics(client: RegistryClient): RegistryMetrics {
const cacheMetrics = client.getCacheMetrics();
return {
queryLatencyMs: Array.from(client.getQueryLatencies().entries())
.map(([source, latencies]) => ({ source, latencies })),
cacheHitRatio: cacheMetrics.ratio,
cacheHits: cacheMetrics.hits,
cacheMisses: cacheMetrics.misses,
errorCounts: Array.from(client.getErrorCounts().entries())
.map(([source, count]) => ({ source, count })),
};
}

View File

@@ -23,7 +23,7 @@ export class GlamaRegistrySource extends RegistrySource {
url.searchParams.set('after', cursor);
}
const response = await withRetry(() => fetch(url.toString()));
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
if (!response.ok) {
throw new Error(`Glama registry returned ${String(response.status)}`);
}
@@ -74,6 +74,10 @@ export class GlamaRegistrySource extends RegistrySource {
packages.npm = entry.slug;
}
// Extract category from attributes (e.g. "category:devops" -> "devops")
const categoryAttr = attrs.find((a: string) => a.startsWith('category:'));
const category = categoryAttr ? categoryAttr.split(':')[1] : undefined;
const result: RegistryServer = {
name: sanitizeString(entry.name),
description: sanitizeString(entry.description),
@@ -84,6 +88,9 @@ export class GlamaRegistrySource extends RegistrySource {
verified: attrs.includes('author:official'),
sourceRegistry: 'glama',
};
if (category !== undefined) {
result.category = category;
}
if (entry.repository?.url !== undefined) {
result.repositoryUrl = entry.repository.url;
}

View File

@@ -24,7 +24,7 @@ export class OfficialRegistrySource extends RegistrySource {
url.searchParams.set('cursor', cursor);
}
const response = await withRetry(() => fetch(url.toString()));
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
if (!response.ok) {
throw new Error(`Official registry returned ${String(response.status)}`);
}

View File

@@ -22,7 +22,7 @@ export class SmitheryRegistrySource extends RegistrySource {
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
url.searchParams.set('page', String(page));
const response = await withRetry(() => fetch(url.toString()));
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
if (!response.ok) {
throw new Error(`Smithery registry returned ${String(response.status)}`);
}

View File

@@ -23,6 +23,7 @@ export interface RegistryServer {
repositoryUrl?: string;
popularityScore: number;
verified: boolean;
category?: string;
sourceRegistry: 'official' | 'glama' | 'smithery';
lastUpdated?: Date;
}
@@ -44,6 +45,7 @@ export interface RegistryClientConfig {
smitheryApiKey?: string;
httpProxy?: string;
httpsProxy?: string;
caPath?: string;
}
// ── Zod schemas for API response validation ──

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHttpAgent } from '../../src/registry/http-agent.js';
// Mock undici with proper constructable classes
vi.mock('undici', () => {
class MockAgent {
__type = 'Agent';
__opts: unknown;
constructor(opts: unknown) {
this.__opts = opts;
}
}
class MockProxyAgent {
__type = 'ProxyAgent';
__opts: unknown;
constructor(opts: unknown) {
this.__opts = opts;
}
}
return { Agent: MockAgent, ProxyAgent: MockProxyAgent };
});
// Mock fs
vi.mock('node:fs', () => ({
default: {
readFileSync: vi.fn().mockReturnValue(Buffer.from('mock-ca-cert')),
},
readFileSync: vi.fn().mockReturnValue(Buffer.from('mock-ca-cert')),
}));
describe('createHttpAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns undefined when no proxy and no CA configured', () => {
const result = createHttpAgent({});
expect(result).toBeUndefined();
});
it('returns undefined when config has empty strings', () => {
const result = createHttpAgent({ httpProxy: '', httpsProxy: '' });
expect(result).toBeUndefined();
});
it('returns a ProxyAgent when httpProxy is configured', () => {
const result = createHttpAgent({ httpProxy: 'http://proxy:8080' }) as { __type: string };
expect(result).toBeDefined();
expect(result.__type).toBe('ProxyAgent');
});
it('returns a ProxyAgent when httpsProxy is configured', () => {
const result = createHttpAgent({ httpsProxy: 'http://proxy:8443' }) as { __type: string };
expect(result).toBeDefined();
expect(result.__type).toBe('ProxyAgent');
});
it('prefers httpsProxy over httpProxy', () => {
const result = createHttpAgent({
httpProxy: 'http://proxy:8080',
httpsProxy: 'http://proxy:8443',
}) as { __type: string; __opts: { uri: string } };
expect(result.__type).toBe('ProxyAgent');
expect(result.__opts.uri).toBe('http://proxy:8443');
});
it('returns an Agent with CA when only caPath is configured', () => {
const result = createHttpAgent({ caPath: '/path/to/ca.pem' }) as { __type: string };
expect(result).toBeDefined();
expect(result.__type).toBe('Agent');
});
it('returns a ProxyAgent with CA when both proxy and caPath are configured', () => {
const result = createHttpAgent({
httpsProxy: 'http://proxy:8443',
caPath: '/path/to/ca.pem',
}) as { __type: string; __opts: { uri: string; connect: { ca: Buffer } } };
expect(result.__type).toBe('ProxyAgent');
expect(result.__opts.uri).toBe('http://proxy:8443');
expect(result.__opts.connect).toBeDefined();
expect(result.__opts.connect.ca).toBeDefined();
});
it('reads CA file from filesystem', async () => {
const fs = await import('node:fs');
createHttpAgent({ caPath: '/etc/ssl/custom-ca.pem' });
expect(fs.default.readFileSync).toHaveBeenCalledWith('/etc/ssl/custom-ca.pem');
});
});

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);
});
});