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:
File diff suppressed because one or more lines are too long
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
|
undici:
|
||||||
|
specifier: ^7.22.0
|
||||||
|
version: 7.22.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -1807,6 +1810,10 @@ packages:
|
|||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
|
undici@7.22.0:
|
||||||
|
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
unpipe@1.0.0:
|
unpipe@1.0.0:
|
||||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3580,6 +3587,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
|
undici@7.22.0: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
|
|||||||
68
pr.sh
Executable file
68
pr.sh
Executable 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"
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
"inquirer": "^12.0.0",
|
"inquirer": "^12.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"undici": "^7.22.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,21 @@ import type { RegistryServer } from './types.js';
|
|||||||
|
|
||||||
export abstract class RegistrySource {
|
export abstract class RegistrySource {
|
||||||
abstract readonly name: string;
|
abstract readonly name: string;
|
||||||
|
protected dispatcher: unknown | undefined;
|
||||||
|
|
||||||
|
setDispatcher(dispatcher: unknown | undefined): void {
|
||||||
|
this.dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
abstract search(query: string, limit: number): Promise<RegistryServer[]>;
|
abstract search(query: string, limit: number): Promise<RegistryServer[]>;
|
||||||
|
|
||||||
protected abstract normalizeResult(raw: unknown): 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SmitheryRegistrySource } from './sources/smithery.js';
|
|||||||
import { RegistryCache } from './cache.js';
|
import { RegistryCache } from './cache.js';
|
||||||
import { deduplicateResults } from './dedup.js';
|
import { deduplicateResults } from './dedup.js';
|
||||||
import { rankResults } from './ranking.js';
|
import { rankResults } from './ranking.js';
|
||||||
|
import { createHttpAgent } from './http-agent.js';
|
||||||
|
|
||||||
export class RegistryClient {
|
export class RegistryClient {
|
||||||
private sources: Map<RegistryName, RegistrySource>;
|
private sources: Map<RegistryName, RegistrySource>;
|
||||||
@@ -20,11 +21,27 @@ export class RegistryClient {
|
|||||||
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
|
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
|
||||||
this.cache = new RegistryCache(config.cacheTTLMs);
|
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()],
|
['official', new OfficialRegistrySource()],
|
||||||
['glama', new GlamaRegistrySource()],
|
['glama', new GlamaRegistrySource()],
|
||||||
['smithery', new SmitheryRegistrySource()],
|
['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[]> {
|
async search(options: SearchOptions): Promise<RegistryServer[]> {
|
||||||
@@ -64,6 +81,12 @@ export class RegistryClient {
|
|||||||
if (options.transport !== undefined) {
|
if (options.transport !== undefined) {
|
||||||
combined = combined.filter((s) => s.transport === options.transport);
|
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
|
// Deduplicate, rank, and limit
|
||||||
const deduped = deduplicateResults(combined);
|
const deduped = deduplicateResults(combined);
|
||||||
|
|||||||
26
src/cli/src/registry/http-agent.ts
Normal file
26
src/cli/src/registry/http-agent.ts
Normal 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 } });
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export { RegistrySource } from './base.js';
|
|||||||
export { deduplicateResults } from './dedup.js';
|
export { deduplicateResults } from './dedup.js';
|
||||||
export { rankResults } from './ranking.js';
|
export { rankResults } from './ranking.js';
|
||||||
export { withRetry } from './retry.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 { OfficialRegistrySource } from './sources/official.js';
|
||||||
export { GlamaRegistrySource } from './sources/glama.js';
|
export { GlamaRegistrySource } from './sources/glama.js';
|
||||||
export { SmitheryRegistrySource } from './sources/smithery.js';
|
export { SmitheryRegistrySource } from './sources/smithery.js';
|
||||||
|
|||||||
22
src/cli/src/registry/metrics.ts
Normal file
22
src/cli/src/registry/metrics.ts
Normal 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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export class GlamaRegistrySource extends RegistrySource {
|
|||||||
url.searchParams.set('after', cursor);
|
url.searchParams.set('after', cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await withRetry(() => fetch(url.toString()));
|
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Glama registry returned ${String(response.status)}`);
|
throw new Error(`Glama registry returned ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,10 @@ export class GlamaRegistrySource extends RegistrySource {
|
|||||||
packages.npm = entry.slug;
|
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 = {
|
const result: RegistryServer = {
|
||||||
name: sanitizeString(entry.name),
|
name: sanitizeString(entry.name),
|
||||||
description: sanitizeString(entry.description),
|
description: sanitizeString(entry.description),
|
||||||
@@ -84,6 +88,9 @@ export class GlamaRegistrySource extends RegistrySource {
|
|||||||
verified: attrs.includes('author:official'),
|
verified: attrs.includes('author:official'),
|
||||||
sourceRegistry: 'glama',
|
sourceRegistry: 'glama',
|
||||||
};
|
};
|
||||||
|
if (category !== undefined) {
|
||||||
|
result.category = category;
|
||||||
|
}
|
||||||
if (entry.repository?.url !== undefined) {
|
if (entry.repository?.url !== undefined) {
|
||||||
result.repositoryUrl = entry.repository.url;
|
result.repositoryUrl = entry.repository.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class OfficialRegistrySource extends RegistrySource {
|
|||||||
url.searchParams.set('cursor', cursor);
|
url.searchParams.set('cursor', cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await withRetry(() => fetch(url.toString()));
|
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Official registry returned ${String(response.status)}`);
|
throw new Error(`Official registry returned ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class SmitheryRegistrySource extends RegistrySource {
|
|||||||
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
|
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
|
||||||
url.searchParams.set('page', String(page));
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
const response = await withRetry(() => fetch(url.toString()));
|
const response = await withRetry(() => this.fetchWithDispatcher(url.toString()));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Smithery registry returned ${String(response.status)}`);
|
throw new Error(`Smithery registry returned ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface RegistryServer {
|
|||||||
repositoryUrl?: string;
|
repositoryUrl?: string;
|
||||||
popularityScore: number;
|
popularityScore: number;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
category?: string;
|
||||||
sourceRegistry: 'official' | 'glama' | 'smithery';
|
sourceRegistry: 'official' | 'glama' | 'smithery';
|
||||||
lastUpdated?: Date;
|
lastUpdated?: Date;
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,7 @@ export interface RegistryClientConfig {
|
|||||||
smitheryApiKey?: string;
|
smitheryApiKey?: string;
|
||||||
httpProxy?: string;
|
httpProxy?: string;
|
||||||
httpsProxy?: string;
|
httpsProxy?: string;
|
||||||
|
caPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zod schemas for API response validation ──
|
// ── Zod schemas for API response validation ──
|
||||||
|
|||||||
89
src/cli/tests/registry/http-agent.test.ts
Normal file
89
src/cli/tests/registry/http-agent.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
164
src/cli/tests/registry/metrics.test.ts
Normal file
164
src/cli/tests/registry/metrics.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user