Files
mcpctl/src/mcplocal/tests/llm-reference-resolver.test.ts
Michal de854b1944 feat(project): Project.llmProvider semantically names an Llm resource
Why: Phases 0-3 built the server-managed Llm registry; this phase pivots the
existing Project.llmProvider column from "local provider hint" to "named Llm
reference" so operators can pick a centralised Llm per project. No schema
change — the column stays a free-form string for backward compat.

- `mcpctl create project --llm <name>` (+ `--llm-model <override>`) sets
  llmProvider/llmModel to a centralised Llm reference, or 'none' to disable.
- `mcpctl describe project` fetches the Llm catalogue alongside prompts and
  flags values that don't resolve with a visible warning. 'none' is treated
  as an explicit disable, not an orphan.
- `apply -f` doc comments updated; --llm-provider still accepted but now
  documented as naming an Llm resource.
- New `resolveProjectLlmReference(mcpdClient, name)` helper in mcplocal's
  discovery: returns `registered`/`disabled`/`unregistered`/`unreachable`.
  The HTTP-mode proxy-model pipeline will consume this when it pivots to
  mcpd's /api/v1/llms/:name/infer proxy.
- project-mcp-endpoint.ts cache-namespace path gets a comment explaining
  the new resolution order — behavior unchanged, just clarified.

Tests: 6 resolver unit tests + 3 new describe-warning cases. Full suite
1853/1853 (+9 from Phase 3's 1844). TypeScript clean; completions
regenerated for the new create-project flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:28:46 +01:00

46 lines
2.1 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { resolveProjectLlmReference } from '../src/discovery.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
function mockClient(get: (path: string) => Promise<unknown>): McpdClient {
return { get } as unknown as McpdClient;
}
describe('resolveProjectLlmReference', () => {
it('returns "disabled" for the literal string "none"', async () => {
const client = mockClient(async () => { throw new Error('should not be called'); });
expect(await resolveProjectLlmReference(client, 'none')).toBe('disabled');
});
it('returns "unregistered" when llmProvider is empty or undefined', async () => {
const client = mockClient(async () => { throw new Error('should not be called'); });
expect(await resolveProjectLlmReference(client, undefined)).toBe('unregistered');
expect(await resolveProjectLlmReference(client, '')).toBe('unregistered');
});
it('returns "registered" when mcpd returns 200 for the name', async () => {
const get = vi.fn(async () => ({ name: 'claude' }));
expect(await resolveProjectLlmReference(mockClient(get), 'claude')).toBe('registered');
expect(get).toHaveBeenCalledWith('/api/v1/llms/claude');
});
it('returns "unregistered" on 404', async () => {
const client = mockClient(async () => { throw new Error('HTTP 404 not found'); });
expect(await resolveProjectLlmReference(client, 'missing')).toBe('unregistered');
});
it('returns "unreachable" on other errors (500, network)', async () => {
const client = mockClient(async () => { throw new Error('HTTP 500 internal error'); });
expect(await resolveProjectLlmReference(client, 'x')).toBe('unreachable');
const client2 = mockClient(async () => { throw new Error('ECONNREFUSED'); });
expect(await resolveProjectLlmReference(client2, 'x')).toBe('unreachable');
});
it('URL-encodes names with special characters', async () => {
const get = vi.fn(async () => ({}));
await resolveProjectLlmReference(mockClient(get), 'weird name/with/slashes');
expect(get).toHaveBeenCalledWith('/api/v1/llms/weird%20name%2Fwith%2Fslashes');
});
});