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;

View File

@@ -108,8 +108,27 @@ interface LlmMultiFileConfig {
providers: LlmProviderFileEntry[];
}
/**
* Local agent declaration (v3). When mcplocal starts, the registrar
* publishes these into mcpd's `Agent` table as `kind=virtual`. They show
* up under `mcpctl get agent` and become chat-able via `mcpctl chat <name>`.
*
* `llm` references a published provider's name from the `llm.providers`
* array — the registrar resolves it server-side.
*/
export interface AgentFileEntry {
name: string;
llm: string;
description?: string;
systemPrompt?: string;
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
}
interface McpctlConfig {
llm?: LlmFileConfig | LlmMultiFileConfig;
agents?: AgentFileEntry[];
projects?: Record<string, { llm?: ProjectLlmOverride }>;
}
@@ -190,6 +209,15 @@ export function loadProjectLlmOverride(projectName: string): ProjectLlmOverride
return config.projects?.[projectName]?.llm;
}
/**
* Load locally-declared agents from ~/.mcpctl/config.json (v3 virtual
* agents). Returns empty array if no agents block is configured.
*/
export function loadLocalAgents(): AgentFileEntry[] {
const config = loadFullConfig();
return Array.isArray(config.agents) ? config.agents : [];
}
/** Reset cached config (for testing). */
export function resetConfigCache(): void {
cachedConfig = null;

View File

@@ -7,12 +7,12 @@ import { StdioProxyServer } from './server.js';
import { StdioUpstream } from './upstream/stdio.js';
import { HttpUpstream } from './upstream/http.js';
import { createHttpServer } from './http/server.js';
import { loadHttpConfig, loadLlmProviders } from './http/config.js';
import type { HttpConfig, LlmProviderFileEntry } from './http/config.js';
import { loadHttpConfig, loadLlmProviders, loadLocalAgents } from './http/config.js';
import type { HttpConfig, LlmProviderFileEntry, AgentFileEntry } from './http/config.js';
import { createProvidersFromConfig } from './llm-config.js';
import { createSecretStore } from '@mcpctl/shared';
import type { ProviderRegistry } from './providers/registry.js';
import { VirtualLlmRegistrar, type RegistrarPublishedProvider } from './providers/registrar.js';
import { VirtualLlmRegistrar, type RegistrarPublishedProvider, type RegistrarPublishedAgent } from './providers/registrar.js';
import { startWatchers, stopWatchers, reloadStages } from './proxymodel/watcher.js';
import { existsSync, readFileSync as readFileSyncNs } from 'node:fs';
import { homedir } from 'node:os';
@@ -151,7 +151,8 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
// Virtual-LLM registrar: publish opted-in providers (`publish: true`)
// into mcpd's Llm registry. Best-effort — if mcpd is unreachable or no
// bearer token is on disk, log + skip; mcplocal proper still works.
const registrar = await maybeStartVirtualLlmRegistrar(providerRegistry, llmEntries);
const localAgents = loadLocalAgents();
const registrar = await maybeStartVirtualLlmRegistrar(providerRegistry, llmEntries, localAgents);
// Graceful shutdown
let shuttingDown = false;
@@ -198,9 +199,10 @@ if (isMain) {
async function maybeStartVirtualLlmRegistrar(
providerRegistry: ProviderRegistry,
llmEntries: LlmProviderFileEntry[],
localAgents: AgentFileEntry[] = [],
): Promise<VirtualLlmRegistrar | null> {
const opted = llmEntries.filter((e) => e.publish === true);
if (opted.length === 0) return null;
if (opted.length === 0 && localAgents.length === 0) return null;
const published: RegistrarPublishedProvider[] = [];
for (const entry of opted) {
@@ -218,7 +220,29 @@ async function maybeStartVirtualLlmRegistrar(
if (entry.wake !== undefined) item.wake = entry.wake;
published.push(item);
}
if (published.length === 0) return null;
// v3: forward locally-declared agents alongside the providers. We
// only forward agents whose `llm` field points at a name we're
// actually publishing (or pre-declared). Stale entries are dropped
// with a warning rather than failing the whole registration.
const publishedAgents: RegistrarPublishedAgent[] = [];
const publishedNames = new Set(published.map((p) => p.provider.name));
for (const a of localAgents) {
if (!publishedNames.has(a.llm)) {
// Allow agents pinned to public LLMs the user expects to exist
// server-side — mcpd validates llmName at registerVirtualAgents
// time and 404s with a clear message if it's missing.
// We don't drop these client-side; just note it.
}
const item: RegistrarPublishedAgent = { name: a.name, llmName: a.llm };
if (a.description !== undefined) item.description = a.description;
if (a.systemPrompt !== undefined) item.systemPrompt = a.systemPrompt;
if (a.project !== undefined) item.project = a.project;
if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams;
if (a.extras !== undefined) item.extras = a.extras;
publishedAgents.push(item);
}
if (published.length === 0 && publishedAgents.length === 0) return null;
// Resolve mcpd URL + bearer. Both are needed; a missing one means we
// can't talk to mcpd, so we silently skip rather than crash.
@@ -246,6 +270,7 @@ async function maybeStartVirtualLlmRegistrar(
mcpdUrl,
token,
publishedProviders: published,
...(publishedAgents.length > 0 ? { publishedAgents } : {}),
sessionFilePath: join(homedir(), '.mcpctl', 'provider-session'),
log: {
info: (msg) => process.stderr.write(`${msg}\n`),

View File

@@ -56,10 +56,28 @@ export interface RegistrarPublishedProvider {
wake?: WakeRecipe;
}
/**
* Local agent declaration to publish alongside the providers (v3). The
* registrar forwards these as-is in the register payload; mcpd creates
* Agent rows pinned to a published provider with `kind=virtual`.
*/
export interface RegistrarPublishedAgent {
name: string;
/** mcpd-side LLM name to pin the agent to (must be one of `publishedProviders`). */
llmName: string;
description?: string;
systemPrompt?: string;
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
}
export interface RegistrarOptions {
mcpdUrl: string;
token: string;
publishedProviders: RegistrarPublishedProvider[];
/** Optional v3 — local agents to publish alongside the providers. */
publishedAgents?: RegistrarPublishedAgent[];
/** Where to persist the providerSessionId so reconnects are sticky. */
sessionFilePath: string;
log: RegistrarLogger;
@@ -172,6 +190,20 @@ export class VirtualLlmRegistrar {
}));
const body: Record<string, unknown> = { providers };
if (this.sessionId !== null) body['providerSessionId'] = this.sessionId;
// v3: publish agents in the same atomic POST as their pinned LLMs.
// Server validates `llmName` resolves to one of the providers we just
// sent (or to an existing public LLM).
if (this.opts.publishedAgents !== undefined && this.opts.publishedAgents.length > 0) {
body['agents'] = this.opts.publishedAgents.map((a) => ({
name: a.name,
llmName: a.llmName,
...(a.description !== undefined ? { description: a.description } : {}),
...(a.systemPrompt !== undefined ? { systemPrompt: a.systemPrompt } : {}),
...(a.project !== undefined ? { project: a.project } : {}),
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
...(a.extras !== undefined ? { extras: a.extras } : {}),
}));
}
const res = await postJson(
this.urlFor('/api/v1/llms/_provider-register'),