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