feat(mcpd): auto-create project on virtual-agent register (v6 Stage 2)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / lint (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m41s
CI/CD / publish (pull_request) Has been skipped

Closes the v3-deferred "project must already exist" gap. When a
virtual agent declares `project: "my-team"` and no such project
exists, mcpd creates it idempotently with the publishing user as
owner (instead of throwing 404 from registerVirtualAgents).

ProjectService gains `ensureByName(name, ownerId, opts)` — find
the project or create it with sensible defaults (description carries
an audit note pointing at the registrar; proxyModel/gated take
their schema defaults). First publisher to land on a name owns the
row; subsequent publishers reuse the existing one.

AgentService.registerVirtualAgents calls ensureByName instead of
resolveAndGet, so the same agent register payload works regardless
of whether the project pre-existed or not.

Tests: 2 new tests (auto-creates a missing project on first publish;
reuses an existing project without re-creating). Mock projects
factory rebuilt to track _created names + maintain id→name reverse
lookup so the agent's toView returns the correct project name
(prior mock hardcoded 'mcpctl-dev').

Existing 13 virtual-agent tests + 870 mcpd suite green.
This commit is contained in:
Michal
2026-04-28 15:54:27 +01:00
parent c346b93789
commit ee18c5107e
3 changed files with 111 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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<ProjectWithRelations> {
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<ProjectWithRelations> {
const data = CreateProjectSchema.parse(input);

View File

@@ -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<string, string>();
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());