diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index caadcf2..601d0dc 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -228,8 +228,18 @@ export class AgentService { const out: AgentView[] = []; for (const a of inputs) { const llm = await this.llms.getByName(a.llmName); + // v6: auto-create the project if it doesn't exist. Publishers + // commonly want their virtual agents pinned to a team-scoped + // project; pre-v6 they had to ask an admin to create the + // project first. ensureByName is idempotent — the second + // publisher landing on the same name reuses the existing row. + // Owner is the publishing user (first writer wins). const projectId = a.project !== undefined - ? (await this.projects.resolveAndGet(a.project)).id + ? (await this.projects.ensureByName( + a.project, + ownerId, + { auditNote: `Auto-created by virtual-agent registrar for '${a.name}'` }, + )).id : null; const existing = await this.repo.findByName(a.name); if (existing !== null) { diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index 38f49be..712d4f3 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -35,6 +35,41 @@ export class ProjectService { throw new NotFoundError(`Project not found: ${idOrName}`); } + /** + * v6: idempotent get-or-create by name. Used by virtual-agent + * registration so a publisher can declare `project: "my-team"` in + * their mcplocal config and have mcpd auto-create the project on + * first publish, with sensible defaults (description carries an + * audit trail of who created it; proxyModel + gated take their + * schema defaults). Subsequent registers from the same or other + * publishers reuse the existing project — only the publishing user + * who wins the create race owns the row. + * + * Caller passes `ownerId` so the project is owned by whoever first + * triggered the auto-create. Returns the resolved row whether it + * was found or just created. + */ + async ensureByName(name: string, ownerId: string, opts: { auditNote?: string } = {}): Promise { + const byName = await this.projectRepo.findByName(name); + if (byName !== null) return byName; + // Validate the name client-side to surface a clean error before + // we touch the DB. Same regex CreateProjectSchema enforces. + if (!/^[a-z0-9-]+$/.test(name) || name.length > 100) { + throw new Error( + `Cannot auto-create project '${name}' — name must be lowercase alphanumeric with hyphens (max 100 chars)`, + ); + } + const description = opts.auditNote ?? 'Auto-created by mcplocal virtual-agent registrar'; + return this.projectRepo.create({ + name, + description, + prompt: '', + ownerId, + proxyModel: '', + gated: true, + }); + } + async create(input: unknown, ownerId: string): Promise { const data = CreateProjectSchema.parse(input); diff --git a/src/mcpd/tests/virtual-agent-service.test.ts b/src/mcpd/tests/virtual-agent-service.test.ts index d5c8989..282f3e0 100644 --- a/src/mcpd/tests/virtual-agent-service.test.ts +++ b/src/mcpd/tests/virtual-agent-service.test.ts @@ -140,14 +140,40 @@ function mockLlms(): LlmService { } as unknown as LlmService; } -function mockProjects(): ProjectService { - return { - getById: vi.fn(async (id: string) => ({ id, name: 'mcpctl-dev' })), - resolveAndGet: vi.fn(async (idOrName: string) => ({ - id: idOrName === 'mcpctl-dev' ? 'proj-1' : 'proj-other', - name: idOrName, - })), - } as unknown as ProjectService; +function mockProjects(opts: { existingNames?: string[] } = {}): ProjectService & { _created: string[] } { + const existing = new Set(opts.existingNames ?? ['mcpctl-dev']); + const created: string[] = []; + // Reverse-lookup id → name so toView's getById finds the project we + // ensured/created (instead of always returning a hardcoded name). + const idToName = new Map(); + const remember = (name: string): { id: string; name: string } => { + const id = `proj-${name}`; + idToName.set(id, name); + return { id, name }; + }; + // Pre-seed the lookup so existing projects round-trip correctly. + for (const n of existing) remember(n); + const svc = { + _created: created, + getById: vi.fn(async (id: string) => ({ id, name: idToName.get(id) ?? 'mcpctl-dev' })), + resolveAndGet: vi.fn(async (idOrName: string) => { + if (!existing.has(idOrName)) { + const err = new Error(`Project not found: ${idOrName}`); + err.name = 'NotFoundError'; + throw err; + } + return remember(idOrName); + }), + // v6: ensureByName auto-creates when missing. + ensureByName: vi.fn(async (name: string, ownerId: string, _opts?: { auditNote?: string }) => { + if (existing.has(name)) return remember(name); + created.push(name); + existing.add(name); + const row = remember(name); + return { ...row, ownerId }; + }), + }; + return svc as unknown as ProjectService & { _created: string[] }; } describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => { @@ -192,6 +218,37 @@ describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => { expect(out[0]!.status).toBe('active'); }); + it('registerVirtualAgents auto-creates a missing project (v6 Stage 2)', async () => { + // Pre-v6 the publisher had to ask an admin to create the project + // first; otherwise registerVirtualAgents 404'd. v6: ensureByName + // creates with sensible defaults and the publishing user as owner. + const repo = mockAgentRepo(); + const projects = mockProjects({ existingNames: [] }); + const svc = new AgentService(repo, mockLlms(), projects); + const out = await svc.registerVirtualAgents( + 'sess-1', + [{ name: 'team-coder', llmName: 'vllm-local', project: 'platform' }], + 'owner-platform', + ); + expect(out).toHaveLength(1); + expect(out[0]!.project?.name).toBe('platform'); + expect(projects._created).toEqual(['platform']); + }); + + it('registerVirtualAgents reuses an existing project without re-creating it (v6 Stage 2)', async () => { + // ensureByName must be idempotent — second publisher landing on + // the same project name doesn't try to re-create (would 409). + const repo = mockAgentRepo(); + const projects = mockProjects({ existingNames: ['platform'] }); + const svc = new AgentService(repo, mockLlms(), projects); + await svc.registerVirtualAgents( + 'sess-2', + [{ name: 'team-reviewer', llmName: 'vllm-local', project: 'platform' }], + 'owner-other', + ); + expect(projects._created).toEqual([]); + }); + it('registerVirtualAgents refuses to overwrite a public agent (409)', async () => { const repo = mockAgentRepo([makeAgent({ name: 'reviewer', kind: 'public', providerSessionId: null })]); const svc = new AgentService(repo, mockLlms(), mockProjects());