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:
@@ -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;
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user