/** * Catalog assembly: remote registries only. * * - Remote registries are fetched over HTTP (30s timeout; the official * registry is known to vary between ~2s and ~20s) and cached to * `/pi-mcp-market/cache/.json`; on fetch failure the * cache is used, and without a cache the registry is reported as failed. * - Each registry declares a payload `format` (native / official / glama / * smithery); non-native formats are normalized into the native MarketServer * array shape before the same minimal validation applies. * - Optional auth: `headers` for static headers, `apiKeyEnv` for a key read * from the environment at request time (never persisted). When the env var * is unset the source is marked failed and no request is made. */ import { loadRegistries, readRegistryCache, writeRegistryCache } from "./config.ts"; import type { AuthType, CatalogBundle, MarketServer, RegistryFormat, RegistrySpec, RequiredEnv, ServerEntry, } from "./types.ts"; const FETCH_TIMEOUT_MS = 30_000; /** Placeholder value inside a static header that gets replaced by the env key. */ const API_KEY_PLACEHOLDER = "{{API_KEY}}"; /** * Validate and normalize a raw catalog payload. Invalid entries are skipped * and counted. Returns the normalized servers. */ export function normalizeCatalogEntries( raw: unknown, source: string, ): { servers: MarketServer[]; skipped: number } { if (!Array.isArray(raw)) return { servers: [], skipped: 0 }; const servers: MarketServer[] = []; let skipped = 0; for (const item of raw) { const normalized = normalizeCatalogEntry(item, source); if (normalized) { servers.push(normalized); } else { skipped++; } } return { servers, skipped }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } /** * Normalize one raw item into the native catalog shape (or null when it * cannot be a valid entry). Existing field whitelisting then applies. */ function toNativeShape(item: unknown): { id?: string; name?: string; summary?: string; description?: string; tags?: string[]; entry?: unknown; requiredEnv?: unknown; } | null { if (!isRecord(item)) return null; return { id: asString(item.id), name: asString(item.name), summary: asString(item.summary), description: asString(item.description), tags: Array.isArray(item.tags) ? item.tags.filter((t): t is string => typeof t === "string") : undefined, entry: item.entry, requiredEnv: item.requiredEnv, }; } /** * Normalize the official modelcontextprotocol.io registry payload: * `{ servers: [{ server: { name, title, description, remotes: [{ type: "streamable-http", url }] }, _meta }] }`. * The transport lives in `server.remotes[]`; the first streamable-http (or * any) url becomes the entry's `url`. Entries without a transport url are * skipped and counted by the shared validation. */ export function normalizeOfficial( raw: unknown, source: string, ): { servers: MarketServer[]; skipped: number } { if (!isRecord(raw) || !Array.isArray(raw.servers)) return { servers: [], skipped: 0 }; const rawItems = raw.servers.filter(isRecord); const latestByName = new Set(); for (const item of rawItems) { const name = isRecord(item.server) ? asString(item.server.name) : undefined; const meta = isRecord(item._meta) ? item._meta["io.modelcontextprotocol.registry/official"] : undefined; if (name && isRecord(meta) && meta.isLatest === true) latestByName.add(name); } const currentItems = rawItems.filter((item) => { if (!isRecord(item.server)) return true; const id = asString(item.server.name); if (!id || !latestByName.has(id)) return true; const meta = isRecord(item._meta) ? item._meta["io.modelcontextprotocol.registry/official"] : undefined; return isRecord(meta) && meta.isLatest === true; }); const items = currentItems.map((item) => { if (!isRecord(item.server)) return null; const server = item.server; const id = asString(server.name); if (!id) return null; const remotes = Array.isArray(server.remotes) ? server.remotes.filter(isRecord) : []; const streamableHttp = remotes.find((r) => asString(r.type) === "streamable-http"); const anyUrl = remotes.find((r) => asString(r.url) !== undefined); const url = asString(streamableHttp?.url) ?? (anyUrl ? asString(anyUrl.url) : undefined); if (!url) return null; const description = asString(server.description); const displayName = asString(server.title) ?? id; // Extract auth headers from the streamable-http remote (fallback to anyUrl) // Official registry format: headers: [{ name, description, isRequired, isSecret }] const remoteWithHeaders = streamableHttp ?? anyUrl; const rawHeaders = Array.isArray(remoteWithHeaders?.headers) ? remoteWithHeaders.headers.filter(isRecord) : []; const requiredEnv: RequiredEnv[] = []; let bearerTokenEnv: string | undefined; const authHeaders = rawHeaders.filter((h) => h.isRequired === true && h.isSecret === true); if (authHeaders.length > 0) { for (const h of authHeaders) { const headerName = asString(h.name); if (!headerName) continue; // Derive env var name from server name when header is Authorization, else from header name const envKey = headerName.toLowerCase() === "authorization" ? `${displayName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY` : headerName.toUpperCase().replace(/[^A-Z0-9]/g, "_"); if (headerName.toLowerCase() === "authorization" && !bearerTokenEnv) { bearerTokenEnv = envKey; } // Parse whereToGet from description — extract URL, or use the full description as hint const headerDesc = asString(h.description); let whereToGet: string | undefined; let hint: string | undefined; if (headerDesc) { const urlMatch = headerDesc.match(/https?:\/\/[^\s]+/); if (urlMatch) { whereToGet = urlMatch[0]; hint = headerDesc.replace(/\s*https?:\/\/[^\s]+\s*/g, "").trim() || undefined; } else { hint = headerDesc; } } // Fallback: use websiteUrl or publisher-provided connect/docs URL if (!whereToGet) { whereToGet = asString(server.websiteUrl); } if (!whereToGet) { const pubMeta = isRecord(server._meta) ? server._meta["io.modelcontextprotocol.registry/publisher-provided"] : undefined; if (isRecord(pubMeta)) { whereToGet = asString(pubMeta.connect) ?? asString(pubMeta.docs) ?? undefined; } } requiredEnv.push({ key: envKey, label: `${displayName} ${headerName} Token`, hint, whereToGet, }); } } return { id, name: displayName, summary: description ?? displayName, description, entry: { url, ...(bearerTokenEnv ? { auth: "bearer" as const, bearerTokenEnv } : {}) }, requiredEnv: requiredEnv.length > 0 ? requiredEnv : undefined, }; }); return normalizeCatalogEntries(items, source); } /** * Derive the requiredEnv list from a JSON Schema-style env descriptor such as * Glama's `environmentVariablesJsonSchema` (`{ required: [...], properties: {...} }`). * Every required key comes first, then any remaining property keys. */ export function glamaRequiredEnv(schema: unknown): RequiredEnv[] | undefined { if (!isRecord(schema)) return undefined; const names: string[] = []; const seen = new Set(); const push = (key: unknown) => { const k = asString(key); if (k && !seen.has(k)) { seen.add(k); names.push(k); } }; if (Array.isArray(schema.required)) { for (const key of schema.required) push(key); } if (isRecord(schema.properties)) { for (const key of Object.keys(schema.properties)) push(key); } if (names.length === 0) return undefined; return names.map((key) => ({ key, label: key })); } /** * Normalize the Glama registry payload: * `{ pageInfo, servers: [{ id, name, slug, description, environmentVariablesJsonSchema }] }`. * Glama entries carry no transport field (no command/url), so every entry is * skipped and counted — the panel stays intact and the README documents that * this source is metadata only. We never invent a gateway URL. */ export function normalizeGlama( raw: unknown, source: string, ): { servers: MarketServer[]; skipped: number } { if (!isRecord(raw) || !Array.isArray(raw.servers)) return { servers: [], skipped: 0 }; const items = raw.servers.map((item) => { if (!isRecord(item)) return null; const shape = toNativeShape(item); if (!shape) return null; return { ...shape, requiredEnv: glamaRequiredEnv(item.environmentVariablesJsonSchema) }; }); return normalizeCatalogEntries(items, source); } /** * Normalize the Smithery registry payload: * `{ servers: [{ qualifiedName, displayName, description, ... }] }`. * Installation follows the documented CLI route: * `npx -y @smithery/cli run `. */ export function normalizeSmithery( raw: unknown, source: string, ): { servers: MarketServer[]; skipped: number } { if (!isRecord(raw) || !Array.isArray(raw.servers)) return { servers: [], skipped: 0 }; const items = raw.servers.map((item) => { if (!isRecord(item)) return null; const shape = toNativeShape(item); if (!shape) return null; const qualifiedName = asString(item.qualifiedName); if (!qualifiedName) return null; return { id: qualifiedName, name: asString(item.displayName) ?? shape.name ?? qualifiedName, summary: shape.description ?? shape.summary ?? asString(item.displayName) ?? qualifiedName, description: shape.description, tags: shape.tags, entry: { command: "npx", args: ["-y", "@smithery/cli", "run", qualifiedName] }, requiredEnv: item.requiredEnv, }; }); return normalizeCatalogEntries(items, source); } /** * Normalize the DeepNLP marketplace payload: * `{ total_hits, count, items: [{ content_name, id, description, subfield, * content_tag_list, field, website, github, ext_info: { tools }, config }] }`. * * Entry derivation (URL-first): * 1. OneKey URL via `https://agent.deepnlp.org/mcp?server_name={repo}&onekey=$DEEPNLP_ONEKEY_ROUTER_ACCESS` * 2. GitHub npx command (`npx -y owner/repo`) as fallback * * Items without a derivable transport are skipped and counted. */ export function normalizeDeepnlp( raw: unknown, source: string, ): { servers: MarketServer[]; skipped: number } { if (!isRecord(raw) || !Array.isArray(raw.items)) return { servers: [], skipped: 0 }; const items = raw.items.filter(isRecord); const servers: MarketServer[] = []; let skipped = 0; for (const item of items) { const id = asString(item.id); const name = asString(item.content_name); if (!id || !name) { skipped++; continue; } const description = asString(item.description); const subfield = asString(item.subfield); const github = asString(item.github); const extInfo = isRecord(item.ext_info) ? item.ext_info : undefined; const rawTools = Array.isArray(extInfo?.tools) ? extInfo!.tools : []; // Parse tags from subfield + content_tag_list const tagStr = asString(item.content_tag_list); const tags: string[] = []; if (subfield) tags.push(subfield.toLowerCase()); if (tagStr) { for (const t of tagStr.split(",")) { const trimmed = t.trim().toLowerCase(); if (trimmed) tags.push(trimmed); } } // Derive transport: OneKey URL > npx const derived = deriveDeepnlpEntry(id, github); if (!derived) { skipped++; continue; } const authType = deriveDeepnlpAuth(derived, tags); const configTemplate = formatConfigSnippet(id, derived.entry, authType); const toolCount = rawTools.length > 0 ? rawTools.length : undefined; servers.push({ id, name, summary: description ? truncateDescription(description, 100) : name, description, tags: tags.length > 0 ? tags : undefined, entry: derived.entry, requiredEnv: derived.requiredEnv, source, authType, configTemplate, toolCount, installable: true, }); } return { servers, skipped }; } /** Truncate a markdown description to plain text at N chars. */ function truncateDescription(text: string, maxChars: number): string { // Strip markdown formatting for summary display const plain = text.replace(/[#*`\[\]]/g, "").replace(/\n+/g, " ").trim(); if (plain.length <= maxChars) return plain; return plain.slice(0, maxChars).replace(/\s+\S*$/, "") + "…"; } /** Derive entry + requiredEnv from a deepnlp item. URL (OneKey) > npx. */ function deriveDeepnlpEntry( id: string, github: string | undefined, ): { entry: ServerEntry; requiredEnv?: RequiredEnv[] } | null { const parts = id.split("/"); // Option 1: OneKey URL — the "远程 URL" option for deepnlp items if (parts.length === 2 && parts[0] && parts[1]) { const repo = parts[1]; const url = `https://agent.deepnlp.org/mcp?server_name=${repo}&onekey=\${DEEPNLP_ONEKEY_ROUTER_ACCESS}`; return { entry: { url }, requiredEnv: [{ key: "DEEPNLP_ONEKEY_ROUTER_ACCESS", label: "DeepNLP OneKey 访问密钥", hint: "环境变量值将替换 URL 中的 ${DEEPNLP_ONEKEY_ROUTER_ACCESS}", whereToGet: "https://www.deepnlp.org/workspace/keys", }], }; } // Option 2: npx command from GitHub URL if (github) { const m = github.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); if (m) { const [, owner, repo] = m; return { entry: { command: "npx", args: ["-y", `${owner}/${repo.replace(/\.git$/, "")}`] }, }; } } return null; } /** Classify auth type for a deepnlp-derived entry. */ function deriveDeepnlpAuth( derived: { entry: ServerEntry; requiredEnv?: RequiredEnv[] }, tags: string[], ): AuthType { // OneKey URL needs an env var → api-key if (derived.entry.url?.includes("onekey")) return "api-key"; // Official servers → generally no extra auth if (tags.includes("official")) return "none"; // Unknown return "unknown"; } /** Format a copyable JSON snippet for the panel detail view. */ function formatConfigSnippet(id: string, entry: ServerEntry, authType: AuthType): string { const lines: string[] = [`"${id}": {`]; if (entry.url) lines.push(` "url": "${entry.url}",`); if (entry.command) { lines.push(` "command": "${entry.command}",`); if (entry.args && entry.args.length > 0) { lines.push(` "args": ${JSON.stringify(entry.args)},`); } } if (authType === "api-key") { lines.push(` "auth": "bearer",`); lines.push(` "bearerTokenEnv": "${(entry.bearerTokenEnv ?? "") || guessBearerEnv(id)}",`); } if (lines[lines.length - 1]!.endsWith(",")) { lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -1); } lines.push("}"); return lines.join("\n"); } /** Guess the bearer token env var name from the server id. */ function guessBearerEnv(id: string): string { const repo = id.split("/")[1] ?? id; return `${repo.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`; } /** Pick the normalizer for a registry format. Defaults to native. */ function normalizerFor( format: RegistryFormat | undefined, ): (raw: unknown, source: string) => { servers: MarketServer[]; skipped: number } { switch (format) { case "official": return normalizeOfficial; case "glama": return normalizeGlama; case "smithery": return normalizeSmithery; case "deepnlp": return normalizeDeepnlp; default: return normalizeCatalogEntries; } } /** Validate a single entry: needs id, name, and an entry with command or url. */ function normalizeCatalogEntry(item: unknown, source: string): MarketServer | null { if (!isRecord(item)) return null; const id = asString(item.id); const name = asString(item.name); if (!id || !name) return null; if (!isRecord(item.entry)) return null; const entry: MarketServer["entry"] = {}; const command = asString(item.entry.command); const url = asString(item.entry.url); if (!command && !url) return null; if (command) entry.command = command; if (url) entry.url = url; if (Array.isArray(item.entry.args)) { entry.args = item.entry.args.filter((a): a is string => typeof a === "string"); } if (asString(item.entry.auth) !== undefined) { const auth = item.entry.auth; if (auth === "oauth" || auth === "bearer" || auth === false) { entry.auth = auth; } } if (asString(item.entry.bearerTokenEnv) !== undefined) { entry.bearerTokenEnv = asString(item.entry.bearerTokenEnv); } const lifecycle = asString(item.entry.lifecycle); if (lifecycle === "keep-alive" || lifecycle === "lazy" || lifecycle === "lazy-keep-alive" || lifecycle === "eager") { entry.lifecycle = lifecycle; } const directTools = item.entry.directTools; if (typeof directTools === "boolean" || Array.isArray(directTools)) { entry.directTools = directTools; } if (isRecord(item.entry.env)) { const env: Record = {}; for (const [k, v] of Object.entries(item.entry.env)) { if (typeof v === "string") env[k] = v; } entry.env = env; } if (isRecord(item.entry.headers)) { const headers: Record = {}; for (const [k, v] of Object.entries(item.entry.headers)) { if (typeof v === "string") headers[k] = v; } entry.headers = headers; } const tags = Array.isArray(item.tags) ? item.tags.filter((t): t is string => typeof t === "string") : undefined; // When bearerTokenEnv is set without a matching requiredEnv entry, // auto-generate one so users get prompted with whereToGet info let requiredEnv: Array<{ key: string; label: string; hint: string | undefined; whereToGet: string | undefined }> | undefined = Array.isArray(item.requiredEnv) ? item.requiredEnv .filter(isRecord) .map((r) => { const key = asString(r.key); if (!key) return null; return { key, label: asString(r.label) ?? key, hint: asString(r.hint), whereToGet: asString(r.whereToGet), }; }) .filter((r): r is NonNullable => r !== null) : []; if (entry.bearerTokenEnv && !requiredEnv.some((r) => r.key === entry.bearerTokenEnv)) { requiredEnv.push({ key: entry.bearerTokenEnv, label: entry.bearerTokenEnv, hint: undefined, whereToGet: undefined, }); } if (requiredEnv.length === 0) { requiredEnv = undefined; } // Auto-set entry.auth when bearerTokenEnv is present if (entry.bearerTokenEnv && !entry.auth) { entry.auth = "bearer"; } // Derive authType from entry.auth so the badge shows for all sources // (native / official / smithery / glama all flow through this validator) let authType: AuthType | undefined; if (entry.auth === "oauth") authType = "oauth"; else if (entry.auth === "bearer") authType = "bearer"; else if (entry.auth === false) authType = "none"; return { id, name, installName: asString(item.installName) ?? name, summary: asString(item.summary) ?? name, description: asString(item.description), tags, entry, requiredEnv, source, authType, installable: true, }; } /** * Resolve the final request headers for a registry. * * - Static `headers` from config come first. * - When `apiKeyEnv` is set and the env var holds a value, any header whose * value is exactly `{{API_KEY}}` is replaced with the env value; if no * header was replaced and there is no Authorization header, a * `Authorization: Bearer ` header is added. */ export function resolveRegistryHeaders( registry: RegistrySpec, env: NodeJS.ProcessEnv = process.env, ): Record | null { const headers: Record = { ...(registry.headers ?? {}) }; if (registry.apiKeyEnv) { const value = env[registry.apiKeyEnv]; if (!value) return null; // env key unset → caller must fail without requesting let replaced = false; for (const [k, v] of Object.entries(headers)) { if (v.includes(API_KEY_PLACEHOLDER)) { headers[k] = v.replaceAll(API_KEY_PLACEHOLDER, value); replaced = true; } } if (!replaced) { const hasAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization"); if (!hasAuth) headers.Authorization = `Bearer ${value}`; } } return headers; } /** Fetch a remote registry with a 30s timeout; returns raw JSON or null. */ export async function fetchRegistryPayload( url: string, headers: Record = {}, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { headers: { accept: "application/json", ...headers }, signal: controller.signal, }); if (!response.ok) return null; return (await response.json()) as unknown; } catch { return null; } finally { clearTimeout(timer); } } /** Fetch one registry with cache fallback. */ export async function loadRemoteRegistry(registry: RegistrySpec): Promise<{ servers: MarketServer[]; skipped: number; failed: boolean; }> { // apiKeyEnv set but env var missing → fail fast, no request, no cache read. if (registry.apiKeyEnv && !process.env[registry.apiKeyEnv]) { return { servers: [], skipped: 0, failed: true }; } const headers = resolveRegistryHeaders(registry); const fetched = await fetchRegistryPayload(registry.url, headers ?? {}); if (fetched !== null) { writeRegistryCache(registry.url, fetched); const normalize = normalizerFor(registry.format); const { servers, skipped } = normalize(fetched, registry.id); return { servers, skipped, failed: false }; } // fetch failed → try cache const cached = readRegistryCache(registry.url); if (cached !== null) { const normalize = normalizerFor(registry.format); const { servers, skipped } = normalize(cached, registry.id); return { servers, skipped, failed: false }; } return { servers: [], skipped: 0, failed: true }; } /** Assemble the full catalog from all configured remote registries. */ export async function buildCatalog(): Promise { const { registries } = loadRegistries(); const servers: MarketServer[] = []; const failures: string[] = []; const skipped: Record = {}; await Promise.all( registries.map(async (registry) => { const result = await loadRemoteRegistry(registry); servers.push(...result.servers); if (result.failed) failures.push(registry.name); if (result.skipped > 0) skipped[registry.name] = result.skipped; }), ); return { servers, failures, skipped, sourcesConfigured: registries.length > 0 }; }