feat(mcpd+mcplocal): register-agents endpoint + mcplocal agents block (v3 Stage 3)

Extends the existing `_provider-register` payload with an optional `agents`
array so a single round-trip atomically publishes both virtual Llms and
their pinned virtual Agents. v1/v2 publishers (providers-only) keep
working unchanged — the agents path is gated on the route receiving an
AgentService instance, otherwise it logs a warning and ignores the array.

mcplocal config gains a top-level `agents` block (loadLocalAgents)
mirroring the providers shape. The registrar reads it, builds
RegistrarPublishedAgent entries against the published provider names,
and folds them into the same register POST. mcpd routes the agents
through AgentService.registerVirtualAgents(sessionId, ..., ownerId),
which was added in Stage 2.

No CLI changes here — `mcpctl chat <virtual-agent>` already works once
chat.service has the kind=virtual branch (Stage 1) and the agents are
present in the Agent table. CLI columns + smoke land in Stage 4.
This commit is contained in:
Michal
2026-04-27 18:38:37 +01:00
parent c7b1bd8e2c
commit 58bc277242
5 changed files with 144 additions and 9 deletions

View File

@@ -630,7 +630,7 @@ async function main(): Promise<void> {
});
},
});
registerVirtualLlmRoutes(app, virtualLlmService);
registerVirtualLlmRoutes(app, virtualLlmService, agentService);
registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService);

View File

@@ -17,6 +17,7 @@
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { VirtualLlmService, VirtualSessionHandle, VirtualTaskFrame } from '../services/virtual-llm.service.js';
import type { AgentService, VirtualAgentInput } from '../services/agent.service.js';
const SSE_PING_MS = 20_000;
const PROVIDER_SESSION_HEADER = 'x-mcpctl-provider-session';
@@ -24,8 +25,15 @@ const PROVIDER_SESSION_HEADER = 'x-mcpctl-provider-session';
export function registerVirtualLlmRoutes(
app: FastifyInstance,
service: VirtualLlmService,
/**
* Optional. v3 wires AgentService here so the register endpoint can
* also accept an `agents` array alongside `providers` and atomic-publish
* both. Absent (older test wirings): the route still works for Llm-only
* publishers, agents in the payload are ignored with a warning.
*/
agentService?: AgentService,
): void {
app.post<{ Body: { providerSessionId?: string; providers?: unknown[] } }>(
app.post<{ Body: { providerSessionId?: string; providers?: unknown[]; agents?: unknown[] } }>(
'/api/v1/llms/_provider-register',
async (request, reply) => {
const body = (request.body ?? {});
@@ -34,14 +42,29 @@ export function registerVirtualLlmRoutes(
reply.code(400);
return { error: '`providers` array is required and must be non-empty' };
}
const agentsInput = Array.isArray(body.agents) ? body.agents : null;
try {
const result = await service.register({
providerSessionId: body.providerSessionId ?? null,
providers: providers.map(coerceProviderInput),
});
// v3: atomically publish virtual agents tied to the same session.
// If the caller didn't include an agents array, skip silently.
let agents: unknown[] = [];
if (agentsInput !== null && agentsInput.length > 0) {
if (agentService === undefined) {
app.log.warn('virtual-llm register received `agents` but AgentService is not wired');
} else {
agents = await agentService.registerVirtualAgents(
result.providerSessionId,
agentsInput.map(coerceAgentInput),
request.userId ?? 'system',
);
}
}
reply.code(201);
return result;
return { ...result, agents };
} catch (err) {
const status = (err as { statusCode?: number }).statusCode ?? 500;
reply.code(status);
@@ -142,6 +165,33 @@ export function registerVirtualLlmRoutes(
);
}
/** Narrow an unknown agents array element into the service's input shape (v3). */
function coerceAgentInput(raw: unknown): VirtualAgentInput {
if (raw === null || typeof raw !== 'object') {
throw Object.assign(new Error('agent entry must be an object'), { statusCode: 400 });
}
const o = raw as Record<string, unknown>;
const name = o['name'];
const llmName = o['llmName'];
if (typeof name !== 'string' || typeof llmName !== 'string') {
throw Object.assign(
new Error('agent entry requires string `name` and `llmName`'),
{ statusCode: 400 },
);
}
const out: VirtualAgentInput = { name, llmName };
if (typeof o['description'] === 'string') out.description = o['description'];
if (typeof o['systemPrompt'] === 'string') out.systemPrompt = o['systemPrompt'];
if (typeof o['project'] === 'string') out.project = o['project'];
if (o['defaultParams'] !== null && typeof o['defaultParams'] === 'object') {
out.defaultParams = o['defaultParams'] as Record<string, unknown>;
}
if (o['extras'] !== null && typeof o['extras'] === 'object') {
out.extras = o['extras'] as Record<string, unknown>;
}
return out;
}
/** Narrow an unknown providers array element into the service's input shape. */
function coerceProviderInput(raw: unknown): {
name: string;