feat(agiliton-account): per-customer LiteLLM MCP server provisioning

At first login, provisionMcpServer() creates a sitebridge-{customer_id}
entry in LiteLLM DB via POST /v1/mcp/server, pointing to the customer's
demux URL. The virtual key is then scoped to ["sitebridge-{customer_id}"]
so LiteLLM routes tool calls only to that customer's WebSocket.

Also adds AGILITON_ACCOUNT_URL config for self-referencing in MCP URLs.

CF-3032

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-10 16:18:02 +03:00
parent 21780d3e45
commit 52c2e9eca5
3 changed files with 61 additions and 1 deletions

View File

@@ -19,6 +19,10 @@ JWT_EXPIRES_IN=7d
# AES-256-GCM encryption for LiteLLM virtual keys (64 hex chars = 32 bytes)
ENCRYPTION_KEY=changeme-generate-with-openssl-rand-hex-32
# Self-referencing URL used when registering per-customer MCP servers in LiteLLM
# LiteLLM calls this URL to forward tool calls to the right customer's WebSocket
AGILITON_ACCOUNT_URL=http://agiliton-account:4100
# LiteLLM
LITELLM_URL=http://litellm:4000
LITELLM_MASTER_KEY=sk-litellm-master-key

View File

@@ -5,14 +5,67 @@ interface LiteLLMKeyResponse {
key_alias: string;
}
interface LiteLLMMCPServerResponse {
server_id: string;
server_name: string;
alias: string;
url: string;
}
/**
* Register a per-customer sitebridge MCP server entry in LiteLLM DB.
* Returns the server_id (alias) used when provisioning the virtual key.
*
* LiteLLM does not support URL templates per virtual key (verified in 1.83.3),
* so we create one MCP server entry per customer with the customer-specific
* demux URL. The virtual key is then scoped to only that server.
*/
export async function provisionMcpServer(customerId: string): Promise<string> {
const serverAlias = `sitebridge-${customerId}`;
const demuxUrl = `${config.agilitonAccountUrl}/mcp/demux/${customerId}/mcp`;
const body = {
server_name: serverAlias,
alias: serverAlias,
description: `SiteBridge MCP bridge for customer ${customerId}`,
url: demuxUrl,
transport: "http",
auth_type: "bearer_token",
auth_value: config.mcpBridgeSecret,
allow_all_keys: false,
};
const res = await fetch(`${config.litellmUrl}/v1/mcp/server`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.litellmMasterKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`LiteLLM /v1/mcp/server failed ${res.status}: ${text}`);
}
return serverAlias;
}
/**
* Provision a new virtual key for a customer. Called once at first login.
* Also creates a per-customer MCP server entry so LiteLLM routes sitebridge
* calls to the correct agiliton-account demux endpoint.
*/
export async function provisionLiteLLMKey(customerId: string, email: string): Promise<string> {
// 1. Register per-customer MCP server
const mcpServerAlias = await provisionMcpServer(customerId);
// 2. Create virtual key scoped to that MCP server
const body = {
key_alias: `sb-${customerId}`,
models: config.defaultModels,
mcp_servers: ["sitebridge"],
mcp_servers: [mcpServerAlias],
max_budget: config.defaultBudgetUsd,
budget_duration: config.defaultBudgetDuration,
rpm_limit: config.defaultRpmLimit,

View File

@@ -31,6 +31,9 @@ export const config = {
litellmUrl: envOptional("LITELLM_URL", "http://litellm:4000"),
litellmMasterKey: env("LITELLM_MASTER_KEY"),
// Self-referencing URL for MCP demux (used in LiteLLM MCP server registration)
agilitonAccountUrl: envOptional("AGILITON_ACCOUNT_URL", "http://agiliton-account:4100"),
// Shared secret used by LiteLLM when calling /mcp/demux/:id/mcp
mcpBridgeSecret: env("MCP_BRIDGE_SECRET"),