Files
mcpctl/src/cli/src/registry/sources/official.ts
Michal 386029d052 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>
2026-02-21 03:46:14 +00:00

107 lines
3.8 KiB
TypeScript

import { RegistrySource } from '../base.js';
import {
OfficialRegistryResponseSchema,
sanitizeString,
type OfficialServerEntry,
type RegistryServer,
} from '../types.js';
import { withRetry } from '../retry.js';
const BASE_URL = 'https://registry.modelcontextprotocol.io/v0/servers';
export class OfficialRegistrySource extends RegistrySource {
readonly name = 'official' as const;
async search(query: string, limit: number): Promise<RegistryServer[]> {
const results: RegistryServer[] = [];
let cursor: string | null | undefined;
while (results.length < limit) {
const url = new URL(BASE_URL);
url.searchParams.set('search', query);
url.searchParams.set('limit', String(Math.min(limit - results.length, 100)));
if (cursor !== undefined && cursor !== null) {
url.searchParams.set('cursor', cursor);
}
const response = await withRetry(() => fetch(url.toString()));
if (!response.ok) {
throw new Error(`Official registry returned ${String(response.status)}`);
}
const raw: unknown = await response.json();
const parsed = OfficialRegistryResponseSchema.parse(raw);
for (const entry of parsed.servers) {
results.push(this.normalizeResult(entry));
}
cursor = parsed.metadata?.nextCursor;
if (cursor === null || cursor === undefined || parsed.servers.length === 0) break;
}
return results.slice(0, limit);
}
protected normalizeResult(raw: unknown): RegistryServer {
const entry = raw as OfficialServerEntry;
const server = entry.server;
// Extract env vars from packages
const envTemplate = server.packages.flatMap((pkg: { environmentVariables: Array<{ name: string; description?: string; isSecret?: boolean }> }) =>
pkg.environmentVariables.map((ev: { name: string; description?: string; isSecret?: boolean }) => ({
name: ev.name,
description: sanitizeString(ev.description ?? ''),
isSecret: ev.isSecret ?? false,
})),
);
// Determine transport from packages or remotes
let transport: RegistryServer['transport'] = 'stdio';
if (server.packages.length > 0) {
const pkgTransport = server.packages[0]?.transport?.type;
if (pkgTransport === 'stdio') transport = 'stdio';
}
if (server.remotes.length > 0) {
const remoteType = server.remotes[0]?.type;
if (remoteType === 'sse') transport = 'sse';
else if (remoteType === 'streamable-http') transport = 'streamable-http';
}
// Extract npm package identifier
const npmPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'npm');
const dockerPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'oci');
// Extract dates from _meta
const meta = entry._meta as Record<string, Record<string, unknown>> | undefined;
const officialMeta = meta?.['io.modelcontextprotocol.registry/official'];
const updatedAt = officialMeta?.['updatedAt'];
const packages: RegistryServer['packages'] = {};
if (npmPkg !== undefined) {
packages.npm = npmPkg.identifier;
}
if (dockerPkg !== undefined) {
packages.docker = dockerPkg.identifier;
}
const result: RegistryServer = {
name: sanitizeString(server.title ?? server.name),
description: sanitizeString(server.description),
packages,
envTemplate,
transport,
popularityScore: 0, // Official registry has no popularity data
verified: false, // Official registry has no verified badges
sourceRegistry: 'official',
};
if (server.repository?.url !== undefined) {
result.repositoryUrl = server.repository.url;
}
if (typeof updatedAt === 'string') {
result.lastUpdated = new Date(updatedAt);
}
return result;
}
}