feat(mcplocal): RBAC-bounded vllm-managed failover + name-based llm lookup

Why: when mcpd's inference proxy is unreachable, clients with a local
vllm-managed provider should be able to substitute — but only if they still
have view permission on the centralized Llm. Otherwise revoking an Llm
wouldn't actually stop a misbehaving client.

Infrastructure (the agent + mcplocal HTTP-mode wire-up will land separately
when those clients pivot to mcpd's proxy):

- LlmProviderFileEntry gains optional `failoverFor: <central llm name>`. The
  entry is otherwise the same local provider it always was; the new field
  just declares which central Llm it can substitute for.
- ProviderRegistry tracks a failover map (registerFailover / getFailoverFor /
  listFailovers). Unregister removes any failover entry pointing at the
  removed provider so we don't end up with dangling references.
- New FailoverRouter wraps a primary inference call. On primary failure: if
  a local provider is registered for the Llm, HEAD-probe `mcpd /api/v1/llms/
  :name` with the caller's bearer to verify view permission, then either
  invoke the local provider (allowed) or re-throw the primary error (403,
  401, network unreachable, anything else — all fail-closed).
- Server: GET /api/v1/llms/:idOrName accepts both CUID and human name. Lets
  FailoverRouter probe by name without a separate id-resolution call. HEAD
  derives automatically from GET in Fastify, which runs the same RBAC hook
  and drops the body — exactly what the probe needs.

Tests: 11 failover unit tests (registry map, decision flow, fail-closed for
forbidden + unreachable, checkAuth status mapping) + 4 new route tests
(name lookup, HEAD existing/missing). Full suite 1844/1844 (+14 from Phase
2's 1830). TypeScript clean across mcpd + mcplocal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-19 13:05:43 +01:00
parent 23f53a0798
commit 4d8ee23d0e
7 changed files with 355 additions and 1 deletions

View File

@@ -10,9 +10,12 @@ export function registerLlmRoutes(
return service.list();
});
// Accepts either CUID or human name. Used both by the CLI (which usually
// resolves to CUID first) and by FailoverRouter's RBAC pre-check (which
// hands over the user-facing name to avoid an extra round-trip).
app.get<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => {
try {
return await service.getById(request.params.id);
return await getByIdOrName(service, request.params.id);
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
@@ -22,6 +25,10 @@ export function registerLlmRoutes(
}
});
// No explicit HEAD handler: Fastify auto-derives HEAD from GET, which runs
// the same RBAC hook + lookup and drops the body. That's exactly what
// FailoverRouter wants for its "can the caller still view this Llm?" probe.
app.post('/api/v1/llms', async (request, reply) => {
try {
const row = await service.create(request.body);
@@ -62,3 +69,17 @@ export function registerLlmRoutes(
}
});
}
const CUID_RE = /^c[a-z0-9]{24}/i;
/**
* Look up by CUID first; if the input doesn't look like one, fall back to
* findByName. Lets the same URL serve both `mcpctl describe llm <name>` and
* the FailoverRouter's name-based RBAC check.
*/
async function getByIdOrName(service: LlmService, idOrName: string) {
if (CUID_RE.test(idOrName)) {
return service.getById(idOrName);
}
return service.getByName(idOrName);
}

View File

@@ -104,6 +104,25 @@ describe('Llm Routes', () => {
expect(res.statusCode).toBe(404);
});
it('GET /api/v1/llms/:nameOrId resolves by human name when not a CUID', async () => {
await createApp(mockRepo([makeLlm({ id: 'llm-1', name: 'claude' })]));
const res = await app.inject({ method: 'GET', url: '/api/v1/llms/claude' });
expect(res.statusCode).toBe(200);
expect(res.json<{ name: string; id: string }>().name).toBe('claude');
});
it('HEAD /api/v1/llms/:name returns 200 for an existing Llm (failover RBAC pre-check)', async () => {
await createApp(mockRepo([makeLlm({ name: 'claude' })]));
const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/claude' });
expect(res.statusCode).toBe(200);
});
it('HEAD /api/v1/llms/:name returns 404 for a missing Llm', async () => {
await createApp(mockRepo());
const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/missing' });
expect(res.statusCode).toBe(404);
});
it('POST /api/v1/llms creates and returns 201', async () => {
await createApp(mockRepo());
const res = await app.inject({