feat(mcpd): Llm resource — CRUD + CLI + apply

Why: every client that wants an LLM (the agent, HTTP-mode mcplocal, Claude
Code's STDIO mcplocal) today has to know the provider URL + key, and each
user's ~/.mcpctl/config.json carries them. Centralising the catalogue on the
server is the prerequisite for Phase 2 (mcpd proxies inference so credentials
never leave the cluster).

This phase adds the `Llm` resource and its CRUD surface — no proxy yet, no
client pivot yet. Just enough to register what you have.

Schema:
- New `Llm` model: name/type/model/url/tier/description + {apiKeySecretId,
  apiKeySecretKey} FK pair. Reverse `llms` relation on Secret.
- Provider types: anthropic | openai | deepseek | vllm | ollama | gemini-cli.
- Tiers: fast | heavy.

mcpd:
- LlmRepository + LlmService + Zod validation schema + /api/v1/llms routes.
- API surface exposes `apiKeyRef: {name, key}` — the service translates to/
  from the FK pair so clients never deal in cuids.
- `resolveApiKey(llmName)` reads through SecretService (which itself dispatches
  to the right SecretBackend). That's the hook Phase 2's inference proxy uses.
- RBAC: added `'llms'` to RBAC_RESOURCES + resource alias. Standard
  view/create/edit/delete semantics.
- Wired into main.ts (repo, service, routes).

CLI:
- `mcpctl create llm <name> --type X --model Y --tier fast|heavy --api-key-ref SECRET/KEY [--url ...] [--extra k=v ...]`
- `mcpctl get|describe|delete llm` — standard resource verbs.
- `mcpctl apply -f` with `kind: llm` (single- or multi-doc yaml/json).
  Applied after secrets, before servers — apiKeyRef resolves an existing Secret.
- Shell completions regenerated.

Tests: 11 service unit tests + 9 route tests (happy path, 404s, 409, validation).
Full suite 1812/1812 (+20 from the 1792 Phase 0 baseline). TypeScript clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-18 21:28:43 +01:00
parent 029c3d5f34
commit 6ff90a8228
17 changed files with 1009 additions and 12 deletions

View File

@@ -49,6 +49,20 @@ const SecretBackendSpecSchema = z.object({
config: z.record(z.unknown()).default({}),
});
const LlmSpecSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
type: z.enum(['anthropic', 'openai', 'deepseek', 'vllm', 'ollama', 'gemini-cli']),
model: z.string().min(1),
url: z.string().url().optional(),
tier: z.enum(['fast', 'heavy']).default('fast'),
description: z.string().max(500).default(''),
apiKeyRef: z.object({
name: z.string().min(1),
key: z.string().min(1),
}).nullable().optional(),
extraConfig: z.record(z.unknown()).default({}),
});
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
@@ -152,6 +166,7 @@ const McpTokenSpecSchema = z.object({
const ApplyConfigSchema = z.object({
secretbackends: z.array(SecretBackendSpecSchema).default([]),
secrets: z.array(SecretSpecSchema).default([]),
llms: z.array(LlmSpecSchema).default([]),
servers: z.array(ServerSpecSchema).default([]),
users: z.array(UserSpecSchema).default([]),
groups: z.array(GroupSpecSchema).default([]),
@@ -194,6 +209,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
log('Dry run - would apply:');
if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`);
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.llms.length > 0) log(` ${config.llms.length} llm(s)`);
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
@@ -240,6 +256,7 @@ const KIND_TO_RESOURCE: Record<string, string> = {
serverattachment: 'serverattachments',
mcptoken: 'mcptokens',
secretbackend: 'secretbackends',
llm: 'llms',
};
/**
@@ -376,6 +393,25 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply LLMs (after secrets — apiKeyRef resolves to an existing Secret)
for (const llm of config.llms) {
try {
const existing = await cachedFindByName('llms', llm.name);
if (existing) {
// Exclude type on update — type is immutable.
const { name: _n, type: _t, ...updateBody } = llm;
await withRetry(() => client.put(`/api/v1/llms/${existing.id}`, updateBody));
log(`Updated llm: ${llm.name}`);
} else {
await withRetry(() => client.post('/api/v1/llms', llm));
invalidateCache('llms');
log(`Created llm: ${llm.name}`);
}
} catch (err) {
log(`Error applying llm '${llm.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply servers
for (const server of config.servers) {
try {

View File

@@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)');
.description('Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)');
// --- create server ---
cmd.command('server')
@@ -252,6 +252,61 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
}
});
// --- create llm ---
cmd.command('llm')
.description('Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)')
.argument('<name>', 'LLM name (lowercase alphanumeric with hyphens)')
.requiredOption('--type <type>', 'Provider type (anthropic, openai, deepseek, vllm, ollama, gemini-cli)')
.requiredOption('--model <model>', 'Model identifier (e.g. claude-3-5-sonnet-20241022)')
.option('--url <url>', 'Endpoint URL (empty = provider default)')
.option('--tier <tier>', 'Tier: fast or heavy', 'fast')
.option('--description <text>', 'Description')
.option('--api-key-ref <ref>', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)')
.option('--extra <entry>', 'Extra config key=value (repeat)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
type: opts.type,
model: opts.model,
tier: opts.tier,
};
if (opts.url) body.url = opts.url;
if (opts.description !== undefined) body.description = opts.description;
if (opts.apiKeyRef) {
const slashIdx = (opts.apiKeyRef as string).indexOf('/');
if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`);
body.apiKeyRef = {
name: (opts.apiKeyRef as string).slice(0, slashIdx),
key: (opts.apiKeyRef as string).slice(slashIdx + 1),
};
}
if (opts.extra && (opts.extra as string[]).length > 0) {
const extra: Record<string, unknown> = {};
for (const entry of opts.extra as string[]) {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) throw new Error(`Invalid --extra '${entry}'. Expected key=value`);
extra[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
}
body.extraConfig = extra;
}
try {
const row = await client.post<{ id: string; name: string }>('/api/v1/llms', body);
log(`llm '${row.name}' created (id: ${row.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/llms')).find((l) => l.name === name);
if (!existing) throw err;
const { name: _n, type: _t, ...updateBody } = body;
await client.put(`/api/v1/llms/${existing.id}`, updateBody);
log(`llm '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create secretbackend ---
cmd.command('secretbackend')
.alias('sb')

View File

@@ -218,6 +218,49 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
return lines.join('\n');
}
function formatLlmDetail(llm: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== LLM: ${llm.name} ===`);
lines.push(`${pad('Name:')}${llm.name}`);
lines.push(`${pad('Type:')}${llm.type}`);
lines.push(`${pad('Model:')}${llm.model}`);
lines.push(`${pad('Tier:')}${llm.tier ?? 'fast'}`);
if (llm.url) lines.push(`${pad('URL:')}${llm.url}`);
if (llm.description) lines.push(`${pad('Description:')}${llm.description}`);
const ref = llm.apiKeyRef as { name: string; key: string } | null | undefined;
lines.push('');
lines.push('API Key:');
if (ref) {
lines.push(` ${pad('Secret:', 12)}${ref.name}`);
lines.push(` ${pad('Key:', 12)}${ref.key}`);
} else {
lines.push(' (none)');
}
const extra = llm.extraConfig as Record<string, unknown> | undefined;
if (extra && Object.keys(extra).length > 0) {
lines.push('');
lines.push('Extra Config:');
const keyW = Math.max(6, ...Object.keys(extra).map((k) => k.length)) + 2;
for (const [k, v] of Object.entries(extra)) {
let display: string;
if (v === null || v === undefined) display = '-';
else if (typeof v === 'object') display = JSON.stringify(v);
else display = String(v);
lines.push(` ${k.padEnd(keyW)}${display}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${llm.id}`);
if (llm.createdAt) lines.push(` ${pad('Created:', 12)}${llm.createdAt}`);
if (llm.updatedAt) lines.push(` ${pad('Updated:', 12)}${llm.updatedAt}`);
return lines.join('\n');
}
function formatSecretBackendDetail(backend: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== SecretBackend: ${backend.name} ===`);
@@ -840,6 +883,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'secretbackends':
deps.log(formatSecretBackendDetail(item));
break;
case 'llms':
deps.log(formatLlmDetail(item));
break;
case 'projects': {
const projectPrompts = await deps.client
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)

View File

@@ -119,6 +119,26 @@ const rbacColumns: Column<RbacRow>[] = [
{ header: 'ID', key: 'id' },
];
interface LlmRow {
id: string;
name: string;
type: string;
model: string;
tier: string;
url: string;
description: string;
apiKeyRef: { name: string; key: string } | null;
}
const llmColumns: Column<LlmRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'TYPE', key: 'type', width: 12 },
{ header: 'MODEL', key: 'model', width: 28 },
{ header: 'TIER', key: 'tier', width: 8 },
{ header: 'KEY', key: (r) => r.apiKeyRef ? `secret://${r.apiKeyRef.name}/${r.apiKeyRef.key}` : '-', width: 34 },
{ header: 'ID', key: 'id' },
];
interface SecretBackendRow {
id: string;
name: string;
@@ -284,6 +304,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
case 'secretbackends':
return secretBackendColumns as unknown as Column<Record<string, unknown>>[];
case 'llms':
return llmColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -307,6 +329,7 @@ const RESOURCE_KIND: Record<string, string> = {
serverattachments: 'serverattachment',
mcptokens: 'mcptoken',
secretbackends: 'secretbackend',
llms: 'llm',
};
/**

View File

@@ -34,6 +34,8 @@ export const RESOURCE_ALIASES: Record<string, string> = {
secretbackend: 'secretbackends',
secretbackends: 'secretbackends',
sb: 'secretbackends',
llm: 'llms',
llms: 'llms',
all: 'all',
};