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

@@ -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'),