fix(litellm): idempotent MCP server provisioning + self-heal on login (SB-56)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 16s

provisionMcpServer now GETs /v1/mcp/server first and skips the POST if the
sitebridge_<id> entry already exists. upsertCustomer calls it on every
existing-customer login (fire-and-forget) so pre-existing customers whose
row was created before this code path shipped — or whose LiteLLM entry was
deleted/rebuilt — get their /mcp/ routing restored on next login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-15 15:42:32 +03:00
parent 17b92f8514
commit 74de6d37ff
2 changed files with 27 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
import { query, queryOne } from "../db/pool.js"; import { query, queryOne } from "../db/pool.js";
import { encrypt, decrypt } from "../utils/crypto.js"; import { encrypt, decrypt } from "../utils/crypto.js";
import { provisionLiteLLMKey } from "./litellm.js"; import { provisionLiteLLMKey, provisionMcpServer } from "./litellm.js";
export interface Customer { export interface Customer {
id: string; id: string;
@@ -67,6 +67,14 @@ export async function upsertCustomer(params: {
); );
existing.last_login_at = new Date(); existing.last_login_at = new Date();
existing.name = params.name; existing.name = params.name;
// Self-heal: ensure LiteLLM MCP server entry exists for this customer.
// Idempotent in provisionMcpServer; swallow errors so a LiteLLM hiccup
// doesn't block login.
provisionMcpServer(existing.id).catch((err) => {
console.error(`[customers] provisionMcpServer self-heal failed for ${existing.id}:`, err);
});
return { customer: existing, virtualKey: getVirtualKey(existing), isNew: false }; return { customer: existing, virtualKey: getVirtualKey(existing), isNew: false };
} }

View File

@@ -25,8 +25,26 @@ function toServerAlias(customerId: string): string {
return `sitebridge_${customerId.replace(/-/g, "_")}`; return `sitebridge_${customerId.replace(/-/g, "_")}`;
} }
async function getMcpServer(serverAlias: string): Promise<LiteLLMMCPServerResponse | null> {
const res = await fetch(`${config.litellmUrl}/v1/mcp/server`, {
headers: { Authorization: `Bearer ${config.litellmMasterKey}` },
});
if (!res.ok) throw new Error(`LiteLLM /v1/mcp/server GET failed ${res.status}`);
const data = (await res.json()) as unknown;
const list = (Array.isArray(data)
? data
: ((data as { data?: unknown; servers?: unknown }).data ??
(data as { servers?: unknown }).servers ??
[])) as LiteLLMMCPServerResponse[];
return list.find((s) => s.server_name === serverAlias || s.alias === serverAlias) ?? null;
}
export async function provisionMcpServer(customerId: string): Promise<string> { export async function provisionMcpServer(customerId: string): Promise<string> {
const serverAlias = toServerAlias(customerId); const serverAlias = toServerAlias(customerId);
const existing = await getMcpServer(serverAlias);
if (existing) return serverAlias;
const demuxUrl = `${config.agilitonAccountUrl}/mcp/demux/${customerId}/mcp`; const demuxUrl = `${config.agilitonAccountUrl}/mcp/demux/${customerId}/mcp`;
const body = { const body = {