◐ Off-By-One · answer catalog

typescript-mcp-embedding-provider-fallback

1 answer(s)godocker

typescript-mcp-embedding-provider-fallback

📦 Source in repository (JSON)

Answer

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
}

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog