agent-ecosystem/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts
iliya 51f8f3545c style: auto-fix import/export sorting and formatting
Ran pnpm lint:fix to resolve 220 auto-fixable lint issues.
All changes are import/export reordering — no logic changes.
2026-03-16 20:48:42 +02:00

190 lines
6.4 KiB
TypeScript

/**
* Fetches MCP server data from the Glama.ai API.
*
* Optional enrichment layer — NOT a hard dependency.
* Provides: license, tools, Glama URL.
* Does NOT provide install info (no packages/remotes).
*
* Base URL: https://glama.ai/api/mcp/v1/servers
* Cursor-based pagination (after), no auth required.
*/
import http from 'node:http';
import https from 'node:https';
import { createLogger } from '@shared/utils/logger';
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
const logger = createLogger('Extensions:GlamaMcp');
// ── Constants ──────────────────────────────────────────────────────────────
const GLAMA_BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
const HTTP_TIMEOUT_MS = 15_000;
const MAX_REDIRECTS = 5;
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
// ── HTTP helper ────────────────────────────────────────────────────────────
function httpGet(
url: string,
redirectsLeft = MAX_REDIRECTS
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
const settleResolve = (v: { statusCode: number; body: string }): void => {
if (!settled) {
settled = true;
resolve(v);
}
};
const settleReject = (e: Error): void => {
if (!settled) {
settled = true;
reject(e);
}
};
const req = transport.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
settleReject(new Error('Too many redirects'));
return;
}
res.destroy();
httpGet(new URL(res.headers.location, url).toString(), redirectsLeft - 1).then(
settleResolve,
settleReject
);
return;
}
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (c: Buffer) => {
totalSize += c.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
return;
}
chunks.push(c);
});
res.on('end', () =>
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
);
res.on('error', settleReject);
});
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
});
}
// ── Raw Glama API shapes ───────────────────────────────────────────────────
interface GlamaResponse {
pageInfo: {
endCursor?: string;
hasNextPage?: boolean;
};
servers: GlamaServer[];
}
interface GlamaServer {
id: string;
name: string;
namespace?: string;
description?: string;
slug?: string;
url?: string;
repository?: { url: string };
spdxLicense?: { name: string; url?: string } | null;
tools?: { name?: string; description?: string }[];
attributes?: string[];
}
// ── Service ────────────────────────────────────────────────────────────────
export class GlamaMcpEnrichmentService {
/**
* Search Glama for MCP servers.
*/
async search(query: string, limit = 20): Promise<McpCatalogItem[]> {
const params = new URLSearchParams({ search: query, first: String(limit) });
const url = `${GLAMA_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as GlamaResponse;
return json.servers.map((s) => this.normalize(s));
} catch (err) {
logger.warn('Glama MCP search failed:', err);
return [];
}
}
/**
* Browse Glama catalog with cursor pagination.
*/
async browse(
cursor?: string,
limit = 20
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
const params = new URLSearchParams({ first: String(limit) });
if (cursor) params.set('after', cursor);
const url = `${GLAMA_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as GlamaResponse;
return {
servers: json.servers.map((s) => this.normalize(s)),
nextCursor: json.pageInfo.hasNextPage ? json.pageInfo.endCursor : undefined,
};
} catch (err) {
logger.warn('Glama MCP browse failed:', err);
return { servers: [] };
}
}
// ── Private ────────────────────────────────────────────────────────────
private normalize(raw: GlamaServer): McpCatalogItem {
const tools: McpToolDef[] = (raw.tools ?? [])
.filter((t): t is { name: string; description: string } => Boolean(t.name))
.map((t) => ({ name: t.name, description: t.description ?? '' }));
return {
id: `glama:${raw.id}`,
name: raw.name,
description: raw.description ?? '',
repositoryUrl: raw.repository?.url,
version: undefined, // Glama doesn't expose version
source: 'glama',
installSpec: null, // Glama has NO install info
envVars: [],
license: raw.spdxLicense?.name,
tools,
glamaUrl: raw.url,
requiresAuth: false,
author: raw.namespace,
hostingType: this.deriveHostingType(raw.attributes),
};
}
private deriveHostingType(attributes?: string[]): McpHostingType | undefined {
if (!attributes?.length) return undefined;
const hasLocal = attributes.includes('hosting:local-only');
const hasRemote = attributes.includes('hosting:remote-capable');
if (hasLocal && hasRemote) return 'both';
if (hasLocal) return 'local';
if (hasRemote) return 'remote';
return undefined;
}
}