Skip to content

Commit 5c84314

Browse files
committed
fix(mcp-registry): graceful embedding fallback
The nomic model can fail to cache in the Render container's filesystem (same pattern tasks.ts / agents.ts already handle). Wrap every embed() call in the registry service with tryEmbed() which logs + returns null on failure. Callers now fall back to keyword-only paths: - listServers(q=...): ILIKE match on name/description - recommendForTask: quality-ranked + ILIKE filter, similarity=0 marker - submitServer / updateServer: skip embedding column, server still saved - ingestion upsert: skip embedding, next ingestion cycle retries This unblocks the public /api/v1/mcp/servers/recommend endpoint which was returning 500 before the model first loaded.
1 parent cf26d30 commit 5c84314

1 file changed

Lines changed: 56 additions & 15 deletions

File tree

packages/api/src/services/mcp-registry.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ import {
2525
agents,
2626
} from '../db/schema.js';
2727
import { embed } from './embeddings.js';
28+
import { createLogger } from '../lib/logger.js';
29+
30+
const embedLog = createLogger({ service: 'mcp-registry' });
31+
32+
/**
33+
* embed() wrapper that swallows failures. The nomic model downloads on first
34+
* use; if the download is broken (no cache, no network, etc.) we fall back to
35+
* the keyword-only path rather than failing the whole request. Existing
36+
* routes (tasks.ts, agents.ts) use the same graceful-degradation pattern.
37+
*/
38+
async function tryEmbed(text: string, type: 'document' | 'query'): Promise<number[] | null> {
39+
try {
40+
return await embed(text, type);
41+
} catch (err) {
42+
embedLog.warn(`embed failed, falling back to lexical path: ${String(err)}`);
43+
return null;
44+
}
45+
}
2846
import {
2947
canonicalizeAttestationPayload,
3048
type McpServer,
@@ -90,10 +108,18 @@ export async function listServers(
90108
let orderExpr = sql`${mcpServers.qualityScore} DESC, ${mcpServers.verifiedUsageCount} DESC`;
91109

92110
if (query.q && query.q.trim().length > 0) {
93-
const queryEmbedding = await embed(query.q, 'query');
94-
const embedLiteral = `[${queryEmbedding.join(',')}]`;
95-
predicates.push(sql`${mcpServers.descriptionEmbedding} IS NOT NULL`);
96-
orderExpr = sql`${mcpServers.descriptionEmbedding} <=> ${embedLiteral}::vector`;
111+
const queryEmbedding = await tryEmbed(query.q, 'query');
112+
if (queryEmbedding) {
113+
const embedLiteral = `[${queryEmbedding.join(',')}]`;
114+
predicates.push(sql`${mcpServers.descriptionEmbedding} IS NOT NULL`);
115+
orderExpr = sql`${mcpServers.descriptionEmbedding} <=> ${embedLiteral}::vector`;
116+
} else {
117+
// Lexical fallback — case-insensitive contains over name + description
118+
const needle = `%${query.q.trim()}%`;
119+
predicates.push(
120+
sql`(${mcpServers.name} ILIKE ${needle} OR ${mcpServers.description} ILIKE ${needle})`,
121+
);
122+
}
97123
}
98124

99125
const rows = await db
@@ -161,7 +187,7 @@ export async function submitServer(
161187
throw Errors.conflict('An MCP server with that slug already exists');
162188
}
163189

164-
const descriptionEmbedding = await embed(`${input.name}\n\n${input.description}`, 'document');
190+
const descriptionEmbedding = await tryEmbed(`${input.name}\n\n${input.description}`, 'document');
165191

166192
return db.transaction(async (tx) => {
167193
const [server] = await tx.insert(mcpServers).values({
@@ -199,7 +225,7 @@ export async function submitServer(
199225

200226
if (input.tools.length > 0) {
201227
const toolEmbeddings = await Promise.all(
202-
input.tools.map((t) => embed(`${t.name}\n\n${t.description ?? ''}`, 'document')),
228+
input.tools.map((t) => tryEmbed(`${t.name}\n\n${t.description ?? ''}`, 'document')),
203229
);
204230
await tx.insert(mcpServerTools).values(
205231
input.tools.map((t, i) => ({
@@ -246,7 +272,8 @@ export async function updateServer(
246272
if (input.name !== undefined || input.description !== undefined) {
247273
const name = input.name ?? existing.name;
248274
const description = input.description ?? existing.description;
249-
patch.descriptionEmbedding = await embed(`${name}\n\n${description}`, 'document');
275+
const emb = await tryEmbed(`${name}\n\n${description}`, 'document');
276+
if (emb) patch.descriptionEmbedding = emb;
250277
}
251278

252279
await db.update(mcpServers).set(patch).where(eq(mcpServers.id, existing.id));
@@ -424,7 +451,7 @@ export async function upsertIngestedServer(
424451
},
425452
): Promise<{ serverId: string; created: boolean }> {
426453
const existing = await db.select().from(mcpServers).where(eq(mcpServers.slug, upstream.slug)).limit(1);
427-
const descriptionEmbedding = await embed(`${upstream.name}\n\n${upstream.description}`, 'document');
454+
const descriptionEmbedding = await tryEmbed(`${upstream.name}\n\n${upstream.description}`, 'document');
428455

429456
if (existing.length === 0) {
430457
const [server] = await db.insert(mcpServers).values({
@@ -459,7 +486,7 @@ export async function upsertIngestedServer(
459486

460487
if (upstream.tools?.length) {
461488
const toolEmbeddings = await Promise.all(
462-
upstream.tools.map((t) => embed(`${t.name}\n\n${t.description ?? ''}`, 'document')),
489+
upstream.tools.map((t) => tryEmbed(`${t.name}\n\n${t.description ?? ''}`, 'document')),
463490
);
464491
await db.insert(mcpServerTools).values(
465492
upstream.tools.map((t, i) => ({
@@ -540,20 +567,34 @@ export async function recommendForTask(params: {
540567
limit?: number;
541568
}): Promise<Array<McpServer & { similarity: number }>> {
542569
const limit = params.limit ?? 10;
543-
const queryEmbedding = await embed(params.description, 'query');
544-
const embedLiteral = `[${queryEmbedding.join(',')}]`;
570+
const queryEmbedding = await tryEmbed(params.description, 'query');
545571

546-
const predicates = [
547-
sql`${mcpServers.archivedAt} IS NULL`,
548-
sql`${mcpServers.descriptionEmbedding} IS NOT NULL`,
549-
];
572+
const predicates = [sql`${mcpServers.archivedAt} IS NULL`];
550573
if (params.transport) predicates.push(eq(mcpServers.transport, params.transport));
551574
if (params.maxPriceMicroUsdc !== undefined) {
552575
predicates.push(
553576
sql`(${mcpServers.paidTier} = false OR ${mcpServers.priceMicroUsdc} <= ${params.maxPriceMicroUsdc})`,
554577
);
555578
}
556579

580+
if (!queryEmbedding) {
581+
// Lexical fallback — quality-ranked results with ILIKE match on the
582+
// description. Semantic similarity is set to 0 so callers can detect
583+
// the degraded path.
584+
const needle = `%${params.description.slice(0, 200).trim()}%`;
585+
predicates.push(sql`(${mcpServers.name} ILIKE ${needle} OR ${mcpServers.description} ILIKE ${needle})`);
586+
const rows = await db
587+
.select()
588+
.from(mcpServers)
589+
.where(and(...predicates))
590+
.orderBy(desc(mcpServers.qualityScore), desc(mcpServers.verifiedUsageCount))
591+
.limit(limit);
592+
return rows.map((row) => ({ ...rowToServer(row), similarity: 0 }));
593+
}
594+
595+
const embedLiteral = `[${queryEmbedding.join(',')}]`;
596+
predicates.push(sql`${mcpServers.descriptionEmbedding} IS NOT NULL`);
597+
557598
const rows = await db
558599
.select({
559600
row: mcpServers,

0 commit comments

Comments
 (0)