typescript-mcp-embedding-provider-fallback
The root cause was a single healthy provider being selected by a port-reachability probe (LM Studio's port answered while its model was unloaded), with recall using only that provider and swallowing failures. The fix makes provider selection a fallback chain and makes failures visible to MCP clients.
1. createAutoProviders() — return ALL healthy providers in priority order (never just the first port that answers). The probe is injected so tests can use either the weak reachability probe or a strong real-embed probe:
export async function createAutoProviders(
cfg: EmbeddingConfig,
factories: readonly ProviderFactory[],
probe: (p: EmbeddingProvider) => Promise<void> = probeEmbed,
log: (msg: string) => void = () => {},
): Promise<EmbeddingProvider[]> {
const healthy: EmbeddingProvider[] = [];
for (const factory of factories) {
try {
const provider = factory.create(cfg);
await probe(provider); // reachability-only probe still passes for a 400-ing LM Studio
healthy.push(provider); // -> keep it, but ALSO keep Ollama below it
} catch (err) {
log(`[embed] ${factory.id} failed probe: ${(err as Error).message}`);
}
}
return healthy; // ALL healthy, priority order preserved
}
2. Recall iterates with embed-failure fallback — empty vector [] is a failure too ([] is truthy in JS, so !result never catches it; the guard requires an explicit length check):
export async function embedWithFallback(
providers: readonly EmbeddingProvider[],
texts: string[],
): Promise<number[][]> {
if (providers.length === 0) throw new EmbeddingUnavailableError("no embedding providers available");
let lastError: unknown;
for (const provider of providers) {
try {
const result: unknown = await provider.embed(texts);
if (!isValidEmbeddingBatch(result, texts.length)) { // [] => 0 !== texts.length => failure
throw new Error(`invalid embedding batch from ${provider.id}: expected ${texts.length} vectors`);
}
return result as number[][];
} catch (err) {
lastError = err; // 400 from unloaded LM Studio -> try Ollama next
}
}
throw new EmbeddingUnavailableError("all embedding providers failed", lastError);
}
where isValidEmbeddingBatch requires exactly one non-empty, all-finite vector per input.
3. wrapHandler sets isError:true on error-field/throw so MCP clients see real failures instead of silent empty memories:
export function wrapHandler<TArgs, TResult extends MCPToolResult>(
handler: (args: TArgs) => Promise<TResult>,
): (args: TArgs) => Promise<MCPToolResult> {
return async (args) => {
try {
const result = await handler(args);
if (result.error !== undefined && result.error !== null) {
return { content: [{ type: "text", text: `embedding error: ${result.error}` }], isError: true };
}
return { ...result, isError: false }; // legitimate empty results stay isError:false
} catch (err) {
return { content: [{ type: "text", text: `embedding error: ${err}` }], isError: true };
}
};
}
4. cosineSimilarity throws on zero-length vectors instead of returning NaN:
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || b.length === 0) throw new Error("cosineSimilarity: zero-length vector");
if (a.length !== b.length) throw new Error("cosineSimilarity: dimension mismatch");
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] ** 2; normB += b[i] ** 2; }
if (normA === 0 || normB === 0) throw new Error("cosineSimilarity: zero-magnitude vector");
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
5. resolveEmbeddingConfig default drift fixed — an unset provider resolves to AUTO (probe the whole chain), never to an implicit explicit "lmstudio":
export function resolveEmbeddingConfig(cfg: EmbeddingConfig = {}): ResolvedEmbeddingConfig {
if (cfg.provider != null && cfg.provider !== "") {
if (!isKnownProvider(cfg.provider)) throw new Error(`unknown embedding provider "${cfg.provider}"`);
return { mode: "explicit", providerId: cfg.provider };
}
return { mode: "auto" }; // old code did cfg.provider ?? "lmstudio" + explicit flag — the drift
}
Implemented in `/tmp/embed-fix/src/embedding.ts` with a 23-case test suite (`/tmp/embed-fix/test/embedding.test.ts`), run with `node --import tsx --test` (Node 22.22.3) and type-checked with `tsc --strict`:
```
TSC: CLEAN (strict)
# tests 23
# pass 23
# fail 0
```
**DOGFOOD-002 scenario reproduced before/after** (`scripts/bug-repro.ts` — LM Studio port answers but every embed 400s; Ollama works):
```
OLD (reachability-only, errors swallowed): {"isError":false,"memories":[]} <- the bug
NEW (all-healthy + fallback): {"structuredContent":{"memories":[{"id":"m2",...},{"id":"m1",...}]},"isError":false}
RESULT: bug reproduced on old path, fixed on new path
```
**Edge cases tested:**
- `resolveEmbeddingConfig`: `{}` / `{provider:""}` / `{provider:undefined}` → AUTO (drift regression); explicit ids stay explicit; unknown id throws.
- `createAutoProviders`: all healthy providers returned in priority order; weak reachability probe keeps a 400-ing LM Studio **and** Ollama (fallback covers it); strong embed probe drops the 400-ing provider; empty list when all fail.
- `embedWithFallback`: throwing provider skipped → next used; **`[]` returned by a provider treated as failure** (truthy trap) and `[[1]]` short batch also rejected; NaN vectors rejected; all-fail throws `EmbeddingUnavailableError` carrying the last HTTP-400 cause; empty provider list throws immediately.
- `wrapHandler`: throw → `isError:true` with message; returned `{error}` → `isError:true`; success → `isError:false` with payload preserved; a legitimate zero-match recall result stays `isError:false`.
- `cosineSimilarity`: identical/collinear/orthogonal vectors; throws on zero-length, zero-magnitude (all-zeros), and dimension mismatch.
- End-to-end recall tool: LM Studio 400 + Ollama fallback → results, `isError:false`; **all** providers fail → `isError:true` (no silent empty memories); explicit `lmstudio` config → error surfaces, no silent fallback to Ollama.{"model": "deepseek-v4-flash", "problem_class": "typescript-mcp-embedding-provider-fallback", "result": "passed", "tests": 23}