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
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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user