From 58bc27724222977470c6c0740745537de2adcc03 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 27 Apr 2026 18:38:37 +0100 Subject: [PATCH] feat(mcpd+mcplocal): register-agents endpoint + mcplocal agents block (v3 Stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- src/mcpd/src/main.ts | 2 +- src/mcpd/src/routes/virtual-llms.ts | 54 ++++++++++++++++++++++++- src/mcplocal/src/http/config.ts | 28 +++++++++++++ src/mcplocal/src/main.ts | 37 ++++++++++++++--- src/mcplocal/src/providers/registrar.ts | 32 +++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index cb65a87..e54de89 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -630,7 +630,7 @@ async function main(): Promise { }); }, }); - registerVirtualLlmRoutes(app, virtualLlmService); + registerVirtualLlmRoutes(app, virtualLlmService, agentService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); registerAuditLogRoutes(app, auditLogService); diff --git a/src/mcpd/src/routes/virtual-llms.ts b/src/mcpd/src/routes/virtual-llms.ts index 78a0e67..d92bc8f 100644 --- a/src/mcpd/src/routes/virtual-llms.ts +++ b/src/mcpd/src/routes/virtual-llms.ts @@ -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; + 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; + } + if (o['extras'] !== null && typeof o['extras'] === 'object') { + out.extras = o['extras'] as Record; + } + return out; +} + /** Narrow an unknown providers array element into the service's input shape. */ function coerceProviderInput(raw: unknown): { name: string; diff --git a/src/mcplocal/src/http/config.ts b/src/mcplocal/src/http/config.ts index 1d4a9c3..9e9cd5e 100644 --- a/src/mcplocal/src/http/config.ts +++ b/src/mcplocal/src/http/config.ts @@ -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 `. + * + * `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; + extras?: Record; +} + interface McpctlConfig { llm?: LlmFileConfig | LlmMultiFileConfig; + agents?: AgentFileEntry[]; projects?: Record; } @@ -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; diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 670ba04..9300a20 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -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 { // 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 { 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`), diff --git a/src/mcplocal/src/providers/registrar.ts b/src/mcplocal/src/providers/registrar.ts index e09392c..cd7e46d 100644 --- a/src/mcplocal/src/providers/registrar.ts +++ b/src/mcplocal/src/providers/registrar.ts @@ -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; + extras?: Record; +} + 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 = { 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'),